Compare commits

...
Author SHA1 Message Date
QuickandClaude Fable 5 712969d351 fix(naming): let trailing bare IMDb ids anchor group identity
hasStructuredIDAnchor only consulted ParseStructuredFolderIDs, so folders
tagged with an unbracketed trailing id ("Eggs Run (2021) tt8049994") still
went ambiguous on title conflicts. ParseFolderIDs now returns only explicit
evidence (structured tags plus unambiguous tt-prefixed ids), so use it for
the anchor check. Covers the last 3 ambiguous-with-IDs groups on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:58:00 -04:00
QuickandClaude Fable 5 9ada640fcc fix(naming): merge trailing bare IMDb id with structured folder tags
ParseFolderIDs returned early on any structured tag, so a folder like
"Show [tvdbid-81189] tt1375666" lost the trailing IMDb id. Parse both and
merge, with an explicit structured imdb tag still taking precedence over a
trailing bare id. Matches Jellyfin, which resolves each provider key
independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:35:19 -04:00
Quick 296d366af9 Merge remote-tracking branch 'origin/main' into worktree-scanner-matching-fixes 2026-06-09 15:51:28 -04:00
QuickandClaude Fable 5 46807e079c fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries
Three scanner/matching fixes validated against dev data:

- Group identity: explicit structured provider tags ({tmdb-...},
  [tvdbid-...]) now anchor a group's identity, so folder/file title
  conflicts (renamed releases in Radarr-tagged folders) no longer mark
  groups ambiguous and silently exclude them from matching. 3,049 of
  3,121 ambiguous groups on dev carried explicit tags.

- ParseFolderIDs: remove bare trailing numeric ID parsing entirely,
  mirroring Jellyfin's path-attribute model (bracketed key tags plus
  unambiguous tt-prefixed IMDb ids only). Titles ending in numbers
  ("District 9", "Beverly Hills 90210", "Season 01") were misparsed as
  trusted IDs, which suppresses title search and silently mismatches.
  The folderType parameter existed only to type bare numerics, so it
  is gone too.

