Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712969d351 | ||
|
|
9ada640fcc | ||
|
|
296d366af9 | ||
|
|
46807e079c |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user