refactor: tighten media upsert metadata handling

This commit is contained in:
ShukeBta
2026-06-25 02:33:12 +08:00
parent 3d3679c21c
commit 02fe4fd374
2 changed files with 212 additions and 105 deletions
+144 -105
View File
@@ -75,6 +75,20 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
}
func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
existing, created, err := r.findOrCreateMediaByPath(ctx, m)
if err != nil {
return err
}
if created {
r.indexMediaBestEffort(ctx, *m)
return nil
}
updates := mediaUpsertUpdates(existing, *m)
return r.applyMediaUpsertUpdates(ctx, m, existing, updates)
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, m *model.Media) (model.Media, bool, error) {
var existing model.Media
err := r.db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -83,144 +97,169 @@ func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
m.ScrapeStatus = "pending"
}
if createErr := r.db.WithContext(ctx).Create(m).Error; createErr == nil {
r.indexMediaBestEffort(ctx, *m)
return nil
return *m, true, nil
} else if retryErr := r.db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
return createErr
return model.Media{}, false, createErr
}
}
if err != nil {
return err
return model.Media{}, false, err
}
return existing, false, nil
}
// 已存在:仅刷新文件层面的字段。
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
updates := map[string]any{}
setIfChanged(updates, "size_bytes", existing.SizeBytes, m.SizeBytes)
setIfChanged(updates, "duration_sec", existing.DurationSec, m.DurationSec)
setIfChanged(updates, "width", existing.Width, m.Width)
setIfChanged(updates, "height", existing.Height, m.Height)
setIfChanged(updates, "video_codec", existing.VideoCodec, m.VideoCodec)
setIfChanged(updates, "audio_codec", existing.AudioCodec, m.AudioCodec)
setIfChanged(updates, "container", existing.Container, m.Container)
addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming)
addMatchedMediaMetadataUpdates(updates, existing, incoming)
addMediaArtworkUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming)
addMediaSTRMUpdate(updates, existing, incoming)
return updates
}
func addMediaFileScanUpdates(updates map[string]any, existing, incoming model.Media) {
// 已存在:仅刷新文件层面的字段。
setIfChanged(updates, "size_bytes", existing.SizeBytes, incoming.SizeBytes)
setIfChanged(updates, "duration_sec", existing.DurationSec, incoming.DurationSec)
setIfChanged(updates, "width", existing.Width, incoming.Width)
setIfChanged(updates, "height", existing.Height, incoming.Height)
setIfChanged(updates, "video_codec", existing.VideoCodec, incoming.VideoCodec)
setIfChanged(updates, "audio_codec", existing.AudioCodec, incoming.AudioCodec)
setIfChanged(updates, "container", existing.Container, incoming.Container)
if existing.DeletedAt.Valid {
updates["deleted_at"] = nil
}
// 回填硬链接身份标识,便于后续扫描去重(避免重复识别/多倍占用)。
if m.FileID != "" && m.FileID != existing.FileID {
updates["file_id"] = m.FileID
if incoming.FileID != "" && incoming.FileID != existing.FileID {
updates["file_id"] = incoming.FileID
}
if m.Title != "" {
}
func addMediaTitleUpdates(updates map[string]any, existing, incoming model.Media) {
if incoming.Title != "" {
// scanner 给出的标题只是从路径推导,刮削后 title 已被替换为
// 真实剧名。仅在 existing 还停留在 'pending'/'' 时回填扫描标题,
// 避免覆盖刮削结果。
if m.ScrapeStatus == "matched" || existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" {
setIfChanged(updates, "title", existing.Title, m.Title)
if m.Year > 0 {
setIfChanged(updates, "year", existing.Year, m.Year)
if incoming.ScrapeStatus == "matched" || existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" {
setIfChanged(updates, "title", existing.Title, incoming.Title)
if incoming.Year > 0 {
setIfChanged(updates, "year", existing.Year, incoming.Year)
}
}
}
}
func addMediaExternalIDUpdates(updates map[string]any, existing, incoming model.Media) {
status := strings.TrimSpace(existing.ScrapeStatus)
canRefreshExternalIDs := status == "pending" || status == "" || status == "no_match" ||
m.ScrapeStatus == "matched" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://")
if canRefreshExternalIDs {
changedExternalID := false
if m.TMDbID > 0 && existing.TMDbID != m.TMDbID {
updates["tm_db_id"] = m.TMDbID
changedExternalID = true
}
if m.BangumiID > 0 && existing.BangumiID != m.BangumiID {
updates["bangumi_id"] = m.BangumiID
changedExternalID = true
}
if m.DoubanID != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(m.DoubanID) {
updates["douban_id"] = m.DoubanID
changedExternalID = true
}
if m.TheTVDBID != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(m.TheTVDBID) {
updates["thetvdb_id"] = m.TheTVDBID
changedExternalID = true
}
if m.Year > 0 && existing.Year <= 0 {
updates["year"] = m.Year
}
if changedExternalID && (status == "no_match" || status == "matched") && m.ScrapeStatus != "matched" {
updates["scrape_status"] = "pending"
}
if !mediaCanRefreshExternalIDs(status, incoming) {
return
}
if m.ScrapeStatus == "matched" {
setIfChanged(updates, "scrape_status", existing.ScrapeStatus, m.ScrapeStatus)
if m.OriginalName != "" {
setIfChanged(updates, "original_name", existing.OriginalName, m.OriginalName)
}
if m.EpisodeTitle != "" {
setIfChanged(updates, "episode_title", existing.EpisodeTitle, m.EpisodeTitle)
}
if m.PosterURL != "" {
setIfChanged(updates, "poster_url", existing.PosterURL, m.PosterURL)
}
if m.BackdropURL != "" {
setIfChanged(updates, "backdrop_url", existing.BackdropURL, m.BackdropURL)
}
if m.Overview != "" {
setIfChanged(updates, "overview", existing.Overview, m.Overview)
}
if m.Rating > 0 {
setIfChanged(updates, "rating", existing.Rating, m.Rating)
}
if m.Year > 0 {
setIfChanged(updates, "year", existing.Year, m.Year)
}
if m.TMDbID > 0 {
setIfChanged(updates, "tm_db_id", existing.TMDbID, m.TMDbID)
}
if m.BangumiID > 0 {
setIfChanged(updates, "bangumi_id", existing.BangumiID, m.BangumiID)
}
if m.DoubanID != "" {
setIfChanged(updates, "douban_id", existing.DoubanID, m.DoubanID)
}
if m.TheTVDBID != "" {
setIfChanged(updates, "thetvdb_id", existing.TheTVDBID, m.TheTVDBID)
}
if m.Languages != "" {
setIfChanged(updates, "languages", existing.Languages, m.Languages)
}
if m.Countries != "" {
setIfChanged(updates, "countries", existing.Countries, m.Countries)
}
if m.Genres != "" {
setIfChanged(updates, "genres", existing.Genres, m.Genres)
}
if m.NSFW && !existing.NSFW {
updates["nsfw"] = true
}
changedExternalID := addIncomingMediaProviderIDs(updates, existing, incoming)
if incoming.Year > 0 && existing.Year <= 0 {
updates["year"] = incoming.Year
}
if m.PosterURL != "" {
setIfChanged(updates, "poster_url", existing.PosterURL, m.PosterURL)
if changedExternalID && (status == "no_match" || status == "matched") && incoming.ScrapeStatus != "matched" {
updates["scrape_status"] = "pending"
}
if m.BackdropURL != "" {
setIfChanged(updates, "backdrop_url", existing.BackdropURL, m.BackdropURL)
}
func mediaCanRefreshExternalIDs(existingStatus string, incoming model.Media) bool {
return existingStatus == "pending" || existingStatus == "" || existingStatus == "no_match" ||
incoming.ScrapeStatus == "matched" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://")
}
func addMatchedMediaMetadataUpdates(updates map[string]any, existing, incoming model.Media) {
if incoming.ScrapeStatus == "matched" {
setIfChanged(updates, "scrape_status", existing.ScrapeStatus, incoming.ScrapeStatus)
addMatchedMediaDetailUpdates(updates, existing, incoming)
addIncomingMediaProviderIDs(updates, existing, incoming)
}
}
func addMatchedMediaDetailUpdates(updates map[string]any, existing, incoming model.Media) {
setNonEmptyMediaString(updates, "original_name", existing.OriginalName, incoming.OriginalName)
setNonEmptyMediaString(updates, "episode_title", existing.EpisodeTitle, incoming.EpisodeTitle)
setNonEmptyMediaString(updates, "poster_url", existing.PosterURL, incoming.PosterURL)
setNonEmptyMediaString(updates, "backdrop_url", existing.BackdropURL, incoming.BackdropURL)
setNonEmptyMediaString(updates, "overview", existing.Overview, incoming.Overview)
setNonEmptyMediaString(updates, "languages", existing.Languages, incoming.Languages)
setNonEmptyMediaString(updates, "countries", existing.Countries, incoming.Countries)
setNonEmptyMediaString(updates, "genres", existing.Genres, incoming.Genres)
if incoming.Rating > 0 {
setIfChanged(updates, "rating", existing.Rating, incoming.Rating)
}
if incoming.Year > 0 {
setIfChanged(updates, "year", existing.Year, incoming.Year)
}
if incoming.NSFW && !existing.NSFW {
updates["nsfw"] = true
}
}
func addMediaArtworkUpdates(updates map[string]any, existing, incoming model.Media) {
if incoming.PosterURL != "" {
setIfChanged(updates, "poster_url", existing.PosterURL, incoming.PosterURL)
}
if incoming.BackdropURL != "" {
setIfChanged(updates, "backdrop_url", existing.BackdropURL, incoming.BackdropURL)
}
}
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media) {
// 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级
// 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库,
// 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。
// 本地媒体物理位置固定:仅在原 library_id 为空时回填,不迁移。
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://"); m.LibraryID != "" && m.LibraryID != existing.LibraryID {
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://"); incoming.LibraryID != "" && incoming.LibraryID != existing.LibraryID {
if isCloudMediaPath || existing.LibraryID == "" {
updates["library_id"] = m.LibraryID
updates["library_id"] = incoming.LibraryID
}
}
if (m.SeasonNum > 0 || m.EpisodeNum > 0) && existing.SeasonNum != m.SeasonNum {
updates["season_num"] = m.SeasonNum
if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum {
updates["season_num"] = incoming.SeasonNum
}
if m.EpisodeNum > 0 && existing.EpisodeNum != m.EpisodeNum {
updates["episode_num"] = m.EpisodeNum
}
if m.STRMURL != "" {
setIfChanged(updates, "strm_url", existing.STRMURL, m.STRMURL)
if incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum {
updates["episode_num"] = incoming.EpisodeNum
}
}
func addMediaSTRMUpdate(updates map[string]any, existing, incoming model.Media) {
if incoming.STRMURL != "" {
setIfChanged(updates, "strm_url", existing.STRMURL, incoming.STRMURL)
}
}
func addIncomingMediaProviderIDs(updates map[string]any, existing, incoming model.Media) bool {
changed := false
if incoming.TMDbID > 0 && existing.TMDbID != incoming.TMDbID {
updates["tm_db_id"] = incoming.TMDbID
changed = true
}
if incoming.BangumiID > 0 && existing.BangumiID != incoming.BangumiID {
updates["bangumi_id"] = incoming.BangumiID
changed = true
}
if incoming.DoubanID != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(incoming.DoubanID) {
updates["douban_id"] = incoming.DoubanID
changed = true
}
if incoming.TheTVDBID != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(incoming.TheTVDBID) {
updates["thetvdb_id"] = incoming.TheTVDBID
changed = true
}
return changed
}
func setNonEmptyMediaString(updates map[string]any, key, current, next string) {
if next != "" {
setIfChanged(updates, key, current, next)
}
}
func (r *MediaRepository) applyMediaUpsertUpdates(ctx context.Context, m *model.Media, existing model.Media, updates map[string]any) error {
if len(updates) == 0 {
*m = existing
return nil
+68
View File
@@ -118,6 +118,74 @@ func TestMediaUpsertRefreshesCloudExternalIDFromPathHint(t *testing.T) {
}
}
func TestMediaUpsertMatchedIncomingRefreshesScrapedMetadata(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
path := "/media/tv/show/S01E01.mkv"
existing := model.Media{
LibraryID: lib.ID,
Title: "扫描标题",
Path: path,
ScrapeStatus: "no_match",
PosterURL: "/old-poster.jpg",
}
if err := repos.Media.Upsert(t.Context(), &existing); err != nil {
t.Fatal(err)
}
incoming := model.Media{
LibraryID: lib.ID,
Title: "中文剧名",
OriginalName: "Original Show",
EpisodeTitle: "第一集",
Path: path,
PosterURL: "/poster.jpg",
BackdropURL: "/backdrop.jpg",
Overview: "剧情简介",
Rating: 8.6,
Year: 2026,
SeasonNum: 1,
EpisodeNum: 1,
ScrapeStatus: "matched",
TMDbID: 123,
BangumiID: 456,
DoubanID: "db-1",
TheTVDBID: "tvdb-1",
Languages: "zh,en",
Countries: "CN",
Genres: "剧情,悬疑",
NSFW: true,
}
if err := repos.Media.Upsert(t.Context(), &incoming); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
t.Fatal(err)
}
if got.Title != "中文剧名" || got.OriginalName != "Original Show" || got.EpisodeTitle != "第一集" {
t.Fatalf("matched names not refreshed: %#v", got)
}
if got.PosterURL != "/poster.jpg" || got.BackdropURL != "/backdrop.jpg" || got.Overview != "剧情简介" {
t.Fatalf("matched artwork/overview not refreshed: %#v", got)
}
if got.ScrapeStatus != "matched" || got.TMDbID != 123 || got.BangumiID != 456 || got.DoubanID != "db-1" || got.TheTVDBID != "tvdb-1" {
t.Fatalf("matched provider metadata not refreshed: %#v", got)
}
if got.Year != 2026 || got.SeasonNum != 1 || got.EpisodeNum != 1 || got.Rating != 8.6 || got.Languages != "zh,en" || got.Countries != "CN" || got.Genres != "剧情,悬疑" || !got.NSFW {
t.Fatalf("matched detail metadata not refreshed: %#v", got)
}
}
// TestMediaUpsertMigratesCloudLibraryIDOnRescan 复现"一键挂载子目录后媒体消失"的
// 回归:同一 cloud:// 文件先被父目录库扫描入库,之后用户按二级分类重新挂载到更
// 精确的分类库并扫描,library_id 必须迁移到新分类库,否则媒体被钉死在旧库、新库