- Match queues: replace the constant 15s/30s retry delay with shared
  exponential backoff capped at 24h. Terminal failures ("no metadata
  found from any provider") had rows at 15k+ attempts hot-looping
  every 15s on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:51:28 -04:00
11 changed files with 271 additions and 110 deletions
+26 -2
View File
@@ -18,8 +18,32 @@ const (
movieQueueRetryDelay = 15 * time.Second
seriesRootQueueQuietWindow = 10 * time.Second
seriesRootQueueRetryDelay = 30 * time.Second
// matchQueueRetryMaxDelay caps the exponential retry backoff shared by both
// match queues. Terminal outcomes like "no metadata found from any
// provider" stay in the queue but decay to at most one attempt per day
// instead of hot-looping at the base delay forever. A row self-heals by
// being deleted on the first successful attempt.
matchQueueRetryMaxDelay = 24 * time.Hour
// matchQueueBackoffMaxExponent clamps the 2^attempt_count factor so the
// float math stays finite for rows that accumulated very large attempt
// counts before backoff existed.
matchQueueBackoffMaxExponent = 16
)
// matchQueueBackoffExpr returns the SQL expression both match queues use to
// schedule the next retry after a failure: base_delay * 2^attempt_count,
// capped at matchQueueRetryMaxDelay. attempt_count is incremented when a row
// is claimed, so the first failure backs off to twice the base delay.
// basePlaceholder and maxPlaceholder name the interval bind parameters (e.g.
// "$3", "$4") in the caller's statement.
func matchQueueBackoffExpr(basePlaceholder, maxPlaceholder string) string {
return fmt.Sprintf(
"NOW() + LEAST(%s::interval * power(2::float8, LEAST(attempt_count, %d)), %s::interval)",
basePlaceholder, matchQueueBackoffMaxExponent, maxPlaceholder,
)
}
type MovieMatchQueueRepository struct {
pool *pgxpool.Pool
fileRepo *scannerrepo.FileRepository
@@ -477,10 +501,10 @@ func (r *MovieMatchQueueRepository) UpdateError(ctx context.Context, mediaFileID
if _, err := r.pool.Exec(ctx, `
UPDATE movie_match_queue
SET last_error = $2,
available_at = NOW() + $3::interval,
available_at = `+matchQueueBackoffExpr("$3", "$4")+`,
updated_at = NOW()
WHERE media_file_id = $1
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay)); err != nil {
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay)); err != nil {
return fmt.Errorf("updating movie queue error: %w", err)
}
return nil
@@ -550,10 +550,10 @@ func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folder
_, err := r.pool.Exec(ctx, `
UPDATE series_root_match_queue
SET last_error = $3,
available_at = NOW() + $4::interval,
available_at = `+matchQueueBackoffExpr("$4", "$5")+`,
updated_at = NOW()
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay))
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay))
if err != nil {
return fmt.Errorf("updating series root queue error: %w", err)
}
+2 -6
View File
@@ -3856,10 +3856,6 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
if res.Type == "" {
res.Type = "movie"
}
folderType := res.Type
if folderType == "" {
folderType = "movie"
}
// Explicit structured IDs from the file or folder are treated as trusted.
// When present, they override scanner ambiguity and become the authoritative
@@ -3874,9 +3870,9 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
// Parse observed location names for external IDs before falling back to the
// legacy canonical root path. This preserves existing heuristic behavior for
// roots without explicit structured tags.
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath), folderType)
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath))
if folderIDs == nil && contentRootPath != "" && contentRootPath != observedRootPath {
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath), folderType)
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath))
}
effectiveExternalIDs := folderIDs
+20 -46
View File
@@ -3,7 +3,6 @@ package naming
import (
"regexp"
"strings"
"unicode"
)
// folderIDPattern matches patterns like [tmdbid-27205], {tmdb-27205},
@@ -11,7 +10,6 @@ import (
// The regex captures the provider prefix and the ID value.
var folderIDPattern = regexp.MustCompile(`[{\[](tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-([\w]+)[}\]]`)
var trailingImdbIDPattern = regexp.MustCompile(`(?i)(?:^|\s)(tt\d{7,8})$`)
var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`)
// bracketedBareImdbPattern matches a bare IMDb id wrapped in brackets without a
// provider prefix, e.g. [tt10011226] or {tt0095016} (Plex/Kodi-style tags). A
@@ -48,54 +46,30 @@ func ParseStructuredFolderIDs(name string) *FolderIDHints {
return hints
}
// ParseFolderIDs extracts external provider IDs from a folder name.
// It supports bracket styles [] and {} with provider prefixes tmdb/tmdbid,
// imdb/imdbid, tvdb/tvdbid, plus bare trailing IDs. Bare numeric IDs are
// interpreted using folderType: series -> TVDB, everything else -> TMDB.
func ParseFolderIDs(folderName string, folderType string) *FolderIDHints {
if hints := ParseStructuredFolderIDs(folderName); hints != nil {
return hints
}
// ParseFolderIDs extracts external provider IDs from a folder name. Mirroring
// Jellyfin's path-attribute model, only explicit evidence is honored: bracket
// tags with provider prefixes ({tmdb-27205}, [tvdbid-81189], [imdbid-tt1375666]),
// bracketed bare IMDb ids ([tt1375666]), and a trailing bare IMDb id — the
// "tt" prefix makes IMDb ids unambiguous without brackets. Bare trailing
// numbers are never treated as IDs: titles legitimately end in numbers
// ("District 9", "Beverly Hills 90210"), no mainstream tool emits bare-number
// tags, and a misparsed ID becomes a trusted match hint downstream where it
// silently produces a wrong match or blocks matching entirely.
func ParseFolderIDs(folderName string) *FolderIDHints {
hints := ParseStructuredFolderIDs(folderName)
// A trailing bare IMDb id complements structured tags for other providers
// ("Show [tvdbid-81189] tt1375666"); an explicit structured imdb tag still
// wins when both are present.
trimmed := strings.TrimSpace(folderName)
if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil {
return &FolderIDHints{ImdbID: strings.ToLower(m[1])}
}
m := trailingNumericIDPattern.FindStringSubmatch(trimmed)
if m == nil {
return nil
}
id := m[1]
if looksLikeYear(id) {
return nil
}
// A bare trailing number is only an ID when appended to a real title. If the
// name has no letters (e.g. "86", "22 7"), it's a numeric title, not an ID.
// Trade-off: a folder named ONLY a bare provider id (e.g. "81189") with no
// title text now returns nil rather than that id — acceptable because
// Sonarr/Radarr never produce bare-number folders.
if !containsLetter(trimmed) {
return nil
}
if strings.EqualFold(strings.TrimSpace(folderType), "series") {
return &FolderIDHints{TvdbID: id}
}
return &FolderIDHints{TmdbID: id}
}
func containsLetter(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
if hints == nil {
return &FolderIDHints{ImdbID: strings.ToLower(m[1])}
}
if hints.ImdbID == "" {
hints.ImdbID = strings.ToLower(m[1])
}
}
return false
}
func looksLikeYear(value string) bool {
return len(value) == 4 && value >= "1800" && value <= "2100"
return hints
}
+58 -31
View File
@@ -2,36 +2,63 @@ package naming
import "testing"
func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) {
// Numeric-only anime titles must NOT be parsed as a bare trailing tvdb/tmdb id.
if got := ParseFolderIDs("86", "series"); got != nil {
t.Errorf(`ParseFolderIDs("86","series") = %+v, want nil`, got)
}
if got := ParseFolderIDs("22 7", "series"); got != nil {
t.Errorf(`ParseFolderIDs("22 7","series") = %+v, want nil`, got)
}
// A real bare trailing id with title text must still be parsed.
got := ParseFolderIDs("Some Show 81189", "series")
if got == nil || got.TvdbID != "81189" {
t.Errorf(`ParseFolderIDs("Some Show 81189","series") = %+v, want TvdbID="81189"`, got)
}
// Structured tags must still win regardless of letters.
got = ParseFolderIDs("{tmdb-27205}", "movies")
if got == nil || got.TmdbID != "27205" {
t.Errorf(`ParseFolderIDs("{tmdb-27205}","movies") = %+v, want TmdbID="27205"`, got)
}
// Non-Latin (CJK) title with a trailing real id must still parse —
// unicode.IsLetter covers kana/kanji, so this is treated as title + id.
got = ParseFolderIDs("進撃の巨人 73743", "series")
if got == nil || got.TvdbID != "73743" {
t.Errorf(`ParseFolderIDs("進撃の巨人 73743","series") = %+v, want TvdbID="73743"`, got)
}
// Numeric-only title for a movies library is also not an id.
if got := ParseFolderIDs("86", "movies"); got != nil {
t.Errorf(`ParseFolderIDs("86","movies") = %+v, want nil`, got)
// Bare numbers in folder names are never provider IDs. This mirrors
// Jellyfin's path-attribute model: only explicit bracket tags (and
// unambiguous tt-prefixed IMDb ids) carry identity. Titles legitimately end
// in numbers, and a misparsed bare ID becomes a trusted match hint that
// silently mismatches or blocks matching.
func TestParseFolderIDs_BareNumbersAreNeverIDs(t *testing.T) {
for _, c := range []string{
// Numeric-only titles (anime and otherwise).
"86",
"22 7",
// Titles ending in a short number.
"District 9",
"Apollo 13",
"Ocean's 11",
"THX 1138",
"Stranger Things 4",
// Season-style directory names.
"Season 01",
// Titles ending in a long number that looks like a plausible ID.
"Beverly Hills 90210",
// Bare trailing numbers that previously parsed as IDs.
"Some Show 81189",
"進撃の巨人 73743",
} {
if got := ParseFolderIDs(c); got != nil {
t.Errorf(`ParseFolderIDs(%q) = %+v, want nil`, c, got)
}
}
}
func TestParseFolderIDs_StructuredTagsStillParse(t *testing.T) {
got := ParseFolderIDs("{tmdb-27205}")
if got == nil || got.TmdbID != "27205" {
t.Errorf(`ParseFolderIDs("{tmdb-27205}") = %+v, want TmdbID="27205"`, got)
}
got = ParseFolderIDs("Some Show (2010) [tvdbid-81189]")
if got == nil || got.TvdbID != "81189" {
t.Errorf(`ParseFolderIDs("Some Show (2010) [tvdbid-81189]") = %+v, want TvdbID="81189"`, got)
}
// Trailing bare IMDb ids stay supported — the tt prefix is unambiguous.
got = ParseFolderIDs("Some Movie (2010) tt1375666")
if got == nil || got.ImdbID != "tt1375666" {
t.Errorf(`ParseFolderIDs("Some Movie (2010) tt1375666") = %+v, want ImdbID="tt1375666"`, got)
}
// A structured tag for one provider must not swallow a trailing bare IMDb
// id for another — both merge.
got = ParseFolderIDs("Some Show (2010) [tvdbid-81189] tt1375666")
if got == nil || got.TvdbID != "81189" || got.ImdbID != "tt1375666" {
t.Errorf(`ParseFolderIDs("Some Show (2010) [tvdbid-81189] tt1375666") = %+v, want TvdbID="81189" and ImdbID="tt1375666"`, got)
}
// An explicit structured imdb tag still wins over a trailing bare id.
got = ParseFolderIDs("Some Movie [imdb-tt1111111] tt2222222")
if got == nil || got.ImdbID != "tt1111111" {
t.Errorf(`ParseFolderIDs("Some Movie [imdb-tt1111111] tt2222222") = %+v, want ImdbID="tt1111111"`, got)
}
}
+4 -4
View File
@@ -11,19 +11,19 @@ func TestParseFolderIDs_BracketedBareImdb(t *testing.T) {
{"8-digit tt", "Movie (2024) [tt12345678]", "tt12345678"},
}
for _, c := range cases {
got := ParseFolderIDs(c.folder, "movie")
got := ParseFolderIDs(c.folder)
if got == nil || got.ImdbID != c.wantImdb {
t.Errorf("%s: ParseFolderIDs(%q) = %+v, want ImdbID=%q", c.name, c.folder, got, c.wantImdb)
}
}
// must NOT change existing behavior:
if got := ParseFolderIDs("Movie [imdb-tt1375666]", "movie"); got == nil || got.ImdbID != "tt1375666" {
if got := ParseFolderIDs("Movie [imdb-tt1375666]"); got == nil || got.ImdbID != "tt1375666" {
t.Errorf("structured imdb regressed: %+v", got)
}
if got := ParseFolderIDs("Some Movie (2020) [BB]", "movie"); got != nil {
if got := ParseFolderIDs("Some Movie (2020) [BB]"); got != nil {
t.Errorf("non-tt bracket tag must not parse as id, got %+v", got)
}
if got := ParseFolderIDs("17 Blocks (2021)", "movie"); got != nil {
if got := ParseFolderIDs("17 Blocks (2021)"); got != nil {
t.Errorf("no-id folder must return nil, got %+v", got)
}
}
+45 -13
View File
@@ -47,13 +47,20 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
}
}
// Explicit structured provider IDs (e.g. {tmdb-694938}) anchor the group's
// identity regardless of how folder and file titles relate, so title
// conflicts must not mark the group ambiguous. Renamed releases inside a
// tagged folder ("Override (2021) {tmdb-694938}" containing "R.I.A.
// (2021).mkv") are the common case.
idAnchored := hasStructuredIDAnchor(cleanFilePath, group.ObservedRootPath)
if group.BaseType == "series" {
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, &group)
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, idAnchored, &group)
} else {
populateMovieGroupIdentity(cleanFilePath, assignment, &group)
populateMovieGroupIdentity(cleanFilePath, assignment, idAnchored, &group)
}
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath), group.BaseType); ids != nil {
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath)); ids != nil {
group.TmdbID = ids.TmdbID
group.ImdbID = ids.ImdbID
group.TvdbID = ids.TvdbID
@@ -79,7 +86,7 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
return group
}
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, group *GroupIdentity) {
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
parentDir := filepath.Dir(filePath)
parentTitle, parentYear, parentTrusted := parseInferFolderTitleYear(filepath.Base(group.ObservedRootPath))
parentTitle = StripComparisonSafeEditionSuffix(parentTitle)
@@ -107,8 +114,12 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
case parentTrusted && relation == titleRelationSoft:
reasons = append(reasons, "variant_suffix")
case parentTrusted && relation == titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "unrelated_title")
if idAnchored {
reasons = append(reasons, "unrelated_title_resolved_by_provider_ids")
} else {
state = "ambiguous"
reasons = append(reasons, "unrelated_title")
}
default:
if baseTitle == "" {
baseTitle = StripComparisonSafeEditionSuffix(stem.Title)
@@ -130,7 +141,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
baseYear = parentYear
}
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted {
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted && !idAnchored {
state = "ambiguous"
reasons = append(reasons, "episode_pattern")
}
@@ -154,7 +165,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
})
}
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, group *GroupIdentity) {
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
ctx := ResolvePathContext(filePath, libraryType)
title := assignment.Title
year := assignment.Year
@@ -179,8 +190,12 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
case titleRelationSoft:
reasons = append(reasons, "series_alias")
case titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "series_title_conflict")
if idAnchored {
reasons = append(reasons, "series_title_conflict_resolved_by_provider_ids")
} else {
state = "ambiguous"
reasons = append(reasons, "series_title_conflict")
}
}
if year == 0 {
year = observedYear
@@ -191,8 +206,12 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
case titleRelationSoft:
reasons = append(reasons, "series_assignment_soft_conflict")
case titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "series_assignment_conflict")
if idAnchored {
reasons = append(reasons, "series_assignment_conflict_resolved_by_provider_ids")
} else {
state = "ambiguous"
reasons = append(reasons, "series_assignment_conflict")
}
}
}
@@ -206,7 +225,7 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
if assignment.HasEpisodePattern && (assignment.HasSeasonStructure || (ctx != nil && ctx.HasSeasonStructure)) && state != "ambiguous" {
confidence = "high"
}
if title == "" && assignment.HasEpisodePattern {
if title == "" && assignment.HasEpisodePattern && !idAnchored {
state = "ambiguous"
reasons = append(reasons, "series_identity_missing")
}
@@ -229,6 +248,19 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
})
}
// hasStructuredIDAnchor reports whether the observed root folder or the file
// name carries explicit provider IDs — structured tags (e.g. {tmdb-694938},
// [tvdbid-81189]) or an unambiguous tt-prefixed IMDb id ("Eggs Run (2021)
// tt8049994"). ParseFolderIDs returns only such explicit evidence, which is
// strong enough to anchor identity over a title conflict.
func hasStructuredIDAnchor(filePath, observedRootPath string) bool {
if ParseFolderIDs(filepath.Base(observedRootPath)) != nil {
return true
}
baseNoExt := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
return ParseFolderIDs(baseNoExt) != nil
}
func deriveObservedRootPath(filePath, candidateRoot string) string {
cleanFilePath := filepath.Clean(filePath)
cleanRoot := filepath.Clean(candidateRoot)
+108
View File
@@ -0,0 +1,108 @@
package naming
import "testing"
// A renamed release inside a provider-tagged folder must stay resolved: the
// structured IDs anchor the identity even though folder and file titles are
// unrelated (Radarr keeps the folder name from grab time while the release
// inside carries the retitled name).
func TestInferGroupIdentity_StructuredIDsResolveMovieTitleConflict(t *testing.T) {
group := InferGroupIdentity(
"/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
"movies",
RootAssignment{
RootPath: "/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}",
InferredType: "movie",
},
)
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.BaseTitle, "On Fire"; got != want {
t.Fatalf("BaseTitle = %q, want %q", got, want)
}
if got, want := group.TmdbID, "1196791"; got != want {
t.Fatalf("TmdbID = %q, want %q", got, want)
}
if got, want := group.ImdbID, "tt28078628"; got != want {
t.Fatalf("ImdbID = %q, want %q", got, want)
}
}
// An unbracketed trailing IMDb id anchors identity the same way a structured
// tag does — the tt prefix is unambiguous. Mirrors the dev-library case
// "Eggs Run (2021) tt8049994" whose file carries a different release title.
func TestInferGroupIdentity_TrailingImdbIDResolvesMovieTitleConflict(t *testing.T) {
group := InferGroupIdentity(
"/movies-int/es/Eggs Run (2021) tt8049994/Huevitos Congelados (2022) [WEBDL-1080p]-GRP.mkv",
"movies",
RootAssignment{
RootPath: "/movies-int/es/Eggs Run (2021) tt8049994",
InferredType: "movie",
},
)
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.ImdbID, "tt8049994"; got != want {
t.Fatalf("ImdbID = %q, want %q", got, want)
}
}
// Without provider IDs the same folder/file title conflict must still be
// flagged ambiguous — there is nothing to anchor the identity.
func TestInferGroupIdentity_TitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
group := InferGroupIdentity(
"/movies/20s/On Fire (2024)/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
"movies",
RootAssignment{
RootPath: "/movies/20s/On Fire (2024)",
InferredType: "movie",
},
)
if got, want := group.State, "ambiguous"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
}
func TestInferGroupIdentity_StructuredIDsResolveSeriesTitleConflict(t *testing.T) {
group := InferGroupIdentity(
"/tv/Border Control - Sweden (2023) {tvdb-442777}/Season 01/Gränsbevakarna Sverige S01E01.mkv",
"series",
RootAssignment{
RootPath: "/tv/Border Control - Sweden (2023) {tvdb-442777}",
InferredType: "series",
Title: "Gränsbevakarna Sverige",
HasSeasonStructure: true,
HasEpisodePattern: true,
},
)
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.TvdbID, "442777"; got != want {
t.Fatalf("TvdbID = %q, want %q", got, want)
}
}
func TestInferGroupIdentity_SeriesTitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
group := InferGroupIdentity(
"/tv/Border Control - Sweden (2023)/Season 01/Gränsbevakarna Sverige S01E01.mkv",
"series",
RootAssignment{
RootPath: "/tv/Border Control - Sweden (2023)",
InferredType: "series",
Title: "Gränsbevakarna Sverige",
HasSeasonStructure: true,
HasEpisodePattern: true,
},
)
if got, want := group.State, "ambiguous"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
}
+1 -1
View File
@@ -157,7 +157,7 @@ func parseInferFolderTitleYear(name string) (string, int, bool) {
year, _ := strconv.Atoi(match[2])
return strings.TrimSpace(match[1]), year, true
}
if ids := ParseFolderIDs(name, "movie"); ids != nil || ParseFolderIDs(name, "series") != nil {
if ParseFolderIDs(name) != nil {
return strings.TrimSpace(surface), 0, true
}
return strings.TrimSpace(surface), 0, false
+4 -4
View File
@@ -89,7 +89,7 @@ func InferRootAssignments(
Year: assignment.Year,
},
}
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); ids != nil {
agg.hasFolderIDs = true
agg.root.TmdbID = ids.TmdbID
agg.root.ImdbID = ids.ImdbID
@@ -254,7 +254,7 @@ func inferFileRootAssignment(
}
}
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); ids != nil {
assignment.HasFolderIDs = true
}
@@ -369,7 +369,7 @@ func detectInferMovieFolderEvidence(parentBase string, nameNoExt string, hasSeas
if hasSeasonStructure {
return false
}
if ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil {
if ParseFolderIDs(parentBase) != nil {
return true
}
parentTitle, parentYear, trusted := parseInferFolderTitleYear(parentBase)
@@ -457,7 +457,7 @@ func promoteCandidateRoot(filePath, currentRoot, title string, year int) (string
parentBase := filepath.Base(parent)
currentBase := filepath.Base(cleanRoot)
parentIDs := ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil
parentIDs := ParseFolderIDs(parentBase) != nil
childTitle, childYear := parseInferTitleYear(currentBase)
parentTitle, parentYear := parseInferTitleYear(parentBase)
+1 -1
View File
@@ -86,7 +86,7 @@ func observationFromAssignment(assignment fileRootAssignment) RootObservation {
}
func observationFromSnapshot(snapshot models.ScannedMediaRoot) RootObservation {
hasFolderIDs := naming.ParseFolderIDs(filepath.Base(snapshot.RootPath), snapshot.InferredType) != nil
hasFolderIDs := naming.ParseFolderIDs(filepath.Base(snapshot.RootPath)) != nil
observation := RootObservation{
RootPath: snapshot.RootPath,
SampleFilePath: snapshot.SampleFilePath,