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 diff --git a/AGENTS.md b/AGENTS.md index 48ab79e..16a5394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ 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. +- `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. --- @@ -110,6 +113,51 @@ 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 +``` + +**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: @@ -293,10 +341,11 @@ 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 + 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 - quality-switch-reverse-spec.md # Reverse script specification cookie-extraction.md # Guide for exporting YouTube cookies for yt-dlp ``` @@ -324,6 +373,19 @@ 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 + +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 74dca77..038fdc5 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,148 @@ 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 +``` + +--- + +### 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`) + +Set `TMDB_COOKIE_FILE` in `scripts.conf` to use a custom path: + +```sh +TMDB_COOKIE_FILE="/path/to/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`) | +| `--rate-limit ` | Seconds between requests in pipe mode (default: 1) | +| `-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/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/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" + } +} 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/scripts.conf.sample b/radarr/connect/scripts.conf.sample index 45718de..28a51cf 100644 --- a/radarr/connect/scripts.conf.sample +++ b/radarr/connect/scripts.conf.sample @@ -70,3 +70,12 @@ 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="" + +# 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/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 new file mode 100755 index 0000000..1e45792 --- /dev/null +++ b/radarr/fetch_physical_dates.sh @@ -0,0 +1,308 @@ +#!/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 ;; + --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 +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})" + + _tmdb_url="https://www.themoviedb.org/movie/${_tmdb_id}" + + _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}" \ + --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)) + 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 %-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)|\(.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 +fi + +if [ -n "${_EXPORT_FILE}" ] +then + if [ "${_FLAG_CSV}" = "true" ] + then + 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 \ + --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 diff --git a/radarr/push_physical_to_tmdb.sh b/radarr/push_physical_to_tmdb.sh new file mode 100755 index 0000000..1c1ae0d --- /dev/null +++ b/radarr/push_physical_to_tmdb.sh @@ -0,0 +1,308 @@ +#!/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}" +: "${TMDB_COOKIE_FILE:=${HOME}/.tmdb_cookies.txt}" + +_COUNTRY="US" +_LANGUAGE="" +_CERTIFICATION="" +_RELEASE_TYPE=5 +_NOTE="" +_DRY_RUN=false +_COOKIE_FILE="${TMDB_COOKIE_FILE}" +_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 + ;; + --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 ;; + *) _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 + ;; + --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: 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)" + 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 +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 certification "${_CERTIFICATION}" \ + --arg date "${_date}" \ + --arg note "${_NOTE}" \ + --argjson type "${_RELEASE_TYPE}" \ + '{iso_3166_1: $country, iso_639_1: $language, release_date: $date, certification: $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 ',') + _day=${_day#0} + _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}" + + # 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 + + 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: 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 +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..c2e3710 --- /dev/null +++ b/radarr/tmdb_login.sh @@ -0,0 +1,143 @@ +#!/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:=}" +: "${TMDB_COOKIE_FILE:=${HOME}/.tmdb_cookies.txt}" +: "${DEBUG:=false}" + +_COOKIE_FILE="${TMDB_COOKIE_FILE}" + +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 ;; + --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 + +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"