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(`
`))
+ 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(`
`))
+ 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外部 API 配置
- TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理 + TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 密钥与源管理 · AES-GCM 加密存储