fix(media): a number too long to be an episode is not one

The Attack on Titan box set on the array names two files S03E2019 and
S04E47370355, and the provider rows the metadata server recorded for them carry
the same junk. EP_RANGE capped its first episode group at three digits without
guarding the run, so the pass-2 plan filed both under E201 and E473 — slots
that belong to real episodes — inside 進撃の巨人 (2013) [tvdbid-267440].

- guard the first episode group the way the second was already guarded, so a
  longer digit run states no marker at all; a glued resolution stays legal,
  because S01E01-1080p is still ONE episode
- cap a provider row's episode at EP_MAX: such a row is renumbered from tvdb's
  title when the series' id answers, and parks with a reason that names the
  junk when nothing answers
- split the operator-map exceptions into the two things they are: a folder that
  IS on the array whose files never became a series (bonus material with no
  owner: Fairy Tail, Kimetsu no Yaiba, Dorohedoro) is no longer reported as a
  folder the array no longer holds, and the missing-folder hint says the review
  pile counts as moved because the map reads top-level folders only
- the vault's evidence list carries the law; the four tests were read red
  against the shipping planner before any of it changed

Replanning the two real files now parks them under their own names with the
reasons "a provider row of season 3 episode 2019" and
"… season 4 episode 47370355".
This commit is contained in:
Frank committed 2026-10-31 14:49:12 -07:00
1 parent 9c25b1cccb
commit 2625ad1083
3 files changed
+91 -11

No files matched your search

