fix(organize): repair classification and forced rescrape

This commit is contained in:
ShukeBta
2026-08-10 19:16:12 +08:00
parent 60d2cfe2f1
commit 5c6fe91742
16 changed files with 416 additions and 34 deletions
@@ -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")
}
+12 -1
View File
@@ -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:")
}
+23 -14
View File
@@ -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) {
+87
View File
@@ -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")
+25 -7
View File
@@ -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, " ", "")
+53 -3
View File
@@ -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 != "" {
+40
View File
@@ -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)
}
}
+18 -4
View File
@@ -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
}
+1
View File
@@ -6,6 +6,7 @@ type ScrapeOptions struct {
RefreshWeakMatched bool
EpisodeArtwork *bool
DeferEpisodeDetails bool
ForceRematch bool
}
func (o ScrapeOptions) episodeArtworkEnabled() bool {
+41
View File
@@ -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)
}