From b610dda0987fe4c0fec26b44444e3d549595fa2c Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:55:33 +0800 Subject: [PATCH] fix: align scraped media naming and release sorting --- internal/database/schema_migration.go | 1 + internal/model/library_media.go | 1 + internal/repository/media_repository.go | 11 ++- .../repository/media_repository_upsert.go | 9 ++ internal/repository/repository_test.go | 36 +++++++ internal/service/cloud_metadata_json.go | 3 +- internal/service/cloud_metadata_merge.go | 6 ++ internal/service/emby_compat_test.go | 44 +++++++++ internal/service/emby_items_detail.go | 12 ++- internal/service/emby_items_list.go | 14 ++- internal/service/emby_movie_items.go | 29 ++++-- internal/service/emby_series.go | 24 +++-- internal/service/emby_series_ids.go | 23 ++++- internal/service/emby_series_payload.go | 6 +- internal/service/external_search.go | 2 + internal/service/local_metadata_apply.go | 4 + internal/service/local_metadata_convert.go | 3 +- internal/service/local_metadata_helpers.go | 13 +++ internal/service/local_metadata_types.go | 1 + internal/service/manual_scrape.go | 1 + internal/service/manual_scrape_helpers.go | 3 + internal/service/manual_scrape_search.go | 1 + internal/service/media_metadata.go | 4 + internal/service/media_metadata_test.go | 17 ++-- internal/service/media_release_sort.go | 42 +++++++++ internal/service/media_series.go | 8 ++ internal/service/organize_pipeline.go | 6 +- internal/service/organize_pipeline_sync.go | 94 +++++++++++++++++++ internal/service/organizer.go | 32 +++++++ internal/service/organizer_media.go | 18 ++++ internal/service/organizer_media_metadata.go | 1 + internal/service/organizer_metadata_match.go | 2 + .../organizer_reclassify_media_rows.go | 3 + .../service/organizer_reclassify_metadata.go | 3 + internal/service/organizer_scrape_test.go | 56 +++++++++++ internal/service/scanner.go | 2 + internal/service/scanner_cloud_enrich.go | 3 + internal/service/scanner_existing_media.go | 6 +- internal/service/scanner_metadata_refresh.go | 12 +++ internal/service/scraper.go | 3 + internal/service/scraper_local_metadata.go | 9 ++ internal/service/tmdb_match.go | 2 + internal/service/tmdb_search.go | 2 + internal/service/tmdb_types.go | 1 + web/src/api/library.ts | 2 + web/src/components/MetadataEditDialog.tsx | 7 ++ web/src/pages/librariesPageModel.ts | 3 + web/src/types/media.ts | 1 + 48 files changed, 544 insertions(+), 42 deletions(-) create mode 100644 internal/service/media_release_sort.go create mode 100644 internal/service/organize_pipeline_sync.go diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index c9f5fe5..a74d340 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -54,6 +54,7 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error { func ensurePerformanceIndexes(db *gorm.DB) error { statements := []string{ `CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_media_library_release_active ON media(library_id, release_date DESC, year DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`, diff --git a/internal/model/library_media.go b/internal/model/library_media.go index 256709a..d373e23 100644 --- a/internal/model/library_media.go +++ b/internal/model/library_media.go @@ -43,6 +43,7 @@ type Media struct { Overview string `gorm:"type:text" json:"overview,omitempty"` Rating float32 `json:"rating"` Year int `json:"year"` + ReleaseDate string `gorm:"size:10;index" json:"release_date,omitempty"` SeasonNum int `json:"season_num"` EpisodeNum int `json:"episode_num"` ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"` diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index b423161..44521ab 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -103,12 +103,13 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID return nil, 0, err } // 多级排序消除"随机"观感: - // 1. year desc — 上映年份新→旧(用户期望的上映时间维度) - // 2. updated_at desc — 同年按最近更新(刮削/补集会刷新) - // 3. created_at desc — 再按入库时间 - // 4. id desc — 稳定 tie-breaker:云盘批量扫描同批 created_at 相同时, + // 1. release_date desc — 精确上映/首播日期新→旧 + // 2. year desc — 老数据没有完整日期时仍按年份新→旧 + // 3. updated_at desc — 同日期/同年按最近更新兜底 + // 4. created_at desc — 再按入库时间 + // 5. id desc — 稳定 tie-breaker:云盘批量扫描同批 created_at 相同时, // 没有它 DB 返回顺序不确定,正是"随机排序"的根因。 - err := q.Order("year DESC, updated_at DESC, created_at DESC, id DESC"). + err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC"). Offset(offset).Limit(limit).Find(&items).Error return items, total, err } diff --git a/internal/repository/media_repository_upsert.go b/internal/repository/media_repository_upsert.go index f17ed08..1bfeb59 100644 --- a/internal/repository/media_repository_upsert.go +++ b/internal/repository/media_repository_upsert.go @@ -103,6 +103,9 @@ func addMediaTitleUpdates(updates map[string]any, existing, incoming model.Media if incoming.Year > 0 { setIfChanged(updates, "year", existing.Year, incoming.Year) } + if incoming.ReleaseDate != "" { + setIfChanged(updates, "release_date", existing.ReleaseDate, incoming.ReleaseDate) + } if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (titleChanged || yearChanged) { updates["scrape_status"] = "pending" } @@ -119,6 +122,9 @@ func addMediaExternalIDUpdates(updates map[string]any, existing, incoming model. if incoming.Year > 0 && existing.Year <= 0 { updates["year"] = incoming.Year } + if incoming.ReleaseDate != "" && existing.ReleaseDate == "" { + updates["release_date"] = incoming.ReleaseDate + } if changedExternalID && (status == "no_match" || status == "matched") && incoming.ScrapeStatus != "matched" { updates["scrape_status"] = "pending" } @@ -152,6 +158,9 @@ func addMatchedMediaDetailUpdates(updates map[string]any, existing, incoming mod if incoming.Year > 0 { setIfChanged(updates, "year", existing.Year, incoming.Year) } + if incoming.ReleaseDate != "" { + setIfChanged(updates, "release_date", existing.ReleaseDate, incoming.ReleaseDate) + } if incoming.NSFW && !existing.NSFW { updates["nsfw"] = true } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 3693345..2ad464a 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -186,6 +186,42 @@ func TestMediaUpsertMatchedIncomingRefreshesScrapedMetadata(t *testing.T) { } } +func TestListByLibraryOrdersByReleaseDate(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) + } + rows := []model.Media{ + {LibraryID: lib.ID, Title: "旧片但最近更新", Path: "/media/tv/old.mkv", Year: 2026, ReleaseDate: "2026-01-10", ScrapeStatus: "matched"}, + {LibraryID: lib.ID, Title: "最新首播", Path: "/media/tv/newest.mkv", Year: 2026, ReleaseDate: "2026-06-23", ScrapeStatus: "matched"}, + {LibraryID: lib.ID, Title: "无完整日期", Path: "/media/tv/year-only.mkv", Year: 2025, ScrapeStatus: "matched"}, + } + for i := range rows { + if err := repos.Media.Upsert(t.Context(), &rows[i]); err != nil { + t.Fatal(err) + } + } + + items, total, err := repos.Media.ListByLibrary(t.Context(), lib.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + if total != 3 || len(items) != 3 { + t.Fatalf("list total=%d len=%d", total, len(items)) + } + if got := []string{items[0].Title, items[1].Title, items[2].Title}; got[0] != "最新首播" || got[1] != "旧片但最近更新" || got[2] != "无完整日期" { + t.Fatalf("release-date order = %#v", got) + } +} + func TestMediaUpsertScanDoesNotClearMatchedMetadata(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/service/cloud_metadata_json.go b/internal/service/cloud_metadata_json.go index 2c62dd3..26907a0 100644 --- a/internal/service/cloud_metadata_json.go +++ b/internal/service/cloud_metadata_json.go @@ -25,6 +25,7 @@ func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) { OriginalName: firstJSONString(obj, "original_title", "originaltitle", "original_name", "originalname", "sorttitle"), EpisodeTitle: firstJSONString(obj, "episode_title", "episodetitle", "episode_name", "episodename"), Year: firstJSONInt(obj, "year"), + ReleaseDate: normalizeReleaseDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date")), Overview: firstJSONString(obj, "overview", "plot", "outline", "summary", "description"), Rating: firstJSONFloat(obj, "rating", "vote_average", "score"), TMDbID: firstJSONInt(obj, "tmdb_id", "tmdbid", "tmdb"), @@ -44,7 +45,7 @@ func metadataFromCloudJSON(body []byte) (*LocalMetadata, cloudJSONArtwork) { meta.Title = showTitle } if meta.Year == 0 { - meta.Year = yearFromDate(firstJSONString(obj, "release_date", "releasedate", "premiered", "aired", "date")) + meta.Year = yearFromDate(meta.ReleaseDate) } artwork := cloudJSONArtwork{ posterValues: firstJSONStrings(obj, diff --git a/internal/service/cloud_metadata_merge.go b/internal/service/cloud_metadata_merge.go index 8300dbb..e3b06b9 100644 --- a/internal/service/cloud_metadata_merge.go +++ b/internal/service/cloud_metadata_merge.go @@ -24,6 +24,9 @@ func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata { if src.Year > 0 { dst.Year = src.Year } + if src.ReleaseDate != "" { + dst.ReleaseDate = src.ReleaseDate + } if src.Overview != "" { dst.Overview = src.Overview } @@ -86,6 +89,9 @@ func mergeCloudPathHintMetadata(dst, hint *LocalMetadata) *LocalMetadata { if dst.Year == 0 { dst.Year = hint.Year } + if dst.ReleaseDate == "" { + dst.ReleaseDate = hint.ReleaseDate + } if dst.TMDbID == 0 { dst.TMDbID = hint.TMDbID } diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 668d627..2d321be 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -3,6 +3,7 @@ package service import ( "context" "testing" + "time" "go.uber.org/zap" @@ -24,6 +25,49 @@ func newTestEmbyService(t *testing.T) *EmbyService { return NewEmbyService(&config.Config{}, zap.NewNop(), repos) } +func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + base := time.Now() + rows := []model.Media{ + { + Base: model.Base{ID: "older-release-newer-scan", CreatedAt: base.Add(2 * time.Hour)}, + LibraryID: lib.ID, + Title: "旧上映新入库", + Path: `/media/movies/old.mkv`, + Year: 2026, + ReleaseDate: "2026-01-10", + }, + { + Base: model.Base{ID: "newer-release-older-scan", CreatedAt: base}, + LibraryID: lib.ID, + Title: "新上映", + Path: `/media/movies/new.mkv`, + Year: 2026, + ReleaseDate: "2026-06-23", + }, + } + for i := range rows { + if err := svc.repo.DB.Create(&rows[i]).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + items, err := svc.LatestItems(t.Context(), "", lib.ID, 10) + if err != nil { + t.Fatalf("latest items: %v", err) + } + if len(items) != 2 || items[0]["Id"] != "newer-release-older-scan" { + t.Fatalf("latest items should prefer release date over created_at, got %#v", items) + } + if _, ok := items[0]["PremiereDate"].(time.Time); !ok { + t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0]) + } +} + type fakeCloudPlaybackResolver struct { link *cloud.DirectLink typ string diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index f87ca1c..ff4e255 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -101,7 +101,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, rowLimit = 500 } var rows []model.Media - if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil { + if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil { return nil, err } rows = e.collapseMediaVersionRows(ctx, rows) @@ -126,11 +126,11 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID)) q = e.applyUserMediaVisibility(ctx, q, userID) var rows []model.Media - if err := q.Order("media.created_at desc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { + if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return nil, err } groups := e.seriesGroupsFromMedia(rows) - sortSeriesGroups(groups, ItemsParams{SortBy: "datecreated", SortOrder: "Descending"}) + sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"}) if len(groups) > limit { groups = groups[:limit] } @@ -219,7 +219,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, pct = float64(posMs) / float64(durationMs) * 100 } - return map[string]any{ + item := map[string]any{ "Id": m.ID, "Name": name, "OriginalTitle": m.OriginalName, @@ -259,4 +259,8 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, }, "MediaSources": e.mediaSourcesForItem(ctx, m, true, false), } + if premiered, ok := embyPremiereDate(m.ReleaseDate); ok { + item["PremiereDate"] = premiered + } + return item } diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index bff6f29..3ec405a 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -66,20 +66,26 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string if err := q.Count(&total).Error; err != nil { return nil, err } - order := "media.created_at desc" + desc := !strings.EqualFold(firstCSVValue(p.SortOrder), "Ascending") + order := mediaReleaseOrderSQL(true) + orderIncludesDirection := true switch primarySupportedEmbySort(p.SortBy, resumeFilter) { case "sortname", "name": order = "media.title" + orderIncludesDirection = false case "premieredate", "productionyear": - order = "media.year" + order = mediaReleaseOrderSQL(desc) case "datecreated": order = "media.created_at" + orderIncludesDirection = false case "dateplayed": order = "resume.watched_at" + orderIncludesDirection = false case "communityrating": order = "media.rating" + orderIncludesDirection = false } - if strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") { + if !orderIncludesDirection && strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") { if !strings.HasSuffix(order, " desc") { order = order + " desc" } @@ -231,7 +237,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID) } var rows []model.Media - if err := q.Order("media.created_at desc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { + if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return nil, err } groups := e.seriesGroupsFromMedia(rows) diff --git a/internal/service/emby_movie_items.go b/internal/service/emby_movie_items.go index 1021cc3..67ab069 100644 --- a/internal/service/emby_movie_items.go +++ b/internal/service/emby_movie_items.go @@ -60,7 +60,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil } epQ = epQ.Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...). - Order("media.created_at desc").Limit(embySeriesGroupingLimit) + Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit) if err := epQ.Find(&episodicRows).Error; err != nil { return nil, err } @@ -73,7 +73,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil } movieQ = filterLikelyEpisodicPathsFromMovieQuery(movieQ). - Order("media.created_at desc").Limit(embySeriesGroupingLimit) + Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit) var movieRows []model.Media if err := movieQ.Find(&movieRows).Error; err != nil { return nil, err @@ -83,20 +83,20 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map return nil, err } - // 合并: Series 卡片 + Movie 项, 统一按 DateCreated 倒序。 + // 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。 type entry struct { - createdAt time.Time - payload map[string]any + sortAt time.Time + payload map[string]any } entries := make([]entry, 0, len(seriesGroups)+len(movieItems)) for _, g := range seriesGroups { - entries = append(entries, entry{createdAt: g.CreatedAt, payload: e.seriesPayload(g)}) + entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)}) } for _, item := range movieItems { - entries = append(entries, entry{createdAt: embyPayloadCreatedAt(item), payload: item}) + entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item}) } sort.SliceStable(entries, func(i, j int) bool { - return entries[i].createdAt.After(entries[j].createdAt) + return entries[i].sortAt.After(entries[j].sortAt) }) total := len(entries) paged := pageSlice(entries, p.StartIndex, p.Limit) @@ -118,6 +118,19 @@ func embyPayloadCreatedAt(item map[string]any) time.Time { return time.Time{} } +func embyPayloadReleaseSortTime(item map[string]any) time.Time { + if item == nil { + return time.Time{} + } + if v, ok := item["PremiereDate"].(time.Time); ok { + return v + } + if year, ok := item["ProductionYear"].(int); ok && year > 0 { + return time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC) + } + return embyPayloadCreatedAt(item) +} + func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (bool, error) { if strings.TrimSpace(libraryID) == "" { return false, nil diff --git a/internal/service/emby_series.go b/internal/service/emby_series.go index fdaba2b..7c319d9 100644 --- a/internal/service/emby_series.go +++ b/internal/service/emby_series.go @@ -18,6 +18,7 @@ type embySeriesGroup struct { Overview string Rating float32 Year int + ReleaseDate string TMDbID int BangumiID int CreatedAt time.Time @@ -117,13 +118,14 @@ func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGrou group, ok := byID[seriesID] if !ok { group = &embySeriesGroup{ - ID: seriesID, - LibraryID: row.LibraryID, - Name: e.seriesNameForMedia(&row), - Year: row.Year, - TMDbID: row.TMDbID, - BangumiID: row.BangumiID, - CreatedAt: row.CreatedAt, + ID: seriesID, + LibraryID: row.LibraryID, + Name: e.seriesNameForMedia(&row), + Year: row.Year, + ReleaseDate: row.ReleaseDate, + TMDbID: row.TMDbID, + BangumiID: row.BangumiID, + CreatedAt: row.CreatedAt, } byID[seriesID] = group order = append(order, seriesID) @@ -131,6 +133,14 @@ func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGrou if row.CreatedAt.After(group.CreatedAt) { group.CreatedAt = row.CreatedAt } + if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) { + group.ReleaseDate = row.ReleaseDate + if row.Year > 0 { + group.Year = row.Year + } + } else if group.ReleaseDate == "" && group.Year == 0 && row.Year > 0 { + group.Year = row.Year + } if group.PosterURL == "" && row.PosterURL != "" { group.PosterURL = row.PosterURL } diff --git a/internal/service/emby_series_ids.go b/internal/service/emby_series_ids.go index 67e42c1..44df15a 100644 --- a/internal/service/emby_series_ids.go +++ b/internal/service/emby_series_ids.go @@ -9,6 +9,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -87,7 +88,7 @@ func seasonName(seasonNum int) string { } func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) { - switch strings.ToLower(p.SortBy) { + switch primarySupportedEmbySort(p.SortBy, false) { case "sortname", "name": sort.SliceStable(groups, func(i, j int) bool { if strings.EqualFold(p.SortOrder, "Descending") { @@ -95,12 +96,30 @@ func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) { } return groups[i].Name < groups[j].Name }) - default: + case "datecreated": sort.SliceStable(groups, func(i, j int) bool { if strings.EqualFold(p.SortOrder, "Ascending") { return groups[i].CreatedAt.Before(groups[j].CreatedAt) } return groups[i].CreatedAt.After(groups[j].CreatedAt) }) + default: + sort.SliceStable(groups, func(i, j int) bool { + if strings.EqualFold(p.SortOrder, "Ascending") { + return embySeriesReleaseSortTime(groups[i]).Before(embySeriesReleaseSortTime(groups[j])) + } + return embySeriesReleaseSortTime(groups[i]).After(embySeriesReleaseSortTime(groups[j])) + }) } } + +func embySeriesReleaseSortTime(group embySeriesGroup) time.Time { + return mediaReleaseSortTime(model.Media{ + ReleaseDate: group.ReleaseDate, + Year: group.Year, + Base: model.Base{ + CreatedAt: group.CreatedAt, + UpdatedAt: group.CreatedAt, + }, + }) +} diff --git a/internal/service/emby_series_payload.go b/internal/service/emby_series_payload.go index 10bab89..f794e46 100644 --- a/internal/service/emby_series_payload.go +++ b/internal/service/emby_series_payload.go @@ -10,7 +10,7 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any { if group.BackdropURL != "" { backdropTags = append(backdropTags, group.ID+"-bd") } - return map[string]any{ + item := map[string]any{ "Id": group.ID, "Name": group.Name, "ServerId": embyServerID, @@ -32,6 +32,10 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any { }, "UserData": emptyUserData(), } + if premiered, ok := embyPremiereDate(group.ReleaseDate); ok { + item["PremiereDate"] = premiered + } + return item } func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any { diff --git a/internal/service/external_search.go b/internal/service/external_search.go index 545efcd..33a0d2f 100644 --- a/internal/service/external_search.go +++ b/internal/service/external_search.go @@ -19,6 +19,7 @@ type ExternalMediaResult struct { PosterURL string `json:"poster_url,omitempty"` BackdropURL string `json:"backdrop_url,omitempty"` Year int `json:"year,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` Rating float32 `json:"rating,omitempty"` TMDbID int `json:"tmdb_id,omitempty"` BangumiID int `json:"bangumi_id,omitempty"` @@ -64,6 +65,7 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType PosterURL: m.PosterURL, BackdropURL: m.BackdropURL, Year: m.Year, + ReleaseDate: m.ReleaseDate, Rating: m.Rating, TMDbID: m.TMDbID, BangumiID: m.BangumiID, diff --git a/internal/service/local_metadata_apply.go b/internal/service/local_metadata_apply.go index cc142b9..474ba62 100644 --- a/internal/service/local_metadata_apply.go +++ b/internal/service/local_metadata_apply.go @@ -29,6 +29,9 @@ func applyLocalIdentityMetadata(m *model.Media, local *LocalMetadata) { if local.Year > 0 { m.Year = local.Year } + if local.ReleaseDate != "" { + m.ReleaseDate = local.ReleaseDate + } if local.Overview != "" { m.Overview = local.Overview } @@ -98,6 +101,7 @@ func localHasDescriptiveMetadata(local *LocalMetadata) bool { local.EpisodeTitle != "" || local.AdultCode != "" || local.Year > 0 || + local.ReleaseDate != "" || local.Overview != "" || local.Rating > 0 || local.TMDbID > 0 || diff --git a/internal/service/local_metadata_convert.go b/internal/service/local_metadata_convert.go index a3da043..b519b1d 100644 --- a/internal/service/local_metadata_convert.go +++ b/internal/service/local_metadata_convert.go @@ -14,6 +14,7 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe OriginalName: cleanXMLText(doc.OriginalTitle), AdultCode: normalizeAdultCode(doc.Num), Year: int(doc.Year), + ReleaseDate: normalizeReleaseDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired)), Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot), Rating: float32(doc.Rating), PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...), @@ -48,7 +49,7 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe } } if meta.Year == 0 { - meta.Year = yearFromDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired)) + meta.Year = yearFromDate(meta.ReleaseDate) } if meta.TMDbID == 0 { meta.TMDbID = tmdbIDFromUniqueIDs(doc.UniqueIDs) diff --git a/internal/service/local_metadata_helpers.go b/internal/service/local_metadata_helpers.go index ec43695..8471816 100644 --- a/internal/service/local_metadata_helpers.go +++ b/internal/service/local_metadata_helpers.go @@ -4,6 +4,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) func firstRemoteURL(baseDir string, values ...string) string { @@ -69,6 +70,18 @@ func yearFromDate(value string) int { return year } +func normalizeReleaseDate(value string) string { + value = strings.TrimSpace(value) + if len(value) < len("2006-01-02") { + return "" + } + value = value[:len("2006-01-02")] + if parsed, err := time.Parse("2006-01-02", value); err == nil { + return parsed.Format("2006-01-02") + } + return "" +} + func samePath(a, b string) bool { return strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) } diff --git a/internal/service/local_metadata_types.go b/internal/service/local_metadata_types.go index 1ab72ef..80f724c 100644 --- a/internal/service/local_metadata_types.go +++ b/internal/service/local_metadata_types.go @@ -13,6 +13,7 @@ type LocalMetadata struct { EpisodeTitle string AdultCode string Year int + ReleaseDate string Overview string Rating float32 PosterURL string diff --git a/internal/service/manual_scrape.go b/internal/service/manual_scrape.go index c4779a6..125e0ad 100644 --- a/internal/service/manual_scrape.go +++ b/internal/service/manual_scrape.go @@ -17,6 +17,7 @@ type ManualScrapeRequest struct { PosterURL string `json:"poster_url"` BackdropURL string `json:"backdrop_url"` Year int `json:"year"` + ReleaseDate string `json:"release_date"` Rating float32 `json:"rating"` TMDbID int `json:"tmdb_id"` BangumiID int `json:"bangumi_id"` diff --git a/internal/service/manual_scrape_helpers.go b/internal/service/manual_scrape_helpers.go index c6149bb..15bdf88 100644 --- a/internal/service/manual_scrape_helpers.go +++ b/internal/service/manual_scrape_helpers.go @@ -31,6 +31,9 @@ func mergeManualRequestIntoMatch(match *Match, req ManualScrapeRequest) *Match { if req.Year > 0 { match.Year = req.Year } + if value := normalizeReleaseDate(req.ReleaseDate); value != "" { + match.ReleaseDate = value + } if req.Rating > 0 { match.Rating = req.Rating } diff --git a/internal/service/manual_scrape_search.go b/internal/service/manual_scrape_search.go index 3e6fc99..baa0913 100644 --- a/internal/service/manual_scrape_search.go +++ b/internal/service/manual_scrape_search.go @@ -45,6 +45,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q PosterURL: match.PosterURL, BackdropURL: match.BackdropURL, Year: match.Year, + ReleaseDate: match.ReleaseDate, Rating: match.Rating, TMDbID: match.TMDbID, BangumiID: match.BangumiID, diff --git a/internal/service/media_metadata.go b/internal/service/media_metadata.go index a57799a..4e3f750 100644 --- a/internal/service/media_metadata.go +++ b/internal/service/media_metadata.go @@ -15,6 +15,7 @@ type MediaMetadataUpdate struct { PosterURL *string `json:"poster_url"` BackdropURL *string `json:"backdrop_url"` Year *int `json:"year"` + ReleaseDate *string `json:"release_date"` Rating *float32 `json:"rating"` SeasonNum *int `json:"season_num"` EpisodeNum *int `json:"episode_num"` @@ -64,6 +65,9 @@ func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaM if req.Year != nil { updates["year"] = clampNonNegativeInt(*req.Year) } + if req.ReleaseDate != nil { + updates["release_date"] = normalizeReleaseDate(*req.ReleaseDate) + } if req.Rating != nil { updates["rating"] = clampRating(*req.Rating) } diff --git a/internal/service/media_metadata_test.go b/internal/service/media_metadata_test.go index 24bf5c7..77efaba 100644 --- a/internal/service/media_metadata_test.go +++ b/internal/service/media_metadata_test.go @@ -24,17 +24,19 @@ func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) title := "手动标题" overview := "手动简介" + releaseDate := "2026-06-23" season := 0 episode := 1 tmdbID := 12345 nsfw := true updated, err := svc.UpdateMetadata(t.Context(), media.ID, MediaMetadataUpdate{ - Title: &title, - Overview: &overview, - SeasonNum: &season, - EpisodeNum: &episode, - TMDbID: &tmdbID, - NSFW: &nsfw, + Title: &title, + Overview: &overview, + ReleaseDate: &releaseDate, + SeasonNum: &season, + EpisodeNum: &episode, + TMDbID: &tmdbID, + NSFW: &nsfw, }) if err != nil { t.Fatalf("update metadata: %v", err) @@ -45,4 +47,7 @@ func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { if updated.SeasonNum != 0 || updated.EpisodeNum != 1 || updated.TMDbID != tmdbID || !updated.NSFW { t.Fatalf("ids/episode metadata not saved: %#v", updated) } + if updated.ReleaseDate != releaseDate { + t.Fatalf("release date = %q, want %q", updated.ReleaseDate, releaseDate) + } } diff --git a/internal/service/media_release_sort.go b/internal/service/media_release_sort.go new file mode 100644 index 0000000..6575328 --- /dev/null +++ b/internal/service/media_release_sort.go @@ -0,0 +1,42 @@ +package service + +import ( + "fmt" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func mediaReleaseSortTime(media model.Media) time.Time { + if value := normalizeReleaseDate(media.ReleaseDate); value != "" { + if t, err := time.Parse("2006-01-02", value); err == nil { + return t + } + } + if media.Year > 0 { + if t, err := time.Parse("2006-01-02", fmt.Sprintf("%04d-12-31", media.Year)); err == nil { + return t + } + } + if !media.UpdatedAt.IsZero() { + return media.UpdatedAt + } + return media.CreatedAt +} + +func mediaReleaseOrderSQL(desc bool) string { + dir := "ASC" + if desc { + dir = "DESC" + } + return fmt.Sprintf("media.release_date %s, media.year %s, media.created_at %s, media.id %s", dir, dir, dir, dir) +} + +func embyPremiereDate(value string) (time.Time, bool) { + value = normalizeReleaseDate(value) + if value == "" { + return time.Time{}, false + } + t, err := time.Parse("2006-01-02", value) + return t, err == nil +} diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 10f7c76..9773a43 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -146,6 +146,14 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { } func seriesMediaTime(media model.Media) time.Time { + if releaseDate := strings.TrimSpace(media.ReleaseDate); releaseDate != "" { + if parsed, err := time.Parse("2006-01-02", releaseDate); err == nil { + return parsed + } + } + if media.Year > 0 { + return time.Date(media.Year, time.December, 31, 0, 0, 0, 0, time.UTC) + } if media.UpdatedAt.After(media.CreatedAt) { return media.UpdatedAt } diff --git a/internal/service/organize_pipeline.go b/internal/service/organize_pipeline.go index b1ebb7d..62fe9fa 100644 --- a/internal/service/organize_pipeline.go +++ b/internal/service/organize_pipeline.go @@ -135,7 +135,11 @@ func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineR scanRoot = filepath.Dir(path) } preferredLibraryID := p.scanPreferredLibraryID(ctx, req) - res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, p.scrapeAfter(ctx, req)) + scrapeAfter := p.scrapeAfter(ctx, req) + res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, scrapeAfter) + if scrapeAfter { + p.syncScrapedOrganizedNames(ctx, opts, res, path) + } } else if p.log != nil && res != nil && !req.DryRun { p.log.Info("organize pipeline skipped scan; no destination changes", zap.String("trigger", string(req.Trigger)), diff --git a/internal/service/organize_pipeline_sync.go b/internal/service/organize_pipeline_sync.go new file mode 100644 index 0000000..1f0344d --- /dev/null +++ b/internal/service/organize_pipeline_sync.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "path/filepath" + "strings" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (p *OrganizePipelineService) syncScrapedOrganizedNames(ctx context.Context, opts OrganizeOptions, res *OrganizeResult, path string) { + if p == nil || p.organizer == nil || p.repo == nil || p.repo.DB == nil || res == nil || res.DryRun { + return + } + for _, target := range organizeNameSyncTargets(res, path) { + media := p.mediaByPath(ctx, target) + if media == nil { + continue + } + before := media.Path + syncOpts := opts + syncOpts.SourcePath = "" + syncOpts.TransferMode = TransferMove + after, err := p.organizer.SyncMediaPathWithMetadata(ctx, media.ID, syncOpts) + if err != nil { + res.Errors = append(res.Errors, filepath.Base(before)+": "+err.Error()) + if p.log != nil { + p.log.Warn("organize pipeline scrape rename failed", + zap.String("media", media.ID), + zap.String("path", before), + zap.Error(err)) + } + continue + } + if samePath(before, after) { + continue + } + res.Items = append(res.Items, OrganizePreviewItem{ + Source: before, + Target: after, + Action: "organize", + Reason: "scraped metadata rename", + Title: media.Title, + }) + if p.log != nil { + p.log.Info("organize pipeline synced scraped media name", + zap.String("media", media.ID), + zap.String("from", before), + zap.String("to", after)) + } + } +} + +func organizeNameSyncTargets(res *OrganizeResult, path string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(res.Items)+1) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + key := strings.ToLower(filepath.Clean(value)) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, value) + } + for _, item := range res.Items { + switch item.Action { + case "organize", "replace", "reclassify": + add(item.Target) + } + } + add(path) + return out +} + +func (p *OrganizePipelineService) mediaByPath(ctx context.Context, path string) *model.Media { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + return nil + } + var media model.Media + if err := p.repo.DB.WithContext(ctx). + Where("path = ? AND deleted_at IS NULL", path). + Limit(1). + Take(&media).Error; err != nil { + return nil + } + return &media +} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index fd86b38..6db7646 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -102,6 +102,38 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID return o.applyOrganizeMedia(ctx, req, dst) } +// SyncMediaPathWithMetadata renames an already-organized media file after +// metadata scraping has finalized the display title/year. It always moves the +// organized target itself, even when the ingest transfer mode was copy/link. +func (o *OrganizerService) SyncMediaPathWithMetadata(ctx context.Context, mediaID string, opts OrganizeOptions) (string, error) { + req, err := o.resolveOrganizeMediaRequest(ctx, mediaID, opts) + if err != nil { + return "", err + } + dst, err := o.buildOrganizeMediaDestination(ctx, req) + if err != nil { + return "", err + } + if samePath(req.media.Path, dst.path) { + if !req.dryRun { + if err := o.persistOrganizedMediaMetadata(ctx, req.media); err != nil { + return "", err + } + } + return dst.path, nil + } + if req.dryRun { + return dst.path, nil + } + req.transferMode = TransferMove + before := req.media.Path + after, err := o.applyOrganizeMedia(ctx, req, dst) + if err == nil { + cleanupEmptyOrganizeParents(before, req.baseRoot) + } + return after, err +} + // OrganizeLibrary organizes every media row in a library whose file is // not already in the expected path structure. func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string) (*OrganizeResult, error) { diff --git a/internal/service/organizer_media.go b/internal/service/organizer_media.go index c625597..b6dc799 100644 --- a/internal/service/organizer_media.go +++ b/internal/service/organizer_media.go @@ -186,3 +186,21 @@ func (o *OrganizerService) applyOrganizeMedia(ctx context.Context, req organizeM ) return dst.path, nil } + +func cleanupEmptyOrganizeParents(oldPath, stopRoot string) { + dir := filepath.Clean(filepath.Dir(strings.TrimSpace(oldPath))) + root := filepath.Clean(strings.TrimSpace(stopRoot)) + if dir == "" || root == "" || dir == "." || root == "." || !pathWithin(dir, root) { + return + } + for !samePath(dir, root) { + if err := os.Remove(dir); err != nil { + return + } + next := filepath.Dir(dir) + if samePath(next, dir) { + return + } + dir = next + } +} diff --git a/internal/service/organizer_media_metadata.go b/internal/service/organizer_media_metadata.go index bb77b9c..2ac8364 100644 --- a/internal/service/organizer_media_metadata.go +++ b/internal/service/organizer_media_metadata.go @@ -81,6 +81,7 @@ func addOrganizedMediaMetadataUpdates(updates map[string]any, media model.Media) if media.Year > 0 { updates["year"] = media.Year } + setNonEmptyUpdate(updates, "release_date", media.ReleaseDate) if media.Rating > 0 { updates["rating"] = media.Rating } diff --git a/internal/service/organizer_metadata_match.go b/internal/service/organizer_metadata_match.go index f89cb0b..ac946ba 100644 --- a/internal/service/organizer_metadata_match.go +++ b/internal/service/organizer_metadata_match.go @@ -39,6 +39,7 @@ func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match { PosterURL: local.PosterURL, BackdropURL: local.BackdropURL, Year: local.Year, + ReleaseDate: local.ReleaseDate, Rating: local.Rating, TMDbID: local.TMDbID, DoubanID: local.DoubanID, @@ -90,6 +91,7 @@ func organizeMatchFromMedia(media *model.Media) *Match { PosterURL: media.PosterURL, BackdropURL: media.BackdropURL, Year: media.Year, + ReleaseDate: media.ReleaseDate, Rating: media.Rating, Languages: parseCommaList(media.Languages), Countries: parseCommaList(media.Countries), diff --git a/internal/service/organizer_reclassify_media_rows.go b/internal/service/organizer_reclassify_media_rows.go index 27e8ee2..270fa9d 100644 --- a/internal/service/organizer_reclassify_media_rows.go +++ b/internal/service/organizer_reclassify_media_rows.go @@ -63,6 +63,9 @@ func applyReclassifyMatchUpdates(updates map[string]any, match *Match) { if match.Year > 0 { updates["year"] = match.Year } + if value := strings.TrimSpace(match.ReleaseDate); value != "" { + updates["release_date"] = value + } if match.TMDbID > 0 { updates["tm_db_id"] = match.TMDbID } diff --git a/internal/service/organizer_reclassify_metadata.go b/internal/service/organizer_reclassify_metadata.go index 1a6e034..fdf3d99 100644 --- a/internal/service/organizer_reclassify_metadata.go +++ b/internal/service/organizer_reclassify_metadata.go @@ -86,6 +86,9 @@ func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media { if match.Year > 0 { media.Year = match.Year } + if value := strings.TrimSpace(match.ReleaseDate); value != "" { + media.ReleaseDate = value + } if match.TMDbID > 0 { media.TMDbID = match.TMDbID } diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index 52cd530..b8dbdb2 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -109,6 +109,62 @@ func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) { } } +func TestOrganizePipelineRenamesAfterScrape(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(src, "Spy.x.Family.S01E01.2022.1080p.mkv") + writeOrgFile(t, sourceFile, "episode") + + lib := model.Library{ + Name: "剧集", + Path: filepath.Join(dest, "电视剧"), + Type: "tv", + Enabled: true, + } + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, scraper) + pipeline := NewOrganizePipelineService(zap.NewNop(), repos, organizer, scanner, nil) + _, err := pipeline.Run(t.Context(), OrganizePipelineRequest{ + Scope: OrganizeScopeDirectory, + Trigger: OrganizeTriggerManual, + SourcePath: src, + DestPath: dest, + TransferMode: string(TransferCopy), + MediaType: "tv", + }) + if err != nil { + t.Fatalf("pipeline organize: %v", err) + } + + englishPath := filepath.Join(dest, "电视剧", "Spy Family", "Season 01", "Spy Family - S01E01.mkv") + if _, err := os.Stat(englishPath); !os.IsNotExist(err) { + t.Fatalf("english pre-scrape path should be renamed away, stat err=%v", err) + } + englishDir := filepath.Join(dest, "电视剧", "Spy Family") + if _, err := os.Stat(englishDir); !os.IsNotExist(err) { + t.Fatalf("empty english pre-scrape directory should be cleaned up, stat err=%v", err) + } + want := filepath.Join(dest, "电视剧", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("scraped rename target missing %q: %v", want, err) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { + t.Fatal(err) + } + if got.Title != "间谍过家家" || got.ReleaseDate != "2022-04-09" || got.ScrapeStatus != "matched" { + t.Fatalf("media after scrape rename = title=%q release=%q status=%q", got.Title, got.ReleaseDate, got.ScrapeStatus) + } +} + func TestOrganizeMediaRefreshesMetadataBeforeRename(t *testing.T) { scraper, repos, closeServer := newTestScraper(t) defer closeServer() diff --git a/internal/service/scanner.go b/internal/service/scanner.go index e529d12..f2d2dbe 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -253,6 +253,7 @@ type existingCloudMedia struct { STRMURL string Overview string Year int + ReleaseDate string Rating float32 TMDbID int BangumiID int @@ -286,6 +287,7 @@ type existingLocalMedia struct { BackdropURL string Overview string Year int + ReleaseDate string Rating float32 TMDbID int BangumiID int diff --git a/internal/service/scanner_cloud_enrich.go b/internal/service/scanner_cloud_enrich.go index f1abc0f..77a5d1e 100644 --- a/internal/service/scanner_cloud_enrich.go +++ b/internal/service/scanner_cloud_enrich.go @@ -101,6 +101,9 @@ func mergeMatchIntoLocalMetadata(meta *LocalMetadata, match *Match) { if match.Year > 0 { meta.Year = match.Year } + if match.ReleaseDate != "" { + meta.ReleaseDate = match.ReleaseDate + } if match.Overview != "" { meta.Overview = match.Overview } diff --git a/internal/service/scanner_existing_media.go b/internal/service/scanner_existing_media.go index ba59022..b41abd7 100644 --- a/internal/service/scanner_existing_media.go +++ b/internal/service/scanner_existing_media.go @@ -19,7 +19,7 @@ func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Cont var rows []model.Media if err := s.repo.DB.WithContext(ctx). Model(&model.Media{}). - Select("library_id", "path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "poster_url", "backdrop_url", "strm_url", "overview", "year", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status"). + Select("library_id", "path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "poster_url", "backdrop_url", "strm_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status"). Where("library_id IN ? AND path LIKE ?", libraryIDs, "cloud://%"). Find(&rows).Error; err != nil { return nil, err @@ -46,6 +46,7 @@ func (s *ScannerService) existingCloudMediaSnapshotForLibraries(ctx context.Cont STRMURL: row.STRMURL, Overview: row.Overview, Year: row.Year, + ReleaseDate: row.ReleaseDate, Rating: row.Rating, TMDbID: row.TMDbID, BangumiID: row.BangumiID, @@ -67,7 +68,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library var rows []model.Media if err := s.repo.DB.WithContext(ctx). Model(&model.Media{}). - Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status"). + Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status"). Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%"). Find(&rows).Error; err != nil { return nil, err @@ -96,6 +97,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library BackdropURL: row.BackdropURL, Overview: row.Overview, Year: row.Year, + ReleaseDate: row.ReleaseDate, Rating: row.Rating, TMDbID: row.TMDbID, BangumiID: row.BangumiID, diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go index 146b4b0..1bd4d60 100644 --- a/internal/service/scanner_metadata_refresh.go +++ b/internal/service/scanner_metadata_refresh.go @@ -10,6 +10,7 @@ type scanDerivedMetadata struct { Title string ScrapeStatus string Year int + ReleaseDate string TMDbID int BangumiID int DoubanID string @@ -43,6 +44,9 @@ func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMeta if localMeta.Year > 0 && existing.Year != localMeta.Year { return true } + if localMeta.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(localMeta.ReleaseDate) { + return true + } if localMeta.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(localMeta.Overview) { return true } @@ -131,6 +135,9 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata if local.Year > 0 && existing.Year != local.Year { return true } + if local.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(local.ReleaseDate) { + return true + } if local.Overview != "" && strings.TrimSpace(existing.Overview) != strings.TrimSpace(local.Overview) { return true } @@ -181,6 +188,7 @@ func cloudDerivedMetadataNeedsRefresh(existing existingCloudMedia, incoming *mod Title: existing.Title, ScrapeStatus: existing.ScrapeStatus, Year: existing.Year, + ReleaseDate: existing.ReleaseDate, TMDbID: existing.TMDbID, BangumiID: existing.BangumiID, DoubanID: existing.DoubanID, @@ -204,6 +212,7 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod Title: existing.Title, ScrapeStatus: existing.ScrapeStatus, Year: existing.Year, + ReleaseDate: existing.ReleaseDate, TMDbID: existing.TMDbID, BangumiID: existing.BangumiID, DoubanID: existing.DoubanID, @@ -222,6 +231,9 @@ func scanDerivedMetadataNeedsRefresh(existing scanDerivedMetadata, incoming *mod if enrichable && incoming.Year > 0 && existing.Year != incoming.Year { return true } + if enrichable && incoming.ReleaseDate != "" && strings.TrimSpace(existing.ReleaseDate) != strings.TrimSpace(incoming.ReleaseDate) { + return true + } if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum { return true } diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 37fffc0..3aed197 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -126,6 +126,9 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m "year": match.Year, "scrape_status": "matched", } + if match.ReleaseDate != "" { + updates["release_date"] = match.ReleaseDate + } if match.OriginalName != "" { updates["original_name"] = match.OriginalName } diff --git a/internal/service/scraper_local_metadata.go b/internal/service/scraper_local_metadata.go index b4bbc73..7584460 100644 --- a/internal/service/scraper_local_metadata.go +++ b/internal/service/scraper_local_metadata.go @@ -89,6 +89,9 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) { if local.Year > 0 { match.Year = local.Year } + if local.ReleaseDate != "" { + match.ReleaseDate = local.ReleaseDate + } if local.TMDbID > 0 { match.TMDbID = local.TMDbID } @@ -150,6 +153,9 @@ func mergeScrapePathHintMetadata(dst, src *LocalMetadata) *LocalMetadata { if dst.Year <= 0 && src.Year > 0 { dst.Year = src.Year } + if dst.ReleaseDate == "" && src.ReleaseDate != "" { + dst.ReleaseDate = src.ReleaseDate + } if src.TMDbID > 0 { dst.TMDbID = src.TMDbID } @@ -204,6 +210,9 @@ func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.M if next.Year > 0 { updates["year"] = next.Year } + if next.ReleaseDate != "" { + updates["release_date"] = next.ReleaseDate + } if next.TMDbID > 0 { updates["tm_db_id"] = next.TMDbID } diff --git a/internal/service/tmdb_match.go b/internal/service/tmdb_match.go index 93778f3..7ae130f 100644 --- a/internal/service/tmdb_match.go +++ b/internal/service/tmdb_match.go @@ -60,6 +60,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e if r.BackdropPath != "" { m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath } + m.ReleaseDate = normalizeReleaseDate(r.ReleaseDate) if len(r.ReleaseDate) >= 4 { _, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year) } @@ -131,6 +132,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro if r.BackdropPath != "" { m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath } + m.ReleaseDate = normalizeReleaseDate(r.FirstAirDate) if len(r.FirstAirDate) >= 4 { _, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year) } diff --git a/internal/service/tmdb_search.go b/internal/service/tmdb_search.go index 7bdb2bc..16f2e5c 100644 --- a/internal/service/tmdb_search.go +++ b/internal/service/tmdb_search.go @@ -105,6 +105,7 @@ func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match if r.BackdropPath != "" { m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath } + m.ReleaseDate = normalizeReleaseDate(r.ReleaseDate) if len(r.ReleaseDate) >= 4 { _, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year) } @@ -184,6 +185,7 @@ func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match { if r.BackdropPath != "" { m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath } + m.ReleaseDate = normalizeReleaseDate(r.FirstAirDate) if len(r.FirstAirDate) >= 4 { _, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year) } diff --git a/internal/service/tmdb_types.go b/internal/service/tmdb_types.go index f8adc37..d9b8581 100644 --- a/internal/service/tmdb_types.go +++ b/internal/service/tmdb_types.go @@ -15,6 +15,7 @@ type Match struct { PosterURL string `json:"poster_url"` BackdropURL string `json:"backdrop_url"` Year int `json:"year"` + ReleaseDate string `json:"release_date,omitempty"` Rating float32 `json:"rating"` Languages []string `json:"languages,omitempty"` Countries []string `json:"countries,omitempty"` diff --git a/web/src/api/library.ts b/web/src/api/library.ts index b717c1b..3aadc64 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -39,6 +39,7 @@ export interface ManualScrapeCandidate { poster_url?: string backdrop_url?: string year?: number + release_date?: string rating?: number tmdb_id?: number bangumi_id?: number @@ -66,6 +67,7 @@ export interface MediaMetadataUpdate { poster_url?: string backdrop_url?: string year?: number + release_date?: string rating?: number season_num?: number episode_num?: number diff --git a/web/src/components/MetadataEditDialog.tsx b/web/src/components/MetadataEditDialog.tsx index 4ebbeee..5c7a5cd 100644 --- a/web/src/components/MetadataEditDialog.tsx +++ b/web/src/components/MetadataEditDialog.tsx @@ -31,6 +31,7 @@ export function MetadataEditDialog({ poster_url: '', backdrop_url: '', year: '', + release_date: '', rating: '', season_num: '', episode_num: '', @@ -54,6 +55,7 @@ export function MetadataEditDialog({ poster_url: media.poster_url || '', backdrop_url: media.backdrop_url || '', year: media.year > 0 ? String(media.year) : '', + release_date: media.release_date || '', rating: media.rating > 0 ? String(media.rating) : '', season_num: media.season_num > 0 || media.episode_num > 0 ? String(media.season_num || 0) : '', episode_num: media.episode_num > 0 ? String(media.episode_num) : '', @@ -89,6 +91,7 @@ export function MetadataEditDialog({ poster_url: form.poster_url, backdrop_url: form.backdrop_url, year: Math.trunc(toNumber(form.year)), + release_date: form.release_date, rating: toNumber(form.rating), tmdb_id: Math.trunc(toNumber(form.tmdb_id)), bangumi_id: Math.trunc(toNumber(form.bangumi_id)), @@ -151,6 +154,7 @@ export function MetadataEditDialog({ set('poster_url', value)} /> set('backdrop_url', value)} /> set('year', value)} inputMode="numeric" /> + set('release_date', value)} type="date" /> set('rating', value)} inputMode="decimal" /> {!isSeries && set('season_num', value)} inputMode="numeric" />} {!isSeries && set('episode_num', value)} inputMode="numeric" />} @@ -198,18 +202,21 @@ function Field({ onChange, placeholder, inputMode, + type = 'text', }: { label: string value: string onChange: (value: string) => void placeholder?: string inputMode?: 'numeric' | 'decimal' + type?: 'text' | 'date' }) { return (