diff --git a/.gitignore b/.gitignore index d35ab77..ce3c27d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ logs/ downloads/ media/ +dist/ diff --git a/dist/sortarr.zip b/dist/sortarr.zip deleted file mode 100644 index 09f649b..0000000 Binary files a/dist/sortarr.zip and /dev/null differ diff --git a/dist/sortarr/.env.example b/dist/sortarr/.env.example deleted file mode 100644 index cf3807c..0000000 --- a/dist/sortarr/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index d35ab77..0000000 --- a/dist/sortarr/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.env -__pycache__/ -*.py[cod] -data/ -logs/ -downloads/ -media/ - diff --git a/dist/sortarr/README.md b/dist/sortarr/README.md deleted file mode 100644 index 8d60fbc..0000000 --- a/dist/sortarr/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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 deleted file mode 100644 index 9e026bc..0000000 --- a/dist/sortarr/backend/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index ab7cdcc..0000000 --- a/dist/sortarr/backend/default-config/app.toml +++ /dev/null @@ -1,90 +0,0 @@ -[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 deleted file mode 100644 index f2923b7..0000000 --- a/dist/sortarr/backend/sortarr/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__ = ["config", "organizer", "server"] - diff --git a/dist/sortarr/backend/sortarr/app.py b/dist/sortarr/backend/sortarr/app.py deleted file mode 100644 index 3e21a31..0000000 --- a/dist/sortarr/backend/sortarr/app.py +++ /dev/null @@ -1,356 +0,0 @@ -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 deleted file mode 100644 index 7fc5794..0000000 --- a/dist/sortarr/backend/sortarr/cache.py +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 40bf1fb..0000000 --- a/dist/sortarr/backend/sortarr/config.py +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index e9feba7..0000000 --- a/dist/sortarr/backend/sortarr/downloads.py +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index c50052f..0000000 --- a/dist/sortarr/backend/sortarr/healthcheck.py +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 99a41c4..0000000 --- a/dist/sortarr/backend/sortarr/library.py +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index 9604397..0000000 --- a/dist/sortarr/backend/sortarr/logging_setup.py +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index a841679..0000000 --- a/dist/sortarr/backend/sortarr/media_probe.py +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index a166ceb..0000000 --- a/dist/sortarr/backend/sortarr/metadata.py +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index 652a83a..0000000 --- a/dist/sortarr/backend/sortarr/organizer.py +++ /dev/null @@ -1,293 +0,0 @@ -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 deleted file mode 100644 index 932c3a0..0000000 --- a/dist/sortarr/backend/sortarr/parser.py +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index 24af6bf..0000000 --- a/dist/sortarr/backend/sortarr/releases.py +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 33a42d4..0000000 --- a/dist/sortarr/backend/sortarr/scanner.py +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index e93ecf2..0000000 --- a/dist/sortarr/backend/sortarr/storage.py +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 787f663..0000000 --- a/dist/sortarr/backend/sortarr/store.py +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 4ff3b10..0000000 --- a/dist/sortarr/backend/sortarr/tools.py +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 4427631..0000000 --- a/dist/sortarr/config/app.toml +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 0798da1..0000000 --- a/dist/sortarr/config/custom-theme.css +++ /dev/null @@ -1,6 +0,0 @@ -/* 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 deleted file mode 100644 index 380265a..0000000 --- a/dist/sortarr/docker-compose.yaml +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index af6c4a4..0000000 --- a/dist/sortarr/docs/api.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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 deleted file mode 100644 index fd55b1a..0000000 --- a/dist/sortarr/docs/architecture.md +++ /dev/null @@ -1,251 +0,0 @@ -# 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 deleted file mode 100644 index 4348192..0000000 --- a/dist/sortarr/docs/configuration.md +++ /dev/null @@ -1,77 +0,0 @@ -# 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 deleted file mode 100644 index 0e24bac..0000000 --- a/dist/sortarr/docs/operations.md +++ /dev/null @@ -1,62 +0,0 @@ -# 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 deleted file mode 100644 index b302f74..0000000 --- a/dist/sortarr/web/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 28a2a8a..0000000 --- a/dist/sortarr/web/nginx.conf +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 1896d00..0000000 --- a/dist/sortarr/web/src/app.js +++ /dev/null @@ -1,1006 +0,0 @@ -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 deleted file mode 100644 index 4fb9e8c..0000000 --- a/dist/sortarr/web/src/index.html +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - 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 deleted file mode 100644 index 2bdac4a..0000000 --- a/dist/sortarr/web/src/styles.css +++ /dev/null @@ -1,822 +0,0 @@ -* { 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 deleted file mode 100644 index 88ce20f..0000000 --- a/dist/sortarr/web/src/themes.css +++ /dev/null @@ -1,134 +0,0 @@ -: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; -} -