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.euus_tv", "欧美剧")
v.SetDefault("organizer.categories.jk_tv", "日韩剧")
v.SetDefault("organizer.categories.unclassified_tv", "未分类")
v.SetDefault("organizer.categories.jp_anime", "日番")
v.SetDefault("organizer.categories.cn_anime", "国漫")
v.SetDefault("organizer.categories.kr_anime", "韩漫")
+18 -1
View File
@@ -23,6 +23,24 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
return
}
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)
if svc.WSHub != nil {
svc.WSHub.Publish("scan", gin.H{
@@ -34,7 +52,6 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
})
}
_, _, _ = svc.Scan.StartCloudLibraryScan(id, true)
finishHTTPTask(task, nil, "queued", "云盘扫描已加入后台队列", map[string]int64{"queued": 1}, nil)
c.JSON(http.StatusAccepted, gin.H{
"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) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
+10 -4
View File
@@ -130,6 +130,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isAdultText {
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 {
return categoryName(categories, "documentary", "纪录片")
}
@@ -139,9 +144,6 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
return categoryName(categories, "variety", "综艺")
}
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
return animeCategory()
}
if !hasMetadata && sourceHint != "" {
return sourceHint
}
@@ -154,7 +156,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isWestern {
return categoryName(categories, "euus_tv", "欧美剧")
}
return categoryName(categories, "euus_tv", "欧美剧")
return categoryName(categories, "unclassified_tv", "未分类")
case "adult":
return categoryName(categories, "adult", "成人")
}
@@ -213,6 +215,10 @@ func (o *OrganizerService) categoryMap() map[string]string {
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 {
if m == nil {
return ""
@@ -19,6 +19,7 @@ var sourceCategoryHints = []sourceCategoryHintDef{
{Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"},
{Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"},
{Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv", Aliases: []string{"日剧", "韩剧", "泰剧"}},
{Key: "unclassified_tv", Fallback: "未分类", MediaType: "tv", Aliases: []string{"uncategorized"}},
{Key: "cn_anime", Fallback: "国漫", MediaType: "anime"},
{Key: "jp_anime", Fallback: "日番", MediaType: "anime"},
{Key: "kr_anime", Fallback: "韩漫", MediaType: "anime"},
@@ -27,7 +28,6 @@ var sourceCategoryHints = []sourceCategoryHintDef{
{Key: "variety", Fallback: "综艺", MediaType: "variety"},
{Key: "documentary", 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"}},
}
+17 -6
View File
@@ -110,12 +110,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "国产剧",
},
{
name: "latin tv title without metadata falls back to western tv",
name: "latin tv title without metadata stays unclassified",
input: mediaClassifyInput{
MediaType: "tv",
Title: "The Last of Us S01E01 1080p",
},
want: "欧美剧",
want: "未分类",
},
{
name: "latin tv keeps explicit western source category",
@@ -127,13 +127,13 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "欧美剧",
},
{
name: "generic tv folder is not treated as chinese category",
name: "generic tv folder stays unclassified without metadata",
input: mediaClassifyInput{
MediaType: "tv",
Title: "The Last of Us S01E01 1080p",
Category: "downloads 电视剧",
},
want: "欧美剧",
want: "未分类",
},
{
name: "gala title overrides wrong western source category",
@@ -145,12 +145,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
want: "综艺",
},
{
name: "platform token alone does not classify romanized drama",
name: "platform token alone leaves romanized drama unclassified",
input: mediaClassifyInput{
MediaType: "tv",
Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL",
},
want: "欧美剧",
want: "未分类",
},
{
name: "metadata classifies romanized chinese drama",
@@ -217,6 +217,17 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
},
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",
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
}
out := make([]model.Media, 0)
resolver := newMediaSeriesKeyResolver(rows)
for _, row := range rows {
if mediaSeriesKey(row) == key {
if resolver.key(row) == key {
out = append(out, row)
}
}
@@ -101,8 +102,9 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
}
groups := make([]seriesCardGroup, 0)
byKey := make(map[string]int, len(items))
resolver := newMediaSeriesKeyResolver(items)
for _, item := range items {
key := mediaSeriesKey(item)
key := resolver.key(item)
if key == "" {
continue
}
@@ -112,7 +114,12 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
group.latest = latest
}
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) {
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)
}
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 {
return fmt.Sprintf("bgm:%d", media.BangumiID)
return seriesFingerprint("episodic-external", fmt.Sprintf("bgm:%d", media.BangumiID))
}
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) != "" {
return "thetvdb:" + strings.TrimSpace(media.TheTVDBID)
return seriesFingerprint("episodic-external", "thetvdb:"+strings.TrimSpace(media.TheTVDBID))
}
if 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)
}
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 {
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-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)
}
}
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 {
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)
if titleKey == "" {
continue
@@ -90,8 +93,15 @@ func metadataTrustLocalizedSearchKeyword(queryKey string, match *Match) bool {
if match == nil || !metadataMatchHasExternalID(match) {
return false
}
searchKey := metadataTrustKey(match.SearchKeyword)
return searchKey != "" && searchKey == queryKey && metadataTrustStrongCJKQuery(queryKey)
if !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) {
+1
View File
@@ -35,6 +35,7 @@ func TestOrganizeNaming(t *testing.T) {
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 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},
{"Pressure.2026.2155p.iT.WEB-DL.HEVC.mkv", "Pressure", 2026, 0, 0},
}
for _, tc := range cases {
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, "电影", "欧美电影", "Dune (2021)", "Dune (2021).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 {
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("documentary", "纪录片", "tv")
addConfigured("children", "儿童", "tv")
addAlias("未分类", "euus_tv", "欧美剧", "tv")
addAlias("uncategorized", "euus_tv", "欧美剧", "tv")
addConfigured("unclassified_tv", "未分类", "tv")
addAlias("uncategorized", "unclassified_tv", "未分类", "tv")
addConfigured("adult", "成人", "adult")
addAlias("9KG", "adult", "成人", "adult")
addAlias("番号", "adult", "成人", "adult")
@@ -68,6 +68,8 @@ func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) m
add("纪录片", categoryName(categories, "documentary", "纪录片"))
case normalizeOrganizeCategoryKey(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", "华语电影")), "华语电影", "国产电影", "大陆电影":
add("华语电影", categoryName(categories, "chinese_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
if category == "" {
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 == "" {
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 {
return s.applyLocalMetadataMatch(ctx, m, local)
}
_ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
Update("scrape_status", "no_match").Error
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).
Update("scrape_status", "no_match").Error; err != nil {
return err
}
s.invalidateMediaCache(ctx)
s.log.Info("metadata scrape no match",
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 {
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"
}
if s.tmdb != nil && s.tmdb.Enabled() {
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" {
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 match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil {
return match
}
}
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
} else if err != nil {
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
}
}
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
} else if err != nil {
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 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
} else if err != nil {
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
}
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.
// When retryNoMatch is true it also retries rows previously marked no_match,
// 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": {},
}
var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`)
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
var multiWordNoise = []*regexp.Regexp{
@@ -105,6 +107,10 @@ func CleanQuery(raw string) (title string, year int) {
out := make([]string, 0, 8)
seenReleaseBoundary := false
for _, w := range strings.Fields(lower) {
if dynamicReleaseBoundaryTokenRE.MatchString(w) {
seenReleaseBoundary = true
continue
}
if _, ok := noiseTokenSet[w]; ok {
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
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) {
var queries []string
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)
continue
}
if isMediaCollectionFolder(base) {
dir = parentSlashPath(dir)
continue
}
if isGenericMediaCategoryFolder(base) {
return ""
}
@@ -114,7 +118,7 @@ func libraryRootTitle(libraryRoot string) string {
} else {
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 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{
Title: "Monarch: Legacy of Monsters",
OriginalName: "Monarch: Legacy of Monsters",
@@ -61,12 +61,21 @@ func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) {
TMDbID: 202411,
SearchKeyword: "帝王计划:怪兽遗产",
}
if !organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) {
t.Fatal("localized TMDb search keyword should be trusted even when returned title is not localized")
if organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) {
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
import (
"errors"
"path/filepath"
"strings"
"testing"
"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) {
scraper, repos, closeServer := newTestScraper(t)
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.Set("api_key", apiKey)
q.Set("language", "zh-CN")
q.Set("append_to_response", "alternative_titles,translations")
u := base + "/movie/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
@@ -38,6 +39,12 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `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 {
return nil, err
@@ -54,6 +61,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
if m.Title == "" {
m.Title = r.OriginalTitle
}
applyTMDbChineseTitle(m, r.AlternativeTitles.Titles, r.Translations.Translations)
if 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.Set("api_key", apiKey)
q.Set("language", "zh-CN")
q.Set("append_to_response", "alternative_titles,translations")
u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode()
var r struct {
ID int `json:"id"`
@@ -109,6 +118,12 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
SpokenLanguages []struct {
Iso639_1 string `json:"iso_639_1"`
} `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 {
return nil, err
@@ -126,6 +141,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
if m.Title == "" {
m.Title = r.OriginalName
}
applyTMDbChineseTitle(m, r.AlternativeTitles.Results, r.Translations.Translations)
if 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
}
// 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.
// SearchMovieCandidates returns the first localized TMDb result page. Manual
// scrape exposes the full page; automatic scrape ranks the same candidates.
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 == "" {
return nil, errors.New("empty query")
}
@@ -61,7 +64,7 @@ func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string,
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("query", query)
q.Set("language", "zh-CN")
q.Set("language", language)
q.Set("include_adult", "false")
if year > 0 {
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.
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 == "" {
return nil, errors.New("empty query")
}
@@ -137,7 +144,7 @@ func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, yea
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("query", query)
q.Set("language", "zh-CN")
q.Set("language", language)
q.Set("include_adult", "false")
if year > 0 {
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"`
Countries []string `json:"countries,omitempty"`
Genres []string `json:"genres,omitempty"`
Aliases []string `json:"aliases,omitempty"`
NSFW bool `json:"nsfw,omitempty"`
SearchKeyword string `json:"-"`
}
+65 -9
View File
@@ -44,17 +44,19 @@ function getSeriesRawKey(media: Media): string {
const pathID = seriesExternalIDFromPath(media.path)
if (pathID) return seriesFingerprint('library-path-id', targetLibraryID(media), pathID)
// 无路径剧名(扁平目录)时才退而用外部 id;此时各集若共享整剧 id 仍能合并。
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (media.douban_id) return `douban:${media.douban_id}`
if (media.thetvdb_id) return `thetvdb:${media.thetvdb_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 seriesFingerprint('episodic-external', `bgm:${media.bangumi_id}`)
if (media.douban_id) return seriesFingerprint('episodic-external', `douban:${media.douban_id}`)
if (media.thetvdb_id) return seriesFingerprint('episodic-external', `thetvdb:${media.thetvdb_id}`)
if (media.series_id) return `series:${media.series_id}`
return seriesFingerprint('library-title', targetLibraryID(media), normalizeTitle(seriesTitle(media)))
}
if (media.series_id) return `series:${media.series_id}`
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (fromPath) return seriesFingerprint('library-path', media.library_id, fromPath)
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 seriesFingerprint('movie-external', `bgm:${media.bangumi_id}`)
if (fromPath && !mediaParentLooksLikeCollection(media.path)) {
return seriesFingerprint('library-path', media.library_id, fromPath)
}
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[] {
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>()
for (const m of safeItems) {
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)
if (!g) {
groups.set(key, { key, rep: m, linkMedia: m, count: 1 })
} 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)) {
g.linkMedia = m
}
@@ -277,6 +306,33 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
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 {
if (isSeriesCard(card)) {
return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`