fix(media): TMDb's v3 key travels as a query parameter; Bearer is a JWT only
The first tvdb-grade TV plan built 49,771 moves with not ONE TMDb answer: every search and every external_ids call came back 401. The credential on this box is the v3 key (~/.config/secrets/tmdb, `api_key=`), and TMDb takes a v3 key only as the `api_key` query parameter — a v4 request token is the only thing the Bearer header accepts. Measured on the same credential in one pass: the query form resolves `New Girl (2011)`, the Bearer form raises HTTP 401. tmdb_request() now picks the transport from the shape it was handed (three dot-separated segments = a JWT → Bearer, anything else → query parameter), and the old header comment's reasoning is corrected: a token in argv is world-readable in /proc/<pid>/cmdline, but this URL is built inside the process and never reaches argv, so the query form leaks nothing. The failure mode that let it look like a finished plan is fixed with it. The 401s were stderr-only, so the summary printed a confident move count while every series folder still carried the metadata provider's folder-echo name — the exact junk this contract exists to undo. Provider health is now part of the summary line and of the exceptions file, and a test covers it offline with `env -u TMDB_API_KEY`: a plan that asked and could not ask says NOT provider-complete in both places, and still writes the moves it can justify, because the answers that did arrive are worth their rename.
This commit is contained in:
1 parent
0a365274b6
commit
32b58dd787
3 files changed
+73
-13
No files matched your search
+50
-9
@@ -127,6 +127,7 @@ try:
|
|||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
CACHE = {}
|
CACHE = {}
|
||||||
API_KEY = os.environ.get("TMDB_API_KEY", "")
|
API_KEY = os.environ.get("TMDB_API_KEY", "")
|
||||||
|
TMDB = {"tries": 0, "failed": 0}
|
||||||
|
|
||||||
VIDEO_EXT = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts", ".m2ts", ".mpg",
|
VIDEO_EXT = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts", ".m2ts", ".mpg",
|
||||||
".mpeg", ".webm", ".wmv", ".divx", ".ogm", ".flv", ".vob", ".rm",
|
".mpeg", ".webm", ".wmv", ".divx", ".ogm", ".flv", ".vob", ".rm",
|
||||||
@@ -240,6 +241,20 @@ def leading_index(name):
|
|||||||
return int(m.group(1)) if m else None
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def tmdb_request(url):
|
||||||
|
"""One TMDb call, authenticated. The two credential shapes are NOT
|
||||||
|
interchangeable and the difference is a 401 on every single call: a v3 key
|
||||||
|
travels as the `api_key` query parameter, and only a v4 request token is
|
||||||
|
accepted as a Bearer header. A token in argv is world-readable in
|
||||||
|
/proc/<pid>/cmdline — this URL is built inside the process and never reaches
|
||||||
|
argv, so the query form is safe here."""
|
||||||
|
TMDB["tries"] += 1
|
||||||
|
if API_KEY.count(".") == 2: # three segments: a v4 JWT
|
||||||
|
return http_json(url, headers={"Authorization": "Bearer " + API_KEY})
|
||||||
|
return http_json(url + ("&" if "?" in url else "?") + "api_key="
|
||||||
|
+ urllib.parse.quote(API_KEY))
|
||||||
|
|
||||||
|
|
||||||
def tmdb(kind, query):
|
def tmdb(kind, query):
|
||||||
"""A provider answer for a query: the cache first, TMDb once, then never again."""
|
"""A provider answer for a query: the cache first, TMDb once, then never again."""
|
||||||
ck = f"{kind}:{query}"
|
ck = f"{kind}:{query}"
|
||||||
@@ -253,14 +268,10 @@ def tmdb(kind, query):
|
|||||||
path = "movie" if kind == "movie" else "tv"
|
path = "movie" if kind == "movie" else "tv"
|
||||||
url = ("https://api.themoviedb.org/3/search/" + path + "?query="
|
url = ("https://api.themoviedb.org/3/search/" + path + "?query="
|
||||||
+ urllib.parse.quote(query) + "&include_adult=false")
|
+ urllib.parse.quote(query) + "&include_adult=false")
|
||||||
req = urllib.request.Request(url, headers={
|
|
||||||
# The key rides a header, never the URL: a token in argv is world-readable.
|
|
||||||
"Authorization": "Bearer " + API_KEY,
|
|
||||||
"Accept": "application/json"})
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=20) as r:
|
found = tmdb_request(url).get("results") or []
|
||||||
found = json.load(r).get("results") or []
|
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
|
TMDB["failed"] += 1
|
||||||
print(f"tmdb {kind} lookup failed for {query!r}: {e}", file=sys.stderr)
|
print(f"tmdb {kind} lookup failed for {query!r}: {e}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
if not found:
|
if not found:
|
||||||
@@ -362,9 +373,9 @@ def tvdb_id_for_tmdb(mid):
|
|||||||
if not (ARGS.tmdb and API_KEY):
|
if not (ARGS.tmdb and API_KEY):
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
got = http_json(f"https://api.themoviedb.org/3/tv/{mid}/external_ids",
|
got = tmdb_request("https://api.themoviedb.org/3/tv/" + mid + "/external_ids")
|
||||||
headers={"Authorization": "Bearer " + API_KEY})
|
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
|
TMDB["failed"] += 1
|
||||||
print(f"tmdb external ids failed for {mid}: {e}", file=sys.stderr)
|
print(f"tmdb external ids failed for {mid}: {e}", file=sys.stderr)
|
||||||
return ""
|
return ""
|
||||||
CACHE[ck] = str(got.get("tvdb_id") or "")
|
CACHE[ck] = str(got.get("tvdb_id") or "")
|
||||||
@@ -894,6 +905,30 @@ for r, reason in unknown:
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- output
|
# --------------------------------------------------------------------- output
|
||||||
|
def provider_broken():
|
||||||
|
"""TMDb was asked for names and did not answer: the plan is not the plan the
|
||||||
|
operator asked for, whatever the move count says."""
|
||||||
|
return bool(ARGS.tmdb and (not API_KEY or TMDB["failed"]))
|
||||||
|
|
||||||
|
|
||||||
|
def provider_note():
|
||||||
|
"""Provider health on the summary line. The 401s are already on stderr, and a
|
||||||
|
terminal that scrolled past them shows a plan that looks finished: the count
|
||||||
|
belongs where it cannot be missed, because a half-resolved plan renames
|
||||||
|
folders with the folder's own junk words."""
|
||||||
|
if not (ARGS.tmdb or ARGS.tvdb):
|
||||||
|
return ""
|
||||||
|
if not ARGS.tmdb:
|
||||||
|
return ", tmdb off (names from the cache and tvdb only)"
|
||||||
|
if not API_KEY:
|
||||||
|
return ", tmdb NOT ASKED (TMDB_API_KEY unset): NOT provider-complete"
|
||||||
|
if not TMDB["tries"]:
|
||||||
|
return ", tmdb cache-only (every name was already answered)"
|
||||||
|
done = TMDB["tries"] - TMDB["failed"]
|
||||||
|
return (f", tmdb {done}/{TMDB['tries']} answered"
|
||||||
|
+ (" — NOT provider-complete" if TMDB["failed"] else ""))
|
||||||
|
|
||||||
|
|
||||||
def write_plan():
|
def write_plan():
|
||||||
"""mkdir rows for every destination folder and its whole chain, then the moves.
|
"""mkdir rows for every destination folder and its whole chain, then the moves.
|
||||||
Parents first: the executor creates directories in the order they are listed."""
|
Parents first: the executor creates directories in the order they are listed."""
|
||||||
@@ -915,7 +950,7 @@ def write_plan():
|
|||||||
sized = sum(held[s]["bytes"] for s, _d, _k in moves if s in held)
|
sized = sum(held[s]["bytes"] for s, _d, _k in moves if s in held)
|
||||||
print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked"
|
print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked"
|
||||||
f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed,"
|
f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed,"
|
||||||
f" {len(dirs)} folders -> {ARGS.out}")
|
f" {len(dirs)} folders{provider_note()} -> {ARGS.out}")
|
||||||
|
|
||||||
|
|
||||||
def write_exceptions():
|
def write_exceptions():
|
||||||
@@ -926,6 +961,12 @@ def write_exceptions():
|
|||||||
fh.write(f"Everything below is parked under {PARK}, keeping the relative path it\n")
|
fh.write(f"Everything below is parked under {PARK}, keeping the relative path it\n")
|
||||||
fh.write("has today. Nothing is deleted and nothing is overwritten: a parked file\n")
|
fh.write("has today. Nothing is deleted and nothing is overwritten: a parked file\n")
|
||||||
fh.write("is one rename from wherever the plan is pointed next.\n\n")
|
fh.write("is one rename from wherever the plan is pointed next.\n\n")
|
||||||
|
if provider_broken():
|
||||||
|
fh.write("WARNING: " + provider_note().strip(", ").replace(": NOT", " — NOT")
|
||||||
|
+ "\n Names and episode titles that should have come from TMDb are\n"
|
||||||
|
" missing, so folders keep the metadata provider's words. Fix the\n"
|
||||||
|
" credential and re-plan: the cache keeps every answer that did\n"
|
||||||
|
" arrive, so the re-run costs only the lookups that failed.\n\n")
|
||||||
for reason in sorted(reasons, key=lambda r: -len(reasons[r])):
|
for reason in sorted(reasons, key=lambda r: -len(reasons[r])):
|
||||||
rs = reasons[reason]
|
rs = reasons[reason]
|
||||||
fh.write(f"{len(rs):>6} files {reason}\n")
|
fh.write(f"{len(rs):>6} files {reason}\n")
|
||||||
|
|||||||
@@ -181,10 +181,13 @@ python3 scripts/media-census.py probe --root /mnt/entropy/TVShows \
|
|||||||
# seeking the array again. It is also the only column the film arm reads, and
|
# seeking the array again. It is also the only column the film arm reads, and
|
||||||
# only for a file with no marker and no provider row (4,794 of 58,551 today,
|
# only for a file with no marker and no provider row (4,794 of 58,551 today,
|
||||||
# all of them covered by a re-key that costs seconds, not an hour).
|
# all of them covered by a re-key that costs seconds, not an hour).
|
||||||
# 3. The plan. --tmdb needs TMDB_API_KEY, --tvdb needs ~/.config/secrets/tvdb
|
# 3. The plan. --tmdb needs TMDB_API_KEY — the v3 key in ~/.config/secrets/tmdb,
|
||||||
# (`api_key=`, never argv); both write into the SAME cache file, so the plan is
|
# which travels as the `api_key` query parameter. Only a v4 request token is
|
||||||
# reproducible offline afterwards. Run it once to fill the cache, then again:
|
# accepted as a Bearer header, and the wrong shape answers 401 on EVERY call,
|
||||||
# the second run is the one whose numbers are quoted.
|
# which the summary line flags as NOT provider-complete. --tvdb reads its
|
||||||
|
# `api_key=` line from ~/.config/secrets/tvdb, never argv. Both write the SAME
|
||||||
|
# cache file, so the plan is reproducible offline afterwards: run it once to
|
||||||
|
# fill the cache, and quote the second run.
|
||||||
# Paths are only strings to a dry run, so this step can run on the workstation
|
# Paths are only strings to a dry run, so this step can run on the workstation
|
||||||
# against scp'd TSVs — which is where the two provider secrets live.
|
# against scp'd TSVs — which is where the two provider secrets live.
|
||||||
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
||||||
|
|||||||
@@ -135,6 +135,22 @@ plan_park() { # the review pile outside the lib
|
|||||||
--park "$OUTSIDE" --out "$OUT" --exceptions "$EXC"
|
--park "$OUTSIDE" --out "$OUT" --exceptions "$EXC"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "--tmdb with no key is reported as an unfinished plan, not a finished one" {
|
||||||
|
# A dead provider is invisible where it happens: every 401 is one line on
|
||||||
|
# stderr, and a terminal that scrolled past them shows a plan that reads as
|
||||||
|
# complete while every folder still carries the metadata provider's junk. The
|
||||||
|
# plan is still written — the answers that DID arrive are worth their rename —
|
||||||
|
# so the state has to ride in the summary line and in the review file.
|
||||||
|
# env -u keeps this offline no matter what the developer's shell exports.
|
||||||
|
run env -u TMDB_API_KEY python3 "$PLAN" --root "$LIB" --enum "$ENUM" \
|
||||||
|
--titles "$TITLES" --probed "$PROBED" --tmdb-cache "$CACHE" \
|
||||||
|
--movies "$MOVIES" --tmdb --out "$OUT" --exceptions "$EXC"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
grep -q 'tmdb NOT ASKED (TMDB_API_KEY unset): NOT provider-complete' <<<"$output"
|
||||||
|
grep -q 'WARNING: tmdb NOT ASKED (TMDB_API_KEY unset) — NOT provider-complete' "$EXC"
|
||||||
|
grep -qE '^plan: [1-9][0-9]* moves' <<<"$output"
|
||||||
|
}
|
||||||
|
|
||||||
@test "the census exports the provider's episode numbering as a TSV" {
|
@test "the census exports the provider's episode numbering as a TSV" {
|
||||||
DB="$BATS_TEST_TMPDIR/jellyfin.db"
|
DB="$BATS_TEST_TMPDIR/jellyfin.db"
|
||||||
python3 - "$DB" "$SEASON" <<'PY'
|
python3 - "$DB" "$SEASON" <<'PY'
|
||||||
|
|||||||
Reference in new issue
Block a user