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