commit e2de5f705a55763ef6ddf470b90096bb3d7600d4 Author: scoped Date: Fri May 15 02:41:52 2026 +0000 Initial commit 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 0000000..09f649b Binary files /dev/null and b/dist/sortarr.zip differ diff --git a/dist/sortarr/.env.example b/dist/sortarr/.env.example new file mode 100644 index 0000000..cf3807c --- /dev/null +++ b/dist/sortarr/.env.example @@ -0,0 +1,28 @@ +# Sortarr Environment Configuration + +# Network Settings +SORTARR_WEB_PORT=8088 +SORTARR_API_PORT=8099 +SORTARR_TZ=Etc/UTC + +# Runtime Settings +# Set to 'true' to simulate moves without actually moving files +SORTARR_DRY_RUN=false +SORTARR_LOG_LEVEL=INFO +SORTARR_SCAN_INTERVAL_SECONDS=20 +SORTARR_SETTLE_SECONDS=90 +SORTARR_MIN_FREE_GB=20 + +# Optional: TMDb API for posters and metadata +TMDB_API_KEY= +TMDB_BEARER_TOKEN= + +# Host Paths (Relative to docker-compose.yaml or absolute paths) +DOWNLOADS_PATH=./downloads +CONFIG_PATH=./config +LOGS_PATH=./logs +DATA_PATH=./data +DRIVE1_PATH=./media/drive1 +DRIVE2_PATH=./media/drive2 +DRIVE3_PATH=./media/drive3 +DRIVE4_PATH=./media/drive4 diff --git a/dist/sortarr/.gitignore b/dist/sortarr/.gitignore new file mode 100644 index 0000000..d35ab77 --- /dev/null +++ b/dist/sortarr/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.py[cod] +data/ +logs/ +downloads/ +media/ + diff --git a/dist/sortarr/README.md b/dist/sortarr/README.md new file mode 100644 index 0000000..8d60fbc --- /dev/null +++ b/dist/sortarr/README.md @@ -0,0 +1,61 @@ +# Sortarr + +Sortarr is a self-hosted Jellyfin media organizer and dashboard. It watches your downloads, plans safe Jellyfin-friendly moves across multiple media drives, and provides a fully editable dashboard to manage your library. + +## Features + +- **Automated Organizing**: Watches `/downloads` and moves files to appropriate Movie/Show folders. +- **Multi-Drive Support**: Supports up to 4 media drives with smart drive selection. +- **Safety First**: Optional dry-run mode and atomic move operations. +- **Customizable**: Fully editable vanilla JS dashboard and TOML-based backend configuration. +- **Lightweight**: Built with Python and Nginx, optimized for self-hosting. + +## Getting Started + +### 1. Prerequisites + +- Docker and Docker Compose installed on your host. + +### 2. Setup + +1. **Copy the environment template**: + ```bash + cp .env.example .env + ``` + +2. **Configure paths**: + Edit `.env` and set the paths to your downloads and media folders. By default, it uses folders within the project directory. + +3. **Review Configuration**: + Check `config/app.toml` to customize organizer rules, naming templates, and more. + +4. **Start Sortarr**: + ```bash + docker compose up -d --build + ``` + +### 3. Usage + +- **Web Dashboard**: Open `http://localhost:8088` (or the port you configured). +- **First Run**: By default, `SORTARR_DRY_RUN` is `false` in this distribution. If you want to test first, set it to `true` in your `.env`. +- **Library Scan**: Go to the Library page and click "Scan library" to index your existing media. + +## Directory Structure + +- `backend/`: Python backend source and Dockerfile. +- `web/`: Dashboard source, Nginx config, and Dockerfile. +- `config/`: Configuration files (`app.toml`, `custom-theme.css`). +- `data/`: Persistent state and cache. +- `logs/`: Application logs. +- `downloads/`: Default watch directory for incoming media. +- `media/`: Default mount points for your media drives. + +## Customization + +- **Dashboard**: Edit files in `web/src` to change the UI. +- **Theming**: Use the Settings page or edit `config/custom-theme.css`. +- **Logic**: Backend logic is in `backend/sortarr/`. + +## License + +This project is source-available and intended for personal self-hosting. diff --git a/dist/sortarr/backend/Dockerfile b/dist/sortarr/backend/Dockerfile new file mode 100644 index 0000000..9e026bc --- /dev/null +++ b/dist/sortarr/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/dist/sortarr/backend/default-config/app.toml b/dist/sortarr/backend/default-config/app.toml new file mode 100644 index 0000000..ab7cdcc --- /dev/null +++ b/dist/sortarr/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/dist/sortarr/backend/sortarr/__init__.py b/dist/sortarr/backend/sortarr/__init__.py new file mode 100644 index 0000000..f2923b7 --- /dev/null +++ b/dist/sortarr/backend/sortarr/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["config", "organizer", "server"] + diff --git a/dist/sortarr/backend/sortarr/app.py b/dist/sortarr/backend/sortarr/app.py new file mode 100644 index 0000000..3e21a31 --- /dev/null +++ b/dist/sortarr/backend/sortarr/app.py @@ -0,0 +1,356 @@ +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, search_tmdb, identify_item +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 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) + 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; +} +