From 1ffb68e74c5053cd766322215a9606429beff706 Mon Sep 17 00:00:00 2001 From: scoped Date: Fri, 15 May 2026 02:42:13 +0000 Subject: [PATCH] Ignore dist/ directory --- .gitignore | 1 + dist/sortarr.zip | Bin 60852 -> 0 bytes dist/sortarr/.env.example | 28 - dist/sortarr/.gitignore | 8 - dist/sortarr/README.md | 61 - dist/sortarr/backend/Dockerfile | 15 - dist/sortarr/backend/default-config/app.toml | 90 -- dist/sortarr/backend/sortarr/__init__.py | 2 - dist/sortarr/backend/sortarr/app.py | 356 ------ dist/sortarr/backend/sortarr/cache.py | 75 -- dist/sortarr/backend/sortarr/config.py | 67 -- dist/sortarr/backend/sortarr/downloads.py | 139 --- dist/sortarr/backend/sortarr/healthcheck.py | 7 - dist/sortarr/backend/sortarr/library.py | 261 ----- dist/sortarr/backend/sortarr/logging_setup.py | 25 - dist/sortarr/backend/sortarr/media_probe.py | 121 -- dist/sortarr/backend/sortarr/metadata.py | 216 ---- dist/sortarr/backend/sortarr/organizer.py | 293 ----- dist/sortarr/backend/sortarr/parser.py | 143 --- dist/sortarr/backend/sortarr/releases.py | 59 - dist/sortarr/backend/sortarr/scanner.py | 104 -- dist/sortarr/backend/sortarr/storage.py | 53 - dist/sortarr/backend/sortarr/store.py | 73 -- dist/sortarr/backend/sortarr/tools.py | 98 -- dist/sortarr/config/app.toml | 19 - dist/sortarr/config/custom-theme.css | 6 - dist/sortarr/docker-compose.yaml | 56 - dist/sortarr/docs/api.md | 70 -- dist/sortarr/docs/architecture.md | 251 ---- dist/sortarr/docs/configuration.md | 77 -- dist/sortarr/docs/operations.md | 62 - dist/sortarr/web/Dockerfile | 4 - dist/sortarr/web/nginx.conf | 20 - dist/sortarr/web/src/app.js | 1006 ----------------- dist/sortarr/web/src/index.html | 158 --- dist/sortarr/web/src/styles.css | 822 -------------- dist/sortarr/web/src/themes.css | 134 --- 37 files changed, 1 insertion(+), 4979 deletions(-) delete mode 100644 dist/sortarr.zip delete mode 100644 dist/sortarr/.env.example delete mode 100644 dist/sortarr/.gitignore delete mode 100644 dist/sortarr/README.md delete mode 100644 dist/sortarr/backend/Dockerfile delete mode 100644 dist/sortarr/backend/default-config/app.toml delete mode 100644 dist/sortarr/backend/sortarr/__init__.py delete mode 100644 dist/sortarr/backend/sortarr/app.py delete mode 100644 dist/sortarr/backend/sortarr/cache.py delete mode 100644 dist/sortarr/backend/sortarr/config.py delete mode 100644 dist/sortarr/backend/sortarr/downloads.py delete mode 100644 dist/sortarr/backend/sortarr/healthcheck.py delete mode 100644 dist/sortarr/backend/sortarr/library.py delete mode 100644 dist/sortarr/backend/sortarr/logging_setup.py delete mode 100644 dist/sortarr/backend/sortarr/media_probe.py delete mode 100644 dist/sortarr/backend/sortarr/metadata.py delete mode 100644 dist/sortarr/backend/sortarr/organizer.py delete mode 100644 dist/sortarr/backend/sortarr/parser.py delete mode 100644 dist/sortarr/backend/sortarr/releases.py delete mode 100644 dist/sortarr/backend/sortarr/scanner.py delete mode 100644 dist/sortarr/backend/sortarr/storage.py delete mode 100644 dist/sortarr/backend/sortarr/store.py delete mode 100644 dist/sortarr/backend/sortarr/tools.py delete mode 100644 dist/sortarr/config/app.toml delete mode 100644 dist/sortarr/config/custom-theme.css delete mode 100644 dist/sortarr/docker-compose.yaml delete mode 100644 dist/sortarr/docs/api.md delete mode 100644 dist/sortarr/docs/architecture.md delete mode 100644 dist/sortarr/docs/configuration.md delete mode 100644 dist/sortarr/docs/operations.md delete mode 100644 dist/sortarr/web/Dockerfile delete mode 100644 dist/sortarr/web/nginx.conf delete mode 100644 dist/sortarr/web/src/app.js delete mode 100644 dist/sortarr/web/src/index.html delete mode 100644 dist/sortarr/web/src/styles.css delete mode 100644 dist/sortarr/web/src/themes.css 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 09f649bec2624ffaf82e7c5a2731429aa0305268..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60852 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 dict: - for key, value in override.items(): - if isinstance(value, dict) and isinstance(base.get(key), dict): - deep_merge(base[key], value) - else: - base[key] = value - return base - - -def coerce_value(value, caster): - if caster is bool: - return bool(value) - if caster is int: - return int(value) - if caster is list: - if isinstance(value, list): - return [str(item).strip() for item in value if str(item).strip()] - return [item.strip() for item in str(value).split(",") if item.strip()] - return caster(value) - - -def apply_settings(config: dict, settings: dict) -> dict: - if any(key in SETTINGS_SCHEMA["app"] for key in settings): - settings = {"app": settings} - applied = {} - for section, fields in SETTINGS_SCHEMA.items(): - values = settings.get(section) - if not isinstance(values, dict): - continue - target = config.setdefault(section, {}) - applied_section = applied.setdefault(section, {}) - for key, caster in fields.items(): - if key not in values: - continue - target[key] = coerce_value(values[key], caster) - applied_section[key] = target[key] - if not applied_section: - applied.pop(section, None) - - if isinstance(settings.get("drives"), list): - drives = [] - for idx, drive in enumerate(settings["drives"]): - if not isinstance(drive, dict): - continue - existing = (config.get("drives") or [{}] * (idx + 1))[idx] if idx < len(config.get("drives", [])) else {} - drives.append({ - "id": str(drive.get("id", existing.get("id", f"drive{idx + 1}"))), - "name": str(drive.get("name", existing.get("name", f"Media Drive {idx + 1}"))), - "path": str(drive.get("path", existing.get("path", ""))), - "min_free_gb": int(drive.get("min_free_gb", existing.get("min_free_gb", 20))), - }) - config["drives"] = drives - applied["drives"] = drives - - if isinstance(settings.get("release_providers"), list): - providers = [] - for provider in settings["release_providers"]: - if not isinstance(provider, dict): - continue - providers.append({ - "id": str(provider.get("id", "")), - "name": str(provider.get("name", "")), - "enabled": bool(provider.get("enabled", False)), - "type": str(provider.get("type", "rss")), - "url": str(provider.get("url", "")), - }) - config["release_providers"] = providers - applied["release_providers"] = providers - - return applied - - -CONFIG = load_config() -configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO")) -STORE = JsonStore(CONFIG["paths"]["data"]) -apply_settings(CONFIG, STORE.snapshot().get("settings", {})) -SCANNER = Scanner(CONFIG, STORE) - - -class Handler(BaseHTTPRequestHandler): - server_version = "Sortarr/0.1" - - def log_message(self, fmt: str, *args) -> None: - return - - def send_json(self, payload, status=HTTPStatus.OK) -> None: - body = json.dumps(payload, indent=2).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_OPTIONS(self) -> None: - self.send_response(204) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.end_headers() - - def do_GET(self) -> None: - parsed_url = urlparse(self.path) - path = parsed_url.path - try: - if path == "/api/health": - self.send_json({"ok": True}) - elif path == "/api/config": - self.send_json(public_config(CONFIG)) - elif path == "/api/dashboard": - snap = STORE.snapshot() - cached_library = snap.get("library") or { - "drives": drive_stats(CONFIG), - "items": [], - "counts": {"movies": 0, "tv": 0, "total": 0}, - "extensions": {}, - "scanned_files": 0, - "truncated": False, - "cached": False, - } - cached_library = normalize_library(cached_library) - cached_library.pop("items", None) - public_state = { - "events": snap.get("events", [])[:200], - "organizer": snap.get("organizer", {"queue": [], "updated_at": None}), - "settings": snap.get("settings", {}), - "updated_at": snap.get("updated_at"), - } - self.send_json({ - "state": public_state, - "library": cached_library, - "dry_run": CONFIG["app"].get("dry_run"), - }) - elif path == "/api/downloads": - self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())}) - elif path == "/api/releases": - self.send_json({"releases": fetch_releases(CONFIG, STORE.snapshot().get("library"))}) - elif path == "/api/media/probe": - params = parse_qs(parsed_url.query) - target = unquote((params.get("path") or [""])[0]) - self.send_json({"media": media_probe(CONFIG, target)}) - elif path == "/api/metadata/search": - params = parse_qs(parsed_url.query) - query = unquote((params.get("query") or [""])[0]) - kind = unquote((params.get("type") or ["movie"])[0]) - self.send_json({"results": search_tmdb(CONFIG, kind, query)}) - elif path == "/api/tools/subtitles": - self.send_json({"audit": subtitle_audit(CONFIG, STORE.snapshot().get("library"))}) - elif path == "/api/tools/transcoder": - self.send_json({"transcoder": transcode_plan(CONFIG, STORE.snapshot().get("library"))}) - elif path == "/api/theme/custom.css": - custom = CONFIG.get("theme", {}).get("custom_css_path") - if custom and CONFIG.get("theme", {}).get("allow_custom_css", True) and os.path.exists(custom): - body = open(custom, "rb").read() - self.send_response(200) - self.send_header("Content-Type", "text/css") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - else: - self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) - except Exception as exc: - self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) - - def do_POST(self) -> None: - path = urlparse(self.path).path - try: - if path == "/api/scan": - started = SCANNER.request_scan() - snap = STORE.snapshot() - self.send_json({ - "started": started, - "status": "started" if started else "already-running", - "queue": snap.get("organizer", {}).get("queue", []), - }, HTTPStatus.ACCEPTED) - elif path == "/api/organizer/approve": - length = int(self.headers.get("Content-Length", "0") or "0") - body = self.rfile.read(length).decode() if length else "{}" - payload = json.loads(body) - plan_id = payload.get("id") - snap = STORE.snapshot() - queue = snap.get("organizer", {}).get("queue", []) - plan = next((item for item in queue if item.get("id") == plan_id), None) - if not plan: - self.send_json({"error": "plan not found"}, HTTPStatus.NOT_FOUND) - return - result = execute_bundle_plan(CONFIG, plan, force=True) - updated = [result if item.get("id") == plan_id else item for item in queue] - STORE.set_organizer_queue(updated) - STORE.add_event("info", f"approved organizer plan: {result.get('result')}", path=result.get("source"), confidence=result.get("confidence")) - self.send_json({"plan": result}) - elif path == "/api/organizer/skip": - length = int(self.headers.get("Content-Length", "0") or "0") - body = self.rfile.read(length).decode() if length else "{}" - payload = json.loads(body) - plan_id = payload.get("id") - snap = STORE.snapshot() - queue = snap.get("organizer", {}).get("queue", []) - updated = [{**item, "status": "skipped", "result": "skipped"} if item.get("id") == plan_id else item for item in queue] - STORE.set_organizer_queue(updated) - self.send_json({"ok": True}) - elif path == "/api/library/identify": - length = int(self.headers.get("Content-Length", "0") or "0") - body = self.rfile.read(length).decode() if length else "{}" - payload = json.loads(body) - key = payload.get("key") - tmdb_id = payload.get("tmdb_id") - kind = payload.get("type") - - snap = STORE.snapshot() - library = snap.get("library", {}) - collections = library.get("collections", {"movies": [], "series": []}) - - found_item = None - if kind == "movie": - for item in collections["movies"]: - if item["key"] == key: - found_item = identify_item(CONFIG, item, tmdb_id, kind) - break - else: - for item in collections["series"]: - if item["key"] == key: - found_item = identify_item(CONFIG, item, tmdb_id, "tv") - break - - if found_item: - STORE.set_library(library) - self.send_json({"ok": True, "item": found_item}) - else: - self.send_json({"error": "item not found"}, HTTPStatus.NOT_FOUND) - elif path == "/api/library/scan": - library = library_snapshot(CONFIG) - STORE.set_library(library) - self.send_json({"library": library}) - elif path == "/api/tools/transcoder/run-next": - result = run_next_transcode(CONFIG, STORE.snapshot().get("library")) - STORE.add_event("info", f"transcoder: {result.get('status')}") - self.send_json({"transcoder": result}) - elif path == "/api/metadata/tmdb/test": - self.send_json({"tmdb": test_tmdb(CONFIG)}) - elif path == "/api/media/tracks": - length = int(self.headers.get("Content-Length", "0") or "0") - body = self.rfile.read(length).decode() if length else "{}" - payload = json.loads(body) - result = edit_track(CONFIG, payload.get("path", ""), payload.get("action", ""), int(payload.get("stream_index", -1))) - STORE.add_event("info", f"track edit: {result.get('status')}", path=payload.get("path", "")) - self.send_json({"media": result}) - elif path == "/api/settings": - length = int(self.headers.get("Content-Length", "0") or "0") - body = self.rfile.read(length).decode() if length else "{}" - updates = json.loads(body) - applied = apply_settings(CONFIG, updates) - snap = STORE.snapshot() - settings = snap.get("settings", {}) - deep_merge(settings, applied) - STORE.state["settings"] = settings - STORE.save() - self.send_json({"settings": applied, "config": public_config(CONFIG)}) - else: - self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND) - except Exception as exc: - self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) - - -def main() -> None: - SCANNER.start() - host = os.getenv("SORTARR_HOST", "0.0.0.0") - port = int(os.getenv("SORTARR_API_PORT", "8099")) - ThreadingHTTPServer((host, port), Handler).serve_forever() - - -if __name__ == "__main__": - main() diff --git a/dist/sortarr/backend/sortarr/cache.py b/dist/sortarr/backend/sortarr/cache.py 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; -} -