diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index b4718a2..886ee1d 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -144,10 +144,15 @@ func addMediaTitleUpdates(updates map[string]any, existing, incoming model.Media // 真实剧名。仅在 existing 还停留在 'pending'/'' 时回填扫描标题, // 避免覆盖刮削结果。 if incoming.ScrapeStatus == "matched" || existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" { + titleChanged := !strings.EqualFold(strings.TrimSpace(existing.Title), strings.TrimSpace(incoming.Title)) + yearChanged := incoming.Year > 0 && existing.Year != incoming.Year setIfChanged(updates, "title", existing.Title, incoming.Title) if incoming.Year > 0 { setIfChanged(updates, "year", existing.Year, incoming.Year) } + if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (titleChanged || yearChanged) { + updates["scrape_status"] = "pending" + } } } } @@ -218,12 +223,17 @@ func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.M updates["library_id"] = incoming.LibraryID } } - if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum { + seasonChanged := (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum + episodeChanged := incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum + if seasonChanged { updates["season_num"] = incoming.SeasonNum } - if incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum { + if episodeChanged { updates["episode_num"] = incoming.EpisodeNum } + if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (seasonChanged || episodeChanged) { + updates["scrape_status"] = "pending" + } } func addMediaSTRMUpdate(updates map[string]any, existing, incoming model.Media) { diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index 75e3489..dba903a 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -23,6 +23,7 @@ import ( var ( patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) + patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`) patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) diff --git a/internal/service/metadata_match_trust.go b/internal/service/metadata_match_trust.go index 8a9d90b..4d3b4ac 100644 --- a/internal/service/metadata_match_trust.go +++ b/internal/service/metadata_match_trust.go @@ -34,7 +34,7 @@ func metadataTrustKey(value string) string { } value = bracketedTag.ReplaceAllString(value, " ") value = yearPattern.ReplaceAllString(value, " ") - for _, re := range []*regexp.Regexp{patSEnE, patNxE, patEP, patCN, patSeasonOnly, patCNSeason} { + for _, re := range []*regexp.Regexp{patSEnE, patDanglingSE, patNxE, patEP, patCN, patSeasonOnly, patCNSeason} { value = re.ReplaceAllString(value, " ") } tokens := metadataTrustTokenRE.FindAllString(value, -1) diff --git a/internal/service/organizer_target.go b/internal/service/organizer_target.go index 3f8caea..fd6f93c 100644 --- a/internal/service/organizer_target.go +++ b/internal/service/organizer_target.go @@ -263,6 +263,7 @@ func extractOrganizeReleaseTag(source string) string { } value := strings.Trim(matches[1], " ._-[]()") value = patSEnE.ReplaceAllString(value, " ") + value = patDanglingSE.ReplaceAllString(value, " ") value = patNxE.ReplaceAllString(value, " ") value = patEP.ReplaceAllString(value, " ") value = patCN.ReplaceAllString(value, " ") diff --git a/internal/service/scanner_cloud_ingest.go b/internal/service/scanner_cloud_ingest.go index eb7ac22..1a0bc83 100644 --- a/internal/service/scanner_cloud_ingest.go +++ b/internal/service/scanner_cloud_ingest.go @@ -30,22 +30,6 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library } } expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref) - isNewMedia := false - needsTrackProbe := true - if existingMedia != nil { - existing, exists := existingMedia[path] - isNewMedia = !exists - needsTrackProbe = !exists || cloudTrackMetadataMissing(existing) - if exists && existing.LibraryID == lib.ID && existing.SizeBytes == size && existing.STRMURL == expectedSTRMURL && !cloudMetadataNeedsRefresh(existing, localMeta) { - if needsTrackProbe && ext != ".strm" { - s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) - } - res.Skipped++ - return - } - } else { - isNewMedia = !s.mediaPathExists(ctx, path) - } m := &model.Media{ LibraryID: lib.ID, Title: title, @@ -55,6 +39,8 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library Container: strings.TrimPrefix(ext, "."), STRMURL: expectedSTRMURL, ScrapeStatus: "pending", + SeasonNum: parsedSeason, + EpisodeNum: parsedEpisode, } if ext == ".strm" { if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" { @@ -63,11 +49,8 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err)) } } - m.SeasonNum = parsedSeason - m.EpisodeNum = parsedEpisode if localMeta != nil { applyLocalMetadata(m, localMeta) - res.LocalMetadata++ s.queueCloudArtworkPrefetch(localMeta.PosterURL) s.queueCloudArtworkPrefetch(localMeta.BackdropURL) } @@ -85,6 +68,25 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library m.TheTVDBID = strings.TrimSpace(hints.TheTVDBID) } } + isNewMedia := false + needsTrackProbe := true + if existingMedia != nil { + existing, exists := existingMedia[path] + isNewMedia = !exists + needsTrackProbe = !exists || cloudTrackMetadataMissing(existing) + if exists && existing.LibraryID == lib.ID && existing.SizeBytes == size && existing.STRMURL == expectedSTRMURL && !cloudMetadataNeedsRefresh(existing, localMeta) && !cloudDerivedMetadataNeedsRefresh(existing, m) { + if needsTrackProbe && ext != ".strm" { + s.queueCloudMediaProbeWithBudget(typ, ref, path, probeBudget) + } + res.Skipped++ + return + } + } else { + isNewMedia = !s.mediaPathExists(ctx, path) + } + if localMeta != nil { + res.LocalMetadata++ + } if isNewMedia && writeBatch != nil { var after func() if needsTrackProbe && ext != ".strm" { diff --git a/internal/service/scanner_cloud_openlist_test.go b/internal/service/scanner_cloud_openlist_test.go index 0380725..71cc5b8 100644 --- a/internal/service/scanner_cloud_openlist_test.go +++ b/internal/service/scanner_cloud_openlist_test.go @@ -268,3 +268,71 @@ func TestScanCloudLibraryQueuesMissingExistingTrackMetadataBeforeNewFiles(t *tes } } } + +func TestScanCloudLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) { + const showDir = "Hntv Spring Festival Gala S01e (2026)" + const seasonDir = "Season 1" + const name = "Hntv Spring Festival Gala S01e - S01E202-DD5.QHstudIo.6.4K - 第 202 集.ts" + upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { + switch path { + case "/": + return []openListTestEntry{{Name: showDir, IsDir: true}}, 1 + case "/" + showDir: + return []openListTestEntry{{Name: seasonDir, IsDir: true}}, 1 + case "/" + showDir + "/" + seasonDir: + return []openListTestEntry{{Name: name, Size: 1}}, 1 + default: + t.Fatalf("unexpected openlist path %q", path) + return nil, 0 + } + }) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "server": upstream.URL, + "token": "openlist-token", + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "OpenList · 综艺", Path: "cloud://openlist", Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + ref := "/" + showDir + "/" + seasonDir + "/" + name + path := "cloud://openlist/" + showDir + "/" + seasonDir + "/" + name + if err := repos.DB.Create(&model.Media{ + LibraryID: lib.ID, + Title: "hntv spring festival gala s01e", + Path: path, + SizeBytes: 1, + Container: "ts", + STRMURL: BuildRelativeCloudPlayURL("openlist", ref), + ScrapeStatus: "no_match", + }).Error; err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Updated != 1 || res.Skipped != 0 { + t.Fatalf("scan result updated=%d skipped=%d, want 1/0: %#v", res.Updated, res.Skipped, res) + } + var media model.Media + if err := repos.DB.First(&media, "path = ?", path).Error; err != nil { + t.Fatal(err) + } + if media.Title != "hntv spring festival gala" || media.SeasonNum != 1 || media.EpisodeNum != 202 || media.ScrapeStatus != "pending" { + t.Fatalf("stale cloud row was not refreshed: title=%q s=%d e=%d status=%q", media.Title, media.SeasonNum, media.EpisodeNum, media.ScrapeStatus) + } +} diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go index 6cd460e..63a92bf 100644 --- a/internal/service/scanner_local_ingest.go +++ b/internal/service/scanner_local_ingest.go @@ -25,20 +25,6 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, pat parsedSeason, parsedEpisode := ParseEpisode(path) localMeta := s.readLocalScanMetadata(lib, path, parsedSeason, parsedEpisode) - isNewMedia, skipUnchanged := s.localMediaScanState(localMediaScanStateInput{ - ctx: ctx, - path: path, - cleanPath: cleanPath, - ext: ext, - size: size, - localMeta: localMeta, - existingMedia: existingMedia, - }) - if skipUnchanged { - res.Skipped++ - return - } - media := s.buildLocalScanMedia(localScanMediaInput{ lib: lib, path: path, @@ -48,8 +34,25 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, pat parsedSeason: parsedSeason, parsedEpisode: parsedEpisode, localMeta: localMeta, - res: res, }) + isNewMedia, skipUnchanged := s.localMediaScanState(localMediaScanStateInput{ + ctx: ctx, + path: path, + cleanPath: cleanPath, + ext: ext, + size: size, + localMeta: localMeta, + incoming: media, + existingMedia: existingMedia, + }) + if skipUnchanged { + res.Skipped++ + return + } + if localMeta != nil { + res.LocalMetadata++ + } + s.writeLocalScanMedia(localScanWriteInput{ ctx: ctx, path: path, @@ -99,6 +102,7 @@ type localMediaScanStateInput struct { ext string size int64 localMeta *LocalMetadata + incoming *model.Media existingMedia map[string]existingLocalMedia } @@ -108,7 +112,7 @@ func (s *ScannerService) localMediaScanState(in localMediaScanStateInput) (bool, } existing, exists := in.existingMedia[in.cleanPath] isNewMedia := !exists - if exists && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) { + if exists && in.ext != ".strm" && existing.SizeBytes == in.size && !localMetadataNeedsRefresh(existing, in.localMeta) && !localDerivedMetadataNeedsRefresh(existing, in.incoming) { return isNewMedia, true } return isNewMedia, false @@ -123,7 +127,6 @@ type localScanMediaInput struct { parsedSeason int parsedEpisode int localMeta *LocalMetadata - res *ScanResult } func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media { @@ -153,7 +156,6 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi } if in.localMeta != nil { applyLocalMetadata(media, in.localMeta) - in.res.LocalMetadata++ } return media } diff --git a/internal/service/scanner_local_metadata_test.go b/internal/service/scanner_local_metadata_test.go index f9f8ad7..b001085 100644 --- a/internal/service/scanner_local_metadata_test.go +++ b/internal/service/scanner_local_metadata_test.go @@ -181,6 +181,50 @@ func TestScanLibraryParsesEpisodesForMovieTypedLibrary(t *testing.T) { } } +func TestScanLibraryRefreshesStaleNoMatchDerivedMetadata(t *testing.T) { + root := t.TempDir() + seasonDir := filepath.Join(root, "Hntv Spring Festival Gala S01e (2026)", "Season 1") + if err := os.MkdirAll(seasonDir, 0o755); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(seasonDir, "Hntv Spring Festival Gala S01e - S01E202-DD5.QHstudIo.6.4K - 第 202 集.ts") + if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}) + repos := repository.New(db) + lib := model.Library{Name: "综艺", Path: root, Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.Media{ + LibraryID: lib.ID, + Title: "hntv spring festival gala s01e", + Path: mediaPath, + SizeBytes: 1, + ScrapeStatus: "no_match", + }).Error; err != nil { + t.Fatal(err) + } + + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatal(err) + } + if res.Updated != 1 || res.Skipped != 0 { + t.Fatalf("scan counts updated=%d skipped=%d, want 1/0", res.Updated, res.Skipped) + } + var media model.Media + if err := db.First(&media, "path = ?", mediaPath).Error; err != nil { + t.Fatal(err) + } + if media.Title != "hntv spring festival gala" || media.SeasonNum != 1 || media.EpisodeNum != 202 || media.ScrapeStatus != "pending" { + t.Fatalf("stale no_match row was not refreshed: title=%q s=%d e=%d status=%q", media.Title, media.SeasonNum, media.EpisodeNum, media.ScrapeStatus) + } +} + func TestScanLibraryPrunesMissingMedia(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "Show.S02E03.mkv") diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go index 1b1285c..30a68b3 100644 --- a/internal/service/scanner_metadata_refresh.go +++ b/internal/service/scanner_metadata_refresh.go @@ -1,6 +1,22 @@ package service -import "strings" +import ( + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +type scanDerivedMetadata struct { + Title string + ScrapeStatus string + Year int + TMDbID int + BangumiID int + DoubanID string + TheTVDBID string + SeasonNum int + EpisodeNum int +} func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool { if localMeta == nil { @@ -157,6 +173,67 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata return local.NSFW && !existing.NSFW } +func cloudDerivedMetadataNeedsRefresh(existing existingCloudMedia, incoming *model.Media) bool { + if incoming == nil { + return false + } + return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{ + Title: existing.Title, + ScrapeStatus: existing.ScrapeStatus, + Year: existing.Year, + TMDbID: existing.TMDbID, + BangumiID: existing.BangumiID, + DoubanID: existing.DoubanID, + TheTVDBID: existing.TheTVDBID, + SeasonNum: existing.SeasonNum, + EpisodeNum: existing.EpisodeNum, + }, incoming) +} + +func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *model.Media) bool { + if incoming == nil { + return false + } + return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{ + Title: existing.Title, + ScrapeStatus: existing.ScrapeStatus, + Year: existing.Year, + TMDbID: existing.TMDbID, + BangumiID: existing.BangumiID, + DoubanID: existing.DoubanID, + TheTVDBID: existing.TheTVDBID, + SeasonNum: existing.SeasonNum, + EpisodeNum: existing.EpisodeNum, + }, incoming) +} + +func scanDerivedMetadataNeedsRefresh(existing scanDerivedMetadata, incoming *model.Media) bool { + status := strings.TrimSpace(existing.ScrapeStatus) + enrichable := status == "" || status == "pending" || status == "no_match" + if enrichable && strings.TrimSpace(incoming.Title) != "" && !strings.EqualFold(strings.TrimSpace(existing.Title), strings.TrimSpace(incoming.Title)) { + return true + } + if enrichable && incoming.Year > 0 && existing.Year != incoming.Year { + return true + } + if (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum { + return true + } + if incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum { + return true + } + if incoming.TMDbID > 0 && existing.TMDbID != incoming.TMDbID { + return true + } + if incoming.BangumiID > 0 && existing.BangumiID != incoming.BangumiID { + return true + } + if strings.TrimSpace(incoming.DoubanID) != "" && strings.TrimSpace(existing.DoubanID) != strings.TrimSpace(incoming.DoubanID) { + return true + } + return strings.TrimSpace(incoming.TheTVDBID) != "" && strings.TrimSpace(existing.TheTVDBID) != strings.TrimSpace(incoming.TheTVDBID) +} + func cloudSeriesTitleFromMediaPath(mediaPath string) (string, int) { displayPath := strings.TrimSpace(mediaPath) if strings.HasPrefix(strings.ToLower(displayPath), "cloud://") { diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index e58b56f..bceefcb 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -92,6 +92,7 @@ func CleanQuery(raw string) (title string, year int) { lower = bracketedTag.ReplaceAllString(lower, " ") lower = patSEnE.ReplaceAllString(lower, " ") + lower = patDanglingSE.ReplaceAllString(lower, " ") lower = patNxE.ReplaceAllString(lower, " ") lower = patEP.ReplaceAllString(lower, " ") lower = patCN.ReplaceAllString(lower, " ") diff --git a/internal/service/scraper_query_test.go b/internal/service/scraper_query_test.go index 600237f..5721693 100644 --- a/internal/service/scraper_query_test.go +++ b/internal/service/scraper_query_test.go @@ -32,6 +32,7 @@ func TestCleanQuery(t *testing.T) { {"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024}, {"紫川 (2024) {tmdb-247590}", "紫川", 2024}, {"HNTV.Spring.Festival.Gala.FPS.HLG-QHStudio.S01E202-DD5.QHstudIo.6.4K.ts", "hntv spring festival gala", 0}, + {"Hntv Spring Festival Gala S01e (2026)", "hntv spring festival gala", 2026}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) {