mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Fix media classification scraping and Chinese naming
This commit is contained in:
@@ -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", "韩漫")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
Reference in New Issue
Block a user