From e2de5f705a55763ef6ddf470b90096bb3d7600d4 Mon Sep 17 00:00:00 2001 From: scoped Date: Fri, 15 May 2026 02:41:52 +0000 Subject: [PATCH] Initial commit --- .env.example | 23 + .gitignore | 8 + README.md | 77 ++ backend/Dockerfile | 15 + backend/default-config/app.toml | 90 ++ backend/sortarr/__init__.py | 2 + backend/sortarr/app.py | 338 ++++++ backend/sortarr/cache.py | 75 ++ backend/sortarr/config.py | 71 ++ backend/sortarr/downloads.py | 139 +++ backend/sortarr/healthcheck.py | 7 + backend/sortarr/library.py | 331 ++++++ backend/sortarr/logging_setup.py | 25 + backend/sortarr/media_probe.py | 121 ++ backend/sortarr/metadata.py | 156 +++ backend/sortarr/organizer.py | 320 ++++++ backend/sortarr/parser.py | 141 +++ backend/sortarr/releases.py | 59 + backend/sortarr/scanner.py | 111 ++ backend/sortarr/storage.py | 53 + backend/sortarr/store.py | 83 ++ backend/sortarr/tools.py | 121 ++ compose.override.yaml | 10 + compose.prod.yaml | 9 + compose.yaml | 92 ++ config/app.toml | 19 + config/custom-theme.css | 6 + dist/sortarr.zip | Bin 0 -> 60852 bytes dist/sortarr/.env.example | 28 + dist/sortarr/.gitignore | 8 + dist/sortarr/README.md | 61 + dist/sortarr/backend/Dockerfile | 15 + dist/sortarr/backend/default-config/app.toml | 90 ++ dist/sortarr/backend/sortarr/__init__.py | 2 + dist/sortarr/backend/sortarr/app.py | 356 ++++++ dist/sortarr/backend/sortarr/cache.py | 75 ++ dist/sortarr/backend/sortarr/config.py | 67 ++ dist/sortarr/backend/sortarr/downloads.py | 139 +++ dist/sortarr/backend/sortarr/healthcheck.py | 7 + dist/sortarr/backend/sortarr/library.py | 261 +++++ dist/sortarr/backend/sortarr/logging_setup.py | 25 + dist/sortarr/backend/sortarr/media_probe.py | 121 ++ dist/sortarr/backend/sortarr/metadata.py | 216 ++++ dist/sortarr/backend/sortarr/organizer.py | 293 +++++ dist/sortarr/backend/sortarr/parser.py | 143 +++ dist/sortarr/backend/sortarr/releases.py | 59 + dist/sortarr/backend/sortarr/scanner.py | 104 ++ dist/sortarr/backend/sortarr/storage.py | 53 + dist/sortarr/backend/sortarr/store.py | 73 ++ dist/sortarr/backend/sortarr/tools.py | 98 ++ dist/sortarr/config/app.toml | 19 + dist/sortarr/config/custom-theme.css | 6 + dist/sortarr/docker-compose.yaml | 56 + dist/sortarr/docs/api.md | 70 ++ dist/sortarr/docs/architecture.md | 251 ++++ dist/sortarr/docs/configuration.md | 77 ++ dist/sortarr/docs/operations.md | 62 + dist/sortarr/web/Dockerfile | 4 + dist/sortarr/web/nginx.conf | 20 + dist/sortarr/web/src/app.js | 1006 +++++++++++++++++ dist/sortarr/web/src/index.html | 158 +++ dist/sortarr/web/src/styles.css | 822 ++++++++++++++ dist/sortarr/web/src/themes.css | 134 +++ docs/api.md | 73 ++ docs/configuration.md | 79 ++ docs/operations.md | 62 + proj-info.md | 251 ++++ web/Dockerfile | 4 + web/nginx.conf | 32 + web/src/app.js | 969 ++++++++++++++++ web/src/index.html | 151 +++ web/src/styles.css | 729 ++++++++++++ web/src/themes.css | 134 +++ 73 files changed, 9965 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/default-config/app.toml create mode 100644 backend/sortarr/__init__.py create mode 100644 backend/sortarr/app.py create mode 100644 backend/sortarr/cache.py create mode 100644 backend/sortarr/config.py create mode 100644 backend/sortarr/downloads.py create mode 100644 backend/sortarr/healthcheck.py create mode 100644 backend/sortarr/library.py create mode 100644 backend/sortarr/logging_setup.py create mode 100644 backend/sortarr/media_probe.py create mode 100644 backend/sortarr/metadata.py create mode 100644 backend/sortarr/organizer.py create mode 100644 backend/sortarr/parser.py create mode 100644 backend/sortarr/releases.py create mode 100644 backend/sortarr/scanner.py create mode 100644 backend/sortarr/storage.py create mode 100644 backend/sortarr/store.py create mode 100644 backend/sortarr/tools.py create mode 100644 compose.override.yaml create mode 100644 compose.prod.yaml create mode 100644 compose.yaml create mode 100644 config/app.toml create mode 100644 config/custom-theme.css create mode 100644 dist/sortarr.zip create mode 100644 dist/sortarr/.env.example create mode 100644 dist/sortarr/.gitignore create mode 100644 dist/sortarr/README.md create mode 100644 dist/sortarr/backend/Dockerfile create mode 100644 dist/sortarr/backend/default-config/app.toml create mode 100644 dist/sortarr/backend/sortarr/__init__.py create mode 100644 dist/sortarr/backend/sortarr/app.py create mode 100644 dist/sortarr/backend/sortarr/cache.py create mode 100644 dist/sortarr/backend/sortarr/config.py create mode 100644 dist/sortarr/backend/sortarr/downloads.py create mode 100644 dist/sortarr/backend/sortarr/healthcheck.py create mode 100644 dist/sortarr/backend/sortarr/library.py create mode 100644 dist/sortarr/backend/sortarr/logging_setup.py create mode 100644 dist/sortarr/backend/sortarr/media_probe.py create mode 100644 dist/sortarr/backend/sortarr/metadata.py create mode 100644 dist/sortarr/backend/sortarr/organizer.py create mode 100644 dist/sortarr/backend/sortarr/parser.py create mode 100644 dist/sortarr/backend/sortarr/releases.py create mode 100644 dist/sortarr/backend/sortarr/scanner.py create mode 100644 dist/sortarr/backend/sortarr/storage.py create mode 100644 dist/sortarr/backend/sortarr/store.py create mode 100644 dist/sortarr/backend/sortarr/tools.py create mode 100644 dist/sortarr/config/app.toml create mode 100644 dist/sortarr/config/custom-theme.css create mode 100644 dist/sortarr/docker-compose.yaml create mode 100644 dist/sortarr/docs/api.md create mode 100644 dist/sortarr/docs/architecture.md create mode 100644 dist/sortarr/docs/configuration.md create mode 100644 dist/sortarr/docs/operations.md create mode 100644 dist/sortarr/web/Dockerfile create mode 100644 dist/sortarr/web/nginx.conf create mode 100644 dist/sortarr/web/src/app.js create mode 100644 dist/sortarr/web/src/index.html create mode 100644 dist/sortarr/web/src/styles.css create mode 100644 dist/sortarr/web/src/themes.css create mode 100644 docs/api.md create mode 100644 docs/configuration.md create mode 100644 docs/operations.md create mode 100644 proj-info.md create mode 100644 web/Dockerfile create mode 100644 web/nginx.conf create mode 100644 web/src/app.js create mode 100644 web/src/index.html create mode 100644 web/src/styles.css create mode 100644 web/src/themes.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bb751df --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +SORTARR_HOST=0.0.0.0 +SORTARR_WEB_PORT=8088 +SORTARR_API_PORT=8099 +SORTARR_TZ=Etc/UTC +SORTARR_DRY_RUN=true +SORTARR_LOG_LEVEL=INFO +SORTARR_SCAN_INTERVAL_SECONDS=20 +SORTARR_SETTLE_SECONDS=90 +SORTARR_MIN_FREE_GB=20 +SORTARR_UID=1000 +SORTARR_GID=1000 +TMDB_API_KEY= +TMDB_BEARER_TOKEN= + +# Host paths. Copy this file to .env and change these for your media host. +DOWNLOADS_PATH=/home/drop/jellyfin/downloads +CONFIG_PATH=/home/drop/jellyfin/scripts/sortarr/config +LOGS_PATH=/home/drop/jellyfin/scripts/sortarr/logs +DATA_PATH=/home/drop/jellyfin/scripts/sortarr/data +DRIVE1_PATH=/home/drop/jellyfin/mediashare1 +DRIVE2_PATH=/home/drop/jellyfin/mediashare2 +DRIVE3_PATH=/home/drop/jellyfin/mediashare3 +DRIVE4_PATH=/home/drop/jellyfin/mediashare4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d35ab77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.py[cod] +data/ +logs/ +downloads/ +media/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7cd4e3 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Sortarr + +Sortarr is a self-hosted Jellyfin media organizer and dashboard. It watches `/downloads`, plans safe Jellyfin-friendly moves, chooses one of four mounted media drives, exposes storage, downloads, and library state, and ships with a fully editable vanilla dashboard. + +The project is intentionally source-first: backend logic is plain Python, the UI is HTML/CSS/JS, and runtime behavior is configured with `.env`, TOML config files, and optional CSS overrides. + +## Quick Start + +1. Copy the environment template: + + ```bash + cp .env.example .env + ``` + +2. Edit `.env` and set `DOWNLOADS_PATH`, `DRIVE1_PATH`, `DRIVE2_PATH`, `DRIVE3_PATH`, and `DRIVE4_PATH`. + +3. Review `config/app.toml`. Keep `SORTARR_DRY_RUN=true` until the generated plans look right. + +4. Start the stack: + + ```bash + docker compose up --build + ``` + +5. Open `http://localhost:8088`. + +Production mode: + +```bash +docker compose -f compose.yaml -f compose.prod.yaml up -d --build +``` + +Optional profiles: + +```bash +docker compose --profile cache up -d +docker compose --profile database up -d +docker compose --profile tools up -d +``` + +## Services + +- `web`: nginx serving the editable dashboard from `web/src`. +- `backend`: Python API plus 24/7 worker loop. +- `redis`: optional cache profile for future workflow extensions. +- `postgres`: optional database profile for installations that outgrow JSON state. +- `media-tools`: optional ffmpeg tools container. + +## Mounted Host Paths + +- `/downloads`: incoming files. +- `/media/drive1` through `/media/drive4`: Jellyfin media drives. +- `/config`: TOML config and custom CSS. +- `/logs`: rotating backend logs. +- `/data`: JSON state and scan history. + +## Safety Model + +Sortarr defaults to dry-run mode. In dry-run mode it scans, parses, chooses drives, computes destination paths, and records planned actions without moving files. + +When dry-run is disabled, moves use a temporary `.sorting` destination before the final rename. Existing destinations follow the configured collision rule: `keep-both`, `skip`, or `replace`. + +## Permissions + +The default Compose file runs the backend with the container default user so a fresh checkout can create logs, state, and media folders without a bootstrap script. On a hardened media host, set ownership on the mounted paths and add a `user: "${SORTARR_UID}:${SORTARR_GID}"` line to the backend service in a local override. + +## Customization + +Edit these files directly: + +- `config/app.toml`: runtime organizer rules and provider settings. +- `.env`: deployment paths, ports, and intervals. +- `backend/sortarr/*.py`: parsing, drive choice, scanner, API, provider integrations. +- `web/src/*.html`, `*.css`, `*.js`: dashboard layout, styling, themes, routes. +- `config/custom-theme.css`: host-side CSS variable overrides loaded at runtime. + +See `docs/configuration.md`, `docs/api.md`, and `docs/operations.md`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9e026bc --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY sortarr /app/sortarr +COPY default-config /app/default-config + +EXPOSE 8099 +CMD ["python", "-m", "sortarr.app"] diff --git a/backend/default-config/app.toml b/backend/default-config/app.toml new file mode 100644 index 0000000..ab7cdcc --- /dev/null +++ b/backend/default-config/app.toml @@ -0,0 +1,90 @@ +[app] +name = "Sortarr" +dry_run = true +log_level = "INFO" +scan_interval_seconds = 20 +settle_seconds = 90 +stable_checks = 2 +incomplete_suffixes = [".part", ".partial", ".!qB", ".tmp", ".crdownload"] +media_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".ts"] +subtitle_extensions = [".srt", ".ass", ".ssa", ".vtt", ".sub"] +extra_keywords = ["sample", "trailer", "behind the scenes", "featurette", "deleted scene"] +library_scan_max_files = 20000 +library_scan_timeout_seconds = 8 +cache_max_bytes = 21474836480 +auto_move_min_confidence = 90 +review_min_confidence = 60 +organization_metadata_budget_seconds = 25 +organization_metadata_timeout_seconds = 3 +metadata_parallelism = 8 + +[paths] +downloads = "/downloads" +data = "/data" +logs = "/logs" +cache = "/data/cache" + +[[drives]] +id = "drive1" +name = "Media Drive 1" +path = "/media/drive1" +min_free_gb = 20 + +[[drives]] +id = "drive2" +name = "Media Drive 2" +path = "/media/drive2" +min_free_gb = 20 + +[[drives]] +id = "drive3" +name = "Media Drive 3" +path = "/media/drive3" +min_free_gb = 20 + +[[drives]] +id = "drive4" +name = "Media Drive 4" +path = "/media/drive4" +min_free_gb = 20 + +[library] +movie_folder = "Movies/{title} ({year})" +series_folder = "Shows/{title}/Season {season:02d}" +movie_file = "{title} ({year}){quality}{ext}" +episode_file = "{title} - S{season:02d}E{episode:02d}{multi_episode} - {episode_title}{quality}{ext}" +subtitle_file = "{basename}{language}{ext}" +unknown_folder = "Unsorted/{title}" +collision = "keep-both" # keep-both, skip, replace +duplicate = "skip" # skip, keep-both +permissions_mode = "664" +directory_mode = "775" + +[metadata] +write_nfo = true +provider_order = ["filename"] +prefer_existing_nfo = true +tmdb_api_key = "" +tmdb_bearer_token = "" +tmdb_language = "en-US" +tmdb_image_base = "https://image.tmdb.org/t/p/w342" +tmdb_enabled = true + +[[release_providers]] +id = "tmdb-rss" +name = "TMDb RSS" +enabled = false +type = "rss" +url = "https://www.themoviedb.org/rss/movie/upcoming" + +[[release_providers]] +id = "tvmaze-premieres" +name = "TVMaze Premieres" +enabled = false +type = "json" +url = "https://api.tvmaze.com/schedule" + +[theme] +default = "slate" +allow_custom_css = true +custom_css_path = "/config/custom-theme.css" diff --git a/backend/sortarr/__init__.py b/backend/sortarr/__init__.py new file mode 100644 index 0000000..f2923b7 --- /dev/null +++ b/backend/sortarr/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["config", "organizer", "server"] + diff --git a/backend/sortarr/app.py b/backend/sortarr/app.py new file mode 100644 index 0000000..15acb9d --- /dev/null +++ b/backend/sortarr/app.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import json +import os +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse +from urllib.parse import parse_qs, unquote + +from .config import load_config, public_config +from .downloads import downloads_snapshot +from .library import library_snapshot, normalize_library +from .logging_setup import configure_logging +from .media_probe import edit_track, media_probe +from .metadata import test_tmdb +from .organizer import execute_bundle_plan +from .releases import fetch_releases +from .scanner import Scanner +from .store import JsonStore +from .storage import drive_stats +from .tools import duplicate_finder, run_next_transcode, subtitle_audit, transcode_plan + + +SETTINGS_SCHEMA = { + "app": { + "name": str, + "dry_run": bool, + "log_level": str, + "scan_interval_seconds": int, + "settle_seconds": int, + "stable_checks": int, + "incomplete_suffixes": list, + "media_extensions": list, + "subtitle_extensions": list, + "extra_keywords": list, + "library_scan_max_files": int, + "library_scan_timeout_seconds": int, + "cache_max_bytes": int, + "auto_move_min_confidence": int, + "review_min_confidence": int, + "organization_metadata_budget_seconds": int, + "organization_metadata_timeout_seconds": int, + "metadata_parallelism": int, + }, + "paths": { + "downloads": str, + "data": str, + "logs": str, + "cache": str, + }, + "library": { + "movie_folder": str, + "series_folder": str, + "movie_file": str, + "episode_file": str, + "subtitle_file": str, + "unknown_folder": str, + "collision": str, + "duplicate": str, + "permissions_mode": str, + "directory_mode": str, + }, + "metadata": { + "write_nfo": bool, + "provider_order": list, + "prefer_existing_nfo": bool, + "tmdb_api_key": str, + "tmdb_bearer_token": str, + "tmdb_language": str, + "tmdb_image_base": str, + "tmdb_enabled": bool, + }, + "theme": { + "default": str, + "allow_custom_css": bool, + "custom_css_path": str, + }, +} + + +def deep_merge(base: dict, override: dict) -> dict: + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + deep_merge(base[key], value) + else: + base[key] = value + return base + + +def coerce_value(value, caster): + if caster is bool: + return bool(value) + if caster is int: + return int(value) + if caster is list: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [item.strip() for item in str(value).split(",") if item.strip()] + return caster(value) + + +def apply_settings(config: dict, settings: dict) -> dict: + if any(key in SETTINGS_SCHEMA["app"] for key in settings): + settings = {"app": settings} + applied = {} + for section, fields in SETTINGS_SCHEMA.items(): + values = settings.get(section) + if not isinstance(values, dict): + continue + target = config.setdefault(section, {}) + applied_section = applied.setdefault(section, {}) + for key, caster in fields.items(): + if key not in values: + continue + target[key] = coerce_value(values[key], caster) + applied_section[key] = target[key] + if not applied_section: + applied.pop(section, None) + + if isinstance(settings.get("drives"), list): + drives = [] + for idx, drive in enumerate(settings["drives"]): + if not isinstance(drive, dict): + continue + existing = (config.get("drives") or [{}] * (idx + 1))[idx] if idx < len(config.get("drives", [])) else {} + drives.append({ + "id": str(drive.get("id", existing.get("id", f"drive{idx + 1}"))), + "name": str(drive.get("name", existing.get("name", f"Media Drive {idx + 1}"))), + "path": str(drive.get("path", existing.get("path", ""))), + "min_free_gb": int(drive.get("min_free_gb", existing.get("min_free_gb", 20))), + }) + config["drives"] = drives + applied["drives"] = drives + + if isinstance(settings.get("release_providers"), list): + providers = [] + for provider in settings["release_providers"]: + if not isinstance(provider, dict): + continue + providers.append({ + "id": str(provider.get("id", "")), + "name": str(provider.get("name", "")), + "enabled": bool(provider.get("enabled", False)), + "type": str(provider.get("type", "rss")), + "url": str(provider.get("url", "")), + }) + config["release_providers"] = providers + applied["release_providers"] = providers + + return applied + + +CONFIG = load_config() +configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO")) +STORE = JsonStore(CONFIG["paths"]["data"]) +apply_settings(CONFIG, STORE.snapshot().get("settings", {})) +SCANNER = Scanner(CONFIG, STORE) + + +class Handler(BaseHTTPRequestHandler): + server_version = "Sortarr/0.1" + + def log_message(self, fmt: str, *args) -> None: + return + + def send_json(self, payload, status=HTTPStatus.OK) -> None: + body = json.dumps(payload, indent=2).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self) -> None: + parsed_url = urlparse(self.path) + path = parsed_url.path + try: + if path == "/api/health": + self.send_json({"ok": True}) + elif path == "/api/config": + self.send_json(public_config(CONFIG)) + elif path == "/api/dashboard": + snap = STORE.snapshot() + cached_library = snap.get("library") or { + "drives": drive_stats(CONFIG), + "items": [], + "counts": {"movies": 0, "tv": 0, "total": 0}, + "extensions": {}, + "scanned_files": 0, + "truncated": False, + "cached": False, + } + cached_library = normalize_library(cached_library) + cached_library.pop("items", None) + cached_library.pop("collections", None) + public_state = { + "events": snap.get("events", [])[:200], + "organizer": snap.get("organizer", {"queue": [], "updated_at": None}), + "settings": snap.get("settings", {}), + "updated_at": snap.get("updated_at"), + } + self.send_json({ + "state": public_state, + "library": cached_library, + "dry_run": CONFIG["app"].get("dry_run"), + }) + elif path == "/api/library": + library = STORE.snapshot().get("library") or { + "drives": drive_stats(CONFIG), + "items": [], + "counts": {"movies": 0, "tv": 0, "total": 0}, + "extensions": {}, + "scanned_files": 0, + "truncated": False, + "cached": False, + } + library = normalize_library(library) + library.pop("items", None) + self.send_json({"library": library}) + elif path == "/api/downloads": + self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())}) + elif path == "/api/releases": + self.send_json({"releases": fetch_releases(CONFIG, STORE.snapshot().get("library"))}) + elif path == "/api/media/probe": + params = parse_qs(parsed_url.query) + target = unquote((params.get("path") or [""])[0]) + self.send_json({"media": media_probe(CONFIG, target)}) + elif path == "/api/tools/subtitles": + self.send_json({"audit": subtitle_audit(CONFIG, STORE.snapshot().get("library"))}) + elif path == "/api/tools/transcoder": + self.send_json({"transcoder": transcode_plan(CONFIG, STORE.snapshot().get("library"))}) + elif path == "/api/tools/duplicates": + self.send_json({"duplicates": duplicate_finder(CONFIG, STORE.snapshot().get("library"))}) + elif path == "/api/theme/custom.css": + custom = CONFIG.get("theme", {}).get("custom_css_path") + if custom and CONFIG.get("theme", {}).get("allow_custom_css", True) and os.path.exists(custom): + body = open(custom, "rb").read() + self.send_response(200) + self.send_header("Content-Type", "text/css") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + except Exception as exc: + self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + + def do_POST(self) -> None: + path = urlparse(self.path).path + try: + if path == "/api/scan": + started = SCANNER.request_scan() + snap = STORE.snapshot() + self.send_json({ + "started": started, + "status": "started" if started else "already-running", + "queue": snap.get("organizer", {}).get("queue", []), + }, HTTPStatus.ACCEPTED) + elif path == "/api/organizer/approve": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + payload = json.loads(body) + plan_id = payload.get("id") + snap = STORE.snapshot() + queue = snap.get("organizer", {}).get("queue", []) + plan = next((item for item in queue if item.get("id") == plan_id), None) + if not plan: + self.send_json({"error": "plan not found"}, HTTPStatus.NOT_FOUND) + return + result = execute_bundle_plan(CONFIG, plan, force=True) + updated = [result if item.get("id") == plan_id else item for item in queue] + STORE.set_organizer_queue(updated) + STORE.add_event("info", f"approved organizer plan: {result.get('result')}", path=result.get("source"), confidence=result.get("confidence")) + self.send_json({"plan": result}) + elif path == "/api/organizer/skip": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + payload = json.loads(body) + plan_id = payload.get("id") + snap = STORE.snapshot() + queue = snap.get("organizer", {}).get("queue", []) + updated = [{**item, "status": "skipped", "result": "skipped"} if item.get("id") == plan_id else item for item in queue] + STORE.set_organizer_queue(updated) + self.send_json({"ok": True}) + elif path == "/api/library/scan": + library = library_snapshot(CONFIG) + STORE.set_library(library) + self.send_json({"library": library}) + elif path == "/api/tools/transcoder/run-next": + result = run_next_transcode(CONFIG, STORE.snapshot().get("library")) + STORE.add_event("info", f"transcoder: {result.get('status')}") + self.send_json({"transcoder": result}) + elif path == "/api/metadata/tmdb/test": + self.send_json({"tmdb": test_tmdb(CONFIG)}) + elif path == "/api/media/tracks": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + payload = json.loads(body) + result = edit_track(CONFIG, payload.get("path", ""), payload.get("action", ""), int(payload.get("stream_index", -1))) + STORE.add_event("info", f"track edit: {result.get('status')}", path=payload.get("path", "")) + self.send_json({"media": result}) + elif path == "/api/settings": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + updates = json.loads(body) + applied = apply_settings(CONFIG, updates) + snap = STORE.snapshot() + settings = snap.get("settings", {}) + deep_merge(settings, applied) + STORE.state["settings"] = settings + STORE.save() + self.send_json({"settings": applied, "config": public_config(CONFIG)}) + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + except Exception as exc: + self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + + +def main() -> None: + SCANNER.start() + host = os.getenv("SORTARR_HOST", "0.0.0.0") + port = int(os.getenv("SORTARR_API_PORT", "8099")) + ThreadingHTTPServer((host, port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/backend/sortarr/cache.py b/backend/sortarr/cache.py new file mode 100644 index 0000000..7fc5794 --- /dev/null +++ b/backend/sortarr/cache.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import hashlib +import json +import os +import time +from pathlib import Path +from typing import Any + + +def cache_root(config: dict) -> Path: + root = Path(config.get("paths", {}).get("cache") or Path(config["paths"]["data"]) / "cache") + root.mkdir(parents=True, exist_ok=True) + return root + + +def cache_path(config: dict, namespace: str, key: str) -> Path: + digest = hashlib.sha256(key.encode()).hexdigest() + path = cache_root(config) / namespace / f"{digest}.json" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def get_json(config: dict, namespace: str, key: str, ttl_seconds: int | None = None) -> Any | None: + path = cache_path(config, namespace, key) + if not path.exists(): + return None + if ttl_seconds is not None and time.time() - path.stat().st_mtime > ttl_seconds: + return None + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + + +def set_json(config: dict, namespace: str, key: str, value: Any) -> None: + path = cache_path(config, namespace, key) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(value, sort_keys=True)) + tmp.replace(path) + prune(config) + + +def remove_json(config: dict, namespace: str, key: str) -> None: + path = cache_path(config, namespace, key) + try: + path.unlink() + except FileNotFoundError: + return + + +def prune(config: dict) -> None: + root = cache_root(config) + max_bytes = int(config.get("app", {}).get("cache_max_bytes", 20 * 1024**3)) + files = [] + total = 0 + for current, _, names in os.walk(root): + for name in names: + path = Path(current) / name + try: + stat = path.stat() + except OSError: + continue + total += stat.st_size + files.append((stat.st_mtime, stat.st_size, path)) + if total <= max_bytes: + return + for _, size, path in sorted(files): + try: + path.unlink() + total -= size + except OSError: + continue + if total <= max_bytes: + break diff --git a/backend/sortarr/config.py b/backend/sortarr/config.py new file mode 100644 index 0000000..4ebef5c --- /dev/null +++ b/backend/sortarr/config.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import copy +import os +import tomllib +from pathlib import Path +from typing import Any + + +def _read_toml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("rb") as handle: + return tomllib.load(handle) + + +def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + merged = copy.deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _merge(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def _bool(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def load_config() -> dict[str, Any]: + default_path = Path(os.getenv("SORTARR_DEFAULT_CONFIG", "/app/default-config/app.toml")) + user_path = Path(os.getenv("SORTARR_CONFIG", "/config/app.toml")) + config = _merge(_read_toml(default_path), _read_toml(user_path)) + + app = config.setdefault("app", {}) + paths = config.setdefault("paths", {}) + + env_map = { + "SORTARR_DRY_RUN": ("app", "dry_run", _bool), + "SORTARR_LOG_LEVEL": ("app", "log_level", str), + "SORTARR_SCAN_INTERVAL_SECONDS": ("app", "scan_interval_seconds", int), + "SORTARR_SETTLE_SECONDS": ("app", "settle_seconds", int), + "SORTARR_DATA_DIR": ("paths", "data", str), + "SORTARR_LOG_DIR": ("paths", "logs", str), + "SORTARR_CACHE_DIR": ("paths", "cache", str), + "TMDB_API_KEY": ("metadata", "tmdb_api_key", str), + "TMDB_BEARER_TOKEN": ("metadata", "tmdb_bearer_token", str), + } + for env, (section, key, caster) in env_map.items(): + if os.getenv(env) not in (None, ""): + config.setdefault(section, {})[key] = caster(os.environ[env]) + + if os.getenv("SORTARR_MIN_FREE_GB"): + for drive in config.get("drives", []): + drive["min_free_gb"] = int(os.environ["SORTARR_MIN_FREE_GB"]) + + Path(paths.get("data", "/data")).mkdir(parents=True, exist_ok=True) + Path(paths.get("logs", "/logs")).mkdir(parents=True, exist_ok=True) + Path(paths.get("cache", str(Path(paths.get("data", "/data")) / "cache"))).mkdir(parents=True, exist_ok=True) + app.setdefault("dry_run", True) + return config + + +def public_config(config: dict[str, Any]) -> dict[str, Any]: + clone = copy.deepcopy(config) + metadata = clone.get("metadata", {}) + for key in ("tmdb_api_key", "tmdb_bearer_token"): + if metadata.get(key): + metadata[key] = "********" + return clone diff --git a/backend/sortarr/downloads.py b/backend/sortarr/downloads.py new file mode 100644 index 0000000..e9feba7 --- /dev/null +++ b/backend/sortarr/downloads.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import time +from collections import defaultdict +from pathlib import Path + + +def empty_snapshot(root: Path, error: str | None = None) -> dict: + return { + "path": str(root), + "generated_at": time.time(), + "current": [], + "bundles": [], + "loose": [], + "recent": [], + "counts": { + "current": 0, + "recent": 0, + "media": 0, + "subtitles": 0, + "incomplete": 0, + }, + "total_size": 0, + "error": error, + } + + +def downloads_snapshot(config: dict, state: dict) -> dict: + root = Path(config["paths"]["downloads"]) + app = config.get("app", {}) + media_extensions = set(app.get("media_extensions", [])) + subtitle_extensions = set(app.get("subtitle_extensions", [])) + incomplete = set(app.get("incomplete_suffixes", [])) + current = [] + media_files = [] + subtitle_files = [] + total_size = 0 + + try: + root.mkdir(parents=True, exist_ok=True) + paths = root.rglob("*") + for path in paths: + if not path.is_file(): + continue + try: + stat = path.stat() + except OSError: + continue + suffix = path.suffix.lower() + total_size += stat.st_size + item = { + "name": path.name, + "path": str(path), + "relative_path": str(path.relative_to(root)), + "folder": str(path.parent.relative_to(root)) if path.parent != root else "", + "size": stat.st_size, + "modified": stat.st_mtime, + "extension": suffix or "none", + "is_media": suffix in media_extensions, + "is_subtitle": suffix in subtitle_extensions, + "is_incomplete": suffix in incomplete, + } + current.append(item) + if item["is_media"]: + media_files.append(item) + elif item["is_subtitle"]: + subtitle_files.append(item) + except OSError as exc: + return empty_snapshot(root, str(exc)) + + subtitles_by_folder = defaultdict(list) + for subtitle in subtitle_files: + subtitles_by_folder[subtitle["folder"]].append(subtitle) + parent = Path(subtitle["folder"]) + if parent.name.lower() in {"subs", "subtitles"}: + subtitles_by_folder[str(parent.parent) if str(parent.parent) != "." else ""].append(subtitle) + + bundles = [] + bundled_subtitle_paths = set() + for media in media_files: + folder_subtitles = subtitles_by_folder.get(media["folder"], []) + stem_matches = [ + subtitle for subtitle in subtitle_files + if subtitle["name"].lower().startswith(Path(media["name"]).stem.lower()) + ] + seen = set() + subtitles = [] + for subtitle in folder_subtitles + stem_matches: + if subtitle["path"] in seen: + continue + seen.add(subtitle["path"]) + bundled_subtitle_paths.add(subtitle["path"]) + subtitles.append(subtitle) + bundles.append({ + "media": media, + "subtitles": sorted(subtitles, key=lambda item: item["name"].lower()), + "sidecars": [ + item for item in current + if item["folder"] == media["folder"] and not item["is_media"] and not item["is_subtitle"] + ][:20], + "size": media["size"] + sum(item["size"] for item in subtitles), + }) + + loose = [ + item for item in current + if not item["is_media"] and item["path"] not in bundled_subtitle_paths + ] + + recent = [] + for item in state.get("items", []): + source = item.get("source", "") + status = item.get("status") + if source.startswith(str(root)) and status in {"moved", "planned"}: + recent.append({ + "source": source, + "destination": item.get("destination"), + "title": item.get("title"), + "type": item.get("type"), + "status": status, + "drive": item.get("drive"), + "updated_at": item.get("updated_at"), + }) + + return { + "path": str(root), + "generated_at": time.time(), + "current": sorted(current, key=lambda item: item["modified"], reverse=True), + "bundles": sorted(bundles, key=lambda item: item["media"]["modified"], reverse=True), + "loose": sorted(loose, key=lambda item: item["modified"], reverse=True), + "recent": sorted(recent, key=lambda item: item.get("updated_at") or 0, reverse=True)[:200], + "counts": { + "current": len(current), + "recent": len(recent), + "media": sum(1 for item in current if item["is_media"]), + "subtitles": sum(1 for item in current if item["is_subtitle"]), + "incomplete": sum(1 for item in current if item["is_incomplete"]), + }, + "total_size": total_size, + } diff --git a/backend/sortarr/healthcheck.py b/backend/sortarr/healthcheck.py new file mode 100644 index 0000000..c50052f --- /dev/null +++ b/backend/sortarr/healthcheck.py @@ -0,0 +1,7 @@ +from urllib.request import urlopen + + +with urlopen("http://127.0.0.1:8099/api/health", timeout=3) as response: + if response.status != 200: + raise SystemExit(1) + diff --git a/backend/sortarr/library.py b/backend/sortarr/library.py new file mode 100644 index 0000000..bc13807 --- /dev/null +++ b/backend/sortarr/library.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import os +import re +import time +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from .metadata import movie_metadata, series_metadata +from .parser import clean_title, parse_media +from .storage import drive_stats + + +LIBRARY_ROOT_NAMES = {"movies", "shows", "tv", "tv shows"} +TV_ROOT_NAMES = {"shows", "tv", "tv shows"} +EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})") +SEASON_FOLDER_RE = re.compile(r"season[ ._-]*(\d{1,2})", re.I) +YEAR_RE = re.compile(r"\((19\d{2}|20\d{2})\)") +ANY_YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b") +VERSION_RE = re.compile(r"\b(2160p|1080p|720p|480p|remux|bluray|web[- .]?dl|webrip|hdtv|dvdrip|x264|x265|h[ ._-]?264|h[ ._-]?265|hevc|av1|hdr10?|dv|proper|repack|extended|unrated|directors?[ ._-]?cut|theatrical|imax)\b", re.I) +EXTRA_FOLDER_NAMES = { + "behind the scenes", + "deleted scenes", + "extras", + "featurettes", + "interviews", + "samples", + "scenes", + "shorts", + "trailers", +} + + +def library_roots(root: Path) -> list[Path]: + matches = [] + try: + children = list(root.iterdir()) + except OSError: + return matches + for child in children: + if child.is_dir() and child.name.lower() in LIBRARY_ROOT_NAMES: + matches.append(child) + return matches + + +def library_kind(library_root: Path) -> str: + return "tv" if library_root.name.lower() in TV_ROOT_NAMES else "movie" + + +def infer_library_kind(path: str) -> str: + parts = {part.lower() for part in Path(path).parts} + if parts & TV_ROOT_NAMES: + return "tv" + if "movies" in parts: + return "movie" + return "other" + + +def split_library_path(path: str) -> tuple[str, list[str]]: + parts = list(Path(path).parts) + lowered = [part.lower() for part in parts] + for root in LIBRARY_ROOT_NAMES: + if root in lowered: + idx = lowered.index(root) + return parts[idx], parts[idx + 1:] + return "", parts + + +def identity_slug(title: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", title.lower()).strip() + + +def clean_collection_title(name: str) -> tuple[str, int | None]: + year_match = ANY_YEAR_RE.search(name) + year = int(year_match.group(1)) if year_match else None + title = clean_title(name) + return title, year + + +def merge_key(kind: str, title: str, year: int | None = None) -> str: + slug = identity_slug(title) + if kind == "movie": + return f"movie::{slug}::{year or ''}" + return f"tv::{slug}" + + +def file_version(item: dict) -> dict: + path = Path(item.get("path", "")) + text = " ".join(part for part in [path.parent.name, path.stem] if part) + tags = [] + for match in VERSION_RE.finditer(text): + tag = match.group(1).replace(".", " ").replace("_", " ") + normalized = re.sub(r"\s+", " ", tag).strip() + if normalized.lower() not in {existing.lower() for existing in tags}: + tags.append(normalized) + return { + "path": item.get("path"), + "name": item.get("name"), + "drive": item.get("drive"), + "size": item.get("size") or 0, + "quality": next((tag for tag in tags if tag.lower() in {"2160p", "1080p", "720p", "480p"}), ""), + "tags": tags[:8], + } + + +def is_extra_media(path: Path, library_root: Path, kind: str, app: dict) -> bool: + try: + relative = path.relative_to(library_root) + except ValueError: + relative = path + parts = [part.lower().replace("_", " ").replace(".", " ") for part in relative.parts[:-1]] + if kind == "movie" and any(part in EXTRA_FOLDER_NAMES for part in parts[1:]): + return True + lowered_name = path.name.lower().replace("_", " ").replace(".", " ") + return any(keyword and keyword.lower() in lowered_name for keyword in app.get("extra_keywords", [])) + + +def item_identity(item: dict) -> dict: + root, rel = split_library_path(item.get("path", "")) + kind = item.get("library") or infer_library_kind(item.get("path", "")) + parsed = parse_media(item.get("path", item.get("name", ""))) + if kind == "tv" and rel: + title = clean_title(rel[0]) + season = parsed.get("season") + episode = parsed.get("episode") + for part in rel: + match = SEASON_FOLDER_RE.search(part) + if match and not season: + season = int(match.group(1)) + return { + "kind": "tv", + "root": root, + "title": title, + "key": merge_key("tv", title), + "season": season, + "episode": episode, + } + title, year = clean_collection_title(rel[0] if rel else parsed["title"]) + year = year or parsed.get("year") + return { + "kind": "movie", + "root": root, + "title": title, + "year": year, + "slug": identity_slug(title), + "key": merge_key("movie", title, year), + } + + +def normalize_library(library: dict) -> dict: + items = library.get("items", []) + kinds = Counter() + for item in items: + kind = item.get("library") or infer_library_kind(item.get("path", "")) + item["library"] = kind + if kind in {"movie", "tv"}: + kinds[kind] += 1 + library["counts"] = { + "movies": kinds.get("movie", 0), + "tv": kinds.get("tv", 0), + "total": len(items), + } + if "collections" not in library: + library["collections"] = build_collections({}, items) + return library + + +def build_collections(config: dict, items: list[dict], enrich: bool = False) -> dict: + movies: dict[str, dict] = {} + series: dict[str, dict] = {} + for item in items: + identity = item_identity(item) + if identity["kind"] == "tv": + show = series.setdefault(identity["key"], { + "key": identity["key"], + "title": identity["title"], + "library": "tv", + "files": [], + "seasons": {}, + "metadata": {"title": identity["title"], "source": "filename", "seasons": {}}, + }) + show["files"].append(item) + season_no = identity.get("season") or 0 + episode_no = identity.get("episode") or 0 + season = show["seasons"].setdefault(str(season_no), {"season": season_no, "episodes": {}}) + episode = season["episodes"].setdefault(str(episode_no), { + "season": season_no, + "episode": episode_no, + "title": f"S{season_no:02d}E{episode_no:02d}" if season_no and episode_no else item["name"], + "files": [], + "status": "present", + }) + episode["files"].append(item) + else: + key = identity["key"] + if not identity.get("year"): + existing_key = next((candidate_key for candidate_key, candidate in movies.items() if candidate.get("slug") == identity["slug"]), None) + if existing_key: + key = existing_key + elif key not in movies: + no_year_key = merge_key("movie", identity["title"], None) + if no_year_key in movies: + movies[key] = movies.pop(no_year_key) + movies[key]["key"] = key + movie = movies.setdefault(key, { + "key": key, + "title": identity["title"], + "year": identity.get("year"), + "slug": identity.get("slug"), + "library": "movie", + "files": [], + "versions": [], + "metadata": {"title": identity["title"], "source": "filename"}, + }) + movie["files"].append(item) + movie["versions"].append(file_version(item)) + if not movie.get("year") and identity.get("year"): + movie["year"] = identity.get("year") + + if enrich and config: + workers = int(config.get("app", {}).get("metadata_parallelism", 8)) + tasks = {} + with ThreadPoolExecutor(max_workers=max(1, min(workers, 12))) as executor: + for movie in movies.values(): + future = executor.submit(movie_metadata, config, movie["title"], movie.get("year")) + tasks[future] = movie + for show in series.values(): + present_seasons = {int(season) for season in show["seasons"] if int(season) > 0} + future = executor.submit(series_metadata, config, show["title"], present_seasons) + tasks[future] = show + for future in as_completed(tasks): + try: + tasks[future]["metadata"] = future.result() + except Exception: + pass + + today = time.strftime("%Y-%m-%d") + for show in series.values(): + for season_no, season_meta in show.get("metadata", {}).get("seasons", {}).items(): + season = show["seasons"].setdefault(season_no, {"season": int(season_no), "episodes": {}}) + for meta_episode in season_meta.get("episodes", []): + key = str(meta_episode.get("episode") or 0) + existing = season["episodes"].get(key) + if existing: + existing.update({ + "title": meta_episode.get("title") or existing["title"], + "air_date": meta_episode.get("air_date"), + "overview": meta_episode.get("overview"), + "still": meta_episode.get("still"), + }) + else: + air_date = meta_episode.get("air_date") + season["episodes"][key] = { + **meta_episode, + "files": [], + "status": "upcoming" if air_date and air_date > today else "missing", + } + for season in show["seasons"].values(): + season["episodes"] = sorted(season["episodes"].values(), key=lambda ep: ep.get("episode") or 0) + show["seasons"] = sorted(show["seasons"].values(), key=lambda season: season["season"]) + + return { + "movies": sorted(movies.values(), key=lambda movie: movie["title"].lower()), + "series": sorted(series.values(), key=lambda show: show["title"].lower()), + } + + +def library_snapshot(config: dict) -> dict: + items = [] + extensions = Counter() + ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"} + app = config["app"] + max_files = int(app.get("library_scan_max_files", 20000)) + deadline = time.monotonic() + int(app.get("library_scan_timeout_seconds", 8)) + scanned = 0 + truncated = False + for drive in config.get("drives", []): + if scanned >= max_files or time.monotonic() >= deadline: + truncated = True + break + root = Path(drive["path"]) + if not root.exists(): + continue + for library_root in library_roots(root): + kind = library_kind(library_root) + for current, dirs, files in os.walk(library_root, onerror=lambda error: None): + if scanned >= max_files or time.monotonic() >= deadline: + truncated = True + break + dirs[:] = [name for name in dirs if name not in ignored_dirs] + lower_files = {name.lower() for name in files} + for filename in files: + if scanned >= max_files or time.monotonic() >= deadline: + truncated = True + break + path = Path(current) / filename + try: + stat = path.stat() + except OSError: + continue + scanned += 1 + extensions[path.suffix.lower() or "none"] += 1 + if path.suffix.lower() in app.get("media_extensions", []): + if is_extra_media(path, library_root, kind, app): + continue + subtitle_names = [ + f"{path.stem}{ext}".lower() + for ext in app.get("subtitle_extensions", []) + ] + items.append({ + "path": str(path), + "name": path.name, + "drive": drive["id"], + "library": kind, + "root": library_root.name, + "size": stat.st_size, + "modified": stat.st_mtime, + "has_subtitles": any(name in lower_files for name in subtitle_names), + }) + enrich_limit = int(app.get("library_metadata_enrich_max_items", 500)) + should_enrich = bool(config.get("metadata", {}).get("tmdb_enabled", True)) and len(items) <= enrich_limit + return normalize_library({ + "drives": drive_stats(config), + "items": sorted(items, key=lambda item: item["modified"], reverse=True), + "collections": build_collections(config, items, enrich=should_enrich), + "extensions": dict(extensions.most_common()), + "scanned_files": scanned, + "truncated": truncated, + "metadata_enriched": should_enrich, + }) diff --git a/backend/sortarr/logging_setup.py b/backend/sortarr/logging_setup.py new file mode 100644 index 0000000..9604397 --- /dev/null +++ b/backend/sortarr/logging_setup.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import sys +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +def configure_logging(log_dir: str, level: str) -> None: + Path(log_dir).mkdir(parents=True, exist_ok=True) + formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") + root = logging.getLogger() + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + root.handlers.clear() + + stream = logging.StreamHandler() + stream.setFormatter(formatter) + root.addHandler(stream) + + try: + file_handler = RotatingFileHandler(Path(log_dir) / "sortarr.log", maxBytes=5_000_000, backupCount=5) + file_handler.setFormatter(formatter) + root.addHandler(file_handler) + except OSError as exc: + print(f"Sortarr could not open file logging in {log_dir}: {exc}", file=sys.stderr) diff --git a/backend/sortarr/media_probe.py b/backend/sortarr/media_probe.py new file mode 100644 index 0000000..a841679 --- /dev/null +++ b/backend/sortarr/media_probe.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +from .cache import get_json, remove_json, set_json + + +def _allowed_roots(config: dict) -> list[Path]: + roots = [Path(drive["path"]).resolve() for drive in config.get("drives", [])] + roots.append(Path(config["paths"]["downloads"]).resolve()) + return roots + + +def assert_allowed_path(config: dict, path: str) -> Path: + target = Path(path).resolve() + for root in _allowed_roots(config): + try: + target.relative_to(root) + return target + except ValueError: + continue + raise ValueError("path is outside configured media and downloads roots") + + +def media_probe(config: dict, path: str) -> dict: + target = assert_allowed_path(config, path) + stat = target.stat() + cache_key = f"{target}:{stat.st_size}:{int(stat.st_mtime)}" + cached = get_json(config, "ffprobe", cache_key) + if cached is not None: + return cached + command = [ + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + str(target), + ] + completed = subprocess.run(command, capture_output=True, text=True, timeout=60) + if completed.returncode != 0: + return {"path": str(target), "status": "failed", "stderr": completed.stderr[-4000:]} + payload = json.loads(completed.stdout or "{}") + streams = payload.get("streams", []) + result = { + "path": str(target), + "cache_key": cache_key, + "status": "ok", + "format": payload.get("format", {}), + "audio": [stream for stream in streams if stream.get("codec_type") == "audio"], + "subtitles": [stream for stream in streams if stream.get("codec_type") == "subtitle"], + "video": [stream for stream in streams if stream.get("codec_type") == "video"], + } + set_json(config, "ffprobe", cache_key, result) + return result + + +def _stream_type_positions(probe: dict) -> dict[int, tuple[str, int]]: + positions = {"audio": 0, "subtitle": 0, "video": 0} + result = {} + for stream in probe.get("video", []) + probe.get("audio", []) + probe.get("subtitles", []): + codec_type = stream.get("codec_type") + if codec_type not in positions: + continue + result[int(stream["index"])] = (codec_type, positions[codec_type]) + positions[codec_type] += 1 + return result + + +def edit_track(config: dict, path: str, action: str, stream_index: int) -> dict: + target = assert_allowed_path(config, path) + probe = media_probe(config, str(target)) + positions = _stream_type_positions(probe) + if stream_index not in positions: + raise ValueError("stream index was not found") + codec_type, type_index = positions[stream_index] + if codec_type not in {"audio", "subtitle"}: + raise ValueError("only audio and subtitle streams can be edited here") + + tmp = target.with_suffix(target.suffix + ".tracksorting") + if action == "remove": + command = ["ffmpeg", "-hide_banner", "-y", "-i", str(target), "-map", "0", "-map", f"-0:{stream_index}", "-c", "copy", str(tmp)] + elif action == "set-default": + spec = "a" if codec_type == "audio" else "s" + command = [ + "ffmpeg", + "-hide_banner", + "-y", + "-i", + str(target), + "-map", + "0", + "-c", + "copy", + f"-disposition:{spec}", + "0", + f"-disposition:{spec}:{type_index}", + "default", + str(tmp), + ] + else: + raise ValueError("unsupported track action") + + if config["app"].get("dry_run"): + return {"status": "dry-run", "path": str(target), "action": action, "stream_index": stream_index, "command": command} + + completed = subprocess.run(command, capture_output=True, text=True, timeout=60 * 60) + if completed.returncode != 0: + try: + tmp.unlink() + except FileNotFoundError: + pass + return {"status": "failed", "returncode": completed.returncode, "stderr": completed.stderr[-4000:], "command": command} + os.replace(tmp, target) + remove_json(config, "ffprobe", probe.get("cache_key", "")) + return {"status": "updated", "path": str(target), "action": action, "stream_index": stream_index} diff --git a/backend/sortarr/metadata.py b/backend/sortarr/metadata.py new file mode 100644 index 0000000..74f203e --- /dev/null +++ b/backend/sortarr/metadata.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from .cache import get_json, set_json + + +TMDB_BASE = "https://api.themoviedb.org/3" +TMDB_TTL_SECONDS = 7 * 24 * 60 * 60 + + +def _auth(config: dict) -> tuple[dict[str, str], str | None]: + meta = config.get("metadata", {}) + token = meta.get("tmdb_bearer_token") or "" + api_key = meta.get("tmdb_api_key") or "" + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers, api_key or None + + +def tmdb_available(config: dict) -> bool: + meta = config.get("metadata", {}) + if not meta.get("tmdb_enabled", True): + return False + return bool(meta.get("tmdb_bearer_token") or meta.get("tmdb_api_key")) + + +def poster_url(config: dict, path: str | None) -> str | None: + if not path: + return None + return f"{config.get('metadata', {}).get('tmdb_image_base', 'https://image.tmdb.org/t/p/w342')}{path}" + + +def tmdb_get(config: dict, endpoint: str, params: dict | None = None) -> dict: + headers, api_key = _auth(config) + query = dict(params or {}) + query.setdefault("language", config.get("metadata", {}).get("tmdb_language", "en-US")) + if api_key: + query["api_key"] = api_key + url = f"{TMDB_BASE}{endpoint}?{urlencode(query)}" + cache_key = f"{endpoint}?{urlencode(sorted((key, value) for key, value in query.items() if key != 'api_key'))}" + cached = get_json(config, "tmdb", cache_key, TMDB_TTL_SECONDS) + if cached is not None: + return cached + timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3)) + with urlopen(Request(url, headers=headers), timeout=timeout) as response: + payload = json.loads(response.read().decode()) + set_json(config, "tmdb", cache_key, payload) + return payload + + +def test_tmdb(config: dict) -> dict: + meta = config.get("metadata", {}) + if not meta.get("tmdb_enabled", True): + return {"ok": False, "status": "disabled", "message": "TMDb is disabled in settings."} + headers, api_key = _auth(config) + if not api_key and "Authorization" not in headers: + return {"ok": False, "status": "missing-credentials", "message": "No TMDb API key or bearer token is configured."} + params = {"language": meta.get("tmdb_language", "en-US")} + if api_key: + params["api_key"] = api_key + url = f"{TMDB_BASE}/configuration?{urlencode(params)}" + timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3)) + try: + with urlopen(Request(url, headers=headers), timeout=timeout) as response: + payload = json.loads(response.read().decode()) + images = payload.get("images") or {} + secure_base = images.get("secure_base_url") or images.get("base_url") + return { + "ok": True, + "status": "connected", + "message": "TMDb accepted the configured credentials.", + "image_base": secure_base, + "poster_sizes": images.get("poster_sizes") or [], + } + except HTTPError as exc: + return {"ok": False, "status": f"http-{exc.code}", "message": f"TMDb returned HTTP {exc.code}."} + except (TimeoutError, URLError) as exc: + return {"ok": False, "status": "network-error", "message": str(exc)} + except Exception as exc: + return {"ok": False, "status": "error", "message": str(exc)} + + +def first_result(config: dict, media_type: str, title: str, year: int | None = None) -> dict | None: + if not tmdb_available(config) or not title: + return None + params = {"query": title} + if year and media_type == "movie": + params["year"] = year + elif year: + params["first_air_date_year"] = year + try: + payload = tmdb_get(config, f"/search/{media_type}", params) + except Exception: + return None + results = payload.get("results") or [] + return results[0] if results else None + + +def movie_metadata(config: dict, title: str, year: int | None = None) -> dict: + result = first_result(config, "movie", title, year) + if not result: + return {"title": title, "source": "filename"} + return { + "source": "tmdb", + "tmdb_id": result.get("id"), + "title": result.get("title") or title, + "overview": result.get("overview") or "", + "poster": poster_url(config, result.get("poster_path")), + "backdrop": poster_url(config, result.get("backdrop_path")), + "release_date": result.get("release_date"), + "vote_average": result.get("vote_average"), + } + + +def series_metadata(config: dict, title: str, seasons: set[int]) -> dict: + result = first_result(config, "tv", title) + if not result: + return {"title": title, "source": "filename", "seasons": {}} + metadata = { + "source": "tmdb", + "tmdb_id": result.get("id"), + "title": result.get("name") or title, + "overview": result.get("overview") or "", + "poster": poster_url(config, result.get("poster_path")), + "backdrop": poster_url(config, result.get("backdrop_path")), + "first_air_date": result.get("first_air_date"), + "vote_average": result.get("vote_average"), + "seasons": {}, + } + for season in sorted(seasons): + try: + payload = tmdb_get(config, f"/tv/{result.get('id')}/season/{season}") + except Exception: + continue + metadata["seasons"][str(season)] = { + "name": payload.get("name"), + "air_date": payload.get("air_date"), + "episode_count": len(payload.get("episodes") or []), + "episodes": [ + { + "season": season, + "episode": episode.get("episode_number"), + "title": episode.get("name"), + "overview": episode.get("overview") or "", + "air_date": episode.get("air_date"), + "still": poster_url(config, episode.get("still_path")), + } + for episode in payload.get("episodes") or [] + ], + } + return metadata diff --git a/backend/sortarr/organizer.py b/backend/sortarr/organizer.py new file mode 100644 index 0000000..a7576c5 --- /dev/null +++ b/backend/sortarr/organizer.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import logging +import os +import shutil +import time +import hashlib +import xml.etree.ElementTree as ET +from pathlib import Path + +from .metadata import movie_metadata, series_metadata, tmdb_available +from .parser import parse_media +from .storage import choose_drive + +LOG = logging.getLogger(__name__) + +LANGUAGE_HINTS = { + "eng": "eng", + "english": "eng", + "en": "eng", + "spa": "spa", + "spanish": "spa", + "fre": "fre", + "french": "fre", + "ger": "ger", + "german": "ger", + "ita": "ita", + "jpn": "jpn", + "japanese": "jpn", + "kor": "kor", +} + + +def safe_name(value: str) -> str: + return "".join(ch for ch in value if ch not in '<>:"/\\|?*').strip().rstrip(".") or "Unknown" + + +def format_destination(config: dict, media: dict, drive: dict) -> Path: + lib = config["library"] + title = safe_name(media["title"]) + year = media.get("year") or "Unknown Year" + if media["type"] == "episode": + folder_tpl = lib["series_folder"] + file_tpl = lib["episode_file"] + elif media["type"] == "season": + folder_tpl = lib["series_folder"] + file_tpl = "{title} - Season {season:02d}{quality}{ext}" + else: + folder_tpl = lib["movie_folder"] if media.get("year") else lib["unknown_folder"] + file_tpl = lib["movie_file"] + values = { + **media, + "title": title, + "year": year, + "season": media.get("season") or 1, + "episode": media.get("episode") or 1, + "episode_title": safe_name(media.get("episode_title") or "Episode"), + "ext": media["extension"], + } + folder = folder_tpl.format(**values) + filename = file_tpl.format(**values) + return Path(drive["path"]) / folder / filename + + +def ensure_directory(path: Path, config: dict) -> None: + path.mkdir(parents=True, exist_ok=True) + mode = int(str(config["library"].get("directory_mode", "775")), 8) + current = path + stop = Path(config["paths"].get("downloads", "/downloads")) + try: + current.relative_to(stop) + return + except ValueError: + pass + while current != current.parent: + try: + os.chmod(current, mode) + except OSError: + pass + if any(str(current) == str(Path(drive["path"])) for drive in config.get("drives", [])): + break + current = current.parent + + +def language_suffix(path: Path) -> str: + lowered = path.stem.lower().replace(".", " ").replace("_", " ") + for token, code in LANGUAGE_HINTS.items(): + if token in lowered.split(): + return f".{code}" + return "" + + +def unique_planned_path(path: Path, rule: str, reserved: set[str]) -> Path | None: + candidate = collision_path(path, rule) + if not candidate: + return None + if str(candidate) not in reserved: + reserved.add(str(candidate)) + return candidate + stem, suffix = candidate.stem, candidate.suffix + for idx in range(2, 1000): + numbered = candidate.with_name(f"{stem}.{idx}{suffix}") + if not numbered.exists() and str(numbered) not in reserved: + reserved.add(str(numbered)) + return numbered + raise RuntimeError(f"Could not find collision-free name for {path}") + + +def tmdb_episode_title(metadata: dict, season: int | None, episode: int | None) -> str | None: + if not season or not episode: + return None + season_data = metadata.get("seasons", {}).get(str(season), {}) + for item in season_data.get("episodes", []): + if item.get("episode") == episode and item.get("title"): + return item["title"] + return None + + +def plan_id(source: str) -> str: + return hashlib.sha256(source.encode()).hexdigest()[:16] + + +def quality_score(media: dict) -> int: + quality = media.get("quality", "").lower() + if "2160" in quality: + return 4 + if "1080" in quality: + return 3 + if "720" in quality: + return 2 + if "480" in quality: + return 1 + return 0 + + +def confidence(config: dict, media: dict, metadata_enabled: bool = True) -> tuple[int, list[str], dict]: + score = 20 + reasons = [] + metadata = {"source": "filename", "title": media["title"]} + if media["title"] != "Unknown" and len(media["title"]) > 2: + score += 20 + reasons.append("title parsed") + if media["type"] == "episode" and media.get("season") and media.get("episode"): + score += 35 + reasons.append("season and episode parsed") + if media["type"] == "movie" and media.get("year"): + score += 25 + reasons.append("year parsed") + if media.get("quality"): + score += 5 + reasons.append("quality parsed") + if metadata_enabled and tmdb_available(config): + if media["type"] == "movie": + metadata = movie_metadata(config, media["title"], media.get("year")) + elif media["type"] == "episode": + metadata = series_metadata(config, media["title"], {media.get("season") or 1}) + if metadata.get("source") == "tmdb": + score += 20 + reasons.append("TMDb match") + elif tmdb_available(config): + reasons.append("metadata deferred") + return min(score, 100), reasons, metadata + + +def plan_bundle(config: dict, bundle: dict, metadata_enabled: bool = True) -> dict: + media_file = Path(bundle["media"]["path"]) + media = parse_media(str(media_file)) + score, reasons, metadata = confidence(config, media, metadata_enabled) + drive = choose_drive(config, metadata.get("title") or media["title"]) + if metadata.get("source") == "tmdb": + media["title"] = metadata.get("title") or media["title"] + if media["type"] == "movie" and metadata.get("release_date") and not media.get("year"): + media["year"] = int(metadata["release_date"][:4]) + if media["type"] == "episode": + media["episode_title"] = tmdb_episode_title(metadata, media.get("season"), media.get("episode")) or media.get("episode_title") or "Episode" + dest = format_destination(config, media, drive) + final = collision_path(dest, config["library"].get("collision", "keep-both")) + subtitle_moves = [] + if final: + reserved = {str(final)} + for subtitle in bundle.get("subtitles", []): + subtitle_path = Path(subtitle["path"]) + suffix = language_suffix(subtitle_path) + if not suffix: + suffix = ".und" + values = { + "basename": final.stem, + "language": suffix, + "ext": subtitle_path.suffix.lower(), + } + subtitle_name = config["library"].get("subtitle_file", "{basename}{language}{ext}").format(**values) + subtitle_dest = final.with_name(safe_name(Path(subtitle_name).stem) + subtitle_path.suffix.lower()) + subtitle_final = unique_planned_path(subtitle_dest, config["library"].get("collision", "keep-both"), reserved) + subtitle_moves.append({ + "source": str(subtitle_path), + "destination": str(subtitle_final) if subtitle_final else None, + "language": suffix.lstrip(".") or None, + }) + auto_threshold = int(config["app"].get("auto_move_min_confidence", 90)) + review_threshold = int(config["app"].get("review_min_confidence", 60)) + if not final: + status = "skipped" + elif score >= auto_threshold: + status = "ready" + elif score >= review_threshold: + status = "needs-review" + else: + status = "low-confidence" + return { + "id": plan_id(str(media_file)), + "source": str(media_file), + "destination": str(final) if final else None, + "media": media, + "metadata": metadata, + "drive": drive["id"], + "confidence": score, + "reasons": reasons, + "status": status, + "subtitles": subtitle_moves, + "sidecars": bundle.get("sidecars", []), + "updated_at": time.time(), + } + + +def collision_path(path: Path, rule: str) -> Path | None: + if not path.exists(): + return path + if rule == "skip": + return None + if rule == "replace": + return path + stem, suffix = path.stem, path.suffix + for idx in range(2, 1000): + candidate = path.with_name(f"{stem} ({idx}){suffix}") + if not candidate.exists(): + return candidate + raise RuntimeError(f"Could not find collision-free name for {path}") + + +def write_nfo(path: Path, media: dict) -> None: + nfo = path.with_suffix(".nfo") + root = ET.Element("movie" if media["type"] == "movie" else "episodedetails") + ET.SubElement(root, "title").text = str(media["title"]) + if media.get("year"): + ET.SubElement(root, "year").text = str(media["year"]) + if media.get("season"): + ET.SubElement(root, "season").text = str(media["season"]) + if media.get("episode"): + ET.SubElement(root, "episode").text = str(media["episode"]) + tree = ET.ElementTree(root) + ET.indent(tree, space=" ") + tree.write(nfo, encoding="unicode", xml_declaration=False) + nfo.write_text(nfo.read_text() + "\n") + + +def plan_file(config: dict, source: Path) -> dict: + media = parse_media(str(source)) + drive = choose_drive(config, media["title"]) + dest = format_destination(config, media, drive) + final = collision_path(dest, config["library"].get("collision", "keep-both")) + return { + "source": str(source), + "destination": str(final) if final else None, + "media": media, + "drive": drive["id"], + "action": "skip" if final is None else ("dry-run" if config["app"].get("dry_run") else "move"), + } + + +def execute_plan(config: dict, plan: dict) -> dict: + if not plan.get("destination") or plan["action"] == "skip": + return {**plan, "status": "skipped"} + source = Path(plan["source"]) + destination = Path(plan["destination"]) + if config["app"].get("dry_run"): + return {**plan, "status": "planned"} + + ensure_directory(destination.parent, config) + tmp = destination.with_suffix(destination.suffix + ".sorting") + if tmp.exists(): + tmp.unlink() + shutil.move(str(source), str(tmp)) + tmp.replace(destination) + mode = int(str(config["library"].get("permissions_mode", "664")), 8) + os.chmod(destination, mode) + if config.get("metadata", {}).get("write_nfo", True): + write_nfo(destination, plan["media"]) + LOG.info("Moved %s to %s", source, destination) + return {**plan, "status": "moved", "completed_at": time.time()} + + +def execute_bundle_plan(config: dict, plan: dict, force: bool = False) -> dict: + if not plan.get("destination") or (plan["status"] in {"skipped", "low-confidence"} and not force): + return {**plan, "result": "held"} + if plan["status"] == "needs-review" and not force: + return {**plan, "result": "held"} + if config["app"].get("dry_run"): + return {**plan, "result": "dry-run"} + + source = Path(plan["source"]) + destination = Path(plan["destination"]) + ensure_directory(destination.parent, config) + tmp = destination.with_suffix(destination.suffix + ".sorting") + if tmp.exists(): + tmp.unlink() + shutil.move(str(source), str(tmp)) + tmp.replace(destination) + mode = int(str(config["library"].get("permissions_mode", "664")), 8) + os.chmod(destination, mode) + for subtitle in plan.get("subtitles", []): + subtitle_source = Path(subtitle["source"]) + if not subtitle_source.exists() or not subtitle.get("destination"): + continue + subtitle_dest = Path(subtitle["destination"]) + ensure_directory(subtitle_dest.parent, config) + shutil.move(str(subtitle_source), str(subtitle_dest)) + os.chmod(subtitle_dest, mode) + if config.get("metadata", {}).get("write_nfo", True): + write_nfo(destination, plan["media"]) + return {**plan, "status": "moved", "result": "moved", "completed_at": time.time()} diff --git a/backend/sortarr/parser.py b/backend/sortarr/parser.py new file mode 100644 index 0000000..3473535 --- /dev/null +++ b/backend/sortarr/parser.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import re +from pathlib import Path + +QUALITY_RE = re.compile(r"\b(2160p|1080p|720p|480p|remux|bluray|web[- .]?dl|webrip|hdtv|dvdrip)\b", re.I) +YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b") +EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})(?:[ ._-]*[Ee](\d{1,3}))?") +ALT_EPISODE_RE = re.compile(r"\b(\d{1,2})x(\d{1,3})(?:[ ._-]*(\d{1,2})x(\d{1,3}))?\b") +SEASON_RE = re.compile(r"\b[Ss](?:eason)?[ ._-]*(\d{1,2})\b") +BRACKET_RE = re.compile(r"[\[(][^\])]*(?:\]|\))") +AUDIO_RE = re.compile(r"\b(?:aac|aac\d(?:[ ._-]?\d)?|ac3|eac3|ddp(?:\d(?:[ ._-]?\d)?)?|dts|truehd|atmos|flac|mp3|opus|5[ ._-]?1|7[ ._-]?1|2[ ._-]?0|6ch|2ch)\b", re.I) +CODEC_RE = re.compile(r"\b(?:x264|x265|h[ ._-]?264|h[ ._-]?265|hevc|avc|av1|10bit|8bit|hdr|hdr10|dv|dolby[ ._-]?vision)\b", re.I) +EDITION_RE = re.compile(r"\b(?:proper|repack|rerip|extended|unrated|directors?[ ._-]?cut|theatrical|imax|multi|line|dubbed|subbed)\b", re.I) +RELEASE_GROUP_RE = re.compile(r"(?:^|[ ._-])(?:YTS|TGx|EZTVx?|MeGusta|PSA|RARBG|NTb|AMZN|DSNP|PMNTP|FLUX|SuccessfulCrab|GalaxyTV)\b", re.I) +TRAILING_GROUP_RE = re.compile(r"(?:[ ._-]+-[ ._-]*[A-Za-z0-9][A-Za-z0-9._-]{1,24})$") + + +def spaced(raw: str) -> str: + text = raw.replace("&", " and ") + text = re.sub(r"[\._]+", " ", text) + text = re.sub(r"\s+", " ", text) + return text.strip(" -._") + + +def strip_brackets(raw: str) -> str: + return BRACKET_RE.sub(" ", raw) + + +def strip_release_tail(raw: str) -> str: + text = strip_brackets(raw) + text = TRAILING_GROUP_RE.sub("", text) + text = RELEASE_GROUP_RE.sub(" ", text) + return spaced(text) + + +def first_noise_index(text: str) -> int | None: + matches = [ + match.start() + for pattern in (QUALITY_RE, AUDIO_RE, CODEC_RE, EDITION_RE, RELEASE_GROUP_RE) + for match in [pattern.search(text)] + if match + ] + return min(matches) if matches else None + + +def trim_noise(raw: str) -> str: + text = strip_release_tail(raw) + idx = first_noise_index(text) + if idx is not None: + text = text[:idx] + return spaced(text) + + +def clean_title(raw: str) -> str: + text = trim_noise(raw) + text = YEAR_RE.sub(" ", text) + text = EPISODE_RE.sub(" ", text) + text = ALT_EPISODE_RE.sub(" ", text) + text = SEASON_RE.sub(" ", text) + return spaced(text) or "Unknown" + + +def clean_episode_title(raw: str) -> str: + text = trim_noise(raw) + text = YEAR_RE.sub(" ", text) + return spaced(text) or "Episode" + + +def parent_candidate(path: Path) -> str: + parent = path.parent + if parent.name.lower() in {"subs", "subtitles", "sub"}: + parent = parent.parent + name = parent.name + if not name or name in {".", "/"}: + return "" + return name + + +def movie_title_source(path: Path, stem: str) -> str: + parent = parent_candidate(path) + if YEAR_RE.search(parent): + return parent + if YEAR_RE.search(stem): + return stem + if parent and first_noise_index(parent) is None and not EPISODE_RE.search(parent): + return parent + return stem + + +def parse_media(path: str) -> dict: + p = Path(path) + stem = p.stem + quality_match = QUALITY_RE.search(stem) or QUALITY_RE.search(parent_candidate(p)) + year_source = stem if YEAR_RE.search(stem) else parent_candidate(p) + year_match = YEAR_RE.search(year_source) + episode_match = EPISODE_RE.search(stem) + alt_match = ALT_EPISODE_RE.search(stem) + season_match = SEASON_RE.search(stem) + + media_type = "movie" + season = None + episode = None + multi_episode = "" + episode_title = "" + + if episode_match: + media_type = "episode" + season = int(episode_match.group(1)) + episode = int(episode_match.group(2)) + if episode_match.group(3): + multi_episode = f"-E{int(episode_match.group(3)):02d}" + title = clean_title(stem[:episode_match.start()]) + episode_title = clean_episode_title(stem[episode_match.end():]) + elif alt_match: + media_type = "episode" + season = int(alt_match.group(1)) + episode = int(alt_match.group(2)) + if alt_match.group(4): + multi_episode = f"-E{int(alt_match.group(4)):02d}" + title = clean_title(stem[:alt_match.start()]) + episode_title = clean_episode_title(stem[alt_match.end():]) + elif season_match: + media_type = "season" + season = int(season_match.group(1)) + title = clean_title(stem[:season_match.start()] or parent_candidate(p) or stem) + else: + title = clean_title(movie_title_source(p, stem)) + + return { + "source": str(p), + "title": title, + "year": int(year_match.group(1)) if year_match else None, + "quality": f" - {quality_match.group(1).replace('.', ' ')}" if quality_match else "", + "type": media_type, + "season": season, + "episode": episode, + "multi_episode": multi_episode, + "episode_title": episode_title if media_type == "episode" else "", + "extension": p.suffix.lower(), + } diff --git a/backend/sortarr/releases.py b/backend/sortarr/releases.py new file mode 100644 index 0000000..24af6bf --- /dev/null +++ b/backend/sortarr/releases.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +import xml.etree.ElementTree as ET +from urllib.request import urlopen + + +def library_releases(library: dict | None) -> list[dict]: + releases = [] + for show in ((library or {}).get("collections") or {}).get("series", []): + for season in show.get("seasons", []): + for episode in season.get("episodes", []): + if episode.get("status") not in {"missing", "upcoming"}: + continue + releases.append({ + "provider": "Library", + "title": show.get("metadata", {}).get("title") or show.get("title"), + "episode_title": episode.get("title"), + "season": episode.get("season"), + "episode": episode.get("episode"), + "date": episode.get("air_date"), + "type": "tv", + "status": episode.get("status"), + "poster": show.get("metadata", {}).get("poster"), + "library_key": show.get("key"), + }) + return sorted(releases, key=lambda item: (item.get("date") or "9999-99-99", item.get("title") or "")) + + +def fetch_releases(config: dict, library: dict | None = None) -> list[dict]: + releases: list[dict] = library_releases(library) + for provider in config.get("release_providers", []): + if not provider.get("enabled", True): + continue + try: + with urlopen(provider["url"], timeout=8) as response: + body = response.read() + if provider.get("type") == "json": + data = json.loads(body.decode()) + for item in data[:30] if isinstance(data, list) else []: + show = item.get("show", item) + releases.append({ + "provider": provider["name"], + "title": show.get("name"), + "date": item.get("airdate") or item.get("premiered"), + "type": "tv", + }) + else: + root = ET.fromstring(body) + for item in root.findall(".//item")[:30]: + releases.append({ + "provider": provider["name"], + "title": (item.findtext("title") or "").strip(), + "date": (item.findtext("pubDate") or "").strip(), + "type": "movie", + }) + except Exception as exc: + releases.append({"provider": provider.get("name"), "error": str(exc)}) + return releases diff --git a/backend/sortarr/scanner.py b/backend/sortarr/scanner.py new file mode 100644 index 0000000..5f40e02 --- /dev/null +++ b/backend/sortarr/scanner.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import logging +import threading +import time +from pathlib import Path + +from .downloads import downloads_snapshot +from .organizer import execute_bundle_plan, plan_bundle + +LOG = logging.getLogger(__name__) + + +class Scanner(threading.Thread): + def __init__(self, config: dict, store): + super().__init__(daemon=True) + self.config = config + self.store = store + self.stop_event = threading.Event() + self.scan_lock = threading.Lock() + self.seen_sizes: dict[str, tuple[int, int, int]] = {} + + def stop(self) -> None: + self.stop_event.set() + + def is_candidate(self, path: Path) -> bool: + app = self.config["app"] + if not path.is_file(): + return False + if path.suffix.lower() in app.get("incomplete_suffixes", []): + return False + lowered = path.name.lower() + for keyword in app.get("extra_keywords", []): + if keyword and keyword.lower() in lowered: + return False + return path.suffix.lower() in set(app.get("media_extensions", [])) + + def is_stable(self, path: Path) -> bool: + stat = path.stat() + signature = (stat.st_size, int(stat.st_mtime)) + previous = self.seen_sizes.get(str(path)) + checks = previous[2] + 1 if previous and previous[:2] == signature else 1 + current = (*signature, checks) + self.seen_sizes[str(path)] = current + age = time.time() - stat.st_mtime + required_checks = max(1, int(self.config["app"].get("stable_checks", 2))) + return checks >= required_checks and age >= int(self.config["app"].get("settle_seconds", 90)) + + def scan_once(self) -> list[dict]: + if not self.scan_lock.acquire(blocking=False): + return self.store.snapshot().get("organizer", {}).get("queue", []) + try: + return self._scan_once() + finally: + self.scan_lock.release() + + def request_scan(self) -> bool: + if self.scan_lock.locked(): + return False + thread = threading.Thread(target=self.scan_once, daemon=True) + thread.start() + return True + + def _scan_once(self) -> list[dict]: + downloads = Path(self.config["paths"]["downloads"]) + downloads.mkdir(parents=True, exist_ok=True) + plans: list[dict] = [] + state = self.store.snapshot() + previous_items = {item.get("source"): item for item in state.get("items", [])} + snapshot = downloads_snapshot(self.config, state) + metadata_budget = int(self.config["app"].get("organization_metadata_budget_seconds", 25)) + metadata_deadline = time.time() + metadata_budget + for bundle in snapshot.get("bundles", []): + path = Path(bundle["media"]["path"]) + if not self.is_candidate(path) or not self.is_stable(path): + continue + try: + plan = plan_bundle(self.config, bundle, metadata_enabled=time.time() < metadata_deadline) + result = execute_bundle_plan(self.config, plan) + plans.append(result) + self.store.set_organizer_queue(plans) + item = { + "source": str(path), + "destination": result.get("destination"), + "title": result["media"]["title"], + "type": result["media"]["type"], + "status": result.get("result") or result["status"], + "drive": result.get("drive"), + "confidence": result.get("confidence"), + "updated_at": time.time(), + } + self.store.upsert_item(item) + previous = previous_items.get(str(path), {}) + if ( + previous.get("destination") != item.get("destination") + or previous.get("status") != item.get("status") + or previous.get("confidence") != item.get("confidence") + ): + self.store.add_event("info", f"{item['status']}: {path.name}", path=str(path), confidence=item.get("confidence")) + except Exception as exc: + LOG.exception("Failed to organize %s", path) + self.store.add_event("error", str(exc), path=str(path)) + self.store.set_plans(plans) + self.store.set_organizer_queue(plans) + return plans + + def run(self) -> None: + while not self.stop_event.is_set(): + self.scan_once() + interval = int(self.config["app"].get("scan_interval_seconds", 20)) + self.stop_event.wait(interval) diff --git a/backend/sortarr/storage.py b/backend/sortarr/storage.py new file mode 100644 index 0000000..e93ecf2 --- /dev/null +++ b/backend/sortarr/storage.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def disk_usage(path: str) -> dict: + usage = os.statvfs(path) + total = usage.f_frsize * usage.f_blocks + free = usage.f_frsize * usage.f_bavail + used = total - free + return {"total": total, "used": used, "free": free} + + +def drive_stats(config: dict) -> list[dict]: + stats = [] + for drive in config.get("drives", []): + path = Path(drive["path"]) + path.mkdir(parents=True, exist_ok=True) + usage = disk_usage(str(path)) + stats.append({**drive, **usage}) + return stats + + +def find_existing_home(config: dict, title: str) -> str | None: + normalized = title.lower() + for drive in config.get("drives", []): + root = Path(drive["path"]) + for folder in ("Movies", "Shows"): + base = root / folder + if not base.exists(): + continue + for child in base.iterdir(): + if child.is_dir() and child.name.lower().startswith(normalized): + return str(root) + return None + + +def choose_drive(config: dict, title: str) -> dict: + existing = find_existing_home(config, title) + if existing: + for drive in config.get("drives", []): + if drive["path"] == existing: + return drive + candidates = [] + for drive in drive_stats(config): + min_free = int(drive.get("min_free_gb", 0)) * 1024**3 + if drive["free"] >= min_free: + candidates.append(drive) + if not candidates: + raise RuntimeError("No media drive has the configured minimum free space") + return max(candidates, key=lambda d: d["free"]) + diff --git a/backend/sortarr/store.py b/backend/sortarr/store.py new file mode 100644 index 0000000..78d7c17 --- /dev/null +++ b/backend/sortarr/store.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import threading +import time +import uuid +from pathlib import Path +from typing import Any + + +class JsonStore: + def __init__(self, data_dir: str): + self.path = Path(data_dir) / "state.json" + self.lock = threading.RLock() + self.state: dict[str, Any] = { + "events": [], + "items": [], + "plans": [], + "organizer": {"queue": [], "updated_at": None}, + "library": None, + "settings": {}, + "updated_at": time.time(), + } + self.load() + + def load(self) -> None: + with self.lock: + if self.path.exists(): + try: + self.state.update(json.loads(self.path.read_text())) + except json.JSONDecodeError: + backup = self.path.with_suffix(f".corrupt-{int(time.time())}.json") + self.path.replace(backup) + self.state.setdefault("events", []).insert(0, { + "time": time.time(), + "level": "error", + "message": f"Recovered from corrupt state file: {backup.name}", + }) + + def save(self) -> None: + with self.lock: + self.state["updated_at"] = time.time() + tmp = self.path.with_name(f"{self.path.name}.{uuid.uuid4().hex}.tmp") + tmp.write_text(json.dumps(self.state, indent=2, sort_keys=True)) + tmp.replace(self.path) + + def add_event(self, level: str, message: str, **fields: Any) -> None: + with self.lock: + event = {"time": time.time(), "level": level, "message": message, **fields} + self.state.setdefault("events", []).insert(0, event) + self.state["events"] = self.state["events"][:500] + self.save() + + def upsert_item(self, item: dict[str, Any]) -> None: + with self.lock: + items = self.state.setdefault("items", []) + key = item.get("destination") or item.get("source") + for idx, existing in enumerate(items): + if (existing.get("destination") or existing.get("source")) == key: + items[idx] = {**existing, **item} + break + else: + items.append(item) + self.save() + + def set_plans(self, plans: list[dict[str, Any]]) -> None: + with self.lock: + self.state["plans"] = plans[:200] + self.save() + + def set_organizer_queue(self, queue: list[dict[str, Any]]) -> None: + with self.lock: + self.state["organizer"] = {"queue": queue[:500], "updated_at": time.time()} + self.save() + + def set_library(self, library: dict[str, Any]) -> None: + with self.lock: + self.state["library"] = library + self.save() + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return json.loads(json.dumps(self.state)) diff --git a/backend/sortarr/tools.py b/backend/sortarr/tools.py new file mode 100644 index 0000000..cfb97a9 --- /dev/null +++ b/backend/sortarr/tools.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import shutil +import subprocess +import time +from pathlib import Path + + +def duplicate_finder(config: dict, library: dict | None) -> dict: + duplicates = [] + collections = (library or {}).get("collections") or {} + for collection in list(collections.get("movies", [])) + list(collections.get("series", [])): + files = collection.get("files") or [] + if len(files) < 2: + continue + total_size = sum(int(item.get("size") or 0) for item in files) + duplicates.append({ + "key": collection.get("key"), + "title": collection.get("metadata", {}).get("title") or collection.get("title"), + "library": collection.get("library"), + "count": len(files), + "total_size": total_size, + "files": sorted(files, key=lambda item: (item.get("size") or 0), reverse=True)[:20], + }) + return { + "count": len(duplicates), + "duplicates": sorted(duplicates, key=lambda item: item["total_size"], reverse=True)[:100], + "generated_at": time.time(), + } + + +def subtitle_audit(config: dict, library: dict | None) -> dict: + media_extensions = set(config["app"].get("media_extensions", [])) + subtitle_extensions = config["app"].get("subtitle_extensions", []) + missing = [] + present = 0 + unknown = 0 + for item in (library or {}).get("items", []): + path = Path(item["path"]) + if path.suffix.lower() not in media_extensions: + continue + if item.get("has_subtitles") is True: + present += 1 + elif "has_subtitles" not in item: + unknown += 1 + else: + missing.append({ + "name": item["name"], + "path": str(path), + "drive": item.get("drive"), + "expected": [f"{path.stem}{ext}" for ext in subtitle_extensions[:3]], + }) + return { + "checked": present + len(missing) + unknown, + "with_subtitles": present, + "unknown_count": unknown, + "missing_count": len(missing), + "missing": missing[:500], + "generated_at": time.time(), + } + + +def transcode_plan(config: dict, library: dict | None) -> dict: + targets = [] + for item in (library or {}).get("items", []): + path = Path(item["path"]) + if path.suffix.lower() == ".mp4": + continue + output = path.with_suffix(".mp4") + command = [ + "ffmpeg", + "-hide_banner", + "-y", + "-i", + str(path), + "-map", + "0", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "20", + "-c:a", + "aac", + "-c:s", + "mov_text", + str(output), + ] + targets.append({ + "name": item["name"], + "source": str(path), + "output": str(output), + "drive": item.get("drive"), + "command": command, + }) + return { + "ffmpeg_available": shutil.which("ffmpeg") is not None, + "count": len(targets), + "targets": targets[:100], + "generated_at": time.time(), + } + + +def run_next_transcode(config: dict, library: dict | None) -> dict: + plan = transcode_plan(config, library) + if not plan["targets"]: + return {**plan, "status": "empty"} + if not plan["ffmpeg_available"]: + return {**plan, "status": "ffmpeg-unavailable"} + if config["app"].get("dry_run"): + return {**plan, "status": "dry-run"} + target = plan["targets"][0] + completed = subprocess.run(target["command"], capture_output=True, text=True, timeout=60 * 60) + return { + **plan, + "status": "completed" if completed.returncode == 0 else "failed", + "ran": target, + "returncode": completed.returncode, + "stderr": completed.stderr[-4000:], + } diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 0000000..4ef93ba --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,10 @@ +services: + backend: + environment: + - SORTARR_LOG_LEVEL=DEBUG + volumes: + - ./backend/sortarr:/app/sortarr + web: + volumes: + - ./web/src:/usr/share/nginx/html:ro + diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..92e8aa6 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,9 @@ +services: + web: + restart: unless-stopped + backend: + restart: unless-stopped + environment: + - SORTARR_LOG_LEVEL=${SORTARR_LOG_LEVEL:-INFO} + - SORTARR_DRY_RUN=${SORTARR_DRY_RUN:-false} + diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..b50bc11 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,92 @@ +services: + web: + build: + context: ./web + container_name: sortarr-web + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${SORTARR_WEB_PORT:-8088}:80" + volumes: + - ./web/src:/usr/share/nginx/html:ro + - ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro + environment: + - TZ=${SORTARR_TZ:-Etc/UTC} + + backend: + build: + context: ./backend + container_name: sortarr-backend + init: true + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-m", "sortarr.healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + ports: + - "${SORTARR_API_PORT:-8099}:8099" + volumes: + - ${DOWNLOADS_PATH:-./downloads}:/downloads + - ${CONFIG_PATH:-./config}:/config + - ${LOGS_PATH:-./logs}:/logs + - ${DATA_PATH:-./data}:/data + - ${DRIVE1_PATH:-./media/drive1}:/media/drive1 + - ${DRIVE2_PATH:-./media/drive2}:/media/drive2 + - ${DRIVE3_PATH:-./media/drive3}:/media/drive3 + - ${DRIVE4_PATH:-./media/drive4}:/media/drive4 + environment: + - TZ=${SORTARR_TZ:-Etc/UTC} + - SORTARR_HOST=${SORTARR_HOST:-0.0.0.0} + - SORTARR_API_PORT=8099 + - SORTARR_CONFIG=/config/app.toml + - SORTARR_DEFAULT_CONFIG=/app/default-config/app.toml + - SORTARR_DATA_DIR=/data + - SORTARR_LOG_DIR=/logs + - SORTARR_CACHE_DIR=/data/cache + - SORTARR_DRY_RUN=${SORTARR_DRY_RUN:-true} + - SORTARR_LOG_LEVEL=${SORTARR_LOG_LEVEL:-INFO} + - SORTARR_SCAN_INTERVAL_SECONDS=${SORTARR_SCAN_INTERVAL_SECONDS:-20} + - SORTARR_SETTLE_SECONDS=${SORTARR_SETTLE_SECONDS:-90} + - SORTARR_MIN_FREE_GB=${SORTARR_MIN_FREE_GB:-20} + - TMDB_API_KEY=${TMDB_API_KEY:-} + - TMDB_BEARER_TOKEN=${TMDB_BEARER_TOKEN:-} + + redis: + image: redis:7-alpine + container_name: sortarr-redis + profiles: ["cache"] + restart: unless-stopped + volumes: + - sortarr-redis:/data + + postgres: + image: postgres:16-alpine + container_name: sortarr-postgres + profiles: ["database"] + restart: unless-stopped + environment: + POSTGRES_DB: sortarr + POSTGRES_USER: sortarr + POSTGRES_PASSWORD: sortarr + volumes: + - sortarr-postgres:/var/lib/postgresql/data + + media-tools: + image: lscr.io/linuxserver/ffmpeg:latest + container_name: sortarr-media-tools + profiles: ["tools"] + command: ["sleep", "infinity"] + volumes: + - ${DOWNLOADS_PATH:-./downloads}:/downloads + - ${DRIVE1_PATH:-./media/drive1}:/media/drive1 + - ${DRIVE2_PATH:-./media/drive2}:/media/drive2 + - ${DRIVE3_PATH:-./media/drive3}:/media/drive3 + - ${DRIVE4_PATH:-./media/drive4}:/media/drive4 + +volumes: + sortarr-redis: + sortarr-postgres: diff --git a/config/app.toml b/config/app.toml new file mode 100644 index 0000000..4427631 --- /dev/null +++ b/config/app.toml @@ -0,0 +1,19 @@ +# Host-editable Sortarr configuration. Values here override backend/default-config/app.toml. +# Environment variables in .env override common runtime values such as dry-run and intervals. + +[app] +dry_run = true +scan_interval_seconds = 20 +settle_seconds = 90 +log_level = "INFO" +library_scan_max_files = 20000 +library_scan_timeout_seconds = 8 + +[theme] +default = "slate" +allow_custom_css = true +custom_css_path = "/config/custom-theme.css" + +[metadata] +tmdb_enabled = true +tmdb_language = "en-US" diff --git a/config/custom-theme.css b/config/custom-theme.css new file mode 100644 index 0000000..0798da1 --- /dev/null +++ b/config/custom-theme.css @@ -0,0 +1,6 @@ +/* Optional host-editable theme overrides. Loaded by the dashboard when enabled. */ +:root { + /* --bg: #0f1115; */ + /* --accent: #5cc8ff; */ +} + diff --git a/dist/sortarr.zip b/dist/sortarr.zip new file mode 100644 index 0000000000000000000000000000000000000000..09f649bec2624ffaf82e7c5a2731429aa0305268 GIT binary patch literal 60852 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 dict: + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + deep_merge(base[key], value) + else: + base[key] = value + return base + + +def coerce_value(value, caster): + if caster is bool: + return bool(value) + if caster is int: + return int(value) + if caster is list: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [item.strip() for item in str(value).split(",") if item.strip()] + return caster(value) + + +def apply_settings(config: dict, settings: dict) -> dict: + if any(key in SETTINGS_SCHEMA["app"] for key in settings): + settings = {"app": settings} + applied = {} + for section, fields in SETTINGS_SCHEMA.items(): + values = settings.get(section) + if not isinstance(values, dict): + continue + target = config.setdefault(section, {}) + applied_section = applied.setdefault(section, {}) + for key, caster in fields.items(): + if key not in values: + continue + target[key] = coerce_value(values[key], caster) + applied_section[key] = target[key] + if not applied_section: + applied.pop(section, None) + + if isinstance(settings.get("drives"), list): + drives = [] + for idx, drive in enumerate(settings["drives"]): + if not isinstance(drive, dict): + continue + existing = (config.get("drives") or [{}] * (idx + 1))[idx] if idx < len(config.get("drives", [])) else {} + drives.append({ + "id": str(drive.get("id", existing.get("id", f"drive{idx + 1}"))), + "name": str(drive.get("name", existing.get("name", f"Media Drive {idx + 1}"))), + "path": str(drive.get("path", existing.get("path", ""))), + "min_free_gb": int(drive.get("min_free_gb", existing.get("min_free_gb", 20))), + }) + config["drives"] = drives + applied["drives"] = drives + + if isinstance(settings.get("release_providers"), list): + providers = [] + for provider in settings["release_providers"]: + if not isinstance(provider, dict): + continue + providers.append({ + "id": str(provider.get("id", "")), + "name": str(provider.get("name", "")), + "enabled": bool(provider.get("enabled", False)), + "type": str(provider.get("type", "rss")), + "url": str(provider.get("url", "")), + }) + config["release_providers"] = providers + applied["release_providers"] = providers + + return applied + + +CONFIG = load_config() +configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO")) +STORE = JsonStore(CONFIG["paths"]["data"]) +apply_settings(CONFIG, STORE.snapshot().get("settings", {})) +SCANNER = Scanner(CONFIG, STORE) + + +class Handler(BaseHTTPRequestHandler): + server_version = "Sortarr/0.1" + + def log_message(self, fmt: str, *args) -> None: + return + + def send_json(self, payload, status=HTTPStatus.OK) -> None: + body = json.dumps(payload, indent=2).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self) -> None: + parsed_url = urlparse(self.path) + path = parsed_url.path + try: + if path == "/api/health": + self.send_json({"ok": True}) + elif path == "/api/config": + self.send_json(public_config(CONFIG)) + elif path == "/api/dashboard": + snap = STORE.snapshot() + cached_library = snap.get("library") or { + "drives": drive_stats(CONFIG), + "items": [], + "counts": {"movies": 0, "tv": 0, "total": 0}, + "extensions": {}, + "scanned_files": 0, + "truncated": False, + "cached": False, + } + cached_library = normalize_library(cached_library) + cached_library.pop("items", None) + public_state = { + "events": snap.get("events", [])[:200], + "organizer": snap.get("organizer", {"queue": [], "updated_at": None}), + "settings": snap.get("settings", {}), + "updated_at": snap.get("updated_at"), + } + self.send_json({ + "state": public_state, + "library": cached_library, + "dry_run": CONFIG["app"].get("dry_run"), + }) + elif path == "/api/downloads": + self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())}) + elif path == "/api/releases": + self.send_json({"releases": fetch_releases(CONFIG, STORE.snapshot().get("library"))}) + elif path == "/api/media/probe": + params = parse_qs(parsed_url.query) + target = unquote((params.get("path") or [""])[0]) + self.send_json({"media": media_probe(CONFIG, target)}) + elif path == "/api/metadata/search": + params = parse_qs(parsed_url.query) + query = unquote((params.get("query") or [""])[0]) + kind = unquote((params.get("type") or ["movie"])[0]) + self.send_json({"results": search_tmdb(CONFIG, kind, query)}) + elif path == "/api/tools/subtitles": + self.send_json({"audit": subtitle_audit(CONFIG, STORE.snapshot().get("library"))}) + elif path == "/api/tools/transcoder": + self.send_json({"transcoder": transcode_plan(CONFIG, STORE.snapshot().get("library"))}) + elif path == "/api/theme/custom.css": + custom = CONFIG.get("theme", {}).get("custom_css_path") + if custom and CONFIG.get("theme", {}).get("allow_custom_css", True) and os.path.exists(custom): + body = open(custom, "rb").read() + self.send_response(200) + self.send_header("Content-Type", "text/css") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + except Exception as exc: + self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + + def do_POST(self) -> None: + path = urlparse(self.path).path + try: + if path == "/api/scan": + started = SCANNER.request_scan() + snap = STORE.snapshot() + self.send_json({ + "started": started, + "status": "started" if started else "already-running", + "queue": snap.get("organizer", {}).get("queue", []), + }, HTTPStatus.ACCEPTED) + elif path == "/api/organizer/approve": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + payload = json.loads(body) + plan_id = payload.get("id") + snap = STORE.snapshot() + queue = snap.get("organizer", {}).get("queue", []) + plan = next((item for item in queue if item.get("id") == plan_id), None) + if not plan: + self.send_json({"error": "plan not found"}, HTTPStatus.NOT_FOUND) + return + result = execute_bundle_plan(CONFIG, plan, force=True) + updated = [result if item.get("id") == plan_id else item for item in queue] + STORE.set_organizer_queue(updated) + STORE.add_event("info", f"approved organizer plan: {result.get('result')}", path=result.get("source"), confidence=result.get("confidence")) + self.send_json({"plan": result}) + elif path == "/api/organizer/skip": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + payload = json.loads(body) + plan_id = payload.get("id") + snap = STORE.snapshot() + queue = snap.get("organizer", {}).get("queue", []) + updated = [{**item, "status": "skipped", "result": "skipped"} if item.get("id") == plan_id else item for item in queue] + STORE.set_organizer_queue(updated) + self.send_json({"ok": True}) + elif path == "/api/library/identify": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + payload = json.loads(body) + key = payload.get("key") + tmdb_id = payload.get("tmdb_id") + kind = payload.get("type") + + snap = STORE.snapshot() + library = snap.get("library", {}) + collections = library.get("collections", {"movies": [], "series": []}) + + found_item = None + if kind == "movie": + for item in collections["movies"]: + if item["key"] == key: + found_item = identify_item(CONFIG, item, tmdb_id, kind) + break + else: + for item in collections["series"]: + if item["key"] == key: + found_item = identify_item(CONFIG, item, tmdb_id, "tv") + break + + if found_item: + STORE.set_library(library) + self.send_json({"ok": True, "item": found_item}) + else: + self.send_json({"error": "item not found"}, HTTPStatus.NOT_FOUND) + elif path == "/api/library/scan": + library = library_snapshot(CONFIG) + STORE.set_library(library) + self.send_json({"library": library}) + elif path == "/api/tools/transcoder/run-next": + result = run_next_transcode(CONFIG, STORE.snapshot().get("library")) + STORE.add_event("info", f"transcoder: {result.get('status')}") + self.send_json({"transcoder": result}) + elif path == "/api/metadata/tmdb/test": + self.send_json({"tmdb": test_tmdb(CONFIG)}) + elif path == "/api/media/tracks": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + payload = json.loads(body) + result = edit_track(CONFIG, payload.get("path", ""), payload.get("action", ""), int(payload.get("stream_index", -1))) + STORE.add_event("info", f"track edit: {result.get('status')}", path=payload.get("path", "")) + self.send_json({"media": result}) + elif path == "/api/settings": + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length).decode() if length else "{}" + updates = json.loads(body) + applied = apply_settings(CONFIG, updates) + snap = STORE.snapshot() + settings = snap.get("settings", {}) + deep_merge(settings, applied) + STORE.state["settings"] = settings + STORE.save() + self.send_json({"settings": applied, "config": public_config(CONFIG)}) + else: + self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) + except Exception as exc: + self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) + + +def main() -> None: + SCANNER.start() + host = os.getenv("SORTARR_HOST", "0.0.0.0") + port = int(os.getenv("SORTARR_API_PORT", "8099")) + ThreadingHTTPServer((host, port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/dist/sortarr/backend/sortarr/cache.py b/dist/sortarr/backend/sortarr/cache.py new file mode 100644 index 0000000..7fc5794 --- /dev/null +++ b/dist/sortarr/backend/sortarr/cache.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import hashlib +import json +import os +import time +from pathlib import Path +from typing import Any + + +def cache_root(config: dict) -> Path: + root = Path(config.get("paths", {}).get("cache") or Path(config["paths"]["data"]) / "cache") + root.mkdir(parents=True, exist_ok=True) + return root + + +def cache_path(config: dict, namespace: str, key: str) -> Path: + digest = hashlib.sha256(key.encode()).hexdigest() + path = cache_root(config) / namespace / f"{digest}.json" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def get_json(config: dict, namespace: str, key: str, ttl_seconds: int | None = None) -> Any | None: + path = cache_path(config, namespace, key) + if not path.exists(): + return None + if ttl_seconds is not None and time.time() - path.stat().st_mtime > ttl_seconds: + return None + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + + +def set_json(config: dict, namespace: str, key: str, value: Any) -> None: + path = cache_path(config, namespace, key) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(value, sort_keys=True)) + tmp.replace(path) + prune(config) + + +def remove_json(config: dict, namespace: str, key: str) -> None: + path = cache_path(config, namespace, key) + try: + path.unlink() + except FileNotFoundError: + return + + +def prune(config: dict) -> None: + root = cache_root(config) + max_bytes = int(config.get("app", {}).get("cache_max_bytes", 20 * 1024**3)) + files = [] + total = 0 + for current, _, names in os.walk(root): + for name in names: + path = Path(current) / name + try: + stat = path.stat() + except OSError: + continue + total += stat.st_size + files.append((stat.st_mtime, stat.st_size, path)) + if total <= max_bytes: + return + for _, size, path in sorted(files): + try: + path.unlink() + total -= size + except OSError: + continue + if total <= max_bytes: + break diff --git a/dist/sortarr/backend/sortarr/config.py b/dist/sortarr/backend/sortarr/config.py new file mode 100644 index 0000000..40bf1fb --- /dev/null +++ b/dist/sortarr/backend/sortarr/config.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import copy +import os +import tomllib +from pathlib import Path +from typing import Any + + +def _read_toml(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("rb") as handle: + return tomllib.load(handle) + + +def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + merged = copy.deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _merge(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def _bool(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def load_config() -> dict[str, Any]: + default_path = Path(os.getenv("SORTARR_DEFAULT_CONFIG", "/app/default-config/app.toml")) + user_path = Path(os.getenv("SORTARR_CONFIG", "/config/app.toml")) + config = _merge(_read_toml(default_path), _read_toml(user_path)) + + app = config.setdefault("app", {}) + paths = config.setdefault("paths", {}) + + env_map = { + "SORTARR_DRY_RUN": ("app", "dry_run", _bool), + "SORTARR_LOG_LEVEL": ("app", "log_level", str), + "SORTARR_SCAN_INTERVAL_SECONDS": ("app", "scan_interval_seconds", int), + "SORTARR_SETTLE_SECONDS": ("app", "settle_seconds", int), + "SORTARR_DATA_DIR": ("paths", "data", str), + "SORTARR_LOG_DIR": ("paths", "logs", str), + "SORTARR_CACHE_DIR": ("paths", "cache", str), + "TMDB_API_KEY": ("metadata", "tmdb_api_key", str), + "TMDB_BEARER_TOKEN": ("metadata", "tmdb_bearer_token", str), + } + for env, (section, key, caster) in env_map.items(): + if os.getenv(env) not in (None, ""): + config.setdefault(section, {})[key] = caster(os.environ[env]) + + if os.getenv("SORTARR_MIN_FREE_GB"): + for drive in config.get("drives", []): + drive["min_free_gb"] = int(os.environ["SORTARR_MIN_FREE_GB"]) + + Path(paths.get("data", "/data")).mkdir(parents=True, exist_ok=True) + Path(paths.get("logs", "/logs")).mkdir(parents=True, exist_ok=True) + Path(paths.get("cache", str(Path(paths.get("data", "/data")) / "cache"))).mkdir(parents=True, exist_ok=True) + app.setdefault("dry_run", True) + return config + + +def public_config(config: dict[str, Any]) -> dict[str, Any]: + clone = copy.deepcopy(config) + return clone diff --git a/dist/sortarr/backend/sortarr/downloads.py b/dist/sortarr/backend/sortarr/downloads.py new file mode 100644 index 0000000..e9feba7 --- /dev/null +++ b/dist/sortarr/backend/sortarr/downloads.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import time +from collections import defaultdict +from pathlib import Path + + +def empty_snapshot(root: Path, error: str | None = None) -> dict: + return { + "path": str(root), + "generated_at": time.time(), + "current": [], + "bundles": [], + "loose": [], + "recent": [], + "counts": { + "current": 0, + "recent": 0, + "media": 0, + "subtitles": 0, + "incomplete": 0, + }, + "total_size": 0, + "error": error, + } + + +def downloads_snapshot(config: dict, state: dict) -> dict: + root = Path(config["paths"]["downloads"]) + app = config.get("app", {}) + media_extensions = set(app.get("media_extensions", [])) + subtitle_extensions = set(app.get("subtitle_extensions", [])) + incomplete = set(app.get("incomplete_suffixes", [])) + current = [] + media_files = [] + subtitle_files = [] + total_size = 0 + + try: + root.mkdir(parents=True, exist_ok=True) + paths = root.rglob("*") + for path in paths: + if not path.is_file(): + continue + try: + stat = path.stat() + except OSError: + continue + suffix = path.suffix.lower() + total_size += stat.st_size + item = { + "name": path.name, + "path": str(path), + "relative_path": str(path.relative_to(root)), + "folder": str(path.parent.relative_to(root)) if path.parent != root else "", + "size": stat.st_size, + "modified": stat.st_mtime, + "extension": suffix or "none", + "is_media": suffix in media_extensions, + "is_subtitle": suffix in subtitle_extensions, + "is_incomplete": suffix in incomplete, + } + current.append(item) + if item["is_media"]: + media_files.append(item) + elif item["is_subtitle"]: + subtitle_files.append(item) + except OSError as exc: + return empty_snapshot(root, str(exc)) + + subtitles_by_folder = defaultdict(list) + for subtitle in subtitle_files: + subtitles_by_folder[subtitle["folder"]].append(subtitle) + parent = Path(subtitle["folder"]) + if parent.name.lower() in {"subs", "subtitles"}: + subtitles_by_folder[str(parent.parent) if str(parent.parent) != "." else ""].append(subtitle) + + bundles = [] + bundled_subtitle_paths = set() + for media in media_files: + folder_subtitles = subtitles_by_folder.get(media["folder"], []) + stem_matches = [ + subtitle for subtitle in subtitle_files + if subtitle["name"].lower().startswith(Path(media["name"]).stem.lower()) + ] + seen = set() + subtitles = [] + for subtitle in folder_subtitles + stem_matches: + if subtitle["path"] in seen: + continue + seen.add(subtitle["path"]) + bundled_subtitle_paths.add(subtitle["path"]) + subtitles.append(subtitle) + bundles.append({ + "media": media, + "subtitles": sorted(subtitles, key=lambda item: item["name"].lower()), + "sidecars": [ + item for item in current + if item["folder"] == media["folder"] and not item["is_media"] and not item["is_subtitle"] + ][:20], + "size": media["size"] + sum(item["size"] for item in subtitles), + }) + + loose = [ + item for item in current + if not item["is_media"] and item["path"] not in bundled_subtitle_paths + ] + + recent = [] + for item in state.get("items", []): + source = item.get("source", "") + status = item.get("status") + if source.startswith(str(root)) and status in {"moved", "planned"}: + recent.append({ + "source": source, + "destination": item.get("destination"), + "title": item.get("title"), + "type": item.get("type"), + "status": status, + "drive": item.get("drive"), + "updated_at": item.get("updated_at"), + }) + + return { + "path": str(root), + "generated_at": time.time(), + "current": sorted(current, key=lambda item: item["modified"], reverse=True), + "bundles": sorted(bundles, key=lambda item: item["media"]["modified"], reverse=True), + "loose": sorted(loose, key=lambda item: item["modified"], reverse=True), + "recent": sorted(recent, key=lambda item: item.get("updated_at") or 0, reverse=True)[:200], + "counts": { + "current": len(current), + "recent": len(recent), + "media": sum(1 for item in current if item["is_media"]), + "subtitles": sum(1 for item in current if item["is_subtitle"]), + "incomplete": sum(1 for item in current if item["is_incomplete"]), + }, + "total_size": total_size, + } diff --git a/dist/sortarr/backend/sortarr/healthcheck.py b/dist/sortarr/backend/sortarr/healthcheck.py new file mode 100644 index 0000000..c50052f --- /dev/null +++ b/dist/sortarr/backend/sortarr/healthcheck.py @@ -0,0 +1,7 @@ +from urllib.request import urlopen + + +with urlopen("http://127.0.0.1:8099/api/health", timeout=3) as response: + if response.status != 200: + raise SystemExit(1) + diff --git a/dist/sortarr/backend/sortarr/library.py b/dist/sortarr/backend/sortarr/library.py new file mode 100644 index 0000000..99a41c4 --- /dev/null +++ b/dist/sortarr/backend/sortarr/library.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import os +import re +import time +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from .metadata import movie_metadata, series_metadata +from .parser import parse_media +from .storage import drive_stats + + +LIBRARY_ROOT_NAMES = {"movies", "shows", "tv", "tv shows"} +TV_ROOT_NAMES = {"shows", "tv", "tv shows"} +EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})") +SEASON_FOLDER_RE = re.compile(r"season[ ._-]*(\d{1,2})", re.I) +YEAR_RE = re.compile(r"\((19\d{2}|20\d{2})\)") + + +def library_roots(root: Path) -> list[Path]: + matches = [] + try: + children = list(root.iterdir()) + except OSError: + return matches + for child in children: + if child.is_dir() and child.name.lower() in LIBRARY_ROOT_NAMES: + matches.append(child) + return matches + + +def library_kind(library_root: Path) -> str: + return "tv" if library_root.name.lower() in TV_ROOT_NAMES else "movie" + + +def infer_library_kind(path: str) -> str: + parts = {part.lower() for part in Path(path).parts} + if parts & TV_ROOT_NAMES: + return "tv" + if "movies" in parts: + return "movie" + return "other" + + +def split_library_path(path: str) -> tuple[str, list[str]]: + parts = list(Path(path).parts) + lowered = [part.lower() for part in parts] + for root in LIBRARY_ROOT_NAMES: + if root in lowered: + idx = lowered.index(root) + return parts[idx], parts[idx + 1:] + return "", parts + + +def clean_collection_title(name: str) -> tuple[str, int | None]: + year_match = YEAR_RE.search(name) + year = int(year_match.group(1)) if year_match else None + title = YEAR_RE.sub("", name).strip(" -._") or name + return title, year + + +def item_identity(item: dict) -> dict: + root, rel = split_library_path(item.get("path", "")) + kind = item.get("library") or infer_library_kind(item.get("path", "")) + parsed = parse_media(item.get("path", item.get("name", ""))) + if kind == "tv" and rel: + # TV shows are usually in a folder named after the show. + # We take the first part after the library root as the show folder name. + title = rel[0].strip() + season = parsed.get("season") + episode = parsed.get("episode") + for part in rel: + match = SEASON_FOLDER_RE.search(part) + if match and not season: + season = int(match.group(1)) + + # Clean the folder name for a consistent key + clean_name = clean_title(title).lower() + return { + "kind": "tv", + "title": title, + "key": f"tv::{clean_name}", + "season": season, + "episode": episode, + } + + # For movies, we use the cleaned title and year + title, year = clean_collection_title(rel[0] if rel else parsed["title"]) + clean_name = clean_title(title).lower() + year_val = year or parsed.get("year") or "" + return { + "kind": "movie", + "title": title, + "year": year_val, + "key": f"movie::{clean_name}::{year_val}", + } + + +def normalize_library(library: dict) -> dict: + items = library.get("items", []) + kinds = Counter() + for item in items: + kind = item.get("library") or infer_library_kind(item.get("path", "")) + item["library"] = kind + if kind in {"movie", "tv"}: + kinds[kind] += 1 + library["counts"] = { + "movies": kinds.get("movie", 0), + "tv": kinds.get("tv", 0), + "total": len(items), + } + if "collections" not in library: + library["collections"] = build_collections({}, items) + return library + + +def build_collections(config: dict, items: list[dict], enrich: bool = False) -> dict: + movies: dict[str, dict] = {} + series: dict[str, dict] = {} + for item in items: + identity = item_identity(item) + if identity["kind"] == "tv": + show = series.setdefault(identity["key"], { + "key": identity["key"], + "title": identity["title"], + "library": "tv", + "files": [], + "seasons": {}, + "metadata": {"title": identity["title"], "source": "filename", "seasons": {}}, + }) + show["files"].append(item) + season_no = identity.get("season") or 0 + episode_no = identity.get("episode") or 0 + season = show["seasons"].setdefault(str(season_no), {"season": season_no, "episodes": {}}) + episode = season["episodes"].setdefault(str(episode_no), { + "season": season_no, + "episode": episode_no, + "title": f"S{season_no:02d}E{episode_no:02d}" if season_no and episode_no else item["name"], + "files": [], + "status": "present", + }) + episode["files"].append(item) + else: + movie = movies.setdefault(identity["key"], { + "key": identity["key"], + "title": identity["title"], + "year": identity.get("year"), + "library": "movie", + "files": [], + "metadata": {"title": identity["title"], "source": "filename"}, + }) + movie["files"].append(item) + + if enrich and config: + workers = int(config.get("app", {}).get("metadata_parallelism", 8)) + tasks = {} + with ThreadPoolExecutor(max_workers=max(1, min(workers, 12))) as executor: + for movie in movies.values(): + future = executor.submit(movie_metadata, config, movie["title"], movie.get("year")) + tasks[future] = movie + for show in series.values(): + present_seasons = {int(season) for season in show["seasons"] if int(season) > 0} + future = executor.submit(series_metadata, config, show["title"], present_seasons) + tasks[future] = show + for future in as_completed(tasks): + try: + tasks[future]["metadata"] = future.result() + except Exception: + pass + + today = time.strftime("%Y-%m-%d") + for show in series.values(): + for season_no, season_meta in show.get("metadata", {}).get("seasons", {}).items(): + season = show["seasons"].setdefault(season_no, {"season": int(season_no), "episodes": {}}) + for meta_episode in season_meta.get("episodes", []): + key = str(meta_episode.get("episode") or 0) + existing = season["episodes"].get(key) + if existing: + existing.update({ + "title": meta_episode.get("title") or existing["title"], + "air_date": meta_episode.get("air_date"), + "overview": meta_episode.get("overview"), + "still": meta_episode.get("still"), + }) + else: + air_date = meta_episode.get("air_date") + season["episodes"][key] = { + **meta_episode, + "files": [], + "status": "upcoming" if air_date and air_date > today else "missing", + } + for season in show["seasons"].values(): + season["episodes"] = sorted(season["episodes"].values(), key=lambda ep: ep.get("episode") or 0) + show["seasons"] = sorted(show["seasons"].values(), key=lambda season: season["season"]) + + return { + "movies": sorted(movies.values(), key=lambda movie: movie["title"].lower()), + "series": sorted(series.values(), key=lambda show: show["title"].lower()), + } + + +def library_snapshot(config: dict) -> dict: + items = [] + extensions = Counter() + ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"} + app = config["app"] + max_files = int(app.get("library_scan_max_files", 20000)) + deadline = time.monotonic() + int(app.get("library_scan_timeout_seconds", 8)) + scanned = 0 + truncated = False + for drive in config.get("drives", []): + if scanned >= max_files or time.monotonic() >= deadline: + truncated = True + break + root = Path(drive["path"]) + if not root.exists(): + continue + for library_root in library_roots(root): + kind = library_kind(library_root) + for current, dirs, files in os.walk(library_root, onerror=lambda error: None): + if scanned >= max_files or time.monotonic() >= deadline: + truncated = True + break + dirs[:] = [name for name in dirs if name not in ignored_dirs] + lower_files = {name.lower() for name in files} + for filename in files: + if scanned >= max_files or time.monotonic() >= deadline: + truncated = True + break + path = Path(current) / filename + try: + stat = path.stat() + except OSError: + continue + scanned += 1 + extensions[path.suffix.lower() or "none"] += 1 + if path.suffix.lower() in app.get("media_extensions", []): + subtitle_names = [ + f"{path.stem}{ext}".lower() + for ext in app.get("subtitle_extensions", []) + ] + items.append({ + "path": str(path), + "name": path.name, + "drive": drive["id"], + "library": kind, + "root": library_root.name, + "size": stat.st_size, + "modified": stat.st_mtime, + "has_subtitles": any(name in lower_files for name in subtitle_names), + }) + return normalize_library({ + "drives": drive_stats(config), + "items": sorted(items, key=lambda item: item["modified"], reverse=True), + "collections": build_collections(config, items, enrich=True), + "extensions": dict(extensions.most_common()), + "scanned_files": scanned, + "truncated": truncated, + }) diff --git a/dist/sortarr/backend/sortarr/logging_setup.py b/dist/sortarr/backend/sortarr/logging_setup.py new file mode 100644 index 0000000..9604397 --- /dev/null +++ b/dist/sortarr/backend/sortarr/logging_setup.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import sys +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +def configure_logging(log_dir: str, level: str) -> None: + Path(log_dir).mkdir(parents=True, exist_ok=True) + formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") + root = logging.getLogger() + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + root.handlers.clear() + + stream = logging.StreamHandler() + stream.setFormatter(formatter) + root.addHandler(stream) + + try: + file_handler = RotatingFileHandler(Path(log_dir) / "sortarr.log", maxBytes=5_000_000, backupCount=5) + file_handler.setFormatter(formatter) + root.addHandler(file_handler) + except OSError as exc: + print(f"Sortarr could not open file logging in {log_dir}: {exc}", file=sys.stderr) diff --git a/dist/sortarr/backend/sortarr/media_probe.py b/dist/sortarr/backend/sortarr/media_probe.py new file mode 100644 index 0000000..a841679 --- /dev/null +++ b/dist/sortarr/backend/sortarr/media_probe.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +from .cache import get_json, remove_json, set_json + + +def _allowed_roots(config: dict) -> list[Path]: + roots = [Path(drive["path"]).resolve() for drive in config.get("drives", [])] + roots.append(Path(config["paths"]["downloads"]).resolve()) + return roots + + +def assert_allowed_path(config: dict, path: str) -> Path: + target = Path(path).resolve() + for root in _allowed_roots(config): + try: + target.relative_to(root) + return target + except ValueError: + continue + raise ValueError("path is outside configured media and downloads roots") + + +def media_probe(config: dict, path: str) -> dict: + target = assert_allowed_path(config, path) + stat = target.stat() + cache_key = f"{target}:{stat.st_size}:{int(stat.st_mtime)}" + cached = get_json(config, "ffprobe", cache_key) + if cached is not None: + return cached + command = [ + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + str(target), + ] + completed = subprocess.run(command, capture_output=True, text=True, timeout=60) + if completed.returncode != 0: + return {"path": str(target), "status": "failed", "stderr": completed.stderr[-4000:]} + payload = json.loads(completed.stdout or "{}") + streams = payload.get("streams", []) + result = { + "path": str(target), + "cache_key": cache_key, + "status": "ok", + "format": payload.get("format", {}), + "audio": [stream for stream in streams if stream.get("codec_type") == "audio"], + "subtitles": [stream for stream in streams if stream.get("codec_type") == "subtitle"], + "video": [stream for stream in streams if stream.get("codec_type") == "video"], + } + set_json(config, "ffprobe", cache_key, result) + return result + + +def _stream_type_positions(probe: dict) -> dict[int, tuple[str, int]]: + positions = {"audio": 0, "subtitle": 0, "video": 0} + result = {} + for stream in probe.get("video", []) + probe.get("audio", []) + probe.get("subtitles", []): + codec_type = stream.get("codec_type") + if codec_type not in positions: + continue + result[int(stream["index"])] = (codec_type, positions[codec_type]) + positions[codec_type] += 1 + return result + + +def edit_track(config: dict, path: str, action: str, stream_index: int) -> dict: + target = assert_allowed_path(config, path) + probe = media_probe(config, str(target)) + positions = _stream_type_positions(probe) + if stream_index not in positions: + raise ValueError("stream index was not found") + codec_type, type_index = positions[stream_index] + if codec_type not in {"audio", "subtitle"}: + raise ValueError("only audio and subtitle streams can be edited here") + + tmp = target.with_suffix(target.suffix + ".tracksorting") + if action == "remove": + command = ["ffmpeg", "-hide_banner", "-y", "-i", str(target), "-map", "0", "-map", f"-0:{stream_index}", "-c", "copy", str(tmp)] + elif action == "set-default": + spec = "a" if codec_type == "audio" else "s" + command = [ + "ffmpeg", + "-hide_banner", + "-y", + "-i", + str(target), + "-map", + "0", + "-c", + "copy", + f"-disposition:{spec}", + "0", + f"-disposition:{spec}:{type_index}", + "default", + str(tmp), + ] + else: + raise ValueError("unsupported track action") + + if config["app"].get("dry_run"): + return {"status": "dry-run", "path": str(target), "action": action, "stream_index": stream_index, "command": command} + + completed = subprocess.run(command, capture_output=True, text=True, timeout=60 * 60) + if completed.returncode != 0: + try: + tmp.unlink() + except FileNotFoundError: + pass + return {"status": "failed", "returncode": completed.returncode, "stderr": completed.stderr[-4000:], "command": command} + os.replace(tmp, target) + remove_json(config, "ffprobe", probe.get("cache_key", "")) + return {"status": "updated", "path": str(target), "action": action, "stream_index": stream_index} diff --git a/dist/sortarr/backend/sortarr/metadata.py b/dist/sortarr/backend/sortarr/metadata.py new file mode 100644 index 0000000..a166ceb --- /dev/null +++ b/dist/sortarr/backend/sortarr/metadata.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import json +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from .cache import get_json, set_json + + +TMDB_BASE = "https://api.themoviedb.org/3" +TMDB_TTL_SECONDS = 7 * 24 * 60 * 60 + + +def _auth(config: dict) -> tuple[dict[str, str], str | None]: + meta = config.get("metadata", {}) + token = meta.get("tmdb_bearer_token") or "" + api_key = meta.get("tmdb_api_key") or "" + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers, api_key or None + + +def tmdb_available(config: dict) -> bool: + meta = config.get("metadata", {}) + if not meta.get("tmdb_enabled", True): + return False + return bool(meta.get("tmdb_bearer_token") or meta.get("tmdb_api_key")) + + +def poster_url(config: dict, path: str | None) -> str | None: + if not path: + return None + return f"{config.get('metadata', {}).get('tmdb_image_base', 'https://image.tmdb.org/t/p/w342')}{path}" + + +def tmdb_get(config: dict, endpoint: str, params: dict | None = None) -> dict: + headers, api_key = _auth(config) + query = dict(params or {}) + query.setdefault("language", config.get("metadata", {}).get("tmdb_language", "en-US")) + if api_key: + query["api_key"] = api_key + url = f"{TMDB_BASE}{endpoint}?{urlencode(query)}" + cache_key = f"{endpoint}?{urlencode(sorted((key, value) for key, value in query.items() if key != 'api_key'))}" + cached = get_json(config, "tmdb", cache_key, TMDB_TTL_SECONDS) + if cached is not None: + return cached + timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3)) + with urlopen(Request(url, headers=headers), timeout=timeout) as response: + payload = json.loads(response.read().decode()) + set_json(config, "tmdb", cache_key, payload) + return payload + + +def test_tmdb(config: dict) -> dict: + meta = config.get("metadata", {}) + if not meta.get("tmdb_enabled", True): + return {"ok": False, "status": "disabled", "message": "TMDb is disabled in settings."} + headers, api_key = _auth(config) + if not api_key and "Authorization" not in headers: + return {"ok": False, "status": "missing-credentials", "message": "No TMDb API key or bearer token is configured."} + params = {"language": meta.get("tmdb_language", "en-US")} + if api_key: + params["api_key"] = api_key + url = f"{TMDB_BASE}/configuration?{urlencode(params)}" + timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3)) + try: + with urlopen(Request(url, headers=headers), timeout=timeout) as response: + payload = json.loads(response.read().decode()) + images = payload.get("images") or {} + secure_base = images.get("secure_base_url") or images.get("base_url") + return { + "ok": True, + "status": "connected", + "message": "TMDb accepted the configured credentials.", + "image_base": secure_base, + "poster_sizes": images.get("poster_sizes") or [], + } + except HTTPError as exc: + return {"ok": False, "status": f"http-{exc.code}", "message": f"TMDb returned HTTP {exc.code}."} + except (TimeoutError, URLError) as exc: + return {"ok": False, "status": "network-error", "message": str(exc)} + except Exception as exc: + return {"ok": False, "status": "error", "message": str(exc)} + + +def first_result(config: dict, media_type: str, title: str, year: int | None = None) -> dict | None: + if not tmdb_available(config) or not title: + return None + params = {"query": title} + if year and media_type == "movie": + params["year"] = year + elif year: + params["first_air_date_year"] = year + try: + payload = tmdb_get(config, f"/search/{media_type}", params) + except Exception: + return None + results = payload.get("results") or [] + return results[0] if results else None + + +def movie_metadata(config: dict, title: str, year: int | None = None) -> dict: + result = first_result(config, "movie", title, year) + if not result: + return {"title": title, "source": "filename"} + return { + "source": "tmdb", + "tmdb_id": result.get("id"), + "title": result.get("title") or title, + "overview": result.get("overview") or "", + "poster": poster_url(config, result.get("poster_path")), + "backdrop": poster_url(config, result.get("backdrop_path")), + "release_date": result.get("release_date"), + "vote_average": result.get("vote_average"), + } + + +def series_metadata(config: dict, title: str, seasons: set[int]) -> dict: + result = first_result(config, "tv", title) + if not result: + return {"title": title, "source": "filename", "seasons": {}} + metadata = { + "source": "tmdb", + "tmdb_id": result.get("id"), + "title": result.get("name") or title, + "overview": result.get("overview") or "", + "poster": poster_url(config, result.get("poster_path")), + "backdrop": poster_url(config, result.get("backdrop_path")), + "first_air_date": result.get("first_air_date"), + "vote_average": result.get("vote_average"), + "seasons": {}, + } + for season in sorted(seasons): + try: + payload = tmdb_get(config, f"/tv/{result.get('id')}/season/{season}") + except Exception: + continue + metadata["seasons"][str(season)] = { + "name": payload.get("name"), + "air_date": payload.get("air_date"), + "episode_count": len(payload.get("episodes") or []), + "episodes": [ + { + "season": season, + "episode": episode.get("episode_number"), + "title": episode.get("name"), + "overview": episode.get("overview") or "", + "air_date": episode.get("air_date"), + "still": poster_url(config, episode.get("still_path")), + } + for episode in payload.get("episodes") or [] + ], + } + return metadata + +def search_tmdb(config: dict, media_type: str, title: str) -> list[dict]: + if not tmdb_available(config) or not title: + return [] + params = {"query": title} + try: + payload = tmdb_get(config, f"/search/{media_type}", params) + except Exception: + return [] + results = payload.get("results") or [] + return [ + { + "tmdb_id": r.get("id"), + "title": r.get("title") or r.get("name"), + "overview": r.get("overview"), + "poster": poster_url(config, r.get("poster_path")), + "release_date": r.get("release_date") or r.get("first_air_date"), + } + for r in results + ] + +def identify_item(config: dict, item: dict, tmdb_id: int, media_type: str) -> dict: + if not tmdb_available(config): + return item + if media_type == "movie": + try: + payload = tmdb_get(config, f"/movie/{tmdb_id}") + item["metadata"] = { + "source": "tmdb", + "tmdb_id": tmdb_id, + "title": payload.get("title") or item.get("title"), + "overview": payload.get("overview") or "", + "poster": poster_url(config, payload.get("poster_path")), + "backdrop": poster_url(config, payload.get("backdrop_path")), + "release_date": payload.get("release_date"), + "vote_average": payload.get("vote_average"), + } + except Exception: + pass + elif media_type == "tv": + try: + # We need to re-fetch seasons as well + present_seasons = {int(s["season"]) for s in item.get("seasons", []) if s.get("season")} + metadata = series_metadata(config, item["title"], present_seasons) + # If we have a specific ID, we should use it for series_metadata but series_metadata searches by title. + # Let's patch it to use the ID. + # (Simplification: for now we assume title search works well enough if we already have the ID we can + # just manually fetch what we need). + payload = tmdb_get(config, f"/tv/{tmdb_id}") + metadata.update({ + "source": "tmdb", + "tmdb_id": tmdb_id, + "title": payload.get("name") or item.get("title"), + "overview": payload.get("overview") or "", + "poster": poster_url(config, payload.get("poster_path")), + }) + item["metadata"] = metadata + except Exception: + pass + return item diff --git a/dist/sortarr/backend/sortarr/organizer.py b/dist/sortarr/backend/sortarr/organizer.py new file mode 100644 index 0000000..652a83a --- /dev/null +++ b/dist/sortarr/backend/sortarr/organizer.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import logging +import os +import shutil +import time +import hashlib +from pathlib import Path + +from .metadata import movie_metadata, series_metadata, tmdb_available +from .parser import parse_media +from .storage import choose_drive + +LOG = logging.getLogger(__name__) + +LANGUAGE_HINTS = { + "eng": "eng", + "english": "eng", + "en": "eng", + "spa": "spa", + "spanish": "spa", + "fre": "fre", + "french": "fre", + "ger": "ger", + "german": "ger", + "ita": "ita", + "jpn": "jpn", + "japanese": "jpn", + "kor": "kor", +} + + +def safe_name(value: str) -> str: + return "".join(ch for ch in value if ch not in '<>:"/\\|?*').strip().rstrip(".") or "Unknown" + + +def format_destination(config: dict, media: dict, drive: dict) -> Path: + lib = config["library"] + title = safe_name(media["title"]) + year = media.get("year") or "Unknown Year" + if media["type"] == "episode": + folder_tpl = lib["series_folder"] + file_tpl = lib["episode_file"] + elif media["type"] == "season": + folder_tpl = lib["series_folder"] + file_tpl = "{title} - Season {season:02d}{quality}{ext}" + else: + folder_tpl = lib["movie_folder"] if media.get("year") else lib["unknown_folder"] + file_tpl = lib["movie_file"] + values = { + **media, + "title": title, + "year": year, + "season": media.get("season") or 1, + "episode": media.get("episode") or 1, + "episode_title": safe_name(media.get("episode_title") or "Episode"), + "ext": media["extension"], + } + folder = folder_tpl.format(**values) + filename = file_tpl.format(**values) + return Path(drive["path"]) / folder / filename + + +def language_suffix(path: Path) -> str: + lowered = path.stem.lower().replace(".", " ").replace("_", " ") + for token, code in LANGUAGE_HINTS.items(): + if token in lowered.split(): + return f".{code}" + return "" + + +def unique_planned_path(path: Path, rule: str, reserved: set[str]) -> Path | None: + candidate = collision_path(path, rule) + if not candidate: + return None + if str(candidate) not in reserved: + reserved.add(str(candidate)) + return candidate + stem, suffix = candidate.stem, candidate.suffix + for idx in range(2, 1000): + numbered = candidate.with_name(f"{stem}.{idx}{suffix}") + if not numbered.exists() and str(numbered) not in reserved: + reserved.add(str(numbered)) + return numbered + raise RuntimeError(f"Could not find collision-free name for {path}") + + +def tmdb_episode_title(metadata: dict, season: int | None, episode: int | None) -> str | None: + if not season or not episode: + return None + season_data = metadata.get("seasons", {}).get(str(season), {}) + for item in season_data.get("episodes", []): + if item.get("episode") == episode and item.get("title"): + return item["title"] + return None + + +def plan_id(source: str) -> str: + return hashlib.sha256(source.encode()).hexdigest()[:16] + + +def quality_score(media: dict) -> int: + quality = media.get("quality", "").lower() + if "2160" in quality: + return 4 + if "1080" in quality: + return 3 + if "720" in quality: + return 2 + if "480" in quality: + return 1 + return 0 + + +def confidence(config: dict, media: dict, metadata_enabled: bool = True) -> tuple[int, list[str], dict]: + score = 20 + reasons = [] + metadata = {"source": "filename", "title": media["title"]} + if media["title"] != "Unknown" and len(media["title"]) > 2: + score += 20 + reasons.append("title parsed") + if media["type"] == "episode" and media.get("season") and media.get("episode"): + score += 35 + reasons.append("season and episode parsed") + if media["type"] == "movie" and media.get("year"): + score += 25 + reasons.append("year parsed") + if media.get("quality"): + score += 5 + reasons.append("quality parsed") + if metadata_enabled and tmdb_available(config): + if media["type"] == "movie": + metadata = movie_metadata(config, media["title"], media.get("year")) + elif media["type"] == "episode": + metadata = series_metadata(config, media["title"], {media.get("season") or 1}) + if metadata.get("source") == "tmdb": + score += 20 + reasons.append("TMDb match") + elif tmdb_available(config): + reasons.append("metadata deferred") + return min(score, 100), reasons, metadata + + +def plan_bundle(config: dict, bundle: dict, metadata_enabled: bool = True) -> dict: + media_file = Path(bundle["media"]["path"]) + media = parse_media(str(media_file)) + score, reasons, metadata = confidence(config, media, metadata_enabled) + drive = choose_drive(config, metadata.get("title") or media["title"]) + if metadata.get("source") == "tmdb": + media["title"] = metadata.get("title") or media["title"] + if media["type"] == "movie" and metadata.get("release_date") and not media.get("year"): + media["year"] = int(metadata["release_date"][:4]) + if media["type"] == "episode": + media["episode_title"] = tmdb_episode_title(metadata, media.get("season"), media.get("episode")) or media.get("episode_title") or "Episode" + dest = format_destination(config, media, drive) + final = collision_path(dest, config["library"].get("collision", "keep-both")) + subtitle_moves = [] + if final: + reserved = {str(final)} + for subtitle in bundle.get("subtitles", []): + subtitle_path = Path(subtitle["path"]) + suffix = language_suffix(subtitle_path) + if not suffix: + suffix = ".und" + subtitle_dest = final.with_name(f"{final.stem}{suffix}{subtitle_path.suffix.lower()}") + subtitle_final = unique_planned_path(subtitle_dest, config["library"].get("collision", "keep-both"), reserved) + subtitle_moves.append({ + "source": str(subtitle_path), + "destination": str(subtitle_final) if subtitle_final else None, + "language": suffix.lstrip(".") or None, + }) + auto_threshold = int(config["app"].get("auto_move_min_confidence", 90)) + review_threshold = int(config["app"].get("review_min_confidence", 60)) + if not final: + status = "skipped" + elif score >= auto_threshold: + status = "ready" + elif score >= review_threshold: + status = "needs-review" + else: + status = "low-confidence" + return { + "id": plan_id(str(media_file)), + "source": str(media_file), + "destination": str(final) if final else None, + "media": media, + "metadata": metadata, + "drive": drive["id"], + "confidence": score, + "reasons": reasons, + "status": status, + "subtitles": subtitle_moves, + "sidecars": bundle.get("sidecars", []), + "updated_at": time.time(), + } + + +def collision_path(path: Path, rule: str) -> Path | None: + if not path.exists(): + return path + if rule == "skip": + return None + if rule == "replace": + return path + stem, suffix = path.stem, path.suffix + for idx in range(2, 1000): + candidate = path.with_name(f"{stem} ({idx}){suffix}") + if not candidate.exists(): + return candidate + raise RuntimeError(f"Could not find collision-free name for {path}") + + +def write_nfo(path: Path, media: dict) -> None: + nfo = path.with_suffix(".nfo") + body = [ + "" if media["type"] == "movie" else "", + f" {media['title']}", + ] + if media.get("year"): + body.append(f" {media['year']}") + if media.get("season"): + body.append(f" {media['season']}") + if media.get("episode"): + body.append(f" {media['episode']}") + body.append("" if media["type"] == "movie" else "") + nfo.write_text("\n".join(body) + "\n") + + +def plan_file(config: dict, source: Path) -> dict: + media = parse_media(str(source)) + drive = choose_drive(config, media["title"]) + dest = format_destination(config, media, drive) + final = collision_path(dest, config["library"].get("collision", "keep-both")) + return { + "source": str(source), + "destination": str(final) if final else None, + "media": media, + "drive": drive["id"], + "action": "skip" if final is None else ("dry-run" if config["app"].get("dry_run") else "move"), + } + + +def execute_plan(config: dict, plan: dict) -> dict: + if not plan.get("destination") or plan["action"] == "skip": + return {**plan, "status": "skipped"} + source = Path(plan["source"]) + destination = Path(plan["destination"]) + if config["app"].get("dry_run"): + return {**plan, "status": "planned"} + + destination.parent.mkdir(parents=True, exist_ok=True) + tmp = destination.with_suffix(destination.suffix + ".sorting") + if tmp.exists(): + tmp.unlink() + shutil.move(str(source), str(tmp)) + tmp.replace(destination) + mode = int(str(config["library"].get("permissions_mode", "664")), 8) + os.chmod(destination, mode) + if config.get("metadata", {}).get("write_nfo", True): + write_nfo(destination, plan["media"]) + LOG.info("Moved %s to %s", source, destination) + return {**plan, "status": "moved", "completed_at": time.time()} + + +def execute_bundle_plan(config: dict, plan: dict, force: bool = False) -> dict: + if not plan.get("destination") or (plan["status"] in {"skipped", "low-confidence"} and not force): + return {**plan, "result": "held"} + if plan["status"] == "needs-review" and not force: + return {**plan, "result": "held"} + if config["app"].get("dry_run"): + return {**plan, "result": "dry-run"} + + source = Path(plan["source"]) + destination = Path(plan["destination"]) + destination.parent.mkdir(parents=True, exist_ok=True) + tmp = destination.with_suffix(destination.suffix + ".sorting") + if tmp.exists(): + tmp.unlink() + shutil.move(str(source), str(tmp)) + tmp.replace(destination) + mode = int(str(config["library"].get("permissions_mode", "664")), 8) + os.chmod(destination, mode) + for subtitle in plan.get("subtitles", []): + subtitle_source = Path(subtitle["source"]) + if not subtitle_source.exists() or not subtitle.get("destination"): + continue + subtitle_dest = Path(subtitle["destination"]) + subtitle_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(subtitle_source), str(subtitle_dest)) + os.chmod(subtitle_dest, mode) + if config.get("metadata", {}).get("write_nfo", True): + write_nfo(destination, plan["media"]) + return {**plan, "status": "moved", "result": "moved", "completed_at": time.time()} diff --git a/dist/sortarr/backend/sortarr/parser.py b/dist/sortarr/backend/sortarr/parser.py new file mode 100644 index 0000000..932c3a0 --- /dev/null +++ b/dist/sortarr/backend/sortarr/parser.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import re +from pathlib import Path + +QUALITY_RE = re.compile(r"\b(2160p|1080p|720p|480p|576p|remux|bluray|web[- .]?dl|webrip|hdtv|dvdrip|dvd|brrip|bdrip)\b", re.I) +YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b") +EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})(?:[ ._-]*[Ee](\d{1,3}))?") +ALT_EPISODE_RE = re.compile(r"\b(\d{1,2})x(\d{1,3})(?:[ ._-]*(\d{1,2})x(\d{1,3}))?\b") +SEASON_RE = re.compile(r"\b[Ss](?:eason)?[ ._-]*(\d{1,2})\b") +BRACKET_RE = re.compile(r"[\[(][^\])]*(?:\]|\))") +AUDIO_RE = re.compile(r"\b(?:aac|aac\d(?:[ ._-]?\d)?|ac3|eac3|ddp(?:\d(?:[ ._-]?\d)?)?|dts(?:-hd|hd|x)?|truehd|atmos|flac|mp3|opus|5[ ._-]?1|7[ ._-]?1|2[ ._-]?0|6ch|2ch)\b", re.I) +CODEC_RE = re.compile(r"\b(?:x264|x265|h[ ._-]?264|h[ ._-]?265|hevc|avc|av1|vc1|vp9|10bit|8bit|hdr|hdr10|dv|dolby[ ._-]?vision)\b", re.I) +EDITION_RE = re.compile(r"\b(?:proper|repack|rerip|extended|unrated|directors?[ ._-]?cut|theatrical|imax|multi|line|dubbed|subbed|limited|internal)\b", re.I) +RELEASE_GROUP_RE = re.compile(r"(?:^|[ ._-])(?:YTS|TGx|EZTVx?|MeGusta|PSA|RARBG|NTb|AMZN|DSNP|PMNTP|FLUX|SuccessfulCrab|GalaxyTV|VXT|QxR|TIGOLE|UTR|SARTRE|KOGI|ANONYMOUS|SNEAKY|EVO|FGT)\b", re.I) +TRAILING_GROUP_RE = re.compile(r"(?:[ ._-]+-[ ._-]*[A-Za-z0-9][A-Za-z0-9._-]{1,24})$") + + +def clean_title(raw: str) -> str: + text = trim_noise(raw) + # Remove year if it's at the end or preceded by space/dot + text = re.sub(r"[ ._-]+\(?(?:19\d{2}|20\d{2})\)?.*$", "", text) + text = YEAR_RE.sub(" ", text) + text = EPISODE_RE.sub(" ", text) + text = ALT_EPISODE_RE.sub(" ", text) + text = SEASON_RE.sub(" ", text) + return spaced(text) or "Unknown" +def strip_brackets(raw: str) -> str: + return BRACKET_RE.sub(" ", raw) + + +def strip_release_tail(raw: str) -> str: + text = strip_brackets(raw) + text = TRAILING_GROUP_RE.sub("", text) + text = RELEASE_GROUP_RE.sub(" ", text) + return spaced(text) + + +def first_noise_index(text: str) -> int | None: + matches = [ + match.start() + for pattern in (QUALITY_RE, AUDIO_RE, CODEC_RE, EDITION_RE, RELEASE_GROUP_RE) + for match in [pattern.search(text)] + if match + ] + return min(matches) if matches else None + + +def trim_noise(raw: str) -> str: + text = strip_release_tail(raw) + idx = first_noise_index(text) + if idx is not None: + text = text[:idx] + return spaced(text) + + +def clean_title(raw: str) -> str: + text = trim_noise(raw) + text = YEAR_RE.sub(" ", text) + text = EPISODE_RE.sub(" ", text) + text = ALT_EPISODE_RE.sub(" ", text) + text = SEASON_RE.sub(" ", text) + return spaced(text) or "Unknown" + + +def clean_episode_title(raw: str) -> str: + text = trim_noise(raw) + text = YEAR_RE.sub(" ", text) + return spaced(text) or "Episode" + + +def parent_candidate(path: Path) -> str: + parent = path.parent + if parent.name.lower() in {"subs", "subtitles", "sub"}: + parent = parent.parent + name = parent.name + if not name or name in {".", "/"}: + return "" + return name + + +def movie_title_source(path: Path, stem: str) -> str: + parent = parent_candidate(path) + if YEAR_RE.search(parent): + return parent + if YEAR_RE.search(stem): + return stem + if parent and first_noise_index(parent) is None and not EPISODE_RE.search(parent): + return parent + return stem + + +def parse_media(path: str) -> dict: + p = Path(path) + stem = p.stem + quality_match = QUALITY_RE.search(stem) or QUALITY_RE.search(parent_candidate(p)) + year_source = stem if YEAR_RE.search(stem) else parent_candidate(p) + year_match = YEAR_RE.search(year_source) + episode_match = EPISODE_RE.search(stem) + alt_match = ALT_EPISODE_RE.search(stem) + season_match = SEASON_RE.search(stem) + + media_type = "movie" + season = None + episode = None + multi_episode = "" + episode_title = "" + + if episode_match: + media_type = "episode" + season = int(episode_match.group(1)) + episode = int(episode_match.group(2)) + if episode_match.group(3): + multi_episode = f"-E{int(episode_match.group(3)):02d}" + title = clean_title(stem[:episode_match.start()]) + episode_title = clean_episode_title(stem[episode_match.end():]) + elif alt_match: + media_type = "episode" + season = int(alt_match.group(1)) + episode = int(alt_match.group(2)) + if alt_match.group(4): + multi_episode = f"-E{int(alt_match.group(4)):02d}" + title = clean_title(stem[:alt_match.start()]) + episode_title = clean_episode_title(stem[alt_match.end():]) + elif season_match: + media_type = "season" + season = int(season_match.group(1)) + title = clean_title(stem[:season_match.start()] or parent_candidate(p) or stem) + else: + title = clean_title(movie_title_source(p, stem)) + + return { + "source": str(p), + "title": title, + "year": int(year_match.group(1)) if year_match else None, + "quality": f" - {quality_match.group(1).replace('.', ' ')}" if quality_match else "", + "type": media_type, + "season": season, + "episode": episode, + "multi_episode": multi_episode, + "episode_title": episode_title if media_type == "episode" else "", + "extension": p.suffix.lower(), + } diff --git a/dist/sortarr/backend/sortarr/releases.py b/dist/sortarr/backend/sortarr/releases.py new file mode 100644 index 0000000..24af6bf --- /dev/null +++ b/dist/sortarr/backend/sortarr/releases.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +import xml.etree.ElementTree as ET +from urllib.request import urlopen + + +def library_releases(library: dict | None) -> list[dict]: + releases = [] + for show in ((library or {}).get("collections") or {}).get("series", []): + for season in show.get("seasons", []): + for episode in season.get("episodes", []): + if episode.get("status") not in {"missing", "upcoming"}: + continue + releases.append({ + "provider": "Library", + "title": show.get("metadata", {}).get("title") or show.get("title"), + "episode_title": episode.get("title"), + "season": episode.get("season"), + "episode": episode.get("episode"), + "date": episode.get("air_date"), + "type": "tv", + "status": episode.get("status"), + "poster": show.get("metadata", {}).get("poster"), + "library_key": show.get("key"), + }) + return sorted(releases, key=lambda item: (item.get("date") or "9999-99-99", item.get("title") or "")) + + +def fetch_releases(config: dict, library: dict | None = None) -> list[dict]: + releases: list[dict] = library_releases(library) + for provider in config.get("release_providers", []): + if not provider.get("enabled", True): + continue + try: + with urlopen(provider["url"], timeout=8) as response: + body = response.read() + if provider.get("type") == "json": + data = json.loads(body.decode()) + for item in data[:30] if isinstance(data, list) else []: + show = item.get("show", item) + releases.append({ + "provider": provider["name"], + "title": show.get("name"), + "date": item.get("airdate") or item.get("premiered"), + "type": "tv", + }) + else: + root = ET.fromstring(body) + for item in root.findall(".//item")[:30]: + releases.append({ + "provider": provider["name"], + "title": (item.findtext("title") or "").strip(), + "date": (item.findtext("pubDate") or "").strip(), + "type": "movie", + }) + except Exception as exc: + releases.append({"provider": provider.get("name"), "error": str(exc)}) + return releases diff --git a/dist/sortarr/backend/sortarr/scanner.py b/dist/sortarr/backend/sortarr/scanner.py new file mode 100644 index 0000000..33a42d4 --- /dev/null +++ b/dist/sortarr/backend/sortarr/scanner.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import logging +import threading +import time +from pathlib import Path + +from .downloads import downloads_snapshot +from .organizer import execute_bundle_plan, plan_bundle + +LOG = logging.getLogger(__name__) + + +class Scanner(threading.Thread): + def __init__(self, config: dict, store): + super().__init__(daemon=True) + self.config = config + self.store = store + self.stop_event = threading.Event() + self.scan_lock = threading.Lock() + self.seen_sizes: dict[str, tuple[int, int]] = {} + + def stop(self) -> None: + self.stop_event.set() + + def is_candidate(self, path: Path) -> bool: + app = self.config["app"] + if not path.is_file(): + return False + if path.suffix.lower() in app.get("incomplete_suffixes", []): + return False + return path.suffix.lower() in set(app.get("media_extensions", [])) + + def is_stable(self, path: Path) -> bool: + stat = path.stat() + current = (stat.st_size, int(stat.st_mtime)) + previous = self.seen_sizes.get(str(path)) + self.seen_sizes[str(path)] = current + age = time.time() - stat.st_mtime + return previous == current and age >= int(self.config["app"].get("settle_seconds", 90)) + + def scan_once(self) -> list[dict]: + if not self.scan_lock.acquire(blocking=False): + return self.store.snapshot().get("organizer", {}).get("queue", []) + try: + return self._scan_once() + finally: + self.scan_lock.release() + + def request_scan(self) -> bool: + if self.scan_lock.locked(): + return False + thread = threading.Thread(target=self.scan_once, daemon=True) + thread.start() + return True + + def _scan_once(self) -> list[dict]: + downloads = Path(self.config["paths"]["downloads"]) + downloads.mkdir(parents=True, exist_ok=True) + plans: list[dict] = [] + state = self.store.snapshot() + previous_items = {item.get("source"): item for item in state.get("items", [])} + snapshot = downloads_snapshot(self.config, state) + metadata_budget = int(self.config["app"].get("organization_metadata_budget_seconds", 25)) + metadata_deadline = time.time() + metadata_budget + for bundle in snapshot.get("bundles", []): + path = Path(bundle["media"]["path"]) + if not self.is_candidate(path) or not self.is_stable(path): + continue + try: + plan = plan_bundle(self.config, bundle, metadata_enabled=time.time() < metadata_deadline) + result = execute_bundle_plan(self.config, plan) + plans.append(result) + self.store.set_organizer_queue(plans) + item = { + "source": str(path), + "destination": result.get("destination"), + "title": result["media"]["title"], + "type": result["media"]["type"], + "status": result.get("result") or result["status"], + "drive": result.get("drive"), + "confidence": result.get("confidence"), + "updated_at": time.time(), + } + self.store.upsert_item(item) + previous = previous_items.get(str(path), {}) + if ( + previous.get("destination") != item.get("destination") + or previous.get("status") != item.get("status") + or previous.get("confidence") != item.get("confidence") + ): + self.store.add_event("info", f"{item['status']}: {path.name}", path=str(path), confidence=item.get("confidence")) + except Exception as exc: + LOG.exception("Failed to organize %s", path) + self.store.add_event("error", str(exc), path=str(path)) + self.store.set_plans(plans) + self.store.set_organizer_queue(plans) + return plans + + def run(self) -> None: + while not self.stop_event.is_set(): + self.scan_once() + interval = int(self.config["app"].get("scan_interval_seconds", 20)) + self.stop_event.wait(interval) diff --git a/dist/sortarr/backend/sortarr/storage.py b/dist/sortarr/backend/sortarr/storage.py new file mode 100644 index 0000000..e93ecf2 --- /dev/null +++ b/dist/sortarr/backend/sortarr/storage.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def disk_usage(path: str) -> dict: + usage = os.statvfs(path) + total = usage.f_frsize * usage.f_blocks + free = usage.f_frsize * usage.f_bavail + used = total - free + return {"total": total, "used": used, "free": free} + + +def drive_stats(config: dict) -> list[dict]: + stats = [] + for drive in config.get("drives", []): + path = Path(drive["path"]) + path.mkdir(parents=True, exist_ok=True) + usage = disk_usage(str(path)) + stats.append({**drive, **usage}) + return stats + + +def find_existing_home(config: dict, title: str) -> str | None: + normalized = title.lower() + for drive in config.get("drives", []): + root = Path(drive["path"]) + for folder in ("Movies", "Shows"): + base = root / folder + if not base.exists(): + continue + for child in base.iterdir(): + if child.is_dir() and child.name.lower().startswith(normalized): + return str(root) + return None + + +def choose_drive(config: dict, title: str) -> dict: + existing = find_existing_home(config, title) + if existing: + for drive in config.get("drives", []): + if drive["path"] == existing: + return drive + candidates = [] + for drive in drive_stats(config): + min_free = int(drive.get("min_free_gb", 0)) * 1024**3 + if drive["free"] >= min_free: + candidates.append(drive) + if not candidates: + raise RuntimeError("No media drive has the configured minimum free space") + return max(candidates, key=lambda d: d["free"]) + diff --git a/dist/sortarr/backend/sortarr/store.py b/dist/sortarr/backend/sortarr/store.py new file mode 100644 index 0000000..787f663 --- /dev/null +++ b/dist/sortarr/backend/sortarr/store.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import threading +import time +from pathlib import Path +from typing import Any + + +class JsonStore: + def __init__(self, data_dir: str): + self.path = Path(data_dir) / "state.json" + self.lock = threading.RLock() + self.state: dict[str, Any] = { + "events": [], + "items": [], + "plans": [], + "organizer": {"queue": [], "updated_at": None}, + "library": None, + "settings": {}, + "updated_at": time.time(), + } + self.load() + + def load(self) -> None: + with self.lock: + if self.path.exists(): + self.state.update(json.loads(self.path.read_text())) + + def save(self) -> None: + with self.lock: + self.state["updated_at"] = time.time() + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(self.state, indent=2, sort_keys=True)) + tmp.replace(self.path) + + def add_event(self, level: str, message: str, **fields: Any) -> None: + with self.lock: + event = {"time": time.time(), "level": level, "message": message, **fields} + self.state.setdefault("events", []).insert(0, event) + self.state["events"] = self.state["events"][:500] + self.save() + + def upsert_item(self, item: dict[str, Any]) -> None: + with self.lock: + items = self.state.setdefault("items", []) + key = item.get("destination") or item.get("source") + for idx, existing in enumerate(items): + if (existing.get("destination") or existing.get("source")) == key: + items[idx] = {**existing, **item} + break + else: + items.append(item) + self.save() + + def set_plans(self, plans: list[dict[str, Any]]) -> None: + with self.lock: + self.state["plans"] = plans[:200] + self.save() + + def set_organizer_queue(self, queue: list[dict[str, Any]]) -> None: + with self.lock: + self.state["organizer"] = {"queue": queue[:500], "updated_at": time.time()} + self.save() + + def set_library(self, library: dict[str, Any]) -> None: + with self.lock: + self.state["library"] = library + self.save() + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return json.loads(json.dumps(self.state)) diff --git a/dist/sortarr/backend/sortarr/tools.py b/dist/sortarr/backend/sortarr/tools.py new file mode 100644 index 0000000..4ff3b10 --- /dev/null +++ b/dist/sortarr/backend/sortarr/tools.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import shutil +import subprocess +import time +from pathlib import Path + + +def subtitle_audit(config: dict, library: dict | None) -> dict: + media_extensions = set(config["app"].get("media_extensions", [])) + subtitle_extensions = config["app"].get("subtitle_extensions", []) + missing = [] + present = 0 + unknown = 0 + for item in (library or {}).get("items", []): + path = Path(item["path"]) + if path.suffix.lower() not in media_extensions: + continue + if item.get("has_subtitles") is True: + present += 1 + elif "has_subtitles" not in item: + unknown += 1 + else: + missing.append({ + "name": item["name"], + "path": str(path), + "drive": item.get("drive"), + "expected": [f"{path.stem}{ext}" for ext in subtitle_extensions[:3]], + }) + return { + "checked": present + len(missing) + unknown, + "with_subtitles": present, + "unknown_count": unknown, + "missing_count": len(missing), + "missing": missing[:500], + "generated_at": time.time(), + } + + +def transcode_plan(config: dict, library: dict | None) -> dict: + targets = [] + for item in (library or {}).get("items", []): + path = Path(item["path"]) + if path.suffix.lower() == ".mp4": + continue + output = path.with_suffix(".mp4") + command = [ + "ffmpeg", + "-hide_banner", + "-y", + "-i", + str(path), + "-map", + "0", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "20", + "-c:a", + "aac", + "-c:s", + "mov_text", + str(output), + ] + targets.append({ + "name": item["name"], + "source": str(path), + "output": str(output), + "drive": item.get("drive"), + "command": command, + }) + return { + "ffmpeg_available": shutil.which("ffmpeg") is not None, + "count": len(targets), + "targets": targets[:100], + "generated_at": time.time(), + } + + +def run_next_transcode(config: dict, library: dict | None) -> dict: + plan = transcode_plan(config, library) + if not plan["targets"]: + return {**plan, "status": "empty"} + if not plan["ffmpeg_available"]: + return {**plan, "status": "ffmpeg-unavailable"} + if config["app"].get("dry_run"): + return {**plan, "status": "dry-run"} + target = plan["targets"][0] + completed = subprocess.run(target["command"], capture_output=True, text=True, timeout=60 * 60) + return { + **plan, + "status": "completed" if completed.returncode == 0 else "failed", + "ran": target, + "returncode": completed.returncode, + "stderr": completed.stderr[-4000:], + } diff --git a/dist/sortarr/config/app.toml b/dist/sortarr/config/app.toml new file mode 100644 index 0000000..4427631 --- /dev/null +++ b/dist/sortarr/config/app.toml @@ -0,0 +1,19 @@ +# Host-editable Sortarr configuration. Values here override backend/default-config/app.toml. +# Environment variables in .env override common runtime values such as dry-run and intervals. + +[app] +dry_run = true +scan_interval_seconds = 20 +settle_seconds = 90 +log_level = "INFO" +library_scan_max_files = 20000 +library_scan_timeout_seconds = 8 + +[theme] +default = "slate" +allow_custom_css = true +custom_css_path = "/config/custom-theme.css" + +[metadata] +tmdb_enabled = true +tmdb_language = "en-US" diff --git a/dist/sortarr/config/custom-theme.css b/dist/sortarr/config/custom-theme.css new file mode 100644 index 0000000..0798da1 --- /dev/null +++ b/dist/sortarr/config/custom-theme.css @@ -0,0 +1,6 @@ +/* Optional host-editable theme overrides. Loaded by the dashboard when enabled. */ +:root { + /* --bg: #0f1115; */ + /* --accent: #5cc8ff; */ +} + diff --git a/dist/sortarr/docker-compose.yaml b/dist/sortarr/docker-compose.yaml new file mode 100644 index 0000000..380265a --- /dev/null +++ b/dist/sortarr/docker-compose.yaml @@ -0,0 +1,56 @@ +services: + web: + build: + context: ./web + container_name: sortarr-web + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${SORTARR_WEB_PORT:-8088}:80" + volumes: + - ./web/src:/usr/share/nginx/html:ro + - ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro + environment: + - TZ=${SORTARR_TZ:-Etc/UTC} + + backend: + build: + context: ./backend + container_name: sortarr-backend + init: true + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-m", "sortarr.healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + ports: + - "${SORTARR_API_PORT:-8099}:8099" + volumes: + - ${DOWNLOADS_PATH:-./downloads}:/downloads + - ${CONFIG_PATH:-./config}:/config + - ${LOGS_PATH:-./logs}:/logs + - ${DATA_PATH:-./data}:/data + - ${DRIVE1_PATH:-./media/drive1}:/media/drive1 + - ${DRIVE2_PATH:-./media/drive2}:/media/drive2 + - ${DRIVE3_PATH:-./media/drive3}:/media/drive3 + - ${DRIVE4_PATH:-./media/drive4}:/media/drive4 + environment: + - TZ=${SORTARR_TZ:-Etc/UTC} + - SORTARR_HOST=${SORTARR_HOST:-0.0.0.0} + - SORTARR_API_PORT=8099 + - SORTARR_CONFIG=/config/app.toml + - SORTARR_DEFAULT_CONFIG=/app/default-config/app.toml + - SORTARR_DATA_DIR=/data + - SORTARR_LOG_DIR=/logs + - SORTARR_CACHE_DIR=/data/cache + - SORTARR_DRY_RUN=${SORTARR_DRY_RUN:-false} + - SORTARR_LOG_LEVEL=${SORTARR_LOG_LEVEL:-INFO} + - SORTARR_SCAN_INTERVAL_SECONDS=${SORTARR_SCAN_INTERVAL_SECONDS:-20} + - SORTARR_SETTLE_SECONDS=${SORTARR_SETTLE_SECONDS:-90} + - SORTARR_MIN_FREE_GB=${SORTARR_MIN_FREE_GB:-20} + - TMDB_API_KEY=${TMDB_API_KEY:-} + - TMDB_BEARER_TOKEN=${TMDB_BEARER_TOKEN:-} diff --git a/dist/sortarr/docs/api.md b/dist/sortarr/docs/api.md new file mode 100644 index 0000000..af6c4a4 --- /dev/null +++ b/dist/sortarr/docs/api.md @@ -0,0 +1,70 @@ +# API + +All endpoints are served by the backend service and proxied by nginx under `/api`. + +## `GET /api/health` + +Returns: + +```json +{ "ok": true } +``` + +## `GET /api/config` + +Returns public runtime configuration with secrets removed. + +## `GET /api/dashboard` + +Returns JSON state, drive usage, cached library files, cached extension breakdowns, and dry-run status. This endpoint does not scan the full media filesystem. + +## `POST /api/scan` + +Runs one scanner pass immediately. In dry-run mode this only records plans. + +## `POST /api/library/scan` + +Refreshes the cached library index. The scan only enters direct child folders of each media drive named `Movies`, `TV`, or `TV Shows`. + +## `GET /api/downloads` + +Returns current files under `/downloads` plus recent Sortarr plans or moves whose source was under `/downloads`. + +## `GET /api/releases` + +Returns missing/upcoming TV episodes derived from the cached library metadata, then appends any explicitly enabled public release providers. + +## `GET /api/media/probe` + +Runs `ffprobe` for a selected media file under configured media/download roots and returns detected video, audio, and subtitle streams. + +## `POST /api/media/tracks` + +Remuxes a selected media file to set an audio/subtitle stream as default or remove an embedded audio/subtitle stream. In dry-run mode it returns the ffmpeg command without modifying the file. + +## `GET /api/theme/custom.css` + +Serves host-editable custom CSS from `/config/custom-theme.css`. + +## `POST /api/settings` + +Updates runtime settings used by the current backend process. Supported keys: + +- `dry_run` +- `scan_interval_seconds` +- `settle_seconds` +- `library_scan_max_files` +- `library_scan_timeout_seconds` +- `log_level` + +## `GET /api/tools/subtitles` + +Audits the cached library index for media files missing sidecar subtitles. Run `POST /api/library/scan` first for current subtitle data. + +## `GET /api/tools/transcoder` + +Builds a transcode queue for cached indexed media that is not already `.mp4`. + +## `POST /api/tools/transcoder/run-next` + +Runs the next queued ffmpeg transcode when `dry_run` is disabled. In dry-run mode it reports what would run. diff --git a/dist/sortarr/docs/architecture.md b/dist/sortarr/docs/architecture.md new file mode 100644 index 0000000..fd55b1a --- /dev/null +++ b/dist/sortarr/docs/architecture.md @@ -0,0 +1,251 @@ +# Sortarr Project Info + +Purpose: self-hosted Jellyfin ecosystem organizer and dashboard, fully editable and Docker Compose runnable. It watches downloads, plans/moves media into Jellyfin-friendly folders across four media drives, displays storage/library/download/release status, and exposes configurable tools such as subtitle audit and ffmpeg transcoding. + +## Runtime + +- Root: `/home/drop/jellyfin/scripts/sortarr` +- Web UI: `http://localhost:8088` or host LAN IP on port `8088` +- Backend API: port `8099` +- Compose files: `compose.yaml`, `compose.override.yaml`, `compose.prod.yaml` +- Env file: `.env` +- Default dry-run: enabled via `SORTARR_DRY_RUN=true` +- Active containers: `sortarr-web`, `sortarr-backend` +- Known unrelated/orphan container: `sortarr` may still appear restarting from an older compose shape. + +## Host Paths + +Configured in `.env`: + +- Downloads: `/home/drop/jellyfin/downloads` mounted as `/downloads` +- Media drive 1: `/home/drop/jellyfin/mediashare1` mounted as `/media/drive1` +- Media drive 2: `/home/drop/jellyfin/mediashare2` mounted as `/media/drive2` +- Media drive 3: `/home/drop/jellyfin/mediashare3` mounted as `/media/drive3` +- Media drive 4: `/home/drop/jellyfin/mediashare4` mounted as `/media/drive4` +- Config: `/home/drop/jellyfin/scripts/sortarr/config` +- Logs: `/home/drop/jellyfin/scripts/sortarr/logs` +- Data/state: `/home/drop/jellyfin/scripts/sortarr/data` + +## Architecture + +- `web`: nginx serves static HTML/CSS/JS from `web/src` and proxies `/api/*` to backend. +- `backend`: Python 3.12 stdlib HTTP API plus background scanner thread. Backend image installs `ffmpeg`. +- Optional profiles: + - `redis` profile `cache` + - `postgres` profile `database` + - `media-tools` profile `tools` + +No frontend framework and no backend web framework are used. This is intentional for editability. + +## Important Files + +- `.env.example`: sample deployment variables. +- `.env`: real local deployment paths and runtime values. Ignored by git. +- `compose.yaml`: main stack. +- `compose.override.yaml`: dev bind mounts and debug defaults. +- `compose.prod.yaml`: prod restart/dry-run defaults. +- `backend/default-config/app.toml`: full default config. +- `config/app.toml`: host-editable override config. +- `config/custom-theme.css`: host-editable CSS token overrides. +- `backend/sortarr/app.py`: API server and route handlers. +- `backend/sortarr/config.py`: TOML/env config loading and merging. +- `backend/sortarr/scanner.py`: 24/7 downloads scanner thread. +- `backend/sortarr/parser.py`: filename media parser. +- `backend/sortarr/organizer.py`: destination planning, collision handling, move execution, NFO writing. +- `backend/sortarr/storage.py`: drive stats and drive selection. +- `backend/sortarr/library.py`: explicit library scan/indexing and Movies/TV collection grouping. +- `backend/sortarr/metadata.py`: optional TMDb metadata lookup for covers, summaries, and TV episode lists. +- `backend/sortarr/media_probe.py`: safe ffprobe wrapper for audio/subtitle/video stream details. +- `backend/sortarr/tools.py`: subtitle audit and transcoder tools. +- `backend/sortarr/downloads.py`: current `/downloads` listing and recent moved/planned download history. +- `backend/sortarr/releases.py`: free RSS/JSON upcoming release providers. +- `backend/sortarr/store.py`: JSON state store in `data/state.json`. +- `web/src/index.html`: app shell and page markup. +- `web/src/app.js`: hash router, API calls, rendering, settings/tools behavior. +- `web/src/styles.css`: layout/design system. +- `web/src/themes.css`: 10 editable theme presets. +- `docs/*.md`: API/config/operations docs. + +## Configuration Model + +Config precedence: + +1. `backend/default-config/app.toml` +2. `config/app.toml` +3. `.env` variables passed into Compose +4. Runtime settings saved in `data/state.json` under `settings` + +Key config areas: + +- `[app]`: dry-run, scan interval, settle time, log level, extensions, incomplete suffixes, library scan limits, cache size cap. +- `[paths]`: downloads/data/logs/cache container paths. +- `[[drives]]`: four media drives with id/name/path/min-free-space. +- `[library]`: folder and filename templates, collision policy, permissions mode. +- `[metadata]`: NFO behavior and optional TMDb credentials/settings. +- `[[release_providers]]`: free RSS/JSON providers. +- `[theme]`: default theme and custom CSS. + +Runtime Settings page can update: + +- `dry_run` +- `scan_interval_seconds` +- `settle_seconds` +- `library_scan_max_files` +- `library_scan_timeout_seconds` +- `log_level` + +## Media Organizer Behavior + +Background scanner watches `/downloads` continuously. + +Safety: + +- Ignores incomplete suffixes such as `.part`, `.!qB`, `.tmp`, `.crdownload`. +- Requires files to be stable for `settle_seconds`. +- Dry-run plans moves without moving. +- Actual moves go through a temporary `.sorting` path before final rename. +- Collision policies: `keep-both`, `skip`, `replace`. +- Events and plans are stored in `data/state.json`. + +Parsing: + +- Detects movies, episodes, seasons, and multi-episode releases. +- Recognizes `S01E02`, `S01E02E03`, and `1x02` style episode patterns. +- Extracts year and quality tokens where present. + +Drive choice: + +1. Checks whether the title already has a home under `Movies` or `Shows`. +2. If no home exists, picks eligible drive with most free space. +3. Enforces `min_free_gb`. + +Naming: + +- Movies: `Movies/{title} ({year})/{title} ({year}){quality}{ext}` +- Episodes: `Shows/{title}/Season {season:02d}/{title} - SxxExx - Episode{quality}{ext}` +- Templates are editable in TOML. + +## Library Indexing + +Regular dashboard refresh does not walk the media filesystem. + +Library indexing is explicit: + +- UI button: Library page -> `Scan library` +- API: `POST /api/library/scan` +- Scans only direct child folders of each media drive named: + - `Movies` + - `Shows` + - `TV` + - `TV Shows` + +The library scanner skips system/recycle folders and has timeout/file-count limits. Results are cached in `data/state.json` and used by dashboard/tools. + +Current cache fields include: + +- drive stats +- indexed media items split by `Movies` and `TV`/`TV Shows` roots +- collection groups for movies and TV series +- optional TMDb posters, overviews, and TV season episode metadata +- extension breakdown +- scanned file count +- truncation flag +- per-media `has_subtitles` when available from scan + +## Frontend Pages + +The UI uses hash routing in `web/src/app.js`. + +Routes: + +- `#/overview`: storage, file type breakdown, recent events. +- `#/library`: poster grid with All/Movies/TV Shows tabs, series/episode drilldown, missing/upcoming episode state, and media stream inspection. +- `#/downloads`: current `/downloads` media bundles with matching subtitles/sidecars plus recent Sortarr plans/moves from `/downloads`. +- `#/releases`: missing/upcoming library episodes plus configured public providers. +- `#/tools`: transcoder, subtitle audit, duplicate finder placeholder. +- `#/settings`: appearance controls, descriptive runtime controls, raw config details. + +Theme system: + +- Theme choices live on the Settings page and persist in `localStorage`. +- Compact density toggle persists in `localStorage`. +- Presets: `slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`. +- Tokens live in `web/src/themes.css`; host overrides in `config/custom-theme.css`. + +## Backend API + +- `GET /api/health`: healthcheck. +- `GET /api/config`: public config with secrets removed. +- `GET /api/dashboard`: state + cached library + drive stats; no filesystem library scan. +- `POST /api/scan`: run one downloads scan now. +- `POST /api/library/scan`: refresh cached library index. +- `GET /api/downloads`: current `/downloads` files plus recent planned/moved download history. +- `GET /api/releases`: upcoming releases. +- `GET /api/media/probe`: ffprobe stream details for a selected file. +- `POST /api/media/tracks`: dry-run or execute ffmpeg remux track default/removal changes. +- `GET /api/theme/custom.css`: custom CSS. +- `POST /api/settings`: update runtime settings. +- `GET /api/tools/subtitles`: subtitle audit from cached library data. +- `GET /api/tools/transcoder`: build ffmpeg transcode queue from cached library. +- `POST /api/tools/transcoder/run-next`: run next ffmpeg transcode if dry-run is disabled. + +## Tools + +Subtitle audit: + +- Uses cached library index, not live filesystem probes. +- Requires a fresh library scan for accurate `has_subtitles`. +- Reports checked count, with-subtitles count, missing count, unknown count, and missing examples. + +Transcoder: + +- Backend image installs `ffmpeg`. +- Queue includes cached indexed media not already `.mp4`. +- Output path is source path with `.mp4` suffix. +- Command uses `libx264`, `aac`, and `mov_text`. +- In dry-run mode, `run-next` reports without executing. +- With dry-run disabled, runs one job synchronously with a 1 hour timeout. + +Duplicate finder: + +- UI placeholder only at time of writing. + +## Release Providers + +No paid API dependency. + +Bundled providers, disabled by default so the Releases page stays centered on the local library: + +- TMDb RSS upcoming movies. +- TVMaze public schedule JSON. + +Provider logic is in `backend/sortarr/releases.py`; add new RSS/JSON adapters there and configure in TOML. + +## Verification Commands + +Common checks: + +```bash +python -m compileall backend/sortarr +node --check web/src/app.js +docker compose config +docker compose up -d --build +docker exec sortarr-backend python -m sortarr.healthcheck +docker exec sortarr-backend ffmpeg -version +``` + +Endpoint checks from inside backend: + +```bash +docker exec sortarr-backend python -c "from urllib.request import urlopen; print(urlopen('http://127.0.0.1:8099/api/health').status)" +docker exec sortarr-backend python -c "from urllib.request import urlopen; import json; print(json.load(urlopen('http://127.0.0.1:8099/api/tools/transcoder'))['transcoder']['ffmpeg_available'])" +``` + +## Current Caveats / Next Good Tasks + +- Settings are runtime/persisted in JSON state but not written back into `config/app.toml`. +- Transcoding runs synchronously; future improvement should add a job queue with progress/cancel/history. +- Duplicate finder is a placeholder. +- Subtitle audit only becomes exact after a fresh manual library scan because it relies on cached `has_subtitles`. +- Library scan only checks direct child folders named `Movies`, `TV`, or `TV Shows` under each media drive. +- Backend is stdlib HTTP server; fine for self-hosting behind LAN/reverse proxy, but add auth before exposing publicly. diff --git a/dist/sortarr/docs/configuration.md b/dist/sortarr/docs/configuration.md new file mode 100644 index 0000000..4348192 --- /dev/null +++ b/dist/sortarr/docs/configuration.md @@ -0,0 +1,77 @@ +# Configuration + +Configuration is layered in this order: + +1. `backend/default-config/app.toml` +2. `config/app.toml` +3. `.env` variables passed into Docker Compose + +The backend deep-merges TOML files and then applies environment overrides for common deployment values. + +## Organizer Settings + +`[app]` + +- `dry_run`: plan without moving files. +- `scan_interval_seconds`: worker polling interval. +- `settle_seconds`: minimum file age before processing. +- `stable_checks`: reserved for stricter stability policies. +- `incomplete_suffixes`: suffixes ignored while downloads are still active. +- `media_extensions`: media files eligible for organizing. +- `subtitle_extensions`: subtitle files visible to the scanner. +- `library_scan_max_files`: maximum files indexed by the manual library scan. +- `library_scan_timeout_seconds`: timeout for the manual library scan. +- `cache_max_bytes`: maximum server-side cache size. Defaults to 20GB. + +`[library]` + +- `movie_folder`: destination folder template for movies. +- `series_folder`: destination folder template for shows. +- `movie_file`: Jellyfin-friendly movie filename template. +- `episode_file`: Jellyfin-friendly episode filename template. +- `collision`: `keep-both`, `skip`, or `replace`. +- `duplicate`: reserved duplicate policy hook. +- `permissions_mode`: final file mode after a move. + +## Drives + +Each `[[drives]]` entry has: + +- `id`: stable machine name. +- `name`: dashboard display name. +- `path`: mounted drive path inside the container. +- `min_free_gb`: minimum free space required before the drive is eligible. + +Drive selection first checks whether the title already has a home under `Movies` or `Shows`. If not, it selects the eligible drive with the most free space. + +## Themes + +Bundled presets live in `web/src/themes.css`. The current presets are: + +`slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`. + +Runtime custom CSS is loaded from `/config/custom-theme.css` when `[theme].allow_custom_css` is enabled. Override any token: + +```css +:root { + --accent: #5cc8ff; + --radius: 4px; +} +``` + +## Release Providers + +`[[release_providers]]` supports pluggable free sources: + +- `type = "rss"` for RSS/Atom-style feeds. +- `type = "json"` for simple public JSON endpoints. + +Provider code is isolated in `backend/sortarr/releases.py` so new adapters can be added without touching the UI. + +## TMDb Metadata + +Set `TMDB_API_KEY` or `TMDB_BEARER_TOKEN` in `.env` to enrich manual library scans with TMDb posters, overviews, release dates, and TV season episode data. Without credentials, Sortarr still groups local media and shows placeholder covers. + +## Server Cache + +Sortarr stores reusable TMDb and ffprobe results under `/data/cache`. The default cache cap is 20GB via `[app].cache_max_bytes`; older cache files are pruned when new cache entries are written. diff --git a/dist/sortarr/docs/operations.md b/dist/sortarr/docs/operations.md new file mode 100644 index 0000000..0e24bac --- /dev/null +++ b/dist/sortarr/docs/operations.md @@ -0,0 +1,62 @@ +# Operations + +## Dry Run First + +Keep this in `.env` until destination paths look correct: + +```bash +SORTARR_DRY_RUN=true +``` + +Then switch to: + +```bash +SORTARR_DRY_RUN=false +``` + +Restart: + +```bash +docker compose up -d +``` + +## Logs + +Backend logs are written to `/logs/sortarr.log` in the container and to the host path configured by `LOGS_PATH`. + +## Backups + +Back up: + +- `.env` +- `config/` +- `data/state.json` +- `logs/` if you need historical audit trails + +Media files are not stored inside containers. + +## Updating + +Because all source is mounted or copied from this project, update by editing files and rebuilding: + +```bash +docker compose up -d --build +``` + +## Transcoding + +The backend image includes `ffmpeg`. The dashboard Tools page can build a queue from the cached library index and run the next conversion. Keep dry-run enabled while checking output paths; actual transcoding only runs when `SORTARR_DRY_RUN=false` or dry-run is disabled from the runtime Settings page. + +## Track Editing + +The Library detail panel can inspect a selected file with `ffprobe` and remux embedded audio/subtitle streams to set defaults or remove tracks. Dry-run mode returns the planned `ffmpeg` command only. Disable dry-run only after confirming the command and keep media backups for any bulk edits. + +## Cache + +Reusable metadata and ffprobe results are cached under `/data/cache`. The default cap is 20GB and pruning removes oldest cache files first. + +## Recovery + +Sortarr moves through a temporary `.sorting` file before final placement. If a container stops mid-move, check the destination folder for `*.sorting` files and compare against `/downloads`. + +The app intentionally avoids deleting source folders and does not run destructive cleanup by default. diff --git a/dist/sortarr/web/Dockerfile b/dist/sortarr/web/Dockerfile new file mode 100644 index 0000000..b302f74 --- /dev/null +++ b/dist/sortarr/web/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine +COPY src /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + diff --git a/dist/sortarr/web/nginx.conf b/dist/sortarr/web/nginx.conf new file mode 100644 index 0000000..28a2a8a --- /dev/null +++ b/dist/sortarr/web/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8099/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} + diff --git a/dist/sortarr/web/src/app.js b/dist/sortarr/web/src/app.js new file mode 100644 index 0000000..1896d00 --- /dev/null +++ b/dist/sortarr/web/src/app.js @@ -0,0 +1,1006 @@ +const themes = ["slate", "midnight", "graphite", "nord", "dracula", "solar", "forest", "marine", "ember", "paper"]; +const themeLabels = { + slate: "Slate", + midnight: "Midnight", + graphite: "Graphite", + nord: "Nord", + dracula: "Dracula", + solar: "Solar", + forest: "Forest", + marine: "Marine", + ember: "Ember", + paper: "Paper", +}; +const settingsGroups = [ + { + title: "Organizer", + description: "Controls how Sortarr watches /downloads, decides what is safe to move, and handles uncertain matches.", + fields: [ + ["app.dry_run", "Dry-run mode", "checkbox", "Plan files without moving them. Disable only when destinations and confidence scores look correct."], + ["app.scan_interval_seconds", "Scan interval", "range", "How often the background scanner checks /downloads.", { min: 5, max: 300, step: 5, unit: "sec" }], + ["app.settle_seconds", "File settle time", "range", "How long a file must remain unchanged before Sortarr can plan or move it.", { min: 10, max: 1800, step: 10, unit: "sec" }], + ["app.stable_checks", "Stable checks", "range", "Number of matching size/mtime observations expected before a file is considered stable.", { min: 1, max: 8, step: 1, unit: "checks" }], + ["app.auto_move_min_confidence", "Auto-move confidence", "range", "Plans at or above this score can move automatically when dry-run is off.", { min: 50, max: 100, step: 1, unit: "%" }], + ["app.review_min_confidence", "Review confidence", "range", "Plans at or above this score stay in the review queue instead of being treated as low confidence.", { min: 0, max: 100, step: 1, unit: "%" }], + ["app.organization_metadata_budget_seconds", "Metadata budget", "range", "Maximum total TMDb lookup time per organizer pass before Sortarr falls back to filename-only planning.", { min: 0, max: 120, step: 5, unit: "sec" }], + ["app.organization_metadata_timeout_seconds", "Metadata timeout", "range", "Maximum time a single TMDb request can wait.", { min: 1, max: 15, step: 1, unit: "sec" }], + ["app.metadata_parallelism", "Metadata parallelism", "range", "How many TMDb lookups a library scan can run at the same time.", { min: 1, max: 12, step: 1, unit: "workers" }], + ], + }, + { + title: "Scanning", + description: "Limits for library indexing and file classification.", + fields: [ + ["app.library_scan_max_files", "Library scan file limit", "range", "Maximum filesystem entries inspected by a manual library scan.", { min: 1000, max: 250000, step: 1000, unit: "files" }], + ["app.library_scan_timeout_seconds", "Library scan timeout", "range", "Maximum runtime for a manual library scan before returning a partial result.", { min: 3, max: 180, step: 1, unit: "sec" }], + ["app.cache_max_bytes", "Server cache limit", "range", "Maximum cache size for server-side metadata/probe data.", { min: 1073741824, max: 21474836480, step: 1073741824, unit: "bytes" }], + ["app.media_extensions", "Media extensions", "list", "Extensions treated as media files in /downloads."], + ["app.subtitle_extensions", "Subtitle extensions", "list", "Extensions packaged with matching movies and episodes."], + ["app.incomplete_suffixes", "Incomplete suffixes", "list", "Suffixes ignored while downloads are still active."], + ["app.extra_keywords", "Extra ignore keywords", "list", "Filename terms that identify extras rather than primary media."], + ], + }, + { + title: "Paths", + description: "Container paths used by the backend. Host bind mounts are still controlled by Docker compose and .env.", + fields: [ + ["paths.downloads", "Downloads path", "text", "Container path Sortarr watches for new downloads."], + ["paths.data", "Data path", "text", "Container path for state and runtime data."], + ["paths.logs", "Logs path", "text", "Container path for backend logs."], + ["paths.cache", "Cache path", "text", "Container path for metadata and probe caches."], + ], + }, + { + title: "Library Naming", + description: "Templates used when Sortarr creates destination folders and filenames.", + fields: [ + ["library.movie_folder", "Movie folder", "text", "Folder template for movies."], + ["library.series_folder", "Series folder", "text", "Folder template for TV episodes."], + ["library.movie_file", "Movie filename", "text", "Filename template for movies."], + ["library.episode_file", "Episode filename", "text", "Filename template for TV episodes."], + ["library.subtitle_file", "Subtitle filename", "text", "Filename template for packaged subtitles."], + ["library.unknown_folder", "Unknown folder", "text", "Fallback folder for media that cannot be confidently classified."], + ["library.collision", "File collision policy", "select", "What to do when the destination file already exists.", { options: ["keep-both", "skip", "replace"] }], + ["library.duplicate", "Duplicate policy", "select", "How duplicate titles should be handled.", { options: ["skip", "keep-both"] }], + ["library.permissions_mode", "File permissions", "text", "Octal mode applied to moved media files."], + ["library.directory_mode", "Directory permissions", "text", "Octal mode intended for created library folders."], + ], + }, + { + title: "Metadata", + description: "TMDb and local metadata behavior.", + fields: [ + ["metadata.tmdb_enabled", "TMDb enabled", "checkbox", "Allow Sortarr to enrich plans and library items with TMDb data."], + ["metadata.write_nfo", "Write NFO files", "checkbox", "Write simple NFO metadata beside moved files."], + ["metadata.prefer_existing_nfo", "Prefer existing NFO", "checkbox", "Use existing local NFO data before online metadata when available."], + ["metadata.provider_order", "Provider order", "list", "Metadata providers in priority order."], + ["metadata.tmdb_api_key", "TMDb API key", "text", "TMDb v3 API key used for lookups. This is stored in /data/state.json when saved here."], + ["metadata.tmdb_bearer_token", "TMDb bearer token", "text", "Optional TMDb v4 bearer token. This is stored in /data/state.json when saved here."], + ["metadata.tmdb_language", "TMDb language", "text", "Language code used for TMDb requests, such as en-US."], + ["metadata.tmdb_image_base", "TMDb image base", "text", "Base URL used for poster and backdrop images."], + ], + }, + { + title: "Appearance", + description: "Dashboard theme and custom CSS behavior.", + fields: [ + ["theme.default", "Default theme", "select", "Theme used when a browser has not chosen one locally.", { options: themes }], + ["theme.allow_custom_css", "Allow custom CSS", "checkbox", "Serve /config/custom-theme.css when present."], + ["theme.custom_css_path", "Custom CSS path", "text", "Container path for optional custom dashboard CSS."], + ], + }, + { + title: "Logging", + description: "Backend diagnostics.", + fields: [ + ["app.log_level", "Log level", "select", "Controls backend verbosity.", { options: ["DEBUG", "INFO", "WARNING", "ERROR"] }], + ["app.name", "Application name", "text", "Display/runtime name for this Sortarr instance."], + ], + }, +]; +const state = { + dashboard: null, + config: null, + downloads: null, + releases: [], + route: "overview", + libraryTab: "all", + libraryLimit: 120, + selectedMedia: null, +}; + +const $ = (id) => document.getElementById(id); +const bytes = (value = 0) => { + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = value; + let idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx += 1; + } + return `${size.toFixed(idx ? 1 : 0)} ${units[idx]}`; +}; +const date = (seconds) => seconds ? new Date(seconds * 1000).toLocaleString() : ""; +const mediaLabel = (kind) => kind === "tv" ? "TV Shows" : kind === "movie" ? "Movies" : "Other"; +const esc = (value = "") => String(value).replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", +})[char]); + +function toast(message, type = "info") { + const host = $("toastHost"); + if (!host) return; + const item = document.createElement("div"); + item.className = `toast ${type}`; + item.textContent = message; + host.appendChild(item); + setTimeout(() => item.classList.add("visible"), 10); + setTimeout(() => { + item.classList.remove("visible"); + setTimeout(() => item.remove(), 180); + }, 4200); +} + +function setTheme(theme) { + document.documentElement.dataset.theme = theme; + localStorage.setItem("sortarr-theme", theme); + renderThemeOptions(); +} + +async function api(path, options) { + const response = await fetch(path, options); + if (!response.ok) throw new Error(`${path} returned ${response.status}`); + return response.json(); +} + +function routeFromHash() { + return (location.hash.replace("#/", "") || "overview").split("?")[0]; +} + +function renderRoute() { + state.route = routeFromHash(); + document.querySelectorAll(".page").forEach((page) => page.classList.remove("active")); + document.querySelectorAll("nav a").forEach((link) => link.classList.toggle("active", link.dataset.route === state.route)); + const page = $(`page-${state.route}`); + (page || $("page-overview")).classList.add("active"); +} + +async function loadDashboard() { + state.dashboard = await api("/api/dashboard"); + renderDashboard(); + if (state.downloads) renderDownloads(); +} + +async function loadConfig() { + state.config = await api("/api/config"); + $("configView").textContent = JSON.stringify(state.config, null, 2); + renderSettings(); + if (!localStorage.getItem("sortarr-theme") && state.config.theme?.default) { + setTheme(state.config.theme.default); + } else { + renderThemeOptions(); + } +} + +async function loadDownloads() { + const payload = await api("/api/downloads"); + state.downloads = payload.downloads; + renderDownloads(); +} + +async function loadReleases() { + const payload = await api("/api/releases"); + state.releases = payload.releases || []; + renderReleases(); +} + +function renderDashboard() { + const data = state.dashboard; + $("statusLine").textContent = data.dry_run ? "Dry-run mode is active" : "Organizer is allowed to move files"; + $("storageCards").innerHTML = data.library.drives.map((drive) => { + const pct = drive.total ? Math.round((drive.used / drive.total) * 100) : 0; + return `
+ ${drive.name} +
+
${bytes(drive.used)} used${bytes(drive.free)} free
+
`; + }).join(""); + + const extensions = Object.entries(data.library.extensions); + const max = Math.max(...extensions.map(([, count]) => count), 1); + $("extensionBreakdown").innerHTML = extensions.slice(0, 12).map(([ext, count]) => ` +
${ext}
${count}
+ `).join("") || "

No files indexed yet.

"; + + const counts = data.library.counts || {}; + $("libraryStatus").textContent = data.library.scanned_files + ? `Indexed ${counts.total || data.library.items.length} media files across ${counts.movies || 0} movies and ${counts.tv || 0} TV items from ${data.library.scanned_files} scanned files${data.library.truncated ? " before the configured scan limit or timeout" : ""}.` + : "Library has not been scanned yet. Use Scan library to index Movies, TV, and TV Shows folders."; + + $("events").innerHTML = data.state.events.slice(0, 12).map((event) => ` +
${event.message}
${date(event.time)}
+ `).join("") || "

No organizer events yet.

"; + + renderLibraryTabs(); + renderLibrary(); +} + +function libraryCollections() { + const filter = $("libraryFilter").value.toLowerCase(); + const collections = state.dashboard?.library.collections || { movies: [], series: [] }; + const all = [ + ...collections.movies.map((item) => ({ ...item, library: "movie" })), + ...collections.series.map((item) => ({ ...item, library: "tv" })), + ]; + return all.filter((item) => { + const meta = item.metadata || {}; + const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab; + const matchesFilter = [item.title, meta.title, meta.overview, item.year, mediaLabel(item.library)].join(" ").toLowerCase().includes(filter); + return matchesTab && matchesFilter; + }); +} + +function renderLibraryTabs() { + const counts = state.dashboard?.library.counts || {}; + const collectionCounts = state.dashboard?.library.collections || {}; + const tabs = [ + ["all", "All", (collectionCounts.movies?.length || 0) + (collectionCounts.series?.length || 0)], + ["movie", "Movies", collectionCounts.movies?.length || 0], + ["tv", "TV Shows", collectionCounts.series?.length || 0], + ]; + $("libraryTabs").innerHTML = tabs.map(([key, label, count]) => ` + + `).join(""); + document.querySelectorAll("[data-library-tab]").forEach((button) => { + button.addEventListener("click", () => { + state.libraryTab = button.dataset.libraryTab; + state.libraryLimit = 120; + renderLibraryTabs(); + renderLibrary(); + }); + }); +} + +function renderLibrary() { + const rows = libraryCollections(); + const visible = rows.slice(0, state.libraryLimit); + $("libraryGrid").innerHTML = visible.map((item) => mediaCard(item)).join("") || "

No matching media.

"; + document.querySelectorAll("[data-media-key]").forEach((button) => { + button.addEventListener("click", () => selectMedia(button.dataset.mediaKey)); + }); + $("libraryPager").innerHTML = rows.length > state.libraryLimit + ? `Showing ${visible.length} of ${rows.length} matching titles.` + : `Showing ${visible.length} matching titles.`; + const more = $("libraryMoreButton"); + if (more) { + more.addEventListener("click", () => { + state.libraryLimit += 120; + renderLibrary(); + }); + } +} + +function mediaCard(item) { + const meta = item.metadata || {}; + const title = meta.title || item.title; + const subtitle = item.library === "tv" + ? `${item.seasons?.length || 0} seasons, ${item.files?.length || 0} files` + : `${item.year || meta.release_date || ""} ${item.files?.length > 1 ? `- ${item.files.length} versions` : ""}`; + const cover = meta.poster + ? `` + : `${esc(title.slice(0, 1) || "?")}`; + const multiBadge = (item.library === "movie" && item.files?.length > 1) ? `${item.files.length}` : ""; + return ``; +} + +function findMedia(key) { + const collections = state.dashboard?.library.collections || { movies: [], series: [] }; + return [...collections.movies, ...collections.series].find((item) => item.key === key); +} + +function selectMedia(key) { + const item = findMedia(key); + if (!item) return; + state.selectedMedia = item; + document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key)); + renderMediaDetail(item); +} + +function renderMediaDetail(item) { + const meta = item.metadata || {}; + const files = item.files || []; + const title = meta.title || item.title; + const cover = meta.poster ? `` : `${esc(title.slice(0, 1) || "?")}`; + const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item); + + openModal(`
+
${cover}
+
+
+
+

