From b44c7f860bd5d509069520df8c5f179e9a0836aa Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:56:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(media):=20=E4=BF=AE=E5=A4=8Demby=E7=94=B5?= =?UTF-8?q?=E5=BD=B1=E5=BA=93=E6=B7=B7=E5=85=A5=E5=89=A7=E9=9B=86/?= =?UTF-8?q?=E7=89=B9=E5=88=AB=E7=AF=87=E8=AF=86=E5=88=AB/=E5=89=A7?= =?UTF-8?q?=E9=9B=86=E5=90=88=E9=9B=86=20+=20=E6=96=B0=E5=A2=9E=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E7=BC=96=E8=BE=91=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emby 兼容层(hills/yamby 等客户端): - 电影库不再混入剧集:filterLikelyEpisodicPathsFromMovieQuery 按路径 特征(Season/S0x/Specials/电视剧/日番/国漫等)剔除误入电影库的剧集 - itemPayload 改用 mediaShouldBeEpisode:有季集号且(属剧集库或路径像剧集) 才标记为 Episode,修正电影库里剧集被当成 Movie 特别篇识别: - episode_parser 新增 patSpecialSeason,识别 S0/Season 0/Specials/SP/OVA/ OAD/番外/特别篇/特典 文件夹 → 第0季;seasonFromParents 返回 found 标志, 区分"明确第0季"与"未识别默认第1季" - emby seasonDir 正则同步支持 Specials/SP/OVA/特别篇 等 剧集合集: - groupSeries 抽出 isSeriesCard,卡片链接按是否剧集判定,修复剧集卡片 跳转;每集分离的根因(季集号/分组key)随入库识别修复一并改善 手动编辑元数据(自采集视频无TMDB信息时可自定义封面/标题/简介/分类): - 新增 MediaService.UpdateMetadata + PATCH /media/:id/metadata(需admin) - 前端 MetadataEditDialog 接入 LibraryPage 与 MediaDetailPage 其他:scanner/scraper/nfo/local+cloud_metadata/adult 元数据识别配套改进; .gitignore 忽略本地 .dev-cache/.dev-data 运行产物。 --- .gitignore | 4 + internal/handler/media.go | 22 ++ internal/handler/routes_authenticated.go | 1 + internal/repository/repository.go | 27 +-- internal/repository/repository_test.go | 46 ++++ internal/service/adult_scraper.go | 36 +++- internal/service/adult_scraper_test.go | 52 +++++ internal/service/bot_features_test.go | 18 ++ internal/service/cloud_metadata.go | 2 +- internal/service/cloud_path_repair.go | 25 ++- internal/service/emby_compat.go | 91 +++++++- internal/service/emby_compat_test.go | 45 ++++ internal/service/episode_parser.go | 68 +++--- internal/service/episode_parser_test.go | 4 + internal/service/external_search.go | 46 ++-- internal/service/local_metadata.go | 4 +- internal/service/manual_scrape.go | 54 ++++- internal/service/media_metadata.go | 148 +++++++++++++ internal/service/media_test.go | 135 +++++++++++- internal/service/nfo.go | 4 +- internal/service/scanner.go | 18 +- internal/service/scanner_cloud_test.go | 15 ++ internal/service/scraper.go | 100 +++++++-- internal/service/scraper_test.go | 242 +++++++++++++++++++++- internal/service/tmdb.go | 147 ++++++++++--- web/src/api/library.ts | 25 +++ web/src/components/APIConfigsPanel.tsx | 65 ++++-- web/src/components/ManualScrapeDialog.tsx | 3 + web/src/components/MetadataEditDialog.tsx | 220 ++++++++++++++++++++ web/src/pages/APIConfigsPage.tsx | 63 ++++-- web/src/pages/HomePage.tsx | 2 +- web/src/pages/LibraryPage.tsx | 45 +++- web/src/pages/MediaDetailPage.tsx | 21 +- web/src/utils/groupSeries.ts | 10 +- 34 files changed, 1601 insertions(+), 207 deletions(-) create mode 100644 internal/service/media_metadata.go create mode 100644 web/src/components/MetadataEditDialog.tsx diff --git a/.gitignore b/.gitignore index f27f7ea..0608842 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,7 @@ config.yaml downloads/ media/ *.pid + +# 本地开发运行产物 +.dev-cache/ +.dev-data/ diff --git a/internal/handler/media.go b/internal/handler/media.go index f51a9df..3938132 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -255,6 +255,28 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc { } } +func updateMediaMetadataHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req service.MediaMetadataUpdate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + m, err := svc.Media.UpdateMetadata(c.Request.Context(), c.Param("id"), req) + if err != nil { + status := http.StatusInternalServerError + if strings.Contains(strings.ToLower(err.Error()), "not found") { + status = http.StatusNotFound + } else if strings.Contains(strings.ToLower(err.Error()), "required") { + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, m) + } +} + func searchMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { q := c.Query("q") diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index 6c18674..67f4832 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -41,6 +41,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc * // Media. authed.GET("/media/:id", getMediaHandler(svc)) authed.GET("/media", searchMediaHandler(svc)) + authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc)) authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc)) authed.GET("/media/:id/scrape/search", middleware.AdminRequired(), manualScrapeSearchHandler(svc)) authed.POST("/media/:id/scrape/apply", middleware.AdminRequired(), manualScrapeApplyOneHandler(svc)) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 9d98c92..3fbaa48 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -377,28 +377,31 @@ func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error { } } } - if existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" { - backfilledExternalID := false - if m.TMDbID > 0 && existing.TMDbID <= 0 { + 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 - backfilledExternalID = true + changedExternalID = true } - if m.BangumiID > 0 && existing.BangumiID <= 0 { + if m.BangumiID > 0 && existing.BangumiID != m.BangumiID { updates["bangumi_id"] = m.BangumiID - backfilledExternalID = true + changedExternalID = true } - if m.DoubanID != "" && existing.DoubanID == "" { + if m.DoubanID != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(m.DoubanID) { updates["douban_id"] = m.DoubanID - backfilledExternalID = true + changedExternalID = true } - if m.TheTVDBID != "" && existing.TheTVDBID == "" { + if m.TheTVDBID != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(m.TheTVDBID) { updates["thetvdb_id"] = m.TheTVDBID - backfilledExternalID = true + changedExternalID = true } if m.Year > 0 && existing.Year <= 0 { updates["year"] = m.Year } - if backfilledExternalID && existing.ScrapeStatus == "no_match" { + if changedExternalID && (status == "no_match" || status == "matched") && m.ScrapeStatus != "matched" { updates["scrape_status"] = "pending" } } @@ -456,7 +459,7 @@ func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error { if lib := m.LibraryID; lib != "" && lib != existing.LibraryID { updates["library_id"] = m.LibraryID } - if m.SeasonNum > 0 && existing.SeasonNum != m.SeasonNum { + if (m.SeasonNum > 0 || m.EpisodeNum > 0) && existing.SeasonNum != m.SeasonNum { updates["season_num"] = m.SeasonNum } if m.EpisodeNum > 0 && existing.EpisodeNum != m.EpisodeNum { diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 6e033bd..7d67e46 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -72,6 +72,52 @@ func TestMediaUpsertSkipsUnchangedExistingRow(t *testing.T) { } } +func TestMediaUpsertRefreshesCloudExternalIDFromPathHint(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: "OpenList · 国产剧", Path: "cloud://openlist/国产剧", Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" + existing := model.Media{ + LibraryID: lib.ID, + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 220269, + ScrapeStatus: "matched", + } + if err := repos.Media.Upsert(t.Context(), &existing); err != nil { + t.Fatal(err) + } + next := model.Media{ + LibraryID: lib.ID, + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 296753, + } + if err := repos.Media.Upsert(t.Context(), &next); 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.TMDbID != 296753 || got.ScrapeStatus != "pending" { + t.Fatalf("cloud path hint should refresh tmdb and retry scrape, got tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) + } +} + type fakeMediaSearchBackend struct { ids []string err error diff --git a/internal/service/adult_scraper.go b/internal/service/adult_scraper.go index 692c21e..96ee45f 100644 --- a/internal/service/adult_scraper.go +++ b/internal/service/adult_scraper.go @@ -112,23 +112,37 @@ func (p *AdultProvider) resolveBases(ctx context.Context) []string { return nil } configured := []string{} - if resolved.BaseURL != "" { - configured = append(configured, resolved.BaseURL) - } - if resolved.Extra != "" { - for _, part := range strings.Split(resolved.Extra, ",") { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") { - configured = append(configured, part) - } - } - } + configured = append(configured, adultConfiguredBases(resolved.BaseURL)...) + configured = append(configured, adultConfiguredBases(resolved.Extra)...) if len(configured) > 0 { out = append(configured, out...) } return dedupeStrings(out) } +func adultConfiguredBases(value string) []string { + parts := strings.FieldsFunc(value, func(r rune) bool { + switch r { + case ',', ';', '\n', '\r', '\t', ' ': + return true + default: + return false + } + }) + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if !strings.HasPrefix(part, "http://") && !strings.HasPrefix(part, "https://") { + part = "https://" + part + } + out = append(out, strings.TrimRight(part, "/")) + } + return out +} + func adultSourceKind(base string) string { u, err := url.Parse(strings.TrimSpace(base)) host := "" diff --git a/internal/service/adult_scraper_test.go b/internal/service/adult_scraper_test.go index 46cf659..8e9ed41 100644 --- a/internal/service/adult_scraper_test.go +++ b/internal/service/adult_scraper_test.go @@ -2,7 +2,16 @@ package service import ( "context" + "net/http" + "net/http/httptest" "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" ) func TestNormalizeAdultCode(t *testing.T) { @@ -59,6 +68,49 @@ func TestParseAdultDetailHTMLDerivesDMMPoster(t *testing.T) { } } +func TestAdultProviderUsesConfiguredMultipleSources(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "temporary failure", http.StatusInternalServerError) + })) + defer bad.Close() + + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`SSIS-001 多源入口`)) + case "/v/ssis001": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`

