From dd9107daed2d3f16cd4104683ecea77deab2dc81 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Mon, 27 Jul 2026 16:30:00 +0200 Subject: [PATCH 01/14] feat: add Blu-ray.com physical release date lookup script Searches Blu-ray.com for physical release dates missing from Radarr. Outputs results as table, JSON, or CSV for manual TMDB submission. --- radarr/fetch_physical_dates.sh | 289 +++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100755 radarr/fetch_physical_dates.sh diff --git a/radarr/fetch_physical_dates.sh b/radarr/fetch_physical_dates.sh new file mode 100755 index 0000000..4a0c1fb --- /dev/null +++ b/radarr/fetch_physical_dates.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env sh +# Dont warn on the word `local` +# shellcheck disable=SC3043 + +# Script to search Blu-ray.com for physical release dates missing from Radarr. +# Logs results for manual TMDB submission. +# +# Requirements: +# * sh (tested with sh from FreeBSD base FreeBSD 14.1) +# * curl (tested with 8.10.1) +# * jq (tested with 1.7.1) +# +# Version 0.1.0 (Released 2026-07-27) +# * Initial implementation + +# Load shared library and configuration +. "$(dirname "$0")/connect/scripts_common.sh" +load_config "$(dirname "$0")/connect" + +# Script-specific defaults +: "${BLURAY_COUNTRY:=US}" +: "${BLURAY_RATE_LIMIT:=0.5}" +: "${BLURAY_MAX_MOVIES_WARN:=50}" +: "${DEBUG:=false}" + +# CLI flags +_FLAG_JSON=false +_FLAG_QUIET=false +_FLAG_CSV=false +_LIMIT=0 +_EXPORT_FILE="" + +while [ $# -gt 0 ]; do + case "$1" in + --json) _FLAG_JSON=true; shift ;; + --quiet) _FLAG_QUIET=true; shift ;; + --csv) _FLAG_CSV=true; shift ;; + --limit) + case "$2" in + ''|*[!0-9]*) + echo "ERROR: --limit requires a positive integer" >&2 + exit 1 + ;; + *) _LIMIT="$2"; shift 2 ;; + esac + ;; + --export) + case "$2" in + '') echo "ERROR: --export requires a file path" >&2; exit 1 ;; + *) _EXPORT_FILE="$2"; shift 2 ;; + esac + ;; + --country) + case "$2" in + '') echo "ERROR: --country requires a code (e.g. US, UK)" >&2; exit 1 ;; + *) BLURAY_COUNTRY="$2"; shift 2 ;; + esac + ;; + --debug) DEBUG=true; shift ;; + -*) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; + *) break ;; + esac +done + +check_needed_executables "curl jq" + +debug_log "=== Fetch Physical Release Dates ===" +debug_log "Country filter: ${BLURAY_COUNTRY}" +debug_log "Rate limit: ${BLURAY_RATE_LIMIT}s" + +# --json takes precedence over --quiet +if [ "${_FLAG_JSON}" = "true" ] +then + _FLAG_QUIET=false +fi + +############################################################################## +# Blu-ray.com lookup +############################################################################## + +bluray_lookup() { + local _title _year _response _items _matched + + _title="$1" + _year="$2" + + _response=$(curl -sL \ + -H "User-Agent: Mozilla/5.0" \ + "https://m.blu-ray.com/quicksearch/search.php?section=bluraymovies&country=ALL&keyword=$(printf '%s' "${_title}" | jq -sRr @uri)&_=$(date +%s)000") + + if [ -z "${_response}" ] + then + debug_log " No response from Blu-ray.com for '${_title}'" + return 1 + fi + + _items=$(printf '%s' "${_response}" | jq -e '.items // empty' 2>/dev/null) + if [ -z "${_items}" ] || [ "${_items}" = "[]" ] + then + debug_log " No results on Blu-ray.com for '${_title}'" + return 1 + fi + + # Filter: matching country flag, matching year, has release date + _matched=$(printf '%s' "${_items}" | jq -r --arg flag "flags/${BLURAY_COUNTRY}.png" --arg year "${_year}" ' + [.[] | + select( + (.flag | endswith($flag)) + and ((.year | tostring) == $year) + and (.reldate != null) + and (.reldate != "No release date") + ) + ] | sort_by(.reldate) | .[0] // empty + ') + + if [ -z "${_matched}" ] || [ "${_matched}" = "null" ] || [ "${_matched}" = "" ] + then + debug_log " No ${BLURAY_COUNTRY} release found for '${_title} (${_year})'" + return 1 + fi + + printf '%s' "${_matched}" +} + +############################################################################## +# Phase 1: Radarr query +############################################################################## + +debug_log "Fetching all movies from Radarr API" +_ALL_MOVIES=$(radarr_api_get "movie") + +if [ -z "${_ALL_MOVIES}" ] +then + echo "ERROR: No response from movie API" >&2 + exit 1 +fi + +_CANDIDATES=$(printf '%s' "${_ALL_MOVIES}" | jq ' +[.[] | select( + (.physicalRelease == null or .physicalRelease == "") + and (.tmdbId != null and .tmdbId != 0) +) | {id: .id, title: .title, year: .year, tmdbId: .tmdbId}] +') + +# Unset to free memory +unset _ALL_MOVIES + +_CANDIDATE_COUNT=$(printf '%s' "${_CANDIDATES}" | jq 'length') + +debug_log "Candidates: ${_CANDIDATE_COUNT} movies without physical release" + +if [ "${_CANDIDATE_COUNT}" -eq 0 ] +then + if [ "${_FLAG_QUIET}" = "false" ] + then + echo "No movies found without physical release dates." + fi + exit 0 +fi + +if [ "${_CANDIDATE_COUNT}" -gt "${BLURAY_MAX_MOVIES_WARN}" ] && [ "${_LIMIT}" -eq 0 ] +then + echo "WARN: ${_CANDIDATE_COUNT} movies to check. Consider --limit N to batch." >&2 +fi + +# Apply limit +if [ "${_LIMIT}" -gt 0 ] && [ "${_LIMIT}" -lt "${_CANDIDATE_COUNT}" ] +then + _CANDIDATES=$(printf '%s' "${_CANDIDATES}" | jq --argjson limit "${_LIMIT}" '.[:$limit]') + _CANDIDATE_COUNT="${_LIMIT}" + debug_log "Limited to ${_LIMIT} movies" +fi + +############################################################################## +# Phase 2: Blu-ray.com lookups +############################################################################## + +_RESULTS="[]" +_FOUND_COUNT=0 +_CHECKED_COUNT=0 + +_TEMP=$(mktemp) +# shellcheck disable=SC2064 +trap 'rm -f "${_TEMP}"; exit 130' INT TERM +trap 'rm -f "${_TEMP}"' EXIT + +printf '%s' "${_CANDIDATES}" | jq -c '.[]' > "${_TEMP}" + +while read -r _movie +do + _CHECKED_COUNT=$((_CHECKED_COUNT + 1)) + _title=$(printf '%s' "${_movie}" | jq -r '.title') + _year=$(printf '%s' "${_movie}" | jq -r '.year') + _radarr_id=$(printf '%s' "${_movie}" | jq -r '.id') + _tmdb_id=$(printf '%s' "${_movie}" | jq -r '.tmdbId') + + debug_log "[${_CHECKED_COUNT}/${_CANDIDATE_COUNT}] Looking up: ${_title} (${_year})" + + _result=$(bluray_lookup "${_title}" "${_year}") + _lookup_rc=$? + + if [ "${_lookup_rc}" -eq 0 ] && [ -n "${_result}" ] + then + _reldate=$(printf '%s' "${_result}" | jq -r '.reldate') + _bluray_url=$(printf '%s' "${_result}" | jq -r '.url') + _bluray_title=$(printf '%s' "${_result}" | jq -r '.title') + + debug_log " Found: ${_reldate} (${_bluray_title})" + + _entry=$(jq -n \ + --argjson radarr_id "${_radarr_id}" \ + --arg title "${_title}" \ + --argjson year "${_year}" \ + --argjson tmdb_id "${_tmdb_id}" \ + --arg physical_date "${_reldate}" \ + --arg bluray_title "${_bluray_title}" \ + --arg bluray_url "${_bluray_url}" \ + '{radarr_id: $radarr_id, title: $title, year: $year, tmdb_id: $tmdb_id, physical_date: $physical_date, bluray_title: $bluray_title, bluray_url: $bluray_url}') + + _RESULTS=$(printf '%s' "${_RESULTS}" | jq --argjson entry "${_entry}" '. + [$entry]') + _FOUND_COUNT=$((_FOUND_COUNT + 1)) + fi + + sleep "${BLURAY_RATE_LIMIT}" +done < "${_TEMP}" + +rm -f "${_TEMP}" +trap - INT TERM EXIT + +############################################################################## +# Phase 3: Output +############################################################################## + +if [ "${_FLAG_JSON}" = "true" ] +then + printf '%s' "${_RESULTS}" | jq \ + --argjson checked "${_CHECKED_COUNT}" \ + --argjson found "${_FOUND_COUNT}" \ + '{movies_checked: $checked, dates_found: $found, results: .}' + echo + exit 0 +fi + +if [ "${_FLAG_QUIET}" = "false" ] +then + echo + echo "Physical Release Date Lookup" + echo "============================" + echo + echo "Checked: ${_CHECKED_COUNT} movies" + echo "Found: ${_FOUND_COUNT} dates" + echo + + if [ "${_FOUND_COUNT}" -gt 0 ] + then + printf '%-45s %-6s %-20s %s\n' "Movie" "Year" "Blu-ray.com Date" "TMDB ID" + printf '%-45s %-6s %-20s %s\n' "-----" "----" "----------------" "-------" + + printf '%s' "${_RESULTS}" | jq -r '.[] | "\(.title)|\(.year)|\(.physical_date)|\(.tmdb_id)"' | \ + while IFS='|' read -r _title _year _date _tmdb_id; do + printf '%-45s %-6s %-20s %s\n' "${_title}" "${_year}" "${_date}" "${_tmdb_id}" + done + echo + fi +fi + +if [ -n "${_EXPORT_FILE}" ] +then + if [ "${_FLAG_CSV}" = "true" ] + then + echo "radarr_id,title,year,tmdb_id,physical_date,bluray_title,bluray_url" > "${_EXPORT_FILE}" + printf '%s' "${_RESULTS}" | jq -r '.[] | [.radarr_id, .title, .year, .tmdb_id, .physical_date, .bluray_title, .bluray_url] | @csv' >> "${_EXPORT_FILE}" + debug_log "Exported ${_FOUND_COUNT} results to ${_EXPORT_FILE} (CSV)" + else + printf '%s' "${_RESULTS}" | jq \ + --argjson checked "${_CHECKED_COUNT}" \ + --argjson found "${_FOUND_COUNT}" \ + '{movies_checked: $checked, dates_found: $found, results: .}' > "${_EXPORT_FILE}" + debug_log "Exported ${_FOUND_COUNT} results to ${_EXPORT_FILE} (JSON)" + fi + + if [ "${_FLAG_QUIET}" = "false" ] + then + echo "Exported to: ${_EXPORT_FILE}" + echo + fi +fi + +exit 0 From 54f29e37c59a295db3336a76da04b4f754452a86 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Mon, 27 Jul 2026 16:36:07 +0200 Subject: [PATCH 02/14] docs: add fetch_physical_dates.sh to AGENTS.md and README.md --- AGENTS.md | 28 ++++++++++++++++++++++++++++ README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 48ab79e..1312ec7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ This repository contains shell scripts and configuration files for Radarr automa - `radarr/connect/scripts_common.sh` - Shared library sourced by connect scripts (config loading, executable checks, Radarr API helpers). - `radarr/auto_quality_switch.sh` - Switches movies from Remux-only to WebDL profiles when no physical release appears within a statistical threshold. - `radarr/auto_quality_switch_reverse.sh` - Switches movies back to Remux-only when physical release dates appear for previously switched movies. +- `radarr/fetch_physical_dates.sh` - Searches Blu-ray.com for physical release dates missing from Radarr, logs results for TMDB submission. --- @@ -110,6 +111,26 @@ Event types: `Test`, `MovieAdded`, `Download`, `Bulk` ./radarr/auto_quality_switch_reverse.sh --apply ``` +**Run the physical release date lookup:** +```bash +./radarr/fetch_physical_dates.sh +``` + +**Run with batch limit:** +```bash +./radarr/fetch_physical_dates.sh --limit 20 +``` + +**Export results to JSON:** +```bash +./radarr/fetch_physical_dates.sh --export dates.json +``` + +**Export results to CSV:** +```bash +./radarr/fetch_physical_dates.sh --export dates.csv --csv +``` + ### Testing There are no formal test suites in this project. Manual testing can be performed by: @@ -293,11 +314,14 @@ radarr/connect/ radarr/ auto_quality_switch.sh # Forward script: Remux-only → WebDL auto_quality_switch_reverse.sh # Reverse script: WebDL → Remux-only + fetch_physical_dates.sh # Blu-ray.com physical release date lookup docs/ quality-switch-spec.md # Forward script specification quality-switch-reverse-spec.md # Reverse script specification cookie-extraction.md # Guide for exporting YouTube cookies for yt-dlp + superpowers/specs/ # Design specs for new features + superpowers/plans/ # Implementation plans ``` --- @@ -324,6 +348,10 @@ Required executables for `auto_quality_switch.sh` and `auto_quality_switch_rever - curl - jq +Required executables for `fetch_physical_dates.sh` (checked at runtime): +- curl +- jq + --- ## Notes diff --git a/README.md b/README.md index 74dca77..4b6cf1f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,53 @@ See the sample file for all available settings with documentation. --- +### fetch_physical_dates.sh + +Searches Blu-ray.com for physical release dates missing from Radarr. Useful +when TMDB hasn't listed a release date yet (e.g. Supergirl 2026 had a Blu-ray +planned for Sep 08 2026 before TMDB had it). + +Outputs results as table, JSON, or CSV for manual TMDB submission. + +#### Quick start + +```sh +# Preview — check 3 movies, show debug output +./radarr/fetch_physical_dates.sh --limit 3 --debug + +# Check all movies without physical release dates +./radarr/fetch_physical_dates.sh + +# Export to JSON file +./radarr/fetch_physical_dates.sh --export dates.json + +# Export to CSV +./radarr/fetch_physical_dates.sh --export dates.csv --csv +``` + +#### Flags + +| Flag | Effect | +|------|--------| +| `--limit N` | Process max N movies per run (batch size) | +| `--export ` | Write results to file (JSON or CSV) | +| `--csv` | Export as CSV (default: JSON) | +| `-j`, `--json` | Output machine-readable JSON to stdout | +| `-q`, `--quiet` | Suppress table output | +| `-d`, `--debug` | Verbose debug logging to stderr | +| `--country ` | Override country filter (default: US) | + +#### Scheduling + +Run periodically to catch new releases: + +```cron +# Weekly, check 50 movies at a time +0 8 * * 1 /path/to/radarr/fetch_physical_dates.sh --limit 50 --export /var/log/physical-dates.json >> /var/log/fetch-physical-dates.log 2>&1 +``` + +--- + ## Contributing This project follows [Conventional Commits](https://www.conventionalcommits.org/) and the conventions documented in [AGENTS.md](AGENTS.md). From ee30987a84533a0bca7b0891adfae5fa16cd8d4c Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Tue, 28 Jul 2026 00:24:30 +0200 Subject: [PATCH 03/14] feat: add TMDB release date push scripts Two-phase approach for pushing physical release dates to TMDB: - tmdb_login.sh: Playwright-based login, exports session cookies - push_physical_to_tmdb.sh: curl-based API calls, runs anywhere Phase 1 (tmdb_login.sh) runs once on machine with display to solve AWS WAF JavaScript challenge. Phase 2 (push_physical_to_tmdb.sh) uses exported cookies for POSIX-only execution on FreeBSD/headless servers. --- AGENTS.md | 38 ++ README.md | 88 +++ .../plans/2026-07-27-push-physical-to-tmdb.md | 532 ++++++++++++++++++ ...2026-07-27-push-physical-to-tmdb-design.md | 173 ++++++ radarr/connect/scripts.conf.sample | 5 + radarr/push_physical_to_tmdb.sh | 271 +++++++++ radarr/tmdb_login.sh | 130 +++++ 7 files changed, 1237 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md create mode 100644 docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md create mode 100755 radarr/push_physical_to_tmdb.sh create mode 100755 radarr/tmdb_login.sh diff --git a/AGENTS.md b/AGENTS.md index 1312ec7..8316b76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ This repository contains shell scripts and configuration files for Radarr automa - `radarr/auto_quality_switch.sh` - Switches movies from Remux-only to WebDL profiles when no physical release appears within a statistical threshold. - `radarr/auto_quality_switch_reverse.sh` - Switches movies back to Remux-only when physical release dates appear for previously switched movies. - `radarr/fetch_physical_dates.sh` - Searches Blu-ray.com for physical release dates missing from Radarr, logs results for TMDB submission. +- `radarr/tmdb_login.sh` - Playwright-based TMDB login script, exports session cookies for curl-based automation. +- `radarr/push_physical_to_tmdb.sh` - Pushes physical release dates to TMDB via their website's Kendo grid API. --- @@ -131,6 +133,31 @@ Event types: `Test`, `MovieAdded`, `Download`, `Bulk` ./radarr/fetch_physical_dates.sh --export dates.csv --csv ``` +**Run the TMDB login script (requires display/Xvfb):** +```bash +./radarr/tmdb_login.sh +``` + +**Run the TMDB login with custom cookie output:** +```bash +./radarr/tmdb_login.sh --cookies /path/to/cookies.txt +``` + +**Push a single release date to TMDB:** +```bash +./radarr/push_physical_to_tmdb.sh [title] +``` + +**Push release dates from pipe:** +```bash +./radarr/fetch_physical_dates.sh --json --quiet | ./radarr/push_physical_to_tmdb.sh +``` + +**Dry-run mode:** +```bash +./radarr/push_physical_to_tmdb.sh --dry-run 123456 2025-01-15 "Movie Title" +``` + ### Testing There are no formal test suites in this project. Manual testing can be performed by: @@ -315,6 +342,8 @@ radarr/ auto_quality_switch.sh # Forward script: Remux-only → WebDL auto_quality_switch_reverse.sh # Reverse script: WebDL → Remux-only fetch_physical_dates.sh # Blu-ray.com physical release date lookup + tmdb_login.sh # Playwright-based TMDB cookie export + push_physical_to_tmdb.sh # Push release dates to TMDB via curl docs/ quality-switch-spec.md # Forward script specification @@ -352,6 +381,15 @@ Required executables for `fetch_physical_dates.sh` (checked at runtime): - curl - jq +Required executables for `tmdb_login.sh` (checked at runtime): +- node (with playwright package) + +Required executables for `push_physical_to_tmdb.sh` (checked at runtime): +- curl +- jq +- grep +- sed + --- ## Notes diff --git a/README.md b/README.md index 4b6cf1f..b7f15bb 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,94 @@ Run periodically to catch new releases: --- +### tmdb_login.sh + +Playwright-based TMDB login script that exports session cookies for +curl-based automation. Solves AWS WAF JavaScript challenge that blocks +automated requests. + +Run once on a machine with a display (or Xvfb). The exported cookie +file works with both `push_physical_to_tmdb.sh` and `yt-dlp`. + +#### Quick start + +```sh +# Login and export cookies +./radarr/tmdb_login.sh + +# Export to custom location +./radarr/tmdb_login.sh --cookies /path/to/cookies.txt +``` + +#### Prerequisites + +- Node.js with Playwright package installed +- Chromium browser (installed by Playwright) +- Display server or Xvfb for headed mode + +```sh +npm install playwright +npx playwright install chromium +``` + +#### Flags + +| Flag | Effect | +|------|--------| +| `--cookies ` | Output cookie file path (default: `~/.tmdb_cookies.txt`) | +| `-d`, `--debug` | Verbose debug logging to stderr | + +--- + +### push_physical_to_tmdb.sh + +Pushes physical release dates to TMDB via their website's Kendo grid +REST API. Uses session cookies from `tmdb_login.sh`. + +Accepts single movies via arguments or batch processing via pipe from +`fetch_physical_dates.sh`. + +#### Quick start + +```sh +# Single movie +./radarr/push_physical_to_tmdb.sh 123456 2026-09-08 "Movie Title" + +# Pipeline from fetch_physical_dates.sh +./radarr/fetch_physical_dates.sh --json --quiet | ./radarr/push_physical_to_tmdb.sh + +# Dry-run mode +./radarr/push_physical_to_tmdb.sh --dry-run 123456 2026-09-08 "Movie Title" +``` + +#### Prerequisites + +- `curl`, `jq`, `grep`, `sed` (POSIX tools) +- Cookie file from `tmdb_login.sh` (default: `~/.tmdb_cookies.txt`) + +#### Flags + +| Flag | Effect | +|------|--------| +| `--dry-run` | Show what would be submitted, don't POST | +| `--country ` | ISO 3166-1 country code (default: US) | +| `--language ` | ISO 639-1 language code (default: en) | +| `--type ` | Release type 1-7 (default: 5 = Physical) | +| `--note ` | Note field (default: "Physical release") | +| `--cookies ` | Cookie file path (default: `~/.tmdb_cookies.txt`) | +| `-d`, `--debug` | Verbose debug logging to stderr | + +#### Scheduling + +Run after `fetch_physical_dates.sh` to push found dates: + +```cron +# Weekly, push dates to TMDB +0 9 * * 1 /path/to/radarr/fetch_physical_dates.sh --json --quiet | /path/to/radarr/push_physical_to_tmdb.sh >> /var/log/push-physical.log 2>&1 +``` + +--- + ## Contributing This project follows [Conventional Commits](https://www.conventionalcommits.org/) and the conventions documented in [AGENTS.md](AGENTS.md). diff --git a/docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md b/docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md new file mode 100644 index 0000000..f625b69 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md @@ -0,0 +1,532 @@ +# push_physical_to_tmdb Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automate pushing physical release dates from Blu-ray.com into TMDB via their website's Kendo grid REST API. + +**Architecture:** Two scripts — `tmdb_login.sh` (Node.js/Playwright, run once) exports session cookies after solving AWS WAF challenge. `push_physical_to_tmdb.sh` (POSIX shell/curl, cron-friendly) reads cookies and POSTs release dates directly to TMDB's grid API. + +**Tech Stack:** Node.js + Playwright (login only), POSIX sh + curl + jq (push script) + +--- + +## File Structure + +``` +radarr/ + tmdb_login.sh # Playwright login + cookie export + push_physical_to_tmdb.sh # curl-based release date push + connect/scripts.conf.sample # Add TMDB_USERNAME/TMDB_PASSWORD + +docs/superpowers/specs/ + 2026-07-27-push-physical-to-tmdb-design.md # (exists) +``` + +--- + +## Task 1: tmdb_login.sh + +**Files:** +- Create: `radarr/tmdb_login.sh` + +- [ ] **Step 1: Create the Playwright login script** + +```sh +#!/usr/bin/env sh +# shellcheck disable=SC3043 + +# Playwright-based TMDB login script. +# Solves AWS WAF JS challenge and exports session cookies to Netscape format. +# Run once on machine with display (or Xvfb). +# +# Version 0.1.0 (Released 2026-07-27) +# * Initial implementation + +. "$(dirname "$0")/connect/scripts_common.sh" +load_config "$(dirname "$0")/connect" + +: "${TMDB_USERNAME:=}" +: "${TMDB_PASSWORD:=}" +: "${DEBUG:=false}" + +_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" + +while [ $# -gt 0 ]; do + case "$1" in + --cookies) _COOKIE_FILE="$2"; shift 2 ;; + --debug) DEBUG=true; shift ;; + *) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; + esac +done + +if [ -z "${TMDB_USERNAME}" ] || [ -z "${TMDB_PASSWORD}" ]; then + echo "ERROR: TMDB_USERNAME and TMDB_PASSWORD must be set in scripts.conf" >&2 + exit 1 +fi + +# Find node +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: node not found. Install Node.js." >&2 + exit 127 +fi + +# Find or install playwright +_PLAYWRIGHT_SCRIPT=$(cat << 'NODESCRIPT' +const { chromium } = require('playwright'); + +(async () => { + const username = process.env.TMDB_USERNAME; + const password = process.env.TMDB_PASSWORD; + const cookieFile = process.argv[2]; + + if (!username || !password) { + console.error('ERROR: TMDB_USERNAME and TMDB_PASSWORD must be set'); + process.exit(1); + } + + const browser = await chromium.launch({ + headless: false, + args: ['--disable-blink-features=AutomationControlled'] + }); + const context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 720 }, + locale: 'en-US', + }); + await context.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => false }); + }); + const page = await context.newPage(); + + try { + console.error('Navigating to TMDB login...'); + await page.goto('https://www.themoviedb.org/login', { waitUntil: 'networkidle' }); + + console.error('Filling credentials...'); + await page.fill('#username', username); + await page.fill('#password', password); + + console.error('Submitting login...'); + await Promise.all([ + page.waitForNavigation({ timeout: 30000 }), + page.click('#login_button'), + ]); + + // Verify login succeeded + const loggedIn = await page.locator('header ul > li.user').count(); + if (loggedIn === 0) { + console.error('ERROR: Login failed. Check credentials.'); + process.exit(1); + } + + console.error('Login successful. Exporting cookies...'); + + const cookies = await context.cookies(); + const lines = ['# Netscape HTTP Cookie File', '# Generated by tmdb_login.sh', '']; + + for (const c of cookies) { + const domain = c.domain.startsWith('.') ? c.domain : '.' + c.domain; + const flag = 'TRUE'; + const path = c.path || '/'; + const secure = c.secure ? 'TRUE' : 'FALSE'; + const expiry = c.expires > 0 ? Math.floor(c.expires) : 0; + lines.push([domain, flag, path, secure, expiry, c.name, c.value].join('\t')); + } + + const fs = require('fs'); + fs.writeFileSync(cookieFile, lines.join('\n') + '\n'); + console.log(cookieFile); + console.error(`Cookies exported to ${cookieFile}`); + + } catch (err) { + console.error('ERROR:', err.message); + process.exit(1); + } finally { + await browser.close(); + } +})(); +NODESCRIPT +) + +# Check if playwright is available +if ! node -e "require('playwright')" 2>/dev/null; then + echo "ERROR: playwright not found. Run: npm install playwright && npx playwright install chromium" >&2 + exit 127 +fi + +# Run the login script +TMDB_USERNAME="${TMDB_USERNAME}" TMDB_PASSWORD="${TMDB_PASSWORD}" \ + node -e "${_PLAYWRIGHT_SCRIPT}" -- "${_COOKIE_FILE}" + +debug_log "tmdb_login.sh completed" +``` + +- [ ] **Step 2: Make executable and test** + +Run: `chmod +x radarr/tmdb_login.sh && DISPLAY=:99 ./radarr/tmdb_login.sh --debug` +Expected: Creates `~/.tmdb_cookies.txt` with session cookies + +- [ ] **Step 3: Verify cookie file format** + +Run: `head -20 ~/.tmdb_cookies.txt` +Expected: Netscape format with `session` cookie entry + +- [ ] **Step 4: Commit** + +```bash +git add radarr/tmdb_login.sh +git commit -f "feat: add tmdb_login.sh for Playwright-based cookie export" +``` + +--- + +## Task 2: push_physical_to_tmdb.sh + +**Files:** +- Create: `radarr/push_physical_to_tmdb.sh` + +- [ ] **Step 1: Create the push script** + +```sh +#!/usr/bin/env sh +# shellcheck disable=SC3043 + +# Push physical release dates to TMDB via their website's Kendo grid API. +# Uses session cookies from tmdb_login.sh. +# +# Version 0.1.0 (Released 2026-07-27) +# * Initial implementation + +. "$(dirname "$0")/connect/scripts_common.sh" +load_config "$(dirname "$0")/connect" + +: "${DEBUG:=false}" + +# Defaults +_COUNTRY="US" +_LANGUAGE="en" +_RELEASE_TYPE=5 +_NOTE="Physical release" +_DRY_RUN=false +_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" +_RATE_LIMIT=1 + +# Flag parsing +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) _DRY_RUN=true; shift ;; + --debug) DEBUG=true; shift ;; + --country) + case "$2" in + '') echo "ERROR: --country requires a code" >&2; exit 1 ;; + *) _COUNTRY="$2"; shift 2 ;; + esac + ;; + --language) + case "$2" in + '') echo "ERROR: --language requires a code" >&2; exit 1 ;; + *) _LANGUAGE="$2"; shift 2 ;; + esac + ;; + --type) + case "$2" in + ''|*[!0-9]*) echo "ERROR: --type requires a number 1-7" >&2; exit 1 ;; + *) _RELEASE_TYPE="$2"; shift 2 ;; + esac + ;; + --note) + case "$2" in + '') echo "ERROR: --note requires text" >&2; exit 1 ;; + *) _NOTE="$2"; shift 2 ;; + esac + ;; + --cookies) + case "$2" in + '') echo "ERROR: --cookies requires a file path" >&2; exit 1 ;; + *) _COOKIE_FILE="$2"; shift 2 ;; + esac + ;; + --rate-limit) + case "$2" in + '') echo "ERROR: --rate-limit requires seconds" >&2; exit 1 ;; + *) _RATE_LIMIT="$2"; shift 2 ;; + esac + ;; + -*) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; + *) break ;; + esac +done + +check_needed_executables "curl jq grep sed" + +# Validate cookie file +if [ ! -f "${_COOKIE_FILE}" ]; then + echo "ERROR: Cookie file not found: ${_COOKIE_FILE}" >&2 + echo "ERROR: Run tmdb_login.sh first to generate cookies." >&2 + exit 1 +fi + +debug_log "Cookie file: ${_COOKIE_FILE}" +debug_log "Country: ${_COUNTRY}, Language: ${_LANGUAGE}, Type: ${_RELEASE_TYPE}" + +############################################################################## +# CSRF token extraction +############################################################################## + +_get_csrf_token() { + local _tmdb_id _page _token + + _tmdb_id="$1" + + _page=$(curl -s -b "${_COOKIE_FILE}" \ + -H "User-Agent: Mozilla/5.0 (FreeBSD; FreeBSD 14.1; amd64) AppleWebKit/537.36" \ + "https://www.themoviedb.org/movie/${_tmdb_id}/edit?active_nav_item=release_information") + + _token=$(printf '%s' "${_page}" | grep -o 'name="authenticity_token"[^>]*value="\([^"]*\)"' | sed 's/.*value="//;s/"//') + + if [ -z "${_token}" ]; then + echo "ERROR: Could not extract CSRF token for movie ${_tmdb_id}" >&2 + return 1 + fi + + printf '%s' "${_token}" +} + +############################################################################## +# Release date submission +############################################################################## + +_push_release_date() { + local _tmdb_id _date _title _csrf_token _payload _response _http_code + + _tmdb_id="$1" + _date="$2" + _title="${3:-unknown}" + + debug_log "Getting CSRF token for TMDB #${_tmdb_id}..." + _csrf_token=$(_get_csrf_token "${_tmdb_id}") + if [ $? -ne 0 ]; then + return 1 + fi + + debug_log "CSRF token: ${_csrf_token}" + + _payload=$(jq -n \ + --arg country "${_COUNTRY}" \ + --arg language "${_LANGUAGE}" \ + --arg date "${_date}" \ + --arg note "${_NOTE}" \ + --argjson type "${_RELEASE_TYPE}" \ + '{iso_3166_1: $country, iso_639_1: $language, release_date: $date, certification: "", type: $type, note: $note}') + + debug_log "Payload: ${_payload}" + + if [ "${_DRY_RUN}" = "true" ]; then + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ${_date} (dry-run)" + return 0 + fi + + _response=$(curl -s -b "${_COOKIE_FILE}" \ + -H "User-Agent: Mozilla/5.0 (FreeBSD; FreeBSD 14.1; amd64) AppleWebKit/537.36" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -H "X-CSRF-Token: ${_csrf_token}" \ + -H "X-Requested-With: XMLHttpRequest" \ + -H "Referer: https://www.themoviedb.org/movie/${_tmdb_id}/edit?active_nav_item=release_information" \ + -d "data=$(printf '%s' "${_payload}" | jq -sRr @uri)" \ + -w "\n%{http_code}" \ + "https://www.themoviedb.org/movie/${_tmdb_id}/remote/release_information?translate=false&timezone=UTC") + + _http_code=$(printf '%s' "${_response}" | tail -1) + _body=$(printf '%s' "${_response}" | sed '$d') + + debug_log "HTTP ${_http_code}: ${_body}" + + if [ "${_http_code}" = "200" ] || [ "${_http_code}" = "201" ]; then + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ${_date} ✓" + return 0 + elif [ "${_http_code}" = "401" ]; then + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ Cookies expired, re-run tmdb_login.sh" >&2 + return 1 + else + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ HTTP ${_http_code}" >&2 + debug_log "Response: ${_body}" + return 1 + fi +} + +############################################################################## +# Input handling +############################################################################## + +_COUNTER=0 +_TOTAL=0 +_SUCCESS=0 +_FAILED=0 + +# Pipe mode: read JSON from stdin +if [ ! -t 0 ]; then + debug_log "Reading from stdin (pipe mode)" + + _TEMP=$(mktemp) + trap 'rm -f "${_TEMP}"; exit 130' INT TERM + trap 'rm -f "${_TEMP}"' EXIT + + cat > "${_TEMP}" + + _TOTAL=$(jq 'length' "${_TEMP}") + debug_log "Processing ${_TOTAL} movies" + + jq -c '.[]' "${_TEMP}" | while read -r _movie; do + _COUNTER=$((_COUNTER + 1)) + _tmdb_id=$(printf '%s' "${_movie}" | jq -r '.tmdb_id') + _date=$(printf '%s' "${_movie}" | jq -r '.physical_date') + _title=$(printf '%s' "${_movie}" | jq -r '.title') + + # Convert date format if needed (e.g., "Sep 08, 2026" → "2026-09-08") + _date=$(printf '%s' "${_date}" | sed -E 's/^([A-Z][a-z]+) ([0-9]+),? ([0-9]+)$/ \2 \1 \3/' | \ + awk '{ + split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " "); + for (i in m) if ($2 == m[i]) mon = i; + printf "%s-%02d-%02d", $3, mon, $1 + }') + + if [ -z "${_tmdb_id}" ] || [ "${_tmdb_id}" = "null" ]; then + echo "[${_COUNTER}/${_TOTAL}] ${_title}: ✗ No TMDB ID" >&2 + _FAILED=$((_FAILED + 1)) + continue + fi + + if _push_release_date "${_tmdb_id}" "${_date}" "${_title}"; then + _SUCCESS=$((_SUCCESS + 1)) + else + _FAILED=$((_FAILED + 1)) + fi + + sleep "${_RATE_LIMIT}" + done + + rm -f "${_TEMP}" + trap - INT TERM EXIT + + echo "" + echo "Done: ${_SUCCESS} succeeded, ${_FAILED} failed" + exit 0 +fi + +# Args mode: single movie +if [ $# -lt 2 ]; then + echo "Usage: $0 [title]" >&2 + echo " $0 (reads JSON from stdin)" >&2 + echo "" >&2 + echo "Flags:" >&2 + echo " --dry-run Show what would be submitted" >&2 + echo " --country ISO 3166-1 country (default: US)" >&2 + echo " --language ISO 639-1 language (default: en)" >&2 + echo " --type Release type 1-7 (default: 5)" >&2 + echo " --note Note field (default: 'Physical release')" >&2 + echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" >&2 + echo " --debug Verbose logging" >&2 + exit 1 +fi + +_TMDB_ID="$1" +_DATE="$2" +_TITLE="${3:-unknown}" +_TOTAL=1 +_COUNTER=1 + +_push_release_date "${_TMDB_ID}" "${_DATE}" "${_TITLE}" +exit $? +``` + +- [ ] **Step 2: Make executable** + +Run: `chmod +x radarr/push_physical_to_tmdb.sh` + +- [ ] **Step 3: Test --help output** + +Run: `./radarr/push_physical_to_tmdb.sh` +Expected: Shows usage message + +- [ ] **Step 4: Test dry-run with args** + +Run: `./radarr/push_physical_to_tmdb.sh --dry-run 11 2025-01-15 "Star Wars"` +Expected: `[1/1] Star Wars → TMDB #11: 2025-01-15 (dry-run)` + +- [ ] **Step 5: Commit** + +```bash +git add radarr/push_physical_to_tmdb.sh +git commit -m "feat: add push_physical_to_tmdb.sh for TMDB release date submission" +``` + +--- + +## Task 3: Update scripts.conf.sample + +**Files:** +- Modify: `radarr/connect/scripts.conf.sample` + +- [ ] **Step 1: Add TMDB credentials** + +Add to `scripts.conf.sample`: +```sh +# TMDB website credentials (for tmdb_login.sh) +TMDB_USERNAME="" +TMDB_PASSWORD="" +``` + +- [ ] **Step 2: Commit** + +```bash +git add radarr/connect/scripts.conf.sample +git commit -m "docs: add TMDB credentials to scripts.conf.sample" +``` + +--- + +## Task 4: Integration test + +- [ ] **Step 1: Run tmdb_login.sh** + +Run: `DISPLAY=:99 ./radarr/tmdb_login.sh --debug` +Expected: Creates `~/.tmdb_cookies.txt` + +- [ ] **Step 2: Test real push** + +Run: `./radarr/push_physical_to_tmdb.sh --dry-run 11 2025-01-15 "Star Wars"` +Expected: Shows dry-run output + +- [ ] **Step 3: Test with real cookies (non-dry-run)** + +Run: `./radarr/push_physical_to_tmdb.sh 11 2025-01-15 "Star Wars"` +Expected: `✓` or appropriate error + +- [ ] **Step 4: Test pipe mode** + +Run: `echo '[{"tmdb_id":11,"physical_date":"2025-01-15","title":"Star Wars"}]' | ./radarr/push_physical_to_tmdb.sh --dry-run` +Expected: Shows dry-run output + +- [ ] **Step 5: Shellcheck** + +Run: `shellcheck -e SC1091,SC3043 radarr/tmdb_login.sh radarr/push_physical_to_tmdb.sh` +Expected: No errors + +--- + +## Task 5: Documentation + +- [ ] **Step 1: Update AGENTS.md** + +Add `tmdb_login.sh` and `push_physical_to_tmdb.sh` to file organization, dependencies, and build commands. + +- [ ] **Step 2: Update README.md** + +Add usage examples for the new scripts. + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md README.md +git commit -m "docs: add tmdb_login.sh and push_physical_to_tmdb.sh documentation" +``` diff --git a/docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md b/docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md new file mode 100644 index 0000000..96a0570 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md @@ -0,0 +1,173 @@ +# push_physical_to_tmdb.sh — Design Spec + +## Problem + +`fetch_physical_dates.sh` finds physical release dates on Blu-ray.com that Radarr lacks, but TMDB has no API write endpoint for movie metadata. Dates must be submitted manually via TMDB website edit forms. This script automates that submission. + +## Approach + +Two-phase cookie-based authentication (like yt-dlp): + +1. **Playwright login** — runs once on dev machine with display, solves AWS WAF JS challenge, exports session cookies to Netscape format file (`~/.tmdb_cookies.txt`) +2. **curl push script** — reads cookie file, makes direct API calls to TMDB. Runs anywhere (FreeBSD, headless server). + +### Why two phases? + +TMDB login POST is intercepted by AWS WAF (`x-amzn-waf-action: challenge`). curl can't solve the JS challenge. Playwright (headed, with Xvfb) can. Once authenticated, the session cookie works for curl. + +## Data Flow + +``` +Phase 1: tmdb_login.sh (run once, on machine with display) + Playwright → login → export cookies → ~/.tmdb_cookies.txt + +Phase 2: push_physical_to_tmdb.sh (runs anywhere, cron-friendly) + fetch_physical_dates.sh --json --quiet + → JSON with {tmdb_id, physical_date, title, ...} + → piped to push_physical_to_tmdb.sh + → for each movie: + curl with ~/.tmdb_cookies.txt + POST /movie/{tmdb_id}/remote/release_information + with JSON body + CSRF token + Log success/failure +``` + +## Scripts + +### tmdb_login.sh (Phase 1) + +Standalone Node.js script. Uses Playwright to: +1. Open Chromium (headed, needs DISPLAY or Xvfb) +2. Navigate to `https://www.themoviedb.org/login` +3. Fill `#username`, `#password`, click `#login_button` +4. Wait for navigation to complete +5. Extract all cookies from browser context +6. Write to `~/.tmdb_cookies.txt` in Netscape format + +**Output:** `~/.tmdb_cookies.txt` (shared with yt-dlp if needed) + +### push_physical_to_tmdb.sh (Phase 2) + +Shell script. Accepts: +- **Args mode:** `push_physical_to_tmdb.sh [title]` +- **Pipe mode:** `fetch_physical_dates.sh --json --quiet | push_physical_to_tmdb.sh` + +#### Authentication + +1. Check `~/.tmdb_cookies.txt` exists, else error +2. Extract CSRF token from edit page: `GET /movie/{tmdb_id}/edit?active_nav_item=release_information` +3. Extract `authenticity_token` from HTML + +#### Release Date Submission + +### Endpoint + +``` +POST https://www.themoviedb.org/movie/{tmdb_id}/remote/release_information?translate=false&timezone=UTC +``` + +### Headers + +``` +Content-Type: application/x-www-form-urlencoded +X-CSRF-Token: {authenticity_token} +X-Requested-With: XMLHttpRequest +``` + +### Body + +``` +data={"iso_3166_1":"US","iso_639_1":"en","release_date":"2026-01-15","certification":"","type":5,"note":"Physical release"} +``` + +### Release Type Values + +| Value | Type | +|-------|------| +| 1 | Premiere | +| 2 | Limited | +| 3 | Theatrical | +| 4 | Digital | +| 5 | Physical | +| 6 | TV | +| 7 | Total | + +Default: `5` (Physical) + +## Flags + +### push_physical_to_tmdb.sh + +| Flag | Description | +|------|-------------| +| `--dry-run` | Show what would be submitted, don't POST | +| `--debug` | Verbose logging | +| `--country ` | ISO 3166-1 country code (default: US) | +| `--language ` | ISO 639-1 language code (default: en) | +| `--type ` | Release type 1-7 (default: 5) | +| `--note ` | Note field (default: "Physical release") | +| `--cookies ` | Cookie file path (default: ~/.tmdb_cookies.txt) | + +### tmdb_login.sh + +| Flag | Description | +|------|-------------| +| `--cookies ` | Output cookie file path (default: ~/.tmdb_cookies.txt) | +| `--debug` | Verbose logging | + +## Config + +`scripts.conf` needs `TMDB_USERNAME` and `TMDB_PASSWORD` for `tmdb_login.sh`. + +`push_physical_to_tmdb.sh` reads credentials from cookie file only (no config needed). + +## Dependencies + +- `tmdb_login.sh`: Playwright + Chromium (one-time, on dev/display machine) +- `push_physical_to_tmdb.sh`: curl, jq, grep, sed (all POSIX, runs anywhere) + +## Edge Cases + +- **Cookie file missing:** Exit with error, run `tmdb_login.sh` first +- **Cookies expired:** Detect 401, suggest re-running `tmdb_login.sh` +- **CSRF token missing:** Re-fetch page and retry +- **Rate limiting:** 1s sleep between movies in pipe mode +- **Duplicate release date:** TMDB may reject or ignore; log response +- **Invalid TMDB ID:** Skip with warning +- **Date format:** Accept YYYY-MM-DD, convert if needed + +## Output + +### Success +``` +[1/5] Supergirl (2026) → TMDB #123456: 2026-09-08 ✓ +``` + +### Failure +``` +[2/5] Batman (2025) → TMDB #789012: ✗ Cookies expired, re-run tmdb_login.sh +``` + +### Dry-run +``` +[1/5] Supergirl (2026) → TMDB #123456: 2026-09-08 (dry-run) +``` + +## Example Usage + +```sh +# Step 1: Login (run once, on machine with display) +./radarr/tmdb_login.sh + +# Step 2: Push dates +./radarr/push_physical_to_tmdb.sh 123456 2026-09-08 "Supergirl" + +# Pipeline from fetch_physical_dates.sh +./radarr/fetch_physical_dates.sh --json --quiet | ./radarr/push_physical_to_tmdb.sh + +# Dry-run +./radarr/fetch_physical_dates.sh --json --quiet | ./radarr/push_physical_to_tmdb.sh --dry-run + +# Custom country/type +./radarr/push_physical_to_tmdb.sh 123456 2026-09-08 --country GB --type 3 +``` diff --git a/radarr/connect/scripts.conf.sample b/radarr/connect/scripts.conf.sample index 45718de..f8b73e5 100644 --- a/radarr/connect/scripts.conf.sample +++ b/radarr/connect/scripts.conf.sample @@ -70,3 +70,8 @@ DEBUG="false" # Radarr tag to mark movies switched by the forward script. # Used by the reverse script to identify candidates for switching back. # AUTO_SWITCH_TAG="auto-switched" + +# --- TMDB Website Credentials (for tmdb_login.sh) --- +# Required for push_physical_to_tmdb.sh to authenticate with TMDB website. +TMDB_USERNAME="" +TMDB_PASSWORD="" diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh new file mode 100755 index 0000000..e0443dd --- /dev/null +++ b/radarr/push_physical_to_tmdb.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env sh +# Dont warn on the word `local` +# shellcheck disable=SC3043 + +# Push physical release dates to TMDB via their website's Kendo grid API. +# Uses session cookies from tmdb_login.sh. +# +# Requirements: +# * sh (tested with sh from FreeBSD base FreeBSD 14.1) +# * curl (tested with 8.10.1) +# * jq (tested with 1.7.1) +# * grep, sed (POSIX) +# +# Version 0.1.0 (Released 2026-07-27) +# * Initial implementation + +. "$(dirname "$0")/connect/scripts_common.sh" +load_config "$(dirname "$0")/connect" + +: "${DEBUG:=false}" + +_COUNTRY="US" +_LANGUAGE="en" +_RELEASE_TYPE=5 +_NOTE="Physical release" +_DRY_RUN=false +_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" +_RATE_LIMIT=1 + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) _DRY_RUN=true; shift ;; + --debug) DEBUG=true; shift ;; + --country) + case "$2" in + '') echo "ERROR: --country requires a code" >&2; exit 1 ;; + *) _COUNTRY="$2"; shift 2 ;; + esac + ;; + --language) + case "$2" in + '') echo "ERROR: --language requires a code" >&2; exit 1 ;; + *) _LANGUAGE="$2"; shift 2 ;; + esac + ;; + --type) + case "$2" in + ''|*[!0-9]*) echo "ERROR: --type requires a number 1-7" >&2; exit 1 ;; + *) _RELEASE_TYPE="$2"; shift 2 ;; + esac + ;; + --note) + case "$2" in + '') echo "ERROR: --note requires text" >&2; exit 1 ;; + *) _NOTE="$2"; shift 2 ;; + esac + ;; + --cookies) + case "$2" in + '') echo "ERROR: --cookies requires a file path" >&2; exit 1 ;; + *) _COOKIE_FILE="$2"; shift 2 ;; + esac + ;; + --rate-limit) + case "$2" in + '') echo "ERROR: --rate-limit requires seconds" >&2; exit 1 ;; + *) _RATE_LIMIT="$2"; shift 2 ;; + esac + ;; + -*) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; + *) break ;; + esac +done + +check_needed_executables "curl jq grep sed" + +if [ ! -f "${_COOKIE_FILE}" ]; then + echo "ERROR: Cookie file not found: ${_COOKIE_FILE}" >&2 + echo "ERROR: Run tmdb_login.sh first to generate cookies." >&2 + exit 1 +fi + +debug_log "Cookie file: ${_COOKIE_FILE}" +debug_log "Country: ${_COUNTRY}, Language: ${_LANGUAGE}, Type: ${_RELEASE_TYPE}" + +############################################################################## +# Release date submission +############################################################################## + +_push_release_date() { + local _tmdb_id _date _title _payload _response _http_code _body + + _tmdb_id="$1" + _date="$2" + _title="${3:-unknown}" + + _payload=$(jq -n \ + --arg country "${_COUNTRY}" \ + --arg language "${_LANGUAGE}" \ + --arg date "${_date}" \ + --arg note "${_NOTE}" \ + --argjson type "${_RELEASE_TYPE}" \ + '{iso_3166_1: $country, iso_639_1: $language, release_date: $date, certification: "", type: $type, note: $note}') + + debug_log "Payload: ${_payload}" + + if [ "${_DRY_RUN}" = "true" ]; then + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ${_date} (dry-run)" + return 0 + fi + + _response=$(curl -sL -b "${_COOKIE_FILE}" \ + -H "User-Agent: Mozilla/5.0 (FreeBSD; FreeBSD 14.1; amd64) AppleWebKit/537.36" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -H "X-Requested-With: XMLHttpRequest" \ + -H "Referer: https://www.themoviedb.org/movie/${_tmdb_id}/edit?active_nav_item=release_information" \ + -d "data=$(printf '%s' "${_payload}" | jq -sRr @uri)" \ + -w "\n%{http_code}" \ + "https://www.themoviedb.org/movie/${_tmdb_id}/remote/release_information?translate=false&timezone=UTC") + + _http_code=$(printf '%s' "${_response}" | tail -1) + _body=$(printf '%s' "${_response}" | sed '$d') + + debug_log "HTTP ${_http_code}: ${_body}" + + if [ "${_http_code}" = "200" ] || [ "${_http_code}" = "201" ]; then + if printf '%s' "${_body}" | jq -e '.success' >/dev/null 2>&1; then + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ${_date} ✓" + return 0 + elif printf '%s' "${_body}" | jq -e '.failure' >/dev/null 2>&1; then + _error=$(printf '%s' "${_body}" | jq -r '.failure.errors[0] // "unknown error"') + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ ${_error}" >&2 + return 1 + else + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ Unknown response" >&2 + debug_log "Response: ${_body}" + return 1 + fi + elif [ "${_http_code}" = "401" ]; then + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ Cookies expired, re-run tmdb_login.sh" >&2 + return 1 + else + echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ HTTP ${_http_code}" >&2 + debug_log "Response: ${_body}" + return 1 + fi +} + +############################################################################## +# Date format conversion +############################################################################## + +_convert_date() { + local _input _month _day _year _months + + _input="$1" + + # Already YYYY-MM-DD? + if printf '%s' "${_input}" | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'; then + printf '%s' "${_input}" + return 0 + fi + + # Try "Sep 08, 2026" or "Sep 08 2026" format + _months="Jan:01 Feb:02 Mar:03 Apr:04 May:05 Jun:06 Jul:07 Aug:08 Sep:09 Oct:10 Nov:11 Dec:12" + _month=$(printf '%s' "${_input}" | awk '{print $1}') + _day=$(printf '%s' "${_input}" | awk '{print $2}' | tr -d ',') + _year=$(printf '%s' "${_input}" | awk '{print $3}') + + for _m in ${_months}; do + _mname=$(printf '%s' "${_m}" | cut -d: -f1) + _mnum=$(printf '%s' "${_m}" | cut -d: -f2) + if [ "${_month}" = "${_mname}" ]; then + printf '%s-%s-%02d' "${_year}" "${_mnum}" "${_day}" + return 0 + fi + done + + # Fallback: return as-is + printf '%s' "${_input}" +} + +############################################################################## +# Input handling +############################################################################## + +_COUNTER=0 +_TOTAL=0 +_SUCCESS=0 +_FAILED=0 + +# Args mode: single movie (check first, before pipe detection) +if [ $# -ge 2 ]; then + _TMDB_ID="$1" + _DATE="$2" + _TITLE="${3:-unknown}" + _TOTAL=1 + _COUNTER=1 + + _DATE=$(_convert_date "${_DATE}") + + _push_release_date "${_TMDB_ID}" "${_DATE}" "${_TITLE}" + exit $? +fi + +# Pipe mode: read JSON from stdin +if [ ! -t 0 ]; then + debug_log "Reading from stdin (pipe mode)" + + _TEMP=$(mktemp) + trap 'rm -f "${_TEMP}"; exit 130' INT TERM + trap 'rm -f "${_TEMP}"' EXIT + + cat > "${_TEMP}" + + _TOTAL=$(jq 'length' "${_TEMP}") + debug_log "Processing ${_TOTAL} movies" + + _SUCCESS=0 + _FAILED=0 + _COUNTER=0 + + _MOVIES_TEMP=$(mktemp) + jq -c '.[]' "${_TEMP}" > "${_MOVIES_TEMP}" + + while read -r _movie; do + _COUNTER=$((_COUNTER + 1)) + _tmdb_id=$(printf '%s' "${_movie}" | jq -r '.tmdb_id') + _date=$(printf '%s' "${_movie}" | jq -r '.physical_date') + _title=$(printf '%s' "${_movie}" | jq -r '.title') + + _date=$(_convert_date "${_date}") + + if [ -z "${_tmdb_id}" ] || [ "${_tmdb_id}" = "null" ]; then + echo "[${_COUNTER}/${_TOTAL}] ${_title}: ✗ No TMDB ID" >&2 + _FAILED=$((_FAILED + 1)) + continue + fi + + if _push_release_date "${_tmdb_id}" "${_date}" "${_title}"; then + _SUCCESS=$((_SUCCESS + 1)) + else + _FAILED=$((_FAILED + 1)) + fi + + sleep "${_RATE_LIMIT}" + done < "${_MOVIES_TEMP}" + + rm -f "${_MOVIES_TEMP}" + + rm -f "${_TEMP}" + trap - INT TERM EXIT + + echo "" + echo "Done: ${_SUCCESS} succeeded, ${_FAILED} failed" + exit 0 +fi + +# No input +echo "Usage: $0 [title]" >&2 +echo " $0 (reads JSON from stdin)" >&2 +echo "" >&2 +echo "Flags:" >&2 +echo " --dry-run Show what would be submitted" >&2 +echo " --country ISO 3166-1 country (default: US)" >&2 +echo " --language ISO 639-1 language (default: en)" >&2 +echo " --type Release type 1-7 (default: 5)" >&2 +echo " --note Note field (default: 'Physical release')" >&2 +echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" >&2 +echo " --debug Verbose logging" >&2 +exit 1 diff --git a/radarr/tmdb_login.sh b/radarr/tmdb_login.sh new file mode 100755 index 0000000..db6007d --- /dev/null +++ b/radarr/tmdb_login.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env sh +# Dont warn on the word `local` +# shellcheck disable=SC3043 + +# Playwright-based TMDB login script. +# Solves AWS WAF JS challenge and exports session cookies to Netscape format. +# Run once on machine with display (or Xvfb). +# +# Version 0.1.0 (Released 2026-07-27) +# * Initial implementation + +. "$(dirname "$0")/connect/scripts_common.sh" +load_config "$(dirname "$0")/connect" + +: "${TMDB_USERNAME:=}" +: "${TMDB_PASSWORD:=}" +: "${DEBUG:=false}" + +_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" + +while [ $# -gt 0 ]; do + case "$1" in + --cookies) + case "$2" in + '') echo "ERROR: --cookies requires a file path" >&2; exit 1 ;; + *) _COOKIE_FILE="$2"; shift 2 ;; + esac + ;; + --debug) DEBUG=true; shift ;; + *) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; + esac +done + +if [ -z "${TMDB_USERNAME}" ] || [ -z "${TMDB_PASSWORD}" ]; then + echo "ERROR: TMDB_USERNAME and TMDB_PASSWORD must be set in scripts.conf" >&2 + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: node not found. Install Node.js." >&2 + exit 127 +fi + +if ! node -e "require('playwright')" 2>/dev/null; then + echo "ERROR: playwright not found. Run: npm install playwright && npx playwright install chromium" >&2 + exit 127 +fi + +debug_log "Launching Playwright for TMDB login..." + +_PLAYWRIGHT_SCRIPT=$(cat << 'NODESCRIPT' +const { chromium } = require('playwright'); +const fs = require('fs'); + +(async () => { + const username = process.env.TMDB_USERNAME; + const password = process.env.TMDB_PASSWORD; + const cookieFile = process.env.TMDB_COOKIE_FILE; + + if (!username || !password) { + console.error('ERROR: TMDB_USERNAME and TMDB_PASSWORD must be set'); + process.exit(1); + } + + const browser = await chromium.launch({ + headless: false, + args: ['--disable-blink-features=AutomationControlled'] + }); + const context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 720 }, + locale: 'en-US', + }); + await context.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => false }); + }); + const page = await context.newPage(); + + try { + console.error('Navigating to TMDB login...'); + await page.goto('https://www.themoviedb.org/login', { waitUntil: 'networkidle' }); + + console.error('Filling credentials...'); + await page.fill('#username', username); + await page.fill('#password', password); + + console.error('Submitting login...'); + await Promise.all([ + page.waitForNavigation({ timeout: 30000 }), + page.click('#login_button'), + ]); + + const loggedIn = await page.locator('header ul > li.user').count(); + if (loggedIn === 0) { + console.error('ERROR: Login failed. Check credentials.'); + process.exit(1); + } + + console.error('Login successful. Exporting cookies...'); + + const cookies = await context.cookies(); + const lines = ['# Netscape HTTP Cookie File', '# Generated by tmdb_login.sh', '']; + + for (const c of cookies) { + const domain = c.domain.startsWith('.') ? c.domain : '.' + c.domain; + const flag = 'TRUE'; + const path = c.path || '/'; + const secure = c.secure ? 'TRUE' : 'FALSE'; + const expiry = c.expires > 0 ? Math.floor(c.expires) : 0; + lines.push([domain, flag, path, secure, expiry, c.name, c.value].join('\t')); + } + + fs.writeFileSync(cookieFile, lines.join('\n') + '\n'); + console.log(cookieFile); + console.error('Cookies exported to ' + cookieFile); + + } catch (err) { + console.error('ERROR:', err.message); + process.exit(1); + } finally { + await browser.close(); + } +})(); +NODESCRIPT +) + +TMDB_USERNAME="${TMDB_USERNAME}" TMDB_PASSWORD="${TMDB_PASSWORD}" TMDB_COOKIE_FILE="${_COOKIE_FILE}" \ + node -e "${_PLAYWRIGHT_SCRIPT}" + +debug_log "tmdb_login.sh completed" From 95fe6018cb3824de8ddbe80ae3562fbd707a2850 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Tue, 28 Jul 2026 00:26:11 +0200 Subject: [PATCH 04/14] feat: add TMDB_COOKIE_FILE config variable --- radarr/connect/scripts.conf.sample | 4 ++++ radarr/push_physical_to_tmdb.sh | 3 ++- radarr/tmdb_login.sh | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/radarr/connect/scripts.conf.sample b/radarr/connect/scripts.conf.sample index f8b73e5..28a51cf 100644 --- a/radarr/connect/scripts.conf.sample +++ b/radarr/connect/scripts.conf.sample @@ -75,3 +75,7 @@ DEBUG="false" # Required for push_physical_to_tmdb.sh to authenticate with TMDB website. TMDB_USERNAME="" TMDB_PASSWORD="" + +# Path to Netscape-format cookie file for TMDB website authentication. +# Used by push_physical_to_tmdb.sh. Default: ~/.tmdb_cookies.txt +# TMDB_COOKIE_FILE="" diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh index e0443dd..bff388d 100755 --- a/radarr/push_physical_to_tmdb.sh +++ b/radarr/push_physical_to_tmdb.sh @@ -18,13 +18,14 @@ load_config "$(dirname "$0")/connect" : "${DEBUG:=false}" +: "${TMDB_COOKIE_FILE:=${HOME}/.tmdb_cookies.txt}" _COUNTRY="US" _LANGUAGE="en" _RELEASE_TYPE=5 _NOTE="Physical release" _DRY_RUN=false -_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" +_COOKIE_FILE="${TMDB_COOKIE_FILE}" _RATE_LIMIT=1 while [ $# -gt 0 ]; do diff --git a/radarr/tmdb_login.sh b/radarr/tmdb_login.sh index db6007d..b66895d 100755 --- a/radarr/tmdb_login.sh +++ b/radarr/tmdb_login.sh @@ -14,9 +14,10 @@ load_config "$(dirname "$0")/connect" : "${TMDB_USERNAME:=}" : "${TMDB_PASSWORD:=}" +: "${TMDB_COOKIE_FILE:=${HOME}/.tmdb_cookies.txt}" : "${DEBUG:=false}" -_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" +_COOKIE_FILE="${TMDB_COOKIE_FILE}" while [ $# -gt 0 ]; do case "$1" in From 3d48536f46c158c718fe19fdf72f4cbf3a5192f7 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Tue, 28 Jul 2026 00:28:55 +0200 Subject: [PATCH 05/14] docs: add TMDB_COOKIE_FILE and --rate-limit to README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index b7f15bb..038fdc5 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,12 @@ Accepts single movies via arguments or batch processing via pipe from - `curl`, `jq`, `grep`, `sed` (POSIX tools) - Cookie file from `tmdb_login.sh` (default: `~/.tmdb_cookies.txt`) +Set `TMDB_COOKIE_FILE` in `scripts.conf` to use a custom path: + +```sh +TMDB_COOKIE_FILE="/path/to/cookies.txt" +``` + #### Flags | Flag | Effect | @@ -278,6 +284,7 @@ Accepts single movies via arguments or batch processing via pipe from | `--type ` | Release type 1-7 (default: 5 = Physical) | | `--note ` | Note field (default: "Physical release") | | `--cookies ` | Cookie file path (default: `~/.tmdb_cookies.txt`) | +| `--rate-limit ` | Seconds between requests in pipe mode (default: 1) | | `-d`, `--debug` | Verbose debug logging to stderr | #### Scheduling From e02ac87f1cdc3c375ef08ba53791f26dae752993 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Tue, 28 Jul 2026 00:38:01 +0200 Subject: [PATCH 06/14] feat: add --help flag to all scripts --- radarr/auto_quality_switch.sh | 15 +++++++++++++++ radarr/auto_quality_switch_reverse.sh | 14 ++++++++++++++ radarr/connect/download_trailer.sh | 13 +++++++++++++ radarr/connect/tag_dvfelmel.sh | 13 +++++++++++++ radarr/fetch_physical_dates.sh | 16 ++++++++++++++++ radarr/push_physical_to_tmdb.sh | 18 ++++++++++++++++++ radarr/tmdb_login.sh | 12 ++++++++++++ 7 files changed, 101 insertions(+) mode change 100644 => 100755 radarr/auto_quality_switch.sh diff --git a/radarr/auto_quality_switch.sh b/radarr/auto_quality_switch.sh old mode 100644 new mode 100755 index af43a96..f216ce8 --- a/radarr/auto_quality_switch.sh +++ b/radarr/auto_quality_switch.sh @@ -63,6 +63,21 @@ while [ $# -gt 0 ]; do -j|--json) _FLAG_JSON=true; shift ;; -q|--quiet) _FLAG_QUIET=true; shift ;; -d|--debug) DEBUG=true; shift ;; + -h|--help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Switch movies from Remux-only to WebDL when no physical release appears." + echo "" + echo "Options:" + echo " --apply Actually switch profiles (overrides DRY_RUN)" + echo " --migrate One-time library migration mode" + echo " -n, --dry-run Preview mode, no changes (default)" + echo " -j, --json Output machine-readable JSON" + echo " -q, --quiet Only errors and counts" + echo " -d, --debug Verbose logging" + echo " -h, --help Show this help" + exit 0 + ;; *) break ;; esac done diff --git a/radarr/auto_quality_switch_reverse.sh b/radarr/auto_quality_switch_reverse.sh index e27e95b..078caac 100755 --- a/radarr/auto_quality_switch_reverse.sh +++ b/radarr/auto_quality_switch_reverse.sh @@ -44,6 +44,20 @@ while [ $# -gt 0 ]; do -j|--json) _FLAG_JSON=true; shift ;; -q|--quiet) _FLAG_QUIET=true; shift ;; -d|--debug) DEBUG=true; shift ;; + -h|--help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Switch movies back to Remux-only when physical release dates appear." + echo "" + echo "Options:" + echo " --apply Actually switch profiles (overrides DRY_RUN)" + echo " -n, --dry-run Preview mode, no changes (default)" + echo " -j, --json Output machine-readable JSON" + echo " -q, --quiet Only errors and counts" + echo " -d, --debug Verbose logging" + echo " -h, --help Show this help" + exit 0 + ;; *) break ;; esac done diff --git a/radarr/connect/download_trailer.sh b/radarr/connect/download_trailer.sh index 88e19c3..1065cbd 100755 --- a/radarr/connect/download_trailer.sh +++ b/radarr/connect/download_trailer.sh @@ -426,6 +426,19 @@ while [ $# -gt 0 ]; do case "$1" in -n) DRY_RUN=true; shift ;; -d) DEBUG=true; shift ;; + -h|--help) + echo "Usage: $0 [-n] [-d] [event_type] [movie_id] [movie_path]" + echo "" + echo "Download official trailers from TMDB/YouTube for movies in Radarr." + echo "" + echo "Options:" + echo " -n Dry-run mode" + echo " -d Debug logging" + echo " -h Show this help" + echo "" + echo "Event types: Test, MovieAdded, Download, Bulk" + exit 0 + ;; *) break ;; esac done diff --git a/radarr/connect/tag_dvfelmel.sh b/radarr/connect/tag_dvfelmel.sh index bcb418a..092bb99 100755 --- a/radarr/connect/tag_dvfelmel.sh +++ b/radarr/connect/tag_dvfelmel.sh @@ -185,6 +185,19 @@ while [ $# -gt 0 ]; do case "$1" in -n) DRY_RUN=true; shift ;; -d) DEBUG=true; shift ;; + -h|--help) + echo "Usage: $0 [-n] [-d] [event_type] [movie_id] [movie_file_path]" + echo "" + echo "Tag movies with 'fel' or 'mel' based on Dolby Vision Enhancement Layer." + echo "" + echo "Options:" + echo " -n Dry-run mode" + echo " -d Debug logging" + echo " -h Show this help" + echo "" + echo "Event types: Test, MovieFileDelete, Download, Bulk" + exit 0 + ;; *) break ;; esac done diff --git a/radarr/fetch_physical_dates.sh b/radarr/fetch_physical_dates.sh index 4a0c1fb..271f5d4 100755 --- a/radarr/fetch_physical_dates.sh +++ b/radarr/fetch_physical_dates.sh @@ -57,6 +57,22 @@ while [ $# -gt 0 ]; do esac ;; --debug) DEBUG=true; shift ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Search Blu-ray.com for physical release dates missing from Radarr." + echo "" + echo "Options:" + echo " --limit N Process max N movies per run" + echo " --export Write results to file (JSON or CSV)" + echo " --csv Export as CSV (default: JSON)" + echo " --json JSON output to stdout" + echo " --quiet Suppress table output" + echo " --country Country filter (default: US)" + echo " --debug Verbose logging" + echo " -h, --help Show this help" + exit 0 + ;; -*) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; *) break ;; esac diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh index bff388d..cf5a44f 100755 --- a/radarr/push_physical_to_tmdb.sh +++ b/radarr/push_physical_to_tmdb.sh @@ -68,6 +68,24 @@ while [ $# -gt 0 ]; do *) _RATE_LIMIT="$2"; shift 2 ;; esac ;; + --help|-h) + echo "Usage: $0 [title]" + echo " $0 (reads JSON from stdin)" + echo "" + echo "Push physical release dates to TMDB via their website's Kendo grid API." + echo "" + echo "Options:" + echo " --dry-run Show what would be submitted, don't POST" + echo " --country ISO 3166-1 country code (default: US)" + echo " --language ISO 639-1 language code (default: en)" + echo " --type Release type 1-7 (default: 5)" + echo " --note Note field (default: 'Physical release')" + echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" + echo " --rate-limit Seconds between requests in pipe mode (default: 1)" + echo " --debug Verbose logging" + echo " -h, --help Show this help" + exit 0 + ;; -*) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; *) break ;; esac diff --git a/radarr/tmdb_login.sh b/radarr/tmdb_login.sh index b66895d..c2e3710 100755 --- a/radarr/tmdb_login.sh +++ b/radarr/tmdb_login.sh @@ -28,6 +28,18 @@ while [ $# -gt 0 ]; do esac ;; --debug) DEBUG=true; shift ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Playwright-based TMDB login. Exports session cookies to Netscape format." + echo "Run once on machine with display (or Xvfb)." + echo "" + echo "Options:" + echo " --cookies Output cookie file (default: ~/.tmdb_cookies.txt)" + echo " --debug Verbose logging" + echo " -h, --help Show this help" + exit 0 + ;; *) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; esac done From fbad899c8a75fa5f1a1766814e97c60233883478 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Tue, 28 Jul 2026 00:49:23 +0200 Subject: [PATCH 07/14] fix: handle wrapped JSON format from fetch_physical_dates.sh --- radarr/push_physical_to_tmdb.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh index cf5a44f..d2348e7 100755 --- a/radarr/push_physical_to_tmdb.sh +++ b/radarr/push_physical_to_tmdb.sh @@ -232,16 +232,22 @@ if [ ! -t 0 ]; then cat > "${_TEMP}" - _TOTAL=$(jq 'length' "${_TEMP}") + # Handle both flat array and wrapped {results: [...]} format + if jq -e '.results' "${_TEMP}" >/dev/null 2>&1; then + _TOTAL=$(jq '.results | length' "${_TEMP}") + _MOVIES_TEMP=$(mktemp) + jq -c '.results[]' "${_TEMP}" > "${_MOVIES_TEMP}" + else + _TOTAL=$(jq 'length' "${_TEMP}") + _MOVIES_TEMP=$(mktemp) + jq -c '.[]' "${_TEMP}" > "${_MOVIES_TEMP}" + fi debug_log "Processing ${_TOTAL} movies" _SUCCESS=0 _FAILED=0 _COUNTER=0 - _MOVIES_TEMP=$(mktemp) - jq -c '.[]' "${_TEMP}" > "${_MOVIES_TEMP}" - while read -r _movie; do _COUNTER=$((_COUNTER + 1)) _tmdb_id=$(printf '%s' "${_movie}" | jq -r '.tmdb_id') From 99e575331085946174db575ed11bc558c8c4e946 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Tue, 28 Jul 2026 00:56:58 +0200 Subject: [PATCH 08/14] fix: empty defaults for language, certification, and note fields --- radarr/push_physical_to_tmdb.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh index d2348e7..201db71 100755 --- a/radarr/push_physical_to_tmdb.sh +++ b/radarr/push_physical_to_tmdb.sh @@ -21,9 +21,9 @@ load_config "$(dirname "$0")/connect" : "${TMDB_COOKIE_FILE:=${HOME}/.tmdb_cookies.txt}" _COUNTRY="US" -_LANGUAGE="en" +_LANGUAGE="" _RELEASE_TYPE=5 -_NOTE="Physical release" +_NOTE="" _DRY_RUN=false _COOKIE_FILE="${TMDB_COOKIE_FILE}" _RATE_LIMIT=1 @@ -77,9 +77,9 @@ while [ $# -gt 0 ]; do echo "Options:" echo " --dry-run Show what would be submitted, don't POST" echo " --country ISO 3166-1 country code (default: US)" - echo " --language ISO 639-1 language code (default: en)" + echo " --language ISO 639-1 language code (default: empty)" echo " --type Release type 1-7 (default: 5)" - echo " --note Note field (default: 'Physical release')" + echo " --note Note field (default: empty)" echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" echo " --rate-limit Seconds between requests in pipe mode (default: 1)" echo " --debug Verbose logging" @@ -288,9 +288,9 @@ echo "" >&2 echo "Flags:" >&2 echo " --dry-run Show what would be submitted" >&2 echo " --country ISO 3166-1 country (default: US)" >&2 -echo " --language ISO 639-1 language (default: en)" >&2 +echo " --language ISO 639-1 language (default: empty)" >&2 echo " --type Release type 1-7 (default: 5)" >&2 -echo " --note Note field (default: 'Physical release')" >&2 +echo " --note Note field (default: empty)" >&2 echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" >&2 echo " --debug Verbose logging" >&2 exit 1 From 2b2a8de7dab129fab643a30d5cad92862eaf820f Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Tue, 28 Jul 2026 01:00:34 +0200 Subject: [PATCH 09/14] feat: add --certification flag with US rating validation --- radarr/push_physical_to_tmdb.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh index 201db71..2a6ea1e 100755 --- a/radarr/push_physical_to_tmdb.sh +++ b/radarr/push_physical_to_tmdb.sh @@ -22,6 +22,7 @@ load_config "$(dirname "$0")/connect" _COUNTRY="US" _LANGUAGE="" +_CERTIFICATION="" _RELEASE_TYPE=5 _NOTE="" _DRY_RUN=false @@ -44,6 +45,13 @@ while [ $# -gt 0 ]; do *) _LANGUAGE="$2"; shift 2 ;; esac ;; + --certification) + case "$2" in + '') echo "ERROR: --certification requires a value" >&2; exit 1 ;; + G|PG|PG-13|R|NC-17|NR) _CERTIFICATION="$2"; shift 2 ;; + *) echo "ERROR: --certification must be one of: G, PG, PG-13, R, NC-17, NR" >&2; exit 1 ;; + esac + ;; --type) case "$2" in ''|*[!0-9]*) echo "ERROR: --type requires a number 1-7" >&2; exit 1 ;; @@ -78,6 +86,7 @@ while [ $# -gt 0 ]; do echo " --dry-run Show what would be submitted, don't POST" echo " --country ISO 3166-1 country code (default: US)" echo " --language ISO 639-1 language code (default: empty)" + echo " --certification US certification: G, PG, PG-13, R, NC-17, NR (default: empty)" echo " --type Release type 1-7 (default: 5)" echo " --note Note field (default: empty)" echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" @@ -116,10 +125,11 @@ _push_release_date() { _payload=$(jq -n \ --arg country "${_COUNTRY}" \ --arg language "${_LANGUAGE}" \ + --arg certification "${_CERTIFICATION}" \ --arg date "${_date}" \ --arg note "${_NOTE}" \ --argjson type "${_RELEASE_TYPE}" \ - '{iso_3166_1: $country, iso_639_1: $language, release_date: $date, certification: "", type: $type, note: $note}') + '{iso_3166_1: $country, iso_639_1: $language, release_date: $date, certification: $certification, type: $type, note: $note}') debug_log "Payload: ${_payload}" @@ -289,6 +299,7 @@ echo "Flags:" >&2 echo " --dry-run Show what would be submitted" >&2 echo " --country ISO 3166-1 country (default: US)" >&2 echo " --language ISO 639-1 language (default: empty)" >&2 +echo " --certification US certification: G, PG, PG-13, R, NC-17, NR (default: empty)" >&2 echo " --type Release type 1-7 (default: 5)" >&2 echo " --note Note field (default: empty)" >&2 echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" >&2 From 009da2b26b434f1c46d041a8059c57ebd8de1024 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Thu, 30 Jul 2026 14:11:38 +0200 Subject: [PATCH 10/14] feat: add tmdb_url field to fetch_physical_dates output --- radarr/fetch_physical_dates.sh | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/radarr/fetch_physical_dates.sh b/radarr/fetch_physical_dates.sh index 271f5d4..1e45792 100755 --- a/radarr/fetch_physical_dates.sh +++ b/radarr/fetch_physical_dates.sh @@ -223,6 +223,8 @@ do debug_log " Found: ${_reldate} (${_bluray_title})" + _tmdb_url="https://www.themoviedb.org/movie/${_tmdb_id}" + _entry=$(jq -n \ --argjson radarr_id "${_radarr_id}" \ --arg title "${_title}" \ @@ -231,7 +233,8 @@ do --arg physical_date "${_reldate}" \ --arg bluray_title "${_bluray_title}" \ --arg bluray_url "${_bluray_url}" \ - '{radarr_id: $radarr_id, title: $title, year: $year, tmdb_id: $tmdb_id, physical_date: $physical_date, bluray_title: $bluray_title, bluray_url: $bluray_url}') + --arg tmdb_url "${_tmdb_url}" \ + '{radarr_id: $radarr_id, title: $title, year: $year, tmdb_id: $tmdb_id, physical_date: $physical_date, bluray_title: $bluray_title, bluray_url: $bluray_url, tmdb_url: $tmdb_url}') _RESULTS=$(printf '%s' "${_RESULTS}" | jq --argjson entry "${_entry}" '. + [$entry]') _FOUND_COUNT=$((_FOUND_COUNT + 1)) @@ -269,12 +272,12 @@ then if [ "${_FOUND_COUNT}" -gt 0 ] then - printf '%-45s %-6s %-20s %s\n' "Movie" "Year" "Blu-ray.com Date" "TMDB ID" - printf '%-45s %-6s %-20s %s\n' "-----" "----" "----------------" "-------" + printf '%-45s %-6s %-15s %-10s %s\n' "Movie" "Year" "Blu-ray.com Date" "TMDB ID" "TMDB URL" + printf '%-45s %-6s %-15s %-10s %s\n' "-----" "----" "----------------" "-------" "--------" - printf '%s' "${_RESULTS}" | jq -r '.[] | "\(.title)|\(.year)|\(.physical_date)|\(.tmdb_id)"' | \ - while IFS='|' read -r _title _year _date _tmdb_id; do - printf '%-45s %-6s %-20s %s\n' "${_title}" "${_year}" "${_date}" "${_tmdb_id}" + printf '%s' "${_RESULTS}" | jq -r '.[] | "\(.title)|\(.year)|\(.physical_date)|\(.tmdb_id)|\(.tmdb_url)"' | \ + while IFS='|' read -r _title _year _date _tmdb_id _tmdb_url; do + printf '%-45s %-6s %-15s %-10s %s\n' "${_title}" "${_year}" "${_date}" "${_tmdb_id}" "${_tmdb_url}" done echo fi @@ -284,8 +287,8 @@ if [ -n "${_EXPORT_FILE}" ] then if [ "${_FLAG_CSV}" = "true" ] then - echo "radarr_id,title,year,tmdb_id,physical_date,bluray_title,bluray_url" > "${_EXPORT_FILE}" - printf '%s' "${_RESULTS}" | jq -r '.[] | [.radarr_id, .title, .year, .tmdb_id, .physical_date, .bluray_title, .bluray_url] | @csv' >> "${_EXPORT_FILE}" + echo "radarr_id,title,year,tmdb_id,physical_date,bluray_title,bluray_url,tmdb_url" > "${_EXPORT_FILE}" + printf '%s' "${_RESULTS}" | jq -r '.[] | [.radarr_id, .title, .year, .tmdb_id, .physical_date, .bluray_title, .bluray_url, .tmdb_url] | @csv' >> "${_EXPORT_FILE}" debug_log "Exported ${_FOUND_COUNT} results to ${_EXPORT_FILE} (CSV)" else printf '%s' "${_RESULTS}" | jq \ From 486f65a4807acbe8f9388fbfbe28e6e1928461ad Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Thu, 30 Jul 2026 14:11:40 +0200 Subject: [PATCH 11/14] fix: strip leading zero from day before printf to avoid octal error --- radarr/push_physical_to_tmdb.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh index 2a6ea1e..1c1ae0d 100755 --- a/radarr/push_physical_to_tmdb.sh +++ b/radarr/push_physical_to_tmdb.sh @@ -194,6 +194,7 @@ _convert_date() { _months="Jan:01 Feb:02 Mar:03 Apr:04 May:05 Jun:06 Jul:07 Aug:08 Sep:09 Oct:10 Nov:11 Dec:12" _month=$(printf '%s' "${_input}" | awk '{print $1}') _day=$(printf '%s' "${_input}" | awk '{print $2}' | tr -d ',') + _day=${_day#0} _year=$(printf '%s' "${_input}" | awk '{print $3}') for _m in ${_months}; do From 799134ac155c7a023644b4995812a1422c585a36 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Thu, 30 Jul 2026 16:00:21 +0200 Subject: [PATCH 12/14] chore: add package.json and package-lock.json for node deps --- package-lock.json | 56 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +++++ 2 files changed, 61 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0df4f45 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "arr_scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright": "^1.62.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..abb78d7 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "playwright": "^1.62.0" + } +} From 3801cd4e12a2e10f15188c9103c73756ec3f96ef Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Thu, 30 Jul 2026 16:03:02 +0200 Subject: [PATCH 13/14] chore: remove stale plan/spec docs, update file organization --- AGENTS.md | 4 - docs/quality-switch-reverse-spec.md | 404 ------------- docs/quality-switch-spec.md | 535 ------------------ .../plans/2026-07-27-push-physical-to-tmdb.md | 532 ----------------- ...2026-07-27-push-physical-to-tmdb-design.md | 173 ------ 5 files changed, 1648 deletions(-) delete mode 100644 docs/quality-switch-reverse-spec.md delete mode 100644 docs/quality-switch-spec.md delete mode 100644 docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md delete mode 100644 docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md diff --git a/AGENTS.md b/AGENTS.md index 8316b76..16a5394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -346,11 +346,7 @@ radarr/ push_physical_to_tmdb.sh # Push release dates to TMDB via curl docs/ - quality-switch-spec.md # Forward script specification - quality-switch-reverse-spec.md # Reverse script specification cookie-extraction.md # Guide for exporting YouTube cookies for yt-dlp - superpowers/specs/ # Design specs for new features - superpowers/plans/ # Implementation plans ``` --- diff --git a/docs/quality-switch-reverse-spec.md b/docs/quality-switch-reverse-spec.md deleted file mode 100644 index 3fd63e8..0000000 --- a/docs/quality-switch-reverse-spec.md +++ /dev/null @@ -1,404 +0,0 @@ -# Auto Quality Profile Switch (Reverse) — Specification - -## 1. Problem Statement - -The forward switch script (`auto_quality_switch.sh`) moves movies from Remux-only to WebDL profiles when no physical release appears within a statistical threshold. However, some of these movies later receive physical release dates in Radarr (e.g., boutique labels, delayed disc announcements). These movies remain on WebDL profiles indefinitely, missing the opportunity to upgrade to Remux when physical media becomes available. - -**Goal:** Automatically detect movies previously switched to WebDL that now have physical release dates, and switch them back to Remux-only profiles. - -## 2. Design Decisions - -### 2.1 Detection method: Radarr tags - -**Decision:** Use a Radarr tag (`auto-switched`) to track movies switched by the forward script. - -**Rationale:** -- Clean, idempotent, queryable via API -- No false positives (only movies explicitly switched by our script get tagged) -- Survives restarts, DB migrations, script updates -- No external state files to manage - -**Alternatives considered:** -- Fuzzy match (profile == TARGET + physicalRelease set): Rejected — can't distinguish "we switched" from "always was" -- State file: Rejected — breaks on Radarr DB restore, adds file management overhead - -### 2.2 Forward script dependency - -**Prerequisite:** The forward script (`auto_quality_switch.sh`) must be modified to add the `auto-switched` tag when switching movies to the TARGET profile. - -**Forward script changes:** -1. Add `AUTO_SWITCH_TAG` config variable (default: `auto-switched`) -2. On script startup, create tag if it doesn't exist: `POST /api/v3/tag` with `{"label": "auto-switched"}` -3. After each successful profile switch (inside the per-movie loop), add tag to movie: `PUT /api/v3/movie/{id}` with updated tags array -4. If profile switch fails, tag is not added (per-movie atomicity) - -**Tag creation flow:** -``` -# Run once at script startup -tags = GET /api/v3/tag -tag_id = tags[where label == AUTO_SWITCH_TAG].id - -if tag_id == null: - response = POST /api/v3/tag - body: {"label": "auto-switched"} - tag_id = response.id -``` - -**Tag addition flow (per-movie, after successful switch):** -``` -# Fetch movie's current tags -movie = GET /api/v3/movie/{id} - -# Add auto-switched tag (preserve existing tags) -PUT /api/v3/movie/{id} - body: {"tags": movie.tags + [tag_id]} -``` - -**Note:** Profile switch and tag addition are two separate API calls, not truly atomic. If tag addition fails after a successful profile switch, the movie will be switched but untagged. The reverse script will not pick it up (no false positives). The forward script can log the error for manual remediation. - -### 2.3 Script structure: Separate script - -**Decision:** Implement as `radarr/auto_quality_switch_reverse.sh`, separate from the forward script. - -**Rationale:** -- Forward and reverse have different cadence (daily vs weekly) -- Keeps each script simple and focused -- Shared config via `scripts_common.sh` avoids duplication -- Independent scheduling via cron - -**Alternatives considered:** -- Same script with `--reverse` flag: Rejected — adds complexity, harder to schedule independently -- Both directions in one pass: Rejected — too aggressive, no separate control - -### 2.4 File handling: Let Radarr manage - -**Decision:** When switching back, if movie has a WebDL file, let Radarr handle file management. - -**Rationale:** -- Radarr automatically detects profile mismatch -- Next search finds Remux release, downloads it, deletes WebDL -- One wasted WebDL download is acceptable — it auto-corrects -- Simpler implementation, no manual file deletion - -**Alternatives considered:** -- Delete file before switching: Rejected — aggressive, wastes bandwidth if Remux not available -- Skip if hasFile==true: Rejected — defeats the purpose - -### 2.5 Search trigger: Always search - -**Decision:** After switching back to Remux-only, always trigger search for Remux. - -**Rationale:** -- User wants Remux upgrade to happen promptly -- Consistent with forward script behavior -- Radarr handles queue management - -## 3. Script: `radarr/auto_quality_switch_reverse.sh` - -### 3.1 Purpose - -Weekly cron job that: -1. Fetches all movies from Radarr API -2. Filters to movies tagged with `auto-switched` -3. Identifies movies where: - - `physicalRelease` is now set (was previously null) - - Current `qualityProfileId` matches the TARGET profile (WebDL) -4. Switches those movies back to the SOURCE profile (Remux-only) -5. Removes the `auto-switched` tag -6. Triggers search for Remux release - -### 3.2 Behavior modes - -| Mode | Trigger | Effect | -|------|---------|--------| -| Dry-run | Default or `-n` / `--dry-run` | Print candidates, no API mutations | -| Apply | `--apply` | Actually switch profiles | -| JSON | `-j` / `--json` | Output machine-readable JSON | -| Quiet | `-q` / `--quiet` | Only print errors and counts, no per-movie list | - -**Mode precedence:** Same as forward script (see `quality-switch-spec.md` section 3.2). - -### 3.3 Exit codes - -| Code | Meaning | -|------|---------| -| 0 | Success (no changes needed in dry-run) | -| 0 | Success (N movies switched in apply mode) | -| 1 | API error or configuration error | -| 127 | Missing executable dependency | - -## 4. Configuration - -### 4.1 From `scripts.conf` - -The script sources `scripts_common.sh` and reads these existing settings: - -```sh -RADARR_API_URL="http://ip:7878/api/v3" -RADARR_API_KEY="your_api_key" -``` - -### 4.2 Script-specific defaults (can be overridden in `scripts.conf`) - -| Variable | Default | Description | -|----------|---------|-------------| -| `SOURCE_PROFILE_NAME` | `Remux-2160p` | Profile to switch movies back to (Remux-only). Shared with forward script. | -| `TARGET_PROFILE_NAME` | `WebDL-2160p` | Profile to match candidates for reverse switch (WebDL). Shared with forward script. | -| `AUTO_SWITCH_TAG` | `auto-switched` | Radarr tag used to track movies switched by forward script. | -| `DRY_RUN` | `true` | Default preview mode. Set `false` in `scripts.conf` or pass `--apply` to execute switches. | -| `MAX_SWITCH_PER_RUN` | `0` | Max movies to switch per run. `0` = unlimited. Limits batch size to avoid hammering API. | -| `TRIGGER_SEARCH` | `true` | After switching, call `POST /api/v3/command` to queue `MoviesSearch` for switched movies. Set `false` to only switch profiles. | - -### 4.3 Example `scripts.conf` overrides - -```sh -# Auto quality switch (reverse) settings -# These share config with the forward script -SOURCE_PROFILE_NAME="Remux-2160p" -TARGET_PROFILE_NAME="WebDL-2160p" -AUTO_SWITCH_TAG="auto-switched" -# Uncomment to allow switches without --apply flag: -# DRY_RUN=false -# Safety limit per run: -# MAX_SWITCH_PER_RUN=50 -# Uncomment to skip search after switch: -# TRIGGER_SEARCH=false -``` - -## 5. Algorithm - -### 5.1 Tag resolution - -``` -tags = GET /api/v3/tag -auto_switch_tag_id = tags[where label == AUTO_SWITCH_TAG].id -``` - -If tag not found, create it: -``` -POST /api/v3/tag -body: {"label": "auto-switched"} -``` - -### 5.2 Candidate matching - -**Note:** Radarr API does not support tag-based filtering via query parameters. Fetch all movies and filter in jq. - -``` -# Fetch all movies -all_movies = GET /api/v3/movie - -# Filter to tagged movies with physical release -candidates = [] -for movie in all_movies: - if auto_switch_tag_id not in movie.tags: - skip # Not tagged by our script - if movie.physicalRelease == null: - skip # Still no physical release - if movie.qualityProfileId != switch_from_id: - skip # Not on WebDL profile (already switched back?) - - candidates.append(movie) -``` - -### 5.3 Profile switch + tag removal - -For each candidate: -``` -# Switch profile -PUT /api/v3/movie/editor -body: { - "movieIds": [candidate.id], - "qualityProfileId": switch_to_id -} - -# Remove auto-switched tag (keep other tags) -movie = GET /api/v3/movie/{id} -PUT /api/v3/movie/{id} -body: { - "tags": [movie.tags[] | select(. != auto_switch_tag_id)] -} -``` - -**Tag removal approach:** The movie editor endpoint doesn't support tag operations directly. Fetch the movie's current tags, filter out the `auto-switched` tag, and update via the movie update endpoint. - -### 5.4 Search trigger - -After all profile switches, if `TRIGGER_SEARCH=true` and switched list non-empty: - -``` -POST /api/v3/command -body: { - "name": "MoviesSearch", - "movieIds": switched_ids -} -``` - -Same as forward script — Radarr queues search tasks for Remux releases. - -## 6. Output Format - -### 6.1 Pretty table (default) - -``` -Auto Quality Profile Switch (Reverse) -===================================== - -Candidates: 5 movies with physical release now available - -Movie Physical Release Current -> Target ------ ---------------- ------- -------- -The Matrix Resurrections 2022-04-26 WebDL-2160p -> Remux-2160p -... - -DRY-RUN: 5 movies would switch. Run with --apply to execute. -``` - -Or in apply mode: - -``` -APPLY: Switched 5 movies to Remux-2160p -TAGS: Removed auto-switched tag from 5 movies -QUEUED: 5 movies sent for search -``` - -### 6.2 JSON mode (`--json`) - -```json -{ - "switch_to_profile": {"id": 5, "name": "Remux-2160p"}, - "switch_from_profile": {"id": 7, "name": "WebDL-2160p"}, - "tag": {"id": 12, "label": "auto-switched"}, - "candidates": [ - { - "id": 123, - "title": "The Matrix Resurrections", - "year": 2021, - "physicalRelease": "2022-04-26T00:00:00Z", - "qualityProfileId": 7 - } - ], - "candidate_count": 5, - "switched_count": 5, - "tags_removed": 5, - "searched_count": 5, - "search_triggered": true, - "dry_run": false -} -``` - -## 7. Implementation Plan - -### Phase 1: Forward script tag support (prerequisite) - -This phase must be completed before the reverse script can function. The forward script must tag movies it switches so the reverse script can identify them. - -**Changes to `radarr/auto_quality_switch.sh`:** -- Add `AUTO_SWITCH_TAG` config variable (default: `auto-switched`) -- Add tag creation at script startup: check if tag exists, create if not -- After each successful profile switch (inside the per-movie loop), add tag to movie via `PUT /api/v3/movie/{id}` -- If tag addition fails, log warning but continue (movie is switched, just untagged) -- Update changelog to v0.3.0 -- Update `scripts.conf.sample` with new config variable - -### Phase 2: Reverse script scaffold (1 step) - -- Create `radarr/auto_quality_switch_reverse.sh` -- Changelog header following project convention -- Source `scripts_common.sh`, call `load_config` -- Define default variables (section 4.2) -- Update `radarr/connect/scripts.conf.sample` with reverse script config - -### Phase 3: Tag resolution (1 step) - -- Implement tag lookup/creation -- Fetch tag ID from Radarr API -- Create tag if not found - -### Phase 4: Candidate matching (1 step) - -- Fetch all movies (API doesn't support tag filtering) -- Filter by tag in jq -- Filter to those with `physicalRelease` set -- Filter to those on SWITCH_FROM profile -- Output candidate list - -### Phase 5: Switch execution (1 step) - -- Switch profile via movie editor API -- Remove tag via movie update API -- Rate-limit with `sleep 0.5` between calls -- Respect `MAX_SWITCH_PER_RUN` - -### Phase 6: Search trigger (1 step) - -- Same as forward script -- POST `/api/v3/command` with `MoviesSearch` - -### Phase 7: Output (1 step) - -- Pretty table printing (same conventions as forward script) -- JSON output via `--json` flag - -### Phase 8: Documentation (1 step) - -- Update `docs/quality-switch-spec.md` to reference reverse script -- Add changelog entries -- Update `scripts.conf.sample` with all config variables - -### Phase 9: Verification (1 step) - -- Run `shellcheck -e SC3043 -s sh` -- Test with `DRY_RUN=true` on a real Radarr instance -- Verify tag creation and movie tagging -- Verify reverse switch and tag removal - -## 8. Edge Cases - -| Edge case | Handling | -|-----------|----------| -| Tag doesn't exist | Create it automatically on first run | -| Movie has WebDL file when switching back | Let Radarr handle — next search finds Remux, deletes WebDL | -| Movie was manually switched (no tag) | Not affected — only tagged movies are candidates | -| Movie was switched but tag was removed | Not affected — can't be identified | -| Movie on TARGET profile but never switched by us | Not affected — no tag | -| Physical release date removed after being set | Won't be selected (physicalRelease == null) | -| API unreachable | Print error, exit 1 | -| All candidates already switched | "0 candidates" — success | -| `MAX_SWITCH_PER_RUN` reached mid-batch | Stop, report partial switch count | -| Search command fails | Log warning, continue — movies are already switched | -| Tag addition fails after profile switch | Log warning, continue — movie is switched but untagged | - -## 9. Scheduling (user responsibility) - -Recommended crontab (weekly, Sunday at 7am): - -```cron -0 7 * * 0 /path/to/radarr/auto_quality_switch_reverse.sh --apply >> /var/log/quality-switch-reverse.log 2>&1 -``` - -Or run in dry-run mode first: - -```cron -0 7 * * 0 /path/to/radarr/auto_quality_switch_reverse.sh >> /var/log/quality-switch-reverse-preview.log 2>&1 -``` - -## 10. Dependencies - -- `curl` — API calls -- `jq` 1.6+ — JSON processing -- Standard POSIX `sh` — runtime -- Radarr API access with key configured - -## 11. Project conventions (must follow) - -- Shebang: `#!/usr/bin/env sh` -- `# shellcheck disable=SC3043` for `local` -- Changelog version blocks (newest first) -- `scripts_common.sh` for API helpers -- `load_config "$(dirname "$0")/connect"` for config -- Local vars prefixed with underscore -- Errors to stderr -- Quote all variable expansions -- `printf` for formatted output, `echo` for simple strings -- Indent 4 spaces, 100-char soft line limit diff --git a/docs/quality-switch-spec.md b/docs/quality-switch-spec.md deleted file mode 100644 index fd6d58d..0000000 --- a/docs/quality-switch-spec.md +++ /dev/null @@ -1,535 +0,0 @@ -# Auto Quality Profile Switch — Specification - -## 1. Problem Statement - -Radarr profiles can pin movies to Remux-only quality. As studios increasingly skip physical (disc) releases, some movies never get a Remux source. Those movies stall permanently — never upgraded, never downloaded. The library accumulates "dead" entries that will never be satisfied. - -**Goal:** Automatically detect movies unlikely to ever get a physical release and switch them to a quality profile that allows WebDL. - -**See also:** `quality-switch-reverse-spec.md` for the reverse script that switches movies back when physical releases appear. - -## 2. Research Summary - -Empirical analysis of 2,631 movies with cinema dates yields: - -### 2.1 Physical release timing - -| Metric | Value | Meaning | -|--------|-------|---------| -| P50 (median) | 54 days | Half of physical releases within 54 days of web | -| P90 | 195 days | 90% within ~6.5 months | -| P95 | 265 days | 95% within ~9 months | -| P99 | 545 days | 99% within ~18 months | -| Sample | 1,921 movies | Movies with both web + physical dates | -| Outliers removed | 407 | Bogus dates filtered via IQR | - -**Interpretation:** If a movie has no physical release 265 days after its web/streaming date, there is a ~5% chance a physical release will still come later. This is the recommended threshold. - -### 2.2 Current backlog - -Movies with cinema + web but no physical: **334** -Of those, **~290** have waited longer than 265 days. - -These are immediate candidates for profile switching. - -### 2.3 Assumptions and caveats - -- Data comes from TMDB via Radarr's metadata. TMDB may have incomplete or incorrect dates. -- `fromdateiso8601` in jq parses ISO 8601 date strings. Bogus epoch-zero dates or year-1900 values produce extreme outliers; IQR filtering catches most of these. -- The P95 threshold recomputes from your library on each run, adapting to your collection's distribution. -- Some legitimate physical releases genuinely take longer than 265 days (e.g., boutique labels, limited editions) — the 5% false positive rate accounts for these. - -## 3. Script: `radarr/auto_quality_switch.sh` - -### 3.1 Purpose - -Daily cron job that: -1. Fetches all movies from Radarr API -2. Computes the dynamic P95 threshold from movies with both web + physical dates -3. Identifies movies where: - - `digitalRelease` exists and `physicalRelease` is null - - Days since `digitalRelease` > computed threshold - - Current `qualityProfileId` matches the source profile -4. Optionally switches those movies to the target quality profile -5. Reports what happened - -### 3.2 Behavior modes - -| Mode | Trigger | Effect | -|------|---------|--------| -| Dry-run | Default or `-n` / `--dry-run` | Print candidates, no API mutations | -| Apply | `--apply` | Actually switch profiles | -| JSON | `-j` / `--json` | Output machine-readable JSON | -| Quiet | `-q` / `--quiet` | Only print errors and counts, no per-movie list | - -**Mode precedence:** - -1. `--apply` flag always overrides `DRY_RUN` config. This means: - - `DRY_RUN=true` in conf + `--apply` flag → **applies** (flag wins) - - `DRY_RUN=false` in conf + no flag → **applies** (conf says go) - - `DRY_RUN=false` in conf + `--dry-run` flag → **dry-run** (flag wins) -2. `--json` takes precedence over `--quiet` for output format. If both passed, JSON wins. -3. `--apply` + `--json` → switches profiles AND outputs JSON with results. - -### 3.3 Exit codes - -| Code | Meaning | -|------|---------| -| 0 | Success (no changes needed in dry-run) | -| 0 | Success (N movies switched in apply mode) | -| 1 | API error or configuration error | -| 127 | Missing executable dependency | - -## 4. Configuration - -### 4.1 From `scripts.conf` - -The script sources `scripts_common.sh` and reads these existing settings: - -```sh -RADARR_API_URL="http://ip:7878/api/v3" -RADARR_API_KEY="your_api_key" -``` - -### 4.2 Script-specific defaults (can be overridden in `scripts.conf`) - -| Variable | Default | Description | -|----------|---------|-------------| -| `SOURCE_PROFILE_NAME` | `Remux-2160p` | Profile name to match movies that need switching. Case-sensitive, must match Radarr exactly. | -| `TARGET_PROFILE_NAME` | `WebDL-2160p` | Profile name to switch matched movies to. Case-sensitive, must match Radarr exactly. | -| `P_VALUE` | `0.95` | Percentile used as threshold (0.0–1.0). P95 = 95% of physical releases happen within this window. Lower = more aggressive; higher = more conservative. | -| `MIN_SAMPLE` | `30` | Minimum movies with both web+physical dates needed to compute threshold. Below this, uses `FALLBACK_THRESHOLD`. | -| `FALLBACK_THRESHOLD` | `365` | Hardcoded threshold (days) when sample is too small for reliable percentiles. | -| `MIN_THRESHOLD` | `90` | Floor for computed threshold. Prevents nonsense when data is noisy. | -| `MAX_THRESHOLD` | `730` | Ceiling for computed threshold. Prevents excessively long waits. | -| `DRY_RUN` | `true` | Default preview mode. Set `false` in `scripts.conf` or pass `--apply` to execute switches. | -| `MAX_SWITCH_PER_RUN` | `0` | Max movies to switch per run. `0` = unlimited. Limits batch size to avoid hammering API. | -| `TRIGGER_SEARCH` | `true` | After switching, call `POST /api/v3/command` to queue `MoviesSearch` for switched movies. Set `false` to only switch profiles. | - -### 4.3 Example `scripts.conf` overrides - -```sh -# Auto quality switch settings -SOURCE_PROFILE_NAME="Remux-2160p" -TARGET_PROFILE_NAME="WebDL-2160p" -P_VALUE=0.95 -# Uncomment to allow switches without --apply flag: -# DRY_RUN=false -# Safety limit per run: -# MAX_SWITCH_PER_RUN=50 -# Uncomment to skip search after switch: -# TRIGGER_SEARCH=false -``` - -## 5. Algorithm - -### 5.1 Threshold computation - -``` -all_movies = GET /api/v3/movie -``` - -The IQR filter and percentile computation happen in a single jq pass. Pass `P_VALUE` and `MIN_SAMPLE` as jq args: - -```jq -# IQR filter function: removes outliers using 1.5*IQR rule -def iqr_filter: - if length == 0 then [] - else - sort as $sorted - | (($sorted | length) * 0.25 | floor) as $q1_idx - | (($sorted | length) * 0.75 | floor) as $q3_idx - | $sorted[$q1_idx] as $q1 - | $sorted[$q3_idx] as $q3 - | ($q3 - $q1) as $iqr - | ($q1 - 1.5 * $iqr) as $lower - | ($q3 + 1.5 * $iqr) as $upper - | [.[] | select(. >= $lower and . <= $upper)] - end; - -# Build gap array: movies with both digital + physical, gap in days -[.[] | select(.digitalRelease != null and .physicalRelease != null) - | ((.physicalRelease | fromdateiso8601) - (.digitalRelease | fromdateiso8601)) / 86400] as $gaps - -# If sample too small, return null (caller uses FALLBACK_THRESHOLD) -| if ($gaps | length) < ($min_sample | tonumber) then - {threshold: null, sample_size: ($gaps | length), used_fallback: true} - -# Otherwise: IQR filter, then percentile on filtered -else - ($gaps | iqr_filter | sort) as $filtered - | ($filtered[(($filtered | length) * ($p_value | tonumber)) | floor]) as $p_val - | {threshold: $p_val, sample_size: ($gaps | length), filtered_size: ($filtered | length), used_fallback: false} -end -``` - -**Percentile approximation:** Uses index-based percentile: `sorted[floor(length * p)]`. With 30 samples at P95, index 28 = ~96.7th percentile (slightly conservative). This is acceptable — the threshold is clamped by MIN/MAX anyway. - -**Clamping (in shell, not jq):** -```sh -_threshold=$(printf '%s' "${_threshold_json}" | jq -r '.threshold') -_used_fallback=$(printf '%s' "${_threshold_json}" | jq -r '.used_fallback') - -if [ "${_used_fallback}" = "true" ] || [ -z "${_threshold}" ] || [ "${_threshold}" = "null" ] -then - _threshold="${FALLBACK_THRESHOLD}" -fi - -# Clamp to [MIN_THRESHOLD, MAX_THRESHOLD] -[ "${_threshold}" -lt "${MIN_THRESHOLD}" ] && _threshold="${MIN_THRESHOLD}" -[ "${_threshold}" -gt "${MAX_THRESHOLD}" ] && _threshold="${MAX_THRESHOLD}" -``` - -### 5.2 Candidate matching - -``` -# Map profile names to IDs -profiles = GET /api/v3/qualityProfile -source_id = profiles[where name == SOURCE_PROFILE_NAME].id -target_id = profiles[where name == TARGET_PROFILE_NAME].id - -# Find candidates -candidates = [] -for movie in all_movies: - if movie.digitalRelease == null: - skip # No web date to measure from - if movie.physicalRelease != null: - skip # Already has physical - if movie.qualityProfileId != source_id: - skip # Already on a different profile - if movie.hasFile == true: - skip # Already has a downloaded file - if movie.monitored == false: - skip # Unmonitored — search won't trigger anyway - - waiting_days = (now - movie.digitalRelease) in days - - if waiting_days >= threshold: - candidates.append(movie) -``` - -### 5.3 Profile switch - -``` -switched_ids = [] -for candidate in candidates: - PUT /api/v3/movie/editor - body: { - "movieIds": [candidate.id], - "qualityProfileId": target_id - } - switched_ids.append(candidate.id) -``` - -### 5.4 Search trigger - -After all profile switches, if `TRIGGER_SEARCH=true` and `switched_ids` is non-empty: - -``` -if TRIGGER_SEARCH and len(switched_ids) > 0: - POST /api/v3/command - body: { - "name": "MoviesSearch", - "movieIds": switched_ids - } -``` - -Radarr will queue search tasks for the switched movies. They get picked up by Radarr's built-in search queue and download matching releases — no external tool needed. - -**Success criteria:** HTTP 200 (Accepted) response with a JSON body containing a `jobId` field. Any other HTTP status or curl failure = error. Log the response body on failure for diagnosis. - -**Rate limiting:** The search command is a single API call regardless of how many movies were switched. Radarr handles the queue internally, no need to batch or delay. The initial profile switch calls are already rate-limited via `sleep 0.5` between them per `MAX_SWITCH_PER_RUN`. - -### 5.5 Safety guards - -1. **Always dry-run first** — default mode shows what would change -2. **Threshold clamping** — computed P95 clamped to `[MIN_THRESHOLD, MAX_THRESHOLD]` -3. **Minimum sample** — below `MIN_SAMPLE` movies with both dates, use `FALLBACK_THRESHOLD` -4. **Batch limiting** — `MAX_SWITCH_PER_RUN` caps API writes per invocation -5. **Profile existence check** — abort if source or target profile not found -6. **No-downgrade guarantee** — only switches from SOURCE → TARGET, never the reverse - -## 6. Output Format - -### 6.1 Pretty table (default) - -``` -Auto Quality Profile Switch -=========================== -Threshold: P95 = 265 days (based on 1921 movies with both dates) -Source profile: Remux-2160p (id: 5) -Target profile: WebDL-2160p (id: 7) - -Movie Waiting Current → Target -The Matrix Resurrections 487d Remux-2160p → WebDL-2160p -... - -DRY-RUN: 42 movies would switch. Run with --apply to execute. -``` - -Or in apply mode: - -``` -APPLY: Switched 42 movies to WebDL-2160p -QUEUED: 42 movies sent for search -Errors: 0 -``` - -### 6.2 JSON mode (`--json`) - -```json -{ - "threshold_days": 265, - "p_value": 0.95, - "sample_size": 1921, - "source_profile": {"id": 5, "name": "Remux-2160p"}, - "target_profile": {"id": 7, "name": "WebDL-2160p"}, - "candidates": [ - {"id": 123, "title": "The Matrix Resurrections", "waiting_days": 487, "year": 2021} - ], - "candidate_count": 42, - "switched_count": 42, - "searched_count": 42, - "search_triggered": true, - "dry_run": false -} -``` - -## 7. Implementation Plan - -### Phase 1: Scaffold (1 step) - -- Create `radarr/auto_quality_switch.sh` -- Changelog header following project convention -- Source `scripts_common.sh`, call `load_config` -- Define default variables (section 4.2) -- **Update `radarr/connect/scripts.conf.sample`** with all new config variables from section 4.2, commented out with defaults. Project convention requires sample files to document all config variables. - -### Phase 2: Profile resolution (1 step) - -- Implement `_resolve_profile_id(name)`: - - `GET /api/v3/qualityProfile` - - `jq` to find profile by name - - Exit with error if not found -- Validate both source and target profiles exist before proceeding - -### Phase 3: Threshold computation (1 step) - -- Implement the jq query from section 5.1 (inline above — do not reference external files) -- Pass `P_VALUE` and `MIN_SAMPLE` as `--arg` to jq -- Parse result: extract `threshold`, `sample_size`, `used_fallback` -- Apply fallback logic if `used_fallback == true` -- Clamp to `[MIN_THRESHOLD, MAX_THRESHOLD]` in shell -- Log: computed P-value, sample size, outliers removed, final threshold - -### Phase 4: Candidate matching (1 step) - -- Implement in a single jq pass: - - Filter to movies meeting all criteria - - Compute `waiting_days` using `now` - - Compare against threshold - - Output `id`, `title`, `year`, `waiting_days`, `qualityProfileId` - -### Phase 5: Switch execution (1 step) - -- Implement `switch_movie_profile(movie_id, target_id)`: - - Dry-run mode: log intent - - Apply mode: `PUT /api/v3/movie/editor` - - Rate-limit with `sleep 0.5` between calls - - Respect `MAX_SWITCH_PER_RUN` - - Collect switched movie IDs into a list - -### Phase 6: Search trigger (1 step) - -- After all switches, if `TRIGGER_SEARCH=true` and switched list non-empty: - - `POST /api/v3/command` with `{"name": "MoviesSearch", "movieIds": [...]}` - - Validate response for success/error - - Log number of movies queued for search - -### Phase 7: Output (1 step) - -- Pretty table printing: - - Summary header - - Per-movie candidate list using `printf` with fixed-width format specifiers (e.g., `%-40s %8s %-20s %s`). Do NOT use `column -t` — it is not available on all systems (notably some FreeBSD/minimal Linux images). - - Final count and mode notice -- JSON output via `--json` flag - -### Phase 8: Verification (1 step) - -- Run `shellcheck -e SC3043 -s sh` -- Test with `DRY_RUN=true` on a real Radarr instance -- Verify profile resolution with both existing and non-existing profile names -- Verify threshold output matches `release_date_stats.sh` P95 value - -## 8. Edge Cases - -| Edge case | Handling | -|-----------|----------| -| No movies with both dates | Use `FALLBACK_THRESHOLD`, warn | -| Source profile not found | Print configured name + available profiles, exit 1 | -| Target profile not found | Print configured name + available profiles, exit 1 | -| Source == Target | Print warning, skip all | -| Movie with null digitalRelease or missing dates | Skip (no web date to measure) | -| Movie already on target profile | Skip (already switched) | -| Movie already has a downloaded file (`hasFile == true`) | Skip (already has a release) | -| Movie is unmonitored (`monitored == false`) | Skip (search won't trigger) | -| API unreachable | Print error, exit 1 | -| All candidates already switched | "0 candidates" — success | -| P95 computed below `MIN_THRESHOLD` | Clamp to `MIN_THRESHOLD`, log adjustment | -| P95 computed above `MAX_THRESHOLD` | Clamp to `MAX_THRESHOLD`, log adjustment | -| `MAX_SWITCH_PER_RUN` reached mid-batch | Stop, report partial switch count. Still trigger search for movies switched so far. | -| Search command fails (API error) | Log warning, continue. Movies are already switched — they'll be picked up on next Radarr search pass anyway. | -| `TRIGGER_SEARCH=true` but no movies switched | Skip search call entirely. No-op. | - -## 9. Scheduling (user responsibility) - -Recommended crontab (daily at 6am): - -```cron -0 6 * * * /path/to/radarr/auto_quality_switch.sh --apply >> /var/log/quality-switch.log 2>&1 -``` - -Or run in dry-run mode for a week first to observe: - -```cron -0 6 * * * /path/to/radarr/auto_quality_switch.sh >> /var/log/quality-switch-preview.log 2>&1 -``` - -## 10. Dependencies - -- `curl` — API calls -- `jq` 1.6+ — JSON processing (`fromdateiso8601`, `now`, sort) -- Standard POSIX `sh` — runtime -- Radarr API access with key configured - -## 11. Migration Mode (`--migrate`) - -### 11.1 Purpose - -One-time migration tool to fix existing movies in the library that are on Remux-only profiles but should have been switched to WebDL. Unlike the normal mode (which targets movies without files), migration mode targets movies that already have downloaded files. - -**Use case:** After setting up the script, run `--migrate` once to clean up the existing backlog of "dead" movies that will never get a Remux release. - -### 11.2 Behavior - -| Mode | Trigger | Effect | -|------|---------|--------| -| Dry-run (default) | `--migrate` | Print candidates, no API mutations | -| Apply | `--migrate --apply` | Switch WebDL files, log non-WebDL files | -| JSON | `--migrate --json` | Output machine-readable JSON | -| Quiet | `--migrate --quiet` | Only print errors and counts | - -**Key differences from normal mode:** -- Targets movies with `hasFile == true` (instead of `false`) -- Checks file quality source before switching -- WebDL/WebRip files → switch profile + add tag -- Non-WebDL files → log to stderr for manual review -- Never triggers search (manual review needed) -- Run once, then use normal mode for ongoing maintenance - -### 11.3 Candidate matching - -``` -candidates = [] -for movie in all_movies: - if movie.digitalRelease == null: - skip - if movie.physicalRelease != null: - skip - if movie.qualityProfileId != source_id: - skip - if movie.hasFile == false: - skip # Migration mode: only movies with files - if movie.monitored == false: - skip - - waiting_days = (now - movie.digitalRelease) in days - if waiting_days < threshold: - skip - - # Check file quality - movie_file = GET /api/v3/moviefile/{movie.movieFileId} - quality_source = movie_file.quality.quality.source - - if quality_source in ["webdl", "webrip"]: - candidates.append({ - action: "switch", - movie: movie, - quality: quality_source - }) - else: - candidates.append({ - action: "log", - movie: movie, - quality: quality_source - }) -``` - -### 11.4 Quality source classification - -| Source | Action | Reason | -|--------|--------|--------| -| `webdl` | Switch | Already a WebDL release, just wrong profile | -| `webrip` | Switch | Similar to WebDL, acceptable | -| `bluray` | Log | Physical release exists, needs manual review | -| `remux` | Log | Already Remux, might be intentional | -| `hdtv` | Log | Broadcast recording, needs review | -| `dvd` | Log | DVD source, needs review | -| Other | Log | Unknown source, needs review | - -### 11.5 Output format - -**Dry-run:** -``` -Auto Quality Profile Switch (Migration Mode) -============================================= - -Threshold: P95 = 265d (based on 1921 movies with both dates) -Source profile: Remux-2160p (id: 5) -Target profile: WebDL-2160p (id: 7) - -Movies to switch (WebDL/WebRip): - The Matrix Resurrections (2021) — WEBDL-2160p — 487d - ... - -Movies needing manual review (non-WebDL): - WARN: [bluray] Dune (2021) — Bluray-2160p — 1205d - WARN: [remux] Tenet (2020) — Remux-2160p — 1580d - ... - -DRY-RUN: 42 movies would switch, 5 need manual review. -``` - -**Apply:** -``` -APPLY: Switched 42 movies to WebDL-2160p -TAGS: Added auto-switched tag to 42 movies -WARN: 5 movies need manual review (non-WebDL files): - WARN: [bluray] Dune (2021) — Bluray-2160p - WARN: [remux] Tenet (2020) — Remux-2160p - ... -``` - -### 11.6 Implementation notes - -- Add `--migrate` to CLI flags -- Modify candidate matching jq to accept `hasFile` as parameter -- After candidate matching, fetch moviefile for each candidate -- Classify by quality source -- For WebDL/WebRip: switch profile + add tag (same as normal mode) -- For others: log to stderr with quality source -- No search trigger in migration mode -- Changelog: v0.4.0 - -## 12. Project conventions (must follow) - -- Shebang: `#!/usr/bin/env sh` -- `# shellcheck disable=SC3043` for `local` -- Changelog version blocks (newest first) -- `scripts_common.sh` for API helpers -- `load_config "$(dirname "$0")/connect"` for config — note that `load_config` accepts an optional config directory as `$1`, defaulting to `$(dirname "$0")`. This script lives in `radarr/` (not `radarr/connect/`), so it MUST pass the path: `load_config "$(dirname "$0")/connect"`. See `radarr/research/release_date_stats.sh` for a working example of this pattern. -- Local vars prefixed with underscore -- Errors to stderr -- Quote all variable expansions -- `printf` for formatted output, `echo` for simple strings -- Indent 4 spaces, 100-char soft line limit diff --git a/docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md b/docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md deleted file mode 100644 index f625b69..0000000 --- a/docs/superpowers/plans/2026-07-27-push-physical-to-tmdb.md +++ /dev/null @@ -1,532 +0,0 @@ -# push_physical_to_tmdb Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Automate pushing physical release dates from Blu-ray.com into TMDB via their website's Kendo grid REST API. - -**Architecture:** Two scripts — `tmdb_login.sh` (Node.js/Playwright, run once) exports session cookies after solving AWS WAF challenge. `push_physical_to_tmdb.sh` (POSIX shell/curl, cron-friendly) reads cookies and POSTs release dates directly to TMDB's grid API. - -**Tech Stack:** Node.js + Playwright (login only), POSIX sh + curl + jq (push script) - ---- - -## File Structure - -``` -radarr/ - tmdb_login.sh # Playwright login + cookie export - push_physical_to_tmdb.sh # curl-based release date push - connect/scripts.conf.sample # Add TMDB_USERNAME/TMDB_PASSWORD - -docs/superpowers/specs/ - 2026-07-27-push-physical-to-tmdb-design.md # (exists) -``` - ---- - -## Task 1: tmdb_login.sh - -**Files:** -- Create: `radarr/tmdb_login.sh` - -- [ ] **Step 1: Create the Playwright login script** - -```sh -#!/usr/bin/env sh -# shellcheck disable=SC3043 - -# Playwright-based TMDB login script. -# Solves AWS WAF JS challenge and exports session cookies to Netscape format. -# Run once on machine with display (or Xvfb). -# -# Version 0.1.0 (Released 2026-07-27) -# * Initial implementation - -. "$(dirname "$0")/connect/scripts_common.sh" -load_config "$(dirname "$0")/connect" - -: "${TMDB_USERNAME:=}" -: "${TMDB_PASSWORD:=}" -: "${DEBUG:=false}" - -_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" - -while [ $# -gt 0 ]; do - case "$1" in - --cookies) _COOKIE_FILE="$2"; shift 2 ;; - --debug) DEBUG=true; shift ;; - *) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; - esac -done - -if [ -z "${TMDB_USERNAME}" ] || [ -z "${TMDB_PASSWORD}" ]; then - echo "ERROR: TMDB_USERNAME and TMDB_PASSWORD must be set in scripts.conf" >&2 - exit 1 -fi - -# Find node -if ! command -v node >/dev/null 2>&1; then - echo "ERROR: node not found. Install Node.js." >&2 - exit 127 -fi - -# Find or install playwright -_PLAYWRIGHT_SCRIPT=$(cat << 'NODESCRIPT' -const { chromium } = require('playwright'); - -(async () => { - const username = process.env.TMDB_USERNAME; - const password = process.env.TMDB_PASSWORD; - const cookieFile = process.argv[2]; - - if (!username || !password) { - console.error('ERROR: TMDB_USERNAME and TMDB_PASSWORD must be set'); - process.exit(1); - } - - const browser = await chromium.launch({ - headless: false, - args: ['--disable-blink-features=AutomationControlled'] - }); - const context = await browser.newContext({ - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - viewport: { width: 1280, height: 720 }, - locale: 'en-US', - }); - await context.addInitScript(() => { - Object.defineProperty(navigator, 'webdriver', { get: () => false }); - }); - const page = await context.newPage(); - - try { - console.error('Navigating to TMDB login...'); - await page.goto('https://www.themoviedb.org/login', { waitUntil: 'networkidle' }); - - console.error('Filling credentials...'); - await page.fill('#username', username); - await page.fill('#password', password); - - console.error('Submitting login...'); - await Promise.all([ - page.waitForNavigation({ timeout: 30000 }), - page.click('#login_button'), - ]); - - // Verify login succeeded - const loggedIn = await page.locator('header ul > li.user').count(); - if (loggedIn === 0) { - console.error('ERROR: Login failed. Check credentials.'); - process.exit(1); - } - - console.error('Login successful. Exporting cookies...'); - - const cookies = await context.cookies(); - const lines = ['# Netscape HTTP Cookie File', '# Generated by tmdb_login.sh', '']; - - for (const c of cookies) { - const domain = c.domain.startsWith('.') ? c.domain : '.' + c.domain; - const flag = 'TRUE'; - const path = c.path || '/'; - const secure = c.secure ? 'TRUE' : 'FALSE'; - const expiry = c.expires > 0 ? Math.floor(c.expires) : 0; - lines.push([domain, flag, path, secure, expiry, c.name, c.value].join('\t')); - } - - const fs = require('fs'); - fs.writeFileSync(cookieFile, lines.join('\n') + '\n'); - console.log(cookieFile); - console.error(`Cookies exported to ${cookieFile}`); - - } catch (err) { - console.error('ERROR:', err.message); - process.exit(1); - } finally { - await browser.close(); - } -})(); -NODESCRIPT -) - -# Check if playwright is available -if ! node -e "require('playwright')" 2>/dev/null; then - echo "ERROR: playwright not found. Run: npm install playwright && npx playwright install chromium" >&2 - exit 127 -fi - -# Run the login script -TMDB_USERNAME="${TMDB_USERNAME}" TMDB_PASSWORD="${TMDB_PASSWORD}" \ - node -e "${_PLAYWRIGHT_SCRIPT}" -- "${_COOKIE_FILE}" - -debug_log "tmdb_login.sh completed" -``` - -- [ ] **Step 2: Make executable and test** - -Run: `chmod +x radarr/tmdb_login.sh && DISPLAY=:99 ./radarr/tmdb_login.sh --debug` -Expected: Creates `~/.tmdb_cookies.txt` with session cookies - -- [ ] **Step 3: Verify cookie file format** - -Run: `head -20 ~/.tmdb_cookies.txt` -Expected: Netscape format with `session` cookie entry - -- [ ] **Step 4: Commit** - -```bash -git add radarr/tmdb_login.sh -git commit -f "feat: add tmdb_login.sh for Playwright-based cookie export" -``` - ---- - -## Task 2: push_physical_to_tmdb.sh - -**Files:** -- Create: `radarr/push_physical_to_tmdb.sh` - -- [ ] **Step 1: Create the push script** - -```sh -#!/usr/bin/env sh -# shellcheck disable=SC3043 - -# Push physical release dates to TMDB via their website's Kendo grid API. -# Uses session cookies from tmdb_login.sh. -# -# Version 0.1.0 (Released 2026-07-27) -# * Initial implementation - -. "$(dirname "$0")/connect/scripts_common.sh" -load_config "$(dirname "$0")/connect" - -: "${DEBUG:=false}" - -# Defaults -_COUNTRY="US" -_LANGUAGE="en" -_RELEASE_TYPE=5 -_NOTE="Physical release" -_DRY_RUN=false -_COOKIE_FILE="${HOME}/.tmdb_cookies.txt" -_RATE_LIMIT=1 - -# Flag parsing -while [ $# -gt 0 ]; do - case "$1" in - --dry-run) _DRY_RUN=true; shift ;; - --debug) DEBUG=true; shift ;; - --country) - case "$2" in - '') echo "ERROR: --country requires a code" >&2; exit 1 ;; - *) _COUNTRY="$2"; shift 2 ;; - esac - ;; - --language) - case "$2" in - '') echo "ERROR: --language requires a code" >&2; exit 1 ;; - *) _LANGUAGE="$2"; shift 2 ;; - esac - ;; - --type) - case "$2" in - ''|*[!0-9]*) echo "ERROR: --type requires a number 1-7" >&2; exit 1 ;; - *) _RELEASE_TYPE="$2"; shift 2 ;; - esac - ;; - --note) - case "$2" in - '') echo "ERROR: --note requires text" >&2; exit 1 ;; - *) _NOTE="$2"; shift 2 ;; - esac - ;; - --cookies) - case "$2" in - '') echo "ERROR: --cookies requires a file path" >&2; exit 1 ;; - *) _COOKIE_FILE="$2"; shift 2 ;; - esac - ;; - --rate-limit) - case "$2" in - '') echo "ERROR: --rate-limit requires seconds" >&2; exit 1 ;; - *) _RATE_LIMIT="$2"; shift 2 ;; - esac - ;; - -*) echo "ERROR: Unknown flag: $1" >&2; exit 1 ;; - *) break ;; - esac -done - -check_needed_executables "curl jq grep sed" - -# Validate cookie file -if [ ! -f "${_COOKIE_FILE}" ]; then - echo "ERROR: Cookie file not found: ${_COOKIE_FILE}" >&2 - echo "ERROR: Run tmdb_login.sh first to generate cookies." >&2 - exit 1 -fi - -debug_log "Cookie file: ${_COOKIE_FILE}" -debug_log "Country: ${_COUNTRY}, Language: ${_LANGUAGE}, Type: ${_RELEASE_TYPE}" - -############################################################################## -# CSRF token extraction -############################################################################## - -_get_csrf_token() { - local _tmdb_id _page _token - - _tmdb_id="$1" - - _page=$(curl -s -b "${_COOKIE_FILE}" \ - -H "User-Agent: Mozilla/5.0 (FreeBSD; FreeBSD 14.1; amd64) AppleWebKit/537.36" \ - "https://www.themoviedb.org/movie/${_tmdb_id}/edit?active_nav_item=release_information") - - _token=$(printf '%s' "${_page}" | grep -o 'name="authenticity_token"[^>]*value="\([^"]*\)"' | sed 's/.*value="//;s/"//') - - if [ -z "${_token}" ]; then - echo "ERROR: Could not extract CSRF token for movie ${_tmdb_id}" >&2 - return 1 - fi - - printf '%s' "${_token}" -} - -############################################################################## -# Release date submission -############################################################################## - -_push_release_date() { - local _tmdb_id _date _title _csrf_token _payload _response _http_code - - _tmdb_id="$1" - _date="$2" - _title="${3:-unknown}" - - debug_log "Getting CSRF token for TMDB #${_tmdb_id}..." - _csrf_token=$(_get_csrf_token "${_tmdb_id}") - if [ $? -ne 0 ]; then - return 1 - fi - - debug_log "CSRF token: ${_csrf_token}" - - _payload=$(jq -n \ - --arg country "${_COUNTRY}" \ - --arg language "${_LANGUAGE}" \ - --arg date "${_date}" \ - --arg note "${_NOTE}" \ - --argjson type "${_RELEASE_TYPE}" \ - '{iso_3166_1: $country, iso_639_1: $language, release_date: $date, certification: "", type: $type, note: $note}') - - debug_log "Payload: ${_payload}" - - if [ "${_DRY_RUN}" = "true" ]; then - echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ${_date} (dry-run)" - return 0 - fi - - _response=$(curl -s -b "${_COOKIE_FILE}" \ - -H "User-Agent: Mozilla/5.0 (FreeBSD; FreeBSD 14.1; amd64) AppleWebKit/537.36" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -H "X-CSRF-Token: ${_csrf_token}" \ - -H "X-Requested-With: XMLHttpRequest" \ - -H "Referer: https://www.themoviedb.org/movie/${_tmdb_id}/edit?active_nav_item=release_information" \ - -d "data=$(printf '%s' "${_payload}" | jq -sRr @uri)" \ - -w "\n%{http_code}" \ - "https://www.themoviedb.org/movie/${_tmdb_id}/remote/release_information?translate=false&timezone=UTC") - - _http_code=$(printf '%s' "${_response}" | tail -1) - _body=$(printf '%s' "${_response}" | sed '$d') - - debug_log "HTTP ${_http_code}: ${_body}" - - if [ "${_http_code}" = "200" ] || [ "${_http_code}" = "201" ]; then - echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ${_date} ✓" - return 0 - elif [ "${_http_code}" = "401" ]; then - echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ Cookies expired, re-run tmdb_login.sh" >&2 - return 1 - else - echo "[${_COUNTER}/${_TOTAL}] ${_title} → TMDB #${_tmdb_id}: ✗ HTTP ${_http_code}" >&2 - debug_log "Response: ${_body}" - return 1 - fi -} - -############################################################################## -# Input handling -############################################################################## - -_COUNTER=0 -_TOTAL=0 -_SUCCESS=0 -_FAILED=0 - -# Pipe mode: read JSON from stdin -if [ ! -t 0 ]; then - debug_log "Reading from stdin (pipe mode)" - - _TEMP=$(mktemp) - trap 'rm -f "${_TEMP}"; exit 130' INT TERM - trap 'rm -f "${_TEMP}"' EXIT - - cat > "${_TEMP}" - - _TOTAL=$(jq 'length' "${_TEMP}") - debug_log "Processing ${_TOTAL} movies" - - jq -c '.[]' "${_TEMP}" | while read -r _movie; do - _COUNTER=$((_COUNTER + 1)) - _tmdb_id=$(printf '%s' "${_movie}" | jq -r '.tmdb_id') - _date=$(printf '%s' "${_movie}" | jq -r '.physical_date') - _title=$(printf '%s' "${_movie}" | jq -r '.title') - - # Convert date format if needed (e.g., "Sep 08, 2026" → "2026-09-08") - _date=$(printf '%s' "${_date}" | sed -E 's/^([A-Z][a-z]+) ([0-9]+),? ([0-9]+)$/ \2 \1 \3/' | \ - awk '{ - split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " "); - for (i in m) if ($2 == m[i]) mon = i; - printf "%s-%02d-%02d", $3, mon, $1 - }') - - if [ -z "${_tmdb_id}" ] || [ "${_tmdb_id}" = "null" ]; then - echo "[${_COUNTER}/${_TOTAL}] ${_title}: ✗ No TMDB ID" >&2 - _FAILED=$((_FAILED + 1)) - continue - fi - - if _push_release_date "${_tmdb_id}" "${_date}" "${_title}"; then - _SUCCESS=$((_SUCCESS + 1)) - else - _FAILED=$((_FAILED + 1)) - fi - - sleep "${_RATE_LIMIT}" - done - - rm -f "${_TEMP}" - trap - INT TERM EXIT - - echo "" - echo "Done: ${_SUCCESS} succeeded, ${_FAILED} failed" - exit 0 -fi - -# Args mode: single movie -if [ $# -lt 2 ]; then - echo "Usage: $0 [title]" >&2 - echo " $0 (reads JSON from stdin)" >&2 - echo "" >&2 - echo "Flags:" >&2 - echo " --dry-run Show what would be submitted" >&2 - echo " --country ISO 3166-1 country (default: US)" >&2 - echo " --language ISO 639-1 language (default: en)" >&2 - echo " --type Release type 1-7 (default: 5)" >&2 - echo " --note Note field (default: 'Physical release')" >&2 - echo " --cookies Cookie file (default: ~/.tmdb_cookies.txt)" >&2 - echo " --debug Verbose logging" >&2 - exit 1 -fi - -_TMDB_ID="$1" -_DATE="$2" -_TITLE="${3:-unknown}" -_TOTAL=1 -_COUNTER=1 - -_push_release_date "${_TMDB_ID}" "${_DATE}" "${_TITLE}" -exit $? -``` - -- [ ] **Step 2: Make executable** - -Run: `chmod +x radarr/push_physical_to_tmdb.sh` - -- [ ] **Step 3: Test --help output** - -Run: `./radarr/push_physical_to_tmdb.sh` -Expected: Shows usage message - -- [ ] **Step 4: Test dry-run with args** - -Run: `./radarr/push_physical_to_tmdb.sh --dry-run 11 2025-01-15 "Star Wars"` -Expected: `[1/1] Star Wars → TMDB #11: 2025-01-15 (dry-run)` - -- [ ] **Step 5: Commit** - -```bash -git add radarr/push_physical_to_tmdb.sh -git commit -m "feat: add push_physical_to_tmdb.sh for TMDB release date submission" -``` - ---- - -## Task 3: Update scripts.conf.sample - -**Files:** -- Modify: `radarr/connect/scripts.conf.sample` - -- [ ] **Step 1: Add TMDB credentials** - -Add to `scripts.conf.sample`: -```sh -# TMDB website credentials (for tmdb_login.sh) -TMDB_USERNAME="" -TMDB_PASSWORD="" -``` - -- [ ] **Step 2: Commit** - -```bash -git add radarr/connect/scripts.conf.sample -git commit -m "docs: add TMDB credentials to scripts.conf.sample" -``` - ---- - -## Task 4: Integration test - -- [ ] **Step 1: Run tmdb_login.sh** - -Run: `DISPLAY=:99 ./radarr/tmdb_login.sh --debug` -Expected: Creates `~/.tmdb_cookies.txt` - -- [ ] **Step 2: Test real push** - -Run: `./radarr/push_physical_to_tmdb.sh --dry-run 11 2025-01-15 "Star Wars"` -Expected: Shows dry-run output - -- [ ] **Step 3: Test with real cookies (non-dry-run)** - -Run: `./radarr/push_physical_to_tmdb.sh 11 2025-01-15 "Star Wars"` -Expected: `✓` or appropriate error - -- [ ] **Step 4: Test pipe mode** - -Run: `echo '[{"tmdb_id":11,"physical_date":"2025-01-15","title":"Star Wars"}]' | ./radarr/push_physical_to_tmdb.sh --dry-run` -Expected: Shows dry-run output - -- [ ] **Step 5: Shellcheck** - -Run: `shellcheck -e SC1091,SC3043 radarr/tmdb_login.sh radarr/push_physical_to_tmdb.sh` -Expected: No errors - ---- - -## Task 5: Documentation - -- [ ] **Step 1: Update AGENTS.md** - -Add `tmdb_login.sh` and `push_physical_to_tmdb.sh` to file organization, dependencies, and build commands. - -- [ ] **Step 2: Update README.md** - -Add usage examples for the new scripts. - -- [ ] **Step 3: Commit** - -```bash -git add AGENTS.md README.md -git commit -m "docs: add tmdb_login.sh and push_physical_to_tmdb.sh documentation" -``` diff --git a/docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md b/docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md deleted file mode 100644 index 96a0570..0000000 --- a/docs/superpowers/specs/2026-07-27-push-physical-to-tmdb-design.md +++ /dev/null @@ -1,173 +0,0 @@ -# push_physical_to_tmdb.sh — Design Spec - -## Problem - -`fetch_physical_dates.sh` finds physical release dates on Blu-ray.com that Radarr lacks, but TMDB has no API write endpoint for movie metadata. Dates must be submitted manually via TMDB website edit forms. This script automates that submission. - -## Approach - -Two-phase cookie-based authentication (like yt-dlp): - -1. **Playwright login** — runs once on dev machine with display, solves AWS WAF JS challenge, exports session cookies to Netscape format file (`~/.tmdb_cookies.txt`) -2. **curl push script** — reads cookie file, makes direct API calls to TMDB. Runs anywhere (FreeBSD, headless server). - -### Why two phases? - -TMDB login POST is intercepted by AWS WAF (`x-amzn-waf-action: challenge`). curl can't solve the JS challenge. Playwright (headed, with Xvfb) can. Once authenticated, the session cookie works for curl. - -## Data Flow - -``` -Phase 1: tmdb_login.sh (run once, on machine with display) - Playwright → login → export cookies → ~/.tmdb_cookies.txt - -Phase 2: push_physical_to_tmdb.sh (runs anywhere, cron-friendly) - fetch_physical_dates.sh --json --quiet - → JSON with {tmdb_id, physical_date, title, ...} - → piped to push_physical_to_tmdb.sh - → for each movie: - curl with ~/.tmdb_cookies.txt - POST /movie/{tmdb_id}/remote/release_information - with JSON body + CSRF token - Log success/failure -``` - -## Scripts - -### tmdb_login.sh (Phase 1) - -Standalone Node.js script. Uses Playwright to: -1. Open Chromium (headed, needs DISPLAY or Xvfb) -2. Navigate to `https://www.themoviedb.org/login` -3. Fill `#username`, `#password`, click `#login_button` -4. Wait for navigation to complete -5. Extract all cookies from browser context -6. Write to `~/.tmdb_cookies.txt` in Netscape format - -**Output:** `~/.tmdb_cookies.txt` (shared with yt-dlp if needed) - -### push_physical_to_tmdb.sh (Phase 2) - -Shell script. Accepts: -- **Args mode:** `push_physical_to_tmdb.sh [title]` -- **Pipe mode:** `fetch_physical_dates.sh --json --quiet | push_physical_to_tmdb.sh` - -#### Authentication - -1. Check `~/.tmdb_cookies.txt` exists, else error -2. Extract CSRF token from edit page: `GET /movie/{tmdb_id}/edit?active_nav_item=release_information` -3. Extract `authenticity_token` from HTML - -#### Release Date Submission - -### Endpoint - -``` -POST https://www.themoviedb.org/movie/{tmdb_id}/remote/release_information?translate=false&timezone=UTC -``` - -### Headers - -``` -Content-Type: application/x-www-form-urlencoded -X-CSRF-Token: {authenticity_token} -X-Requested-With: XMLHttpRequest -``` - -### Body - -``` -data={"iso_3166_1":"US","iso_639_1":"en","release_date":"2026-01-15","certification":"","type":5,"note":"Physical release"} -``` - -### Release Type Values - -| Value | Type | -|-------|------| -| 1 | Premiere | -| 2 | Limited | -| 3 | Theatrical | -| 4 | Digital | -| 5 | Physical | -| 6 | TV | -| 7 | Total | - -Default: `5` (Physical) - -## Flags - -### push_physical_to_tmdb.sh - -| Flag | Description | -|------|-------------| -| `--dry-run` | Show what would be submitted, don't POST | -| `--debug` | Verbose logging | -| `--country ` | ISO 3166-1 country code (default: US) | -| `--language ` | ISO 639-1 language code (default: en) | -| `--type ` | Release type 1-7 (default: 5) | -| `--note ` | Note field (default: "Physical release") | -| `--cookies ` | Cookie file path (default: ~/.tmdb_cookies.txt) | - -### tmdb_login.sh - -| Flag | Description | -|------|-------------| -| `--cookies ` | Output cookie file path (default: ~/.tmdb_cookies.txt) | -| `--debug` | Verbose logging | - -## Config - -`scripts.conf` needs `TMDB_USERNAME` and `TMDB_PASSWORD` for `tmdb_login.sh`. - -`push_physical_to_tmdb.sh` reads credentials from cookie file only (no config needed). - -## Dependencies - -- `tmdb_login.sh`: Playwright + Chromium (one-time, on dev/display machine) -- `push_physical_to_tmdb.sh`: curl, jq, grep, sed (all POSIX, runs anywhere) - -## Edge Cases - -- **Cookie file missing:** Exit with error, run `tmdb_login.sh` first -- **Cookies expired:** Detect 401, suggest re-running `tmdb_login.sh` -- **CSRF token missing:** Re-fetch page and retry -- **Rate limiting:** 1s sleep between movies in pipe mode -- **Duplicate release date:** TMDB may reject or ignore; log response -- **Invalid TMDB ID:** Skip with warning -- **Date format:** Accept YYYY-MM-DD, convert if needed - -## Output - -### Success -``` -[1/5] Supergirl (2026) → TMDB #123456: 2026-09-08 ✓ -``` - -### Failure -``` -[2/5] Batman (2025) → TMDB #789012: ✗ Cookies expired, re-run tmdb_login.sh -``` - -### Dry-run -``` -[1/5] Supergirl (2026) → TMDB #123456: 2026-09-08 (dry-run) -``` - -## Example Usage - -```sh -# Step 1: Login (run once, on machine with display) -./radarr/tmdb_login.sh - -# Step 2: Push dates -./radarr/push_physical_to_tmdb.sh 123456 2026-09-08 "Supergirl" - -# Pipeline from fetch_physical_dates.sh -./radarr/fetch_physical_dates.sh --json --quiet | ./radarr/push_physical_to_tmdb.sh - -# Dry-run -./radarr/fetch_physical_dates.sh --json --quiet | ./radarr/push_physical_to_tmdb.sh --dry-run - -# Custom country/type -./radarr/push_physical_to_tmdb.sh 123456 2026-09-08 --country GB --type 3 -``` From 97aeb9a6caddc20da5a440d7c10de8074db92478 Mon Sep 17 00:00:00 2001 From: Michiel van Baak Jansen Date: Thu, 30 Jul 2026 16:03:54 +0200 Subject: [PATCH 14/14] chore: add node_modules to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0031e5b..69c46ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ # Configuration file, can hold secrets scripts.conf +# node +node_modules