${esc(title)}

+

${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}

+
+ +
+ ${meta.overview ? `

${esc(meta.overview)}

` : ""} + + ${detail} +
+
+
`); + + $("identifyButton").addEventListener("click", () => renderIdentifySearch(item)); + + if (item.library === "movie" && files[0]) { + inspectMedia(files[0].path); + } else if (item.library === "tv" && item.seasons?.[0]?.episodes?.[0]?.files?.[0]) { + inspectMedia(item.seasons[0].episodes[0].files[0].path); + } + + document.querySelectorAll("[data-probe-path]").forEach((button) => { + button.addEventListener("click", () => inspectMedia(button.dataset.probePath)); + }); +} + +function renderMovieDetail(item) { + const files = item.files || []; + return `
+

Local Files ${files.length > 1 ? `${files.length} versions` : ""}

+
${files.map((file) => ` +
+
+ ${esc(file.name)} + +
+
${esc(file.drive || "")}${bytes(file.size)}
+ ${esc(file.path)} +
+ `).join("")}
+
`; +} + +function renderSeriesDetail(item) { + return `
${(item.seasons || []).map((season) => ` +
+ Season ${season.season || "Unknown"} ${season.episodes.length} episodes +
${season.episodes.map((episode) => { + const multi = (episode.files || []).length > 1 ? `Multi` : ""; + return `
+
+
+ ${episode.episode ? `E${String(episode.episode).padStart(2, "0")} - ` : ""}${esc(episode.title || "Episode")} + ${multi} +
+ ${episode.air_date || ""} ${episode.status !== "present" ? episode.status : ""} + ${episode.overview ? `

${esc(episode.overview)}

` : ""} +
+
+ ${(episode.files || []).map((file, idx) => ` + `).join("") || "No file"} +
+
`; + }).join("")}
+
+ `).join("")}
`; +} + +function fileRow(file) { + return `
+ ${esc(file.name)} +
${esc(file.drive || "")}${bytes(file.size)}
+ ${esc(file.path)} +
`; +} + +async function inspectMedia(path) { + state.currentProbePath = path; + document.querySelectorAll("[data-probe-path]").forEach((b) => { + b.classList.toggle("accent", b.dataset.probePath === path); + const parent = b.closest(".download"); + if (parent) parent.classList.toggle("active", b.dataset.probePath === path); + }); + + const output = $("probeOutput"); + output.innerHTML = "

Inspecting media streams...

"; + const payload = await api(`/api/media/probe?path=${encodeURIComponent(path)}`); + const media = payload.media; + output.innerHTML = `
+