SSIS-001 多源命中标题

`)) + default: + http.NotFound(w, r) + } + })) + defer good.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.APIConfig{}); err != nil { + t.Fatal(err) + } + apiConfig := NewAPIConfigService(zap.NewNop(), repository.New(db), NewCryptoService("", zap.NewNop())) + baseURL := bad.URL + "\n" + good.URL + if _, err := apiConfig.Update(context.Background(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil { + t.Fatal(err) + } + + provider := NewAdultProvider(zap.NewNop(), apiConfig) + match, err := provider.Search(context.Background(), "SSIS-001") + if err != nil { + t.Fatal(err) + } + if match == nil || match.Title != "多源命中标题" || match.OriginalName != "SSIS-001" || !match.NSFW { + t.Fatalf("multi-source adult match = %+v", match) + } +} + func TestAdultSourceKindRecognizesJavBusMirrors(t *testing.T) { cases := map[string]string{ "https://javdb.com": "javdb", diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go index 3d114f9..1fc6b2a 100644 --- a/internal/service/bot_features_test.go +++ b/internal/service/bot_features_test.go @@ -1133,3 +1133,21 @@ func TestBotAdminUnbindInactiveAndInvalidBindings(t *testing.T) { t.Fatal("invalid binding should be removed") } } + +func TestTelegramMembershipChatIDsIncludesCommandChatID(t *testing.T) { + _, bot := newBotTestService(t) + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"command_chat_id":"-100123"}`} + got := bot.telegramMembershipChatIDs(channel) + if len(got) != 1 || got[0] != "-100123" { + t.Fatalf("telegramMembershipChatIDs() = %#v, want command_chat_id", got) + } +} + +func TestTelegramMembershipChatIDsDedupesGroupChannelAndCommandIDs(t *testing.T) { + _, bot := newBotTestService(t) + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","channel_chat_id":"-100124","command_chat_id":"-100123"}`} + got := bot.telegramMembershipChatIDs(channel) + if len(got) != 2 || got[0] != "-100123" || got[1] != "-100124" { + t.Fatalf("telegramMembershipChatIDs() = %#v, want deduped ids", got) + } +} \ No newline at end of file diff --git a/internal/service/cloud_metadata.go b/internal/service/cloud_metadata.go index dd97dfe..5a5a637 100644 --- a/internal/service/cloud_metadata.go +++ b/internal/service/cloud_metadata.go @@ -221,7 +221,7 @@ func mergeCloudMetadata(dst, src *LocalMetadata) *LocalMetadata { if src.TheTVDBID != "" { dst.TheTVDBID = src.TheTVDBID } - if src.SeasonNum > 0 { + if src.SeasonNum > 0 || src.EpisodeNum > 0 { dst.SeasonNum = src.SeasonNum } if src.EpisodeNum > 0 { diff --git a/internal/service/cloud_path_repair.go b/internal/service/cloud_path_repair.go index dc1bf04..0abd04d 100644 --- a/internal/service/cloud_path_repair.go +++ b/internal/service/cloud_path_repair.go @@ -10,9 +10,9 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" ) -// RepairCloudPathMetadata backfills external IDs from cloud paths such as +// RepairCloudPathMetadata backfills external IDs from media paths such as // "Movie (2025) {tmdb-123}" so existing placeholder rows can be scraped -// without requiring another successful cloud provider traversal. +// without requiring another successful filesystem or cloud provider traversal. func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) { if c == nil || c.Repo == nil || c.Repo.DB == nil { return 0, nil @@ -22,7 +22,6 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) { query := c.Repo.DB.WithContext(ctx). Model(&model.Media{}). Select("id, title, path, year, season_num, episode_num, scrape_status, tm_db_id, bangumi_id, douban_id, thetvdb_id"). - Where("path LIKE ?", "cloud://%"). Where("("+strings.Join([]string{ "LOWER(path) LIKE ?", "LOWER(path) LIKE ?", @@ -44,22 +43,22 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) { updates := map[string]any{} status := strings.TrimSpace(row.ScrapeStatus) enrichable := status == "" || status == "pending" || status == "no_match" - backfilledExternalID := false - if meta.TMDbID > 0 && row.TMDbID <= 0 { + changedExternalID := false + if meta.TMDbID > 0 && row.TMDbID != meta.TMDbID { updates["tm_db_id"] = meta.TMDbID - backfilledExternalID = true + changedExternalID = true } - if meta.BangumiID > 0 && row.BangumiID <= 0 { + if meta.BangumiID > 0 && row.BangumiID != meta.BangumiID { updates["bangumi_id"] = meta.BangumiID - backfilledExternalID = true + changedExternalID = true } - if strings.TrimSpace(meta.DoubanID) != "" && strings.TrimSpace(row.DoubanID) == "" { + if strings.TrimSpace(meta.DoubanID) != "" && strings.TrimSpace(row.DoubanID) != strings.TrimSpace(meta.DoubanID) { updates["douban_id"] = strings.TrimSpace(meta.DoubanID) - backfilledExternalID = true + changedExternalID = true } - if strings.TrimSpace(meta.TheTVDBID) != "" && strings.TrimSpace(row.TheTVDBID) == "" { + if strings.TrimSpace(meta.TheTVDBID) != "" && strings.TrimSpace(row.TheTVDBID) != strings.TrimSpace(meta.TheTVDBID) { updates["thetvdb_id"] = strings.TrimSpace(meta.TheTVDBID) - backfilledExternalID = true + changedExternalID = true } if meta.Year > 0 && row.Year <= 0 { updates["year"] = meta.Year @@ -67,7 +66,7 @@ func (c *Container) RepairCloudPathMetadata(ctx context.Context) (int, error) { if enrichable && strings.TrimSpace(meta.Title) != "" && cloudPathRepairShouldReplaceTitle(row.Title, meta.Title) { updates["title"] = strings.TrimSpace(meta.Title) } - if backfilledExternalID && status == "no_match" { + if changedExternalID && (status == "" || status == "no_match" || status == "matched") { updates["scrape_status"] = "pending" } if len(updates) == 0 { diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 355fd5a..19ee565 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -317,7 +317,7 @@ const ( ) var ( - embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季)$`) + embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`) embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`) embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`) ) @@ -492,6 +492,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") { q = e.filterMovieItems(ctx, q) } + if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") { + q = filterLikelyEpisodicPathsFromMovieQuery(q) + } if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") { q = e.filterEpisodeItems(ctx, q) } @@ -892,7 +895,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, seriesID := m.SeriesID seriesName := "" seasonID := "" - if e.mediaBelongsToEpisodicLibrary(ctx, m) && (m.SeasonNum > 0 || m.EpisodeNum > 0) { + if e.mediaShouldBeEpisode(ctx, m) { itemType = "Episode" seriesID = e.seriesIDForMedia(m) seriesName = e.seriesNameForMedia(m) @@ -1027,6 +1030,16 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode return embyLibraryTypeIsEpisodic(lib.Type) } +func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool { + if m == nil || (m.SeasonNum <= 0 && m.EpisodeNum <= 0) { + return false + } + if e.mediaBelongsToEpisodicLibrary(ctx, m) { + return true + } + return embyMediaPathLooksEpisodic(m.Path) +} + func embyLibraryTypeIsEpisodic(typ string) bool { switch strings.ToLower(strings.TrimSpace(typ)) { case "tv", "anime", "variety": @@ -1039,9 +1052,10 @@ func embyLibraryTypeIsEpisodic(typ string) bool { func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB { episodicIDs := e.episodicLibraryIDs(ctx) if len(episodicIDs) == 0 { - return q + return filterLikelyEpisodicPathsFromMovieQuery(q) } - return q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs) + q = q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs) + return filterLikelyEpisodicPathsFromMovieQuery(q) } func (e *EmbyService) filterEpisodeItems(ctx context.Context, q *gorm.DB) *gorm.DB { @@ -1065,6 +1079,63 @@ func (e *EmbyService) episodicLibraryIDs(ctx context.Context) []string { return ids } +func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB { + clause, args := embyLikelyEpisodicPathSQL() + if clause == "" { + return q + } + return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...) +} + +func embyLikelyEpisodicPathSQL() (string, []any) { + patterns := []string{ + "%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%", + "%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%", + "%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%", + "%/电视剧/%", "%/剧集/%", "%/国产剧/%", "%/欧美剧/%", "%/日韩剧/%", "%/日剧/%", "%/韩剧/%", + "%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%", + } + clauses := make([]string, 0, len(patterns)*2) + args := make([]any, 0, len(patterns)*2) + for _, pattern := range patterns { + clauses = append(clauses, "LOWER(media.path) LIKE ?") + args = append(args, pattern) + if strings.Contains(pattern, "/") { + clauses = append(clauses, "LOWER(media.path) LIKE ?") + args = append(args, strings.ReplaceAll(pattern, "/", `\`)) + } + } + return strings.Join(clauses, " OR "), args +} + +func embyMediaPathLooksEpisodic(path string) bool { + normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")) + if normalized == "" { + return false + } + for _, marker := range []string{ + "/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/", + "/电视剧/", "/剧集/", "/国产剧/", "/欧美剧/", "/日韩剧/", "/日剧/", "/韩剧/", + "/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/", + } { + if strings.Contains(normalized, marker) { + return true + } + } + for _, marker := range []string{"/s0", "/s1", "/s2", "/s3", "/s4", "/s5", "/s6", "/s7", "/s8", "/s9"} { + if idx := strings.Index(normalized, marker); idx >= 0 { + after := idx + len(marker) + if after < len(normalized) && normalized[after] >= '0' && normalized[after] <= '9' { + slash := after + 1 + if slash < len(normalized) && normalized[slash] == '/' { + return true + } + } + } + } + return false +} + func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) { if e == nil || strings.TrimSpace(group.ID) == "" { return @@ -1316,7 +1387,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup order := []int{} for _, episode := range series.Episodes { seasonNum := episode.SeasonNum - if seasonNum <= 0 { + if seasonNum < 0 { seasonNum = 1 } season, ok := bySeason[seasonNum] @@ -1502,11 +1573,17 @@ func stableEmbyID(prefix string, parts ...string) string { } func seasonID(seriesID string, seasonNum int) string { - return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(maxInt(seasonNum, 1))) + if seasonNum < 0 { + seasonNum = 1 + } + return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum)) } func seasonName(seasonNum int) string { - if seasonNum <= 0 { + if seasonNum == 0 { + return "特别篇" + } + if seasonNum < 0 { seasonNum = 1 } return fmt.Sprintf("第 %d 季", seasonNum) diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 5f03d78..db24f6d 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -295,6 +295,51 @@ func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) { } } +func TestEmbyMovieLibraryFiltersMisplacedSeriesPaths(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) + } + show := model.Media{ + Base: model.Base{ID: "misplaced-show"}, + LibraryID: lib.ID, + Title: "错放剧集", + Path: `/media/movies/国产剧/错放剧集/Season 01/错放剧集 - S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + movie := model.Media{ + Base: model.Base{ID: "movie"}, + LibraryID: lib.ID, + Title: "普通电影", + Path: `/media/movies/普通电影.2026.mkv`, + } + if err := svc.repo.DB.Create(&show).Error; err != nil { + t.Fatalf("create show: %v", err) + } + if err := svc.repo.DB.Create(&movie).Error; err != nil { + t.Fatalf("create movie: %v", err) + } + + out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Movie"}, Limit: 50}) + if err != nil { + t.Fatalf("items: %v", err) + } + items := out["Items"].([]map[string]any) + if len(items) != 1 || items[0]["Id"] != movie.ID { + t.Fatalf("movie library should filter misplaced series paths, got %#v", items) + } + + item, err := svc.Item(t.Context(), show.ID, "user-1") + if err != nil { + t.Fatalf("direct item: %v", err) + } + if item["Type"] != "Episode" { + t.Fatalf("misplaced series path should be typed as Episode directly, got %#v", item) + } +} + func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) { svc := newTestEmbyService(t) local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index 8404cc4..8b1bb22 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -22,14 +22,15 @@ import ( ) var ( - patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) - patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) - patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) - patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) - patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) - patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) - patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`) - patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) + patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) + patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) + patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) + patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) + patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) + patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) + patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`) + patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) + patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|specials?|sp|ova|oad|extra|extras|番外|特别篇|特別篇|特典)$`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) ) @@ -50,30 +51,33 @@ func ParseEpisode(path string) (season, episode int) { return } if m := patEP.FindStringSubmatch(name); len(m) >= 2 { - season = seasonFromParents(path) - if season == 0 { + var found bool + season, found = seasonFromParents(path) + if !found { season = 1 } episode = mustAtoi(m[1]) return } if m := patCN.FindStringSubmatch(name); len(m) >= 2 { - season = seasonFromParents(path) - if season == 0 { + var found bool + season, found = seasonFromParents(path) + if !found { season = 1 } episode = mustAtoi(m[1]) return } if m := patDashEpisode.FindStringSubmatch(name); len(m) >= 2 { - season = seasonFromParents(path) - if season == 0 { + var found bool + season, found = seasonFromParents(path) + if !found { season = 1 } episode = mustAtoi(m[1]) return } - if parentSeason := seasonFromParents(path); parentSeason > 0 { + if parentSeason, found := seasonFromParents(path); found { if m := patBareEpisode.FindStringSubmatch(strings.TrimSpace(name)); len(m) >= 2 { season = parentSeason episode = mustAtoi(m[1]) @@ -83,27 +87,41 @@ func ParseEpisode(path string) (season, episode int) { return 0, 0 } -func seasonFromParents(path string) int { +func seasonFromParents(path string) (int, bool) { dir := filepath.Dir(path) for i := 0; i < 4; i++ { base := filepath.Base(dir) if base == "." || base == string(filepath.Separator) { - return 0 + return 0, false } - if m := patSeasonFolder.FindStringSubmatch(base); len(m) >= 3 { - for _, group := range m[1:] { - if group != "" { - return mustAtoi(group) - } - } + if season, ok := seasonFromDir(base); ok { + return season, true } parent := filepath.Dir(dir) if parent == dir { - return 0 + return 0, false } dir = parent } - return 0 + return 0, false +} + +func seasonFromDir(name string) (int, bool) { + name = strings.TrimSpace(name) + if name == "" { + return 0, false + } + if patSpecialSeason.MatchString(name) { + return 0, true + } + if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 { + for _, group := range m[1:] { + if group != "" { + return mustAtoi(group), true + } + } + } + return 0, false } func mustAtoi(s string) int { diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go index 847aff7..d344bd2 100644 --- a/internal/service/episode_parser_test.go +++ b/internal/service/episode_parser_test.go @@ -25,6 +25,10 @@ func TestParseEpisode(t *testing.T) { {`综艺/Season 06/综艺 第17期.mkv`, 6, 17}, {`动漫/第二季/04.mkv`, 2, 4}, {`动漫/第十季/第十一集.mkv`, 10, 11}, + {`剧集/S00/剧集 - E01.mkv`, 0, 1}, + {`剧集/Specials/剧集 - 02.mkv`, 0, 2}, + {`剧集/特别篇/03.mkv`, 0, 3}, + {`剧集/剧集 - S00E04.mkv`, 0, 4}, {"Movie.2020.1080p.mkv", 0, 0}, } for _, tc := range cases { diff --git a/internal/service/external_search.go b/internal/service/external_search.go index 33ea605..4891bc9 100644 --- a/internal/service/external_search.go +++ b/internal/service/external_search.go @@ -11,24 +11,29 @@ import ( // intentionally separate from model.Media because the item may not exist in // the local library yet. type ExternalMediaResult struct { - Source string `json:"source"` - MediaType string `json:"media_type,omitempty"` - Title string `json:"title"` - Overview string `json:"overview,omitempty"` - PosterURL string `json:"poster_url,omitempty"` - BackdropURL string `json:"backdrop_url,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - TMDbID int `json:"tmdb_id,omitempty"` - BangumiID int `json:"bangumi_id,omitempty"` - DoubanID string `json:"douban_id,omitempty"` - TheTVDBID string `json:"thetvdb_id,omitempty"` - SubscribeKeyword string `json:"subscribe_keyword"` - TotalEpisodes int `json:"total_episodes,omitempty"` - DownloadedEpisodes int `json:"downloaded_episodes,omitempty"` - LocalMediaCount int `json:"local_media_count,omitempty"` - MissingEpisodes []int `json:"missing_episodes,omitempty"` - InLibrary bool `json:"in_library"` + Source string `json:"source"` + MediaType string `json:"media_type,omitempty"` + Title string `json:"title"` + OriginalName string `json:"original_name,omitempty"` + Overview string `json:"overview,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + Year int `json:"year,omitempty"` + Rating float32 `json:"rating,omitempty"` + TMDbID int `json:"tmdb_id,omitempty"` + BangumiID int `json:"bangumi_id,omitempty"` + DoubanID string `json:"douban_id,omitempty"` + TheTVDBID string `json:"thetvdb_id,omitempty"` + SubscribeKeyword string `json:"subscribe_keyword"` + TotalEpisodes int `json:"total_episodes,omitempty"` + DownloadedEpisodes int `json:"downloaded_episodes,omitempty"` + LocalMediaCount int `json:"local_media_count,omitempty"` + MissingEpisodes []int `json:"missing_episodes,omitempty"` + InLibrary bool `json:"in_library"` + Languages []string `json:"languages,omitempty"` + Countries []string `json:"countries,omitempty"` + Genres []string `json:"genres,omitempty"` + NSFW bool `json:"nsfw,omitempty"` } // SearchExternalMedia fans out one normalized search intent to TMDb, Douban @@ -53,6 +58,7 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType Source: source, MediaType: typ, Title: m.Title, + OriginalName: m.OriginalName, Overview: m.Overview, PosterURL: m.PosterURL, BackdropURL: m.BackdropURL, @@ -62,6 +68,10 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType BangumiID: m.BangumiID, SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year), TotalEpisodes: totalEpisodes, + Languages: m.Languages, + Countries: m.Countries, + Genres: m.Genres, + NSFW: m.NSFW, }) } diff --git a/internal/service/local_metadata.go b/internal/service/local_metadata.go index 91aa91a..8d8bb08 100644 --- a/internal/service/local_metadata.go +++ b/internal/service/local_metadata.go @@ -263,7 +263,7 @@ func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) { for { names := []string{"tvshow.nfo", "series.nfo"} base := filepath.Base(dir) - if seasonFromDir(base) > 0 { + if _, ok := seasonFromDir(base); ok { parentBase := filepath.Base(filepath.Dir(dir)) names = append(names, parentBase+".nfo") } @@ -447,7 +447,7 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) { if episode.TheTVDBID != "" { dst.TheTVDBID = episode.TheTVDBID } - if episode.SeasonNum > 0 { + if episode.SeasonNum > 0 || episode.EpisodeNum > 0 { dst.SeasonNum = episode.SeasonNum } if episode.EpisodeNum > 0 { diff --git a/internal/service/manual_scrape.go b/internal/service/manual_scrape.go index 2d91b97..ba5710f 100644 --- a/internal/service/manual_scrape.go +++ b/internal/service/manual_scrape.go @@ -67,6 +67,7 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q Source: source, MediaType: typ, Title: match.Title, + OriginalName: match.OriginalName, Overview: match.Overview, PosterURL: match.PosterURL, BackdropURL: match.BackdropURL, @@ -77,9 +78,18 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q DoubanID: match.DoubanID, TheTVDBID: match.TheTVDBID, SubscribeKeyword: buildSubscribeKeyword(match.Title, match.Year), + Languages: match.Languages, + Countries: match.Countries, + Genres: match.Genres, + NSFW: match.NSFW, }) } + if provider == "all" || provider == "adult" { + for _, match := range s.manualAdultMatches(ctx, media, query) { + add("adult", "adult", match) + } + } if provider == "all" || provider == "tmdb" { for _, match := range s.manualTMDbMatches(ctx, query, year, mediaType) { typ := mediaType @@ -165,6 +175,10 @@ func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrap return mergeManualRequestIntoMatch(match, req), nil } } + case source == "adult": + if match := s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title)); match != nil { + return mergeManualRequestIntoMatch(match, req), nil + } } return fallback() } @@ -180,18 +194,52 @@ func (s *ScraperService) manualTMDbMatches(ctx context.Context, query string, ye } out := make([]*Match, 0, 2) if mediaType == "" || mediaType == "movie" { - if match, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && match != nil { - out = append(out, match) + if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil { + out = append(out, matches...) } } if mediaType == "" || mediaType == "tv" || mediaType == "anime" || mediaType == "variety" { - if match, err := s.tmdb.SearchTV(ctx, query, year); err == nil && match != nil { + if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil { + out = append(out, matches...) + } + } + return out +} + +func (s *ScraperService) manualAdultMatches(ctx context.Context, media *model.Media, query string) []*Match { + candidates := []string{query} + if media != nil { + candidates = append(candidates, media.Path, media.OriginalName, media.Title) + } + out := make([]*Match, 0, 1) + seen := map[string]struct{}{} + for _, candidate := range candidates { + code := normalizeAdultCode(candidate) + if code == "" { + continue + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + if match := s.manualAdultMatch(ctx, code); match != nil { out = append(out, match) } } return out } +func (s *ScraperService) manualAdultMatch(ctx context.Context, code string) *Match { + if s.adult == nil || !s.adult.Enabled() { + return nil + } + match, err := s.adult.Search(ctx, code) + if err != nil || match == nil { + return nil + } + return match +} + func (s *ScraperService) manualTMDbMatchByID(ctx context.Context, id int, mediaType string) *Match { if s.tmdb == nil || !s.tmdb.Enabled() || id <= 0 { return nil diff --git a/internal/service/media_metadata.go b/internal/service/media_metadata.go new file mode 100644 index 0000000..a57799a --- /dev/null +++ b/internal/service/media_metadata.go @@ -0,0 +1,148 @@ +package service + +import ( + "context" + "errors" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +type MediaMetadataUpdate struct { + Title *string `json:"title"` + OriginalName *string `json:"original_name"` + Overview *string `json:"overview"` + PosterURL *string `json:"poster_url"` + BackdropURL *string `json:"backdrop_url"` + Year *int `json:"year"` + Rating *float32 `json:"rating"` + SeasonNum *int `json:"season_num"` + EpisodeNum *int `json:"episode_num"` + TMDbID *int `json:"tmdb_id"` + BangumiID *int `json:"bangumi_id"` + DoubanID *string `json:"douban_id"` + TheTVDBID *string `json:"thetvdb_id"` + Languages *string `json:"languages"` + Countries *string `json:"countries"` + Genres *string `json:"genres"` + NSFW *bool `json:"nsfw"` +} + +func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaMetadataUpdate) (*model.Media, error) { + if s == nil || s.repo == nil || s.repo.DB == nil { + return nil, errors.New("media service unavailable") + } + id = strings.TrimSpace(id) + if id == "" { + return nil, errors.New("media id required") + } + if existing, err := s.repo.Media.FindByID(ctx, id); err != nil { + return nil, err + } else if existing == nil { + return nil, errors.New("media not found") + } + updates := map[string]any{"scrape_status": "matched"} + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + return nil, errors.New("title required") + } + updates["title"] = title + } + if req.OriginalName != nil { + updates["original_name"] = strings.TrimSpace(*req.OriginalName) + } + if req.Overview != nil { + updates["overview"] = strings.TrimSpace(*req.Overview) + } + if req.PosterURL != nil { + updates["poster_url"] = strings.TrimSpace(*req.PosterURL) + } + if req.BackdropURL != nil { + updates["backdrop_url"] = strings.TrimSpace(*req.BackdropURL) + } + if req.Year != nil { + updates["year"] = clampNonNegativeInt(*req.Year) + } + if req.Rating != nil { + updates["rating"] = clampRating(*req.Rating) + } + if req.SeasonNum != nil { + updates["season_num"] = clampNonNegativeInt(*req.SeasonNum) + } + if req.EpisodeNum != nil { + updates["episode_num"] = clampNonNegativeInt(*req.EpisodeNum) + } + if req.TMDbID != nil { + updates["tm_db_id"] = clampNonNegativeInt(*req.TMDbID) + } + if req.BangumiID != nil { + updates["bangumi_id"] = clampNonNegativeInt(*req.BangumiID) + } + if req.DoubanID != nil { + updates["douban_id"] = strings.TrimSpace(*req.DoubanID) + } + if req.TheTVDBID != nil { + updates["thetvdb_id"] = strings.TrimSpace(*req.TheTVDBID) + } + if req.Languages != nil { + updates["languages"] = normalizeMetadataCSV(*req.Languages) + } + if req.Countries != nil { + updates["countries"] = normalizeMetadataCSV(*req.Countries) + } + if req.Genres != nil { + updates["genres"] = normalizeMetadataCSV(*req.Genres) + } + if req.NSFW != nil { + updates["nsfw"] = *req.NSFW + } + if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return nil, err + } + s.invalidateMediaCache(ctx) + return s.repo.Media.FindByID(ctx, id) +} + +func normalizeMetadataCSV(value string) string { + parts := strings.FieldsFunc(value, func(r rune) bool { + switch r { + case ',', ',', ';', ';', '\n', '\r', '\t': + return true + default: + return false + } + }) + out := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key := strings.ToLower(part) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, part) + } + return strings.Join(out, ",") +} + +func clampNonNegativeInt(value int) int { + if value < 0 { + return 0 + } + return value +} + +func clampRating(value float32) float32 { + if value < 0 { + return 0 + } + if value > 10 { + return 10 + } + return value +} diff --git a/internal/service/media_test.go b/internal/service/media_test.go index a66ae45..f858508 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -235,17 +235,17 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T Path: "/media/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", SeasonNum: 1, EpisodeNum: 1, - TMDbID: 220269, + TMDbID: 296753, SizeBytes: 100, PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", } cloud := model.Media{ LibraryID: "cloud-tv", Title: "折腰", - Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-220269}/Season 1/折腰.S01E01.mkv", + Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv", SeasonNum: 1, EpisodeNum: 1, - TMDbID: 220269, + TMDbID: 296753, SizeBytes: 200, STRMURL: "/api/cloud/play/openlist?ref=/国产剧/折腰/01.mkv", } @@ -259,6 +259,49 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T } } +func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + lib := model.Library{Name: "自采集", Path: "/media/custom", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + media := model.Media{Base: model.Base{ID: "custom-media"}, LibraryID: lib.ID, Title: "raw", Path: "/media/custom/raw.mp4", ScrapeStatus: "no_match"} + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + title := "手动标题" + overview := "手动简介" + 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, + }) + if err != nil { + t.Fatalf("update metadata: %v", err) + } + if updated.Title != title || updated.Overview != overview || updated.ScrapeStatus != "matched" { + t.Fatalf("metadata not saved: %#v", updated) + } + if updated.SeasonNum != 0 || updated.EpisodeNum != 1 || updated.TMDbID != tmdbID || !updated.NSFW { + t.Fatalf("ids/episode metadata not saved: %#v", updated) + } +} + func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { @@ -268,7 +311,7 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { t.Fatal(err) } repos := repository.New(db) - path := "cloud://openlist/国漫/折腰 (2025) {tmdb-220269}/Season 1/折腰.S01E01.mkv" + path := "cloud://openlist/国漫/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" if err := repos.DB.Create(&model.Media{ LibraryID: "cloud-tv", Title: "折腰", @@ -285,7 +328,7 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { Path: path, SeasonNum: 1, EpisodeNum: 1, - TMDbID: 220269, + TMDbID: 296753, Year: 2025, ScrapeStatus: "pending", }); err != nil { @@ -295,11 +338,53 @@ func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { t.Fatal(err) } - if got.TMDbID != 220269 || got.Year != 2025 || got.ScrapeStatus != "pending" { + if got.TMDbID != 296753 || got.Year != 2025 || got.ScrapeStatus != "pending" { t.Fatalf("pending cloud row was not backfilled correctly: tmdb=%d year=%d status=%q", got.TMDbID, got.Year, got.ScrapeStatus) } } +func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" + if err := repos.DB.Create(&model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 220269, + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + if err := repos.Media.Upsert(t.Context(), &model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 296753, + Year: 2025, + ScrapeStatus: "pending", + }); err != nil { + t.Fatal(err) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { + t.Fatal(err) + } + if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { + t.Fatalf("cloud external id conflict was not corrected: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) + } +} + func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { @@ -335,6 +420,44 @@ func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) { } } +func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" + if err := repos.DB.Create(&model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 220269, + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + container := &Container{Repo: repos, Log: zap.NewNop()} + repaired, err := container.RepairCloudPathMetadata(t.Context()) + if err != nil { + t.Fatal(err) + } + if repaired != 1 { + t.Fatalf("repaired = %d, want 1", repaired) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { + t.Fatal(err) + } + if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { + t.Fatalf("conflicting matched id was not repaired: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) + } +} + func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/service/nfo.go b/internal/service/nfo.go index 07d06cd..da91541 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -57,8 +57,8 @@ type episodeNFO struct { XMLName xml.Name `xml:"episodedetails"` Title string `xml:"title"` ShowTitle string `xml:"showtitle,omitempty"` - Season int `xml:"season,omitempty"` - Episode int `xml:"episode,omitempty"` + Season int `xml:"season"` + Episode int `xml:"episode"` Year int `xml:"year,omitempty"` Plot string `xml:"plot,omitempty"` Rating float32 `xml:"rating,omitempty"` diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 9832ef7..12b609f 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -1840,16 +1840,16 @@ func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMeta if localMeta.Year > 0 && existing.Year <= 0 { return true } - if localMeta.TMDbID > 0 && existing.TMDbID <= 0 { + if localMeta.TMDbID > 0 && existing.TMDbID != localMeta.TMDbID { return true } - if localMeta.BangumiID > 0 && existing.BangumiID <= 0 { + if localMeta.BangumiID > 0 && existing.BangumiID != localMeta.BangumiID { return true } - if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) == "" { + if strings.TrimSpace(localMeta.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(localMeta.DoubanID) { return true } - if strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) == "" { + if strings.TrimSpace(localMeta.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(localMeta.TheTVDBID) { return true } if strings.TrimSpace(localMeta.PosterURL) != "" && strings.TrimSpace(existing.PosterURL) == "" { @@ -1897,7 +1897,7 @@ func cloudSeriesTitleFromMediaPath(mediaPath string) (string, int) { } base := strings.TrimSpace(dirs[len(dirs)-1]) usedSeasonFolder := false - if seasonFromDir(base) > 0 { + if _, ok := seasonFromDir(base); ok { usedSeasonFolder = true dirs = dirs[:len(dirs)-1] if len(dirs) == 0 { @@ -2387,7 +2387,7 @@ func applyLocalMetadata(m *model.Media, local *LocalMetadata) { if local.TheTVDBID != "" { m.TheTVDBID = local.TheTVDBID } - if local.SeasonNum > 0 { + if local.SeasonNum > 0 || local.EpisodeNum > 0 { m.SeasonNum = local.SeasonNum } if local.EpisodeNum > 0 { @@ -2405,11 +2405,15 @@ func applyLocalMetadata(m *model.Media, local *LocalMetadata) { if local.NSFW { m.NSFW = true } - if local.HasNFO || (!local.PathHint && localHasDescriptiveMetadata(local)) { + if localMetadataMarksMatched(local) { m.ScrapeStatus = "matched" } } +func localMetadataMarksMatched(local *LocalMetadata) bool { + return local != nil && (local.HasNFO || (!local.PathHint && localHasDescriptiveMetadata(local))) +} + func localHasDescriptiveMetadata(local *LocalMetadata) bool { if local == nil { return false diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index d4b95db..d6da3dd 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -409,6 +409,21 @@ func TestCloudSeriesTitlePrefersShowFolder(t *testing.T) { } } +func TestCloudMetadataNeedsRefreshWhenPathHintConflicts(t *testing.T) { + existing := existingCloudMedia{ + Year: 2025, + TMDbID: 220269, + } + local := &LocalMetadata{ + Year: 2025, + TMDbID: 296753, + PathHint: true, + } + if !cloudMetadataNeedsRefresh(existing, local) { + t.Fatal("conflicting explicit cloud path hint should refresh existing media") + } +} + func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/internal/service/scraper.go b/internal/service/scraper.go index c901d86..3ee87be 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -224,6 +224,9 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { } else if err != nil { s.log.Warn("read local metadata before scrape failed", zap.String("media_id", m.ID), zap.Error(err)) } + } else if hinted, _ := pathHintMetadata(m.Path, seriesLike); hinted != nil { + local = mergeCloudMetadata(local, hinted) + applyLocalMetadata(m, local) } year := mediaYearHint(m) @@ -299,6 +302,9 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model mediaType = lib.Type } if m.TMDbID > 0 { + if mediaIsEpisodic(m, lib) { + mediaType = "tv" + } if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil { return match } @@ -422,6 +428,15 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) { if local.TMDbID > 0 { match.TMDbID = local.TMDbID } + if local.BangumiID > 0 { + match.BangumiID = local.BangumiID + } + if local.DoubanID != "" { + match.DoubanID = local.DoubanID + } + if local.TheTVDBID != "" { + match.TheTVDBID = local.TheTVDBID + } if local.Genres != "" { match.Genres = splitNFOList(local.Genres) } @@ -483,7 +498,7 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media, // Manual cloud/batch applies must not fail just because an optional provider // details request is slow or unavailable. if match.TMDbID > 0 && s.tmdb != nil && s.tmdb.Enabled() { - mediaType := s.determineMediaType(lib, match) + mediaType := s.determineMediaTypeForMedia(lib, m, match) detailCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), tmdbDetailsTimeout) details, err := s.tmdb.GetDetails(detailCtx, match.TMDbID, mediaType) cancel() @@ -518,6 +533,50 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media, zap.Strings("countries", details.Countries), zap.Strings("genres", details.Genres)) } + if mediaType == "tv" && m != nil && m.EpisodeNum > 0 { + episodeCtx, episodeCancel := context.WithTimeout(context.WithoutCancel(ctx), tmdbDetailsTimeout) + episode, err := s.tmdb.GetTVEpisodeDetails(episodeCtx, match.TMDbID, m.SeasonNum, m.EpisodeNum) + episodeCancel() + if err != nil { + s.log.Debug("failed to get tmdb episode details", + zap.String("media_id", m.ID), + zap.Int("tmdb_id", match.TMDbID), + zap.Int("season", m.SeasonNum), + zap.Int("episode", m.EpisodeNum), + zap.Error(err)) + } else if episode != nil { + episodeUpdates := map[string]any{} + if strings.TrimSpace(episode.Name) != "" { + episodeUpdates["original_name"] = strings.TrimSpace(episode.Name) + } + if strings.TrimSpace(episode.Overview) != "" { + episodeUpdates["overview"] = strings.TrimSpace(episode.Overview) + } + if strings.TrimSpace(episode.StillURL) != "" { + episodeUpdates["backdrop_url"] = strings.TrimSpace(episode.StillURL) + } + if episode.Rating > 0 { + episodeUpdates["rating"] = episode.Rating + } + if episode.AirYear > 0 && match.Year <= 0 { + episodeUpdates["year"] = episode.AirYear + } + if episode.Runtime > 0 && m.DurationSec <= 0 { + episodeUpdates["duration_sec"] = episode.Runtime * 60 + } + if len(episodeUpdates) > 0 { + if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID). + Updates(episodeUpdates).Error; err != nil { + s.log.Warn("failed to save tmdb episode metadata", + zap.String("media_id", m.ID), + zap.Int("tmdb_id", match.TMDbID), + zap.Int("season", m.SeasonNum), + zap.Int("episode", m.EpisodeNum), + zap.Error(err)) + } + } + } + } } cloudMedia := isCloudMediaPath(m.Path) || (lib != nil && isCloudMediaPath(lib.Path)) if !cloudMedia { @@ -548,9 +607,13 @@ func isCloudMediaPath(value string) bool { func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.Media, local *LocalMetadata) error { next := *m applyLocalMetadata(&next, local) + status := "matched" + if !localMetadataMarksMatched(local) { + status = "pending" + } updates := map[string]any{ "title": next.Title, - "scrape_status": "matched", + "scrape_status": status, } if next.OriginalName != "" { updates["original_name"] = next.OriginalName @@ -582,7 +645,7 @@ func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.M if next.TheTVDBID != "" { updates["thetvdb_id"] = next.TheTVDBID } - if next.SeasonNum > 0 { + if next.SeasonNum > 0 || next.EpisodeNum > 0 { updates["season_num"] = next.SeasonNum } if next.EpisodeNum > 0 { @@ -644,7 +707,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string { func seriesFolderTitle(mediaPath, libraryRoot string) string { dir := filepath.Dir(mediaPath) - if strictSeasonFolder(filepath.Base(dir)) > 0 { + if strictSeasonFolderMatched(filepath.Base(dir)) { dir = filepath.Dir(dir) } if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) { @@ -687,27 +750,15 @@ func isGenericMediaCategoryFolder(name string) bool { } func strictSeasonFolder(name string) int { - name = strings.TrimSpace(name) - if name == "" { - return 0 - } - for _, pattern := range strictSeasonFolderPatterns { - if m := pattern.FindStringSubmatch(name); len(m) == 2 { - return mustAtoi(m[1]) - } + if season, ok := seasonFromDir(name); ok { + return season } return 0 } -func seasonFromDir(name string) int { - if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 { - for _, group := range m[1:] { - if group != "" { - return mustAtoi(group) - } - } - } - return 0 +func strictSeasonFolderMatched(name string) bool { + _, ok := seasonFromDir(name) + return ok } func titleCandidates(title string) []string { @@ -961,6 +1012,13 @@ func (s *ScraperService) AnyEnabled() bool { // determineMediaType returns "tv" for TV shows and "movie" for movies. // It uses the library type as the primary signal. func (s *ScraperService) determineMediaType(lib *model.Library, match *Match) string { + return s.determineMediaTypeForMedia(lib, nil, match) +} + +func (s *ScraperService) determineMediaTypeForMedia(lib *model.Library, media *model.Media, match *Match) string { + if media != nil && mediaIsEpisodic(media, lib) { + return "tv" + } if lib != nil { switch lib.Type { case "tv", "anime", "variety", "show", "shows": diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index 9d4e35b..464e114 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -46,9 +46,9 @@ func TestCleanQuery(t *testing.T) { } func TestExternalIDHintsFromText(t *testing.T) { - hints := externalIDHintsFromText("国漫/折腰 (2025) {tmdb 220269}/Season 1/折腰.S01E01.mkv") - if hints.TMDbID != 220269 { - t.Fatalf("tmdb hint = %d, want 220269", hints.TMDbID) + hints := externalIDHintsFromText("国漫/折腰 (2025) {tmdb 296753}/Season 1/折腰.S01E01.mkv") + if hints.TMDbID != 296753 { + t.Fatalf("tmdb hint = %d, want 296753", hints.TMDbID) } hints = externalIDHintsFromText("Movie (2026) {tmdb-1630433} [douban=3622222] {bgm 456789} {tvdb:12345}") if hints.TMDbID != 1630433 || hints.DoubanID != "3622222" || hints.BangumiID != 456789 || hints.TheTVDBID != "12345" { @@ -57,8 +57,8 @@ func TestExternalIDHintsFromText(t *testing.T) { } func TestPathHintMetadataDoesNotMarkMediaMatched(t *testing.T) { - meta, hints := pathHintMetadata("cloud://openlist/国漫/折腰 (2025) {tmdb 220269}/Season 1/折腰.S01E01.mkv", true) - if meta == nil || hints.TMDbID != 220269 || meta.TMDbID != 220269 || meta.Title != "折腰" || meta.Year != 2025 { + meta, hints := pathHintMetadata("cloud://openlist/国漫/折腰 (2025) {tmdb 296753}/Season 1/折腰.S01E01.mkv", true) + if meta == nil || hints.TMDbID != 296753 || meta.TMDbID != 296753 || meta.Title != "折腰" || meta.Year != 2025 { t.Fatalf("path hint metadata = %+v hints=%+v", meta, hints) } media := &model.Media{Title: "折腰", ScrapeStatus: "pending"} @@ -68,6 +68,76 @@ func TestPathHintMetadataDoesNotMarkMediaMatched(t *testing.T) { } } +func TestEnrichOneCloudPathHintOverridesStaleTMDbID(t *testing.T) { + var requested []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested = append(requested, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/tv/296753": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 296753, + "name": "折腰", + "overview": "正确的剧集条目", + "poster_path": "/zheyao.jpg", + "first_air_date": "2025-05-13", + "origin_country": []string{"CN"}, + }) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &config.Config{} + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + cfg.Secrets.TMDbImageProxy = upstream.URL + "/images" + log := zap.NewNop() + scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) + + lib := model.Library{Name: "OpenList · 国产剧", Path: "cloud://openlist/国产剧", Type: "tv", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "折腰", + Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 220269, + ScrapeStatus: "pending", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + if err := scraper.EnrichOne(t.Context(), &media); err != nil { + t.Fatal(err) + } + var got model.Media + if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil { + t.Fatal(err) + } + if got.ScrapeStatus != "matched" || got.TMDbID != 296753 || got.Title != "折腰" || got.PosterURL == "" { + t.Fatalf("path hint was not authoritative: status=%q tmdb=%d title=%q poster=%q", got.ScrapeStatus, got.TMDbID, got.Title, got.PosterURL) + } + for _, path := range requested { + if path == "/tv/220269" || path == "/movie/220269" { + t.Fatalf("scraper queried stale tmdb id; requests=%v", requested) + } + } +} + func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) { scraper := &ScraperService{} match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{ @@ -84,6 +154,119 @@ func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) { } } +func TestManualSearchReturnsTMDbCandidatePage(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/search/movie" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + { + "id": 101, + "title": "错误的同名电影", + "poster_path": "/wrong.jpg", + "release_date": "2021-01-01", + "vote_average": 5.1, + "genre_ids": []int{18}, + "backdrop_path": "/wrong-backdrop.jpg", + }, + { + "id": 202, + "title": "正确的同名电影", + "poster_path": "/right.jpg", + "release_date": "2021-08-01", + "vote_average": 8.2, + "genre_ids": []int{28}, + "backdrop_path": "/right-backdrop.jpg", + }, + }, + }) + })) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &config.Config{} + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + log := zap.NewNop() + scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) + + lib := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{LibraryID: lib.ID, Title: "同名电影", Path: "/media/movie/同名电影.mkv"} + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + results, err := scraper.ManualSearch(t.Context(), &media, "同名电影", "tmdb", "movie") + if err != nil { + t.Fatal(err) + } + if len(results) != 2 || results[0].TMDbID != 101 || results[1].TMDbID != 202 { + t.Fatalf("manual TMDb candidates = %#v", results) + } +} + +func TestManualSearchIncludesAdultProvider(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`SSIS-001 手动候选`)) + case "/v/ssis001": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`

SSIS-001 手动成人标题

`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.APIConfig{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + apiConfig := NewAPIConfigService(zap.NewNop(), repos, NewCryptoService("", zap.NewNop())) + baseURL := upstream.URL + if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil { + t.Fatal(err) + } + log := zap.NewNop() + scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig)) + + lib := model.Library{Name: "成人", Path: "/media/adult", Type: "movie", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{LibraryID: lib.ID, Title: "SSIS-001", OriginalName: "SSIS-001", Path: "/media/adult/SSIS-001.mkv"} + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + results, err := scraper.ManualSearch(t.Context(), &media, "SSIS-001", "adult", "adult") + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Source != "adult" || results[0].MediaType != "adult" || !results[0].NSFW || results[0].OriginalName != "SSIS-001" { + t.Fatalf("manual adult candidates = %#v", results) + } +} + func TestApplyManualMatchSavesSelectedCloudMatchWhenDetailsSlow(t *testing.T) { oldTimeout := tmdbDetailsTimeout tmdbDetailsTimeout = 20 * time.Millisecond @@ -277,6 +460,46 @@ func TestEnrichOneWritesTMDbIDColumn(t *testing.T) { } } +func TestEnrichOneWritesTMDbEpisodeMetadata(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + + lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv") + media := model.Media{ + LibraryID: lib.ID, + Title: "间谍过家家", + Path: mediaPath, + SeasonNum: 2, + EpisodeNum: 1, + ScrapeStatus: "pending", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + if err := scraper.EnrichOne(t.Context(), &media); err != nil { + t.Fatal(err) + } + + var got model.Media + if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil { + t.Fatal(err) + } + if got.OriginalName != "任务代号: 猫" || got.Overview != "单集剧情" { + t.Fatalf("episode metadata not saved: original=%q overview=%q", got.OriginalName, got.Overview) + } + if !strings.HasSuffix(got.BackdropURL, "/images/w500/still.jpg") || got.DurationSec != 24*60 { + t.Fatalf("episode still/runtime not saved: backdrop=%q duration=%d", got.BackdropURL, got.DurationSec) + } + if got.Rating < 9.09 || got.Rating > 9.11 { + t.Fatalf("episode rating = %v, want 9.1", got.Rating) + } +} + func TestEnrichOneRejectsWrongYearMatchFromSeriesFolder(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -485,6 +708,15 @@ func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func( "vote_average": 8.6, }}, }) + case r.URL.Path == "/tv/12345/season/2/episode/1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "任务代号: 猫", + "overview": "单集剧情", + "still_path": "/still.jpg", + "air_date": "2023-10-07", + "vote_average": 9.1, + "runtime": 24, + }) case strings.HasPrefix(r.URL.Path, "/tv/12345"): _ = json.NewEncoder(w).Encode(map[string]any{ "id": 12345, diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index d2e17b4..00d339d 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -142,9 +142,47 @@ type Match struct { NSFW bool `json:"nsfw,omitempty"` } +type tmdbMovieSearchResult struct { + ID int `json:"id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + OriginalLanguage string `json:"original_language"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + VoteAverage float32 `json:"vote_average"` + GenreIDs []int `json:"genre_ids"` +} + +type tmdbTVSearchResult struct { + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + OriginalLanguage string `json:"original_language"` + OriginCountry []string `json:"origin_country"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + VoteAverage float32 `json:"vote_average"` + GenreIDs []int `json:"genre_ids"` +} + // SearchMovie issues `/search/movie` and returns the best match, or nil // when no result is found. The `year` argument is optional (0 = any). func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) (*Match, error) { + matches, err := t.SearchMovieCandidates(ctx, query, year) + if err != nil || len(matches) == 0 { + return nil, err + } + return matches[0], nil +} + +// SearchMovieCandidates returns the first TMDb result page as manual-scrape +// candidates. Automatic scrape still uses SearchMovie's first-result behavior, +// while manual correction can show alternatives when the top result is wrong. +func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, year int) ([]*Match, error) { if query == "" { return nil, errors.New("empty query") } @@ -166,20 +204,8 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) } u := base + "/search/movie?" + q.Encode() - type result struct { - ID int `json:"id"` - Title string `json:"title"` - OriginalTitle string `json:"original_title"` - OriginalLanguage string `json:"original_language"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - VoteAverage float32 `json:"vote_average"` - GenreIDs []int `json:"genre_ids"` - } type page struct { - Results []result `json:"results"` + Results []tmdbMovieSearchResult `json:"results"` } var p page @@ -189,7 +215,14 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) if len(p.Results) == 0 { return nil, nil } - r := p.Results[0] + out := make([]*Match, 0, len(p.Results)) + for _, r := range p.Results { + out = append(out, t.movieSearchResultToMatch(r)) + } + return out, nil +} + +func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match { m := &Match{ TMDbID: r.ID, Title: r.Title, @@ -208,12 +241,21 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) if len(r.ReleaseDate) >= 4 { _, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year) } - return m, nil + return m } // SearchTV issues `/search/tv` and returns the best match. Used by anime / // tv libraries before falling back to SearchMovie. func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) { + matches, err := t.SearchTVCandidates(ctx, query, year) + if err != nil || len(matches) == 0 { + return nil, err + } + return matches[0], nil +} + +// SearchTVCandidates returns the first TMDb TV result page for manual scrape. +func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, year int) ([]*Match, error) { if query == "" { return nil, errors.New("empty query") } @@ -234,21 +276,8 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M } u := base + "/search/tv?" + q.Encode() - type result struct { - ID int `json:"id"` - Name string `json:"name"` - OriginalName string `json:"original_name"` - OriginalLanguage string `json:"original_language"` - OriginCountry []string `json:"origin_country"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - FirstAirDate string `json:"first_air_date"` - VoteAverage float32 `json:"vote_average"` - GenreIDs []int `json:"genre_ids"` - } type page struct { - Results []result `json:"results"` + Results []tmdbTVSearchResult `json:"results"` } var p page @@ -258,7 +287,14 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M if len(p.Results) == 0 { return nil, nil } - r := p.Results[0] + out := make([]*Match, 0, len(p.Results)) + for _, r := range p.Results { + out = append(out, t.tvSearchResultToMatch(r)) + } + return out, nil +} + +func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match { m := &Match{ TMDbID: r.ID, Title: r.Name, @@ -281,7 +317,7 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M if len(r.FirstAirDate) >= 4 { _, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year) } - return m, nil + return m } func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error { @@ -437,6 +473,55 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro return m, nil } +// TMDbEpisodeDetails holds per-episode metadata from /tv/{id}/season/{season}/episode/{episode}. +type TMDbEpisodeDetails struct { + Name string + Overview string + StillURL string + AirYear int + Rating float32 + Runtime int +} + +func (t *TMDbProvider) GetTVEpisodeDetails(ctx context.Context, tmdbID, season, episode int) (*TMDbEpisodeDetails, error) { + if tmdbID <= 0 || episode <= 0 { + return nil, nil + } + apiKey := t.resolveAPIKey(ctx) + if apiKey == "" { + return nil, nil + } + base := t.resolveBaseURL(ctx) + q := url.Values{} + q.Set("api_key", apiKey) + q.Set("language", "zh-CN") + u := base + "/tv/" + fmt.Sprint(tmdbID) + "/season/" + fmt.Sprint(season) + "/episode/" + fmt.Sprint(episode) + "?" + q.Encode() + var r struct { + Name string `json:"name"` + Overview string `json:"overview"` + StillPath string `json:"still_path"` + AirDate string `json:"air_date"` + VoteAverage float32 `json:"vote_average"` + Runtime int `json:"runtime"` + } + if err := t.getJSON(ctx, u, &r); err != nil { + return nil, err + } + details := &TMDbEpisodeDetails{ + Name: r.Name, + Overview: r.Overview, + Rating: r.VoteAverage, + Runtime: r.Runtime, + } + if r.StillPath != "" { + details.StillURL = t.imgCDN + "/w500" + r.StillPath + } + if len(r.AirDate) >= 4 { + _, _ = fmt.Sscanf(r.AirDate[:4], "%d", &details.AirYear) + } + return details, nil +} + // TMDbDetails holds extended metadata from the /movie/{id} or /tv/{id} endpoints. type TMDbDetails struct { Languages []string `json:"languages"` diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 3f4927a..6b2cfef 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -19,6 +19,7 @@ export interface ManualScrapeCandidate { source: string media_type?: string title: string + original_name?: string overview?: string poster_url?: string backdrop_url?: string @@ -31,6 +32,27 @@ export interface ManualScrapeCandidate { languages?: string[] countries?: string[] genres?: string[] + nsfw?: boolean +} + +export interface MediaMetadataUpdate { + title?: string + original_name?: string + overview?: string + poster_url?: string + backdrop_url?: string + year?: number + rating?: number + season_num?: number + episode_num?: number + tmdb_id?: number + bangumi_id?: number + douban_id?: string + thetvdb_id?: string + languages?: string + countries?: string + genres?: string + nsfw?: boolean } export const libraryAPI = { @@ -75,6 +97,9 @@ export const mediaAPI = { get: (id: string) => api.get(`/media/${id}`).then((r) => r.data), + updateMetadata: (id: string, payload: MediaMetadataUpdate) => + api.patch(`/media/${id}/metadata`, payload, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data), + manualScrapeSearch: (id: string, params: { query: string; provider?: string; media_type?: string }) => api .get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params }) diff --git a/web/src/components/APIConfigsPanel.tsx b/web/src/components/APIConfigsPanel.tsx index 5eedf72..ece2b25 100644 --- a/web/src/components/APIConfigsPanel.tsx +++ b/web/src/components/APIConfigsPanel.tsx @@ -28,7 +28,7 @@ export function APIConfigsPanel() {

外部 API 配置

- TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理 + TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 密钥与源管理 · AES-GCM 加密存储

@@ -73,14 +73,17 @@ export function APIConfigsPanel() { )} - {item.has_key ? ( + {apiConfigConfigured(item) ? ( {item.masked_key} ) : ( 未配置 )} + {item.provider === 'adult' && apiConfigSourceCount(item) > 0 ? ( + {apiConfigSourceCount(item)} 个源 + ) : null} - {item.has_key ? ( + {apiConfigConfigured(item) ? ( 已配置 @@ -148,14 +151,17 @@ function EditingRow({ }) { const [apiKey, setAPIKey] = useState('') const [baseURL, setBaseURL] = useState(item.base_url ?? '') + const [extra, setExtra] = useState(item.extra ?? '') const [enabled, setEnabled] = useState(item.enabled) const [saving, setSaving] = useState(false) + const isAdult = item.provider === 'adult' const submit = async (e: FormEvent) => { e.preventDefault() setSaving(true) try { const patch: Record = { base_url: baseURL, enabled } + if (isAdult) patch.extra = extra if (apiKey.trim()) patch.api_key = apiKey.trim() await apiConfigsAPI.update(item.provider, patch) toast.success(`${item.provider} 已保存`) @@ -175,25 +181,38 @@ function EditingRow({
{item.provider} + {!isAdult && ( + + )} - + {isAdult && ( +