mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 12:26:36 +08:00
fix: refresh stale scan-derived media metadata
This commit is contained in:
@@ -144,10 +144,15 @@ func addMediaTitleUpdates(updates map[string]any, existing, incoming model.Media
|
|||||||
// 真实剧名。仅在 existing 还停留在 'pending'/'' 时回填扫描标题,
|
// 真实剧名。仅在 existing 还停留在 'pending'/'' 时回填扫描标题,
|
||||||
// 避免覆盖刮削结果。
|
// 避免覆盖刮削结果。
|
||||||
if incoming.ScrapeStatus == "matched" || existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" {
|
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)
|
setIfChanged(updates, "title", existing.Title, incoming.Title)
|
||||||
if incoming.Year > 0 {
|
if incoming.Year > 0 {
|
||||||
setIfChanged(updates, "year", existing.Year, incoming.Year)
|
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
|
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
|
updates["season_num"] = incoming.SeasonNum
|
||||||
}
|
}
|
||||||
if incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum {
|
if episodeChanged {
|
||||||
updates["episode_num"] = incoming.EpisodeNum
|
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) {
|
func addMediaSTRMUpdate(updates map[string]any, existing, incoming model.Media) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
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})`)
|
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func metadataTrustKey(value string) string {
|
|||||||
}
|
}
|
||||||
value = bracketedTag.ReplaceAllString(value, " ")
|
value = bracketedTag.ReplaceAllString(value, " ")
|
||||||
value = yearPattern.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, " ")
|
value = re.ReplaceAllString(value, " ")
|
||||||
}
|
}
|
||||||
tokens := metadataTrustTokenRE.FindAllString(value, -1)
|
tokens := metadataTrustTokenRE.FindAllString(value, -1)
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ func extractOrganizeReleaseTag(source string) string {
|
|||||||
}
|
}
|
||||||
value := strings.Trim(matches[1], " ._-[]()")
|
value := strings.Trim(matches[1], " ._-[]()")
|
||||||
value = patSEnE.ReplaceAllString(value, " ")
|
value = patSEnE.ReplaceAllString(value, " ")
|
||||||
|
value = patDanglingSE.ReplaceAllString(value, " ")
|
||||||
value = patNxE.ReplaceAllString(value, " ")
|
value = patNxE.ReplaceAllString(value, " ")
|
||||||
value = patEP.ReplaceAllString(value, " ")
|
value = patEP.ReplaceAllString(value, " ")
|
||||||
value = patCN.ReplaceAllString(value, " ")
|
value = patCN.ReplaceAllString(value, " ")
|
||||||
|
|||||||
@@ -30,22 +30,6 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
expectedSTRMURL := BuildRelativeCloudPlayURL(typ, ref)
|
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{
|
m := &model.Media{
|
||||||
LibraryID: lib.ID,
|
LibraryID: lib.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -55,6 +39,8 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library
|
|||||||
Container: strings.TrimPrefix(ext, "."),
|
Container: strings.TrimPrefix(ext, "."),
|
||||||
STRMURL: expectedSTRMURL,
|
STRMURL: expectedSTRMURL,
|
||||||
ScrapeStatus: "pending",
|
ScrapeStatus: "pending",
|
||||||
|
SeasonNum: parsedSeason,
|
||||||
|
EpisodeNum: parsedEpisode,
|
||||||
}
|
}
|
||||||
if ext == ".strm" {
|
if ext == ".strm" {
|
||||||
if targetURL, err := s.resolveCloudSTRMTarget(ctx, typ, ref); err == nil && targetURL != "" {
|
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))
|
s.log.Debug("read cloud strm failed", zap.String("ref", ref), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.SeasonNum = parsedSeason
|
|
||||||
m.EpisodeNum = parsedEpisode
|
|
||||||
if localMeta != nil {
|
if localMeta != nil {
|
||||||
applyLocalMetadata(m, localMeta)
|
applyLocalMetadata(m, localMeta)
|
||||||
res.LocalMetadata++
|
|
||||||
s.queueCloudArtworkPrefetch(localMeta.PosterURL)
|
s.queueCloudArtworkPrefetch(localMeta.PosterURL)
|
||||||
s.queueCloudArtworkPrefetch(localMeta.BackdropURL)
|
s.queueCloudArtworkPrefetch(localMeta.BackdropURL)
|
||||||
}
|
}
|
||||||
@@ -85,6 +68,25 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library
|
|||||||
m.TheTVDBID = strings.TrimSpace(hints.TheTVDBID)
|
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 {
|
if isNewMedia && writeBatch != nil {
|
||||||
var after func()
|
var after func()
|
||||||
if needsTrackProbe && ext != ".strm" {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,20 +25,6 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, pat
|
|||||||
|
|
||||||
parsedSeason, parsedEpisode := ParseEpisode(path)
|
parsedSeason, parsedEpisode := ParseEpisode(path)
|
||||||
localMeta := s.readLocalScanMetadata(lib, path, parsedSeason, parsedEpisode)
|
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{
|
media := s.buildLocalScanMedia(localScanMediaInput{
|
||||||
lib: lib,
|
lib: lib,
|
||||||
path: path,
|
path: path,
|
||||||
@@ -48,8 +34,25 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, pat
|
|||||||
parsedSeason: parsedSeason,
|
parsedSeason: parsedSeason,
|
||||||
parsedEpisode: parsedEpisode,
|
parsedEpisode: parsedEpisode,
|
||||||
localMeta: localMeta,
|
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{
|
s.writeLocalScanMedia(localScanWriteInput{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
path: path,
|
path: path,
|
||||||
@@ -99,6 +102,7 @@ type localMediaScanStateInput struct {
|
|||||||
ext string
|
ext string
|
||||||
size int64
|
size int64
|
||||||
localMeta *LocalMetadata
|
localMeta *LocalMetadata
|
||||||
|
incoming *model.Media
|
||||||
existingMedia map[string]existingLocalMedia
|
existingMedia map[string]existingLocalMedia
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +112,7 @@ func (s *ScannerService) localMediaScanState(in localMediaScanStateInput) (bool,
|
|||||||
}
|
}
|
||||||
existing, exists := in.existingMedia[in.cleanPath]
|
existing, exists := in.existingMedia[in.cleanPath]
|
||||||
isNewMedia := !exists
|
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, true
|
||||||
}
|
}
|
||||||
return isNewMedia, false
|
return isNewMedia, false
|
||||||
@@ -123,7 +127,6 @@ type localScanMediaInput struct {
|
|||||||
parsedSeason int
|
parsedSeason int
|
||||||
parsedEpisode int
|
parsedEpisode int
|
||||||
localMeta *LocalMetadata
|
localMeta *LocalMetadata
|
||||||
res *ScanResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
|
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
|
||||||
@@ -153,7 +156,6 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi
|
|||||||
}
|
}
|
||||||
if in.localMeta != nil {
|
if in.localMeta != nil {
|
||||||
applyLocalMetadata(media, in.localMeta)
|
applyLocalMetadata(media, in.localMeta)
|
||||||
in.res.LocalMetadata++
|
|
||||||
}
|
}
|
||||||
return media
|
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) {
|
func TestScanLibraryPrunesMissingMedia(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
|
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
package service
|
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 {
|
func cloudMetadataNeedsRefresh(existing existingCloudMedia, localMeta *LocalMetadata) bool {
|
||||||
if localMeta == nil {
|
if localMeta == nil {
|
||||||
@@ -157,6 +173,67 @@ func localMetadataNeedsRefresh(existing existingLocalMedia, local *LocalMetadata
|
|||||||
return local.NSFW && !existing.NSFW
|
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) {
|
func cloudSeriesTitleFromMediaPath(mediaPath string) (string, int) {
|
||||||
displayPath := strings.TrimSpace(mediaPath)
|
displayPath := strings.TrimSpace(mediaPath)
|
||||||
if strings.HasPrefix(strings.ToLower(displayPath), "cloud://") {
|
if strings.HasPrefix(strings.ToLower(displayPath), "cloud://") {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func CleanQuery(raw string) (title string, year int) {
|
|||||||
lower = bracketedTag.ReplaceAllString(lower, " ")
|
lower = bracketedTag.ReplaceAllString(lower, " ")
|
||||||
|
|
||||||
lower = patSEnE.ReplaceAllString(lower, " ")
|
lower = patSEnE.ReplaceAllString(lower, " ")
|
||||||
|
lower = patDanglingSE.ReplaceAllString(lower, " ")
|
||||||
lower = patNxE.ReplaceAllString(lower, " ")
|
lower = patNxE.ReplaceAllString(lower, " ")
|
||||||
lower = patEP.ReplaceAllString(lower, " ")
|
lower = patEP.ReplaceAllString(lower, " ")
|
||||||
lower = patCN.ReplaceAllString(lower, " ")
|
lower = patCN.ReplaceAllString(lower, " ")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func TestCleanQuery(t *testing.T) {
|
|||||||
{"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024},
|
{"紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv", "紫川", 2024},
|
||||||
{"紫川 (2024) {tmdb-247590}", "紫川", 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.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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.in, func(t *testing.T) {
|
t.Run(tc.in, func(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user