Media Info

+
+
Video${streamRows(media.video)}
+
Audio Tracks${streamRows(media.audio)}
+
Subtitles${streamRows(media.subtitles)}
+
+

Track edits remux the selected file. Dry-run mode reports the command without changing the file.

+
`; + document.querySelectorAll("[data-track-action]").forEach((button) => { + button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex))); + }); +} + +function streamRows(streams = []) { + return streams.map((stream) => { + const tags = stream.tags || {}; + const isDefault = stream.disposition?.default === 1; + return `
+
+
+ ${esc(stream.codec_name || stream.codec_type || "unknown")} + ${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""} +
+ ${isDefault ? `Default` : ""} +
+ ${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? `
+ ${!isDefault ? `` : ""} + +
` : ""} +
`; + }).join("") || "

None detected.

"; +} + +async function editTrack(path, action, streamIndex) { + const output = $("probeOutput"); + const payload = await api("/api/media/tracks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, action, stream_index: streamIndex }), + }); + const result = payload.media; + output.insertAdjacentHTML("afterbegin", `
+ Track edit: ${esc(result.status)} + ${result.command ? `${esc(result.command.join(" "))}` : ""} + ${result.stderr ? `
${esc(result.stderr)}
` : ""} +
`); + if (result.status === "updated") { + await inspectMedia(path); + } +} + +function renderDownloads() { + const downloads = state.downloads; + if (!downloads) return; + $("downloadsStatus").textContent = downloads.error + ? `Cannot read ${downloads.path}: ${downloads.error}` + : `${downloads.counts.current} files in ${downloads.path}, ${downloads.counts.media} media files, ${downloads.counts.subtitles || 0} subtitle files, ${downloads.counts.incomplete} incomplete files. Total size: ${bytes(downloads.total_size)}.`; + const queue = state.dashboard?.state?.organizer?.queue || []; + const queueCounts = queue.reduce((acc, plan) => { + const key = plan.status || plan.result || "planned"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + $("organizerSummary").innerHTML = [ + ["ready", queueCounts.ready || 0], + ["review", queueCounts["needs-review"] || 0], + ["held", queueCounts.held || 0], + ["dry-run", queueCounts["dry-run"] || 0], + ["moved", queueCounts.moved || 0], + ].map(([label, count]) => `${count}${label}`).join(""); + $("organizerRows").innerHTML = queue.slice(0, 100).map(organizerCard).join("") || "

No organizer plans yet. Run scan or wait for the background scanner.

"; + document.querySelectorAll("[data-plan-action]").forEach((button) => { + button.addEventListener("click", () => updateOrganizerPlan(button.dataset.planAction, button.dataset.planId)); + }); + $("downloadRows").innerHTML = [ + ...(downloads.bundles || []).slice(0, 150).map((bundle) => downloadBundle(bundle)), + ...(downloads.loose || []).slice(0, 80).map((item) => ` +
+ ${esc(item.relative_path)} +
${item.is_incomplete ? "Incomplete" : item.is_subtitle ? "Loose subtitle" : "Sidecar file"}${bytes(item.size)}
+ Modified ${date(item.modified)} +
+ `), + ].join("") || "

/downloads is currently empty.

"; + $("recentDownloadRows").innerHTML = downloads.recent.slice(0, 100).map((item) => ` +
+ ${item.title || item.source} + ${item.status} ${item.type || "item"}${item.drive ? ` to ${item.drive}` : ""} + ${item.destination || item.source} + ${date(item.updated_at)} +
+ `).join("") || "

No recent Sortarr plans or moves from /downloads yet.

"; +} + +function organizerCard(plan) { + const status = plan.status || plan.result; + const result = plan.result && plan.result !== plan.status ? ` (${plan.result})` : ""; + const confidenceClass = plan.confidence >= 90 ? "good" : plan.confidence >= 60 ? "warn" : "bad"; + const metaSource = plan.metadata?.source === "tmdb" ? "TMDb matched" : "Filename parsed"; + const label = plan.media?.type === "episode" && plan.media?.season + ? `TV episode S${String(plan.media.season).padStart(2, "0")}E${String(plan.media.episode).padStart(2, "0")}` + : plan.media?.type === "movie" ? `Movie ${plan.media?.year || ""}` : esc(plan.media?.type || ""); + return `
+
+
+ ${esc(plan.media?.title || plan.source)} + ${esc(label)} - ${esc(metaSource)} + ${plan.media?.episode_title ? `${esc(plan.media.episode_title)}` : ""} +
+ ${plan.confidence || 0}% +
+
+ From${esc(plan.source || "")} + To${esc(plan.destination || "No destination planned")} +
+
${(plan.reasons || []).map((reason) => `${esc(reason)}`).join("")}
+
${esc(`${status || ""}${result}`)}${(plan.subtitles || []).length} subtitles
+ ${(plan.subtitles || []).length ? `
${plan.subtitles.map((subtitle) => `${esc(subtitle.language || "und")} -> ${esc(subtitle.destination || "not planned")}`).join("")}
` : ""} +
+ + +
+
`; +} + +async function updateOrganizerPlan(action, id) { + await api(`/api/organizer/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + await Promise.all([loadDashboard(), loadDownloads()]); +} + +function downloadBundle(bundle) { + const media = bundle.media; + return `
+
+
+ ${esc(media.name)} + ${esc(media.folder || "/downloads")} +
+ ${bytes(bundle.size)} +
+
Media file${date(media.modified)}
+
+ ${(bundle.subtitles || []).map((subtitle) => `${esc(subtitle.name)}`).join("") || "No matching subtitles found"} +
+
`; +} + +function renderReleases() { + $("releaseRows").innerHTML = state.releases.map((item) => ` +
+ ${item.poster ? `` : ""} + ${esc(item.title || item.error || "Unknown")} + ${item.episode_title ? `${esc(`S${String(item.season).padStart(2, "0")}E${String(item.episode).padStart(2, "0")} - ${item.episode_title}`)}` : ""} + ${esc(item.status || item.provider || "")} + ${esc(item.date || item.type || "")} + ${item.library_key ? `Open in library` : ""} +
+ `).join("") || "

No release providers returned data.

"; + document.querySelectorAll("[data-release-key]").forEach((link) => { + link.addEventListener("click", () => setTimeout(() => selectMedia(link.dataset.releaseKey), 50)); + }); +} + +function renderThemeOptions() { + const wrap = $("themeOptions"); + if (!wrap) return; + const current = localStorage.getItem("sortarr-theme") || state.config?.theme?.default || "slate"; + wrap.innerHTML = themes.map((theme) => ` + + `).join(""); + document.querySelectorAll("[data-theme-choice]").forEach((button) => { + button.addEventListener("click", () => setTheme(button.dataset.themeChoice)); + }); +} + +function getPath(root, path) { + return path.split(".").reduce((value, key) => value?.[key], root); +} + +function setPath(root, path, value) { + const keys = path.split("."); + let target = root; + keys.slice(0, -1).forEach((key) => { + target[key] = target[key] || {}; + target = target[key]; + }); + target[keys[keys.length - 1]] = value; +} + +function fieldMeta(tuple) { + const [path, label, type, help, options = {}] = tuple; + return { path, label, type, help, ...options }; +} + +function settingField(meta) { + const value = getPath(state.config, meta.path); + const id = meta.path.replaceAll(".", "__"); + const body = `
+
+ ${meta.label} + ${esc(meta.path)} +
+ ${meta.help} +
`; + if (meta.type === "checkbox") { + return `
${body}
`; + } + if (meta.type === "select") { + const options = (meta.options || []).map((option) => ``); + return `
${body}
`; + } + if (meta.type === "list") { + return `
${body}
Comma separated
`; + } + if (meta.type === "text") { + return `
${body}
`; + } + return `
${body}
+ + ${meta.unit} +
`; +} + +function syncSettingControls() { + document.querySelectorAll("[data-range-for]").forEach((range) => { + range.addEventListener("input", () => { + const number = document.querySelector(`[data-number-for="${range.dataset.rangeFor}"]`); + if (number) number.value = range.value; + }); + }); + document.querySelectorAll("[data-number-for]").forEach((number) => { + number.addEventListener("input", () => { + const range = document.querySelector(`[data-range-for="${number.dataset.numberFor}"]`); + if (range) range.value = number.value; + }); + }); +} + +function renderDriveSettings() { + const drives = state.config?.drives || []; + return `
+ +
+

Storage Drives

+

Destination drives Sortarr can choose when moving organized media.

+
+ ${drives.length} drives +
+
${drives.map((drive, idx) => ` +
+
+
+ ${esc(drive.name || drive.id || `Drive ${idx + 1}`)} + drives[${idx}] +
+ Drive identity, container path, and minimum free-space reserve. +
+ + + + + + +
+ `).join("")}
+
`; +} + +function collectDriveSettings() { + return [...document.querySelectorAll("[data-drive-index]")].map((row) => ({ + id: row.querySelector('[data-drive-field="id"]').value, + name: row.querySelector('[data-drive-field="name"]').value, + path: row.querySelector('[data-drive-field="path"]').value, + min_free_gb: Number(row.querySelector('[data-drive-field="min_free_gb"]').value), + })); +} + +function renderReleaseProviderSettings() { + const providers = state.config?.release_providers || []; + return `
+ +
+

Release Providers

+

Sources used by the Releases tab for upcoming or missing media context.

+
+ ${providers.length} providers +
+
${providers.map((provider, idx) => ` +
+
+
+ ${esc(provider.name || provider.id || `Provider ${idx + 1}`)} + release_providers[${idx}] +
+ Enable status, provider type, and feed URL. +
+ + + + + + + +
+ `).join("")}
+
`; +} + +function collectReleaseProviderSettings() { + return [...document.querySelectorAll("[data-provider-index]")].map((row) => ({ + id: row.querySelector('[data-provider-field="id"]').value, + name: row.querySelector('[data-provider-field="name"]').value, + enabled: row.querySelector('[data-provider-field="enabled"]').checked, + type: row.querySelector('[data-provider-field="type"]').value, + url: row.querySelector('[data-provider-field="url"]').value, + })); +} + +function renderSettings() { + if (!state.config) return; + $("settingsForm").innerHTML = settingsGroups.map((group) => ` +
+ +
+

${esc(group.title)}

+

${esc(group.description)}

+
+ ${group.fields.length} settings +
+
${group.fields.map((field) => settingField(fieldMeta(field))).join("")}
+
+ `).join("") + renderDriveSettings() + renderReleaseProviderSettings(); + syncSettingControls(); + renderThemeOptions(); +} + +async function saveSettings() { + const button = $("settingsSaveButton"); + const notice = $("settingsNotice"); + button.disabled = true; + button.textContent = "Saving..."; + if (notice) notice.textContent = ""; + const updates = {}; + try { + document.querySelectorAll("[data-setting]").forEach((field) => { + const path = field.dataset.setting; + if (!path) return; + if (field.type === "checkbox") { + setPath(updates, path, field.checked); + } else if (field.type === "range" || field.type === "number") { + setPath(updates, path, Number(field.value)); + } else if (field.dataset.settingType === "list") { + setPath(updates, path, field.value.split(",").map((item) => item.trim()).filter(Boolean)); + } else { + setPath(updates, path, field.value); + } + }); + updates.drives = collectDriveSettings(); + updates.release_providers = collectReleaseProviderSettings(); + const payload = await api("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + state.config = payload.config; + $("configView").textContent = JSON.stringify(state.config, null, 2); + renderSettings(); + await loadDashboard(); + const message = "Settings saved. Run a library scan to refresh TMDb covers and episode metadata."; + if (notice) notice.textContent = message; + toast(message, "success"); + } catch (error) { + const message = `Settings save failed: ${error.message}`; + if (notice) notice.textContent = message; + toast(message, "error"); + } finally { + button.disabled = false; + button.textContent = "Save settings"; + } +} + +async function testTmdb() { + const button = $("tmdbTestButton"); + const notice = $("settingsNotice"); + button.disabled = true; + button.textContent = "Testing..."; + if (notice) notice.textContent = "Testing TMDb API credentials..."; + try { + const payload = await api("/api/metadata/tmdb/test", { method: "POST" }); + const result = payload.tmdb || {}; + const details = result.ok && result.image_base + ? ` Poster images available from ${result.image_base}.` + : ""; + const message = `${result.ok ? "TMDb API test passed." : "TMDb API test failed."} ${result.message || ""}${details}`; + if (notice) notice.textContent = message; + toast(message, result.ok ? "success" : "error"); + } catch (error) { + const message = `TMDb API test failed: ${error.message}`; + if (notice) notice.textContent = message; + toast(message, "error"); + } finally { + button.disabled = false; + button.textContent = "TMDb API Test"; + } +} + +function renderToolOutput(title, rows) { + $("toolOutput").innerHTML = `

${title}

${rows}`; +} + +async function loadTranscoder() { + const payload = await api("/api/tools/transcoder"); + const plan = payload.transcoder; + renderToolOutput("Transcode Queue", ` +

${plan.count} conversion candidates. ffmpeg ${plan.ffmpeg_available ? "is available" : "is not available"}.

+
${plan.targets.slice(0, 20).map((item) => ` +
${item.name}${item.output}${item.command.join(" ")}
+ `).join("") || "

No transcode candidates found.

"}
+ `); +} + +async function runNextTranscode() { + const payload = await api("/api/tools/transcoder/run-next", { method: "POST" }); + const result = payload.transcoder; + renderToolOutput("Transcoder Result", ` +

Status: ${result.status}. ${result.count || 0} candidates in queue.

+ ${result.ran ? `
${result.ran.name}${result.ran.output}
` : ""} + ${result.stderr ? `
${result.stderr}
` : ""} + `); +} + +async function runSubtitleAudit() { + const payload = await api("/api/tools/subtitles"); + const audit = payload.audit; + renderToolOutput("Subtitle Audit", ` +

Checked ${audit.checked} indexed media files. ${audit.missing_count} missing subtitles. ${audit.unknown_count || 0} need a fresh library scan.

+
${audit.missing.slice(0, 50).map((item) => ` +
${item.name}${item.path}Expected: ${item.expected.join(", ")}
+ `).join("") || "

Every indexed media file has a sidecar subtitle.

"}
+ `); +} + +async function runScan() { + $("scanButton").disabled = true; + try { + const scan = await api("/api/scan", { method: "POST" }); + $("downloadsStatus").textContent = scan.started ? "Scan started. Organizer queue will update as files are parsed." : "A scan is already running. Showing the latest queue."; + await Promise.all([loadDashboard(), loadDownloads()]); + setTimeout(() => Promise.all([loadDashboard(), loadDownloads()]).catch(() => {}), 2500); + } finally { + $("scanButton").disabled = false; + } +} + +async function scanLibrary() { + $("libraryScanButton").disabled = true; + $("libraryStatus").textContent = "Scanning Movies, TV, and TV Shows folders..."; + try { + const payload = await api("/api/library/scan", { method: "POST" }); + state.dashboard.library = payload.library; + state.libraryLimit = 120; + state.selectedMedia = null; + renderDashboard(); + await loadReleases(); + } finally { + $("libraryScanButton").disabled = false; + } +} + +function openModal(html) { + $("modalBody").innerHTML = html; + $("mediaModal").classList.add("active"); + document.body.style.overflow = "hidden"; +} + +function closeModal() { + $("mediaModal").classList.remove("active"); + document.body.style.overflow = ""; + state.selectedMedia = null; + document.querySelectorAll(".poster-card.active").forEach((b) => b.classList.remove("active")); +} + +function init() { + setTheme(localStorage.getItem("sortarr-theme") || "slate"); + window.addEventListener("hashchange", renderRoute); + renderRoute(); + $("refreshButton").addEventListener("click", loadDashboard); + $("scanButton").addEventListener("click", runScan); + $("libraryScanButton").addEventListener("click", scanLibrary); + $("downloadsRefresh").addEventListener("click", loadDownloads); + $("releaseRefresh").addEventListener("click", loadReleases); + $("libraryFilter").addEventListener("input", () => { + state.libraryLimit = 500; + renderLibrary(); + }); + $("settingsSaveButton").addEventListener("click", saveSettings); + $("tmdbTestButton").addEventListener("click", testTmdb); + $("transcoderPlanButton").addEventListener("click", loadTranscoder); + $("transcoderRunButton").addEventListener("click", runNextTranscode); + $("subtitleAuditButton").addEventListener("click", runSubtitleAudit); + $("duplicateButton").addEventListener("click", () => renderToolOutput("Duplicate Finder", "

Duplicate analysis needs the cached library index and will be wired next.

")); + + $("closeModal").addEventListener("click", closeModal); + document.querySelector(".modal-backdrop").addEventListener("click", closeModal); + window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); }); + + Promise.allSettled([loadConfig(), loadDashboard(), loadDownloads(), loadReleases()]); + setInterval(loadDashboard, 30000); +} + +init(); + +function renderIdentifySearch(item) { + const container = $("identifySearch"); + container.innerHTML = ` +
+

Identify Media

+
+ + +
+
+
+ `; + $("identifyRun").addEventListener("click", () => runIdentifySearch(item)); +} + +async function runIdentifySearch(item) { + const query = $("identifyQuery").value; + const resultsDiv = $("identifyResults"); + resultsDiv.innerHTML = "

Searching TMDb...

"; + + const type = item.library === "tv" ? "tv" : "movie"; + const payload = await api(`/api/metadata/search?query=${encodeURIComponent(query)}&type=${type}`); + const results = payload.results || []; + + resultsDiv.innerHTML = results.map(r => ` +
+ +
+ ${esc(r.title)} (${r.release_date ? r.release_date.slice(0, 4) : '?'}) +

${esc(r.overview ? r.overview.slice(0, 120) + '...' : 'No overview.')}

+ +
+
+ `).join("") || "

No matches found.

"; + + resultsDiv.querySelectorAll("button").forEach(btn => { + btn.addEventListener("click", () => applyIdentification(item, Number(btn.dataset.tmdbId))); + }); +} + +async function applyIdentification(item, tmdbId) { + const type = item.library === "tv" ? "tv" : "movie"; + toast("Applying identification..."); + try { + const payload = await api("/api/library/identify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: item.key, tmdb_id: tmdbId, type }) + }); + if (payload.ok) { + toast("Media identified successfully!", "success"); + state.selectedMedia = payload.item; + const collections = state.dashboard.library.collections; + if (item.library === "movie") { + const idx = collections.movies.findIndex(m => m.key === item.key); + if (idx !== -1) collections.movies[idx] = payload.item; + } else { + const idx = collections.series.findIndex(s => s.key === item.key); + if (idx !== -1) collections.series[idx] = payload.item; + } + renderMediaDetail(payload.item); + renderLibrary(); + } + } catch (err) { + toast("Identification failed: " + err.message, "error"); + } +} diff --git a/dist/sortarr/web/src/index.html b/dist/sortarr/web/src/index.html new file mode 100644 index 0000000..4fb9e8c --- /dev/null +++ b/dist/sortarr/web/src/index.html @@ -0,0 +1,158 @@ + + + + + + Sortarr + + + + + +
+ + +
+
+
+

Media Dashboard

+

Connecting to backend...

+
+
+ + +
+
+ +
+
+
+

Storage

+
+
+
+

File Types

+
+
+
+

Activity

+
+
+
+
+
+
+
+

Library Contents

+

+
+
+ + +
+
+
+
+
+
+ +
+
+
+

Downloads

+

+
+ +
+
+
+

Organizer Queue

+
+
+
+
+

Current /downloads Files

+
+
+
+

Recently Planned or Moved

+
+
+
+
+ +
+
+

Missing & Upcoming

+ +
+
+
+ +
+
+

Library Tools

+ Uses the cached library index. Run a library scan first if results look stale. +
+
+ + + + +
+
+
+ +
+
+
+

Settings

+

Runtime settings are saved in /data/state.json and override TOML/env values for this backend process.

+
+
+ + +
+
+
+
+
+

Dashboard Theme

+

Choose the local dashboard theme here. The default theme below is also configurable and saved on the server.

+
+
+
+
+
+ Raw config +

+          
+
+
+
+ +
+ + + diff --git a/dist/sortarr/web/src/styles.css b/dist/sortarr/web/src/styles.css new file mode 100644 index 0000000..2bdac4a --- /dev/null +++ b/dist/sortarr/web/src/styles.css @@ -0,0 +1,822 @@ +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: calc(15px - (var(--compact, 0) * 1px)); +} +.app-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; } +.sidebar { + border-right: 1px solid var(--border); + background: var(--surface); + padding: calc(20px * var(--density)); + position: sticky; + top: 0; + height: 100vh; +} +.brand { display: flex; gap: 12px; align-items: center; margin-bottom: 28px; } +.brand-mark { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: var(--radius); + background: var(--accent); + color: var(--bg); + font-weight: 800; +} +.brand small, #statusLine, .muted { color: var(--muted); } +nav { display: grid; gap: 6px; } +nav a { + color: var(--muted); + text-decoration: none; + padding: 10px 12px; + border-radius: var(--radius); +} +nav a.active, nav a:hover { background: var(--surface-2); color: var(--text); } +.page { display: none; } +.page.active { display: block; } +select, input, button { + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + border-radius: var(--radius); + padding: 10px 12px; +} +button { cursor: pointer; } +button:hover { border-color: var(--accent); } +button:disabled { cursor: wait; opacity: .62; } +main { padding: 24px; display: grid; gap: 24px; align-content: start; } +.topbar, .section-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; } +h1, h2, h3, p { margin: 0; } +h1 { font-size: 28px; } +h2 { font-size: 17px; } +h3 { font-size: 14px; color: var(--muted); font-weight: 700; } +.actions { display: flex; gap: 10px; } +.grid { display: grid; gap: 16px; } +.overview-grid { grid-template-columns: 1.3fr 1fr 1fr; } +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: calc(18px * var(--density)); +} +.storage-list, .event-list, .download-list, .bars { display: grid; gap: 12px; margin-top: 16px; } +.storage-card { display: grid; gap: 8px; } +.meter { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } +.meter span { display: block; height: 100%; background: var(--accent); } +.kv { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; } +.bar-row { display: grid; grid-template-columns: 72px 1fr 44px; gap: 10px; align-items: center; } +.event { border-left: 3px solid var(--accent); padding-left: 10px; color: var(--muted); } +.event.error { border-color: var(--bad); } +.segmented { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} +.segmented button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; +} +.segmented button.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 18%, var(--surface-2)); +} +.segmented span { + color: var(--muted); + font-size: 12px; +} +.table-wrap { overflow: auto; margin-top: 16px; max-height: 68vh; } +table { width: 100%; border-collapse: collapse; min-width: 720px; } +th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; } +th { color: var(--muted); font-weight: 600; } +td:first-child { + max-width: 520px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.download, .release { + display: grid; + gap: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + background: var(--surface-2); +} +.download.warning { border-color: var(--warn); } +.poster-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; + margin-top: 18px; +} +.poster-card { + display: grid; + gap: 8px; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + text-align: left; +} +.poster-card.active .poster, +.poster-card:hover .poster { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.poster { + display: grid; + place-items: center; + aspect-ratio: 2 / 3; + overflow: hidden; + border-radius: var(--radius); + background: var(--surface-2); + border: 1px solid var(--border); +} +.poster img { + width: 100%; + height: 100%; + object-fit: cover; +} +.poster-placeholder { + display: grid; + place-items: center; + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--surface-2), var(--surface)); + color: var(--accent); + font-size: 42px; + font-weight: 800; +} +.poster-card strong, +.poster-card small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.poster-card small { color: var(--muted); } +.media-detail { + margin-top: 22px; +} +.detail-shell { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + gap: 20px; + border-top: 1px solid var(--border); + padding-top: 22px; +} +.detail-poster { + align-self: start; +} +.detail-body { + display: grid; + gap: 16px; + min-width: 0; +} +.detail-block, +.season-list { + display: grid; + gap: 12px; +} +.season-list details { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + padding: 10px 12px; +} +.season-list summary { + cursor: pointer; + font-weight: 700; +} +.episode-list { + display: grid; + gap: 8px; + margin-top: 12px; +} +.episode { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 10px; + border-radius: var(--radius); + background: var(--surface); + border-left: 3px solid var(--good); +} +.episode.missing { border-left-color: var(--bad); } +.episode.upcoming { border-left-color: var(--warn); } +.episode p { + margin-top: 5px; + line-height: 1.35; +} +.episode-actions { + display: flex; + align-items: center; + gap: 8px; +} +.probe-output { + display: grid; + gap: 12px; +} +.stream-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} +.stream-grid section { + display: grid; + align-content: start; + gap: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + padding: 12px; +} +.stream-row { + display: grid; + gap: 6px; + padding-top: 8px; + border-top: 1px solid var(--border); +} +.track-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.track-actions button { + padding: 6px 8px; + font-size: 12px; +} +.downloads-layout { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr); + gap: 18px; + margin-top: 18px; +} +.downloads-layout article { min-width: 0; } +.queue-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); + gap: 8px; + margin-top: 12px; +} +.queue-summary span { + display: grid; + gap: 2px; + padding: 9px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--muted); + font-size: 12px; +} +.queue-summary strong { + color: var(--text); + font-size: 18px; +} +.download small, .download span { + overflow-wrap: anywhere; +} +.download.bundle { + background: var(--surface); +} +.bundle-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} +.bundle-head div { + display: grid; + gap: 4px; + min-width: 0; +} +.subtitle-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.subtitle-chips span { + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-2); + color: var(--muted); + padding: 4px 8px; + font-size: 12px; +} +.download.loose { + opacity: .82; +} +.organizer-card { + border-left: 3px solid var(--accent); +} +.organizer-card.needs-review, +.organizer-card.dry-run { + border-left-color: var(--warn); +} +.organizer-card.low-confidence, +.organizer-card.skipped { + border-left-color: var(--bad); +} +.organizer-card.moved { + border-left-color: var(--good); +} +.confidence { + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; + white-space: nowrap; +} +.confidence.good { color: var(--good); } +.confidence.warn { color: var(--warn); } +.confidence.bad { color: var(--bad); } +.plan-paths { + display: grid; + gap: 5px; +} +.plan-paths small { + display: grid; + gap: 2px; +} +.plan-paths b { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0; +} +.subtitle-list { + display: grid; + gap: 4px; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); +} +.plan-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.plan-actions button { + padding: 7px 10px; +} +.release-grid, .tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; } +.release img { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: var(--radius); +} +.release.missing { border-color: var(--bad); } +.release.upcoming { border-color: var(--warn); } +.release a { + color: var(--accent); + text-decoration: none; +} +.pager { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 14px; +} +.tool-output { margin-top: 18px; display: grid; gap: 12px; } +.tool-output h3 { margin: 0; font-size: 15px; } +code { + display: block; + overflow: auto; + padding: 10px; + border-radius: var(--radius); + background: var(--bg); + color: var(--muted); +} +.settings-hero { + display: grid; + grid-template-columns: minmax(220px, .45fr) minmax(0, 1fr); + gap: 18px; + align-items: start; + margin-top: 18px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} +.settings-hero h3 { margin: 0 0 6px; } +.settings-notice { + display: none; + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + color: var(--muted); +} +.settings-notice:not(:empty) { display: block; } +.settings-stack { + display: grid; + gap: 18px; + margin-top: 18px; + max-width: 1180px; +} +.settings-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; +} +.settings-card-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: start; + padding: 16px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); + cursor: pointer; + list-style: none; +} +.settings-card-head::-webkit-details-marker { + display: none; +} +.settings-card-head h3 { + margin: 0 0 5px; +} +.settings-card-head p { + margin: 0; +} +.settings-card-head > span { + color: var(--muted); + font-size: 12px; + white-space: nowrap; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); +} +.settings-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} +.setting-row { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(260px, 520px); + gap: 24px; + align-items: start; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: var(--surface); + padding: 16px; +} +.setting-row:last-child { border-bottom: 0; } +.setting-rich { + align-items: start; + min-height: 0; +} +.setting-copy { + display: grid; + gap: 8px; + min-width: 0; + max-width: 620px; +} +.setting-copy > div { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; +} +.setting-copy small, +.setting-rich small { + color: var(--muted); + line-height: 1.35; +} +.setting-path { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +.setting-control { + display: flex; + justify-content: flex-end; + align-items: center; + min-width: 0; +} +.setting-control.wide { + display: grid; + gap: 6px; + justify-content: stretch; +} +.setting-row input[type="number"], .setting-row select { width: 132px; } +.setting-row input[type="text"], +.setting-row input[type="password"], +.setting-row textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + padding: 10px; +} +.setting-row textarea { + resize: vertical; + min-height: 70px; +} +.setting-row input[type="checkbox"] { + width: 22px; + height: 22px; + align-self: center; +} +.switch { + justify-self: end; + position: relative; + display: inline-flex; + width: 48px; + height: 28px; +} +.switch input { + position: absolute; + opacity: 0; +} +.switch span { + width: 100%; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border); + transition: background .15s ease, border-color .15s ease; +} +.switch span::after { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 4px; + left: 4px; + border-radius: 50%; + background: var(--muted); + transition: transform .15s ease, background .15s ease; +} +.switch input:checked + span { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 20%, var(--surface-2)); +} +.switch input:checked + span::after { + transform: translateX(20px); + background: var(--accent); +} +.range-control { + display: grid; + align-content: center; + gap: 10px; + min-width: 0; + width: 100%; +} +.range-control input[type="range"] { + width: 100%; + accent-color: var(--accent); +} +.range-control span { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.compound-control { + display: grid; + grid-template-columns: repeat(2, minmax(160px, 1fr)); + gap: 10px; + width: 100%; +} +.compound-control input, +.compound-control select { + min-width: 0; +} +.compound-control label { + display: grid; + gap: 5px; +} +.compound-control .inline-check { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + color: var(--muted); +} +.compound-control .span-2 { + grid-column: 1 / -1; +} +.theme-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 10px; + max-width: 980px; +} +.theme-option { + display: grid; + grid-template-columns: 42px 1fr; + align-items: center; + gap: 10px; + text-align: left; + background: var(--surface-2); +} +.theme-option.active { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} +.theme-swatch { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + width: 42px; + height: 32px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); +} +.theme-swatch i, +.theme-swatch b, +.theme-swatch em { + display: block; + min-width: 0; +} +.theme-swatch i { background: var(--surface); } +.theme-swatch b { background: var(--surface-2); } +.theme-swatch em { + grid-column: 1 / -1; + background: var(--accent); +} +pre { + white-space: pre-wrap; + overflow: auto; + background: var(--surface-2); + border-radius: var(--radius); + padding: 14px; + color: var(--muted); +} +.toast-host { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 20; + display: grid; + gap: 10px; + width: min(420px, calc(100vw - 36px)); +} +.toast { + transform: translateY(8px); + opacity: 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-left: 4px solid var(--accent); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + box-shadow: 0 14px 34px rgba(0, 0, 0, .22); + transition: opacity .18s ease, transform .18s ease; +} +.toast.visible { + transform: translateY(0); + opacity: 1; +} +.toast.success { border-left-color: var(--good); } +.toast.error { border-left-color: var(--bad); } +@media (max-width: 900px) { + .app-shell { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; } + .overview-grid { grid-template-columns: 1fr; } + .downloads-layout { grid-template-columns: 1fr; } + .detail-shell { grid-template-columns: 1fr; } + .detail-poster { max-width: 220px; } + .episode { grid-template-columns: 1fr; } + .topbar, .section-head { align-items: stretch; flex-direction: column; } + .actions, .pager { flex-wrap: wrap; } + .settings-hero { grid-template-columns: 1fr; } + .settings-card-head { flex-direction: column; } + .setting-row { grid-template-columns: 1fr; gap: 14px; } + .range-control { min-width: 0; } + .setting-row input[type="text"], + .setting-row input[type="password"], + .setting-row textarea, + .compound-control { + width: 100%; + } + .compound-control { grid-template-columns: 1fr; } + .bundle-head { flex-direction: column; } +} + +/* Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + overflow: hidden; +} +.modal.active { display: flex; align-items: center; justify-content: center; } +.modal-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.62); + backdrop-filter: blur(4px); +} +.modal-shell { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 90%; + max-width: 900px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.42); + animation: modal-slide 0.24s cubic-bezier(0, 0, 0.2, 1); +} +@keyframes modal-slide { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +.modal-close { + position: absolute; + top: 12px; + right: 12px; + background: var(--surface-2); + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 20px; + display: grid; + place-items: center; + z-index: 10; +} +.modal-content { + overflow-y: auto; + padding: 24px; +} + +/* Badge */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 700; + background: var(--surface-2); + color: var(--muted); + border: 1px solid var(--border); +} +.badge.accent { background: var(--accent); color: var(--bg); border: none; } + +/* Multi-version Card Indicator */ +.poster-card { position: relative; } +.card-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 5; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); +} + +/* Track actions enhanced */ +.track-actions { display: flex; gap: 6px; margin-top: 4px; } +.track-actions button { + padding: 4px 8px; + font-size: 11px; + background: var(--surface-2); +} +.track-actions button:hover { border-color: var(--accent); } + +.track-actions button.danger:hover { border-color: var(--bad); color: var(--bad); } +.stream-row { padding: 8px 0; border-bottom: 1px solid var(--border); } +.stream-row:last-child { border-bottom: none; } +.circle-badge { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + font-size: 12px; + line-height: 1; +} +.identify-panel { margin: 16px 0; background: var(--surface-2); } +.identify-results { display: grid; gap: 8px; margin-top: 16px; max-height: 300px; overflow-y: auto; } +.identify-result { display: flex; gap: 12px; align-items: flex-start; } +.mini-poster { width: 60px; border-radius: 4px; flex-shrink: 0; } +.small { font-size: 12px; } diff --git a/dist/sortarr/web/src/themes.css b/dist/sortarr/web/src/themes.css new file mode 100644 index 0000000..88ce20f --- /dev/null +++ b/dist/sortarr/web/src/themes.css @@ -0,0 +1,134 @@ +:root, +[data-theme="slate"] { + --bg: #111318; + --surface: #191d24; + --surface-2: #222833; + --text: #eef2f7; + --muted: #96a1af; + --border: #303846; + --accent: #60a5fa; + --good: #34d399; + --warn: #fbbf24; + --bad: #f87171; + --radius: 8px; + --density: 1; + --font: Inter, ui-sans-serif, system-ui, sans-serif; +} + +[data-theme="midnight"] { + --bg: #080b12; + --surface: #121826; + --surface-2: #1b2740; + --text: #f8fafc; + --muted: #93a4bd; + --border: #293550; + --accent: #22d3ee; + --good: #4ade80; + --warn: #facc15; + --bad: #fb7185; +} + +[data-theme="graphite"] { + --bg: #151515; + --surface: #202020; + --surface-2: #2b2b2b; + --text: #f5f5f5; + --muted: #b2b2b2; + --border: #3a3a3a; + --accent: #a3e635; + --good: #86efac; + --warn: #fde047; + --bad: #fca5a5; +} + +[data-theme="nord"] { + --bg: #202632; + --surface: #2c3444; + --surface-2: #374155; + --text: #eceff4; + --muted: #c0c9d8; + --border: #4c566a; + --accent: #88c0d0; + --good: #a3be8c; + --warn: #ebcb8b; + --bad: #bf616a; +} + +[data-theme="dracula"] { + --bg: #1d1b26; + --surface: #282a36; + --surface-2: #343746; + --text: #f8f8f2; + --muted: #c7bfdc; + --border: #44475a; + --accent: #bd93f9; + --good: #50fa7b; + --warn: #f1fa8c; + --bad: #ff5555; +} + +[data-theme="solar"] { + --bg: #f4f0df; + --surface: #fffaf0; + --surface-2: #eee8d5; + --text: #273238; + --muted: #657b83; + --border: #d5cdb6; + --accent: #268bd2; + --good: #2aa198; + --warn: #b58900; + --bad: #dc322f; +} + +[data-theme="forest"] { + --bg: #101812; + --surface: #18251b; + --surface-2: #213326; + --text: #eef7ed; + --muted: #a7b9a6; + --border: #314638; + --accent: #7ddf64; + --good: #22c55e; + --warn: #eab308; + --bad: #ef4444; +} + +[data-theme="marine"] { + --bg: #081417; + --surface: #102225; + --surface-2: #183236; + --text: #edfdfd; + --muted: #9fc5c7; + --border: #28494e; + --accent: #2dd4bf; + --good: #5eead4; + --warn: #fcd34d; + --bad: #f97373; +} + +[data-theme="ember"] { + --bg: #171111; + --surface: #241818; + --surface-2: #362221; + --text: #fff7ed; + --muted: #d7b6a2; + --border: #513530; + --accent: #fb923c; + --good: #84cc16; + --warn: #facc15; + --bad: #f43f5e; +} + +[data-theme="paper"] { + --bg: #f7f8fa; + --surface: #ffffff; + --surface-2: #eef1f5; + --text: #151a22; + --muted: #5f6b7a; + --border: #d6dce5; + --accent: #2563eb; + --good: #16a34a; + --warn: #ca8a04; + --bad: #dc2626; +} + diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..0dc9126 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,73 @@ +# API + +All endpoints are served by the backend service and proxied by nginx under `/api`. + +## `GET /api/health` + +Returns: + +```json +{ "ok": true } +``` + +## `GET /api/config` + +Returns public runtime configuration with secrets removed. + +## `GET /api/dashboard` + +Returns JSON state, drive usage, cached library files, cached extension breakdowns, and dry-run status. This endpoint does not scan the full media filesystem. + +## `POST /api/scan` + +Runs one scanner pass immediately. In dry-run mode this only records plans. + +## `POST /api/library/scan` + +Refreshes the cached library index. The scan only enters direct child folders of each media drive named `Movies`, `TV`, or `TV Shows`. + +## `GET /api/library` + +Returns the cached library summary and grouped movie/series collections for the Library page. Raw indexed file items stay server-side to keep routine dashboard refreshes small. + +## `GET /api/downloads` + +Returns current files under `/downloads` plus recent Sortarr plans or moves whose source was under `/downloads`. + +## `GET /api/releases` + +Returns missing/upcoming TV episodes derived from the cached library metadata, then appends any explicitly enabled public release providers. + +## `GET /api/media/probe` + +Runs `ffprobe` for a selected media file under configured media/download roots and returns detected video, audio, and subtitle streams. + +## `POST /api/media/tracks` + +Remuxes a selected media file to set an audio/subtitle stream as default or remove an embedded audio/subtitle stream. In dry-run mode it returns the ffmpeg command without modifying the file. + +## `GET /api/theme/custom.css` + +Serves host-editable custom CSS from `/config/custom-theme.css`. + +## `POST /api/settings` + +Updates runtime settings used by the current backend process. Settings can be sent as nested sections such as `{"app": {"dry_run": true}}` or as legacy top-level app keys. + +Supported sections include `app`, `paths`, `library`, `metadata`, `theme`, `drives`, and `release_providers`. Runtime settings are saved in `/data/state.json`; they are not written back into TOML. + +## `GET /api/tools/subtitles` + +Audits the cached library index for media files missing sidecar subtitles. Run `POST /api/library/scan` first for current subtitle data. + +## `GET /api/tools/transcoder` + +Builds a transcode queue for cached indexed media that is not already `.mp4`. + +## `POST /api/tools/transcoder/run-next` + +Runs the next queued ffmpeg transcode when `dry_run` is disabled. In dry-run mode it reports what would run. + +## `GET /api/tools/duplicates` + +Reports duplicate movie or series groups from the cached library index. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..35cfb50 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,79 @@ +# Configuration + +Configuration is layered in this order: + +1. `backend/default-config/app.toml` +2. `config/app.toml` +3. `.env` variables passed into Docker Compose + +The backend deep-merges TOML files and then applies environment overrides for common deployment values. + +## Organizer Settings + +`[app]` + +- `dry_run`: plan without moving files. +- `scan_interval_seconds`: worker polling interval. +- `settle_seconds`: minimum file age before processing. +- `stable_checks`: number of matching size/mtime observations required before a file is considered stable. +- `incomplete_suffixes`: suffixes ignored while downloads are still active. +- `media_extensions`: media files eligible for organizing. +- `subtitle_extensions`: subtitle files visible to the scanner. +- `extra_keywords`: filename terms ignored by the organizer, such as samples and trailers. +- `library_scan_max_files`: maximum files indexed by the manual library scan. +- `library_scan_timeout_seconds`: timeout for the manual library scan. +- `cache_max_bytes`: maximum server-side cache size. Defaults to 20GB. + +`[library]` + +- `movie_folder`: destination folder template for movies. +- `series_folder`: destination folder template for shows. +- `movie_file`: Jellyfin-friendly movie filename template. +- `episode_file`: Jellyfin-friendly episode filename template. +- `collision`: `keep-both`, `skip`, or `replace`. +- `duplicate`: reserved duplicate policy hook. +- `permissions_mode`: final file mode after a move. +- `directory_mode`: directory mode applied to created destination folders. + +## Drives + +Each `[[drives]]` entry has: + +- `id`: stable machine name. +- `name`: dashboard display name. +- `path`: mounted drive path inside the container. +- `min_free_gb`: minimum free space required before the drive is eligible. + +Drive selection first checks whether the title already has a home under `Movies` or `Shows`. If not, it selects the eligible drive with the most free space. + +## Themes + +Bundled presets live in `web/src/themes.css`. The current presets are: + +`slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`. + +Runtime custom CSS is loaded from `/config/custom-theme.css` when `[theme].allow_custom_css` is enabled. Override any token: + +```css +:root { + --accent: #5cc8ff; + --radius: 4px; +} +``` + +## Release Providers + +`[[release_providers]]` supports pluggable free sources: + +- `type = "rss"` for RSS/Atom-style feeds. +- `type = "json"` for simple public JSON endpoints. + +Provider code is isolated in `backend/sortarr/releases.py` so new adapters can be added without touching the UI. + +## TMDb Metadata + +Set `TMDB_API_KEY` or `TMDB_BEARER_TOKEN` in `.env` to enrich manual library scans with TMDb posters, overviews, release dates, and TV season episode data. Without credentials, Sortarr still groups local media and shows placeholder covers. + +## Server Cache + +Sortarr stores reusable TMDb and ffprobe results under `/data/cache`. The default cache cap is 20GB via `[app].cache_max_bytes`; older cache files are pruned when new cache entries are written. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..0e24bac --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,62 @@ +# Operations + +## Dry Run First + +Keep this in `.env` until destination paths look correct: + +```bash +SORTARR_DRY_RUN=true +``` + +Then switch to: + +```bash +SORTARR_DRY_RUN=false +``` + +Restart: + +```bash +docker compose up -d +``` + +## Logs + +Backend logs are written to `/logs/sortarr.log` in the container and to the host path configured by `LOGS_PATH`. + +## Backups + +Back up: + +- `.env` +- `config/` +- `data/state.json` +- `logs/` if you need historical audit trails + +Media files are not stored inside containers. + +## Updating + +Because all source is mounted or copied from this project, update by editing files and rebuilding: + +```bash +docker compose up -d --build +``` + +## Transcoding + +The backend image includes `ffmpeg`. The dashboard Tools page can build a queue from the cached library index and run the next conversion. Keep dry-run enabled while checking output paths; actual transcoding only runs when `SORTARR_DRY_RUN=false` or dry-run is disabled from the runtime Settings page. + +## Track Editing + +The Library detail panel can inspect a selected file with `ffprobe` and remux embedded audio/subtitle streams to set defaults or remove tracks. Dry-run mode returns the planned `ffmpeg` command only. Disable dry-run only after confirming the command and keep media backups for any bulk edits. + +## Cache + +Reusable metadata and ffprobe results are cached under `/data/cache`. The default cap is 20GB and pruning removes oldest cache files first. + +## Recovery + +Sortarr moves through a temporary `.sorting` file before final placement. If a container stops mid-move, check the destination folder for `*.sorting` files and compare against `/downloads`. + +The app intentionally avoids deleting source folders and does not run destructive cleanup by default. diff --git a/proj-info.md b/proj-info.md new file mode 100644 index 0000000..b134c9f --- /dev/null +++ b/proj-info.md @@ -0,0 +1,251 @@ +# Sortarr Project Info + +Purpose: self-hosted Jellyfin ecosystem organizer and dashboard, fully editable and Docker Compose runnable. It watches downloads, plans/moves media into Jellyfin-friendly folders across four media drives, displays storage/library/download/release status, and exposes configurable tools such as subtitle audit and ffmpeg transcoding. + +## Runtime + +- Root: `/home/drop/jellyfin/scripts/sortarr` +- Web UI: `http://localhost:8088` or host LAN IP on port `8088` +- Backend API: port `8099` +- Compose files: `compose.yaml`, `compose.override.yaml`, `compose.prod.yaml` +- Env file: `.env` +- Default dry-run: enabled via `SORTARR_DRY_RUN=true` +- Active containers: `sortarr-web`, `sortarr-backend` +- Known unrelated/orphan container: `sortarr` may still appear restarting from an older compose shape. + +## Host Paths + +Configured in `.env`: + +- Downloads: `/home/drop/jellyfin/downloads` mounted as `/downloads` +- Media drive 1: `/home/drop/jellyfin/mediashare1` mounted as `/media/drive1` +- Media drive 2: `/home/drop/jellyfin/mediashare2` mounted as `/media/drive2` +- Media drive 3: `/home/drop/jellyfin/mediashare3` mounted as `/media/drive3` +- Media drive 4: `/home/drop/jellyfin/mediashare4` mounted as `/media/drive4` +- Config: `/home/drop/jellyfin/scripts/sortarr/config` +- Logs: `/home/drop/jellyfin/scripts/sortarr/logs` +- Data/state: `/home/drop/jellyfin/scripts/sortarr/data` + +## Architecture + +- `web`: nginx serves static HTML/CSS/JS from `web/src` and proxies `/api/*` to backend. +- `backend`: Python 3.12 stdlib HTTP API plus background scanner thread. Backend image installs `ffmpeg`. +- Optional profiles: + - `redis` profile `cache` + - `postgres` profile `database` + - `media-tools` profile `tools` + +No frontend framework and no backend web framework are used. This is intentional for editability. + +## Important Files + +- `.env.example`: sample deployment variables. +- `.env`: real local deployment paths and runtime values. Ignored by git. +- `compose.yaml`: main stack. +- `compose.override.yaml`: dev bind mounts and debug defaults. +- `compose.prod.yaml`: prod restart/dry-run defaults. +- `backend/default-config/app.toml`: full default config. +- `config/app.toml`: host-editable override config. +- `config/custom-theme.css`: host-editable CSS token overrides. +- `backend/sortarr/app.py`: API server and route handlers. +- `backend/sortarr/config.py`: TOML/env config loading and merging. +- `backend/sortarr/scanner.py`: 24/7 downloads scanner thread. +- `backend/sortarr/parser.py`: filename media parser. +- `backend/sortarr/organizer.py`: destination planning, collision handling, move execution, NFO writing. +- `backend/sortarr/storage.py`: drive stats and drive selection. +- `backend/sortarr/library.py`: explicit library scan/indexing and Movies/TV collection grouping. +- `backend/sortarr/metadata.py`: optional TMDb metadata lookup for covers, summaries, and TV episode lists. +- `backend/sortarr/media_probe.py`: safe ffprobe wrapper for audio/subtitle/video stream details. +- `backend/sortarr/tools.py`: subtitle audit and transcoder tools. +- `backend/sortarr/downloads.py`: current `/downloads` listing and recent moved/planned download history. +- `backend/sortarr/releases.py`: free RSS/JSON upcoming release providers. +- `backend/sortarr/store.py`: JSON state store in `data/state.json`. +- `web/src/index.html`: app shell and page markup. +- `web/src/app.js`: hash router, API calls, rendering, settings/tools behavior. +- `web/src/styles.css`: layout/design system. +- `web/src/themes.css`: 10 editable theme presets. +- `docs/*.md`: API/config/operations docs. + +## Configuration Model + +Config precedence: + +1. `backend/default-config/app.toml` +2. `config/app.toml` +3. `.env` variables passed into Compose +4. Runtime settings saved in `data/state.json` under `settings` + +Key config areas: + +- `[app]`: dry-run, scan interval, settle time, log level, extensions, incomplete suffixes, library scan limits, cache size cap. +- `[paths]`: downloads/data/logs/cache container paths. +- `[[drives]]`: four media drives with id/name/path/min-free-space. +- `[library]`: folder and filename templates, collision policy, permissions mode. +- `[metadata]`: NFO behavior and optional TMDb credentials/settings. +- `[[release_providers]]`: free RSS/JSON providers. +- `[theme]`: default theme and custom CSS. + +Runtime Settings page can update: + +- `dry_run` +- `scan_interval_seconds` +- `settle_seconds` +- `library_scan_max_files` +- `library_scan_timeout_seconds` +- `log_level` + +## Media Organizer Behavior + +Background scanner watches `/downloads` continuously. + +Safety: + +- Ignores incomplete suffixes such as `.part`, `.!qB`, `.tmp`, `.crdownload`. +- Requires files to be stable for `settle_seconds`. +- Dry-run plans moves without moving. +- Actual moves go through a temporary `.sorting` path before final rename. +- Collision policies: `keep-both`, `skip`, `replace`. +- Events and plans are stored in `data/state.json`. + +Parsing: + +- Detects movies, episodes, seasons, and multi-episode releases. +- Recognizes `S01E02`, `S01E02E03`, and `1x02` style episode patterns. +- Extracts year and quality tokens where present. + +Drive choice: + +1. Checks whether the title already has a home under `Movies` or `Shows`. +2. If no home exists, picks eligible drive with most free space. +3. Enforces `min_free_gb`. + +Naming: + +- Movies: `Movies/{title} ({year})/{title} ({year}){quality}{ext}` +- Episodes: `Shows/{title}/Season {season:02d}/{title} - SxxExx - Episode{quality}{ext}` +- Templates are editable in TOML. + +## Library Indexing + +Regular dashboard refresh does not walk the media filesystem. + +Library indexing is explicit: + +- UI button: Library page -> `Scan library` +- API: `POST /api/library/scan` +- Scans only direct child folders of each media drive named: + - `Movies` + - `Shows` + - `TV` + - `TV Shows` + +The library scanner skips system/recycle folders and has timeout/file-count limits. Results are cached in `data/state.json` and used by dashboard/tools. + +Current cache fields include: + +- drive stats +- indexed media items split by `Movies` and `TV`/`TV Shows` roots +- collection groups for movies and TV series +- optional TMDb posters, overviews, and TV season episode metadata +- extension breakdown +- scanned file count +- truncation flag +- per-media `has_subtitles` when available from scan + +## Frontend Pages + +The UI uses hash routing in `web/src/app.js`. + +Routes: + +- `#/overview`: storage, file type breakdown, recent events. +- `#/library`: poster grid with All/Movies/TV Shows tabs, series/episode drilldown, missing/upcoming episode state, and media stream inspection. +- `#/downloads`: current `/downloads` media bundles with matching subtitles/sidecars plus recent Sortarr plans/moves from `/downloads`. +- `#/releases`: missing/upcoming library episodes plus configured public providers. +- `#/tools`: transcoder, subtitle audit, duplicate finder placeholder. +- `#/settings`: appearance controls, descriptive runtime controls, raw config details. + +Theme system: + +- Theme choices live on the Settings page and persist in `localStorage`. +- Compact density toggle persists in `localStorage`. +- Presets: `slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`. +- Tokens live in `web/src/themes.css`; host overrides in `config/custom-theme.css`. + +## Backend API + +- `GET /api/health`: healthcheck. +- `GET /api/config`: public config with secrets removed. +- `GET /api/dashboard`: state + cached library + drive stats; no filesystem library scan. +- `POST /api/scan`: run one downloads scan now. +- `POST /api/library/scan`: refresh cached library index. +- `GET /api/downloads`: current `/downloads` files plus recent planned/moved download history. +- `GET /api/releases`: upcoming releases. +- `GET /api/media/probe`: ffprobe stream details for a selected file. +- `POST /api/media/tracks`: dry-run or execute ffmpeg remux track default/removal changes. +- `GET /api/theme/custom.css`: custom CSS. +- `POST /api/settings`: update runtime settings. +- `GET /api/tools/subtitles`: subtitle audit from cached library data. +- `GET /api/tools/transcoder`: build ffmpeg transcode queue from cached library. +- `POST /api/tools/transcoder/run-next`: run next ffmpeg transcode if dry-run is disabled. + +## Tools + +Subtitle audit: + +- Uses cached library index, not live filesystem probes. +- Requires a fresh library scan for accurate `has_subtitles`. +- Reports checked count, with-subtitles count, missing count, unknown count, and missing examples. + +Transcoder: + +- Backend image installs `ffmpeg`. +- Queue includes cached indexed media not already `.mp4`. +- Output path is source path with `.mp4` suffix. +- Command uses `libx264`, `aac`, and `mov_text`. +- In dry-run mode, `run-next` reports without executing. +- With dry-run disabled, runs one job synchronously with a 1 hour timeout. + +Duplicate finder: + +- Reports duplicate title groups from the cached library index. + +## Release Providers + +No paid API dependency. + +Bundled providers, disabled by default so the Releases page stays centered on the local library: + +- TMDb RSS upcoming movies. +- TVMaze public schedule JSON. + +Provider logic is in `backend/sortarr/releases.py`; add new RSS/JSON adapters there and configure in TOML. + +## Verification Commands + +Common checks: + +```bash +python -m compileall backend/sortarr +node --check web/src/app.js +docker compose config +docker compose up -d --build +docker exec sortarr-backend python -m sortarr.healthcheck +docker exec sortarr-backend ffmpeg -version +``` + +Endpoint checks from inside backend: + +```bash +docker exec sortarr-backend python -c "from urllib.request import urlopen; print(urlopen('http://127.0.0.1:8099/api/health').status)" +docker exec sortarr-backend python -c "from urllib.request import urlopen; import json; print(json.load(urlopen('http://127.0.0.1:8099/api/tools/transcoder'))['transcoder']['ffmpeg_available'])" +``` + +## Current Caveats / Next Good Tasks + +- Settings are runtime/persisted in JSON state but not written back into `config/app.toml`. +- Transcoding runs synchronously; future improvement should add a job queue with progress/cancel/history. +- Duplicate finder reports duplicate title groups from the cached library index. +- Subtitle audit only becomes exact after a fresh manual library scan because it relies on cached `has_subtitles`. +- Library scan only checks direct child folders named `Movies`, `TV`, or `TV Shows` under each media drive. +- Backend is stdlib HTTP server; fine for self-hosting behind LAN/reverse proxy, but add auth before exposing publicly. diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..b302f74 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine +COPY src /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..5d5bb4f --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types application/json application/javascript text/css text/plain; + + location /api/ { + proxy_pass http://backend:8099/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /index.html { + add_header Cache-Control "no-store"; + } + + location = /app.js { + add_header Cache-Control "no-store"; + } + + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +} diff --git a/web/src/app.js b/web/src/app.js new file mode 100644 index 0000000..b52e008 --- /dev/null +++ b/web/src/app.js @@ -0,0 +1,969 @@ +const themes = ["slate", "midnight", "graphite", "nord", "dracula", "solar", "forest", "marine", "ember", "paper"]; +const themeLabels = { + slate: "Slate", + midnight: "Midnight", + graphite: "Graphite", + nord: "Nord", + dracula: "Dracula", + solar: "Solar", + forest: "Forest", + marine: "Marine", + ember: "Ember", + paper: "Paper", +}; +const settingsGroups = [ + { + title: "Organizer", + description: "Controls how Sortarr watches /downloads, decides what is safe to move, and handles uncertain matches.", + fields: [ + ["app.dry_run", "Dry-run mode", "checkbox", "Plan files without moving them. Disable only when destinations and confidence scores look correct."], + ["app.scan_interval_seconds", "Scan interval", "range", "How often the background scanner checks /downloads.", { min: 5, max: 300, step: 5, unit: "sec" }], + ["app.settle_seconds", "File settle time", "range", "How long a file must remain unchanged before Sortarr can plan or move it.", { min: 10, max: 1800, step: 10, unit: "sec" }], + ["app.stable_checks", "Stable checks", "range", "Number of matching size/mtime observations expected before a file is considered stable.", { min: 1, max: 8, step: 1, unit: "checks" }], + ["app.auto_move_min_confidence", "Auto-move confidence", "range", "Plans at or above this score can move automatically when dry-run is off.", { min: 50, max: 100, step: 1, unit: "%" }], + ["app.review_min_confidence", "Review confidence", "range", "Plans at or above this score stay in the review queue instead of being treated as low confidence.", { min: 0, max: 100, step: 1, unit: "%" }], + ["app.organization_metadata_budget_seconds", "Metadata budget", "range", "Maximum total TMDb lookup time per organizer pass before Sortarr falls back to filename-only planning.", { min: 0, max: 120, step: 5, unit: "sec" }], + ["app.organization_metadata_timeout_seconds", "Metadata timeout", "range", "Maximum time a single TMDb request can wait.", { min: 1, max: 15, step: 1, unit: "sec" }], + ["app.metadata_parallelism", "Metadata parallelism", "range", "How many TMDb lookups a library scan can run at the same time.", { min: 1, max: 12, step: 1, unit: "workers" }], + ], + }, + { + title: "Scanning", + description: "Limits for library indexing and file classification.", + fields: [ + ["app.library_scan_max_files", "Library scan file limit", "range", "Maximum filesystem entries inspected by a manual library scan.", { min: 1000, max: 250000, step: 1000, unit: "files" }], + ["app.library_scan_timeout_seconds", "Library scan timeout", "range", "Maximum runtime for a manual library scan before returning a partial result.", { min: 3, max: 180, step: 1, unit: "sec" }], + ["app.cache_max_bytes", "Server cache limit", "range", "Maximum cache size for server-side metadata/probe data.", { min: 1073741824, max: 21474836480, step: 1073741824, unit: "bytes" }], + ["app.media_extensions", "Media extensions", "list", "Extensions treated as media files in /downloads."], + ["app.subtitle_extensions", "Subtitle extensions", "list", "Extensions packaged with matching movies and episodes."], + ["app.incomplete_suffixes", "Incomplete suffixes", "list", "Suffixes ignored while downloads are still active."], + ["app.extra_keywords", "Extra ignore keywords", "list", "Filename terms that identify extras rather than primary media."], + ], + }, + { + title: "Paths", + description: "Container paths used by the backend. Host bind mounts are still controlled by Docker compose and .env.", + fields: [ + ["paths.downloads", "Downloads path", "text", "Container path Sortarr watches for new downloads."], + ["paths.data", "Data path", "text", "Container path for state and runtime data."], + ["paths.logs", "Logs path", "text", "Container path for backend logs."], + ["paths.cache", "Cache path", "text", "Container path for metadata and probe caches."], + ], + }, + { + title: "Library Naming", + description: "Templates used when Sortarr creates destination folders and filenames.", + fields: [ + ["library.movie_folder", "Movie folder", "text", "Folder template for movies."], + ["library.series_folder", "Series folder", "text", "Folder template for TV episodes."], + ["library.movie_file", "Movie filename", "text", "Filename template for movies."], + ["library.episode_file", "Episode filename", "text", "Filename template for TV episodes."], + ["library.subtitle_file", "Subtitle filename", "text", "Filename template for packaged subtitles."], + ["library.unknown_folder", "Unknown folder", "text", "Fallback folder for media that cannot be confidently classified."], + ["library.collision", "File collision policy", "select", "What to do when the destination file already exists.", { options: ["keep-both", "skip", "replace"] }], + ["library.duplicate", "Duplicate policy", "select", "How duplicate titles should be handled.", { options: ["skip", "keep-both"] }], + ["library.permissions_mode", "File permissions", "text", "Octal mode applied to moved media files."], + ["library.directory_mode", "Directory permissions", "text", "Octal mode intended for created library folders."], + ], + }, + { + title: "Metadata", + description: "TMDb and local metadata behavior.", + fields: [ + ["metadata.tmdb_enabled", "TMDb enabled", "checkbox", "Allow Sortarr to enrich plans and library items with TMDb data."], + ["metadata.write_nfo", "Write NFO files", "checkbox", "Write simple NFO metadata beside moved files."], + ["metadata.prefer_existing_nfo", "Prefer existing NFO", "checkbox", "Use existing local NFO data before online metadata when available."], + ["metadata.provider_order", "Provider order", "list", "Metadata providers in priority order."], + ["metadata.tmdb_api_key", "TMDb API key", "text", "TMDb v3 API key used for lookups. This is stored in /data/state.json when saved here."], + ["metadata.tmdb_bearer_token", "TMDb bearer token", "text", "Optional TMDb v4 bearer token. This is stored in /data/state.json when saved here."], + ["metadata.tmdb_language", "TMDb language", "text", "Language code used for TMDb requests, such as en-US."], + ["metadata.tmdb_image_base", "TMDb image base", "text", "Base URL used for poster and backdrop images."], + ], + }, + { + title: "Appearance", + description: "Dashboard theme and custom CSS behavior.", + fields: [ + ["theme.default", "Default theme", "select", "Theme used when a browser has not chosen one locally.", { options: themes }], + ["theme.allow_custom_css", "Allow custom CSS", "checkbox", "Serve /config/custom-theme.css when present."], + ["theme.custom_css_path", "Custom CSS path", "text", "Container path for optional custom dashboard CSS."], + ], + }, + { + title: "Logging", + description: "Backend diagnostics.", + fields: [ + ["app.log_level", "Log level", "select", "Controls backend verbosity.", { options: ["DEBUG", "INFO", "WARNING", "ERROR"] }], + ["app.name", "Application name", "text", "Display/runtime name for this Sortarr instance."], + ], + }, +]; +const state = { + dashboard: null, + config: null, + downloads: null, + library: null, + releases: [], + route: "overview", + libraryTab: "all", + libraryLimit: 120, + selectedMedia: null, +}; + +const $ = (id) => document.getElementById(id); +const bytes = (value = 0) => { + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = value; + let idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx += 1; + } + return `${size.toFixed(idx ? 1 : 0)} ${units[idx]}`; +}; +const date = (seconds) => seconds ? new Date(seconds * 1000).toLocaleString() : ""; +const mediaLabel = (kind) => kind === "tv" ? "TV Shows" : kind === "movie" ? "Movies" : "Other"; +const esc = (value = "") => String(value).replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", +})[char]); + +function toast(message, type = "info") { + const host = $("toastHost"); + if (!host) return; + const item = document.createElement("div"); + item.className = `toast ${type}`; + item.textContent = message; + host.appendChild(item); + setTimeout(() => item.classList.add("visible"), 10); + setTimeout(() => { + item.classList.remove("visible"); + setTimeout(() => item.remove(), 180); + }, 4200); +} + +function setTheme(theme) { + document.documentElement.dataset.theme = theme; + localStorage.setItem("sortarr-theme", theme); + renderThemeOptions(); +} + +async function api(path, options) { + const response = await fetch(path, options); + if (!response.ok) throw new Error(`${path} returned ${response.status}`); + return response.json(); +} + +function routeFromHash() { + return (location.hash.replace("#/", "") || "overview").split("?")[0]; +} + +function renderRoute() { + state.route = routeFromHash(); + document.querySelectorAll(".page").forEach((page) => page.classList.remove("active")); + document.querySelectorAll("nav a").forEach((link) => link.classList.toggle("active", link.dataset.route === state.route)); + const page = $(`page-${state.route}`); + (page || $("page-overview")).classList.add("active"); + if (state.route === "library" && !state.library) { + loadLibrary().catch((error) => toast(`Library load failed: ${error.message}`, "error")); + } +} + +async function loadDashboard() { + state.dashboard = await api("/api/dashboard"); + renderDashboard(); + if (state.downloads) renderDownloads(); +} + +async function loadLibrary() { + const payload = await api("/api/library"); + state.library = payload.library; + renderLibraryStatus(); + renderLibraryTabs(); + renderLibrary(); +} + +async function loadConfig() { + state.config = await api("/api/config"); + $("configView").textContent = JSON.stringify(state.config, null, 2); + renderSettings(); + if (!localStorage.getItem("sortarr-theme") && state.config.theme?.default) { + setTheme(state.config.theme.default); + } else { + renderThemeOptions(); + } +} + +async function loadDownloads() { + const payload = await api("/api/downloads"); + state.downloads = payload.downloads; + renderDownloads(); +} + +async function loadReleases() { + const payload = await api("/api/releases"); + state.releases = payload.releases || []; + renderReleases(); +} + +function renderDashboard() { + const data = state.dashboard; + $("statusLine").textContent = data.dry_run ? "Dry-run mode is active" : "Organizer is allowed to move files"; + $("storageCards").innerHTML = data.library.drives.map((drive) => { + const pct = drive.total ? Math.round((drive.used / drive.total) * 100) : 0; + return `
+ ${drive.name} +
+
${bytes(drive.used)} used${bytes(drive.free)} free
+
`; + }).join(""); + + const extensions = Object.entries(data.library.extensions); + const max = Math.max(...extensions.map(([, count]) => count), 1); + $("extensionBreakdown").innerHTML = extensions.slice(0, 12).map(([ext, count]) => ` +
${ext}
${count}
+ `).join("") || "

No files indexed yet.

"; + + renderLibraryStatus(); + + $("events").innerHTML = data.state.events.slice(0, 12).map((event) => ` +
${event.message}
${date(event.time)}
+ `).join("") || "

No organizer events yet.

"; + + if (state.route === "library" && state.library) { + renderLibraryTabs(); + renderLibrary(); + } +} + +function activeLibrary() { + return state.library || state.dashboard?.library || {}; +} + +function renderLibraryStatus() { + const library = activeLibrary(); + const counts = library.counts || {}; + $("libraryStatus").textContent = library.scanned_files + ? `Indexed ${counts.total || 0} media files across ${counts.movies || 0} movies and ${counts.tv || 0} TV items from ${library.scanned_files} scanned files${library.truncated ? " before the configured scan limit or timeout" : ""}.` + : "Library has not been scanned yet. Use Scan library to index Movies, TV, and TV Shows folders."; +} + +function libraryCollections() { + const filter = $("libraryFilter").value.toLowerCase(); + const collections = activeLibrary().collections || { movies: [], series: [] }; + const all = [ + ...collections.movies.map((item) => ({ ...item, library: "movie" })), + ...collections.series.map((item) => ({ ...item, library: "tv" })), + ]; + return all.filter((item) => { + const meta = item.metadata || {}; + const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab; + const matchesFilter = [item.title, meta.title, meta.overview, item.year, mediaLabel(item.library)].join(" ").toLowerCase().includes(filter); + return matchesTab && matchesFilter; + }); +} + +function renderLibraryTabs() { + const collectionCounts = activeLibrary().collections || {}; + const tabs = [ + ["all", "All", (collectionCounts.movies?.length || 0) + (collectionCounts.series?.length || 0)], + ["movie", "Movies", collectionCounts.movies?.length || 0], + ["tv", "TV Shows", collectionCounts.series?.length || 0], + ]; + $("libraryTabs").innerHTML = tabs.map(([key, label, count]) => ` + + `).join(""); + document.querySelectorAll("[data-library-tab]").forEach((button) => { + button.addEventListener("click", () => { + state.libraryTab = button.dataset.libraryTab; + state.libraryLimit = 120; + renderLibraryTabs(); + renderLibrary(); + }); + }); +} + +function renderLibrary() { + const rows = libraryCollections(); + const visible = rows.slice(0, state.libraryLimit); + $("libraryGrid").innerHTML = visible.map((item) => mediaCard(item)).join("") || "

No matching media.

"; + document.querySelectorAll("[data-media-key]").forEach((button) => { + button.addEventListener("click", () => selectMedia(button.dataset.mediaKey)); + }); + $("libraryPager").innerHTML = rows.length > state.libraryLimit + ? `Showing ${visible.length} of ${rows.length} matching titles.` + : `Showing ${visible.length} matching titles.`; + const more = $("libraryMoreButton"); + if (more) { + more.addEventListener("click", () => { + state.libraryLimit += 120; + renderLibrary(); + }); + } + if (!state.selectedMedia && visible[0]) { + selectMedia(visible[0].key, false); + } else if (state.selectedMedia) { + renderMediaDetail(state.selectedMedia); + } +} + +function mediaCard(item) { + const meta = item.metadata || {}; + const title = meta.title || item.title; + const subtitle = item.library === "tv" + ? `${item.seasons?.length || 0} seasons, ${item.files?.length || 0} files` + : `${item.year || meta.release_date || ""} ${item.versions?.length > 1 ? `- ${item.versions.length} versions` : ""}`; + const versionBadge = item.library === "movie" && (item.versions?.length || item.files?.length || 0) > 1 + ? `${item.versions?.length || item.files?.length}` + : ""; + const cover = meta.poster + ? `` + : `${esc(title.slice(0, 1) || "?")}`; + return ``; +} + +function findMedia(key) { + const collections = activeLibrary().collections || { movies: [], series: [] }; + return [...collections.movies, ...collections.series].find((item) => item.key === key); +} + +function selectMedia(key, scroll = true) { + const item = findMedia(key); + if (!item) return; + state.selectedMedia = item; + document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key)); + renderMediaDetail(item); + if (scroll) $("libraryDetail").scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function renderMediaDetail(item) { + const meta = item.metadata || {}; + const files = item.files || []; + const title = meta.title || item.title; + const cover = meta.poster ? `` : `${esc(title.slice(0, 1) || "?")}`; + const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item); + $("libraryDetail").innerHTML = `
+
${cover}
+
+
+
+

${esc(title)}

+

${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}

+
+ ${files[0] ? `` : ""} +
+ ${meta.overview ? `

${esc(meta.overview)}

` : ""} + ${detail} +
+
+
`; + document.querySelectorAll("[data-probe-path]").forEach((button) => { + button.addEventListener("click", () => inspectMedia(button.dataset.probePath)); + }); +} + +function renderMovieDetail(item) { + const versions = item.versions || (item.files || []).map((file) => ({ + name: file.name, + path: file.path, + drive: file.drive, + size: file.size, + tags: [], + })); + return `
+

${versions.length > 1 ? `${versions.length} Versions` : "Local File"}

+
${versions.map(versionRow).join("")}
+
`; +} + +function versionRow(version) { + const tags = (version.tags || []).map((tag) => `${esc(tag)}`).join(""); + return `
+ ${esc(version.name || "")} +
${esc(version.drive || "")}${bytes(version.size)}
+ ${tags ? `
${tags}
` : ""} + ${esc(version.path || "")} +
`; +} + +function renderSeriesDetail(item) { + return `
${(item.seasons || []).map((season) => ` +
+ Season ${season.season || "Unknown"} ${season.episodes.length} episodes +
${season.episodes.map((episode) => ` +
+
+ ${episode.episode ? `E${String(episode.episode).padStart(2, "0")} - ` : ""}${esc(episode.title || "Episode")} + ${episode.air_date || ""} ${episode.status !== "present" ? episode.status : ""} + ${episode.overview ? `

${esc(episode.overview)}

` : ""} +
+
+ ${(episode.files || []).map((file) => ``).join("") || "No file"} +
+
+ `).join("")}
+
+ `).join("")}
`; +} + +function fileRow(file) { + return `
+ ${esc(file.name)} +
${esc(file.drive || "")}${bytes(file.size)}
+ ${esc(file.path)} +
`; +} + +async function inspectMedia(path) { + const output = $("probeOutput"); + output.innerHTML = "

Inspecting media streams...

"; + const payload = await api(`/api/media/probe?path=${encodeURIComponent(path)}`); + const media = payload.media; + state.currentProbePath = path; + output.innerHTML = `
+

Media Info

+
+
Video${streamRows(media.video)}
+
Audio Tracks${streamRows(media.audio)}
+
Subtitles${streamRows(media.subtitles)}
+
+

Track edits remux the selected file. Dry-run mode reports the command without changing the file.

+
`; + document.querySelectorAll("[data-track-action]").forEach((button) => { + button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex))); + }); +} + +function streamRows(streams = []) { + return streams.map((stream) => { + const tags = stream.tags || {}; + return `
+ ${esc(stream.codec_name || stream.codec_type || "unknown")} + ${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""} + ${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? ` + + + ` : ""} +
`; + }).join("") || "

None detected.

"; +} + +async function editTrack(path, action, streamIndex) { + const output = $("probeOutput"); + const payload = await api("/api/media/tracks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, action, stream_index: streamIndex }), + }); + const result = payload.media; + output.insertAdjacentHTML("afterbegin", `
+ Track edit: ${esc(result.status)} + ${result.command ? `${esc(result.command.join(" "))}` : ""} + ${result.stderr ? `
${esc(result.stderr)}
` : ""} +
`); + if (result.status === "updated") { + await inspectMedia(path); + } +} + +function renderDownloads() { + const downloads = state.downloads; + if (!downloads) return; + $("downloadsStatus").textContent = downloads.error + ? `Cannot read ${downloads.path}: ${downloads.error}` + : `${downloads.counts.current} files in ${downloads.path}, ${downloads.counts.media} media files, ${downloads.counts.subtitles || 0} subtitle files, ${downloads.counts.incomplete} incomplete files. Total size: ${bytes(downloads.total_size)}.`; + const queue = state.dashboard?.state?.organizer?.queue || []; + const queueCounts = queue.reduce((acc, plan) => { + const key = plan.status || plan.result || "planned"; + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + $("organizerSummary").innerHTML = [ + ["ready", queueCounts.ready || 0], + ["review", queueCounts["needs-review"] || 0], + ["held", queueCounts.held || 0], + ["dry-run", queueCounts["dry-run"] || 0], + ["moved", queueCounts.moved || 0], + ].map(([label, count]) => `${count}${label}`).join(""); + $("organizerRows").innerHTML = queue.slice(0, 100).map(organizerCard).join("") || "

No organizer plans yet. Run scan or wait for the background scanner.

"; + document.querySelectorAll("[data-plan-action]").forEach((button) => { + button.addEventListener("click", () => updateOrganizerPlan(button.dataset.planAction, button.dataset.planId)); + }); + $("downloadRows").innerHTML = [ + ...(downloads.bundles || []).slice(0, 150).map((bundle) => downloadBundle(bundle)), + ...(downloads.loose || []).slice(0, 80).map((item) => ` +
+ ${esc(item.relative_path)} +
${item.is_incomplete ? "Incomplete" : item.is_subtitle ? "Loose subtitle" : "Sidecar file"}${bytes(item.size)}
+ Modified ${date(item.modified)} +
+ `), + ].join("") || "

/downloads is currently empty.

"; + $("recentDownloadRows").innerHTML = downloads.recent.slice(0, 100).map((item) => ` +
+ ${item.title || item.source} + ${item.status} ${item.type || "item"}${item.drive ? ` to ${item.drive}` : ""} + ${item.destination || item.source} + ${date(item.updated_at)} +
+ `).join("") || "

No recent Sortarr plans or moves from /downloads yet.

"; +} + +function organizerCard(plan) { + const status = plan.status || plan.result; + const result = plan.result && plan.result !== plan.status ? ` (${plan.result})` : ""; + const confidenceClass = plan.confidence >= 90 ? "good" : plan.confidence >= 60 ? "warn" : "bad"; + const metaSource = plan.metadata?.source === "tmdb" ? "TMDb matched" : "Filename parsed"; + const label = plan.media?.type === "episode" && plan.media?.season + ? `TV episode S${String(plan.media.season).padStart(2, "0")}E${String(plan.media.episode).padStart(2, "0")}` + : plan.media?.type === "movie" ? `Movie ${plan.media?.year || ""}` : esc(plan.media?.type || ""); + return `
+
+
+ ${esc(plan.media?.title || plan.source)} + ${esc(label)} - ${esc(metaSource)} + ${plan.media?.episode_title ? `${esc(plan.media.episode_title)}` : ""} +
+ ${plan.confidence || 0}% +
+
+ From${esc(plan.source || "")} + To${esc(plan.destination || "No destination planned")} +
+
${(plan.reasons || []).map((reason) => `${esc(reason)}`).join("")}
+
${esc(`${status || ""}${result}`)}${(plan.subtitles || []).length} subtitles
+ ${(plan.subtitles || []).length ? `
${plan.subtitles.map((subtitle) => `${esc(subtitle.language || "und")} -> ${esc(subtitle.destination || "not planned")}`).join("")}
` : ""} +
+ + +
+
`; +} + +async function updateOrganizerPlan(action, id) { + await api(`/api/organizer/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + await Promise.all([loadDashboard(), loadDownloads()]); +} + +function downloadBundle(bundle) { + const media = bundle.media; + return `
+
+
+ ${esc(media.name)} + ${esc(media.folder || "/downloads")} +
+ ${bytes(bundle.size)} +
+
Media file${date(media.modified)}
+
+ ${(bundle.subtitles || []).map((subtitle) => `${esc(subtitle.name)}`).join("") || "No matching subtitles found"} +
+
`; +} + +function renderReleases() { + $("releaseRows").innerHTML = state.releases.map((item) => ` +
+ ${item.poster ? `` : ""} + ${esc(item.title || item.error || "Unknown")} + ${item.episode_title ? `${esc(`S${String(item.season).padStart(2, "0")}E${String(item.episode).padStart(2, "0")} - ${item.episode_title}`)}` : ""} + ${esc(item.status || item.provider || "")} + ${esc(item.date || item.type || "")} + ${item.library_key ? `Open in library` : ""} +
+ `).join("") || "

No release providers returned data.

"; + document.querySelectorAll("[data-release-key]").forEach((link) => { + link.addEventListener("click", () => setTimeout(() => selectMedia(link.dataset.releaseKey), 50)); + }); +} + +function renderThemeOptions() { + const wrap = $("themeOptions"); + if (!wrap) return; + const current = localStorage.getItem("sortarr-theme") || state.config?.theme?.default || "slate"; + wrap.innerHTML = themes.map((theme) => ` + + `).join(""); + document.querySelectorAll("[data-theme-choice]").forEach((button) => { + button.addEventListener("click", () => setTheme(button.dataset.themeChoice)); + }); +} + +function getPath(root, path) { + return path.split(".").reduce((value, key) => value?.[key], root); +} + +function setPath(root, path, value) { + const keys = path.split("."); + let target = root; + keys.slice(0, -1).forEach((key) => { + target[key] = target[key] || {}; + target = target[key]; + }); + target[keys[keys.length - 1]] = value; +} + +function fieldMeta(tuple) { + const [path, label, type, help, options = {}] = tuple; + return { path, label, type, help, ...options }; +} + +function settingField(meta) { + const value = getPath(state.config, meta.path); + const id = meta.path.replaceAll(".", "__"); + const body = `
+
+ ${meta.label} + ${esc(meta.path)} +
+ ${meta.help} +
`; + if (meta.type === "checkbox") { + return `
${body}
`; + } + if (meta.type === "select") { + const options = (meta.options || []).map((option) => ``); + return `
${body}
`; + } + if (meta.type === "list") { + return `
${body}
Comma separated
`; + } + if (meta.type === "text") { + return `
${body}
`; + } + return `
${body}
+ + ${meta.unit} +
`; +} + +function syncSettingControls() { + document.querySelectorAll("[data-range-for]").forEach((range) => { + range.addEventListener("input", () => { + const number = document.querySelector(`[data-number-for="${range.dataset.rangeFor}"]`); + if (number) number.value = range.value; + }); + }); + document.querySelectorAll("[data-number-for]").forEach((number) => { + number.addEventListener("input", () => { + const range = document.querySelector(`[data-range-for="${number.dataset.numberFor}"]`); + if (range) range.value = number.value; + }); + }); +} + +function renderDriveSettings() { + const drives = state.config?.drives || []; + return `
+ +
+

Storage Drives

+

Destination drives Sortarr can choose when moving organized media.

+
+ ${drives.length} drives +
+
${drives.map((drive, idx) => ` +
+
+
+ ${esc(drive.name || drive.id || `Drive ${idx + 1}`)} + drives[${idx}] +
+ Drive identity, container path, and minimum free-space reserve. +
+ + + + + + +
+ `).join("")}
+
`; +} + +function collectDriveSettings() { + return [...document.querySelectorAll("[data-drive-index]")].map((row) => ({ + id: row.querySelector('[data-drive-field="id"]').value, + name: row.querySelector('[data-drive-field="name"]').value, + path: row.querySelector('[data-drive-field="path"]').value, + min_free_gb: Number(row.querySelector('[data-drive-field="min_free_gb"]').value), + })); +} + +function renderReleaseProviderSettings() { + const providers = state.config?.release_providers || []; + return `
+ +
+

Release Providers

+

Sources used by the Releases tab for upcoming or missing media context.

+
+ ${providers.length} providers +
+
${providers.map((provider, idx) => ` +
+
+
+ ${esc(provider.name || provider.id || `Provider ${idx + 1}`)} + release_providers[${idx}] +
+ Enable status, provider type, and feed URL. +
+ + + + + + + +
+ `).join("")}
+
`; +} + +function collectReleaseProviderSettings() { + return [...document.querySelectorAll("[data-provider-index]")].map((row) => ({ + id: row.querySelector('[data-provider-field="id"]').value, + name: row.querySelector('[data-provider-field="name"]').value, + enabled: row.querySelector('[data-provider-field="enabled"]').checked, + type: row.querySelector('[data-provider-field="type"]').value, + url: row.querySelector('[data-provider-field="url"]').value, + })); +} + +function renderSettings() { + if (!state.config) return; + $("settingsForm").innerHTML = settingsGroups.map((group) => ` +
+ +
+

${esc(group.title)}

+

${esc(group.description)}

+
+ ${group.fields.length} settings +
+
${group.fields.map((field) => settingField(fieldMeta(field))).join("")}
+
+ `).join("") + renderDriveSettings() + renderReleaseProviderSettings(); + syncSettingControls(); + renderThemeOptions(); +} + +async function saveSettings() { + const button = $("settingsSaveButton"); + const notice = $("settingsNotice"); + button.disabled = true; + button.textContent = "Saving..."; + if (notice) notice.textContent = ""; + const updates = {}; + try { + document.querySelectorAll("[data-setting]").forEach((field) => { + const path = field.dataset.setting; + if (!path) return; + if (field.type === "checkbox") { + setPath(updates, path, field.checked); + } else if (field.type === "range" || field.type === "number") { + setPath(updates, path, Number(field.value)); + } else if (field.dataset.settingType === "list") { + setPath(updates, path, field.value.split(",").map((item) => item.trim()).filter(Boolean)); + } else { + if ((path === "metadata.tmdb_api_key" || path === "metadata.tmdb_bearer_token") && field.value === "********") { + return; + } + setPath(updates, path, field.value); + } + }); + updates.drives = collectDriveSettings(); + updates.release_providers = collectReleaseProviderSettings(); + const payload = await api("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + state.config = payload.config; + $("configView").textContent = JSON.stringify(state.config, null, 2); + renderSettings(); + await loadDashboard(); + const message = "Settings saved. Run a library scan to refresh TMDb covers and episode metadata."; + if (notice) notice.textContent = message; + toast(message, "success"); + } catch (error) { + const message = `Settings save failed: ${error.message}`; + if (notice) notice.textContent = message; + toast(message, "error"); + } finally { + button.disabled = false; + button.textContent = "Save settings"; + } +} + +async function testTmdb() { + const button = $("tmdbTestButton"); + const notice = $("settingsNotice"); + button.disabled = true; + button.textContent = "Testing..."; + if (notice) notice.textContent = "Testing TMDb API credentials..."; + try { + const payload = await api("/api/metadata/tmdb/test", { method: "POST" }); + const result = payload.tmdb || {}; + const details = result.ok && result.image_base + ? ` Poster images available from ${result.image_base}.` + : ""; + const message = `${result.ok ? "TMDb API test passed." : "TMDb API test failed."} ${result.message || ""}${details}`; + if (notice) notice.textContent = message; + toast(message, result.ok ? "success" : "error"); + } catch (error) { + const message = `TMDb API test failed: ${error.message}`; + if (notice) notice.textContent = message; + toast(message, "error"); + } finally { + button.disabled = false; + button.textContent = "TMDb API Test"; + } +} + +function renderToolOutput(title, rows) { + $("toolOutput").innerHTML = `

${esc(title)}

${rows}`; +} + +async function loadTranscoder() { + const payload = await api("/api/tools/transcoder"); + const plan = payload.transcoder; + renderToolOutput("Transcode Queue", ` +

${plan.count} conversion candidates. ffmpeg ${plan.ffmpeg_available ? "is available" : "is not available"}.

+
${plan.targets.slice(0, 20).map((item) => ` +
${esc(item.name)}${esc(item.output)}${esc(item.command.join(" "))}
+ `).join("") || "

No transcode candidates found.

"}
+ `); +} + +async function runNextTranscode() { + const payload = await api("/api/tools/transcoder/run-next", { method: "POST" }); + const result = payload.transcoder; + renderToolOutput("Transcoder Result", ` +

Status: ${result.status}. ${result.count || 0} candidates in queue.

+ ${result.ran ? `
${esc(result.ran.name)}${esc(result.ran.output)}
` : ""} + ${result.stderr ? `
${esc(result.stderr)}
` : ""} + `); +} + +async function runSubtitleAudit() { + const payload = await api("/api/tools/subtitles"); + const audit = payload.audit; + renderToolOutput("Subtitle Audit", ` +

Checked ${audit.checked} indexed media files. ${audit.missing_count} missing subtitles. ${audit.unknown_count || 0} need a fresh library scan.

+
${audit.missing.slice(0, 50).map((item) => ` +
${esc(item.name)}${esc(item.path)}Expected: ${esc(item.expected.join(", "))}
+ `).join("") || "

Every indexed media file has a sidecar subtitle.

"}
+ `); +} + +async function runDuplicateFinder() { + const payload = await api("/api/tools/duplicates"); + const result = payload.duplicates; + renderToolOutput("Duplicate Finder", ` +

${result.count} duplicate title groups found in the cached library index.

+
${result.duplicates.slice(0, 50).map((group) => ` +
+ ${esc(group.title || group.key || "Unknown")} + ${group.count} files, ${bytes(group.total_size)} + ${(group.files || []).map((file) => `${esc(file.path)} (${bytes(file.size)})`).join("")} +
+ `).join("") || "

No duplicate title groups found.

"}
+ `); +} + +async function runScan() { + $("scanButton").disabled = true; + try { + const scan = await api("/api/scan", { method: "POST" }); + $("downloadsStatus").textContent = scan.started ? "Scan started. Organizer queue will update as files are parsed." : "A scan is already running. Showing the latest queue."; + await Promise.all([loadDashboard(), loadDownloads()]); + setTimeout(() => Promise.all([loadDashboard(), loadDownloads()]).catch(() => {}), 2500); + } finally { + $("scanButton").disabled = false; + } +} + +async function scanLibrary() { + $("libraryScanButton").disabled = true; + $("libraryStatus").textContent = "Scanning Movies, TV, and TV Shows folders..."; + try { + const payload = await api("/api/library/scan", { method: "POST" }); + state.library = payload.library; + state.dashboard.library = { + ...state.dashboard.library, + counts: payload.library.counts, + drives: payload.library.drives, + extensions: payload.library.extensions, + scanned_files: payload.library.scanned_files, + truncated: payload.library.truncated, + }; + state.libraryLimit = 120; + state.selectedMedia = null; + renderDashboard(); + await loadReleases(); + } finally { + $("libraryScanButton").disabled = false; + } +} + +function showStartupError(error) { + const message = error?.message || String(error); + const status = $("statusLine"); + if (status) status.textContent = `Frontend startup failed: ${message}`; + toast(`Frontend startup failed: ${message}`, "error"); + console.error(error); +} + +function init() { + setTheme(localStorage.getItem("sortarr-theme") || "slate"); + window.addEventListener("hashchange", renderRoute); + renderRoute(); + $("refreshButton").addEventListener("click", loadDashboard); + $("scanButton").addEventListener("click", runScan); + $("libraryScanButton").addEventListener("click", scanLibrary); + $("downloadsRefresh").addEventListener("click", loadDownloads); + $("releaseRefresh").addEventListener("click", loadReleases); + $("libraryFilter").addEventListener("input", () => { + state.libraryLimit = 500; + renderLibrary(); + }); + $("settingsSaveButton").addEventListener("click", saveSettings); + $("tmdbTestButton").addEventListener("click", testTmdb); + $("transcoderPlanButton").addEventListener("click", loadTranscoder); + $("transcoderRunButton").addEventListener("click", runNextTranscode); + $("subtitleAuditButton").addEventListener("click", runSubtitleAudit); + $("duplicateButton").addEventListener("click", runDuplicateFinder); + Promise.allSettled([loadConfig(), loadDashboard(), loadDownloads(), loadReleases()]).then((results) => { + const failed = results.find((result) => result.status === "rejected"); + if (failed && !state.dashboard) { + showStartupError(failed.reason); + } + }); + setInterval(loadDashboard, 30000); +} + +try { + init(); +} catch (error) { + showStartupError(error); +} diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..d09d61b --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,151 @@ + + + + + + Sortarr + + + + + +
+ + +
+
+
+

Media Dashboard

+

Connecting to backend...

+
+
+ + +
+
+ +
+
+
+

Storage

+
+
+
+

File Types

+
+
+
+

Activity

+
+
+
+
+ +
+
+

Library Contents

+
+ + +
+
+

+
+
+
+
+
+ +
+
+
+

Downloads

+

+
+ +
+
+
+

Organizer Queue

+
+
+
+
+

Current /downloads Files

+
+
+
+

Recently Planned or Moved

+
+
+
+
+ +
+
+

Missing & Upcoming

+ +
+
+
+ +
+
+

Library Tools

+ Uses the cached library index. Run a library scan first if results look stale. +
+
+ + + + +
+
+
+ +
+
+
+

Settings

+

Runtime settings are saved in /data/state.json and override TOML/env values for this backend process.

+
+
+ + +
+
+
+
+
+

Dashboard Theme

+

Choose the local dashboard theme here. The default theme below is also configurable and saved on the server.

+
+
+
+
+
+ Raw config +

+          
+
+
+
+
+ + + diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..3d88480 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,729 @@ +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: calc(15px - (var(--compact, 0) * 1px)); +} +.app-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; } +.sidebar { + border-right: 1px solid var(--border); + background: var(--surface); + padding: calc(20px * var(--density)); + position: sticky; + top: 0; + height: 100vh; +} +.brand { display: flex; gap: 12px; align-items: center; margin-bottom: 28px; } +.brand-mark { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: var(--radius); + background: var(--accent); + color: var(--bg); + font-weight: 800; +} +.brand small, #statusLine, .muted { color: var(--muted); } +nav { display: grid; gap: 6px; } +nav a { + color: var(--muted); + text-decoration: none; + padding: 10px 12px; + border-radius: var(--radius); +} +nav a.active, nav a:hover { background: var(--surface-2); color: var(--text); } +.page { display: none; } +.page.active { display: block; } +select, input, button { + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + border-radius: var(--radius); + padding: 10px 12px; +} +button { cursor: pointer; } +button:hover { border-color: var(--accent); } +button:disabled { cursor: wait; opacity: .62; } +main { padding: 24px; display: grid; gap: 24px; align-content: start; } +.topbar, .section-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; } +h1, h2, h3, p { margin: 0; } +h1 { font-size: 28px; } +h2 { font-size: 17px; } +h3 { font-size: 14px; color: var(--muted); font-weight: 700; } +.actions { display: flex; gap: 10px; } +.grid { display: grid; gap: 16px; } +.overview-grid { grid-template-columns: 1.3fr 1fr 1fr; } +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: calc(18px * var(--density)); +} +.storage-list, .event-list, .download-list, .bars { display: grid; gap: 12px; margin-top: 16px; } +.storage-card { display: grid; gap: 8px; } +.meter { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } +.meter span { display: block; height: 100%; background: var(--accent); } +.kv { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; } +.bar-row { display: grid; grid-template-columns: 72px 1fr 44px; gap: 10px; align-items: center; } +.event { border-left: 3px solid var(--accent); padding-left: 10px; color: var(--muted); } +.event.error { border-color: var(--bad); } +.segmented { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} +.segmented button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; +} +.segmented button.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 18%, var(--surface-2)); +} +.segmented span { + color: var(--muted); + font-size: 12px; +} +.table-wrap { overflow: auto; margin-top: 16px; max-height: 68vh; } +table { width: 100%; border-collapse: collapse; min-width: 720px; } +th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; } +th { color: var(--muted); font-weight: 600; } +td:first-child { + max-width: 520px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.download, .release { + display: grid; + gap: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + background: var(--surface-2); +} +.download.warning { border-color: var(--warn); } +.poster-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; + margin-top: 18px; +} +.poster-card { + display: grid; + gap: 8px; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + text-align: left; +} +.poster-card.active .poster, +.poster-card:hover .poster { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.poster { + display: grid; + place-items: center; + aspect-ratio: 2 / 3; + overflow: hidden; + border-radius: var(--radius); + background: var(--surface-2); + border: 1px solid var(--border); + position: relative; +} +.poster img { + width: 100%; + height: 100%; + object-fit: cover; +} +.poster-placeholder { + display: grid; + place-items: center; + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--surface-2), var(--surface)); + color: var(--accent); + font-size: 42px; + font-weight: 800; +} +.poster-card strong, +.poster-card small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.poster-card small { color: var(--muted); } +.version-badge { + position: absolute; + top: 8px; + right: 8px; + display: grid; + place-items: center; + min-width: 28px; + height: 28px; + padding: 0 8px; + border-radius: 999px; + background: var(--accent); + color: var(--accent-contrast, #08111f); + font-size: 13px; + font-weight: 800; + box-shadow: 0 8px 24px rgba(0, 0, 0, .28); +} +.media-detail { + margin-top: 22px; +} +.detail-shell { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + gap: 20px; + border-top: 1px solid var(--border); + padding-top: 22px; +} +.detail-poster { + align-self: start; +} +.detail-body { + display: grid; + gap: 16px; + min-width: 0; +} +.detail-block, +.season-list { + display: grid; + gap: 12px; +} +.season-list details { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + padding: 10px 12px; +} +.season-list summary { + cursor: pointer; + font-weight: 700; +} +.episode-list { + display: grid; + gap: 8px; + margin-top: 12px; +} +.episode { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 10px; + border-radius: var(--radius); + background: var(--surface); + border-left: 3px solid var(--good); +} +.episode.missing { border-left-color: var(--bad); } +.episode.upcoming { border-left-color: var(--warn); } +.episode p { + margin-top: 5px; + line-height: 1.35; +} +.episode-actions { + display: flex; + align-items: center; + gap: 8px; +} +.probe-output { + display: grid; + gap: 12px; +} +.stream-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} +.stream-grid section { + display: grid; + align-content: start; + gap: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + padding: 12px; +} +.stream-row { + display: grid; + gap: 6px; + padding-top: 8px; + border-top: 1px solid var(--border); +} +.track-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.track-actions button { + padding: 6px 8px; + font-size: 12px; +} +.downloads-layout { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr); + gap: 18px; + margin-top: 18px; +} +.downloads-layout article { min-width: 0; } +.queue-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(78px, 1fr)); + gap: 8px; + margin-top: 12px; +} +.queue-summary span { + display: grid; + gap: 2px; + padding: 9px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--muted); + font-size: 12px; +} +.queue-summary strong { + color: var(--text); + font-size: 18px; +} +.download small, .download span { + overflow-wrap: anywhere; +} +.download.bundle { + background: var(--surface); +} +.bundle-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} +.bundle-head div { + display: grid; + gap: 4px; + min-width: 0; +} +.subtitle-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.subtitle-chips span { + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-2); + color: var(--muted); + padding: 4px 8px; + font-size: 12px; +} +.download.loose { + opacity: .82; +} +.organizer-card { + border-left: 3px solid var(--accent); +} +.organizer-card.needs-review, +.organizer-card.dry-run { + border-left-color: var(--warn); +} +.organizer-card.low-confidence, +.organizer-card.skipped { + border-left-color: var(--bad); +} +.organizer-card.moved { + border-left-color: var(--good); +} +.confidence { + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; + white-space: nowrap; +} +.confidence.good { color: var(--good); } +.confidence.warn { color: var(--warn); } +.confidence.bad { color: var(--bad); } +.plan-paths { + display: grid; + gap: 5px; +} +.plan-paths small { + display: grid; + gap: 2px; +} +.plan-paths b { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0; +} +.subtitle-list { + display: grid; + gap: 4px; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); +} +.plan-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.plan-actions button { + padding: 7px 10px; +} +.release-grid, .tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; } +.release img { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: var(--radius); +} +.release.missing { border-color: var(--bad); } +.release.upcoming { border-color: var(--warn); } +.release a { + color: var(--accent); + text-decoration: none; +} +.pager { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 14px; +} +.tool-output { margin-top: 18px; display: grid; gap: 12px; } +.tool-output h3 { margin: 0; font-size: 15px; } +code { + display: block; + overflow: auto; + padding: 10px; + border-radius: var(--radius); + background: var(--bg); + color: var(--muted); +} +.settings-hero { + display: grid; + grid-template-columns: minmax(220px, .45fr) minmax(0, 1fr); + gap: 18px; + align-items: start; + margin-top: 18px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} +.settings-hero h3 { margin: 0 0 6px; } +.settings-notice { + display: none; + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + color: var(--muted); +} +.settings-notice:not(:empty) { display: block; } +.settings-stack { + display: grid; + gap: 18px; + margin-top: 18px; + max-width: 1180px; +} +.settings-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; +} +.settings-card-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: start; + padding: 16px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); + cursor: pointer; + list-style: none; +} +.settings-card-head::-webkit-details-marker { + display: none; +} +.settings-card-head h3 { + margin: 0 0 5px; +} +.settings-card-head p { + margin: 0; +} +.settings-card-head > span { + color: var(--muted); + font-size: 12px; + white-space: nowrap; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); +} +.settings-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} +.setting-row { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(260px, 520px); + gap: 24px; + align-items: start; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: var(--surface); + padding: 16px; +} +.setting-row:last-child { border-bottom: 0; } +.setting-rich { + align-items: start; + min-height: 0; +} +.setting-copy { + display: grid; + gap: 8px; + min-width: 0; + max-width: 620px; +} +.setting-copy > div { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; +} +.setting-copy small, +.setting-rich small { + color: var(--muted); + line-height: 1.35; +} +.setting-path { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +.setting-control { + display: flex; + justify-content: flex-end; + align-items: center; + min-width: 0; +} +.setting-control.wide { + display: grid; + gap: 6px; + justify-content: stretch; +} +.setting-row input[type="number"], .setting-row select { width: 132px; } +.setting-row input[type="text"], +.setting-row input[type="password"], +.setting-row textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + padding: 10px; +} +.setting-row textarea { + resize: vertical; + min-height: 70px; +} +.setting-row input[type="checkbox"] { + width: 22px; + height: 22px; + align-self: center; +} +.switch { + justify-self: end; + position: relative; + display: inline-flex; + width: 48px; + height: 28px; +} +.switch input { + position: absolute; + opacity: 0; +} +.switch span { + width: 100%; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border); + transition: background .15s ease, border-color .15s ease; +} +.switch span::after { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 4px; + left: 4px; + border-radius: 50%; + background: var(--muted); + transition: transform .15s ease, background .15s ease; +} +.switch input:checked + span { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 20%, var(--surface-2)); +} +.switch input:checked + span::after { + transform: translateX(20px); + background: var(--accent); +} +.range-control { + display: grid; + align-content: center; + gap: 10px; + min-width: 0; + width: 100%; +} +.range-control input[type="range"] { + width: 100%; + accent-color: var(--accent); +} +.range-control span { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.compound-control { + display: grid; + grid-template-columns: repeat(2, minmax(160px, 1fr)); + gap: 10px; + width: 100%; +} +.compound-control input, +.compound-control select { + min-width: 0; +} +.compound-control label { + display: grid; + gap: 5px; +} +.compound-control .inline-check { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + color: var(--muted); +} +.compound-control .span-2 { + grid-column: 1 / -1; +} +.theme-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 10px; + max-width: 980px; +} +.theme-option { + display: grid; + grid-template-columns: 42px 1fr; + align-items: center; + gap: 10px; + text-align: left; + background: var(--surface-2); +} +.theme-option.active { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} +.theme-swatch { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + width: 42px; + height: 32px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); +} +.theme-swatch i, +.theme-swatch b, +.theme-swatch em { + display: block; + min-width: 0; +} +.theme-swatch i { background: var(--surface); } +.theme-swatch b { background: var(--surface-2); } +.theme-swatch em { + grid-column: 1 / -1; + background: var(--accent); +} +pre { + white-space: pre-wrap; + overflow: auto; + background: var(--surface-2); + border-radius: var(--radius); + padding: 14px; + color: var(--muted); +} +.toast-host { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 20; + display: grid; + gap: 10px; + width: min(420px, calc(100vw - 36px)); +} +.toast { + transform: translateY(8px); + opacity: 0; + padding: 12px 14px; + border: 1px solid var(--border); + border-left: 4px solid var(--accent); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + box-shadow: 0 14px 34px rgba(0, 0, 0, .22); + transition: opacity .18s ease, transform .18s ease; +} +.toast.visible { + transform: translateY(0); + opacity: 1; +} +.toast.success { border-left-color: var(--good); } +.toast.error { border-left-color: var(--bad); } +@media (max-width: 900px) { + .app-shell { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; } + .overview-grid { grid-template-columns: 1fr; } + .downloads-layout { grid-template-columns: 1fr; } + .detail-shell { grid-template-columns: 1fr; } + .detail-poster { max-width: 220px; } + .episode { grid-template-columns: 1fr; } + .topbar, .section-head { align-items: stretch; flex-direction: column; } + .actions, .pager { flex-wrap: wrap; } + .settings-hero { grid-template-columns: 1fr; } + .settings-card-head { flex-direction: column; } + .setting-row { grid-template-columns: 1fr; gap: 14px; } + .range-control { min-width: 0; } + .setting-row input[type="text"], + .setting-row input[type="password"], + .setting-row textarea, + .compound-control { + width: 100%; + } + .compound-control { grid-template-columns: 1fr; } + .bundle-head { flex-direction: column; } +} diff --git a/web/src/themes.css b/web/src/themes.css new file mode 100644 index 0000000..88ce20f --- /dev/null +++ b/web/src/themes.css @@ -0,0 +1,134 @@ +:root, +[data-theme="slate"] { + --bg: #111318; + --surface: #191d24; + --surface-2: #222833; + --text: #eef2f7; + --muted: #96a1af; + --border: #303846; + --accent: #60a5fa; + --good: #34d399; + --warn: #fbbf24; + --bad: #f87171; + --radius: 8px; + --density: 1; + --font: Inter, ui-sans-serif, system-ui, sans-serif; +} + +[data-theme="midnight"] { + --bg: #080b12; + --surface: #121826; + --surface-2: #1b2740; + --text: #f8fafc; + --muted: #93a4bd; + --border: #293550; + --accent: #22d3ee; + --good: #4ade80; + --warn: #facc15; + --bad: #fb7185; +} + +[data-theme="graphite"] { + --bg: #151515; + --surface: #202020; + --surface-2: #2b2b2b; + --text: #f5f5f5; + --muted: #b2b2b2; + --border: #3a3a3a; + --accent: #a3e635; + --good: #86efac; + --warn: #fde047; + --bad: #fca5a5; +} + +[data-theme="nord"] { + --bg: #202632; + --surface: #2c3444; + --surface-2: #374155; + --text: #eceff4; + --muted: #c0c9d8; + --border: #4c566a; + --accent: #88c0d0; + --good: #a3be8c; + --warn: #ebcb8b; + --bad: #bf616a; +} + +[data-theme="dracula"] { + --bg: #1d1b26; + --surface: #282a36; + --surface-2: #343746; + --text: #f8f8f2; + --muted: #c7bfdc; + --border: #44475a; + --accent: #bd93f9; + --good: #50fa7b; + --warn: #f1fa8c; + --bad: #ff5555; +} + +[data-theme="solar"] { + --bg: #f4f0df; + --surface: #fffaf0; + --surface-2: #eee8d5; + --text: #273238; + --muted: #657b83; + --border: #d5cdb6; + --accent: #268bd2; + --good: #2aa198; + --warn: #b58900; + --bad: #dc322f; +} + +[data-theme="forest"] { + --bg: #101812; + --surface: #18251b; + --surface-2: #213326; + --text: #eef7ed; + --muted: #a7b9a6; + --border: #314638; + --accent: #7ddf64; + --good: #22c55e; + --warn: #eab308; + --bad: #ef4444; +} + +[data-theme="marine"] { + --bg: #081417; + --surface: #102225; + --surface-2: #183236; + --text: #edfdfd; + --muted: #9fc5c7; + --border: #28494e; + --accent: #2dd4bf; + --good: #5eead4; + --warn: #fcd34d; + --bad: #f97373; +} + +[data-theme="ember"] { + --bg: #171111; + --surface: #241818; + --surface-2: #362221; + --text: #fff7ed; + --muted: #d7b6a2; + --border: #513530; + --accent: #fb923c; + --good: #84cc16; + --warn: #facc15; + --bad: #f43f5e; +} + +[data-theme="paper"] { + --bg: #f7f8fa; + --surface: #ffffff; + --surface-2: #eef1f5; + --text: #151a22; + --muted: #5f6b7a; + --border: #d6dce5; + --accent: #2563eb; + --good: #16a34a; + --warn: #ca8a04; + --bad: #dc2626; +} +