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