fix: refresh stale scan-derived media metadata

This commit is contained in:
ShukeBta
2026-06-25 03:40:50 +08:00
parent 5eb0d4c8af
commit 4e00c4c14d
11 changed files with 248 additions and 41 deletions
+12 -2
View File
@@ -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) {
+1
View File
@@ -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*[集话話期]`)
+1 -1
View File
@@ -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)
+1
View File
@@ -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, " ")
+21 -19
View File
@@ -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" {
@@ -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)
}
}
+20 -18
View File
@@ -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
}
@@ -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")
+78 -1
View File
@@ -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://") {
+1
View File
@@ -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, " ")
+1
View File
@@ -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) {