mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix(organize): repair classification and forced rescrape
This commit is contained in:
@@ -1,14 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func TestRepairAndRescrapeLibraryForceRematchesThenReclassifies(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
root := t.TempDir()
|
||||
wrongRoot := filepath.Join(root, "media", "电视剧", "国产剧")
|
||||
mediaPath := filepath.Join(wrongRoot, "Spy Family", "Season 01", "Spy Family - S01E01.mkv")
|
||||
if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(mediaPath, []byte("episode"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Name: "国产剧", Path: wrongRoot, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "错误旧匹配",
|
||||
Path: mediaPath,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 999,
|
||||
Countries: "CN",
|
||||
Languages: "zh",
|
||||
Genres: "Drama",
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Organizer.SmartClassify = true
|
||||
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||
organizer.SetScraper(scraper)
|
||||
container := &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, Scraper: scraper, Organizer: organizer}
|
||||
result, err := container.RepairAndRescrapeLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("repair and rescrape: %v", err)
|
||||
}
|
||||
if result.Reclassified != 1 {
|
||||
t.Fatalf("result=%+v, want one corrected classification", result)
|
||||
}
|
||||
want := filepath.Join(root, "media", "动漫", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("corrected media missing at %q: %v", want, err)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TMDbID != 12345 || got.Countries != "JP" || got.Path != want {
|
||||
t.Fatalf("repaired media=%#v, want rematched Japanese anime at corrected path", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRescrapeOptionsDefaultSkipsEpisodeArtwork(t *testing.T) {
|
||||
options := repairRescrapeOptions()
|
||||
if !options.RetryNoMatch {
|
||||
@@ -17,6 +78,9 @@ func TestRepairRescrapeOptionsDefaultSkipsEpisodeArtwork(t *testing.T) {
|
||||
if !options.IncludeMatched {
|
||||
t.Fatal("repair rescrape should refresh already matched rows")
|
||||
}
|
||||
if !options.ForceRematch {
|
||||
t.Fatal("repair rescrape should ignore stale external IDs and rematch by path/title")
|
||||
}
|
||||
if options.EpisodeArtwork == nil {
|
||||
t.Fatal("repair rescrape should set an explicit episode artwork option")
|
||||
}
|
||||
|
||||
@@ -59,11 +59,12 @@ func (c *Container) resetEpisodicMatchedForRescrape(ctx context.Context, library
|
||||
// 行的 scrape_status 重置为 pending),随后逐个媒体库重刮(含 no_match 重试),
|
||||
// 让此前因空 ID / 脏 ID 无法刮削的媒体重新匹配到正确数据。
|
||||
func repairRescrapeOptions(values ...ScrapeOptions) ScrapeOptions {
|
||||
options := ScrapeOptions{RetryNoMatch: true, IncludeMatched: true}
|
||||
options := ScrapeOptions{RetryNoMatch: true, IncludeMatched: true, ForceRematch: true}
|
||||
if len(values) > 0 {
|
||||
options = values[0]
|
||||
options.RetryNoMatch = true
|
||||
options.IncludeMatched = true
|
||||
options.ForceRematch = true
|
||||
}
|
||||
if options.EpisodeArtwork == nil {
|
||||
episodeArtwork := false
|
||||
@@ -130,6 +131,7 @@ func (c *Container) RepairAndRescrapeAllLibraries(ctx context.Context, options .
|
||||
if reclassifyResult != nil {
|
||||
result.Reclassified = reclassifyResult.Reclassified
|
||||
result.Errors += len(reclassifyResult.Errors)
|
||||
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
||||
}
|
||||
}
|
||||
if c.Log != nil {
|
||||
@@ -191,6 +193,7 @@ func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID stri
|
||||
if reclassifyResult != nil {
|
||||
result.Reclassified = reclassifyResult.Reclassified
|
||||
result.Errors += len(reclassifyResult.Errors)
|
||||
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
||||
}
|
||||
}
|
||||
if c.Log != nil {
|
||||
@@ -204,3 +207,11 @@ func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID stri
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Container) invalidateRepairReclassifyCache(ctx context.Context, changed int) {
|
||||
if c == nil || c.Cache == nil || changed <= 0 {
|
||||
return
|
||||
}
|
||||
c.Cache.DeletePrefix(ctx, "media:")
|
||||
c.Cache.DeletePrefix(ctx, "stats:")
|
||||
}
|
||||
|
||||
@@ -37,29 +37,35 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
categoryText := strings.ToLower(input.Category)
|
||||
rawText := rawTitleText + " " + input.Category
|
||||
text := strings.ToLower(rawText)
|
||||
hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0
|
||||
hasRegionMetadata := len(countries) > 0 || len(languages) > 0
|
||||
hasMetadata := len(genres) > 0 || hasRegionMetadata
|
||||
contentText := strings.ToLower(rawTitleText)
|
||||
if !hasMetadata {
|
||||
contentText += " " + categoryText
|
||||
}
|
||||
sourceHint := sourceCategoryHint(input.Category, mediaType, categories)
|
||||
animeSourceHint := sourceCategoryHint(input.Category, "anime", categories)
|
||||
|
||||
isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO")
|
||||
isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫")
|
||||
isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画")
|
||||
isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
|
||||
isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画"))
|
||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawTitleText) || (!hasMetadata && strings.Contains(text, "日番"))
|
||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawTitleText) || (!hasMetadata && containsAnyText(categoryText, "韩漫", "韩国动漫", "韩国动画"))
|
||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || (!hasRegionMetadata && (containsJapaneseKana(rawTitleText) || strings.Contains(text, "日番")))
|
||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || (!hasRegionMetadata && (containsKoreanHangul(rawTitleText) || containsAnyText(categoryText, "韩漫", "韩国动漫", "韩国动画")))
|
||||
isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影")
|
||||
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && isEastAsianByCategory)
|
||||
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasRegionMetadata && isEastAsianByCategory)
|
||||
isWesternByMetadata := hasAny(countries,
|
||||
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
|
||||
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
|
||||
)
|
||||
) || hasAny(languages, "EN", "FR", "DE", "ES", "IT", "PT", "NL", "SV", "NO", "DA", "FI", "RU")
|
||||
isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影")
|
||||
isWestern := isWesternByMetadata || (!hasMetadata && isWesternByCategory)
|
||||
isWestern := isWesternByMetadata || (!hasRegionMetadata && isWesternByCategory)
|
||||
isUSAnime := hasAny(countries, "US")
|
||||
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
|
||||
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
||||
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
||||
hasConcertText := containsAnyText(text, "演唱会", "音乐会", "concert", "live concert")
|
||||
hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
|
||||
hasVarietyText := containsAnyText(contentText, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
||||
hasDocumentaryText := containsAnyText(contentText, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
||||
hasConcertText := containsAnyText(contentText, "演唱会", "音乐会", "concert", "live concert")
|
||||
isAdultText := containsAnyText(text, "adult", "nsfw", "成人", "番号", "jav", "9kg", "uncensored", "无码", "有码") || classifierJAVCodeRE.MatchString(strings.ToUpper(rawText))
|
||||
|
||||
hasGenre := func(values ...string) bool {
|
||||
@@ -70,7 +76,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if isDigits(value) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(text, strings.ToLower(value)) {
|
||||
if strings.Contains(contentText, strings.ToLower(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -89,6 +95,9 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if isUSAnime || (!hasMetadata && containsAnyText(categoryText, "美漫", "欧美动漫", "欧美动画", "西方动画")) {
|
||||
return categoryName(categories, "us_anime", "美漫")
|
||||
}
|
||||
if !hasRegionMetadata && animeSourceHint != "" {
|
||||
return animeSourceHint
|
||||
}
|
||||
return categoryName(categories, "other_anime", "其他")
|
||||
}
|
||||
|
||||
@@ -106,7 +115,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
|
||||
return categoryName(categories, "animation_movie", "动画电影")
|
||||
}
|
||||
if !hasMetadata && sourceHint != "" {
|
||||
if !hasRegionMetadata && sourceHint != "" {
|
||||
return sourceHint
|
||||
}
|
||||
if isChinese {
|
||||
@@ -120,7 +129,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if isAdultText {
|
||||
return categoryName(categories, "adult", "成人")
|
||||
}
|
||||
if !hasMetadata && sourceHint != "" {
|
||||
if !hasRegionMetadata && sourceHint != "" {
|
||||
return sourceHint
|
||||
}
|
||||
return animeCategory()
|
||||
@@ -144,7 +153,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
}
|
||||
if !hasMetadata && sourceHint != "" {
|
||||
if !hasRegionMetadata && sourceHint != "" {
|
||||
return sourceHint
|
||||
}
|
||||
if isChinese || (!hasMetadata && isChineseByCategory) {
|
||||
|
||||
@@ -161,6 +161,36 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
},
|
||||
want: "国产剧",
|
||||
},
|
||||
{
|
||||
name: "genre-only metadata keeps domestic source region",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "Archives The Nanyang Mystery",
|
||||
Genres: []string{"Drama"},
|
||||
Category: "downloads 国产剧",
|
||||
},
|
||||
want: "国产剧",
|
||||
},
|
||||
{
|
||||
name: "genre-only metadata keeps western source region",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "movie",
|
||||
Title: "Unknown International Feature",
|
||||
Genres: []string{"Drama"},
|
||||
Category: "downloads 欧美电影",
|
||||
},
|
||||
want: "欧美电影",
|
||||
},
|
||||
{
|
||||
name: "english language metadata classifies western tv without country",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "The Last of Us",
|
||||
Languages: []string{"en"},
|
||||
Genres: []string{"Drama"},
|
||||
},
|
||||
want: "欧美剧",
|
||||
},
|
||||
{
|
||||
name: "gala is variety",
|
||||
input: mediaClassifyInput{
|
||||
@@ -196,6 +226,30 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) {
|
||||
},
|
||||
want: "日番",
|
||||
},
|
||||
{
|
||||
name: "live action metadata overrides wrong anime source folder",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "The Last of Us",
|
||||
Countries: []string{"US"},
|
||||
Languages: []string{"en"},
|
||||
Genres: []string{"Drama"},
|
||||
Category: "downloads 动漫 日番",
|
||||
},
|
||||
want: "欧美剧",
|
||||
},
|
||||
{
|
||||
name: "drama metadata overrides wrong documentary source folder",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "人世间",
|
||||
Countries: []string{"CN"},
|
||||
Languages: []string{"zh"},
|
||||
Genres: []string{"Drama"},
|
||||
Category: "downloads 纪录片",
|
||||
},
|
||||
want: "国产剧",
|
||||
},
|
||||
{
|
||||
name: "western anime metadata uses us anime category",
|
||||
input: mediaClassifyInput{
|
||||
@@ -333,6 +387,39 @@ func TestNormalizeMediaTypeAcceptsChineseLibraryTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLocalClassificationMetadataKeepsScraperRegionWhenNFOIsSparse(t *testing.T) {
|
||||
input := mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "SPY FAMILY",
|
||||
Languages: []string{"ja"},
|
||||
Countries: []string{"JP"},
|
||||
Genres: []string{"16"},
|
||||
}
|
||||
mergeLocalClassificationMetadata(&input, &LocalMetadata{
|
||||
Title: "间谍过家家 第 1 集",
|
||||
HasNFO: true,
|
||||
}, "Spy Family", "episode.mkv")
|
||||
|
||||
if got := classifyMediaCategory(input, nil); got != "日番" {
|
||||
t.Fatalf("category=%q, want 日番 after sparse NFO merge; input=%#v", got, input)
|
||||
}
|
||||
if len(input.Countries) != 1 || input.Countries[0] != "JP" || len(input.Genres) != 1 || input.Genres[0] != "16" {
|
||||
t.Fatalf("sparse NFO erased scraper metadata: %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileOrganizeCategoryMediaTypeKeepsAmbiguousDocumentaryMovie(t *testing.T) {
|
||||
if got := reconcileOrganizeCategoryMediaType("movie", "tv"); got != "movie" {
|
||||
t.Fatalf("ambiguous documentary type=%q, want movie", got)
|
||||
}
|
||||
if got := reconcileOrganizeCategoryMediaType("tv", "anime"); got != "anime" {
|
||||
t.Fatalf("TV animation subtype=%q, want anime", got)
|
||||
}
|
||||
if got := reconcileOrganizeCategoryMediaType("movie", "adult"); got != "adult" {
|
||||
t.Fatalf("adult override=%q, want adult", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMediaTypeDoesNotTreatReleaseTokensAsTV(t *testing.T) {
|
||||
tests := []string{
|
||||
"They Will Kill You 2026 1080p HDTV x264",
|
||||
|
||||
@@ -73,6 +73,24 @@ func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeSourceCategoryKeepsDocumentaryMovieType(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Organizer.SmartClassify = true
|
||||
organizer := NewOrganizerService(cfg, zap.NewNop(), newOrganizerTestRepo(t))
|
||||
layout := organizer.applyOrganizeSourceCategory(
|
||||
t.Context(),
|
||||
organizeSourceFileRequest{Source: filepath.Join(t.TempDir(), "Free.Solo.2018.mkv"), SourceRoot: t.TempDir()},
|
||||
organizeDirectoryLayout{MediaType: "movie"},
|
||||
organizeDirectoryLayout{MediaType: "movie"},
|
||||
"",
|
||||
organizeSourceIdentity{Title: "Free Solo", ParsedTitle: "Free Solo", Year: 2018},
|
||||
&Match{Title: "徒手攀岩", MediaType: "movie", Countries: []string{"US"}, Languages: []string{"en"}, Genres: []string{"99"}},
|
||||
)
|
||||
if layout.MediaType != "movie" || layout.Category != "纪录片" {
|
||||
t.Fatalf("documentary layout=%#v, want movie/纪录片", layout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -60,17 +60,35 @@ func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sou
|
||||
}
|
||||
}
|
||||
if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO {
|
||||
input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ")
|
||||
input.Languages = parseCommaList(meta.Languages)
|
||||
input.Countries = parseCommaList(meta.Countries)
|
||||
input.Genres = parseCommaList(meta.Genres)
|
||||
if meta.NSFW {
|
||||
input.MediaType = "adult"
|
||||
}
|
||||
mergeLocalClassificationMetadata(&input, meta, title, parsedTitle, filepath.Base(src))
|
||||
}
|
||||
return sanitizeFilename(classifyMediaCategory(input, o.categoryMap()))
|
||||
}
|
||||
|
||||
// mergeLocalClassificationMetadata lets a local NFO refine scraper metadata
|
||||
// without erasing fields that the NFO omitted. Episode NFO files commonly
|
||||
// contain only a title/episode number; replacing countries/languages/genres
|
||||
// with those empty values caused the organizer to lose a correct scraper
|
||||
// region and fall back to the wrong directory category.
|
||||
func mergeLocalClassificationMetadata(input *mediaClassifyInput, meta *LocalMetadata, titleParts ...string) {
|
||||
if input == nil || meta == nil {
|
||||
return
|
||||
}
|
||||
input.Title = strings.Join(append([]string{meta.Title, meta.OriginalName}, titleParts...), " ")
|
||||
if values := parseCommaList(meta.Languages); len(values) > 0 {
|
||||
input.Languages = values
|
||||
}
|
||||
if values := parseCommaList(meta.Countries); len(values) > 0 {
|
||||
input.Countries = values
|
||||
}
|
||||
if values := parseCommaList(meta.Genres); len(values) > 0 {
|
||||
input.Genres = values
|
||||
}
|
||||
if meta.NSFW {
|
||||
input.MediaType = "adult"
|
||||
}
|
||||
}
|
||||
|
||||
// parseCommaList splits a comma-separated string into trimmed non-empty values.
|
||||
func parseCommaList(s string) []string {
|
||||
if s == "" {
|
||||
|
||||
@@ -187,9 +187,7 @@ func (o *OrganizerService) applyOrganizeSourceCategory(
|
||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" {
|
||||
layout.Category = normalizedCategory
|
||||
if forcedType == "" {
|
||||
if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category {
|
||||
layout.MediaType = impliedType
|
||||
}
|
||||
layout.MediaType = reconcileOrganizeCategoryMediaType(layout.MediaType, impliedType)
|
||||
}
|
||||
}
|
||||
return layout
|
||||
|
||||
@@ -112,6 +112,27 @@ func organizeLibraryTypeScore(mediaType, libraryType string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// reconcileOrganizeCategoryMediaType applies category-derived type changes
|
||||
// conservatively. Some category labels are intentionally shared by movies
|
||||
// and episodic media (notably "纪录片"). A category lookup therefore must not
|
||||
// turn an already-known movie into TV merely because the shared label was
|
||||
// registered last. Only subtype refinements and the explicit adult category
|
||||
// are allowed to replace a known type.
|
||||
func reconcileOrganizeCategoryMediaType(current, implied string) string {
|
||||
current = normalizeOrganizeMediaType(current)
|
||||
implied = normalizeOrganizeMediaType(implied)
|
||||
if current == "" || current == implied {
|
||||
return implied
|
||||
}
|
||||
if implied == "adult" {
|
||||
return implied
|
||||
}
|
||||
if current == "tv" && (implied == "anime" || implied == "variety") {
|
||||
return implied
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func normalizeOrganizeCategoryKey(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.ReplaceAll(value, " ", "")
|
||||
|
||||
@@ -35,8 +35,22 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi
|
||||
return organizeMediaRequest{}, errors.New("media not found")
|
||||
}
|
||||
lib, err := o.repo.Library.FindByID(ctx, m.LibraryID)
|
||||
if err != nil || lib == nil {
|
||||
return organizeMediaRequest{}, errors.New("library not found")
|
||||
if err != nil {
|
||||
return organizeMediaRequest{}, err
|
||||
}
|
||||
if lib == nil {
|
||||
lib = o.findOrganizeLibraryForMediaPath(ctx, m.Path)
|
||||
if lib == nil {
|
||||
return organizeMediaRequest{}, errors.New("library not found")
|
||||
}
|
||||
// Historical reclassification/library deletion bugs could leave media
|
||||
// rows pointing at a removed library. Repair the ownership before the
|
||||
// scrape-driven rename so the rename does not fail permanently.
|
||||
m.LibraryID = lib.ID
|
||||
if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id = ?", m.ID).Update("library_id", lib.ID).Error; err != nil {
|
||||
return organizeMediaRequest{}, err
|
||||
}
|
||||
}
|
||||
if _, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||
return organizeMediaRequest{}, errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud")
|
||||
@@ -69,6 +83,42 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *OrganizerService) findOrganizeLibraryForMediaPath(ctx context.Context, mediaPath string) *model.Library {
|
||||
if o == nil || o.repo == nil || o.repo.Library == nil || strings.TrimSpace(mediaPath) == "" {
|
||||
return nil
|
||||
}
|
||||
libraries, err := o.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var best *model.Library
|
||||
bestLen := -1
|
||||
for i := range libraries {
|
||||
lib := &libraries[i]
|
||||
if !lib.Enabled {
|
||||
continue
|
||||
}
|
||||
paths := []string{lib.Path}
|
||||
for _, root := range lib.Roots {
|
||||
if root.Enabled {
|
||||
paths = append(paths, root.Path)
|
||||
}
|
||||
}
|
||||
for _, rootPath := range paths {
|
||||
if strings.TrimSpace(rootPath) == "" || !pathWithin(mediaPath, rootPath) {
|
||||
continue
|
||||
}
|
||||
if n := len(filepath.Clean(rootPath)); n > bestLen {
|
||||
candidate := *lib
|
||||
candidate.Path = rootPath
|
||||
best = &candidate
|
||||
bestLen = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, req organizeMediaRequest) (organizeMediaDestination, error) {
|
||||
m := req.media
|
||||
lib := req.library
|
||||
@@ -88,7 +138,7 @@ func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, re
|
||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
|
||||
category = normalizedCategory
|
||||
if normalizeOrganizeMediaType(req.mediaType) == "" {
|
||||
mediaType = impliedType
|
||||
mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType)
|
||||
}
|
||||
}
|
||||
root := o.organizeRoot(req.baseRoot, mediaType, category)
|
||||
|
||||
@@ -33,7 +33,7 @@ func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, medi
|
||||
return false, nil
|
||||
}
|
||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
|
||||
mediaType = impliedType
|
||||
mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType)
|
||||
category = normalizedCategory
|
||||
}
|
||||
if mediaType == "" {
|
||||
|
||||
@@ -159,7 +159,7 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod
|
||||
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" {
|
||||
category = normalizedCategory
|
||||
if !explicitType {
|
||||
mediaType = impliedType
|
||||
mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType)
|
||||
}
|
||||
}
|
||||
if explicitCategory && overrideCategory != "" {
|
||||
|
||||
@@ -496,3 +496,43 @@ func TestOrganizeScrapeAfterEnabledDefaultsOn(t *testing.T) {
|
||||
t.Fatalf("explicit organize.scrape_after=false should be respected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncMediaPathWithMetadataRepairsOrphanedLibraryReference(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
libRoot := filepath.Join(root, "media", "电影")
|
||||
source := filepath.Join(libRoot, "Old Name", "Old Name.mkv")
|
||||
writeOrgFile(t, source, "movie")
|
||||
|
||||
repos := newOrganizerTestRepo(t)
|
||||
lib := model.Library{Name: "电影", Path: libRoot, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: "deleted-library",
|
||||
Title: "New Name",
|
||||
Year: 2026,
|
||||
Path: source,
|
||||
Container: "mkv",
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||
dst, err := organizer.SyncMediaPathWithMetadata(t.Context(), media.ID, OrganizeOptions{DestPath: libRoot})
|
||||
if err != nil {
|
||||
t.Fatalf("sync orphaned media: %v", err)
|
||||
}
|
||||
want := filepath.Join(libRoot, "New Name (2026)", "New Name (2026).mkv")
|
||||
if dst != want {
|
||||
t.Fatalf("dst=%q, want %q", dst, want)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LibraryID != lib.ID || got.Path != want {
|
||||
t.Fatalf("media library/path=%q/%q, want %q/%q", got.LibraryID, got.Path, lib.ID, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,12 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi
|
||||
}
|
||||
}
|
||||
|
||||
if match := s.matchFromMediaExternalIDs(ctx, m, lib); match != nil {
|
||||
s.applyFanartArtwork(ctx, match)
|
||||
mergeLocalMetadataIntoMatch(match, local)
|
||||
return s.applyProviderMatchWithOptions(ctx, m, lib, match, options)
|
||||
if !options.ForceRematch {
|
||||
if match := s.matchFromMediaExternalIDs(ctx, m, lib); match != nil {
|
||||
s.applyFanartArtwork(ctx, match)
|
||||
mergeLocalMetadataIntoMatch(match, local)
|
||||
return s.applyProviderMatchWithOptions(ctx, m, lib, match, options)
|
||||
}
|
||||
}
|
||||
|
||||
candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib)
|
||||
@@ -128,6 +130,18 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
"year": match.Year,
|
||||
"scrape_status": "matched",
|
||||
}
|
||||
if options.ForceRematch {
|
||||
updates["original_name"] = strings.TrimSpace(match.OriginalName)
|
||||
updates["release_date"] = strings.TrimSpace(match.ReleaseDate)
|
||||
updates["tm_db_id"] = match.TMDbID
|
||||
updates["bangumi_id"] = match.BangumiID
|
||||
updates["douban_id"] = strings.TrimSpace(match.DoubanID)
|
||||
updates["thetvdb_id"] = strings.TrimSpace(match.TheTVDBID)
|
||||
updates["genres"] = strings.Join(match.Genres, ",")
|
||||
updates["countries"] = strings.Join(match.Countries, ",")
|
||||
updates["languages"] = strings.Join(match.Languages, ",")
|
||||
updates["nsfw"] = match.NSFW
|
||||
}
|
||||
if match.ReleaseDate != "" {
|
||||
updates["release_date"] = match.ReleaseDate
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ type ScrapeOptions struct {
|
||||
RefreshWeakMatched bool
|
||||
EpisodeArtwork *bool
|
||||
DeferEpisodeDetails bool
|
||||
ForceRematch bool
|
||||
}
|
||||
|
||||
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
||||
|
||||
@@ -77,6 +77,47 @@ func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneForceRematchReplacesStaleExternalIDsAndTaxonomy(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
lib := model.Library{Name: "国产剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "错误旧匹配",
|
||||
Path: filepath.Join(lib.Path, "Spy Family", "Season 01", "Spy Family - S01E01.mkv"),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
TMDbID: 999,
|
||||
BangumiID: 88,
|
||||
DoubanID: "stale",
|
||||
Countries: "CN",
|
||||
Languages: "zh",
|
||||
Genres: "Drama",
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := scraper.EnrichOneWithOptions(t.Context(), &media, ScrapeOptions{ForceRematch: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TMDbID != 12345 || got.BangumiID != 0 || got.DoubanID != "" {
|
||||
t.Fatalf("external ids after forced rematch=%d/%d/%q, want 12345/0/empty", got.TMDbID, got.BangumiID, got.DoubanID)
|
||||
}
|
||||
if got.Title != "间谍过家家" || got.Countries != "JP" || got.Languages != "ja" || !strings.Contains(got.Genres, "Animation") {
|
||||
t.Fatalf("forced rematch metadata=%#v, want corrected Japanese animation metadata", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
@@ -70,6 +70,16 @@ func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func(
|
||||
"name": "Animation",
|
||||
}},
|
||||
})
|
||||
case strings.HasPrefix(r.URL.Path, "/tv/999"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 999,
|
||||
"name": "错误旧匹配",
|
||||
"first_air_date": "2020-01-01",
|
||||
"origin_country": []string{"CN"},
|
||||
"genres": []map[string]any{{
|
||||
"name": "Drama",
|
||||
}},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user