mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +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'/'' 时回填扫描标题,
|
||||
// 避免覆盖刮削结果。
|
||||
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) {
|
||||
|
||||
@@ -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*[集话話期]`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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://") {
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user