Ignore dist/ directory

This commit is contained in:
scoped
2026-05-15 02:42:13 +00:00
parent e2de5f705a
commit 1ffb68e74c
37 changed files with 1 additions and 4979 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ logs/
downloads/
media/
dist/

BIN
dist/sortarr.zip vendored

Binary file not shown.

View File

@@ -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

View File

@@ -1,8 +0,0 @@
.env
__pycache__/
*.py[cod]
data/
logs/
downloads/
media/

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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"

View File

@@ -1,2 +0,0 @@
__all__ = ["config", "organizer", "server"]

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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,
})

View File

@@ -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)

View File

@@ -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}

View File

@@ -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

View File

@@ -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 = [
"<movie>" if media["type"] == "movie" else "<episodedetails>",
f" <title>{media['title']}</title>",
]
if media.get("year"):
body.append(f" <year>{media['year']}</year>")
if media.get("season"):
body.append(f" <season>{media['season']}</season>")
if media.get("episode"):
body.append(f" <episode>{media['episode']}</episode>")
body.append("</movie>" if media["type"] == "movie" else "</episodedetails>")
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()}

View File

@@ -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(),
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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))

View File

@@ -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:],
}

View File

@@ -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"

View File

@@ -1,6 +0,0 @@
/* Optional host-editable theme overrides. Loaded by the dashboard when enabled. */
:root {
/* --bg: #0f1115; */
/* --accent: #5cc8ff; */
}

View File

@@ -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:-}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,4 +0,0 @@
FROM nginx:1.27-alpine
COPY src /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,158 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sortarr</title>
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/themes.css">
<link rel="stylesheet" href="/api/theme/custom.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">S</span>
<div>
<strong>Sortarr</strong>
</div>
</div>
<nav>
<a href="#/overview" data-route="overview" class="active">Overview</a>
<a href="#/library" data-route="library">Library</a>
<a href="#/downloads" data-route="downloads">Downloads</a>
<a href="#/releases" data-route="releases">Releases</a>
<a href="#/tools" data-route="tools">Tools</a>
<a href="#/settings" data-route="settings">Settings</a>
</nav>
</aside>
<main>
<header class="topbar">
<div>
<h1>Media Dashboard</h1>
<p id="statusLine">Connecting to backend...</p>
</div>
<div class="actions">
<button id="scanButton">Run scan</button>
<button id="refreshButton">Refresh</button>
</div>
</header>
<section id="page-overview" class="page active">
<div class="grid overview-grid">
<article class="panel">
<h2>Storage</h2>
<div id="storageCards" class="storage-list"></div>
</article>
<article class="panel">
<h2>File Types</h2>
<div id="extensionBreakdown" class="bars"></div>
</article>
<article class="panel">
<h2>Activity</h2>
<div id="events" class="event-list"></div>
</article>
</div>
</section>
<section id="page-library" class="page panel">
<div class="section-head">
<div>
<h2>Library Contents</h2>
<p id="libraryStatus" class="muted"></p>
</div>
<div class="actions">
<input id="libraryFilter" placeholder="Filter library">
<button id="libraryScanButton">Scan library</button>
</div>
</div>
<div id="libraryTabs" class="segmented"></div>
<div id="libraryGrid" class="poster-grid"></div>
<div id="libraryPager" class="pager"></div>
</section>
<section id="page-downloads" class="page panel">
<div class="section-head">
<div>
<h2>Downloads</h2>
<p id="downloadsStatus" class="muted"></p>
</div>
<button id="downloadsRefresh">Refresh downloads</button>
</div>
<div class="downloads-layout">
<article>
<h3>Organizer Queue</h3>
<div id="organizerSummary" class="queue-summary"></div>
<div id="organizerRows" class="download-list"></div>
</article>
<article>
<h3>Current /downloads Files</h3>
<div id="downloadRows" class="download-list"></div>
</article>
<article>
<h3>Recently Planned or Moved</h3>
<div id="recentDownloadRows" class="download-list"></div>
</article>
</div>
</section>
<section id="page-releases" class="page panel">
<div class="section-head">
<h2>Missing & Upcoming</h2>
<button id="releaseRefresh">Refresh releases</button>
</div>
<div id="releaseRows" class="release-grid"></div>
</section>
<section id="page-tools" class="page panel">
<div class="section-head">
<h2>Library Tools</h2>
<span class="muted">Uses the cached library index. Run a library scan first if results look stale.</span>
</div>
<div class="tool-grid">
<button id="transcoderPlanButton">Build transcode queue</button>
<button id="transcoderRunButton">Run next transcode</button>
<button id="subtitleAuditButton">Run subtitle audit</button>
<button id="duplicateButton">Duplicate finder</button>
</div>
<div id="toolOutput" class="tool-output"></div>
</section>
<section id="page-settings" class="page panel">
<div class="section-head">
<div>
<h2>Settings</h2>
<p class="muted">Runtime settings are saved in /data/state.json and override TOML/env values for this backend process.</p>
</div>
<div class="actions">
<button id="tmdbTestButton" type="button">TMDb API Test</button>
<button id="settingsSaveButton" type="button">Save settings</button>
</div>
</div>
<div id="settingsNotice" class="settings-notice" role="status" aria-live="polite"></div>
<section class="settings-hero">
<div>
<h3>Dashboard Theme</h3>
<p class="muted">Choose the local dashboard theme here. The default theme below is also configurable and saved on the server.</p>
</div>
<div id="themeOptions" class="theme-options"></div>
</section>
<div id="settingsForm" class="settings-stack"></div>
<details open>
<summary>Raw config</summary>
<pre id="configView"></pre>
</details>
</section>
</main>
</div>
<div id="mediaModal" class="modal">
<div class="modal-backdrop"></div>
<div class="modal-shell">
<button id="closeModal" class="modal-close">&times;</button>
<div id="modalBody" class="modal-content"></div>
</div>
</div>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -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; }

View File

@@ -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;
}