+33 -10
View File
@@ -168,12 +168,16 @@ SEASON_META = re.compile(r"^season\.nfo$", re.I)
FOLDER_IMAGE = re.compile(r"^folder\.(jpg|jpeg|png|webp|bmp)$", re.I)
SEASON_DIR_RE = re.compile(r"(?i)^\s*(?:season[\s._-]*(\d{1,3})|s(\d{1,2}))\s*$")
BAD_CHARS = re.compile(r'[<>:"/|?*]')
# SxxEyy with an optional second episode, plus the 1x04 form. The second number
# is guarded so a resolution never reads as an episode: "S01E01-1080p" is ONE
# episode, and the longest-match backtracking of \d{1,3} is what the (?![\dpP])
# after it closes off.
# SxxEyy with an optional second episode, plus the 1x04 form. Both episode numbers
# are guarded so a longer digit run never reads as an episode. A resolution glued
# on stays legal — "S01E01-1080p" is ONE episode — while "S03E2019" and
# "S04E47370355" (both real names on the array, from a box set whose provider rows
# say the same junk) state no episode at all: the run is an airdate or a pack
# index, and the first three digits of it would steal E201 and E473 from the
# episodes that own them. No marker means the file is numbered by its provider
# row, or by tvdb's title, or it parks.
EP_RANGE = re.compile(r"(?i)(?<![a-z0-9])s(?:eason[\s._-]*)?(\d{1,2})[\s._-]*"
r"e(?:p(?:isode)[\s._-]*)?(\d{1,3})"
r"e(?:p(?:isode)[\s._-]*)?(\d{1,3})(?![\dpP])"
r"(?:[\s._-]*(?:-|to[\s._-]*|ep)[\s._-]*(?:s\d{1,2}[\s._-]*)?e?"
r"(\d{1,3})(?![\dpP]))?")
NX_RE = re.compile(r"(?i)(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![a-z0-9])")
@@ -201,6 +205,11 @@ EXTRA_KIND = {"trailer": "trailers", "tmbd": "trailers", "intro": "other",
"deleted": "deleted scenes", "other": "other", "epilogue": "other",
"opening": "other", "featurette": "featurettes", "short": "shorts"}
SHORT_MAX = 45 * 60 # over this, a file with no episode evidence is film-shaped
# An episode number above this is not an episode number: the array's box sets
# carry 2019 (an airdate fragment) and 47370355 (a pack index) in the column the
# metadata server recorded as the match. tvdb's episode list is the only door on
# such a row; nothing files at E1000 or beyond.
EP_MAX = 999
def rel(p):
@@ -748,10 +757,11 @@ for r in video:
else:
season = fs if fs is not None else num(t["season"])
ep = leading_index(r["base"]) or num(t["episode"])
if not 0 <= season <= 200 or ep <= 0:
if not 0 <= season <= 200 or not 1 <= ep <= EP_MAX:
# Season 0 IS a season: specials live in Season 00 by Jellyfin's own
# contract. A season that is a YEAR is the date matcher's doing, and
# the only authority on it is tvdb's episode list for the series.
# contract. A season that is a YEAR is the date matcher's doing and an
# episode above EP_MAX is a box set's airdate or pack index; neither is
# a number, and the only authority on either is tvdb's episode list.
recovered = tvdb_number(t)
if not recovered:
unknown.append(
@@ -1199,7 +1209,9 @@ def write_exceptions():
f"{n} S{'/'.join(f'{x:02d}' for x in ss)}"
for n, ss in clash[:300]) + "\n")
map_ids = {e["id"] for e in MAP.values()}
unseen = sorted(f for f in MAP if f not in MAP_SEEN)
on_array = {rel(p).split("/")[0] for p in held}
stalled = sorted(f for f in MAP if f not in MAP_SEEN and f in on_array)
unseen = sorted(f for f in MAP if f not in MAP_SEEN and f not in on_array)
unanswered = sorted({f"{identity(s)[0]} ({s['tvdb']})"
for s in canon.values()
if s.get("tvdb") in map_ids and not s.get("canon")})
@@ -1211,9 +1223,20 @@ def write_exceptions():
" and re-plan, because a rename is idempotent.\n")
fh.write(" " + "; ".join(
f"{f} -> {MAP[f]['id']}" for f in sorted(MAP_SEEN)) + "\n")
if stalled:
fh.write(f"\n{len(stalled):>6} map rows name a folder on the array that\n")
fh.write(" never became a series: every file in it parked, so the id\n"
" had nothing to name. The map names a RECORD — it numbers\n"
" no episodes and owns no bonus material, so read the park\n"
" reasons above this line: a folder of openings, endings and\n"
" promos with no show to claim them is the usual shape.\n")
fh.write(" " + "; ".join(
f"{f} -> {MAP[f]['id']}" for f in stalled[:300]) + "\n")
if unseen:
fh.write(f"\n{len(unseen):>6} map rows name no folder on the array: the\n")
fh.write(" pack moved, or the row carries a name the array never held.\n")
fh.write(" pack moved (into the review pile counts: the map reads\n"
" top-level folders only), or the row carries a name the\n"
" array never held.\n")
fh.write(" " + "; ".join(unseen[:300]) + "\n")
if MAP_CLASH:
fh.write(f"\n{len(MAP_CLASH):>6} folders carry a provider tvdb id the map\n")
@@ -228,7 +228,10 @@ scripts/media-apply.py apply --plan /mnt/entropy/media-relayout-shows2-plan.tsv
Evidence, strongest first — the order the planner believes things:
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`);
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`); a digit
run longer than three at that position is NOT a number — `S03E2019` and
`S04E47370355` are one box set's airdate fragment and pack index — so such a file
is renumbered by tvdb's title or parks, and is never truncated down to fit;
- the **`Season NN` folder it sits in and the number its name leads with inside
it**: `Season 19/01 Charles Barkley, Nirvana.avi` is S19E01;
- the **provider row**: which episode the file IS, and the numbering of a file
+54
View File
@@ -513,6 +513,46 @@ PY
[[ "$output" == *"Best Of The Examples.mkv"* ]]
}
# A release pack can carry a number that is not an episode at all: two files of
# the Attack on Titan box set on the array are named S03E2019 and S04E47370355,
# and the provider rows the server recorded for them say the same junk. Reading
# either as "the first three digits" files the box set under E201 and E473 and
# steals those episode slots from their real owners, so a run too long to be an
# episode is no marker and no number: the file is renumbered from tvdb's title
# when the series' id answers it, and parks when nothing answers.
@test "an episode number longer than three digits never files as an episode" {
file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000
title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" "Attack Pack" 2013 3 2019
plan_park
run grep -c -- "- S03E201 -" "$OUT"
[ "$output" = "0" ]
[[ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" == "$OUTSIDE/"* ]]
grep -q "a provider row of season 3 episode 2019" "$EXC"
}
@test "a provider episode too long to be one is renumbered by the title tvdb states" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "Attack Pack", "year": "2013"}' \
'{"3/7": "A Title tvdb Holds"}' > "$CACHE"
file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000
title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" \
"Attack Pack" 2013 3 2019 "" "" "A Title tvdb Holds" "4242"
plan
want="$LIB/Attack Pack (2013) [tvdbid-4242]/Season 03"
want+="/Attack Pack (2013) - S03E07 - A Title tvdb Holds.mkv"
[ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" = "$want" ]
}
@test "a resolution glued to the marker still reads as one episode" {
file "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" 5000
title_row "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" \
"The Examples" 2010 1 2 "" "" "Second Look"
plan
[ "$(dst "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv")" \
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E02 - Second Look.mkv" ]
}
# Anime numbering is the one place a tvdb title can be confidently wrong: a pack
# numbered by its release group still names S05E01, and tvdb answers that slot with
# a different episode. A season the authority does not hold, or holds fewer of than
@@ -655,6 +695,20 @@ plan_map() { # the plan, with the operator's map in han
[ "$status" -eq 0 ]
}
@test "a map row whose folder is on the array but never became a series says so" {
# The array holds three folders exactly like this one: a credits/extras pack
# the operator mapped to a record, whose every file is bonus material with no
# series to own it. The folder IS on the array, and telling the operator the
# pack moved sends them hunting for a folder that never left.
printf 'Credit Pack\t114801\tthe record tvdb names\n' > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Credit Pack/01. Extras/NCOP 01.mkv" 1200
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
grep -q "bonus material with no series to own it" "$EXC"
grep -q "never became a series" "$EXC"
run grep "no folder on the array" "$EXC"
[ "$status" -ne 0 ]
}
@test "a map line whose id is not a number stops the plan" {
printf 'Black Clover 001\tnotanid\ttypo\n' > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000