Fix media classification scraping and Chinese naming

This commit is contained in:
ShukeBta
2026-07-15 20:45:24 +08:00
parent 923198ccea
commit 19381eb270
33 changed files with 1150 additions and 67 deletions
+1
View File
@@ -81,6 +81,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("organizer.categories.domestic_tv", "国产剧") v.SetDefault("organizer.categories.domestic_tv", "国产剧")
v.SetDefault("organizer.categories.euus_tv", "欧美剧") v.SetDefault("organizer.categories.euus_tv", "欧美剧")
v.SetDefault("organizer.categories.jk_tv", "日韩剧") v.SetDefault("organizer.categories.jk_tv", "日韩剧")
v.SetDefault("organizer.categories.unclassified_tv", "未分类")
v.SetDefault("organizer.categories.jp_anime", "日番") v.SetDefault("organizer.categories.jp_anime", "日番")
v.SetDefault("organizer.categories.cn_anime", "国漫") v.SetDefault("organizer.categories.cn_anime", "国漫")
v.SetDefault("organizer.categories.kr_anime", "韩漫") v.SetDefault("organizer.categories.kr_anime", "韩漫")
+18 -1
View File
@@ -23,6 +23,24 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
return return
} }
if _, ok := service.ParseCloudLibraryMount(lib.Path); ok { if _, ok := service.ParseCloudLibraryMount(lib.Path); ok {
status, started, startErr := svc.Scan.StartCloudLibraryScan(id, true)
if startErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": startErr.Error()})
return
}
if !started {
c.JSON(http.StatusAccepted, gin.H{
"library_id": id,
"queued": true,
"cloud": true,
"already_running": true,
"stage": status.Stage,
"state": status.State,
"message": "该云盘媒体库正在后台扫描,请在任务面板查看进度",
"estimate_message": "页面关闭不会中断扫描",
})
return
}
task := startScanHTTPTask(svc, "云盘扫描队列", lib.Name, lib.Path) task := startScanHTTPTask(svc, "云盘扫描队列", lib.Name, lib.Path)
if svc.WSHub != nil { if svc.WSHub != nil {
svc.WSHub.Publish("scan", gin.H{ svc.WSHub.Publish("scan", gin.H{
@@ -34,7 +52,6 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
}) })
} }
_, _, _ = svc.Scan.StartCloudLibraryScan(id, true)
finishHTTPTask(task, nil, "queued", "云盘扫描已加入后台队列", map[string]int64{"queued": 1}, nil) finishHTTPTask(task, nil, "queued", "云盘扫描已加入后台队列", map[string]int64{"queued": 1}, nil)
c.JSON(http.StatusAccepted, gin.H{ c.JSON(http.StatusAccepted, gin.H{
"library_id": id, "library_id": id,
+35
View File
@@ -293,6 +293,41 @@ func TestListLibrarySeriesDoesNotTruncateLargeEpisodeLibraries(t *testing.T) {
} }
} }
func TestScanLibraryHandlerSurfacesCloudQueueStartFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "旧夸克云盘", Path: service.BuildCloudLibraryPath(service.LegacyQuarkProvider, "archive", "archive"), Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
log := zap.NewNop()
svc := &service.Container{
Log: log,
Repo: repos,
Scan: service.NewScannerService(&config.Config{}, log, repos, service.NewHub(log), nil, nil),
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: lib.ID}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/libraries/"+lib.ID+"/scan", nil)
scanLibraryHandler(svc)(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s, want bad request", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "deprecated") {
t.Fatalf("body=%s, want cloud queue start error", w.Body.String())
}
}
func TestScrapeOptionsFromRequestPreservesEpisodeImagesFalse(t *testing.T) { func TestScrapeOptionsFromRequestPreservesEpisodeImagesFalse(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
w := httptest.NewRecorder() w := httptest.NewRecorder()
+10 -4
View File
@@ -130,6 +130,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isAdultText { if isAdultText {
return categoryName(categories, "adult", "成人") return categoryName(categories, "adult", "成人")
} }
// Match MoviePilot's ordered category rules: animation categories are
// evaluated before documentary / children / variety and region rules.
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
return animeCategory()
}
if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText { if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText {
return categoryName(categories, "documentary", "纪录片") return categoryName(categories, "documentary", "纪录片")
} }
@@ -139,9 +144,6 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText { if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
return categoryName(categories, "variety", "综艺") return categoryName(categories, "variety", "综艺")
} }
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
return animeCategory()
}
if !hasMetadata && sourceHint != "" { if !hasMetadata && sourceHint != "" {
return sourceHint return sourceHint
} }
@@ -154,7 +156,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isWestern { if isWestern {
return categoryName(categories, "euus_tv", "欧美剧") return categoryName(categories, "euus_tv", "欧美剧")
} }
return categoryName(categories, "euus_tv", "欧美剧") return categoryName(categories, "unclassified_tv", "未分类")
case "adult": case "adult":
return categoryName(categories, "adult", "成人") return categoryName(categories, "adult", "成人")
} }
@@ -213,6 +215,10 @@ func (o *OrganizerService) categoryMap() map[string]string {
return o.cfg.Organizer.Categories return o.cfg.Organizer.Categories
} }
func isUnclassifiedTVCategory(category string, categories map[string]string) bool {
return strings.EqualFold(strings.TrimSpace(category), categoryName(categories, "unclassified_tv", "未分类"))
}
func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, mediaType string) string { func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, mediaType string) string {
if m == nil { if m == nil {
return "" return ""
@@ -19,6 +19,7 @@ var sourceCategoryHints = []sourceCategoryHintDef{
{Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"}, {Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"},
{Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"}, {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"},
{Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv", Aliases: []string{"日剧", "韩剧", "泰剧"}}, {Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv", Aliases: []string{"日剧", "韩剧", "泰剧"}},
{Key: "unclassified_tv", Fallback: "未分类", MediaType: "tv", Aliases: []string{"uncategorized"}},
{Key: "cn_anime", Fallback: "国漫", MediaType: "anime"}, {Key: "cn_anime", Fallback: "国漫", MediaType: "anime"},
{Key: "jp_anime", Fallback: "日番", MediaType: "anime"}, {Key: "jp_anime", Fallback: "日番", MediaType: "anime"},
{Key: "kr_anime", Fallback: "韩漫", MediaType: "anime"}, {Key: "kr_anime", Fallback: "韩漫", MediaType: "anime"},
@@ -27,7 +28,6 @@ var sourceCategoryHints = []sourceCategoryHintDef{
{Key: "variety", Fallback: "综艺", MediaType: "variety"}, {Key: "variety", Fallback: "综艺", MediaType: "variety"},
{Key: "documentary", Fallback: "纪录片", MediaType: "tv"}, {Key: "documentary", Fallback: "纪录片", MediaType: "tv"},
{Key: "children", Fallback: "儿童", MediaType: "tv"}, {Key: "children", Fallback: "儿童", MediaType: "tv"},
{Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv", Aliases: []string{"未分类", "uncategorized"}},
{Key: "adult", Fallback: "成人", MediaType: "adult", Aliases: []string{"9KG", "番号", "JAV", "adult", "nsfw"}}, {Key: "adult", Fallback: "成人", MediaType: "adult", Aliases: []string{"9KG", "番号", "JAV", "adult", "nsfw"}},
} }
+17 -6
View File
@@ -110,12 +110,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "国产剧", want: "国产剧",
}, },
{ {
name: "latin tv title without metadata falls back to western tv", name: "latin tv title without metadata stays unclassified",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "The Last of Us S01E01 1080p", Title: "The Last of Us S01E01 1080p",
}, },
want: "欧美剧", want: "未分类",
}, },
{ {
name: "latin tv keeps explicit western source category", name: "latin tv keeps explicit western source category",
@@ -127,13 +127,13 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "欧美剧", want: "欧美剧",
}, },
{ {
name: "generic tv folder is not treated as chinese category", name: "generic tv folder stays unclassified without metadata",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "The Last of Us S01E01 1080p", Title: "The Last of Us S01E01 1080p",
Category: "downloads 电视剧", Category: "downloads 电视剧",
}, },
want: "欧美剧", want: "未分类",
}, },
{ {
name: "gala title overrides wrong western source category", name: "gala title overrides wrong western source category",
@@ -145,12 +145,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "综艺", want: "综艺",
}, },
{ {
name: "platform token alone does not classify romanized drama", name: "platform token alone leaves romanized drama unclassified",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL", Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL",
}, },
want: "欧美剧", want: "未分类",
}, },
{ {
name: "metadata classifies romanized chinese drama", name: "metadata classifies romanized chinese drama",
@@ -217,6 +217,17 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
}, },
want: "美漫", want: "美漫",
}, },
{
name: "tv animation takes priority over children like MoviePilot rules",
input: mediaClassifyInput{
MediaType: "tv",
Title: "Gourd Brothers",
Countries: []string{"CN"},
Languages: []string{"zh"},
Genres: []string{"16", "10762"},
},
want: "国漫",
},
{ {
name: "western anime legacy source category maps to us anime without metadata", name: "western anime legacy source category maps to us anime without metadata",
input: mediaClassifyInput{ input: mediaClassifyInput{
@@ -0,0 +1,20 @@
package service
import (
"regexp"
"strings"
)
var (
mediaCollectionEnglishRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:collection|anthology|trilogy|quadrilogy|tetralogy|saga|box[\s._-]*set)(?:[\s._-]|$)|(?:complete|all)[\s._-]*(?:\d+[\s._-]*)?(?:movies?|films?|collection|series|saga)(?:[\s._-]|$)|\d+[\s._-]*films?[\s._-]*collection`)
mediaCollectionChineseRE = regexp.MustCompile(`(?:合集|全集|套装|全套|系列合集|电影系列|全\s*\d+\s*部)`)
)
func isMediaCollectionFolder(name string) bool {
name = strings.TrimSpace(name)
return name != "" && (mediaCollectionEnglishRE.MatchString(name) || mediaCollectionChineseRE.MatchString(name))
}
func mediaParentLooksLikeCollection(path string) bool {
return isMediaCollectionFolder(pathBaseSlash(parentSlashPath(path)))
}
+10 -3
View File
@@ -57,8 +57,9 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
return nil, err return nil, err
} }
out := make([]model.Media, 0) out := make([]model.Media, 0)
resolver := newMediaSeriesKeyResolver(rows)
for _, row := range rows { for _, row := range rows {
if mediaSeriesKey(row) == key { if resolver.key(row) == key {
out = append(out, row) out = append(out, row)
} }
} }
@@ -101,8 +102,9 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
} }
groups := make([]seriesCardGroup, 0) groups := make([]seriesCardGroup, 0)
byKey := make(map[string]int, len(items)) byKey := make(map[string]int, len(items))
resolver := newMediaSeriesKeyResolver(items)
for _, item := range items { for _, item := range items {
key := mediaSeriesKey(item) key := resolver.key(item)
if key == "" { if key == "" {
continue continue
} }
@@ -112,7 +114,12 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
group.latest = latest group.latest = latest
} }
card := &group.card card := &group.card
card.Count++ // A shared external ID means duplicate encodes/locations for movies,
// not multiple episodes. Keep a single movie card without presenting
// its versions as an "N episodes" collection.
if mediaLooksEpisodicForGrouping(item) || mediaLooksEpisodicForGrouping(card.LinkMedia) {
card.Count++
}
if betterSeriesLinkMedia(item, card.LinkMedia) { if betterSeriesLinkMedia(item, card.LinkMedia) {
card.LinkMedia = item card.LinkMedia = item
} }
+7 -7
View File
@@ -25,16 +25,16 @@ func mediaSeriesRawKey(media model.Media) string {
return seriesFingerprint("library-path-id", mediaTargetLibraryID(media), idKey) return seriesFingerprint("library-path-id", mediaTargetLibraryID(media), idKey)
} }
if media.TMDbID > 0 { if media.TMDbID > 0 {
return fmt.Sprintf("tmdb:%d", media.TMDbID) return seriesFingerprint("episodic-external", fmt.Sprintf("tmdb:%d", media.TMDbID))
} }
if media.BangumiID > 0 { if media.BangumiID > 0 {
return fmt.Sprintf("bgm:%d", media.BangumiID) return seriesFingerprint("episodic-external", fmt.Sprintf("bgm:%d", media.BangumiID))
} }
if strings.TrimSpace(media.DoubanID) != "" { if strings.TrimSpace(media.DoubanID) != "" {
return "douban:" + strings.TrimSpace(media.DoubanID) return seriesFingerprint("episodic-external", "douban:"+strings.TrimSpace(media.DoubanID))
} }
if strings.TrimSpace(media.TheTVDBID) != "" { if strings.TrimSpace(media.TheTVDBID) != "" {
return "thetvdb:" + strings.TrimSpace(media.TheTVDBID) return seriesFingerprint("episodic-external", "thetvdb:"+strings.TrimSpace(media.TheTVDBID))
} }
if strings.TrimSpace(media.SeriesID) != "" { if strings.TrimSpace(media.SeriesID) != "" {
return "series:" + strings.TrimSpace(media.SeriesID) return "series:" + strings.TrimSpace(media.SeriesID)
@@ -45,12 +45,12 @@ func mediaSeriesRawKey(media model.Media) string {
return "series:" + strings.TrimSpace(media.SeriesID) return "series:" + strings.TrimSpace(media.SeriesID)
} }
if media.TMDbID > 0 { if media.TMDbID > 0 {
return fmt.Sprintf("tmdb:%d", media.TMDbID) return seriesFingerprint("movie-external", fmt.Sprintf("tmdb:%d", media.TMDbID))
} }
if media.BangumiID > 0 { if media.BangumiID > 0 {
return fmt.Sprintf("bgm:%d", media.BangumiID) return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID))
} }
if fromPath != "" { if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) {
return seriesFingerprint("library-path", media.LibraryID, fromPath) return seriesFingerprint("library-path", media.LibraryID, fromPath)
} }
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title)) return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
+90
View File
@@ -0,0 +1,90 @@
package service
import (
"fmt"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
type mediaSeriesKeyResolver struct {
pathCounts map[string]int
externalCounts map[string]int
titleCounts map[string]int
}
func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
resolver := mediaSeriesKeyResolver{
pathCounts: make(map[string]int),
externalCounts: make(map[string]int),
titleCounts: make(map[string]int),
}
for _, item := range items {
if !mediaLooksEpisodicForGrouping(item) {
continue
}
if key := mediaSeriesRawKey(item); strings.HasPrefix(key, "library-path") {
resolver.pathCounts[key]++
}
if key := repeatedSeriesExternalKey(item); key != "" {
resolver.externalCounts[key]++
}
if key := repeatedSeriesTitleKey(item); key != "" {
resolver.titleCounts[key]++
}
}
return resolver
}
func (r mediaSeriesKeyResolver) key(media model.Media) string {
if mediaLooksEpisodicForGrouping(media) {
if key := mediaSeriesRawKey(media); strings.HasPrefix(key, "library-path") && r.pathCounts[key] > 1 {
return compactSeriesKey(key)
}
if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 {
return compactSeriesKey(key)
}
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
return compactSeriesKey(key)
}
}
return mediaSeriesKey(media)
}
func mediaLooksEpisodicForGrouping(media model.Media) bool {
return media.SeasonNum > 0 || media.EpisodeNum > 0 ||
episodicPathRE.MatchString(media.Path+" "+media.DisplayLibraryPath+" "+media.LibraryPath)
}
func repeatedSeriesExternalKey(media model.Media) string {
identity := ""
switch {
case media.TMDbID > 0:
identity = fmt.Sprintf("tmdb:%d", media.TMDbID)
case media.BangumiID > 0:
identity = fmt.Sprintf("bgm:%d", media.BangumiID)
case strings.TrimSpace(media.DoubanID) != "":
identity = "douban:" + strings.TrimSpace(media.DoubanID)
case strings.TrimSpace(media.TheTVDBID) != "":
identity = "thetvdb:" + strings.TrimSpace(media.TheTVDBID)
}
if identity == "" {
return ""
}
return seriesFingerprint("library-external", mediaTargetLibraryID(media), identity)
}
func repeatedSeriesTitleKey(media model.Media) string {
if !strings.EqualFold(strings.TrimSpace(media.ScrapeStatus), "matched") {
return ""
}
title := strings.TrimSpace(firstNonEmpty(media.Title, media.OriginalName))
if title == "" || unsafeAutomaticEpisodeQuery(title) || organizeMediaTitleLooksLikeRelease(title) {
return ""
}
title = normalizeSeriesTitle(title)
if title == "" {
return ""
}
return seriesFingerprint("library-title-year", mediaTargetLibraryID(media), title, fmt.Sprint(media.Year))
}
+79
View File
@@ -344,3 +344,82 @@ func TestMediaSeriesKeyUsesSeriesDirectoryExternalID(t *testing.T) {
t.Fatalf("episode filename tmdb id should not split clean folder key=%q, want %q", got, want) t.Fatalf("episode filename tmdb id should not split clean folder key=%q, want %q", got, want)
} }
} }
func TestGroupMediaSeriesCardsMergesPollutedEpisodeFoldersBySharedShowID(t *testing.T) {
items := []model.Media{
{
LibraryID: "lib-variety",
Title: "脱口秀和Ta的朋友们",
Path: `F:\media\电视剧\综艺\脱口秀和Ta的朋友们 第01期\Season 01\show.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
TMDbID: 260001,
ScrapeStatus: "matched",
},
{
LibraryID: "lib-variety",
Title: "脱口秀和Ta的朋友们",
Path: `F:\media\电视剧\综艺\脱口秀和Ta的朋友们 第02期\Season 01\show.S01E02.mkv`,
SeasonNum: 1,
EpisodeNum: 2,
TMDbID: 260001,
ScrapeStatus: "matched",
},
}
cards := groupMediaSeriesCards(items)
if len(cards) != 1 || cards[0].Count != 2 {
t.Fatalf("cards=%#v, want one show card with two episodes", cards)
}
}
func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
items := []model.Media{
{
Base: model.Base{ID: "movie-copy-a"},
LibraryID: "foreign-movies",
Title: "杀的就是你",
Path: `F:\media\电影\外语电影\They Will Kill You (2026)\movie-a.mkv`,
TMDbID: 1292695,
},
{
Base: model.Base{ID: "movie-copy-b"},
LibraryID: "western-movies",
Title: "杀的就是你",
Path: `F:\media\电影\欧美电影\They Will Kill You (2026)\movie-b.mkv`,
TMDbID: 1292695,
},
}
cards := groupMediaSeriesCards(items)
if len(cards) != 1 {
t.Fatalf("cards=%#v, want duplicate movie locations folded into one card", cards)
}
if cards[0].Count != 1 {
t.Fatalf("movie card count=%d, want 1 so versions are not shown as episodes", cards[0].Count)
}
}
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
movie := model.Media{
Base: model.Base{ID: "movie"},
LibraryID: "movies",
Title: "同号电影",
Path: `/media/电影/同号电影 (2026)/movie.mkv`,
TMDbID: 12345,
}
episode := model.Media{
Base: model.Base{ID: "episode"},
LibraryID: "tv",
Title: "同号剧集",
Path: `/media/tv/同号剧集/episode.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
TMDbID: 12345,
}
cards := groupMediaSeriesCards([]model.Media{movie, episode})
if len(cards) != 2 {
t.Fatalf("cards=%#v, want movie and TV item kept separate despite equal numeric TMDb IDs", cards)
}
}
+13 -3
View File
@@ -15,7 +15,10 @@ func automaticMetadataTitleTrusted(query string, match *Match) bool {
if queryKey == "" || match == nil { if queryKey == "" || match == nil {
return false return false
} }
for _, title := range []string{match.Title, match.OriginalName} { titles := make([]string, 0, 2+len(match.Aliases))
titles = append(titles, match.Title, match.OriginalName)
titles = append(titles, match.Aliases...)
for _, title := range titles {
titleKey := metadataTrustKey(title) titleKey := metadataTrustKey(title)
if titleKey == "" { if titleKey == "" {
continue continue
@@ -90,8 +93,15 @@ func metadataTrustLocalizedSearchKeyword(queryKey string, match *Match) bool {
if match == nil || !metadataMatchHasExternalID(match) { if match == nil || !metadataMatchHasExternalID(match) {
return false return false
} }
searchKey := metadataTrustKey(match.SearchKeyword) if !metadataTrustStrongCJKQuery(queryKey) {
return searchKey != "" && searchKey == queryKey && metadataTrustStrongCJKQuery(queryKey) return false
}
for _, alias := range match.Aliases {
if aliasKey := metadataTrustKey(alias); aliasKey != "" && aliasKey == queryKey {
return true
}
}
return false
} }
func preferLocalizedSearchTitle(query string, match *Match) { func preferLocalizedSearchTitle(query string, match *Match) {
+1
View File
@@ -35,6 +35,7 @@ func TestOrganizeNaming(t *testing.T) {
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0}, {"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0},
{"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0}, {"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0},
{"Big.Buck.Bunny.2008.1080p.CodexVerify.mp4", "Big Buck Bunny", 2008, 0, 0}, {"Big.Buck.Bunny.2008.1080p.CodexVerify.mp4", "Big Buck Bunny", 2008, 0, 0},
{"Pressure.2026.2155p.iT.WEB-DL.HEVC.mkv", "Pressure", 2026, 0, 0},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.file, func(t *testing.T) { t.Run(tc.file, func(t *testing.T) {
@@ -324,7 +324,7 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) {
filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"), filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"),
filepath.Join(dest, "电影", "欧美电影", "Dune (2021)", "Dune (2021).mkv"), filepath.Join(dest, "电影", "欧美电影", "Dune (2021)", "Dune (2021).mkv"),
filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"), filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"),
filepath.Join(dest, "电视剧", "欧美剧", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"), filepath.Join(dest, "电视剧", "未分类", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"),
} { } {
if _, err := os.Stat(want); err != nil { if _, err := os.Stat(want); err != nil {
t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items) t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items)
@@ -156,8 +156,8 @@ func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectory
addConfigured("variety", "综艺", "variety") addConfigured("variety", "综艺", "variety")
addConfigured("documentary", "纪录片", "tv") addConfigured("documentary", "纪录片", "tv")
addConfigured("children", "儿童", "tv") addConfigured("children", "儿童", "tv")
addAlias("未分类", "euus_tv", "欧美剧", "tv") addConfigured("unclassified_tv", "未分类", "tv")
addAlias("uncategorized", "euus_tv", "欧美剧", "tv") addAlias("uncategorized", "unclassified_tv", "未分类", "tv")
addConfigured("adult", "成人", "adult") addConfigured("adult", "成人", "adult")
addAlias("9KG", "adult", "成人", "adult") addAlias("9KG", "adult", "成人", "adult")
addAlias("番号", "adult", "成人", "adult") addAlias("番号", "adult", "成人", "adult")
@@ -68,6 +68,8 @@ func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) m
add("纪录片", categoryName(categories, "documentary", "纪录片")) add("纪录片", categoryName(categories, "documentary", "纪录片"))
case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿": case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿":
add("儿童", categoryName(categories, "children", "儿童")) add("儿童", categoryName(categories, "children", "儿童"))
case normalizeOrganizeCategoryKey(categoryName(categories, "unclassified_tv", "未分类")), "未分类", "uncategorized":
add("未分类", "uncategorized", categoryName(categories, "unclassified_tv", "未分类"))
case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影": case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影":
add("华语电影", categoryName(categories, "chinese_movie", "华语电影")) add("华语电影", categoryName(categories, "chinese_movie", "华语电影"))
case normalizeOrganizeCategoryKey(categoryName(categories, "euus_movie", "欧美电影")), "欧美电影", "外语电影", "外国电影": case normalizeOrganizeCategoryKey(categoryName(categories, "euus_movie", "欧美电影")), "欧美电影", "外语电影", "外国电影":
@@ -0,0 +1,100 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestOrganizeDirectoryUsesTMDbChineseAlternativeTitle(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/search/tv"):
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 7583,
"name": "The Rookie",
"original_name": "The Rookie",
"original_language": "en",
"first_air_date": "2007-03-26",
}},
})
case r.URL.Path == "/tv/7583":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 7583,
"name": "The Rookie",
"original_name": "The Rookie",
"original_language": "en",
"first_air_date": "2007-03-26",
"origin_country": []string{"US"},
"alternative_titles": map[string]any{
"results": []map[string]any{{
"iso_3166_1": "CN",
"title": "菜鸟老警",
}},
},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &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()
tmdb := NewTMDbProvider(cfg, log, nil)
scraper := NewScraperService(cfg, log, repos, tmdb, nil, nil, nil, NewHub(log))
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(src, "The.Rookie.2007.S04E01.1080p.WEB-DL.mkv")
writeOrgFile(t, sourceFile, "episode")
organizer := NewOrganizerService(cfg, log, repos)
organizer.SetScraper(scraper)
result, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
TransferMode: TransferCopy,
MediaType: "tv",
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电视剧", "菜鸟老警", "Season 04", "菜鸟老警 - S04E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized file should use TMDb Chinese alternative title %q: %v; items=%#v", want, err, result.Items)
}
var stored model.Media
if err := repos.DB.First(&stored, "path = ?", want).Error; err != nil {
t.Fatal(err)
}
if stored.Title != "菜鸟老警" || stored.OriginalName != "The Rookie" || stored.TMDbID != 7583 {
t.Fatalf("stored title=%q original=%q tmdb=%d, want localized Chinese metadata", stored.Title, stored.OriginalName, stored.TMDbID)
}
}
@@ -147,6 +147,11 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
category := overrideCategory category := overrideCategory
if category == "" { if category == "" {
category = o.classifyMedia(ctx, &media, mediaType) category = o.classifyMedia(ctx, &media, mediaType)
if isUnclassifiedTVCategory(category, o.categoryMap()) && !mediaHasReliableCategoryMetadata(media) {
if existingType, existingCategory := o.mediaTypeForDirectoryCategory(firstNonEmpty(lib.Name, filepath.Base(lib.Path))); existingType != "" && sourceCategoryCompatible(mediaType, existingType) {
category = existingCategory
}
}
} }
if category == "" { if category == "" {
return false, nil return false, nil
+4 -2
View File
@@ -93,8 +93,10 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
if local != nil && !local.PathHint { if local != nil && !local.PathHint {
return s.applyLocalMetadataMatch(ctx, m, local) return s.applyLocalMetadataMatch(ctx, m, local)
} }
_ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID). if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
Update("scrape_status", "no_match").Error Update("scrape_status", "no_match").Error; err != nil {
return err
}
s.invalidateMediaCache(ctx) s.invalidateMediaCache(ctx)
s.log.Info("metadata scrape no match", s.log.Info("metadata scrape no match",
zap.String("media_id", m.ID), zap.String("media_id", m.ID),
@@ -0,0 +1,133 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func TestEnrichOneUsesAlternateLanguageTitleAndKeepsLocalizedMetadata(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/tv" {
http.NotFound(w, r)
return
}
switch r.URL.Query().Get("language") {
case "en-US":
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
{
"id": 292696, "name": "The First Jasmine", "original_name": "The First Jasmine",
"first_air_date": "2026-01-01", "origin_country": []string{"CN"},
},
}})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
{
"id": 999001, "name": "First Love", "original_name": "First Love",
"first_air_date": "2026-01-01", "origin_country": []string{"US"},
},
{
"id": 292696, "name": "莫离", "original_name": "莫离",
"first_air_date": "2026-01-01", "origin_country": []string{"CN"},
"poster_path": "/jasmine.jpg",
},
}})
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
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/电视剧/未分类`, Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "The First Jasmine",
Path: `/media/电视剧/未分类/The.First.Jasmine.S01E01.2026.mkv`,
Year: 2026,
SeasonNum: 1,
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)
}
got, err := repos.Media.FindByID(t.Context(), media.ID)
if err != nil || got == nil {
t.Fatalf("load media: %v", err)
}
if got.TMDbID != 292696 || got.Title != "莫离" || got.ScrapeStatus != "matched" {
t.Fatalf("matched media=%+v, want localized correct TMDb result", got)
}
if got.SeasonNum != 1 || got.EpisodeNum != 1 {
t.Fatalf("episode markers changed after TV match: season=%d episode=%d", got.SeasonNum, got.EpisodeNum)
}
}
func TestEnrichOneDoesNotFallbackTVEpisodeToMovie(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
case "/search/movie":
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
{"id": 880001, "title": "Unsettled Case", "original_title": "Unsettled Case", "release_date": "2026-01-01"},
}})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
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/电视剧/欧美剧`, Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID, Title: "Unsettled Case", Path: `/media/电视剧/欧美剧/Unsettled.Case.S01E04.mkv`,
SeasonNum: 1, EpisodeNum: 4, 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)
}
got, err := repos.Media.FindByID(t.Context(), media.ID)
if err != nil || got == nil {
t.Fatalf("load media: %v", err)
}
if got.ScrapeStatus != "no_match" || got.TMDbID != 0 {
t.Fatalf("TV episode incorrectly accepted movie metadata: %+v", got)
}
if got.SeasonNum != 1 || got.EpisodeNum != 4 {
t.Fatalf("TV episode collection state was cleared: season=%d episode=%d", got.SeasonNum, got.EpisodeNum)
}
}
@@ -0,0 +1,244 @@
package service
import (
"context"
"strings"
"go.uber.org/zap"
)
func (s *ScraperService) lookupAutomaticTMDb(ctx context.Context, kind, query string, year int) *Match {
var (
candidates []*Match
err error
mediaLabel string
)
if isTVMetadataKind(kind) {
mediaLabel = "tv"
candidates, err = s.tmdb.SearchTVCandidates(ctx, query, year)
} else {
mediaLabel = "movie"
candidates, err = s.tmdb.SearchMovieCandidates(ctx, query, year)
}
if err != nil {
s.log.Debug("tmdb "+mediaLabel+" search failed", zap.String("query", query), zap.Error(err))
return nil
}
if match := bestAutomaticMetadataMatch(query, year, kind, candidates); match != nil {
return s.localizeAutomaticTMDbMatch(ctx, kind, query, match)
}
if !queryNeedsEnglishTMDbFallback(query) {
return nil
}
var alternate []*Match
if isTVMetadataKind(kind) {
alternate, err = s.tmdb.searchTVCandidates(ctx, query, year, "en-US")
} else {
alternate, err = s.tmdb.searchMovieCandidates(ctx, query, year, "en-US")
}
if err != nil {
s.log.Debug("tmdb "+mediaLabel+" alternate-language search failed", zap.String("query", query), zap.Error(err))
return nil
}
match := bestAutomaticMetadataMatch(query, year, kind, mergeTMDbLanguageCandidates(candidates, alternate))
return s.localizeAutomaticTMDbMatch(ctx, kind, query, match)
}
func (s *ScraperService) localizeAutomaticTMDbMatch(ctx context.Context, kind, query string, match *Match) *Match {
if s == nil || s.tmdb == nil || match == nil || match.TMDbID <= 0 || !metadataTitleNeedsChineseLocalization(match) {
return match
}
var (
localized *Match
err error
)
if isTVMetadataKind(kind) {
localized, err = s.tmdb.GetTVMatch(ctx, match.TMDbID)
} else {
localized, err = s.tmdb.GetMovieMatch(ctx, match.TMDbID)
}
if err != nil || localized == nil {
if err != nil && s.log != nil {
s.log.Debug("tmdb localized title lookup failed",
zap.String("query", query),
zap.Int("tmdb_id", match.TMDbID),
zap.Error(err))
}
return match
}
mergeAutomaticTMDbLocalizedMatch(localized, match)
localized.SearchKeyword = query
localized.Aliases = appendMetadataAliases(localized.Aliases,
match.Title, match.OriginalName)
localized.Aliases = appendMetadataAliases(localized.Aliases, match.Aliases...)
return localized
}
func mergeAutomaticTMDbLocalizedMatch(localized, search *Match) {
if localized == nil || search == nil {
return
}
if strings.TrimSpace(localized.OriginalName) == "" {
localized.OriginalName = strings.TrimSpace(firstNonEmpty(search.OriginalName, search.Title))
}
localized.Overview = firstNonEmpty(localized.Overview, search.Overview)
localized.PosterURL = firstNonEmpty(localized.PosterURL, search.PosterURL)
localized.BackdropURL = firstNonEmpty(localized.BackdropURL, search.BackdropURL)
localized.MediaType = firstNonEmpty(localized.MediaType, search.MediaType)
if localized.Year <= 0 {
localized.Year = search.Year
}
if localized.ReleaseDate == "" {
localized.ReleaseDate = search.ReleaseDate
}
if localized.Rating <= 0 {
localized.Rating = search.Rating
}
if len(localized.Languages) == 0 {
localized.Languages = append([]string(nil), search.Languages...)
}
if len(localized.Countries) == 0 {
localized.Countries = append([]string(nil), search.Countries...)
}
if len(localized.Genres) == 0 {
localized.Genres = append([]string(nil), search.Genres...)
}
}
func bestAutomaticMetadataMatch(query string, year int, expectedType string, candidates []*Match) *Match {
bestScore := -1
var best *Match
for index, candidate := range candidates {
if candidate == nil || !metadataMatchCompatibleWithType(expectedType, candidate) {
continue
}
if !organizeMetadataMatchTrusted(query, year, candidate) {
continue
}
score := automaticMetadataMatchScore(query, year, candidate) - index
if score > bestScore {
bestScore = score
best = candidate
}
}
return best
}
func automaticMetadataMatchScore(query string, year int, match *Match) int {
queryKey := metadataTrustKey(query)
score := 0
titles := make([]string, 0, 2+len(match.Aliases))
titles = append(titles, match.Title, match.OriginalName)
titles = append(titles, match.Aliases...)
for _, title := range titles {
titleKey := metadataTrustKey(title)
switch {
case titleKey != "" && titleKey == queryKey:
if score < 1000 {
score = 1000
}
case metadataTrustTokenOverlap(queryKey, titleKey):
if score < 700 {
score = 700
}
}
}
if year > 0 && match.Year > 0 {
diff := year - match.Year
if diff < 0 {
diff = -diff
}
switch diff {
case 0:
score += 200
case 1:
score += 100
}
}
if metadataMatchHasExternalID(match) {
score += 20
}
if strings.TrimSpace(match.PosterURL) != "" {
score += 5
}
return score
}
func metadataMatchCompatibleWithType(expectedType string, match *Match) bool {
if match == nil {
return false
}
expectedType = normalizeOrganizeMediaType(expectedType)
matchType := normalizeOrganizeMediaType(match.MediaType)
if expectedType == "" || matchType == "" {
return true
}
switch expectedType {
case "tv", "anime", "variety":
return matchType == "tv" || matchType == "anime" || matchType == "variety"
case "movie", "adult":
return matchType == "movie" || matchType == "adult"
default:
return expectedType == matchType
}
}
func queryNeedsEnglishTMDbFallback(query string) bool {
for _, r := range query {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return true
}
}
return false
}
func mergeTMDbLanguageCandidates(primary, alternate []*Match) []*Match {
if len(primary) == 0 {
return alternate
}
byID := make(map[int]*Match, len(primary))
out := append([]*Match(nil), primary...)
for _, candidate := range primary {
if candidate != nil && candidate.TMDbID > 0 {
byID[candidate.TMDbID] = candidate
}
}
for _, candidate := range alternate {
if candidate == nil {
continue
}
if localized := byID[candidate.TMDbID]; localized != nil {
localized.Aliases = appendMetadataAliases(localized.Aliases,
candidate.Title, candidate.OriginalName)
localized.Aliases = appendMetadataAliases(localized.Aliases, candidate.Aliases...)
continue
}
out = append(out, candidate)
}
return out
}
func appendMetadataAliases(existing []string, values ...string) []string {
seen := make(map[string]struct{}, len(existing)+len(values))
out := make([]string, 0, len(existing)+len(values))
add := func(value string) {
value = strings.TrimSpace(value)
key := metadataTrustKey(value)
if value == "" || key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, value)
}
for _, value := range existing {
add(value)
}
for _, value := range values {
add(value)
}
return out
}
+19 -16
View File
@@ -18,40 +18,34 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
if lib != nil { if lib != nil {
kind = lib.Type kind = lib.Type
} }
if mediaIsEpisodic(media, lib) { // An explicit movie lookup is used to repair filenames whose release year
// was previously polluted into S01E20x. Do not let the dirty path override
// that caller-supplied media type; TV/anime libraries still force TV below.
explicitEpisode := media != nil && (media.SeasonNum > 0 || media.EpisodeNum > 0)
if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) {
kind = "tv" kind = "tv"
} }
if s.tmdb != nil && s.tmdb.Enabled() { if s.tmdb != nil && s.tmdb.Enabled() {
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。 if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" { return match
if m, err := s.tmdb.SearchTV(ctx, query, year); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("tmdb tv search failed", zap.String("query", query), zap.Error(err))
}
}
if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err))
} }
} }
if s.douban != nil && s.douban.Enabled() { if s.douban != nil && s.douban.Enabled() {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil { if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
return m return m
} else if err != nil { } else if err != nil {
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err)) s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
} }
} }
if s.bangumi != nil && s.bangumi.Enabled() { if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil { if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
return m return m
} else if err != nil { } else if err != nil {
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err)) s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
} }
} }
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() { if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil { if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) {
return m return m
} else if err != nil { } else if err != nil {
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err)) s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
@@ -60,6 +54,15 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *
return nil return nil
} }
func isTVMetadataKind(kind string) bool {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "anime", "tv", "variety", "show", "shows":
return true
default:
return false
}
}
// EnrichLibrary runs the provider chain for every pending media in a library. // EnrichLibrary runs the provider chain for every pending media in a library.
// When retryNoMatch is true it also retries rows previously marked no_match, // When retryNoMatch is true it also retries rows previously marked no_match,
// which is the expected behaviour for a manual "重新刮削" action. Scanner-driven // which is the expected behaviour for a manual "重新刮削" action. Scanner-driven
+6
View File
@@ -55,6 +55,8 @@ var releaseBoundaryTokenSet = map[string]struct{}{
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {}, "x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {},
} }
var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`)
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments. // bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`) var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
var multiWordNoise = []*regexp.Regexp{ var multiWordNoise = []*regexp.Regexp{
@@ -105,6 +107,10 @@ func CleanQuery(raw string) (title string, year int) {
out := make([]string, 0, 8) out := make([]string, 0, 8)
seenReleaseBoundary := false seenReleaseBoundary := false
for _, w := range strings.Fields(lower) { for _, w := range strings.Fields(lower) {
if dynamicReleaseBoundaryTokenRE.MatchString(w) {
seenReleaseBoundary = true
continue
}
if _, ok := noiseTokenSet[w]; ok { if _, ok := noiseTokenSet[w]; ok {
if _, boundary := releaseBoundaryTokenSet[w]; boundary { if _, boundary := releaseBoundaryTokenSet[w]; boundary {
seenReleaseBoundary = true seenReleaseBoundary = true
@@ -60,6 +60,29 @@ func TestScrapeQueryCandidatesUseMovieLibraryRootWhenMountedAtMovieFolder(t *tes
} }
} }
func TestScrapeQueryCandidatesDoNotUseMovieCollectionFolderAsTitle(t *testing.T) {
lib := &model.Library{Path: `/media/movies`, Type: "movie"}
media := &model.Media{
Title: "The Hunger Games Catching Fire",
Year: 2013,
Path: `/media/movies/The.Hunger.Games.Complete.4-Film.Collection/` +
`The.Hunger.Games.Catching.Fire.2013.2160p.mkv`,
}
got := scrapeQueryCandidates(media, lib)
if len(got) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates")
}
if got[0] != "the hunger games catching fire" {
t.Fatalf("first query candidate = %q, want individual movie title; all candidates=%#v", got[0], got)
}
for _, candidate := range got {
if strings.Contains(strings.ToLower(candidate), "complete 4 film collection") {
t.Fatalf("movie collection folder leaked into scrape candidates: %#v", got)
}
}
}
func TestEnrichOneUsesMovieFolderWhenFilenameIsGeneric(t *testing.T) { func TestEnrichOneUsesMovieFolderWhenFilenameIsGeneric(t *testing.T) {
var queries []string var queries []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+5 -1
View File
@@ -17,6 +17,10 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string {
dir = parentSlashPath(dir) dir = parentSlashPath(dir)
continue continue
} }
if isMediaCollectionFolder(base) {
dir = parentSlashPath(dir)
continue
}
if isGenericMediaCategoryFolder(base) { if isGenericMediaCategoryFolder(base) {
return "" return ""
} }
@@ -114,7 +118,7 @@ func libraryRootTitle(libraryRoot string) string {
} else { } else {
base = pathBaseSlash(libraryRoot) base = pathBaseSlash(libraryRoot)
} }
if base == "" || base == "." || isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { if base == "" || base == "." || isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isMediaCollectionFolder(base) {
return "" return ""
} }
return base return base
+15 -6
View File
@@ -53,7 +53,7 @@ func TestOrganizeMetadataRejectsLooseChineseOriginEnglishAlias(t *testing.T) {
} }
} }
func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) { func TestOrganizeMetadataRejectsUnverifiedLocalizedSearchKeyword(t *testing.T) {
match := &Match{ match := &Match{
Title: "Monarch: Legacy of Monsters", Title: "Monarch: Legacy of Monsters",
OriginalName: "Monarch: Legacy of Monsters", OriginalName: "Monarch: Legacy of Monsters",
@@ -61,12 +61,21 @@ func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) {
TMDbID: 202411, TMDbID: 202411,
SearchKeyword: "帝王计划:怪兽遗产", SearchKeyword: "帝王计划:怪兽遗产",
} }
if !organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) { if organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) {
t.Fatal("localized TMDb search keyword should be trusted even when returned title is not localized") t.Fatal("echoing the search keyword must not make an unrelated first result trustworthy")
} }
preferLocalizedSearchTitle("帝王计划:怪兽遗产", match) }
if match.Title != "帝王计划:怪兽遗产" || match.OriginalName != "Monarch: Legacy of Monsters" {
t.Fatalf("localized title not preserved: title=%q original=%q", match.Title, match.OriginalName) func TestOrganizeMetadataTrustsProviderReturnedAlias(t *testing.T) {
match := &Match{
Title: "镖人",
OriginalName: "镖人",
Aliases: []string{"Blades of the Guardians"},
Year: 2023,
TMDbID: 107463,
}
if !organizeMetadataMatchTrusted("blades of the guardians", 2023, match) {
t.Fatal("a title returned by another TMDb language response should be trusted as an alias")
} }
} }
+36
View File
@@ -1,13 +1,49 @@
package service package service
import ( import (
"errors"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"gorm.io/gorm"
) )
func TestEnrichOneReturnsNoMatchPersistenceError(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
lib := model.Library{Name: "电影", Path: t.TempDir(), Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Definitely Missing Metadata Candidate",
Path: filepath.Join(lib.Path, "missing.mkv"),
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
wantErr := errors.New("forced no-match update failure")
callbackName := "test:fail-no-match-update"
if err := repos.DB.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Table == "media" {
tx.AddError(wantErr)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repos.DB.Callback().Update().Remove(callbackName) })
if err := scraper.EnrichOne(t.Context(), &media); !errors.Is(err, wantErr) {
t.Fatalf("EnrichOne() error=%v, want %v", err, wantErr)
}
}
func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) { func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t) scraper, repos, closeServer := newTestScraper(t)
defer closeServer() defer closeServer()
+84
View File
@@ -0,0 +1,84 @@
package service
import "strings"
type tmdbAlternativeTitle struct {
Country string `json:"iso_3166_1"`
Title string `json:"title"`
}
type tmdbTranslation struct {
Country string `json:"iso_3166_1"`
Language string `json:"iso_639_1"`
Data struct {
Title string `json:"title"`
Name string `json:"name"`
} `json:"data"`
}
func applyTMDbChineseTitle(match *Match, alternatives []tmdbAlternativeTitle, translations []tmdbTranslation) {
if match == nil {
return
}
aliases := make([]string, 0, 2+len(alternatives)+len(translations))
aliases = append(aliases, match.Title, match.OriginalName)
for _, alternative := range alternatives {
aliases = append(aliases, alternative.Title)
}
for _, translation := range translations {
aliases = append(aliases, firstNonEmpty(translation.Data.Title, translation.Data.Name))
}
match.Aliases = appendMetadataAliases(match.Aliases, aliases...)
if !metadataTitleNeedsChineseLocalization(match) {
return
}
localized := preferredTMDbChineseTitle(alternatives, translations)
if localized == "" {
return
}
if strings.TrimSpace(match.OriginalName) == "" {
match.OriginalName = strings.TrimSpace(match.Title)
}
match.Title = localized
}
func preferredTMDbChineseTitle(alternatives []tmdbAlternativeTitle, translations []tmdbTranslation) string {
for _, country := range []string{"CN", "SG", "HK", "TW"} {
for _, alternative := range alternatives {
if strings.EqualFold(strings.TrimSpace(alternative.Country), country) && chineseTitleCandidate(alternative.Title) {
return strings.TrimSpace(alternative.Title)
}
}
for _, translation := range translations {
if !strings.EqualFold(strings.TrimSpace(translation.Language), "zh") ||
!strings.EqualFold(strings.TrimSpace(translation.Country), country) {
continue
}
if title := strings.TrimSpace(firstNonEmpty(translation.Data.Title, translation.Data.Name)); chineseTitleCandidate(title) {
return title
}
}
}
return ""
}
func metadataTitleNeedsChineseLocalization(match *Match) bool {
if match == nil || strings.TrimSpace(match.Title) == "" {
return true
}
if !containsCJK(match.Title) {
return true
}
for _, r := range match.Title {
if (r >= '\u3040' && r <= '\u30ff') || (r >= '\uac00' && r <= '\ud7af') {
return true
}
}
return false
}
func chineseTitleCandidate(title string) bool {
title = strings.TrimSpace(title)
return title != "" && containsCJK(title)
}
@@ -0,0 +1,75 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
func TestApplyTMDbChineseTitlePrefersMainlandAlternative(t *testing.T) {
match := &Match{Title: "The Rookie", OriginalName: "The Rookie"}
applyTMDbChineseTitle(match, []tmdbAlternativeTitle{
{Country: "TW", Title: "菜鳥新移民"},
{Country: "CN", Title: "菜鸟老警"},
}, nil)
if match.Title != "菜鸟老警" || match.OriginalName != "The Rookie" {
t.Fatalf("title=%q original=%q, want mainland Chinese title with original preserved", match.Title, match.OriginalName)
}
}
func TestApplyTMDbChineseTitleFallsBackToSingaporeTranslation(t *testing.T) {
match := &Match{Title: "English Title", OriginalName: "English Title"}
translation := tmdbTranslation{Country: "SG", Language: "zh"}
translation.Data.Name = "新加坡中文名"
applyTMDbChineseTitle(match, nil, []tmdbTranslation{translation})
if match.Title != "新加坡中文名" {
t.Fatalf("title=%q, want Singapore Chinese translation", match.Title)
}
}
func TestGetMovieMatchUsesChineseAlternativeTitle(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/1292695" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("append_to_response"); !strings.Contains(got, "alternative_titles") {
t.Fatalf("append_to_response=%q, want alternative_titles", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1292695,
"title": "They Will Kill You",
"original_title": "They Will Kill You",
"original_language": "en",
"release_date": "2026-03-27",
"alternative_titles": map[string]any{
"titles": []map[string]any{{
"iso_3166_1": "CN",
"title": "杀的就是你",
}},
},
})
}))
defer upstream.Close()
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
provider := NewTMDbProvider(cfg, zap.NewNop(), nil)
match, err := provider.GetMovieMatch(t.Context(), 1292695)
if err != nil {
t.Fatal(err)
}
if match == nil || match.Title != "杀的就是你" || match.OriginalName != "They Will Kill You" {
t.Fatalf("match=%#v, want Chinese movie title with English original", match)
}
}
+16
View File
@@ -18,6 +18,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
q := url.Values{} q := url.Values{}
q.Set("api_key", apiKey) q.Set("api_key", apiKey)
q.Set("language", "zh-CN") q.Set("language", "zh-CN")
q.Set("append_to_response", "alternative_titles,translations")
u := base + "/movie/" + fmt.Sprint(tmdbID) + "?" + q.Encode() u := base + "/movie/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct { var r struct {
ID int `json:"id"` ID int `json:"id"`
@@ -38,6 +39,12 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
SpokenLanguages []struct { SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"` Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"` } `json:"spoken_languages"`
AlternativeTitles struct {
Titles []tmdbAlternativeTitle `json:"titles"`
} `json:"alternative_titles"`
Translations struct {
Translations []tmdbTranslation `json:"translations"`
} `json:"translations"`
} }
if err := t.getJSON(ctx, u, &r); err != nil { if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err return nil, err
@@ -54,6 +61,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
if m.Title == "" { if m.Title == "" {
m.Title = r.OriginalTitle m.Title = r.OriginalTitle
} }
applyTMDbChineseTitle(m, r.AlternativeTitles.Titles, r.Translations.Translations)
if r.PosterPath != "" { if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
} }
@@ -91,6 +99,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
q := url.Values{} q := url.Values{}
q.Set("api_key", apiKey) q.Set("api_key", apiKey)
q.Set("language", "zh-CN") q.Set("language", "zh-CN")
q.Set("append_to_response", "alternative_titles,translations")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode() u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct { var r struct {
ID int `json:"id"` ID int `json:"id"`
@@ -109,6 +118,12 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
SpokenLanguages []struct { SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"` Iso639_1 string `json:"iso_639_1"`
} `json:"spoken_languages"` } `json:"spoken_languages"`
AlternativeTitles struct {
Results []tmdbAlternativeTitle `json:"results"`
} `json:"alternative_titles"`
Translations struct {
Translations []tmdbTranslation `json:"translations"`
} `json:"translations"`
} }
if err := t.getJSON(ctx, u, &r); err != nil { if err := t.getJSON(ctx, u, &r); err != nil {
return nil, err return nil, err
@@ -126,6 +141,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
if m.Title == "" { if m.Title == "" {
m.Title = r.OriginalName m.Title = r.OriginalName
} }
applyTMDbChineseTitle(m, r.AlternativeTitles.Results, r.Translations.Translations)
if r.PosterPath != "" { if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
} }
+12 -5
View File
@@ -44,10 +44,13 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
return matches[0], nil return matches[0], nil
} }
// SearchMovieCandidates returns the first TMDb result page as manual-scrape // SearchMovieCandidates returns the first localized TMDb result page. Manual
// candidates. Automatic scrape still uses SearchMovie's first-result behavior, // scrape exposes the full page; automatic scrape ranks the same candidates.
// 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) { func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
return t.searchMovieCandidates(ctx, query, year, "zh-CN")
}
func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string, year int, language string) ([]*Match, error) {
if query == "" { if query == "" {
return nil, errors.New("empty query") return nil, errors.New("empty query")
} }
@@ -61,7 +64,7 @@ func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string,
q := url.Values{} q := url.Values{}
q.Set("api_key", apiKey) q.Set("api_key", apiKey)
q.Set("query", query) q.Set("query", query)
q.Set("language", "zh-CN") q.Set("language", language)
q.Set("include_adult", "false") q.Set("include_adult", "false")
if year > 0 { if year > 0 {
q.Set("year", fmt.Sprintf("%d", year)) q.Set("year", fmt.Sprintf("%d", year))
@@ -124,6 +127,10 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
// SearchTVCandidates returns the first TMDb TV result page for manual scrape. // SearchTVCandidates returns the first TMDb TV result page for manual scrape.
func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, year int) ([]*Match, error) { func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, year int) ([]*Match, error) {
return t.searchTVCandidates(ctx, query, year, "zh-CN")
}
func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, year int, language string) ([]*Match, error) {
if query == "" { if query == "" {
return nil, errors.New("empty query") return nil, errors.New("empty query")
} }
@@ -137,7 +144,7 @@ func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, yea
q := url.Values{} q := url.Values{}
q.Set("api_key", apiKey) q.Set("api_key", apiKey)
q.Set("query", query) q.Set("query", query)
q.Set("language", "zh-CN") q.Set("language", language)
q.Set("include_adult", "false") q.Set("include_adult", "false")
if year > 0 { if year > 0 {
q.Set("first_air_date_year", fmt.Sprintf("%d", year)) q.Set("first_air_date_year", fmt.Sprintf("%d", year))
+1
View File
@@ -20,6 +20,7 @@ type Match struct {
Languages []string `json:"languages,omitempty"` Languages []string `json:"languages,omitempty"`
Countries []string `json:"countries,omitempty"` Countries []string `json:"countries,omitempty"`
Genres []string `json:"genres,omitempty"` Genres []string `json:"genres,omitempty"`
Aliases []string `json:"aliases,omitempty"`
NSFW bool `json:"nsfw,omitempty"` NSFW bool `json:"nsfw,omitempty"`
SearchKeyword string `json:"-"` SearchKeyword string `json:"-"`
} }
+65 -9
View File
@@ -44,17 +44,19 @@ function getSeriesRawKey(media: Media): string {
const pathID = seriesExternalIDFromPath(media.path) const pathID = seriesExternalIDFromPath(media.path)
if (pathID) return seriesFingerprint('library-path-id', targetLibraryID(media), pathID) if (pathID) return seriesFingerprint('library-path-id', targetLibraryID(media), pathID)
// 无路径剧名(扁平目录)时才退而用外部 id;此时各集若共享整剧 id 仍能合并。 // 无路径剧名(扁平目录)时才退而用外部 id;此时各集若共享整剧 id 仍能合并。
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}` if (media.tmdb_id && media.tmdb_id > 0) return seriesFingerprint('episodic-external', `tmdb:${media.tmdb_id}`)
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}` if (media.bangumi_id && media.bangumi_id > 0) return seriesFingerprint('episodic-external', `bgm:${media.bangumi_id}`)
if (media.douban_id) return `douban:${media.douban_id}` if (media.douban_id) return seriesFingerprint('episodic-external', `douban:${media.douban_id}`)
if (media.thetvdb_id) return `thetvdb:${media.thetvdb_id}` if (media.thetvdb_id) return seriesFingerprint('episodic-external', `thetvdb:${media.thetvdb_id}`)
if (media.series_id) return `series:${media.series_id}` if (media.series_id) return `series:${media.series_id}`
return seriesFingerprint('library-title', targetLibraryID(media), normalizeTitle(seriesTitle(media))) return seriesFingerprint('library-title', targetLibraryID(media), normalizeTitle(seriesTitle(media)))
} }
if (media.series_id) return `series:${media.series_id}` if (media.series_id) return `series:${media.series_id}`
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}` if (media.tmdb_id && media.tmdb_id > 0) return seriesFingerprint('movie-external', `tmdb:${media.tmdb_id}`)
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}` if (media.bangumi_id && media.bangumi_id > 0) return seriesFingerprint('movie-external', `bgm:${media.bangumi_id}`)
if (fromPath) return seriesFingerprint('library-path', media.library_id, fromPath) if (fromPath && !mediaParentLooksLikeCollection(media.path)) {
return seriesFingerprint('library-path', media.library_id, fromPath)
}
return seriesFingerprint('library-title', media.library_id, normalizeTitle(media.title)) return seriesFingerprint('library-title', media.library_id, normalizeTitle(media.title))
} }
@@ -250,16 +252,43 @@ function seriesPathPartLooksLikeFile(part: string): boolean {
export function groupSeries(items: Media[] = []): SeriesCard[] { export function groupSeries(items: Media[] = []): SeriesCard[] {
const safeItems = Array.isArray(items) ? items : [] const safeItems = Array.isArray(items) ? items : []
const pathCounts = new Map<string, number>()
const externalCounts = new Map<string, number>()
const titleCounts = new Map<string, number>()
for (const media of safeItems) {
if (!media || (!isEpisodeLike(media) && !pathLooksEpisodic(media))) continue
const pathKey = getSeriesRawKey(media)
if (pathKey.startsWith('library-path')) {
pathCounts.set(pathKey, (pathCounts.get(pathKey) ?? 0) + 1)
}
const externalKey = repeatedSeriesExternalRawKey(media)
if (externalKey) externalCounts.set(externalKey, (externalCounts.get(externalKey) ?? 0) + 1)
const titleKey = repeatedSeriesTitleRawKey(media)
if (titleKey) titleCounts.set(titleKey, (titleCounts.get(titleKey) ?? 0) + 1)
}
const groups = new Map<string, SeriesCard>() const groups = new Map<string, SeriesCard>()
for (const m of safeItems) { for (const m of safeItems) {
if (!m) continue if (!m) continue
const key = getSeriesKey(m) const pathKey = getSeriesRawKey(m)
const externalKey = repeatedSeriesExternalRawKey(m)
const titleKey = repeatedSeriesTitleRawKey(m)
const key = pathKey.startsWith('library-path') && (pathCounts.get(pathKey) ?? 0) > 1
? compactSeriesKey(pathKey)
: externalKey && (externalCounts.get(externalKey) ?? 0) > 1
? compactSeriesKey(externalKey)
: titleKey && (titleCounts.get(titleKey) ?? 0) > 1
? compactSeriesKey(titleKey)
: getSeriesKey(m)
const g = groups.get(key) const g = groups.get(key)
if (!g) { if (!g) {
groups.set(key, { key, rep: m, linkMedia: m, count: 1 }) groups.set(key, { key, rep: m, linkMedia: m, count: 1 })
} else { } else {
g.count += 1 // Repeated movie IDs represent alternate locations/encodes, not
// episodes. Fold the versions but keep the card in movie mode.
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) {
g.count += 1
}
if (betterSeriesLinkMedia(m, g.linkMedia)) { if (betterSeriesLinkMedia(m, g.linkMedia)) {
g.linkMedia = m g.linkMedia = m
} }
@@ -277,6 +306,33 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
return Array.from(groups.values()) return Array.from(groups.values())
} }
function repeatedSeriesExternalRawKey(media: Media): string {
if (!isEpisodeLike(media) && !pathLooksEpisodic(media)) return ''
let identity = ''
if (media.tmdb_id && media.tmdb_id > 0) identity = `tmdb:${media.tmdb_id}`
else if (media.bangumi_id && media.bangumi_id > 0) identity = `bgm:${media.bangumi_id}`
else if (media.douban_id) identity = `douban:${media.douban_id}`
else if (media.thetvdb_id) identity = `thetvdb:${media.thetvdb_id}`
return identity ? seriesFingerprint('library-external', targetLibraryID(media), identity) : ''
}
function repeatedSeriesTitleRawKey(media: Media): string {
if (!isEpisodeLike(media) && !pathLooksEpisodic(media)) return ''
if ((media.scrape_status ?? '').trim().toLowerCase() !== 'matched') return ''
const title = (media.title || media.original_name || '').trim()
if (!title || unsafeEpisodeTitle(title)) return ''
const normalized = normalizeTitle(title)
return normalized
? seriesFingerprint('library-title-year', targetLibraryID(media), normalized, String(media.year || 0))
: ''
}
function mediaParentLooksLikeCollection(path?: string): boolean {
const part = seriesDirectoryNameFromPath(path)
if (!part) return false
return /(?:^|[\s._-])(?:collection|anthology|trilogy|quadrilogy|tetralogy|saga|box[\s._-]*set)(?:[\s._-]|$)|(?:complete|all)[\s._-]*(?:\d+[\s._-]*)?(?:movies?|films?|collection|series|saga)(?:[\s._-]|$)|\d+[\s._-]*films?[\s._-]*collection|合集|全集|套装|全套|系列合集|电影系列|全\s*\d+\s*部/i.test(part)
}
export function seriesCardLink(card: SeriesCard): string { export function seriesCardLink(card: SeriesCard): string {
if (isSeriesCard(card)) { if (isSeriesCard(card)) {
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}` return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`