Initial commit
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
SORTARR_HOST=0.0.0.0
|
||||
SORTARR_WEB_PORT=8088
|
||||
SORTARR_API_PORT=8099
|
||||
SORTARR_TZ=Etc/UTC
|
||||
SORTARR_DRY_RUN=true
|
||||
SORTARR_LOG_LEVEL=INFO
|
||||
SORTARR_SCAN_INTERVAL_SECONDS=20
|
||||
SORTARR_SETTLE_SECONDS=90
|
||||
SORTARR_MIN_FREE_GB=20
|
||||
SORTARR_UID=1000
|
||||
SORTARR_GID=1000
|
||||
TMDB_API_KEY=
|
||||
TMDB_BEARER_TOKEN=
|
||||
|
||||
# Host paths. Copy this file to .env and change these for your media host.
|
||||
DOWNLOADS_PATH=/home/drop/jellyfin/downloads
|
||||
CONFIG_PATH=/home/drop/jellyfin/scripts/sortarr/config
|
||||
LOGS_PATH=/home/drop/jellyfin/scripts/sortarr/logs
|
||||
DATA_PATH=/home/drop/jellyfin/scripts/sortarr/data
|
||||
DRIVE1_PATH=/home/drop/jellyfin/mediashare1
|
||||
DRIVE2_PATH=/home/drop/jellyfin/mediashare2
|
||||
DRIVE3_PATH=/home/drop/jellyfin/mediashare3
|
||||
DRIVE4_PATH=/home/drop/jellyfin/mediashare4
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
data/
|
||||
logs/
|
||||
downloads/
|
||||
media/
|
||||
|
||||
77
README.md
Normal file
77
README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Sortarr
|
||||
|
||||
Sortarr is a self-hosted Jellyfin media organizer and dashboard. It watches `/downloads`, plans safe Jellyfin-friendly moves, chooses one of four mounted media drives, exposes storage, downloads, and library state, and ships with a fully editable vanilla dashboard.
|
||||
|
||||
The project is intentionally source-first: backend logic is plain Python, the UI is HTML/CSS/JS, and runtime behavior is configured with `.env`, TOML config files, and optional CSS overrides.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy the environment template:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and set `DOWNLOADS_PATH`, `DRIVE1_PATH`, `DRIVE2_PATH`, `DRIVE3_PATH`, and `DRIVE4_PATH`.
|
||||
|
||||
3. Review `config/app.toml`. Keep `SORTARR_DRY_RUN=true` until the generated plans look right.
|
||||
|
||||
4. Start the stack:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
5. Open `http://localhost:8088`.
|
||||
|
||||
Production mode:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
Optional profiles:
|
||||
|
||||
```bash
|
||||
docker compose --profile cache up -d
|
||||
docker compose --profile database up -d
|
||||
docker compose --profile tools up -d
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- `web`: nginx serving the editable dashboard from `web/src`.
|
||||
- `backend`: Python API plus 24/7 worker loop.
|
||||
- `redis`: optional cache profile for future workflow extensions.
|
||||
- `postgres`: optional database profile for installations that outgrow JSON state.
|
||||
- `media-tools`: optional ffmpeg tools container.
|
||||
|
||||
## Mounted Host Paths
|
||||
|
||||
- `/downloads`: incoming files.
|
||||
- `/media/drive1` through `/media/drive4`: Jellyfin media drives.
|
||||
- `/config`: TOML config and custom CSS.
|
||||
- `/logs`: rotating backend logs.
|
||||
- `/data`: JSON state and scan history.
|
||||
|
||||
## Safety Model
|
||||
|
||||
Sortarr defaults to dry-run mode. In dry-run mode it scans, parses, chooses drives, computes destination paths, and records planned actions without moving files.
|
||||
|
||||
When dry-run is disabled, moves use a temporary `.sorting` destination before the final rename. Existing destinations follow the configured collision rule: `keep-both`, `skip`, or `replace`.
|
||||
|
||||
## Permissions
|
||||
|
||||
The default Compose file runs the backend with the container default user so a fresh checkout can create logs, state, and media folders without a bootstrap script. On a hardened media host, set ownership on the mounted paths and add a `user: "${SORTARR_UID}:${SORTARR_GID}"` line to the backend service in a local override.
|
||||
|
||||
## Customization
|
||||
|
||||
Edit these files directly:
|
||||
|
||||
- `config/app.toml`: runtime organizer rules and provider settings.
|
||||
- `.env`: deployment paths, ports, and intervals.
|
||||
- `backend/sortarr/*.py`: parsing, drive choice, scanner, API, provider integrations.
|
||||
- `web/src/*.html`, `*.css`, `*.js`: dashboard layout, styling, themes, routes.
|
||||
- `config/custom-theme.css`: host-side CSS variable overrides loaded at runtime.
|
||||
|
||||
See `docs/configuration.md`, `docs/api.md`, and `docs/operations.md`.
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY sortarr /app/sortarr
|
||||
COPY default-config /app/default-config
|
||||
|
||||
EXPOSE 8099
|
||||
CMD ["python", "-m", "sortarr.app"]
|
||||
90
backend/default-config/app.toml
Normal file
90
backend/default-config/app.toml
Normal file
@@ -0,0 +1,90 @@
|
||||
[app]
|
||||
name = "Sortarr"
|
||||
dry_run = true
|
||||
log_level = "INFO"
|
||||
scan_interval_seconds = 20
|
||||
settle_seconds = 90
|
||||
stable_checks = 2
|
||||
incomplete_suffixes = [".part", ".partial", ".!qB", ".tmp", ".crdownload"]
|
||||
media_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".ts"]
|
||||
subtitle_extensions = [".srt", ".ass", ".ssa", ".vtt", ".sub"]
|
||||
extra_keywords = ["sample", "trailer", "behind the scenes", "featurette", "deleted scene"]
|
||||
library_scan_max_files = 20000
|
||||
library_scan_timeout_seconds = 8
|
||||
cache_max_bytes = 21474836480
|
||||
auto_move_min_confidence = 90
|
||||
review_min_confidence = 60
|
||||
organization_metadata_budget_seconds = 25
|
||||
organization_metadata_timeout_seconds = 3
|
||||
metadata_parallelism = 8
|
||||
|
||||
[paths]
|
||||
downloads = "/downloads"
|
||||
data = "/data"
|
||||
logs = "/logs"
|
||||
cache = "/data/cache"
|
||||
|
||||
[[drives]]
|
||||
id = "drive1"
|
||||
name = "Media Drive 1"
|
||||
path = "/media/drive1"
|
||||
min_free_gb = 20
|
||||
|
||||
[[drives]]
|
||||
id = "drive2"
|
||||
name = "Media Drive 2"
|
||||
path = "/media/drive2"
|
||||
min_free_gb = 20
|
||||
|
||||
[[drives]]
|
||||
id = "drive3"
|
||||
name = "Media Drive 3"
|
||||
path = "/media/drive3"
|
||||
min_free_gb = 20
|
||||
|
||||
[[drives]]
|
||||
id = "drive4"
|
||||
name = "Media Drive 4"
|
||||
path = "/media/drive4"
|
||||
min_free_gb = 20
|
||||
|
||||
[library]
|
||||
movie_folder = "Movies/{title} ({year})"
|
||||
series_folder = "Shows/{title}/Season {season:02d}"
|
||||
movie_file = "{title} ({year}){quality}{ext}"
|
||||
episode_file = "{title} - S{season:02d}E{episode:02d}{multi_episode} - {episode_title}{quality}{ext}"
|
||||
subtitle_file = "{basename}{language}{ext}"
|
||||
unknown_folder = "Unsorted/{title}"
|
||||
collision = "keep-both" # keep-both, skip, replace
|
||||
duplicate = "skip" # skip, keep-both
|
||||
permissions_mode = "664"
|
||||
directory_mode = "775"
|
||||
|
||||
[metadata]
|
||||
write_nfo = true
|
||||
provider_order = ["filename"]
|
||||
prefer_existing_nfo = true
|
||||
tmdb_api_key = ""
|
||||
tmdb_bearer_token = ""
|
||||
tmdb_language = "en-US"
|
||||
tmdb_image_base = "https://image.tmdb.org/t/p/w342"
|
||||
tmdb_enabled = true
|
||||
|
||||
[[release_providers]]
|
||||
id = "tmdb-rss"
|
||||
name = "TMDb RSS"
|
||||
enabled = false
|
||||
type = "rss"
|
||||
url = "https://www.themoviedb.org/rss/movie/upcoming"
|
||||
|
||||
[[release_providers]]
|
||||
id = "tvmaze-premieres"
|
||||
name = "TVMaze Premieres"
|
||||
enabled = false
|
||||
type = "json"
|
||||
url = "https://api.tvmaze.com/schedule"
|
||||
|
||||
[theme]
|
||||
default = "slate"
|
||||
allow_custom_css = true
|
||||
custom_css_path = "/config/custom-theme.css"
|
||||
2
backend/sortarr/__init__.py
Normal file
2
backend/sortarr/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
__all__ = ["config", "organizer", "server"]
|
||||
|
||||
338
backend/sortarr/app.py
Normal file
338
backend/sortarr/app.py
Normal file
@@ -0,0 +1,338 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from .config import load_config, public_config
|
||||
from .downloads import downloads_snapshot
|
||||
from .library import library_snapshot, normalize_library
|
||||
from .logging_setup import configure_logging
|
||||
from .media_probe import edit_track, media_probe
|
||||
from .metadata import test_tmdb
|
||||
from .organizer import execute_bundle_plan
|
||||
from .releases import fetch_releases
|
||||
from .scanner import Scanner
|
||||
from .store import JsonStore
|
||||
from .storage import drive_stats
|
||||
from .tools import duplicate_finder, run_next_transcode, subtitle_audit, transcode_plan
|
||||
|
||||
|
||||
SETTINGS_SCHEMA = {
|
||||
"app": {
|
||||
"name": str,
|
||||
"dry_run": bool,
|
||||
"log_level": str,
|
||||
"scan_interval_seconds": int,
|
||||
"settle_seconds": int,
|
||||
"stable_checks": int,
|
||||
"incomplete_suffixes": list,
|
||||
"media_extensions": list,
|
||||
"subtitle_extensions": list,
|
||||
"extra_keywords": list,
|
||||
"library_scan_max_files": int,
|
||||
"library_scan_timeout_seconds": int,
|
||||
"cache_max_bytes": int,
|
||||
"auto_move_min_confidence": int,
|
||||
"review_min_confidence": int,
|
||||
"organization_metadata_budget_seconds": int,
|
||||
"organization_metadata_timeout_seconds": int,
|
||||
"metadata_parallelism": int,
|
||||
},
|
||||
"paths": {
|
||||
"downloads": str,
|
||||
"data": str,
|
||||
"logs": str,
|
||||
"cache": str,
|
||||
},
|
||||
"library": {
|
||||
"movie_folder": str,
|
||||
"series_folder": str,
|
||||
"movie_file": str,
|
||||
"episode_file": str,
|
||||
"subtitle_file": str,
|
||||
"unknown_folder": str,
|
||||
"collision": str,
|
||||
"duplicate": str,
|
||||
"permissions_mode": str,
|
||||
"directory_mode": str,
|
||||
},
|
||||
"metadata": {
|
||||
"write_nfo": bool,
|
||||
"provider_order": list,
|
||||
"prefer_existing_nfo": bool,
|
||||
"tmdb_api_key": str,
|
||||
"tmdb_bearer_token": str,
|
||||
"tmdb_language": str,
|
||||
"tmdb_image_base": str,
|
||||
"tmdb_enabled": bool,
|
||||
},
|
||||
"theme": {
|
||||
"default": str,
|
||||
"allow_custom_css": bool,
|
||||
"custom_css_path": str,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def deep_merge(base: dict, override: dict) -> dict:
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
|
||||
def coerce_value(value, caster):
|
||||
if caster is bool:
|
||||
return bool(value)
|
||||
if caster is int:
|
||||
return int(value)
|
||||
if caster is list:
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return [item.strip() for item in str(value).split(",") if item.strip()]
|
||||
return caster(value)
|
||||
|
||||
|
||||
def apply_settings(config: dict, settings: dict) -> dict:
|
||||
if any(key in SETTINGS_SCHEMA["app"] for key in settings):
|
||||
settings = {"app": settings}
|
||||
applied = {}
|
||||
for section, fields in SETTINGS_SCHEMA.items():
|
||||
values = settings.get(section)
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
target = config.setdefault(section, {})
|
||||
applied_section = applied.setdefault(section, {})
|
||||
for key, caster in fields.items():
|
||||
if key not in values:
|
||||
continue
|
||||
target[key] = coerce_value(values[key], caster)
|
||||
applied_section[key] = target[key]
|
||||
if not applied_section:
|
||||
applied.pop(section, None)
|
||||
|
||||
if isinstance(settings.get("drives"), list):
|
||||
drives = []
|
||||
for idx, drive in enumerate(settings["drives"]):
|
||||
if not isinstance(drive, dict):
|
||||
continue
|
||||
existing = (config.get("drives") or [{}] * (idx + 1))[idx] if idx < len(config.get("drives", [])) else {}
|
||||
drives.append({
|
||||
"id": str(drive.get("id", existing.get("id", f"drive{idx + 1}"))),
|
||||
"name": str(drive.get("name", existing.get("name", f"Media Drive {idx + 1}"))),
|
||||
"path": str(drive.get("path", existing.get("path", ""))),
|
||||
"min_free_gb": int(drive.get("min_free_gb", existing.get("min_free_gb", 20))),
|
||||
})
|
||||
config["drives"] = drives
|
||||
applied["drives"] = drives
|
||||
|
||||
if isinstance(settings.get("release_providers"), list):
|
||||
providers = []
|
||||
for provider in settings["release_providers"]:
|
||||
if not isinstance(provider, dict):
|
||||
continue
|
||||
providers.append({
|
||||
"id": str(provider.get("id", "")),
|
||||
"name": str(provider.get("name", "")),
|
||||
"enabled": bool(provider.get("enabled", False)),
|
||||
"type": str(provider.get("type", "rss")),
|
||||
"url": str(provider.get("url", "")),
|
||||
})
|
||||
config["release_providers"] = providers
|
||||
applied["release_providers"] = providers
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
CONFIG = load_config()
|
||||
configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO"))
|
||||
STORE = JsonStore(CONFIG["paths"]["data"])
|
||||
apply_settings(CONFIG, STORE.snapshot().get("settings", {}))
|
||||
SCANNER = Scanner(CONFIG, STORE)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "Sortarr/0.1"
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, payload, status=HTTPStatus.OK) -> None:
|
||||
body = json.dumps(payload, indent=2).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
try:
|
||||
if path == "/api/health":
|
||||
self.send_json({"ok": True})
|
||||
elif path == "/api/config":
|
||||
self.send_json(public_config(CONFIG))
|
||||
elif path == "/api/dashboard":
|
||||
snap = STORE.snapshot()
|
||||
cached_library = snap.get("library") or {
|
||||
"drives": drive_stats(CONFIG),
|
||||
"items": [],
|
||||
"counts": {"movies": 0, "tv": 0, "total": 0},
|
||||
"extensions": {},
|
||||
"scanned_files": 0,
|
||||
"truncated": False,
|
||||
"cached": False,
|
||||
}
|
||||
cached_library = normalize_library(cached_library)
|
||||
cached_library.pop("items", None)
|
||||
cached_library.pop("collections", None)
|
||||
public_state = {
|
||||
"events": snap.get("events", [])[:200],
|
||||
"organizer": snap.get("organizer", {"queue": [], "updated_at": None}),
|
||||
"settings": snap.get("settings", {}),
|
||||
"updated_at": snap.get("updated_at"),
|
||||
}
|
||||
self.send_json({
|
||||
"state": public_state,
|
||||
"library": cached_library,
|
||||
"dry_run": CONFIG["app"].get("dry_run"),
|
||||
})
|
||||
elif path == "/api/library":
|
||||
library = STORE.snapshot().get("library") or {
|
||||
"drives": drive_stats(CONFIG),
|
||||
"items": [],
|
||||
"counts": {"movies": 0, "tv": 0, "total": 0},
|
||||
"extensions": {},
|
||||
"scanned_files": 0,
|
||||
"truncated": False,
|
||||
"cached": False,
|
||||
}
|
||||
library = normalize_library(library)
|
||||
library.pop("items", None)
|
||||
self.send_json({"library": library})
|
||||
elif path == "/api/downloads":
|
||||
self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())})
|
||||
elif path == "/api/releases":
|
||||
self.send_json({"releases": fetch_releases(CONFIG, STORE.snapshot().get("library"))})
|
||||
elif path == "/api/media/probe":
|
||||
params = parse_qs(parsed_url.query)
|
||||
target = unquote((params.get("path") or [""])[0])
|
||||
self.send_json({"media": media_probe(CONFIG, target)})
|
||||
elif path == "/api/tools/subtitles":
|
||||
self.send_json({"audit": subtitle_audit(CONFIG, STORE.snapshot().get("library"))})
|
||||
elif path == "/api/tools/transcoder":
|
||||
self.send_json({"transcoder": transcode_plan(CONFIG, STORE.snapshot().get("library"))})
|
||||
elif path == "/api/tools/duplicates":
|
||||
self.send_json({"duplicates": duplicate_finder(CONFIG, STORE.snapshot().get("library"))})
|
||||
elif path == "/api/theme/custom.css":
|
||||
custom = CONFIG.get("theme", {}).get("custom_css_path")
|
||||
if custom and CONFIG.get("theme", {}).get("allow_custom_css", True) and os.path.exists(custom):
|
||||
body = open(custom, "rb").read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/css")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
if path == "/api/scan":
|
||||
started = SCANNER.request_scan()
|
||||
snap = STORE.snapshot()
|
||||
self.send_json({
|
||||
"started": started,
|
||||
"status": "started" if started else "already-running",
|
||||
"queue": snap.get("organizer", {}).get("queue", []),
|
||||
}, HTTPStatus.ACCEPTED)
|
||||
elif path == "/api/organizer/approve":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
plan_id = payload.get("id")
|
||||
snap = STORE.snapshot()
|
||||
queue = snap.get("organizer", {}).get("queue", [])
|
||||
plan = next((item for item in queue if item.get("id") == plan_id), None)
|
||||
if not plan:
|
||||
self.send_json({"error": "plan not found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
result = execute_bundle_plan(CONFIG, plan, force=True)
|
||||
updated = [result if item.get("id") == plan_id else item for item in queue]
|
||||
STORE.set_organizer_queue(updated)
|
||||
STORE.add_event("info", f"approved organizer plan: {result.get('result')}", path=result.get("source"), confidence=result.get("confidence"))
|
||||
self.send_json({"plan": result})
|
||||
elif path == "/api/organizer/skip":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
plan_id = payload.get("id")
|
||||
snap = STORE.snapshot()
|
||||
queue = snap.get("organizer", {}).get("queue", [])
|
||||
updated = [{**item, "status": "skipped", "result": "skipped"} if item.get("id") == plan_id else item for item in queue]
|
||||
STORE.set_organizer_queue(updated)
|
||||
self.send_json({"ok": True})
|
||||
elif path == "/api/library/scan":
|
||||
library = library_snapshot(CONFIG)
|
||||
STORE.set_library(library)
|
||||
self.send_json({"library": library})
|
||||
elif path == "/api/tools/transcoder/run-next":
|
||||
result = run_next_transcode(CONFIG, STORE.snapshot().get("library"))
|
||||
STORE.add_event("info", f"transcoder: {result.get('status')}")
|
||||
self.send_json({"transcoder": result})
|
||||
elif path == "/api/metadata/tmdb/test":
|
||||
self.send_json({"tmdb": test_tmdb(CONFIG)})
|
||||
elif path == "/api/media/tracks":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
result = edit_track(CONFIG, payload.get("path", ""), payload.get("action", ""), int(payload.get("stream_index", -1)))
|
||||
STORE.add_event("info", f"track edit: {result.get('status')}", path=payload.get("path", ""))
|
||||
self.send_json({"media": result})
|
||||
elif path == "/api/settings":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
updates = json.loads(body)
|
||||
applied = apply_settings(CONFIG, updates)
|
||||
snap = STORE.snapshot()
|
||||
settings = snap.get("settings", {})
|
||||
deep_merge(settings, applied)
|
||||
STORE.state["settings"] = settings
|
||||
STORE.save()
|
||||
self.send_json({"settings": applied, "config": public_config(CONFIG)})
|
||||
else:
|
||||
self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
SCANNER.start()
|
||||
host = os.getenv("SORTARR_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("SORTARR_API_PORT", "8099"))
|
||||
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
backend/sortarr/cache.py
Normal file
75
backend/sortarr/cache.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def cache_root(config: dict) -> Path:
|
||||
root = Path(config.get("paths", {}).get("cache") or Path(config["paths"]["data"]) / "cache")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def cache_path(config: dict, namespace: str, key: str) -> Path:
|
||||
digest = hashlib.sha256(key.encode()).hexdigest()
|
||||
path = cache_root(config) / namespace / f"{digest}.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_json(config: dict, namespace: str, key: str, ttl_seconds: int | None = None) -> Any | None:
|
||||
path = cache_path(config, namespace, key)
|
||||
if not path.exists():
|
||||
return None
|
||||
if ttl_seconds is not None and time.time() - path.stat().st_mtime > ttl_seconds:
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def set_json(config: dict, namespace: str, key: str, value: Any) -> None:
|
||||
path = cache_path(config, namespace, key)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(value, sort_keys=True))
|
||||
tmp.replace(path)
|
||||
prune(config)
|
||||
|
||||
|
||||
def remove_json(config: dict, namespace: str, key: str) -> None:
|
||||
path = cache_path(config, namespace, key)
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
|
||||
def prune(config: dict) -> None:
|
||||
root = cache_root(config)
|
||||
max_bytes = int(config.get("app", {}).get("cache_max_bytes", 20 * 1024**3))
|
||||
files = []
|
||||
total = 0
|
||||
for current, _, names in os.walk(root):
|
||||
for name in names:
|
||||
path = Path(current) / name
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total += stat.st_size
|
||||
files.append((stat.st_mtime, stat.st_size, path))
|
||||
if total <= max_bytes:
|
||||
return
|
||||
for _, size, path in sorted(files):
|
||||
try:
|
||||
path.unlink()
|
||||
total -= size
|
||||
except OSError:
|
||||
continue
|
||||
if total <= max_bytes:
|
||||
break
|
||||
71
backend/sortarr/config.py
Normal file
71
backend/sortarr/config.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _read_toml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("rb") as handle:
|
||||
return tomllib.load(handle)
|
||||
|
||||
|
||||
def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = copy.deepcopy(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
return merged
|
||||
|
||||
|
||||
def _bool(value: str) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
default_path = Path(os.getenv("SORTARR_DEFAULT_CONFIG", "/app/default-config/app.toml"))
|
||||
user_path = Path(os.getenv("SORTARR_CONFIG", "/config/app.toml"))
|
||||
config = _merge(_read_toml(default_path), _read_toml(user_path))
|
||||
|
||||
app = config.setdefault("app", {})
|
||||
paths = config.setdefault("paths", {})
|
||||
|
||||
env_map = {
|
||||
"SORTARR_DRY_RUN": ("app", "dry_run", _bool),
|
||||
"SORTARR_LOG_LEVEL": ("app", "log_level", str),
|
||||
"SORTARR_SCAN_INTERVAL_SECONDS": ("app", "scan_interval_seconds", int),
|
||||
"SORTARR_SETTLE_SECONDS": ("app", "settle_seconds", int),
|
||||
"SORTARR_DATA_DIR": ("paths", "data", str),
|
||||
"SORTARR_LOG_DIR": ("paths", "logs", str),
|
||||
"SORTARR_CACHE_DIR": ("paths", "cache", str),
|
||||
"TMDB_API_KEY": ("metadata", "tmdb_api_key", str),
|
||||
"TMDB_BEARER_TOKEN": ("metadata", "tmdb_bearer_token", str),
|
||||
}
|
||||
for env, (section, key, caster) in env_map.items():
|
||||
if os.getenv(env) not in (None, ""):
|
||||
config.setdefault(section, {})[key] = caster(os.environ[env])
|
||||
|
||||
if os.getenv("SORTARR_MIN_FREE_GB"):
|
||||
for drive in config.get("drives", []):
|
||||
drive["min_free_gb"] = int(os.environ["SORTARR_MIN_FREE_GB"])
|
||||
|
||||
Path(paths.get("data", "/data")).mkdir(parents=True, exist_ok=True)
|
||||
Path(paths.get("logs", "/logs")).mkdir(parents=True, exist_ok=True)
|
||||
Path(paths.get("cache", str(Path(paths.get("data", "/data")) / "cache"))).mkdir(parents=True, exist_ok=True)
|
||||
app.setdefault("dry_run", True)
|
||||
return config
|
||||
|
||||
|
||||
def public_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
clone = copy.deepcopy(config)
|
||||
metadata = clone.get("metadata", {})
|
||||
for key in ("tmdb_api_key", "tmdb_bearer_token"):
|
||||
if metadata.get(key):
|
||||
metadata[key] = "********"
|
||||
return clone
|
||||
139
backend/sortarr/downloads.py
Normal file
139
backend/sortarr/downloads.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def empty_snapshot(root: Path, error: str | None = None) -> dict:
|
||||
return {
|
||||
"path": str(root),
|
||||
"generated_at": time.time(),
|
||||
"current": [],
|
||||
"bundles": [],
|
||||
"loose": [],
|
||||
"recent": [],
|
||||
"counts": {
|
||||
"current": 0,
|
||||
"recent": 0,
|
||||
"media": 0,
|
||||
"subtitles": 0,
|
||||
"incomplete": 0,
|
||||
},
|
||||
"total_size": 0,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def downloads_snapshot(config: dict, state: dict) -> dict:
|
||||
root = Path(config["paths"]["downloads"])
|
||||
app = config.get("app", {})
|
||||
media_extensions = set(app.get("media_extensions", []))
|
||||
subtitle_extensions = set(app.get("subtitle_extensions", []))
|
||||
incomplete = set(app.get("incomplete_suffixes", []))
|
||||
current = []
|
||||
media_files = []
|
||||
subtitle_files = []
|
||||
total_size = 0
|
||||
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
paths = root.rglob("*")
|
||||
for path in paths:
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
suffix = path.suffix.lower()
|
||||
total_size += stat.st_size
|
||||
item = {
|
||||
"name": path.name,
|
||||
"path": str(path),
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"folder": str(path.parent.relative_to(root)) if path.parent != root else "",
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"extension": suffix or "none",
|
||||
"is_media": suffix in media_extensions,
|
||||
"is_subtitle": suffix in subtitle_extensions,
|
||||
"is_incomplete": suffix in incomplete,
|
||||
}
|
||||
current.append(item)
|
||||
if item["is_media"]:
|
||||
media_files.append(item)
|
||||
elif item["is_subtitle"]:
|
||||
subtitle_files.append(item)
|
||||
except OSError as exc:
|
||||
return empty_snapshot(root, str(exc))
|
||||
|
||||
subtitles_by_folder = defaultdict(list)
|
||||
for subtitle in subtitle_files:
|
||||
subtitles_by_folder[subtitle["folder"]].append(subtitle)
|
||||
parent = Path(subtitle["folder"])
|
||||
if parent.name.lower() in {"subs", "subtitles"}:
|
||||
subtitles_by_folder[str(parent.parent) if str(parent.parent) != "." else ""].append(subtitle)
|
||||
|
||||
bundles = []
|
||||
bundled_subtitle_paths = set()
|
||||
for media in media_files:
|
||||
folder_subtitles = subtitles_by_folder.get(media["folder"], [])
|
||||
stem_matches = [
|
||||
subtitle for subtitle in subtitle_files
|
||||
if subtitle["name"].lower().startswith(Path(media["name"]).stem.lower())
|
||||
]
|
||||
seen = set()
|
||||
subtitles = []
|
||||
for subtitle in folder_subtitles + stem_matches:
|
||||
if subtitle["path"] in seen:
|
||||
continue
|
||||
seen.add(subtitle["path"])
|
||||
bundled_subtitle_paths.add(subtitle["path"])
|
||||
subtitles.append(subtitle)
|
||||
bundles.append({
|
||||
"media": media,
|
||||
"subtitles": sorted(subtitles, key=lambda item: item["name"].lower()),
|
||||
"sidecars": [
|
||||
item for item in current
|
||||
if item["folder"] == media["folder"] and not item["is_media"] and not item["is_subtitle"]
|
||||
][:20],
|
||||
"size": media["size"] + sum(item["size"] for item in subtitles),
|
||||
})
|
||||
|
||||
loose = [
|
||||
item for item in current
|
||||
if not item["is_media"] and item["path"] not in bundled_subtitle_paths
|
||||
]
|
||||
|
||||
recent = []
|
||||
for item in state.get("items", []):
|
||||
source = item.get("source", "")
|
||||
status = item.get("status")
|
||||
if source.startswith(str(root)) and status in {"moved", "planned"}:
|
||||
recent.append({
|
||||
"source": source,
|
||||
"destination": item.get("destination"),
|
||||
"title": item.get("title"),
|
||||
"type": item.get("type"),
|
||||
"status": status,
|
||||
"drive": item.get("drive"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
})
|
||||
|
||||
return {
|
||||
"path": str(root),
|
||||
"generated_at": time.time(),
|
||||
"current": sorted(current, key=lambda item: item["modified"], reverse=True),
|
||||
"bundles": sorted(bundles, key=lambda item: item["media"]["modified"], reverse=True),
|
||||
"loose": sorted(loose, key=lambda item: item["modified"], reverse=True),
|
||||
"recent": sorted(recent, key=lambda item: item.get("updated_at") or 0, reverse=True)[:200],
|
||||
"counts": {
|
||||
"current": len(current),
|
||||
"recent": len(recent),
|
||||
"media": sum(1 for item in current if item["is_media"]),
|
||||
"subtitles": sum(1 for item in current if item["is_subtitle"]),
|
||||
"incomplete": sum(1 for item in current if item["is_incomplete"]),
|
||||
},
|
||||
"total_size": total_size,
|
||||
}
|
||||
7
backend/sortarr/healthcheck.py
Normal file
7
backend/sortarr/healthcheck.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
with urlopen("http://127.0.0.1:8099/api/health", timeout=3) as response:
|
||||
if response.status != 200:
|
||||
raise SystemExit(1)
|
||||
|
||||
331
backend/sortarr/library.py
Normal file
331
backend/sortarr/library.py
Normal file
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
from .metadata import movie_metadata, series_metadata
|
||||
from .parser import clean_title, parse_media
|
||||
from .storage import drive_stats
|
||||
|
||||
|
||||
LIBRARY_ROOT_NAMES = {"movies", "shows", "tv", "tv shows"}
|
||||
TV_ROOT_NAMES = {"shows", "tv", "tv shows"}
|
||||
EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})")
|
||||
SEASON_FOLDER_RE = re.compile(r"season[ ._-]*(\d{1,2})", re.I)
|
||||
YEAR_RE = re.compile(r"\((19\d{2}|20\d{2})\)")
|
||||
ANY_YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
|
||||
VERSION_RE = re.compile(r"\b(2160p|1080p|720p|480p|remux|bluray|web[- .]?dl|webrip|hdtv|dvdrip|x264|x265|h[ ._-]?264|h[ ._-]?265|hevc|av1|hdr10?|dv|proper|repack|extended|unrated|directors?[ ._-]?cut|theatrical|imax)\b", re.I)
|
||||
EXTRA_FOLDER_NAMES = {
|
||||
"behind the scenes",
|
||||
"deleted scenes",
|
||||
"extras",
|
||||
"featurettes",
|
||||
"interviews",
|
||||
"samples",
|
||||
"scenes",
|
||||
"shorts",
|
||||
"trailers",
|
||||
}
|
||||
|
||||
|
||||
def library_roots(root: Path) -> list[Path]:
|
||||
matches = []
|
||||
try:
|
||||
children = list(root.iterdir())
|
||||
except OSError:
|
||||
return matches
|
||||
for child in children:
|
||||
if child.is_dir() and child.name.lower() in LIBRARY_ROOT_NAMES:
|
||||
matches.append(child)
|
||||
return matches
|
||||
|
||||
|
||||
def library_kind(library_root: Path) -> str:
|
||||
return "tv" if library_root.name.lower() in TV_ROOT_NAMES else "movie"
|
||||
|
||||
|
||||
def infer_library_kind(path: str) -> str:
|
||||
parts = {part.lower() for part in Path(path).parts}
|
||||
if parts & TV_ROOT_NAMES:
|
||||
return "tv"
|
||||
if "movies" in parts:
|
||||
return "movie"
|
||||
return "other"
|
||||
|
||||
|
||||
def split_library_path(path: str) -> tuple[str, list[str]]:
|
||||
parts = list(Path(path).parts)
|
||||
lowered = [part.lower() for part in parts]
|
||||
for root in LIBRARY_ROOT_NAMES:
|
||||
if root in lowered:
|
||||
idx = lowered.index(root)
|
||||
return parts[idx], parts[idx + 1:]
|
||||
return "", parts
|
||||
|
||||
|
||||
def identity_slug(title: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", title.lower()).strip()
|
||||
|
||||
|
||||
def clean_collection_title(name: str) -> tuple[str, int | None]:
|
||||
year_match = ANY_YEAR_RE.search(name)
|
||||
year = int(year_match.group(1)) if year_match else None
|
||||
title = clean_title(name)
|
||||
return title, year
|
||||
|
||||
|
||||
def merge_key(kind: str, title: str, year: int | None = None) -> str:
|
||||
slug = identity_slug(title)
|
||||
if kind == "movie":
|
||||
return f"movie::{slug}::{year or ''}"
|
||||
return f"tv::{slug}"
|
||||
|
||||
|
||||
def file_version(item: dict) -> dict:
|
||||
path = Path(item.get("path", ""))
|
||||
text = " ".join(part for part in [path.parent.name, path.stem] if part)
|
||||
tags = []
|
||||
for match in VERSION_RE.finditer(text):
|
||||
tag = match.group(1).replace(".", " ").replace("_", " ")
|
||||
normalized = re.sub(r"\s+", " ", tag).strip()
|
||||
if normalized.lower() not in {existing.lower() for existing in tags}:
|
||||
tags.append(normalized)
|
||||
return {
|
||||
"path": item.get("path"),
|
||||
"name": item.get("name"),
|
||||
"drive": item.get("drive"),
|
||||
"size": item.get("size") or 0,
|
||||
"quality": next((tag for tag in tags if tag.lower() in {"2160p", "1080p", "720p", "480p"}), ""),
|
||||
"tags": tags[:8],
|
||||
}
|
||||
|
||||
|
||||
def is_extra_media(path: Path, library_root: Path, kind: str, app: dict) -> bool:
|
||||
try:
|
||||
relative = path.relative_to(library_root)
|
||||
except ValueError:
|
||||
relative = path
|
||||
parts = [part.lower().replace("_", " ").replace(".", " ") for part in relative.parts[:-1]]
|
||||
if kind == "movie" and any(part in EXTRA_FOLDER_NAMES for part in parts[1:]):
|
||||
return True
|
||||
lowered_name = path.name.lower().replace("_", " ").replace(".", " ")
|
||||
return any(keyword and keyword.lower() in lowered_name for keyword in app.get("extra_keywords", []))
|
||||
|
||||
|
||||
def item_identity(item: dict) -> dict:
|
||||
root, rel = split_library_path(item.get("path", ""))
|
||||
kind = item.get("library") or infer_library_kind(item.get("path", ""))
|
||||
parsed = parse_media(item.get("path", item.get("name", "")))
|
||||
if kind == "tv" and rel:
|
||||
title = clean_title(rel[0])
|
||||
season = parsed.get("season")
|
||||
episode = parsed.get("episode")
|
||||
for part in rel:
|
||||
match = SEASON_FOLDER_RE.search(part)
|
||||
if match and not season:
|
||||
season = int(match.group(1))
|
||||
return {
|
||||
"kind": "tv",
|
||||
"root": root,
|
||||
"title": title,
|
||||
"key": merge_key("tv", title),
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
}
|
||||
title, year = clean_collection_title(rel[0] if rel else parsed["title"])
|
||||
year = year or parsed.get("year")
|
||||
return {
|
||||
"kind": "movie",
|
||||
"root": root,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"slug": identity_slug(title),
|
||||
"key": merge_key("movie", title, year),
|
||||
}
|
||||
|
||||
|
||||
def normalize_library(library: dict) -> dict:
|
||||
items = library.get("items", [])
|
||||
kinds = Counter()
|
||||
for item in items:
|
||||
kind = item.get("library") or infer_library_kind(item.get("path", ""))
|
||||
item["library"] = kind
|
||||
if kind in {"movie", "tv"}:
|
||||
kinds[kind] += 1
|
||||
library["counts"] = {
|
||||
"movies": kinds.get("movie", 0),
|
||||
"tv": kinds.get("tv", 0),
|
||||
"total": len(items),
|
||||
}
|
||||
if "collections" not in library:
|
||||
library["collections"] = build_collections({}, items)
|
||||
return library
|
||||
|
||||
|
||||
def build_collections(config: dict, items: list[dict], enrich: bool = False) -> dict:
|
||||
movies: dict[str, dict] = {}
|
||||
series: dict[str, dict] = {}
|
||||
for item in items:
|
||||
identity = item_identity(item)
|
||||
if identity["kind"] == "tv":
|
||||
show = series.setdefault(identity["key"], {
|
||||
"key": identity["key"],
|
||||
"title": identity["title"],
|
||||
"library": "tv",
|
||||
"files": [],
|
||||
"seasons": {},
|
||||
"metadata": {"title": identity["title"], "source": "filename", "seasons": {}},
|
||||
})
|
||||
show["files"].append(item)
|
||||
season_no = identity.get("season") or 0
|
||||
episode_no = identity.get("episode") or 0
|
||||
season = show["seasons"].setdefault(str(season_no), {"season": season_no, "episodes": {}})
|
||||
episode = season["episodes"].setdefault(str(episode_no), {
|
||||
"season": season_no,
|
||||
"episode": episode_no,
|
||||
"title": f"S{season_no:02d}E{episode_no:02d}" if season_no and episode_no else item["name"],
|
||||
"files": [],
|
||||
"status": "present",
|
||||
})
|
||||
episode["files"].append(item)
|
||||
else:
|
||||
key = identity["key"]
|
||||
if not identity.get("year"):
|
||||
existing_key = next((candidate_key for candidate_key, candidate in movies.items() if candidate.get("slug") == identity["slug"]), None)
|
||||
if existing_key:
|
||||
key = existing_key
|
||||
elif key not in movies:
|
||||
no_year_key = merge_key("movie", identity["title"], None)
|
||||
if no_year_key in movies:
|
||||
movies[key] = movies.pop(no_year_key)
|
||||
movies[key]["key"] = key
|
||||
movie = movies.setdefault(key, {
|
||||
"key": key,
|
||||
"title": identity["title"],
|
||||
"year": identity.get("year"),
|
||||
"slug": identity.get("slug"),
|
||||
"library": "movie",
|
||||
"files": [],
|
||||
"versions": [],
|
||||
"metadata": {"title": identity["title"], "source": "filename"},
|
||||
})
|
||||
movie["files"].append(item)
|
||||
movie["versions"].append(file_version(item))
|
||||
if not movie.get("year") and identity.get("year"):
|
||||
movie["year"] = identity.get("year")
|
||||
|
||||
if enrich and config:
|
||||
workers = int(config.get("app", {}).get("metadata_parallelism", 8))
|
||||
tasks = {}
|
||||
with ThreadPoolExecutor(max_workers=max(1, min(workers, 12))) as executor:
|
||||
for movie in movies.values():
|
||||
future = executor.submit(movie_metadata, config, movie["title"], movie.get("year"))
|
||||
tasks[future] = movie
|
||||
for show in series.values():
|
||||
present_seasons = {int(season) for season in show["seasons"] if int(season) > 0}
|
||||
future = executor.submit(series_metadata, config, show["title"], present_seasons)
|
||||
tasks[future] = show
|
||||
for future in as_completed(tasks):
|
||||
try:
|
||||
tasks[future]["metadata"] = future.result()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
for show in series.values():
|
||||
for season_no, season_meta in show.get("metadata", {}).get("seasons", {}).items():
|
||||
season = show["seasons"].setdefault(season_no, {"season": int(season_no), "episodes": {}})
|
||||
for meta_episode in season_meta.get("episodes", []):
|
||||
key = str(meta_episode.get("episode") or 0)
|
||||
existing = season["episodes"].get(key)
|
||||
if existing:
|
||||
existing.update({
|
||||
"title": meta_episode.get("title") or existing["title"],
|
||||
"air_date": meta_episode.get("air_date"),
|
||||
"overview": meta_episode.get("overview"),
|
||||
"still": meta_episode.get("still"),
|
||||
})
|
||||
else:
|
||||
air_date = meta_episode.get("air_date")
|
||||
season["episodes"][key] = {
|
||||
**meta_episode,
|
||||
"files": [],
|
||||
"status": "upcoming" if air_date and air_date > today else "missing",
|
||||
}
|
||||
for season in show["seasons"].values():
|
||||
season["episodes"] = sorted(season["episodes"].values(), key=lambda ep: ep.get("episode") or 0)
|
||||
show["seasons"] = sorted(show["seasons"].values(), key=lambda season: season["season"])
|
||||
|
||||
return {
|
||||
"movies": sorted(movies.values(), key=lambda movie: movie["title"].lower()),
|
||||
"series": sorted(series.values(), key=lambda show: show["title"].lower()),
|
||||
}
|
||||
|
||||
|
||||
def library_snapshot(config: dict) -> dict:
|
||||
items = []
|
||||
extensions = Counter()
|
||||
ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"}
|
||||
app = config["app"]
|
||||
max_files = int(app.get("library_scan_max_files", 20000))
|
||||
deadline = time.monotonic() + int(app.get("library_scan_timeout_seconds", 8))
|
||||
scanned = 0
|
||||
truncated = False
|
||||
for drive in config.get("drives", []):
|
||||
if scanned >= max_files or time.monotonic() >= deadline:
|
||||
truncated = True
|
||||
break
|
||||
root = Path(drive["path"])
|
||||
if not root.exists():
|
||||
continue
|
||||
for library_root in library_roots(root):
|
||||
kind = library_kind(library_root)
|
||||
for current, dirs, files in os.walk(library_root, onerror=lambda error: None):
|
||||
if scanned >= max_files or time.monotonic() >= deadline:
|
||||
truncated = True
|
||||
break
|
||||
dirs[:] = [name for name in dirs if name not in ignored_dirs]
|
||||
lower_files = {name.lower() for name in files}
|
||||
for filename in files:
|
||||
if scanned >= max_files or time.monotonic() >= deadline:
|
||||
truncated = True
|
||||
break
|
||||
path = Path(current) / filename
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
scanned += 1
|
||||
extensions[path.suffix.lower() or "none"] += 1
|
||||
if path.suffix.lower() in app.get("media_extensions", []):
|
||||
if is_extra_media(path, library_root, kind, app):
|
||||
continue
|
||||
subtitle_names = [
|
||||
f"{path.stem}{ext}".lower()
|
||||
for ext in app.get("subtitle_extensions", [])
|
||||
]
|
||||
items.append({
|
||||
"path": str(path),
|
||||
"name": path.name,
|
||||
"drive": drive["id"],
|
||||
"library": kind,
|
||||
"root": library_root.name,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"has_subtitles": any(name in lower_files for name in subtitle_names),
|
||||
})
|
||||
enrich_limit = int(app.get("library_metadata_enrich_max_items", 500))
|
||||
should_enrich = bool(config.get("metadata", {}).get("tmdb_enabled", True)) and len(items) <= enrich_limit
|
||||
return normalize_library({
|
||||
"drives": drive_stats(config),
|
||||
"items": sorted(items, key=lambda item: item["modified"], reverse=True),
|
||||
"collections": build_collections(config, items, enrich=should_enrich),
|
||||
"extensions": dict(extensions.most_common()),
|
||||
"scanned_files": scanned,
|
||||
"truncated": truncated,
|
||||
"metadata_enriched": should_enrich,
|
||||
})
|
||||
25
backend/sortarr/logging_setup.py
Normal file
25
backend/sortarr/logging_setup.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def configure_logging(log_dir: str, level: str) -> None:
|
||||
Path(log_dir).mkdir(parents=True, exist_ok=True)
|
||||
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
root = logging.getLogger()
|
||||
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
root.handlers.clear()
|
||||
|
||||
stream = logging.StreamHandler()
|
||||
stream.setFormatter(formatter)
|
||||
root.addHandler(stream)
|
||||
|
||||
try:
|
||||
file_handler = RotatingFileHandler(Path(log_dir) / "sortarr.log", maxBytes=5_000_000, backupCount=5)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
except OSError as exc:
|
||||
print(f"Sortarr could not open file logging in {log_dir}: {exc}", file=sys.stderr)
|
||||
121
backend/sortarr/media_probe.py
Normal file
121
backend/sortarr/media_probe.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import get_json, remove_json, set_json
|
||||
|
||||
|
||||
def _allowed_roots(config: dict) -> list[Path]:
|
||||
roots = [Path(drive["path"]).resolve() for drive in config.get("drives", [])]
|
||||
roots.append(Path(config["paths"]["downloads"]).resolve())
|
||||
return roots
|
||||
|
||||
|
||||
def assert_allowed_path(config: dict, path: str) -> Path:
|
||||
target = Path(path).resolve()
|
||||
for root in _allowed_roots(config):
|
||||
try:
|
||||
target.relative_to(root)
|
||||
return target
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError("path is outside configured media and downloads roots")
|
||||
|
||||
|
||||
def media_probe(config: dict, path: str) -> dict:
|
||||
target = assert_allowed_path(config, path)
|
||||
stat = target.stat()
|
||||
cache_key = f"{target}:{stat.st_size}:{int(stat.st_mtime)}"
|
||||
cached = get_json(config, "ffprobe", cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
command = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
str(target),
|
||||
]
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
if completed.returncode != 0:
|
||||
return {"path": str(target), "status": "failed", "stderr": completed.stderr[-4000:]}
|
||||
payload = json.loads(completed.stdout or "{}")
|
||||
streams = payload.get("streams", [])
|
||||
result = {
|
||||
"path": str(target),
|
||||
"cache_key": cache_key,
|
||||
"status": "ok",
|
||||
"format": payload.get("format", {}),
|
||||
"audio": [stream for stream in streams if stream.get("codec_type") == "audio"],
|
||||
"subtitles": [stream for stream in streams if stream.get("codec_type") == "subtitle"],
|
||||
"video": [stream for stream in streams if stream.get("codec_type") == "video"],
|
||||
}
|
||||
set_json(config, "ffprobe", cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def _stream_type_positions(probe: dict) -> dict[int, tuple[str, int]]:
|
||||
positions = {"audio": 0, "subtitle": 0, "video": 0}
|
||||
result = {}
|
||||
for stream in probe.get("video", []) + probe.get("audio", []) + probe.get("subtitles", []):
|
||||
codec_type = stream.get("codec_type")
|
||||
if codec_type not in positions:
|
||||
continue
|
||||
result[int(stream["index"])] = (codec_type, positions[codec_type])
|
||||
positions[codec_type] += 1
|
||||
return result
|
||||
|
||||
|
||||
def edit_track(config: dict, path: str, action: str, stream_index: int) -> dict:
|
||||
target = assert_allowed_path(config, path)
|
||||
probe = media_probe(config, str(target))
|
||||
positions = _stream_type_positions(probe)
|
||||
if stream_index not in positions:
|
||||
raise ValueError("stream index was not found")
|
||||
codec_type, type_index = positions[stream_index]
|
||||
if codec_type not in {"audio", "subtitle"}:
|
||||
raise ValueError("only audio and subtitle streams can be edited here")
|
||||
|
||||
tmp = target.with_suffix(target.suffix + ".tracksorting")
|
||||
if action == "remove":
|
||||
command = ["ffmpeg", "-hide_banner", "-y", "-i", str(target), "-map", "0", "-map", f"-0:{stream_index}", "-c", "copy", str(tmp)]
|
||||
elif action == "set-default":
|
||||
spec = "a" if codec_type == "audio" else "s"
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-i",
|
||||
str(target),
|
||||
"-map",
|
||||
"0",
|
||||
"-c",
|
||||
"copy",
|
||||
f"-disposition:{spec}",
|
||||
"0",
|
||||
f"-disposition:{spec}:{type_index}",
|
||||
"default",
|
||||
str(tmp),
|
||||
]
|
||||
else:
|
||||
raise ValueError("unsupported track action")
|
||||
|
||||
if config["app"].get("dry_run"):
|
||||
return {"status": "dry-run", "path": str(target), "action": action, "stream_index": stream_index, "command": command}
|
||||
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=60 * 60)
|
||||
if completed.returncode != 0:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return {"status": "failed", "returncode": completed.returncode, "stderr": completed.stderr[-4000:], "command": command}
|
||||
os.replace(tmp, target)
|
||||
remove_json(config, "ffprobe", probe.get("cache_key", ""))
|
||||
return {"status": "updated", "path": str(target), "action": action, "stream_index": stream_index}
|
||||
156
backend/sortarr/metadata.py
Normal file
156
backend/sortarr/metadata.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .cache import get_json, set_json
|
||||
|
||||
|
||||
TMDB_BASE = "https://api.themoviedb.org/3"
|
||||
TMDB_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _auth(config: dict) -> tuple[dict[str, str], str | None]:
|
||||
meta = config.get("metadata", {})
|
||||
token = meta.get("tmdb_bearer_token") or ""
|
||||
api_key = meta.get("tmdb_api_key") or ""
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers, api_key or None
|
||||
|
||||
|
||||
def tmdb_available(config: dict) -> bool:
|
||||
meta = config.get("metadata", {})
|
||||
if not meta.get("tmdb_enabled", True):
|
||||
return False
|
||||
return bool(meta.get("tmdb_bearer_token") or meta.get("tmdb_api_key"))
|
||||
|
||||
|
||||
def poster_url(config: dict, path: str | None) -> str | None:
|
||||
if not path:
|
||||
return None
|
||||
return f"{config.get('metadata', {}).get('tmdb_image_base', 'https://image.tmdb.org/t/p/w342')}{path}"
|
||||
|
||||
|
||||
def tmdb_get(config: dict, endpoint: str, params: dict | None = None) -> dict:
|
||||
headers, api_key = _auth(config)
|
||||
query = dict(params or {})
|
||||
query.setdefault("language", config.get("metadata", {}).get("tmdb_language", "en-US"))
|
||||
if api_key:
|
||||
query["api_key"] = api_key
|
||||
url = f"{TMDB_BASE}{endpoint}?{urlencode(query)}"
|
||||
cache_key = f"{endpoint}?{urlencode(sorted((key, value) for key, value in query.items() if key != 'api_key'))}"
|
||||
cached = get_json(config, "tmdb", cache_key, TMDB_TTL_SECONDS)
|
||||
if cached is not None:
|
||||
return cached
|
||||
timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3))
|
||||
with urlopen(Request(url, headers=headers), timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
set_json(config, "tmdb", cache_key, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def test_tmdb(config: dict) -> dict:
|
||||
meta = config.get("metadata", {})
|
||||
if not meta.get("tmdb_enabled", True):
|
||||
return {"ok": False, "status": "disabled", "message": "TMDb is disabled in settings."}
|
||||
headers, api_key = _auth(config)
|
||||
if not api_key and "Authorization" not in headers:
|
||||
return {"ok": False, "status": "missing-credentials", "message": "No TMDb API key or bearer token is configured."}
|
||||
params = {"language": meta.get("tmdb_language", "en-US")}
|
||||
if api_key:
|
||||
params["api_key"] = api_key
|
||||
url = f"{TMDB_BASE}/configuration?{urlencode(params)}"
|
||||
timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3))
|
||||
try:
|
||||
with urlopen(Request(url, headers=headers), timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
images = payload.get("images") or {}
|
||||
secure_base = images.get("secure_base_url") or images.get("base_url")
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "connected",
|
||||
"message": "TMDb accepted the configured credentials.",
|
||||
"image_base": secure_base,
|
||||
"poster_sizes": images.get("poster_sizes") or [],
|
||||
}
|
||||
except HTTPError as exc:
|
||||
return {"ok": False, "status": f"http-{exc.code}", "message": f"TMDb returned HTTP {exc.code}."}
|
||||
except (TimeoutError, URLError) as exc:
|
||||
return {"ok": False, "status": "network-error", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "status": "error", "message": str(exc)}
|
||||
|
||||
|
||||
def first_result(config: dict, media_type: str, title: str, year: int | None = None) -> dict | None:
|
||||
if not tmdb_available(config) or not title:
|
||||
return None
|
||||
params = {"query": title}
|
||||
if year and media_type == "movie":
|
||||
params["year"] = year
|
||||
elif year:
|
||||
params["first_air_date_year"] = year
|
||||
try:
|
||||
payload = tmdb_get(config, f"/search/{media_type}", params)
|
||||
except Exception:
|
||||
return None
|
||||
results = payload.get("results") or []
|
||||
return results[0] if results else None
|
||||
|
||||
|
||||
def movie_metadata(config: dict, title: str, year: int | None = None) -> dict:
|
||||
result = first_result(config, "movie", title, year)
|
||||
if not result:
|
||||
return {"title": title, "source": "filename"}
|
||||
return {
|
||||
"source": "tmdb",
|
||||
"tmdb_id": result.get("id"),
|
||||
"title": result.get("title") or title,
|
||||
"overview": result.get("overview") or "",
|
||||
"poster": poster_url(config, result.get("poster_path")),
|
||||
"backdrop": poster_url(config, result.get("backdrop_path")),
|
||||
"release_date": result.get("release_date"),
|
||||
"vote_average": result.get("vote_average"),
|
||||
}
|
||||
|
||||
|
||||
def series_metadata(config: dict, title: str, seasons: set[int]) -> dict:
|
||||
result = first_result(config, "tv", title)
|
||||
if not result:
|
||||
return {"title": title, "source": "filename", "seasons": {}}
|
||||
metadata = {
|
||||
"source": "tmdb",
|
||||
"tmdb_id": result.get("id"),
|
||||
"title": result.get("name") or title,
|
||||
"overview": result.get("overview") or "",
|
||||
"poster": poster_url(config, result.get("poster_path")),
|
||||
"backdrop": poster_url(config, result.get("backdrop_path")),
|
||||
"first_air_date": result.get("first_air_date"),
|
||||
"vote_average": result.get("vote_average"),
|
||||
"seasons": {},
|
||||
}
|
||||
for season in sorted(seasons):
|
||||
try:
|
||||
payload = tmdb_get(config, f"/tv/{result.get('id')}/season/{season}")
|
||||
except Exception:
|
||||
continue
|
||||
metadata["seasons"][str(season)] = {
|
||||
"name": payload.get("name"),
|
||||
"air_date": payload.get("air_date"),
|
||||
"episode_count": len(payload.get("episodes") or []),
|
||||
"episodes": [
|
||||
{
|
||||
"season": season,
|
||||
"episode": episode.get("episode_number"),
|
||||
"title": episode.get("name"),
|
||||
"overview": episode.get("overview") or "",
|
||||
"air_date": episode.get("air_date"),
|
||||
"still": poster_url(config, episode.get("still_path")),
|
||||
}
|
||||
for episode in payload.get("episodes") or []
|
||||
],
|
||||
}
|
||||
return metadata
|
||||
320
backend/sortarr/organizer.py
Normal file
320
backend/sortarr/organizer.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import hashlib
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from .metadata import movie_metadata, series_metadata, tmdb_available
|
||||
from .parser import parse_media
|
||||
from .storage import choose_drive
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
LANGUAGE_HINTS = {
|
||||
"eng": "eng",
|
||||
"english": "eng",
|
||||
"en": "eng",
|
||||
"spa": "spa",
|
||||
"spanish": "spa",
|
||||
"fre": "fre",
|
||||
"french": "fre",
|
||||
"ger": "ger",
|
||||
"german": "ger",
|
||||
"ita": "ita",
|
||||
"jpn": "jpn",
|
||||
"japanese": "jpn",
|
||||
"kor": "kor",
|
||||
}
|
||||
|
||||
|
||||
def safe_name(value: str) -> str:
|
||||
return "".join(ch for ch in value if ch not in '<>:"/\\|?*').strip().rstrip(".") or "Unknown"
|
||||
|
||||
|
||||
def format_destination(config: dict, media: dict, drive: dict) -> Path:
|
||||
lib = config["library"]
|
||||
title = safe_name(media["title"])
|
||||
year = media.get("year") or "Unknown Year"
|
||||
if media["type"] == "episode":
|
||||
folder_tpl = lib["series_folder"]
|
||||
file_tpl = lib["episode_file"]
|
||||
elif media["type"] == "season":
|
||||
folder_tpl = lib["series_folder"]
|
||||
file_tpl = "{title} - Season {season:02d}{quality}{ext}"
|
||||
else:
|
||||
folder_tpl = lib["movie_folder"] if media.get("year") else lib["unknown_folder"]
|
||||
file_tpl = lib["movie_file"]
|
||||
values = {
|
||||
**media,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"season": media.get("season") or 1,
|
||||
"episode": media.get("episode") or 1,
|
||||
"episode_title": safe_name(media.get("episode_title") or "Episode"),
|
||||
"ext": media["extension"],
|
||||
}
|
||||
folder = folder_tpl.format(**values)
|
||||
filename = file_tpl.format(**values)
|
||||
return Path(drive["path"]) / folder / filename
|
||||
|
||||
|
||||
def ensure_directory(path: Path, config: dict) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
mode = int(str(config["library"].get("directory_mode", "775")), 8)
|
||||
current = path
|
||||
stop = Path(config["paths"].get("downloads", "/downloads"))
|
||||
try:
|
||||
current.relative_to(stop)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
while current != current.parent:
|
||||
try:
|
||||
os.chmod(current, mode)
|
||||
except OSError:
|
||||
pass
|
||||
if any(str(current) == str(Path(drive["path"])) for drive in config.get("drives", [])):
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
|
||||
def language_suffix(path: Path) -> str:
|
||||
lowered = path.stem.lower().replace(".", " ").replace("_", " ")
|
||||
for token, code in LANGUAGE_HINTS.items():
|
||||
if token in lowered.split():
|
||||
return f".{code}"
|
||||
return ""
|
||||
|
||||
|
||||
def unique_planned_path(path: Path, rule: str, reserved: set[str]) -> Path | None:
|
||||
candidate = collision_path(path, rule)
|
||||
if not candidate:
|
||||
return None
|
||||
if str(candidate) not in reserved:
|
||||
reserved.add(str(candidate))
|
||||
return candidate
|
||||
stem, suffix = candidate.stem, candidate.suffix
|
||||
for idx in range(2, 1000):
|
||||
numbered = candidate.with_name(f"{stem}.{idx}{suffix}")
|
||||
if not numbered.exists() and str(numbered) not in reserved:
|
||||
reserved.add(str(numbered))
|
||||
return numbered
|
||||
raise RuntimeError(f"Could not find collision-free name for {path}")
|
||||
|
||||
|
||||
def tmdb_episode_title(metadata: dict, season: int | None, episode: int | None) -> str | None:
|
||||
if not season or not episode:
|
||||
return None
|
||||
season_data = metadata.get("seasons", {}).get(str(season), {})
|
||||
for item in season_data.get("episodes", []):
|
||||
if item.get("episode") == episode and item.get("title"):
|
||||
return item["title"]
|
||||
return None
|
||||
|
||||
|
||||
def plan_id(source: str) -> str:
|
||||
return hashlib.sha256(source.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def quality_score(media: dict) -> int:
|
||||
quality = media.get("quality", "").lower()
|
||||
if "2160" in quality:
|
||||
return 4
|
||||
if "1080" in quality:
|
||||
return 3
|
||||
if "720" in quality:
|
||||
return 2
|
||||
if "480" in quality:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def confidence(config: dict, media: dict, metadata_enabled: bool = True) -> tuple[int, list[str], dict]:
|
||||
score = 20
|
||||
reasons = []
|
||||
metadata = {"source": "filename", "title": media["title"]}
|
||||
if media["title"] != "Unknown" and len(media["title"]) > 2:
|
||||
score += 20
|
||||
reasons.append("title parsed")
|
||||
if media["type"] == "episode" and media.get("season") and media.get("episode"):
|
||||
score += 35
|
||||
reasons.append("season and episode parsed")
|
||||
if media["type"] == "movie" and media.get("year"):
|
||||
score += 25
|
||||
reasons.append("year parsed")
|
||||
if media.get("quality"):
|
||||
score += 5
|
||||
reasons.append("quality parsed")
|
||||
if metadata_enabled and tmdb_available(config):
|
||||
if media["type"] == "movie":
|
||||
metadata = movie_metadata(config, media["title"], media.get("year"))
|
||||
elif media["type"] == "episode":
|
||||
metadata = series_metadata(config, media["title"], {media.get("season") or 1})
|
||||
if metadata.get("source") == "tmdb":
|
||||
score += 20
|
||||
reasons.append("TMDb match")
|
||||
elif tmdb_available(config):
|
||||
reasons.append("metadata deferred")
|
||||
return min(score, 100), reasons, metadata
|
||||
|
||||
|
||||
def plan_bundle(config: dict, bundle: dict, metadata_enabled: bool = True) -> dict:
|
||||
media_file = Path(bundle["media"]["path"])
|
||||
media = parse_media(str(media_file))
|
||||
score, reasons, metadata = confidence(config, media, metadata_enabled)
|
||||
drive = choose_drive(config, metadata.get("title") or media["title"])
|
||||
if metadata.get("source") == "tmdb":
|
||||
media["title"] = metadata.get("title") or media["title"]
|
||||
if media["type"] == "movie" and metadata.get("release_date") and not media.get("year"):
|
||||
media["year"] = int(metadata["release_date"][:4])
|
||||
if media["type"] == "episode":
|
||||
media["episode_title"] = tmdb_episode_title(metadata, media.get("season"), media.get("episode")) or media.get("episode_title") or "Episode"
|
||||
dest = format_destination(config, media, drive)
|
||||
final = collision_path(dest, config["library"].get("collision", "keep-both"))
|
||||
subtitle_moves = []
|
||||
if final:
|
||||
reserved = {str(final)}
|
||||
for subtitle in bundle.get("subtitles", []):
|
||||
subtitle_path = Path(subtitle["path"])
|
||||
suffix = language_suffix(subtitle_path)
|
||||
if not suffix:
|
||||
suffix = ".und"
|
||||
values = {
|
||||
"basename": final.stem,
|
||||
"language": suffix,
|
||||
"ext": subtitle_path.suffix.lower(),
|
||||
}
|
||||
subtitle_name = config["library"].get("subtitle_file", "{basename}{language}{ext}").format(**values)
|
||||
subtitle_dest = final.with_name(safe_name(Path(subtitle_name).stem) + subtitle_path.suffix.lower())
|
||||
subtitle_final = unique_planned_path(subtitle_dest, config["library"].get("collision", "keep-both"), reserved)
|
||||
subtitle_moves.append({
|
||||
"source": str(subtitle_path),
|
||||
"destination": str(subtitle_final) if subtitle_final else None,
|
||||
"language": suffix.lstrip(".") or None,
|
||||
})
|
||||
auto_threshold = int(config["app"].get("auto_move_min_confidence", 90))
|
||||
review_threshold = int(config["app"].get("review_min_confidence", 60))
|
||||
if not final:
|
||||
status = "skipped"
|
||||
elif score >= auto_threshold:
|
||||
status = "ready"
|
||||
elif score >= review_threshold:
|
||||
status = "needs-review"
|
||||
else:
|
||||
status = "low-confidence"
|
||||
return {
|
||||
"id": plan_id(str(media_file)),
|
||||
"source": str(media_file),
|
||||
"destination": str(final) if final else None,
|
||||
"media": media,
|
||||
"metadata": metadata,
|
||||
"drive": drive["id"],
|
||||
"confidence": score,
|
||||
"reasons": reasons,
|
||||
"status": status,
|
||||
"subtitles": subtitle_moves,
|
||||
"sidecars": bundle.get("sidecars", []),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def collision_path(path: Path, rule: str) -> Path | None:
|
||||
if not path.exists():
|
||||
return path
|
||||
if rule == "skip":
|
||||
return None
|
||||
if rule == "replace":
|
||||
return path
|
||||
stem, suffix = path.stem, path.suffix
|
||||
for idx in range(2, 1000):
|
||||
candidate = path.with_name(f"{stem} ({idx}){suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise RuntimeError(f"Could not find collision-free name for {path}")
|
||||
|
||||
|
||||
def write_nfo(path: Path, media: dict) -> None:
|
||||
nfo = path.with_suffix(".nfo")
|
||||
root = ET.Element("movie" if media["type"] == "movie" else "episodedetails")
|
||||
ET.SubElement(root, "title").text = str(media["title"])
|
||||
if media.get("year"):
|
||||
ET.SubElement(root, "year").text = str(media["year"])
|
||||
if media.get("season"):
|
||||
ET.SubElement(root, "season").text = str(media["season"])
|
||||
if media.get("episode"):
|
||||
ET.SubElement(root, "episode").text = str(media["episode"])
|
||||
tree = ET.ElementTree(root)
|
||||
ET.indent(tree, space=" ")
|
||||
tree.write(nfo, encoding="unicode", xml_declaration=False)
|
||||
nfo.write_text(nfo.read_text() + "\n")
|
||||
|
||||
|
||||
def plan_file(config: dict, source: Path) -> dict:
|
||||
media = parse_media(str(source))
|
||||
drive = choose_drive(config, media["title"])
|
||||
dest = format_destination(config, media, drive)
|
||||
final = collision_path(dest, config["library"].get("collision", "keep-both"))
|
||||
return {
|
||||
"source": str(source),
|
||||
"destination": str(final) if final else None,
|
||||
"media": media,
|
||||
"drive": drive["id"],
|
||||
"action": "skip" if final is None else ("dry-run" if config["app"].get("dry_run") else "move"),
|
||||
}
|
||||
|
||||
|
||||
def execute_plan(config: dict, plan: dict) -> dict:
|
||||
if not plan.get("destination") or plan["action"] == "skip":
|
||||
return {**plan, "status": "skipped"}
|
||||
source = Path(plan["source"])
|
||||
destination = Path(plan["destination"])
|
||||
if config["app"].get("dry_run"):
|
||||
return {**plan, "status": "planned"}
|
||||
|
||||
ensure_directory(destination.parent, config)
|
||||
tmp = destination.with_suffix(destination.suffix + ".sorting")
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
shutil.move(str(source), str(tmp))
|
||||
tmp.replace(destination)
|
||||
mode = int(str(config["library"].get("permissions_mode", "664")), 8)
|
||||
os.chmod(destination, mode)
|
||||
if config.get("metadata", {}).get("write_nfo", True):
|
||||
write_nfo(destination, plan["media"])
|
||||
LOG.info("Moved %s to %s", source, destination)
|
||||
return {**plan, "status": "moved", "completed_at": time.time()}
|
||||
|
||||
|
||||
def execute_bundle_plan(config: dict, plan: dict, force: bool = False) -> dict:
|
||||
if not plan.get("destination") or (plan["status"] in {"skipped", "low-confidence"} and not force):
|
||||
return {**plan, "result": "held"}
|
||||
if plan["status"] == "needs-review" and not force:
|
||||
return {**plan, "result": "held"}
|
||||
if config["app"].get("dry_run"):
|
||||
return {**plan, "result": "dry-run"}
|
||||
|
||||
source = Path(plan["source"])
|
||||
destination = Path(plan["destination"])
|
||||
ensure_directory(destination.parent, config)
|
||||
tmp = destination.with_suffix(destination.suffix + ".sorting")
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
shutil.move(str(source), str(tmp))
|
||||
tmp.replace(destination)
|
||||
mode = int(str(config["library"].get("permissions_mode", "664")), 8)
|
||||
os.chmod(destination, mode)
|
||||
for subtitle in plan.get("subtitles", []):
|
||||
subtitle_source = Path(subtitle["source"])
|
||||
if not subtitle_source.exists() or not subtitle.get("destination"):
|
||||
continue
|
||||
subtitle_dest = Path(subtitle["destination"])
|
||||
ensure_directory(subtitle_dest.parent, config)
|
||||
shutil.move(str(subtitle_source), str(subtitle_dest))
|
||||
os.chmod(subtitle_dest, mode)
|
||||
if config.get("metadata", {}).get("write_nfo", True):
|
||||
write_nfo(destination, plan["media"])
|
||||
return {**plan, "status": "moved", "result": "moved", "completed_at": time.time()}
|
||||
141
backend/sortarr/parser.py
Normal file
141
backend/sortarr/parser.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
QUALITY_RE = re.compile(r"\b(2160p|1080p|720p|480p|remux|bluray|web[- .]?dl|webrip|hdtv|dvdrip)\b", re.I)
|
||||
YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
|
||||
EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})(?:[ ._-]*[Ee](\d{1,3}))?")
|
||||
ALT_EPISODE_RE = re.compile(r"\b(\d{1,2})x(\d{1,3})(?:[ ._-]*(\d{1,2})x(\d{1,3}))?\b")
|
||||
SEASON_RE = re.compile(r"\b[Ss](?:eason)?[ ._-]*(\d{1,2})\b")
|
||||
BRACKET_RE = re.compile(r"[\[(][^\])]*(?:\]|\))")
|
||||
AUDIO_RE = re.compile(r"\b(?:aac|aac\d(?:[ ._-]?\d)?|ac3|eac3|ddp(?:\d(?:[ ._-]?\d)?)?|dts|truehd|atmos|flac|mp3|opus|5[ ._-]?1|7[ ._-]?1|2[ ._-]?0|6ch|2ch)\b", re.I)
|
||||
CODEC_RE = re.compile(r"\b(?:x264|x265|h[ ._-]?264|h[ ._-]?265|hevc|avc|av1|10bit|8bit|hdr|hdr10|dv|dolby[ ._-]?vision)\b", re.I)
|
||||
EDITION_RE = re.compile(r"\b(?:proper|repack|rerip|extended|unrated|directors?[ ._-]?cut|theatrical|imax|multi|line|dubbed|subbed)\b", re.I)
|
||||
RELEASE_GROUP_RE = re.compile(r"(?:^|[ ._-])(?:YTS|TGx|EZTVx?|MeGusta|PSA|RARBG|NTb|AMZN|DSNP|PMNTP|FLUX|SuccessfulCrab|GalaxyTV)\b", re.I)
|
||||
TRAILING_GROUP_RE = re.compile(r"(?:[ ._-]+-[ ._-]*[A-Za-z0-9][A-Za-z0-9._-]{1,24})$")
|
||||
|
||||
|
||||
def spaced(raw: str) -> str:
|
||||
text = raw.replace("&", " and ")
|
||||
text = re.sub(r"[\._]+", " ", text)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip(" -._")
|
||||
|
||||
|
||||
def strip_brackets(raw: str) -> str:
|
||||
return BRACKET_RE.sub(" ", raw)
|
||||
|
||||
|
||||
def strip_release_tail(raw: str) -> str:
|
||||
text = strip_brackets(raw)
|
||||
text = TRAILING_GROUP_RE.sub("", text)
|
||||
text = RELEASE_GROUP_RE.sub(" ", text)
|
||||
return spaced(text)
|
||||
|
||||
|
||||
def first_noise_index(text: str) -> int | None:
|
||||
matches = [
|
||||
match.start()
|
||||
for pattern in (QUALITY_RE, AUDIO_RE, CODEC_RE, EDITION_RE, RELEASE_GROUP_RE)
|
||||
for match in [pattern.search(text)]
|
||||
if match
|
||||
]
|
||||
return min(matches) if matches else None
|
||||
|
||||
|
||||
def trim_noise(raw: str) -> str:
|
||||
text = strip_release_tail(raw)
|
||||
idx = first_noise_index(text)
|
||||
if idx is not None:
|
||||
text = text[:idx]
|
||||
return spaced(text)
|
||||
|
||||
|
||||
def clean_title(raw: str) -> str:
|
||||
text = trim_noise(raw)
|
||||
text = YEAR_RE.sub(" ", text)
|
||||
text = EPISODE_RE.sub(" ", text)
|
||||
text = ALT_EPISODE_RE.sub(" ", text)
|
||||
text = SEASON_RE.sub(" ", text)
|
||||
return spaced(text) or "Unknown"
|
||||
|
||||
|
||||
def clean_episode_title(raw: str) -> str:
|
||||
text = trim_noise(raw)
|
||||
text = YEAR_RE.sub(" ", text)
|
||||
return spaced(text) or "Episode"
|
||||
|
||||
|
||||
def parent_candidate(path: Path) -> str:
|
||||
parent = path.parent
|
||||
if parent.name.lower() in {"subs", "subtitles", "sub"}:
|
||||
parent = parent.parent
|
||||
name = parent.name
|
||||
if not name or name in {".", "/"}:
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def movie_title_source(path: Path, stem: str) -> str:
|
||||
parent = parent_candidate(path)
|
||||
if YEAR_RE.search(parent):
|
||||
return parent
|
||||
if YEAR_RE.search(stem):
|
||||
return stem
|
||||
if parent and first_noise_index(parent) is None and not EPISODE_RE.search(parent):
|
||||
return parent
|
||||
return stem
|
||||
|
||||
|
||||
def parse_media(path: str) -> dict:
|
||||
p = Path(path)
|
||||
stem = p.stem
|
||||
quality_match = QUALITY_RE.search(stem) or QUALITY_RE.search(parent_candidate(p))
|
||||
year_source = stem if YEAR_RE.search(stem) else parent_candidate(p)
|
||||
year_match = YEAR_RE.search(year_source)
|
||||
episode_match = EPISODE_RE.search(stem)
|
||||
alt_match = ALT_EPISODE_RE.search(stem)
|
||||
season_match = SEASON_RE.search(stem)
|
||||
|
||||
media_type = "movie"
|
||||
season = None
|
||||
episode = None
|
||||
multi_episode = ""
|
||||
episode_title = ""
|
||||
|
||||
if episode_match:
|
||||
media_type = "episode"
|
||||
season = int(episode_match.group(1))
|
||||
episode = int(episode_match.group(2))
|
||||
if episode_match.group(3):
|
||||
multi_episode = f"-E{int(episode_match.group(3)):02d}"
|
||||
title = clean_title(stem[:episode_match.start()])
|
||||
episode_title = clean_episode_title(stem[episode_match.end():])
|
||||
elif alt_match:
|
||||
media_type = "episode"
|
||||
season = int(alt_match.group(1))
|
||||
episode = int(alt_match.group(2))
|
||||
if alt_match.group(4):
|
||||
multi_episode = f"-E{int(alt_match.group(4)):02d}"
|
||||
title = clean_title(stem[:alt_match.start()])
|
||||
episode_title = clean_episode_title(stem[alt_match.end():])
|
||||
elif season_match:
|
||||
media_type = "season"
|
||||
season = int(season_match.group(1))
|
||||
title = clean_title(stem[:season_match.start()] or parent_candidate(p) or stem)
|
||||
else:
|
||||
title = clean_title(movie_title_source(p, stem))
|
||||
|
||||
return {
|
||||
"source": str(p),
|
||||
"title": title,
|
||||
"year": int(year_match.group(1)) if year_match else None,
|
||||
"quality": f" - {quality_match.group(1).replace('.', ' ')}" if quality_match else "",
|
||||
"type": media_type,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"multi_episode": multi_episode,
|
||||
"episode_title": episode_title if media_type == "episode" else "",
|
||||
"extension": p.suffix.lower(),
|
||||
}
|
||||
59
backend/sortarr/releases.py
Normal file
59
backend/sortarr/releases.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
def library_releases(library: dict | None) -> list[dict]:
|
||||
releases = []
|
||||
for show in ((library or {}).get("collections") or {}).get("series", []):
|
||||
for season in show.get("seasons", []):
|
||||
for episode in season.get("episodes", []):
|
||||
if episode.get("status") not in {"missing", "upcoming"}:
|
||||
continue
|
||||
releases.append({
|
||||
"provider": "Library",
|
||||
"title": show.get("metadata", {}).get("title") or show.get("title"),
|
||||
"episode_title": episode.get("title"),
|
||||
"season": episode.get("season"),
|
||||
"episode": episode.get("episode"),
|
||||
"date": episode.get("air_date"),
|
||||
"type": "tv",
|
||||
"status": episode.get("status"),
|
||||
"poster": show.get("metadata", {}).get("poster"),
|
||||
"library_key": show.get("key"),
|
||||
})
|
||||
return sorted(releases, key=lambda item: (item.get("date") or "9999-99-99", item.get("title") or ""))
|
||||
|
||||
|
||||
def fetch_releases(config: dict, library: dict | None = None) -> list[dict]:
|
||||
releases: list[dict] = library_releases(library)
|
||||
for provider in config.get("release_providers", []):
|
||||
if not provider.get("enabled", True):
|
||||
continue
|
||||
try:
|
||||
with urlopen(provider["url"], timeout=8) as response:
|
||||
body = response.read()
|
||||
if provider.get("type") == "json":
|
||||
data = json.loads(body.decode())
|
||||
for item in data[:30] if isinstance(data, list) else []:
|
||||
show = item.get("show", item)
|
||||
releases.append({
|
||||
"provider": provider["name"],
|
||||
"title": show.get("name"),
|
||||
"date": item.get("airdate") or item.get("premiered"),
|
||||
"type": "tv",
|
||||
})
|
||||
else:
|
||||
root = ET.fromstring(body)
|
||||
for item in root.findall(".//item")[:30]:
|
||||
releases.append({
|
||||
"provider": provider["name"],
|
||||
"title": (item.findtext("title") or "").strip(),
|
||||
"date": (item.findtext("pubDate") or "").strip(),
|
||||
"type": "movie",
|
||||
})
|
||||
except Exception as exc:
|
||||
releases.append({"provider": provider.get("name"), "error": str(exc)})
|
||||
return releases
|
||||
111
backend/sortarr/scanner.py
Normal file
111
backend/sortarr/scanner.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .downloads import downloads_snapshot
|
||||
from .organizer import execute_bundle_plan, plan_bundle
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Scanner(threading.Thread):
|
||||
def __init__(self, config: dict, store):
|
||||
super().__init__(daemon=True)
|
||||
self.config = config
|
||||
self.store = store
|
||||
self.stop_event = threading.Event()
|
||||
self.scan_lock = threading.Lock()
|
||||
self.seen_sizes: dict[str, tuple[int, int, int]] = {}
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
|
||||
def is_candidate(self, path: Path) -> bool:
|
||||
app = self.config["app"]
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.suffix.lower() in app.get("incomplete_suffixes", []):
|
||||
return False
|
||||
lowered = path.name.lower()
|
||||
for keyword in app.get("extra_keywords", []):
|
||||
if keyword and keyword.lower() in lowered:
|
||||
return False
|
||||
return path.suffix.lower() in set(app.get("media_extensions", []))
|
||||
|
||||
def is_stable(self, path: Path) -> bool:
|
||||
stat = path.stat()
|
||||
signature = (stat.st_size, int(stat.st_mtime))
|
||||
previous = self.seen_sizes.get(str(path))
|
||||
checks = previous[2] + 1 if previous and previous[:2] == signature else 1
|
||||
current = (*signature, checks)
|
||||
self.seen_sizes[str(path)] = current
|
||||
age = time.time() - stat.st_mtime
|
||||
required_checks = max(1, int(self.config["app"].get("stable_checks", 2)))
|
||||
return checks >= required_checks and age >= int(self.config["app"].get("settle_seconds", 90))
|
||||
|
||||
def scan_once(self) -> list[dict]:
|
||||
if not self.scan_lock.acquire(blocking=False):
|
||||
return self.store.snapshot().get("organizer", {}).get("queue", [])
|
||||
try:
|
||||
return self._scan_once()
|
||||
finally:
|
||||
self.scan_lock.release()
|
||||
|
||||
def request_scan(self) -> bool:
|
||||
if self.scan_lock.locked():
|
||||
return False
|
||||
thread = threading.Thread(target=self.scan_once, daemon=True)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def _scan_once(self) -> list[dict]:
|
||||
downloads = Path(self.config["paths"]["downloads"])
|
||||
downloads.mkdir(parents=True, exist_ok=True)
|
||||
plans: list[dict] = []
|
||||
state = self.store.snapshot()
|
||||
previous_items = {item.get("source"): item for item in state.get("items", [])}
|
||||
snapshot = downloads_snapshot(self.config, state)
|
||||
metadata_budget = int(self.config["app"].get("organization_metadata_budget_seconds", 25))
|
||||
metadata_deadline = time.time() + metadata_budget
|
||||
for bundle in snapshot.get("bundles", []):
|
||||
path = Path(bundle["media"]["path"])
|
||||
if not self.is_candidate(path) or not self.is_stable(path):
|
||||
continue
|
||||
try:
|
||||
plan = plan_bundle(self.config, bundle, metadata_enabled=time.time() < metadata_deadline)
|
||||
result = execute_bundle_plan(self.config, plan)
|
||||
plans.append(result)
|
||||
self.store.set_organizer_queue(plans)
|
||||
item = {
|
||||
"source": str(path),
|
||||
"destination": result.get("destination"),
|
||||
"title": result["media"]["title"],
|
||||
"type": result["media"]["type"],
|
||||
"status": result.get("result") or result["status"],
|
||||
"drive": result.get("drive"),
|
||||
"confidence": result.get("confidence"),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
self.store.upsert_item(item)
|
||||
previous = previous_items.get(str(path), {})
|
||||
if (
|
||||
previous.get("destination") != item.get("destination")
|
||||
or previous.get("status") != item.get("status")
|
||||
or previous.get("confidence") != item.get("confidence")
|
||||
):
|
||||
self.store.add_event("info", f"{item['status']}: {path.name}", path=str(path), confidence=item.get("confidence"))
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to organize %s", path)
|
||||
self.store.add_event("error", str(exc), path=str(path))
|
||||
self.store.set_plans(plans)
|
||||
self.store.set_organizer_queue(plans)
|
||||
return plans
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
self.scan_once()
|
||||
interval = int(self.config["app"].get("scan_interval_seconds", 20))
|
||||
self.stop_event.wait(interval)
|
||||
53
backend/sortarr/storage.py
Normal file
53
backend/sortarr/storage.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def disk_usage(path: str) -> dict:
|
||||
usage = os.statvfs(path)
|
||||
total = usage.f_frsize * usage.f_blocks
|
||||
free = usage.f_frsize * usage.f_bavail
|
||||
used = total - free
|
||||
return {"total": total, "used": used, "free": free}
|
||||
|
||||
|
||||
def drive_stats(config: dict) -> list[dict]:
|
||||
stats = []
|
||||
for drive in config.get("drives", []):
|
||||
path = Path(drive["path"])
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
usage = disk_usage(str(path))
|
||||
stats.append({**drive, **usage})
|
||||
return stats
|
||||
|
||||
|
||||
def find_existing_home(config: dict, title: str) -> str | None:
|
||||
normalized = title.lower()
|
||||
for drive in config.get("drives", []):
|
||||
root = Path(drive["path"])
|
||||
for folder in ("Movies", "Shows"):
|
||||
base = root / folder
|
||||
if not base.exists():
|
||||
continue
|
||||
for child in base.iterdir():
|
||||
if child.is_dir() and child.name.lower().startswith(normalized):
|
||||
return str(root)
|
||||
return None
|
||||
|
||||
|
||||
def choose_drive(config: dict, title: str) -> dict:
|
||||
existing = find_existing_home(config, title)
|
||||
if existing:
|
||||
for drive in config.get("drives", []):
|
||||
if drive["path"] == existing:
|
||||
return drive
|
||||
candidates = []
|
||||
for drive in drive_stats(config):
|
||||
min_free = int(drive.get("min_free_gb", 0)) * 1024**3
|
||||
if drive["free"] >= min_free:
|
||||
candidates.append(drive)
|
||||
if not candidates:
|
||||
raise RuntimeError("No media drive has the configured minimum free space")
|
||||
return max(candidates, key=lambda d: d["free"])
|
||||
|
||||
83
backend/sortarr/store.py
Normal file
83
backend/sortarr/store.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class JsonStore:
|
||||
def __init__(self, data_dir: str):
|
||||
self.path = Path(data_dir) / "state.json"
|
||||
self.lock = threading.RLock()
|
||||
self.state: dict[str, Any] = {
|
||||
"events": [],
|
||||
"items": [],
|
||||
"plans": [],
|
||||
"organizer": {"queue": [], "updated_at": None},
|
||||
"library": None,
|
||||
"settings": {},
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
with self.lock:
|
||||
if self.path.exists():
|
||||
try:
|
||||
self.state.update(json.loads(self.path.read_text()))
|
||||
except json.JSONDecodeError:
|
||||
backup = self.path.with_suffix(f".corrupt-{int(time.time())}.json")
|
||||
self.path.replace(backup)
|
||||
self.state.setdefault("events", []).insert(0, {
|
||||
"time": time.time(),
|
||||
"level": "error",
|
||||
"message": f"Recovered from corrupt state file: {backup.name}",
|
||||
})
|
||||
|
||||
def save(self) -> None:
|
||||
with self.lock:
|
||||
self.state["updated_at"] = time.time()
|
||||
tmp = self.path.with_name(f"{self.path.name}.{uuid.uuid4().hex}.tmp")
|
||||
tmp.write_text(json.dumps(self.state, indent=2, sort_keys=True))
|
||||
tmp.replace(self.path)
|
||||
|
||||
def add_event(self, level: str, message: str, **fields: Any) -> None:
|
||||
with self.lock:
|
||||
event = {"time": time.time(), "level": level, "message": message, **fields}
|
||||
self.state.setdefault("events", []).insert(0, event)
|
||||
self.state["events"] = self.state["events"][:500]
|
||||
self.save()
|
||||
|
||||
def upsert_item(self, item: dict[str, Any]) -> None:
|
||||
with self.lock:
|
||||
items = self.state.setdefault("items", [])
|
||||
key = item.get("destination") or item.get("source")
|
||||
for idx, existing in enumerate(items):
|
||||
if (existing.get("destination") or existing.get("source")) == key:
|
||||
items[idx] = {**existing, **item}
|
||||
break
|
||||
else:
|
||||
items.append(item)
|
||||
self.save()
|
||||
|
||||
def set_plans(self, plans: list[dict[str, Any]]) -> None:
|
||||
with self.lock:
|
||||
self.state["plans"] = plans[:200]
|
||||
self.save()
|
||||
|
||||
def set_organizer_queue(self, queue: list[dict[str, Any]]) -> None:
|
||||
with self.lock:
|
||||
self.state["organizer"] = {"queue": queue[:500], "updated_at": time.time()}
|
||||
self.save()
|
||||
|
||||
def set_library(self, library: dict[str, Any]) -> None:
|
||||
with self.lock:
|
||||
self.state["library"] = library
|
||||
self.save()
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self.lock:
|
||||
return json.loads(json.dumps(self.state))
|
||||
121
backend/sortarr/tools.py
Normal file
121
backend/sortarr/tools.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def duplicate_finder(config: dict, library: dict | None) -> dict:
|
||||
duplicates = []
|
||||
collections = (library or {}).get("collections") or {}
|
||||
for collection in list(collections.get("movies", [])) + list(collections.get("series", [])):
|
||||
files = collection.get("files") or []
|
||||
if len(files) < 2:
|
||||
continue
|
||||
total_size = sum(int(item.get("size") or 0) for item in files)
|
||||
duplicates.append({
|
||||
"key": collection.get("key"),
|
||||
"title": collection.get("metadata", {}).get("title") or collection.get("title"),
|
||||
"library": collection.get("library"),
|
||||
"count": len(files),
|
||||
"total_size": total_size,
|
||||
"files": sorted(files, key=lambda item: (item.get("size") or 0), reverse=True)[:20],
|
||||
})
|
||||
return {
|
||||
"count": len(duplicates),
|
||||
"duplicates": sorted(duplicates, key=lambda item: item["total_size"], reverse=True)[:100],
|
||||
"generated_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def subtitle_audit(config: dict, library: dict | None) -> dict:
|
||||
media_extensions = set(config["app"].get("media_extensions", []))
|
||||
subtitle_extensions = config["app"].get("subtitle_extensions", [])
|
||||
missing = []
|
||||
present = 0
|
||||
unknown = 0
|
||||
for item in (library or {}).get("items", []):
|
||||
path = Path(item["path"])
|
||||
if path.suffix.lower() not in media_extensions:
|
||||
continue
|
||||
if item.get("has_subtitles") is True:
|
||||
present += 1
|
||||
elif "has_subtitles" not in item:
|
||||
unknown += 1
|
||||
else:
|
||||
missing.append({
|
||||
"name": item["name"],
|
||||
"path": str(path),
|
||||
"drive": item.get("drive"),
|
||||
"expected": [f"{path.stem}{ext}" for ext in subtitle_extensions[:3]],
|
||||
})
|
||||
return {
|
||||
"checked": present + len(missing) + unknown,
|
||||
"with_subtitles": present,
|
||||
"unknown_count": unknown,
|
||||
"missing_count": len(missing),
|
||||
"missing": missing[:500],
|
||||
"generated_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def transcode_plan(config: dict, library: dict | None) -> dict:
|
||||
targets = []
|
||||
for item in (library or {}).get("items", []):
|
||||
path = Path(item["path"])
|
||||
if path.suffix.lower() == ".mp4":
|
||||
continue
|
||||
output = path.with_suffix(".mp4")
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-i",
|
||||
str(path),
|
||||
"-map",
|
||||
"0",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
str(output),
|
||||
]
|
||||
targets.append({
|
||||
"name": item["name"],
|
||||
"source": str(path),
|
||||
"output": str(output),
|
||||
"drive": item.get("drive"),
|
||||
"command": command,
|
||||
})
|
||||
return {
|
||||
"ffmpeg_available": shutil.which("ffmpeg") is not None,
|
||||
"count": len(targets),
|
||||
"targets": targets[:100],
|
||||
"generated_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def run_next_transcode(config: dict, library: dict | None) -> dict:
|
||||
plan = transcode_plan(config, library)
|
||||
if not plan["targets"]:
|
||||
return {**plan, "status": "empty"}
|
||||
if not plan["ffmpeg_available"]:
|
||||
return {**plan, "status": "ffmpeg-unavailable"}
|
||||
if config["app"].get("dry_run"):
|
||||
return {**plan, "status": "dry-run"}
|
||||
target = plan["targets"][0]
|
||||
completed = subprocess.run(target["command"], capture_output=True, text=True, timeout=60 * 60)
|
||||
return {
|
||||
**plan,
|
||||
"status": "completed" if completed.returncode == 0 else "failed",
|
||||
"ran": target,
|
||||
"returncode": completed.returncode,
|
||||
"stderr": completed.stderr[-4000:],
|
||||
}
|
||||
10
compose.override.yaml
Normal file
10
compose.override.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- SORTARR_LOG_LEVEL=DEBUG
|
||||
volumes:
|
||||
- ./backend/sortarr:/app/sortarr
|
||||
web:
|
||||
volumes:
|
||||
- ./web/src:/usr/share/nginx/html:ro
|
||||
|
||||
9
compose.prod.yaml
Normal file
9
compose.prod.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
web:
|
||||
restart: unless-stopped
|
||||
backend:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SORTARR_LOG_LEVEL=${SORTARR_LOG_LEVEL:-INFO}
|
||||
- SORTARR_DRY_RUN=${SORTARR_DRY_RUN:-false}
|
||||
|
||||
92
compose.yaml
Normal file
92
compose.yaml
Normal file
@@ -0,0 +1,92 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
container_name: sortarr-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${SORTARR_WEB_PORT:-8088}:80"
|
||||
volumes:
|
||||
- ./web/src:/usr/share/nginx/html:ro
|
||||
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
environment:
|
||||
- TZ=${SORTARR_TZ:-Etc/UTC}
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: sortarr-backend
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-m", "sortarr.healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "${SORTARR_API_PORT:-8099}:8099"
|
||||
volumes:
|
||||
- ${DOWNLOADS_PATH:-./downloads}:/downloads
|
||||
- ${CONFIG_PATH:-./config}:/config
|
||||
- ${LOGS_PATH:-./logs}:/logs
|
||||
- ${DATA_PATH:-./data}:/data
|
||||
- ${DRIVE1_PATH:-./media/drive1}:/media/drive1
|
||||
- ${DRIVE2_PATH:-./media/drive2}:/media/drive2
|
||||
- ${DRIVE3_PATH:-./media/drive3}:/media/drive3
|
||||
- ${DRIVE4_PATH:-./media/drive4}:/media/drive4
|
||||
environment:
|
||||
- TZ=${SORTARR_TZ:-Etc/UTC}
|
||||
- SORTARR_HOST=${SORTARR_HOST:-0.0.0.0}
|
||||
- SORTARR_API_PORT=8099
|
||||
- SORTARR_CONFIG=/config/app.toml
|
||||
- SORTARR_DEFAULT_CONFIG=/app/default-config/app.toml
|
||||
- SORTARR_DATA_DIR=/data
|
||||
- SORTARR_LOG_DIR=/logs
|
||||
- SORTARR_CACHE_DIR=/data/cache
|
||||
- SORTARR_DRY_RUN=${SORTARR_DRY_RUN:-true}
|
||||
- SORTARR_LOG_LEVEL=${SORTARR_LOG_LEVEL:-INFO}
|
||||
- SORTARR_SCAN_INTERVAL_SECONDS=${SORTARR_SCAN_INTERVAL_SECONDS:-20}
|
||||
- SORTARR_SETTLE_SECONDS=${SORTARR_SETTLE_SECONDS:-90}
|
||||
- SORTARR_MIN_FREE_GB=${SORTARR_MIN_FREE_GB:-20}
|
||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||
- TMDB_BEARER_TOKEN=${TMDB_BEARER_TOKEN:-}
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: sortarr-redis
|
||||
profiles: ["cache"]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- sortarr-redis:/data
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: sortarr-postgres
|
||||
profiles: ["database"]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: sortarr
|
||||
POSTGRES_USER: sortarr
|
||||
POSTGRES_PASSWORD: sortarr
|
||||
volumes:
|
||||
- sortarr-postgres:/var/lib/postgresql/data
|
||||
|
||||
media-tools:
|
||||
image: lscr.io/linuxserver/ffmpeg:latest
|
||||
container_name: sortarr-media-tools
|
||||
profiles: ["tools"]
|
||||
command: ["sleep", "infinity"]
|
||||
volumes:
|
||||
- ${DOWNLOADS_PATH:-./downloads}:/downloads
|
||||
- ${DRIVE1_PATH:-./media/drive1}:/media/drive1
|
||||
- ${DRIVE2_PATH:-./media/drive2}:/media/drive2
|
||||
- ${DRIVE3_PATH:-./media/drive3}:/media/drive3
|
||||
- ${DRIVE4_PATH:-./media/drive4}:/media/drive4
|
||||
|
||||
volumes:
|
||||
sortarr-redis:
|
||||
sortarr-postgres:
|
||||
19
config/app.toml
Normal file
19
config/app.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
# Host-editable Sortarr configuration. Values here override backend/default-config/app.toml.
|
||||
# Environment variables in .env override common runtime values such as dry-run and intervals.
|
||||
|
||||
[app]
|
||||
dry_run = true
|
||||
scan_interval_seconds = 20
|
||||
settle_seconds = 90
|
||||
log_level = "INFO"
|
||||
library_scan_max_files = 20000
|
||||
library_scan_timeout_seconds = 8
|
||||
|
||||
[theme]
|
||||
default = "slate"
|
||||
allow_custom_css = true
|
||||
custom_css_path = "/config/custom-theme.css"
|
||||
|
||||
[metadata]
|
||||
tmdb_enabled = true
|
||||
tmdb_language = "en-US"
|
||||
6
config/custom-theme.css
Normal file
6
config/custom-theme.css
Normal file
@@ -0,0 +1,6 @@
|
||||
/* Optional host-editable theme overrides. Loaded by the dashboard when enabled. */
|
||||
:root {
|
||||
/* --bg: #0f1115; */
|
||||
/* --accent: #5cc8ff; */
|
||||
}
|
||||
|
||||
BIN
dist/sortarr.zip
vendored
Normal file
BIN
dist/sortarr.zip
vendored
Normal file
Binary file not shown.
28
dist/sortarr/.env.example
vendored
Normal file
28
dist/sortarr/.env.example
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Sortarr Environment Configuration
|
||||
|
||||
# Network Settings
|
||||
SORTARR_WEB_PORT=8088
|
||||
SORTARR_API_PORT=8099
|
||||
SORTARR_TZ=Etc/UTC
|
||||
|
||||
# Runtime Settings
|
||||
# Set to 'true' to simulate moves without actually moving files
|
||||
SORTARR_DRY_RUN=false
|
||||
SORTARR_LOG_LEVEL=INFO
|
||||
SORTARR_SCAN_INTERVAL_SECONDS=20
|
||||
SORTARR_SETTLE_SECONDS=90
|
||||
SORTARR_MIN_FREE_GB=20
|
||||
|
||||
# Optional: TMDb API for posters and metadata
|
||||
TMDB_API_KEY=
|
||||
TMDB_BEARER_TOKEN=
|
||||
|
||||
# Host Paths (Relative to docker-compose.yaml or absolute paths)
|
||||
DOWNLOADS_PATH=./downloads
|
||||
CONFIG_PATH=./config
|
||||
LOGS_PATH=./logs
|
||||
DATA_PATH=./data
|
||||
DRIVE1_PATH=./media/drive1
|
||||
DRIVE2_PATH=./media/drive2
|
||||
DRIVE3_PATH=./media/drive3
|
||||
DRIVE4_PATH=./media/drive4
|
||||
8
dist/sortarr/.gitignore
vendored
Normal file
8
dist/sortarr/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
data/
|
||||
logs/
|
||||
downloads/
|
||||
media/
|
||||
|
||||
61
dist/sortarr/README.md
vendored
Normal file
61
dist/sortarr/README.md
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
# Sortarr
|
||||
|
||||
Sortarr is a self-hosted Jellyfin media organizer and dashboard. It watches your downloads, plans safe Jellyfin-friendly moves across multiple media drives, and provides a fully editable dashboard to manage your library.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automated Organizing**: Watches `/downloads` and moves files to appropriate Movie/Show folders.
|
||||
- **Multi-Drive Support**: Supports up to 4 media drives with smart drive selection.
|
||||
- **Safety First**: Optional dry-run mode and atomic move operations.
|
||||
- **Customizable**: Fully editable vanilla JS dashboard and TOML-based backend configuration.
|
||||
- **Lightweight**: Built with Python and Nginx, optimized for self-hosting.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed on your host.
|
||||
|
||||
### 2. Setup
|
||||
|
||||
1. **Copy the environment template**:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Configure paths**:
|
||||
Edit `.env` and set the paths to your downloads and media folders. By default, it uses folders within the project directory.
|
||||
|
||||
3. **Review Configuration**:
|
||||
Check `config/app.toml` to customize organizer rules, naming templates, and more.
|
||||
|
||||
4. **Start Sortarr**:
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 3. Usage
|
||||
|
||||
- **Web Dashboard**: Open `http://localhost:8088` (or the port you configured).
|
||||
- **First Run**: By default, `SORTARR_DRY_RUN` is `false` in this distribution. If you want to test first, set it to `true` in your `.env`.
|
||||
- **Library Scan**: Go to the Library page and click "Scan library" to index your existing media.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `backend/`: Python backend source and Dockerfile.
|
||||
- `web/`: Dashboard source, Nginx config, and Dockerfile.
|
||||
- `config/`: Configuration files (`app.toml`, `custom-theme.css`).
|
||||
- `data/`: Persistent state and cache.
|
||||
- `logs/`: Application logs.
|
||||
- `downloads/`: Default watch directory for incoming media.
|
||||
- `media/`: Default mount points for your media drives.
|
||||
|
||||
## Customization
|
||||
|
||||
- **Dashboard**: Edit files in `web/src` to change the UI.
|
||||
- **Theming**: Use the Settings page or edit `config/custom-theme.css`.
|
||||
- **Logic**: Backend logic is in `backend/sortarr/`.
|
||||
|
||||
## License
|
||||
|
||||
This project is source-available and intended for personal self-hosting.
|
||||
15
dist/sortarr/backend/Dockerfile
vendored
Normal file
15
dist/sortarr/backend/Dockerfile
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY sortarr /app/sortarr
|
||||
COPY default-config /app/default-config
|
||||
|
||||
EXPOSE 8099
|
||||
CMD ["python", "-m", "sortarr.app"]
|
||||
90
dist/sortarr/backend/default-config/app.toml
vendored
Normal file
90
dist/sortarr/backend/default-config/app.toml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
[app]
|
||||
name = "Sortarr"
|
||||
dry_run = true
|
||||
log_level = "INFO"
|
||||
scan_interval_seconds = 20
|
||||
settle_seconds = 90
|
||||
stable_checks = 2
|
||||
incomplete_suffixes = [".part", ".partial", ".!qB", ".tmp", ".crdownload"]
|
||||
media_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".ts"]
|
||||
subtitle_extensions = [".srt", ".ass", ".ssa", ".vtt", ".sub"]
|
||||
extra_keywords = ["sample", "trailer", "behind the scenes", "featurette", "deleted scene"]
|
||||
library_scan_max_files = 20000
|
||||
library_scan_timeout_seconds = 8
|
||||
cache_max_bytes = 21474836480
|
||||
auto_move_min_confidence = 90
|
||||
review_min_confidence = 60
|
||||
organization_metadata_budget_seconds = 25
|
||||
organization_metadata_timeout_seconds = 3
|
||||
metadata_parallelism = 8
|
||||
|
||||
[paths]
|
||||
downloads = "/downloads"
|
||||
data = "/data"
|
||||
logs = "/logs"
|
||||
cache = "/data/cache"
|
||||
|
||||
[[drives]]
|
||||
id = "drive1"
|
||||
name = "Media Drive 1"
|
||||
path = "/media/drive1"
|
||||
min_free_gb = 20
|
||||
|
||||
[[drives]]
|
||||
id = "drive2"
|
||||
name = "Media Drive 2"
|
||||
path = "/media/drive2"
|
||||
min_free_gb = 20
|
||||
|
||||
[[drives]]
|
||||
id = "drive3"
|
||||
name = "Media Drive 3"
|
||||
path = "/media/drive3"
|
||||
min_free_gb = 20
|
||||
|
||||
[[drives]]
|
||||
id = "drive4"
|
||||
name = "Media Drive 4"
|
||||
path = "/media/drive4"
|
||||
min_free_gb = 20
|
||||
|
||||
[library]
|
||||
movie_folder = "Movies/{title} ({year})"
|
||||
series_folder = "Shows/{title}/Season {season:02d}"
|
||||
movie_file = "{title} ({year}){quality}{ext}"
|
||||
episode_file = "{title} - S{season:02d}E{episode:02d}{multi_episode} - {episode_title}{quality}{ext}"
|
||||
subtitle_file = "{basename}{language}{ext}"
|
||||
unknown_folder = "Unsorted/{title}"
|
||||
collision = "keep-both" # keep-both, skip, replace
|
||||
duplicate = "skip" # skip, keep-both
|
||||
permissions_mode = "664"
|
||||
directory_mode = "775"
|
||||
|
||||
[metadata]
|
||||
write_nfo = true
|
||||
provider_order = ["filename"]
|
||||
prefer_existing_nfo = true
|
||||
tmdb_api_key = ""
|
||||
tmdb_bearer_token = ""
|
||||
tmdb_language = "en-US"
|
||||
tmdb_image_base = "https://image.tmdb.org/t/p/w342"
|
||||
tmdb_enabled = true
|
||||
|
||||
[[release_providers]]
|
||||
id = "tmdb-rss"
|
||||
name = "TMDb RSS"
|
||||
enabled = false
|
||||
type = "rss"
|
||||
url = "https://www.themoviedb.org/rss/movie/upcoming"
|
||||
|
||||
[[release_providers]]
|
||||
id = "tvmaze-premieres"
|
||||
name = "TVMaze Premieres"
|
||||
enabled = false
|
||||
type = "json"
|
||||
url = "https://api.tvmaze.com/schedule"
|
||||
|
||||
[theme]
|
||||
default = "slate"
|
||||
allow_custom_css = true
|
||||
custom_css_path = "/config/custom-theme.css"
|
||||
2
dist/sortarr/backend/sortarr/__init__.py
vendored
Normal file
2
dist/sortarr/backend/sortarr/__init__.py
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
__all__ = ["config", "organizer", "server"]
|
||||
|
||||
356
dist/sortarr/backend/sortarr/app.py
vendored
Normal file
356
dist/sortarr/backend/sortarr/app.py
vendored
Normal file
@@ -0,0 +1,356 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from .config import load_config, public_config
|
||||
from .downloads import downloads_snapshot
|
||||
from .library import library_snapshot, normalize_library
|
||||
from .logging_setup import configure_logging
|
||||
from .media_probe import edit_track, media_probe
|
||||
from .metadata import test_tmdb, search_tmdb, identify_item
|
||||
from .organizer import execute_bundle_plan
|
||||
from .releases import fetch_releases
|
||||
from .scanner import Scanner
|
||||
from .store import JsonStore
|
||||
from .storage import drive_stats
|
||||
from .tools import run_next_transcode, subtitle_audit, transcode_plan
|
||||
|
||||
|
||||
SETTINGS_SCHEMA = {
|
||||
"app": {
|
||||
"name": str,
|
||||
"dry_run": bool,
|
||||
"log_level": str,
|
||||
"scan_interval_seconds": int,
|
||||
"settle_seconds": int,
|
||||
"stable_checks": int,
|
||||
"incomplete_suffixes": list,
|
||||
"media_extensions": list,
|
||||
"subtitle_extensions": list,
|
||||
"extra_keywords": list,
|
||||
"library_scan_max_files": int,
|
||||
"library_scan_timeout_seconds": int,
|
||||
"cache_max_bytes": int,
|
||||
"auto_move_min_confidence": int,
|
||||
"review_min_confidence": int,
|
||||
"organization_metadata_budget_seconds": int,
|
||||
"organization_metadata_timeout_seconds": int,
|
||||
"metadata_parallelism": int,
|
||||
},
|
||||
"paths": {
|
||||
"downloads": str,
|
||||
"data": str,
|
||||
"logs": str,
|
||||
"cache": str,
|
||||
},
|
||||
"library": {
|
||||
"movie_folder": str,
|
||||
"series_folder": str,
|
||||
"movie_file": str,
|
||||
"episode_file": str,
|
||||
"subtitle_file": str,
|
||||
"unknown_folder": str,
|
||||
"collision": str,
|
||||
"duplicate": str,
|
||||
"permissions_mode": str,
|
||||
"directory_mode": str,
|
||||
},
|
||||
"metadata": {
|
||||
"write_nfo": bool,
|
||||
"provider_order": list,
|
||||
"prefer_existing_nfo": bool,
|
||||
"tmdb_api_key": str,
|
||||
"tmdb_bearer_token": str,
|
||||
"tmdb_language": str,
|
||||
"tmdb_image_base": str,
|
||||
"tmdb_enabled": bool,
|
||||
},
|
||||
"theme": {
|
||||
"default": str,
|
||||
"allow_custom_css": bool,
|
||||
"custom_css_path": str,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def deep_merge(base: dict, override: dict) -> dict:
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
|
||||
def coerce_value(value, caster):
|
||||
if caster is bool:
|
||||
return bool(value)
|
||||
if caster is int:
|
||||
return int(value)
|
||||
if caster is list:
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return [item.strip() for item in str(value).split(",") if item.strip()]
|
||||
return caster(value)
|
||||
|
||||
|
||||
def apply_settings(config: dict, settings: dict) -> dict:
|
||||
if any(key in SETTINGS_SCHEMA["app"] for key in settings):
|
||||
settings = {"app": settings}
|
||||
applied = {}
|
||||
for section, fields in SETTINGS_SCHEMA.items():
|
||||
values = settings.get(section)
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
target = config.setdefault(section, {})
|
||||
applied_section = applied.setdefault(section, {})
|
||||
for key, caster in fields.items():
|
||||
if key not in values:
|
||||
continue
|
||||
target[key] = coerce_value(values[key], caster)
|
||||
applied_section[key] = target[key]
|
||||
if not applied_section:
|
||||
applied.pop(section, None)
|
||||
|
||||
if isinstance(settings.get("drives"), list):
|
||||
drives = []
|
||||
for idx, drive in enumerate(settings["drives"]):
|
||||
if not isinstance(drive, dict):
|
||||
continue
|
||||
existing = (config.get("drives") or [{}] * (idx + 1))[idx] if idx < len(config.get("drives", [])) else {}
|
||||
drives.append({
|
||||
"id": str(drive.get("id", existing.get("id", f"drive{idx + 1}"))),
|
||||
"name": str(drive.get("name", existing.get("name", f"Media Drive {idx + 1}"))),
|
||||
"path": str(drive.get("path", existing.get("path", ""))),
|
||||
"min_free_gb": int(drive.get("min_free_gb", existing.get("min_free_gb", 20))),
|
||||
})
|
||||
config["drives"] = drives
|
||||
applied["drives"] = drives
|
||||
|
||||
if isinstance(settings.get("release_providers"), list):
|
||||
providers = []
|
||||
for provider in settings["release_providers"]:
|
||||
if not isinstance(provider, dict):
|
||||
continue
|
||||
providers.append({
|
||||
"id": str(provider.get("id", "")),
|
||||
"name": str(provider.get("name", "")),
|
||||
"enabled": bool(provider.get("enabled", False)),
|
||||
"type": str(provider.get("type", "rss")),
|
||||
"url": str(provider.get("url", "")),
|
||||
})
|
||||
config["release_providers"] = providers
|
||||
applied["release_providers"] = providers
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
CONFIG = load_config()
|
||||
configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO"))
|
||||
STORE = JsonStore(CONFIG["paths"]["data"])
|
||||
apply_settings(CONFIG, STORE.snapshot().get("settings", {}))
|
||||
SCANNER = Scanner(CONFIG, STORE)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "Sortarr/0.1"
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, payload, status=HTTPStatus.OK) -> None:
|
||||
body = json.dumps(payload, indent=2).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
try:
|
||||
if path == "/api/health":
|
||||
self.send_json({"ok": True})
|
||||
elif path == "/api/config":
|
||||
self.send_json(public_config(CONFIG))
|
||||
elif path == "/api/dashboard":
|
||||
snap = STORE.snapshot()
|
||||
cached_library = snap.get("library") or {
|
||||
"drives": drive_stats(CONFIG),
|
||||
"items": [],
|
||||
"counts": {"movies": 0, "tv": 0, "total": 0},
|
||||
"extensions": {},
|
||||
"scanned_files": 0,
|
||||
"truncated": False,
|
||||
"cached": False,
|
||||
}
|
||||
cached_library = normalize_library(cached_library)
|
||||
cached_library.pop("items", None)
|
||||
public_state = {
|
||||
"events": snap.get("events", [])[:200],
|
||||
"organizer": snap.get("organizer", {"queue": [], "updated_at": None}),
|
||||
"settings": snap.get("settings", {}),
|
||||
"updated_at": snap.get("updated_at"),
|
||||
}
|
||||
self.send_json({
|
||||
"state": public_state,
|
||||
"library": cached_library,
|
||||
"dry_run": CONFIG["app"].get("dry_run"),
|
||||
})
|
||||
elif path == "/api/downloads":
|
||||
self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())})
|
||||
elif path == "/api/releases":
|
||||
self.send_json({"releases": fetch_releases(CONFIG, STORE.snapshot().get("library"))})
|
||||
elif path == "/api/media/probe":
|
||||
params = parse_qs(parsed_url.query)
|
||||
target = unquote((params.get("path") or [""])[0])
|
||||
self.send_json({"media": media_probe(CONFIG, target)})
|
||||
elif path == "/api/metadata/search":
|
||||
params = parse_qs(parsed_url.query)
|
||||
query = unquote((params.get("query") or [""])[0])
|
||||
kind = unquote((params.get("type") or ["movie"])[0])
|
||||
self.send_json({"results": search_tmdb(CONFIG, kind, query)})
|
||||
elif path == "/api/tools/subtitles":
|
||||
self.send_json({"audit": subtitle_audit(CONFIG, STORE.snapshot().get("library"))})
|
||||
elif path == "/api/tools/transcoder":
|
||||
self.send_json({"transcoder": transcode_plan(CONFIG, STORE.snapshot().get("library"))})
|
||||
elif path == "/api/theme/custom.css":
|
||||
custom = CONFIG.get("theme", {}).get("custom_css_path")
|
||||
if custom and CONFIG.get("theme", {}).get("allow_custom_css", True) and os.path.exists(custom):
|
||||
body = open(custom, "rb").read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/css")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
if path == "/api/scan":
|
||||
started = SCANNER.request_scan()
|
||||
snap = STORE.snapshot()
|
||||
self.send_json({
|
||||
"started": started,
|
||||
"status": "started" if started else "already-running",
|
||||
"queue": snap.get("organizer", {}).get("queue", []),
|
||||
}, HTTPStatus.ACCEPTED)
|
||||
elif path == "/api/organizer/approve":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
plan_id = payload.get("id")
|
||||
snap = STORE.snapshot()
|
||||
queue = snap.get("organizer", {}).get("queue", [])
|
||||
plan = next((item for item in queue if item.get("id") == plan_id), None)
|
||||
if not plan:
|
||||
self.send_json({"error": "plan not found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
result = execute_bundle_plan(CONFIG, plan, force=True)
|
||||
updated = [result if item.get("id") == plan_id else item for item in queue]
|
||||
STORE.set_organizer_queue(updated)
|
||||
STORE.add_event("info", f"approved organizer plan: {result.get('result')}", path=result.get("source"), confidence=result.get("confidence"))
|
||||
self.send_json({"plan": result})
|
||||
elif path == "/api/organizer/skip":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
plan_id = payload.get("id")
|
||||
snap = STORE.snapshot()
|
||||
queue = snap.get("organizer", {}).get("queue", [])
|
||||
updated = [{**item, "status": "skipped", "result": "skipped"} if item.get("id") == plan_id else item for item in queue]
|
||||
STORE.set_organizer_queue(updated)
|
||||
self.send_json({"ok": True})
|
||||
elif path == "/api/library/identify":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
key = payload.get("key")
|
||||
tmdb_id = payload.get("tmdb_id")
|
||||
kind = payload.get("type")
|
||||
|
||||
snap = STORE.snapshot()
|
||||
library = snap.get("library", {})
|
||||
collections = library.get("collections", {"movies": [], "series": []})
|
||||
|
||||
found_item = None
|
||||
if kind == "movie":
|
||||
for item in collections["movies"]:
|
||||
if item["key"] == key:
|
||||
found_item = identify_item(CONFIG, item, tmdb_id, kind)
|
||||
break
|
||||
else:
|
||||
for item in collections["series"]:
|
||||
if item["key"] == key:
|
||||
found_item = identify_item(CONFIG, item, tmdb_id, "tv")
|
||||
break
|
||||
|
||||
if found_item:
|
||||
STORE.set_library(library)
|
||||
self.send_json({"ok": True, "item": found_item})
|
||||
else:
|
||||
self.send_json({"error": "item not found"}, HTTPStatus.NOT_FOUND)
|
||||
elif path == "/api/library/scan":
|
||||
library = library_snapshot(CONFIG)
|
||||
STORE.set_library(library)
|
||||
self.send_json({"library": library})
|
||||
elif path == "/api/tools/transcoder/run-next":
|
||||
result = run_next_transcode(CONFIG, STORE.snapshot().get("library"))
|
||||
STORE.add_event("info", f"transcoder: {result.get('status')}")
|
||||
self.send_json({"transcoder": result})
|
||||
elif path == "/api/metadata/tmdb/test":
|
||||
self.send_json({"tmdb": test_tmdb(CONFIG)})
|
||||
elif path == "/api/media/tracks":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
payload = json.loads(body)
|
||||
result = edit_track(CONFIG, payload.get("path", ""), payload.get("action", ""), int(payload.get("stream_index", -1)))
|
||||
STORE.add_event("info", f"track edit: {result.get('status')}", path=payload.get("path", ""))
|
||||
self.send_json({"media": result})
|
||||
elif path == "/api/settings":
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(length).decode() if length else "{}"
|
||||
updates = json.loads(body)
|
||||
applied = apply_settings(CONFIG, updates)
|
||||
snap = STORE.snapshot()
|
||||
settings = snap.get("settings", {})
|
||||
deep_merge(settings, applied)
|
||||
STORE.state["settings"] = settings
|
||||
STORE.save()
|
||||
self.send_json({"settings": applied, "config": public_config(CONFIG)})
|
||||
else:
|
||||
self.send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
except Exception as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
SCANNER.start()
|
||||
host = os.getenv("SORTARR_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("SORTARR_API_PORT", "8099"))
|
||||
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
dist/sortarr/backend/sortarr/cache.py
vendored
Normal file
75
dist/sortarr/backend/sortarr/cache.py
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def cache_root(config: dict) -> Path:
|
||||
root = Path(config.get("paths", {}).get("cache") or Path(config["paths"]["data"]) / "cache")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def cache_path(config: dict, namespace: str, key: str) -> Path:
|
||||
digest = hashlib.sha256(key.encode()).hexdigest()
|
||||
path = cache_root(config) / namespace / f"{digest}.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_json(config: dict, namespace: str, key: str, ttl_seconds: int | None = None) -> Any | None:
|
||||
path = cache_path(config, namespace, key)
|
||||
if not path.exists():
|
||||
return None
|
||||
if ttl_seconds is not None and time.time() - path.stat().st_mtime > ttl_seconds:
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def set_json(config: dict, namespace: str, key: str, value: Any) -> None:
|
||||
path = cache_path(config, namespace, key)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(value, sort_keys=True))
|
||||
tmp.replace(path)
|
||||
prune(config)
|
||||
|
||||
|
||||
def remove_json(config: dict, namespace: str, key: str) -> None:
|
||||
path = cache_path(config, namespace, key)
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
|
||||
def prune(config: dict) -> None:
|
||||
root = cache_root(config)
|
||||
max_bytes = int(config.get("app", {}).get("cache_max_bytes", 20 * 1024**3))
|
||||
files = []
|
||||
total = 0
|
||||
for current, _, names in os.walk(root):
|
||||
for name in names:
|
||||
path = Path(current) / name
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total += stat.st_size
|
||||
files.append((stat.st_mtime, stat.st_size, path))
|
||||
if total <= max_bytes:
|
||||
return
|
||||
for _, size, path in sorted(files):
|
||||
try:
|
||||
path.unlink()
|
||||
total -= size
|
||||
except OSError:
|
||||
continue
|
||||
if total <= max_bytes:
|
||||
break
|
||||
67
dist/sortarr/backend/sortarr/config.py
vendored
Normal file
67
dist/sortarr/backend/sortarr/config.py
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _read_toml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("rb") as handle:
|
||||
return tomllib.load(handle)
|
||||
|
||||
|
||||
def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = copy.deepcopy(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
return merged
|
||||
|
||||
|
||||
def _bool(value: str) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
default_path = Path(os.getenv("SORTARR_DEFAULT_CONFIG", "/app/default-config/app.toml"))
|
||||
user_path = Path(os.getenv("SORTARR_CONFIG", "/config/app.toml"))
|
||||
config = _merge(_read_toml(default_path), _read_toml(user_path))
|
||||
|
||||
app = config.setdefault("app", {})
|
||||
paths = config.setdefault("paths", {})
|
||||
|
||||
env_map = {
|
||||
"SORTARR_DRY_RUN": ("app", "dry_run", _bool),
|
||||
"SORTARR_LOG_LEVEL": ("app", "log_level", str),
|
||||
"SORTARR_SCAN_INTERVAL_SECONDS": ("app", "scan_interval_seconds", int),
|
||||
"SORTARR_SETTLE_SECONDS": ("app", "settle_seconds", int),
|
||||
"SORTARR_DATA_DIR": ("paths", "data", str),
|
||||
"SORTARR_LOG_DIR": ("paths", "logs", str),
|
||||
"SORTARR_CACHE_DIR": ("paths", "cache", str),
|
||||
"TMDB_API_KEY": ("metadata", "tmdb_api_key", str),
|
||||
"TMDB_BEARER_TOKEN": ("metadata", "tmdb_bearer_token", str),
|
||||
}
|
||||
for env, (section, key, caster) in env_map.items():
|
||||
if os.getenv(env) not in (None, ""):
|
||||
config.setdefault(section, {})[key] = caster(os.environ[env])
|
||||
|
||||
if os.getenv("SORTARR_MIN_FREE_GB"):
|
||||
for drive in config.get("drives", []):
|
||||
drive["min_free_gb"] = int(os.environ["SORTARR_MIN_FREE_GB"])
|
||||
|
||||
Path(paths.get("data", "/data")).mkdir(parents=True, exist_ok=True)
|
||||
Path(paths.get("logs", "/logs")).mkdir(parents=True, exist_ok=True)
|
||||
Path(paths.get("cache", str(Path(paths.get("data", "/data")) / "cache"))).mkdir(parents=True, exist_ok=True)
|
||||
app.setdefault("dry_run", True)
|
||||
return config
|
||||
|
||||
|
||||
def public_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
clone = copy.deepcopy(config)
|
||||
return clone
|
||||
139
dist/sortarr/backend/sortarr/downloads.py
vendored
Normal file
139
dist/sortarr/backend/sortarr/downloads.py
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def empty_snapshot(root: Path, error: str | None = None) -> dict:
|
||||
return {
|
||||
"path": str(root),
|
||||
"generated_at": time.time(),
|
||||
"current": [],
|
||||
"bundles": [],
|
||||
"loose": [],
|
||||
"recent": [],
|
||||
"counts": {
|
||||
"current": 0,
|
||||
"recent": 0,
|
||||
"media": 0,
|
||||
"subtitles": 0,
|
||||
"incomplete": 0,
|
||||
},
|
||||
"total_size": 0,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def downloads_snapshot(config: dict, state: dict) -> dict:
|
||||
root = Path(config["paths"]["downloads"])
|
||||
app = config.get("app", {})
|
||||
media_extensions = set(app.get("media_extensions", []))
|
||||
subtitle_extensions = set(app.get("subtitle_extensions", []))
|
||||
incomplete = set(app.get("incomplete_suffixes", []))
|
||||
current = []
|
||||
media_files = []
|
||||
subtitle_files = []
|
||||
total_size = 0
|
||||
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
paths = root.rglob("*")
|
||||
for path in paths:
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
suffix = path.suffix.lower()
|
||||
total_size += stat.st_size
|
||||
item = {
|
||||
"name": path.name,
|
||||
"path": str(path),
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"folder": str(path.parent.relative_to(root)) if path.parent != root else "",
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"extension": suffix or "none",
|
||||
"is_media": suffix in media_extensions,
|
||||
"is_subtitle": suffix in subtitle_extensions,
|
||||
"is_incomplete": suffix in incomplete,
|
||||
}
|
||||
current.append(item)
|
||||
if item["is_media"]:
|
||||
media_files.append(item)
|
||||
elif item["is_subtitle"]:
|
||||
subtitle_files.append(item)
|
||||
except OSError as exc:
|
||||
return empty_snapshot(root, str(exc))
|
||||
|
||||
subtitles_by_folder = defaultdict(list)
|
||||
for subtitle in subtitle_files:
|
||||
subtitles_by_folder[subtitle["folder"]].append(subtitle)
|
||||
parent = Path(subtitle["folder"])
|
||||
if parent.name.lower() in {"subs", "subtitles"}:
|
||||
subtitles_by_folder[str(parent.parent) if str(parent.parent) != "." else ""].append(subtitle)
|
||||
|
||||
bundles = []
|
||||
bundled_subtitle_paths = set()
|
||||
for media in media_files:
|
||||
folder_subtitles = subtitles_by_folder.get(media["folder"], [])
|
||||
stem_matches = [
|
||||
subtitle for subtitle in subtitle_files
|
||||
if subtitle["name"].lower().startswith(Path(media["name"]).stem.lower())
|
||||
]
|
||||
seen = set()
|
||||
subtitles = []
|
||||
for subtitle in folder_subtitles + stem_matches:
|
||||
if subtitle["path"] in seen:
|
||||
continue
|
||||
seen.add(subtitle["path"])
|
||||
bundled_subtitle_paths.add(subtitle["path"])
|
||||
subtitles.append(subtitle)
|
||||
bundles.append({
|
||||
"media": media,
|
||||
"subtitles": sorted(subtitles, key=lambda item: item["name"].lower()),
|
||||
"sidecars": [
|
||||
item for item in current
|
||||
if item["folder"] == media["folder"] and not item["is_media"] and not item["is_subtitle"]
|
||||
][:20],
|
||||
"size": media["size"] + sum(item["size"] for item in subtitles),
|
||||
})
|
||||
|
||||
loose = [
|
||||
item for item in current
|
||||
if not item["is_media"] and item["path"] not in bundled_subtitle_paths
|
||||
]
|
||||
|
||||
recent = []
|
||||
for item in state.get("items", []):
|
||||
source = item.get("source", "")
|
||||
status = item.get("status")
|
||||
if source.startswith(str(root)) and status in {"moved", "planned"}:
|
||||
recent.append({
|
||||
"source": source,
|
||||
"destination": item.get("destination"),
|
||||
"title": item.get("title"),
|
||||
"type": item.get("type"),
|
||||
"status": status,
|
||||
"drive": item.get("drive"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
})
|
||||
|
||||
return {
|
||||
"path": str(root),
|
||||
"generated_at": time.time(),
|
||||
"current": sorted(current, key=lambda item: item["modified"], reverse=True),
|
||||
"bundles": sorted(bundles, key=lambda item: item["media"]["modified"], reverse=True),
|
||||
"loose": sorted(loose, key=lambda item: item["modified"], reverse=True),
|
||||
"recent": sorted(recent, key=lambda item: item.get("updated_at") or 0, reverse=True)[:200],
|
||||
"counts": {
|
||||
"current": len(current),
|
||||
"recent": len(recent),
|
||||
"media": sum(1 for item in current if item["is_media"]),
|
||||
"subtitles": sum(1 for item in current if item["is_subtitle"]),
|
||||
"incomplete": sum(1 for item in current if item["is_incomplete"]),
|
||||
},
|
||||
"total_size": total_size,
|
||||
}
|
||||
7
dist/sortarr/backend/sortarr/healthcheck.py
vendored
Normal file
7
dist/sortarr/backend/sortarr/healthcheck.py
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
with urlopen("http://127.0.0.1:8099/api/health", timeout=3) as response:
|
||||
if response.status != 200:
|
||||
raise SystemExit(1)
|
||||
|
||||
261
dist/sortarr/backend/sortarr/library.py
vendored
Normal file
261
dist/sortarr/backend/sortarr/library.py
vendored
Normal file
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
from .metadata import movie_metadata, series_metadata
|
||||
from .parser import parse_media
|
||||
from .storage import drive_stats
|
||||
|
||||
|
||||
LIBRARY_ROOT_NAMES = {"movies", "shows", "tv", "tv shows"}
|
||||
TV_ROOT_NAMES = {"shows", "tv", "tv shows"}
|
||||
EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})")
|
||||
SEASON_FOLDER_RE = re.compile(r"season[ ._-]*(\d{1,2})", re.I)
|
||||
YEAR_RE = re.compile(r"\((19\d{2}|20\d{2})\)")
|
||||
|
||||
|
||||
def library_roots(root: Path) -> list[Path]:
|
||||
matches = []
|
||||
try:
|
||||
children = list(root.iterdir())
|
||||
except OSError:
|
||||
return matches
|
||||
for child in children:
|
||||
if child.is_dir() and child.name.lower() in LIBRARY_ROOT_NAMES:
|
||||
matches.append(child)
|
||||
return matches
|
||||
|
||||
|
||||
def library_kind(library_root: Path) -> str:
|
||||
return "tv" if library_root.name.lower() in TV_ROOT_NAMES else "movie"
|
||||
|
||||
|
||||
def infer_library_kind(path: str) -> str:
|
||||
parts = {part.lower() for part in Path(path).parts}
|
||||
if parts & TV_ROOT_NAMES:
|
||||
return "tv"
|
||||
if "movies" in parts:
|
||||
return "movie"
|
||||
return "other"
|
||||
|
||||
|
||||
def split_library_path(path: str) -> tuple[str, list[str]]:
|
||||
parts = list(Path(path).parts)
|
||||
lowered = [part.lower() for part in parts]
|
||||
for root in LIBRARY_ROOT_NAMES:
|
||||
if root in lowered:
|
||||
idx = lowered.index(root)
|
||||
return parts[idx], parts[idx + 1:]
|
||||
return "", parts
|
||||
|
||||
|
||||
def clean_collection_title(name: str) -> tuple[str, int | None]:
|
||||
year_match = YEAR_RE.search(name)
|
||||
year = int(year_match.group(1)) if year_match else None
|
||||
title = YEAR_RE.sub("", name).strip(" -._") or name
|
||||
return title, year
|
||||
|
||||
|
||||
def item_identity(item: dict) -> dict:
|
||||
root, rel = split_library_path(item.get("path", ""))
|
||||
kind = item.get("library") or infer_library_kind(item.get("path", ""))
|
||||
parsed = parse_media(item.get("path", item.get("name", "")))
|
||||
if kind == "tv" and rel:
|
||||
# TV shows are usually in a folder named after the show.
|
||||
# We take the first part after the library root as the show folder name.
|
||||
title = rel[0].strip()
|
||||
season = parsed.get("season")
|
||||
episode = parsed.get("episode")
|
||||
for part in rel:
|
||||
match = SEASON_FOLDER_RE.search(part)
|
||||
if match and not season:
|
||||
season = int(match.group(1))
|
||||
|
||||
# Clean the folder name for a consistent key
|
||||
clean_name = clean_title(title).lower()
|
||||
return {
|
||||
"kind": "tv",
|
||||
"title": title,
|
||||
"key": f"tv::{clean_name}",
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
}
|
||||
|
||||
# For movies, we use the cleaned title and year
|
||||
title, year = clean_collection_title(rel[0] if rel else parsed["title"])
|
||||
clean_name = clean_title(title).lower()
|
||||
year_val = year or parsed.get("year") or ""
|
||||
return {
|
||||
"kind": "movie",
|
||||
"title": title,
|
||||
"year": year_val,
|
||||
"key": f"movie::{clean_name}::{year_val}",
|
||||
}
|
||||
|
||||
|
||||
def normalize_library(library: dict) -> dict:
|
||||
items = library.get("items", [])
|
||||
kinds = Counter()
|
||||
for item in items:
|
||||
kind = item.get("library") or infer_library_kind(item.get("path", ""))
|
||||
item["library"] = kind
|
||||
if kind in {"movie", "tv"}:
|
||||
kinds[kind] += 1
|
||||
library["counts"] = {
|
||||
"movies": kinds.get("movie", 0),
|
||||
"tv": kinds.get("tv", 0),
|
||||
"total": len(items),
|
||||
}
|
||||
if "collections" not in library:
|
||||
library["collections"] = build_collections({}, items)
|
||||
return library
|
||||
|
||||
|
||||
def build_collections(config: dict, items: list[dict], enrich: bool = False) -> dict:
|
||||
movies: dict[str, dict] = {}
|
||||
series: dict[str, dict] = {}
|
||||
for item in items:
|
||||
identity = item_identity(item)
|
||||
if identity["kind"] == "tv":
|
||||
show = series.setdefault(identity["key"], {
|
||||
"key": identity["key"],
|
||||
"title": identity["title"],
|
||||
"library": "tv",
|
||||
"files": [],
|
||||
"seasons": {},
|
||||
"metadata": {"title": identity["title"], "source": "filename", "seasons": {}},
|
||||
})
|
||||
show["files"].append(item)
|
||||
season_no = identity.get("season") or 0
|
||||
episode_no = identity.get("episode") or 0
|
||||
season = show["seasons"].setdefault(str(season_no), {"season": season_no, "episodes": {}})
|
||||
episode = season["episodes"].setdefault(str(episode_no), {
|
||||
"season": season_no,
|
||||
"episode": episode_no,
|
||||
"title": f"S{season_no:02d}E{episode_no:02d}" if season_no and episode_no else item["name"],
|
||||
"files": [],
|
||||
"status": "present",
|
||||
})
|
||||
episode["files"].append(item)
|
||||
else:
|
||||
movie = movies.setdefault(identity["key"], {
|
||||
"key": identity["key"],
|
||||
"title": identity["title"],
|
||||
"year": identity.get("year"),
|
||||
"library": "movie",
|
||||
"files": [],
|
||||
"metadata": {"title": identity["title"], "source": "filename"},
|
||||
})
|
||||
movie["files"].append(item)
|
||||
|
||||
if enrich and config:
|
||||
workers = int(config.get("app", {}).get("metadata_parallelism", 8))
|
||||
tasks = {}
|
||||
with ThreadPoolExecutor(max_workers=max(1, min(workers, 12))) as executor:
|
||||
for movie in movies.values():
|
||||
future = executor.submit(movie_metadata, config, movie["title"], movie.get("year"))
|
||||
tasks[future] = movie
|
||||
for show in series.values():
|
||||
present_seasons = {int(season) for season in show["seasons"] if int(season) > 0}
|
||||
future = executor.submit(series_metadata, config, show["title"], present_seasons)
|
||||
tasks[future] = show
|
||||
for future in as_completed(tasks):
|
||||
try:
|
||||
tasks[future]["metadata"] = future.result()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
for show in series.values():
|
||||
for season_no, season_meta in show.get("metadata", {}).get("seasons", {}).items():
|
||||
season = show["seasons"].setdefault(season_no, {"season": int(season_no), "episodes": {}})
|
||||
for meta_episode in season_meta.get("episodes", []):
|
||||
key = str(meta_episode.get("episode") or 0)
|
||||
existing = season["episodes"].get(key)
|
||||
if existing:
|
||||
existing.update({
|
||||
"title": meta_episode.get("title") or existing["title"],
|
||||
"air_date": meta_episode.get("air_date"),
|
||||
"overview": meta_episode.get("overview"),
|
||||
"still": meta_episode.get("still"),
|
||||
})
|
||||
else:
|
||||
air_date = meta_episode.get("air_date")
|
||||
season["episodes"][key] = {
|
||||
**meta_episode,
|
||||
"files": [],
|
||||
"status": "upcoming" if air_date and air_date > today else "missing",
|
||||
}
|
||||
for season in show["seasons"].values():
|
||||
season["episodes"] = sorted(season["episodes"].values(), key=lambda ep: ep.get("episode") or 0)
|
||||
show["seasons"] = sorted(show["seasons"].values(), key=lambda season: season["season"])
|
||||
|
||||
return {
|
||||
"movies": sorted(movies.values(), key=lambda movie: movie["title"].lower()),
|
||||
"series": sorted(series.values(), key=lambda show: show["title"].lower()),
|
||||
}
|
||||
|
||||
|
||||
def library_snapshot(config: dict) -> dict:
|
||||
items = []
|
||||
extensions = Counter()
|
||||
ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"}
|
||||
app = config["app"]
|
||||
max_files = int(app.get("library_scan_max_files", 20000))
|
||||
deadline = time.monotonic() + int(app.get("library_scan_timeout_seconds", 8))
|
||||
scanned = 0
|
||||
truncated = False
|
||||
for drive in config.get("drives", []):
|
||||
if scanned >= max_files or time.monotonic() >= deadline:
|
||||
truncated = True
|
||||
break
|
||||
root = Path(drive["path"])
|
||||
if not root.exists():
|
||||
continue
|
||||
for library_root in library_roots(root):
|
||||
kind = library_kind(library_root)
|
||||
for current, dirs, files in os.walk(library_root, onerror=lambda error: None):
|
||||
if scanned >= max_files or time.monotonic() >= deadline:
|
||||
truncated = True
|
||||
break
|
||||
dirs[:] = [name for name in dirs if name not in ignored_dirs]
|
||||
lower_files = {name.lower() for name in files}
|
||||
for filename in files:
|
||||
if scanned >= max_files or time.monotonic() >= deadline:
|
||||
truncated = True
|
||||
break
|
||||
path = Path(current) / filename
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
scanned += 1
|
||||
extensions[path.suffix.lower() or "none"] += 1
|
||||
if path.suffix.lower() in app.get("media_extensions", []):
|
||||
subtitle_names = [
|
||||
f"{path.stem}{ext}".lower()
|
||||
for ext in app.get("subtitle_extensions", [])
|
||||
]
|
||||
items.append({
|
||||
"path": str(path),
|
||||
"name": path.name,
|
||||
"drive": drive["id"],
|
||||
"library": kind,
|
||||
"root": library_root.name,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"has_subtitles": any(name in lower_files for name in subtitle_names),
|
||||
})
|
||||
return normalize_library({
|
||||
"drives": drive_stats(config),
|
||||
"items": sorted(items, key=lambda item: item["modified"], reverse=True),
|
||||
"collections": build_collections(config, items, enrich=True),
|
||||
"extensions": dict(extensions.most_common()),
|
||||
"scanned_files": scanned,
|
||||
"truncated": truncated,
|
||||
})
|
||||
25
dist/sortarr/backend/sortarr/logging_setup.py
vendored
Normal file
25
dist/sortarr/backend/sortarr/logging_setup.py
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def configure_logging(log_dir: str, level: str) -> None:
|
||||
Path(log_dir).mkdir(parents=True, exist_ok=True)
|
||||
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
root = logging.getLogger()
|
||||
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
root.handlers.clear()
|
||||
|
||||
stream = logging.StreamHandler()
|
||||
stream.setFormatter(formatter)
|
||||
root.addHandler(stream)
|
||||
|
||||
try:
|
||||
file_handler = RotatingFileHandler(Path(log_dir) / "sortarr.log", maxBytes=5_000_000, backupCount=5)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
except OSError as exc:
|
||||
print(f"Sortarr could not open file logging in {log_dir}: {exc}", file=sys.stderr)
|
||||
121
dist/sortarr/backend/sortarr/media_probe.py
vendored
Normal file
121
dist/sortarr/backend/sortarr/media_probe.py
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .cache import get_json, remove_json, set_json
|
||||
|
||||
|
||||
def _allowed_roots(config: dict) -> list[Path]:
|
||||
roots = [Path(drive["path"]).resolve() for drive in config.get("drives", [])]
|
||||
roots.append(Path(config["paths"]["downloads"]).resolve())
|
||||
return roots
|
||||
|
||||
|
||||
def assert_allowed_path(config: dict, path: str) -> Path:
|
||||
target = Path(path).resolve()
|
||||
for root in _allowed_roots(config):
|
||||
try:
|
||||
target.relative_to(root)
|
||||
return target
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError("path is outside configured media and downloads roots")
|
||||
|
||||
|
||||
def media_probe(config: dict, path: str) -> dict:
|
||||
target = assert_allowed_path(config, path)
|
||||
stat = target.stat()
|
||||
cache_key = f"{target}:{stat.st_size}:{int(stat.st_mtime)}"
|
||||
cached = get_json(config, "ffprobe", cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
command = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
str(target),
|
||||
]
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
if completed.returncode != 0:
|
||||
return {"path": str(target), "status": "failed", "stderr": completed.stderr[-4000:]}
|
||||
payload = json.loads(completed.stdout or "{}")
|
||||
streams = payload.get("streams", [])
|
||||
result = {
|
||||
"path": str(target),
|
||||
"cache_key": cache_key,
|
||||
"status": "ok",
|
||||
"format": payload.get("format", {}),
|
||||
"audio": [stream for stream in streams if stream.get("codec_type") == "audio"],
|
||||
"subtitles": [stream for stream in streams if stream.get("codec_type") == "subtitle"],
|
||||
"video": [stream for stream in streams if stream.get("codec_type") == "video"],
|
||||
}
|
||||
set_json(config, "ffprobe", cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def _stream_type_positions(probe: dict) -> dict[int, tuple[str, int]]:
|
||||
positions = {"audio": 0, "subtitle": 0, "video": 0}
|
||||
result = {}
|
||||
for stream in probe.get("video", []) + probe.get("audio", []) + probe.get("subtitles", []):
|
||||
codec_type = stream.get("codec_type")
|
||||
if codec_type not in positions:
|
||||
continue
|
||||
result[int(stream["index"])] = (codec_type, positions[codec_type])
|
||||
positions[codec_type] += 1
|
||||
return result
|
||||
|
||||
|
||||
def edit_track(config: dict, path: str, action: str, stream_index: int) -> dict:
|
||||
target = assert_allowed_path(config, path)
|
||||
probe = media_probe(config, str(target))
|
||||
positions = _stream_type_positions(probe)
|
||||
if stream_index not in positions:
|
||||
raise ValueError("stream index was not found")
|
||||
codec_type, type_index = positions[stream_index]
|
||||
if codec_type not in {"audio", "subtitle"}:
|
||||
raise ValueError("only audio and subtitle streams can be edited here")
|
||||
|
||||
tmp = target.with_suffix(target.suffix + ".tracksorting")
|
||||
if action == "remove":
|
||||
command = ["ffmpeg", "-hide_banner", "-y", "-i", str(target), "-map", "0", "-map", f"-0:{stream_index}", "-c", "copy", str(tmp)]
|
||||
elif action == "set-default":
|
||||
spec = "a" if codec_type == "audio" else "s"
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-i",
|
||||
str(target),
|
||||
"-map",
|
||||
"0",
|
||||
"-c",
|
||||
"copy",
|
||||
f"-disposition:{spec}",
|
||||
"0",
|
||||
f"-disposition:{spec}:{type_index}",
|
||||
"default",
|
||||
str(tmp),
|
||||
]
|
||||
else:
|
||||
raise ValueError("unsupported track action")
|
||||
|
||||
if config["app"].get("dry_run"):
|
||||
return {"status": "dry-run", "path": str(target), "action": action, "stream_index": stream_index, "command": command}
|
||||
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=60 * 60)
|
||||
if completed.returncode != 0:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return {"status": "failed", "returncode": completed.returncode, "stderr": completed.stderr[-4000:], "command": command}
|
||||
os.replace(tmp, target)
|
||||
remove_json(config, "ffprobe", probe.get("cache_key", ""))
|
||||
return {"status": "updated", "path": str(target), "action": action, "stream_index": stream_index}
|
||||
216
dist/sortarr/backend/sortarr/metadata.py
vendored
Normal file
216
dist/sortarr/backend/sortarr/metadata.py
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .cache import get_json, set_json
|
||||
|
||||
|
||||
TMDB_BASE = "https://api.themoviedb.org/3"
|
||||
TMDB_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _auth(config: dict) -> tuple[dict[str, str], str | None]:
|
||||
meta = config.get("metadata", {})
|
||||
token = meta.get("tmdb_bearer_token") or ""
|
||||
api_key = meta.get("tmdb_api_key") or ""
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers, api_key or None
|
||||
|
||||
|
||||
def tmdb_available(config: dict) -> bool:
|
||||
meta = config.get("metadata", {})
|
||||
if not meta.get("tmdb_enabled", True):
|
||||
return False
|
||||
return bool(meta.get("tmdb_bearer_token") or meta.get("tmdb_api_key"))
|
||||
|
||||
|
||||
def poster_url(config: dict, path: str | None) -> str | None:
|
||||
if not path:
|
||||
return None
|
||||
return f"{config.get('metadata', {}).get('tmdb_image_base', 'https://image.tmdb.org/t/p/w342')}{path}"
|
||||
|
||||
|
||||
def tmdb_get(config: dict, endpoint: str, params: dict | None = None) -> dict:
|
||||
headers, api_key = _auth(config)
|
||||
query = dict(params or {})
|
||||
query.setdefault("language", config.get("metadata", {}).get("tmdb_language", "en-US"))
|
||||
if api_key:
|
||||
query["api_key"] = api_key
|
||||
url = f"{TMDB_BASE}{endpoint}?{urlencode(query)}"
|
||||
cache_key = f"{endpoint}?{urlencode(sorted((key, value) for key, value in query.items() if key != 'api_key'))}"
|
||||
cached = get_json(config, "tmdb", cache_key, TMDB_TTL_SECONDS)
|
||||
if cached is not None:
|
||||
return cached
|
||||
timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3))
|
||||
with urlopen(Request(url, headers=headers), timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
set_json(config, "tmdb", cache_key, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def test_tmdb(config: dict) -> dict:
|
||||
meta = config.get("metadata", {})
|
||||
if not meta.get("tmdb_enabled", True):
|
||||
return {"ok": False, "status": "disabled", "message": "TMDb is disabled in settings."}
|
||||
headers, api_key = _auth(config)
|
||||
if not api_key and "Authorization" not in headers:
|
||||
return {"ok": False, "status": "missing-credentials", "message": "No TMDb API key or bearer token is configured."}
|
||||
params = {"language": meta.get("tmdb_language", "en-US")}
|
||||
if api_key:
|
||||
params["api_key"] = api_key
|
||||
url = f"{TMDB_BASE}/configuration?{urlencode(params)}"
|
||||
timeout = int(config.get("app", {}).get("organization_metadata_timeout_seconds", 3))
|
||||
try:
|
||||
with urlopen(Request(url, headers=headers), timeout=timeout) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
images = payload.get("images") or {}
|
||||
secure_base = images.get("secure_base_url") or images.get("base_url")
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "connected",
|
||||
"message": "TMDb accepted the configured credentials.",
|
||||
"image_base": secure_base,
|
||||
"poster_sizes": images.get("poster_sizes") or [],
|
||||
}
|
||||
except HTTPError as exc:
|
||||
return {"ok": False, "status": f"http-{exc.code}", "message": f"TMDb returned HTTP {exc.code}."}
|
||||
except (TimeoutError, URLError) as exc:
|
||||
return {"ok": False, "status": "network-error", "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "status": "error", "message": str(exc)}
|
||||
|
||||
|
||||
def first_result(config: dict, media_type: str, title: str, year: int | None = None) -> dict | None:
|
||||
if not tmdb_available(config) or not title:
|
||||
return None
|
||||
params = {"query": title}
|
||||
if year and media_type == "movie":
|
||||
params["year"] = year
|
||||
elif year:
|
||||
params["first_air_date_year"] = year
|
||||
try:
|
||||
payload = tmdb_get(config, f"/search/{media_type}", params)
|
||||
except Exception:
|
||||
return None
|
||||
results = payload.get("results") or []
|
||||
return results[0] if results else None
|
||||
|
||||
|
||||
def movie_metadata(config: dict, title: str, year: int | None = None) -> dict:
|
||||
result = first_result(config, "movie", title, year)
|
||||
if not result:
|
||||
return {"title": title, "source": "filename"}
|
||||
return {
|
||||
"source": "tmdb",
|
||||
"tmdb_id": result.get("id"),
|
||||
"title": result.get("title") or title,
|
||||
"overview": result.get("overview") or "",
|
||||
"poster": poster_url(config, result.get("poster_path")),
|
||||
"backdrop": poster_url(config, result.get("backdrop_path")),
|
||||
"release_date": result.get("release_date"),
|
||||
"vote_average": result.get("vote_average"),
|
||||
}
|
||||
|
||||
|
||||
def series_metadata(config: dict, title: str, seasons: set[int]) -> dict:
|
||||
result = first_result(config, "tv", title)
|
||||
if not result:
|
||||
return {"title": title, "source": "filename", "seasons": {}}
|
||||
metadata = {
|
||||
"source": "tmdb",
|
||||
"tmdb_id": result.get("id"),
|
||||
"title": result.get("name") or title,
|
||||
"overview": result.get("overview") or "",
|
||||
"poster": poster_url(config, result.get("poster_path")),
|
||||
"backdrop": poster_url(config, result.get("backdrop_path")),
|
||||
"first_air_date": result.get("first_air_date"),
|
||||
"vote_average": result.get("vote_average"),
|
||||
"seasons": {},
|
||||
}
|
||||
for season in sorted(seasons):
|
||||
try:
|
||||
payload = tmdb_get(config, f"/tv/{result.get('id')}/season/{season}")
|
||||
except Exception:
|
||||
continue
|
||||
metadata["seasons"][str(season)] = {
|
||||
"name": payload.get("name"),
|
||||
"air_date": payload.get("air_date"),
|
||||
"episode_count": len(payload.get("episodes") or []),
|
||||
"episodes": [
|
||||
{
|
||||
"season": season,
|
||||
"episode": episode.get("episode_number"),
|
||||
"title": episode.get("name"),
|
||||
"overview": episode.get("overview") or "",
|
||||
"air_date": episode.get("air_date"),
|
||||
"still": poster_url(config, episode.get("still_path")),
|
||||
}
|
||||
for episode in payload.get("episodes") or []
|
||||
],
|
||||
}
|
||||
return metadata
|
||||
|
||||
def search_tmdb(config: dict, media_type: str, title: str) -> list[dict]:
|
||||
if not tmdb_available(config) or not title:
|
||||
return []
|
||||
params = {"query": title}
|
||||
try:
|
||||
payload = tmdb_get(config, f"/search/{media_type}", params)
|
||||
except Exception:
|
||||
return []
|
||||
results = payload.get("results") or []
|
||||
return [
|
||||
{
|
||||
"tmdb_id": r.get("id"),
|
||||
"title": r.get("title") or r.get("name"),
|
||||
"overview": r.get("overview"),
|
||||
"poster": poster_url(config, r.get("poster_path")),
|
||||
"release_date": r.get("release_date") or r.get("first_air_date"),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
def identify_item(config: dict, item: dict, tmdb_id: int, media_type: str) -> dict:
|
||||
if not tmdb_available(config):
|
||||
return item
|
||||
if media_type == "movie":
|
||||
try:
|
||||
payload = tmdb_get(config, f"/movie/{tmdb_id}")
|
||||
item["metadata"] = {
|
||||
"source": "tmdb",
|
||||
"tmdb_id": tmdb_id,
|
||||
"title": payload.get("title") or item.get("title"),
|
||||
"overview": payload.get("overview") or "",
|
||||
"poster": poster_url(config, payload.get("poster_path")),
|
||||
"backdrop": poster_url(config, payload.get("backdrop_path")),
|
||||
"release_date": payload.get("release_date"),
|
||||
"vote_average": payload.get("vote_average"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
elif media_type == "tv":
|
||||
try:
|
||||
# We need to re-fetch seasons as well
|
||||
present_seasons = {int(s["season"]) for s in item.get("seasons", []) if s.get("season")}
|
||||
metadata = series_metadata(config, item["title"], present_seasons)
|
||||
# If we have a specific ID, we should use it for series_metadata but series_metadata searches by title.
|
||||
# Let's patch it to use the ID.
|
||||
# (Simplification: for now we assume title search works well enough if we already have the ID we can
|
||||
# just manually fetch what we need).
|
||||
payload = tmdb_get(config, f"/tv/{tmdb_id}")
|
||||
metadata.update({
|
||||
"source": "tmdb",
|
||||
"tmdb_id": tmdb_id,
|
||||
"title": payload.get("name") or item.get("title"),
|
||||
"overview": payload.get("overview") or "",
|
||||
"poster": poster_url(config, payload.get("poster_path")),
|
||||
})
|
||||
item["metadata"] = metadata
|
||||
except Exception:
|
||||
pass
|
||||
return item
|
||||
293
dist/sortarr/backend/sortarr/organizer.py
vendored
Normal file
293
dist/sortarr/backend/sortarr/organizer.py
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from .metadata import movie_metadata, series_metadata, tmdb_available
|
||||
from .parser import parse_media
|
||||
from .storage import choose_drive
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
LANGUAGE_HINTS = {
|
||||
"eng": "eng",
|
||||
"english": "eng",
|
||||
"en": "eng",
|
||||
"spa": "spa",
|
||||
"spanish": "spa",
|
||||
"fre": "fre",
|
||||
"french": "fre",
|
||||
"ger": "ger",
|
||||
"german": "ger",
|
||||
"ita": "ita",
|
||||
"jpn": "jpn",
|
||||
"japanese": "jpn",
|
||||
"kor": "kor",
|
||||
}
|
||||
|
||||
|
||||
def safe_name(value: str) -> str:
|
||||
return "".join(ch for ch in value if ch not in '<>:"/\\|?*').strip().rstrip(".") or "Unknown"
|
||||
|
||||
|
||||
def format_destination(config: dict, media: dict, drive: dict) -> Path:
|
||||
lib = config["library"]
|
||||
title = safe_name(media["title"])
|
||||
year = media.get("year") or "Unknown Year"
|
||||
if media["type"] == "episode":
|
||||
folder_tpl = lib["series_folder"]
|
||||
file_tpl = lib["episode_file"]
|
||||
elif media["type"] == "season":
|
||||
folder_tpl = lib["series_folder"]
|
||||
file_tpl = "{title} - Season {season:02d}{quality}{ext}"
|
||||
else:
|
||||
folder_tpl = lib["movie_folder"] if media.get("year") else lib["unknown_folder"]
|
||||
file_tpl = lib["movie_file"]
|
||||
values = {
|
||||
**media,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"season": media.get("season") or 1,
|
||||
"episode": media.get("episode") or 1,
|
||||
"episode_title": safe_name(media.get("episode_title") or "Episode"),
|
||||
"ext": media["extension"],
|
||||
}
|
||||
folder = folder_tpl.format(**values)
|
||||
filename = file_tpl.format(**values)
|
||||
return Path(drive["path"]) / folder / filename
|
||||
|
||||
|
||||
def language_suffix(path: Path) -> str:
|
||||
lowered = path.stem.lower().replace(".", " ").replace("_", " ")
|
||||
for token, code in LANGUAGE_HINTS.items():
|
||||
if token in lowered.split():
|
||||
return f".{code}"
|
||||
return ""
|
||||
|
||||
|
||||
def unique_planned_path(path: Path, rule: str, reserved: set[str]) -> Path | None:
|
||||
candidate = collision_path(path, rule)
|
||||
if not candidate:
|
||||
return None
|
||||
if str(candidate) not in reserved:
|
||||
reserved.add(str(candidate))
|
||||
return candidate
|
||||
stem, suffix = candidate.stem, candidate.suffix
|
||||
for idx in range(2, 1000):
|
||||
numbered = candidate.with_name(f"{stem}.{idx}{suffix}")
|
||||
if not numbered.exists() and str(numbered) not in reserved:
|
||||
reserved.add(str(numbered))
|
||||
return numbered
|
||||
raise RuntimeError(f"Could not find collision-free name for {path}")
|
||||
|
||||
|
||||
def tmdb_episode_title(metadata: dict, season: int | None, episode: int | None) -> str | None:
|
||||
if not season or not episode:
|
||||
return None
|
||||
season_data = metadata.get("seasons", {}).get(str(season), {})
|
||||
for item in season_data.get("episodes", []):
|
||||
if item.get("episode") == episode and item.get("title"):
|
||||
return item["title"]
|
||||
return None
|
||||
|
||||
|
||||
def plan_id(source: str) -> str:
|
||||
return hashlib.sha256(source.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def quality_score(media: dict) -> int:
|
||||
quality = media.get("quality", "").lower()
|
||||
if "2160" in quality:
|
||||
return 4
|
||||
if "1080" in quality:
|
||||
return 3
|
||||
if "720" in quality:
|
||||
return 2
|
||||
if "480" in quality:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def confidence(config: dict, media: dict, metadata_enabled: bool = True) -> tuple[int, list[str], dict]:
|
||||
score = 20
|
||||
reasons = []
|
||||
metadata = {"source": "filename", "title": media["title"]}
|
||||
if media["title"] != "Unknown" and len(media["title"]) > 2:
|
||||
score += 20
|
||||
reasons.append("title parsed")
|
||||
if media["type"] == "episode" and media.get("season") and media.get("episode"):
|
||||
score += 35
|
||||
reasons.append("season and episode parsed")
|
||||
if media["type"] == "movie" and media.get("year"):
|
||||
score += 25
|
||||
reasons.append("year parsed")
|
||||
if media.get("quality"):
|
||||
score += 5
|
||||
reasons.append("quality parsed")
|
||||
if metadata_enabled and tmdb_available(config):
|
||||
if media["type"] == "movie":
|
||||
metadata = movie_metadata(config, media["title"], media.get("year"))
|
||||
elif media["type"] == "episode":
|
||||
metadata = series_metadata(config, media["title"], {media.get("season") or 1})
|
||||
if metadata.get("source") == "tmdb":
|
||||
score += 20
|
||||
reasons.append("TMDb match")
|
||||
elif tmdb_available(config):
|
||||
reasons.append("metadata deferred")
|
||||
return min(score, 100), reasons, metadata
|
||||
|
||||
|
||||
def plan_bundle(config: dict, bundle: dict, metadata_enabled: bool = True) -> dict:
|
||||
media_file = Path(bundle["media"]["path"])
|
||||
media = parse_media(str(media_file))
|
||||
score, reasons, metadata = confidence(config, media, metadata_enabled)
|
||||
drive = choose_drive(config, metadata.get("title") or media["title"])
|
||||
if metadata.get("source") == "tmdb":
|
||||
media["title"] = metadata.get("title") or media["title"]
|
||||
if media["type"] == "movie" and metadata.get("release_date") and not media.get("year"):
|
||||
media["year"] = int(metadata["release_date"][:4])
|
||||
if media["type"] == "episode":
|
||||
media["episode_title"] = tmdb_episode_title(metadata, media.get("season"), media.get("episode")) or media.get("episode_title") or "Episode"
|
||||
dest = format_destination(config, media, drive)
|
||||
final = collision_path(dest, config["library"].get("collision", "keep-both"))
|
||||
subtitle_moves = []
|
||||
if final:
|
||||
reserved = {str(final)}
|
||||
for subtitle in bundle.get("subtitles", []):
|
||||
subtitle_path = Path(subtitle["path"])
|
||||
suffix = language_suffix(subtitle_path)
|
||||
if not suffix:
|
||||
suffix = ".und"
|
||||
subtitle_dest = final.with_name(f"{final.stem}{suffix}{subtitle_path.suffix.lower()}")
|
||||
subtitle_final = unique_planned_path(subtitle_dest, config["library"].get("collision", "keep-both"), reserved)
|
||||
subtitle_moves.append({
|
||||
"source": str(subtitle_path),
|
||||
"destination": str(subtitle_final) if subtitle_final else None,
|
||||
"language": suffix.lstrip(".") or None,
|
||||
})
|
||||
auto_threshold = int(config["app"].get("auto_move_min_confidence", 90))
|
||||
review_threshold = int(config["app"].get("review_min_confidence", 60))
|
||||
if not final:
|
||||
status = "skipped"
|
||||
elif score >= auto_threshold:
|
||||
status = "ready"
|
||||
elif score >= review_threshold:
|
||||
status = "needs-review"
|
||||
else:
|
||||
status = "low-confidence"
|
||||
return {
|
||||
"id": plan_id(str(media_file)),
|
||||
"source": str(media_file),
|
||||
"destination": str(final) if final else None,
|
||||
"media": media,
|
||||
"metadata": metadata,
|
||||
"drive": drive["id"],
|
||||
"confidence": score,
|
||||
"reasons": reasons,
|
||||
"status": status,
|
||||
"subtitles": subtitle_moves,
|
||||
"sidecars": bundle.get("sidecars", []),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def collision_path(path: Path, rule: str) -> Path | None:
|
||||
if not path.exists():
|
||||
return path
|
||||
if rule == "skip":
|
||||
return None
|
||||
if rule == "replace":
|
||||
return path
|
||||
stem, suffix = path.stem, path.suffix
|
||||
for idx in range(2, 1000):
|
||||
candidate = path.with_name(f"{stem} ({idx}){suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
raise RuntimeError(f"Could not find collision-free name for {path}")
|
||||
|
||||
|
||||
def write_nfo(path: Path, media: dict) -> None:
|
||||
nfo = path.with_suffix(".nfo")
|
||||
body = [
|
||||
"<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()}
|
||||
143
dist/sortarr/backend/sortarr/parser.py
vendored
Normal file
143
dist/sortarr/backend/sortarr/parser.py
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
QUALITY_RE = re.compile(r"\b(2160p|1080p|720p|480p|576p|remux|bluray|web[- .]?dl|webrip|hdtv|dvdrip|dvd|brrip|bdrip)\b", re.I)
|
||||
YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
|
||||
EPISODE_RE = re.compile(r"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})(?:[ ._-]*[Ee](\d{1,3}))?")
|
||||
ALT_EPISODE_RE = re.compile(r"\b(\d{1,2})x(\d{1,3})(?:[ ._-]*(\d{1,2})x(\d{1,3}))?\b")
|
||||
SEASON_RE = re.compile(r"\b[Ss](?:eason)?[ ._-]*(\d{1,2})\b")
|
||||
BRACKET_RE = re.compile(r"[\[(][^\])]*(?:\]|\))")
|
||||
AUDIO_RE = re.compile(r"\b(?:aac|aac\d(?:[ ._-]?\d)?|ac3|eac3|ddp(?:\d(?:[ ._-]?\d)?)?|dts(?:-hd|hd|x)?|truehd|atmos|flac|mp3|opus|5[ ._-]?1|7[ ._-]?1|2[ ._-]?0|6ch|2ch)\b", re.I)
|
||||
CODEC_RE = re.compile(r"\b(?:x264|x265|h[ ._-]?264|h[ ._-]?265|hevc|avc|av1|vc1|vp9|10bit|8bit|hdr|hdr10|dv|dolby[ ._-]?vision)\b", re.I)
|
||||
EDITION_RE = re.compile(r"\b(?:proper|repack|rerip|extended|unrated|directors?[ ._-]?cut|theatrical|imax|multi|line|dubbed|subbed|limited|internal)\b", re.I)
|
||||
RELEASE_GROUP_RE = re.compile(r"(?:^|[ ._-])(?:YTS|TGx|EZTVx?|MeGusta|PSA|RARBG|NTb|AMZN|DSNP|PMNTP|FLUX|SuccessfulCrab|GalaxyTV|VXT|QxR|TIGOLE|UTR|SARTRE|KOGI|ANONYMOUS|SNEAKY|EVO|FGT)\b", re.I)
|
||||
TRAILING_GROUP_RE = re.compile(r"(?:[ ._-]+-[ ._-]*[A-Za-z0-9][A-Za-z0-9._-]{1,24})$")
|
||||
|
||||
|
||||
def clean_title(raw: str) -> str:
|
||||
text = trim_noise(raw)
|
||||
# Remove year if it's at the end or preceded by space/dot
|
||||
text = re.sub(r"[ ._-]+\(?(?:19\d{2}|20\d{2})\)?.*$", "", text)
|
||||
text = YEAR_RE.sub(" ", text)
|
||||
text = EPISODE_RE.sub(" ", text)
|
||||
text = ALT_EPISODE_RE.sub(" ", text)
|
||||
text = SEASON_RE.sub(" ", text)
|
||||
return spaced(text) or "Unknown"
|
||||
def strip_brackets(raw: str) -> str:
|
||||
return BRACKET_RE.sub(" ", raw)
|
||||
|
||||
|
||||
def strip_release_tail(raw: str) -> str:
|
||||
text = strip_brackets(raw)
|
||||
text = TRAILING_GROUP_RE.sub("", text)
|
||||
text = RELEASE_GROUP_RE.sub(" ", text)
|
||||
return spaced(text)
|
||||
|
||||
|
||||
def first_noise_index(text: str) -> int | None:
|
||||
matches = [
|
||||
match.start()
|
||||
for pattern in (QUALITY_RE, AUDIO_RE, CODEC_RE, EDITION_RE, RELEASE_GROUP_RE)
|
||||
for match in [pattern.search(text)]
|
||||
if match
|
||||
]
|
||||
return min(matches) if matches else None
|
||||
|
||||
|
||||
def trim_noise(raw: str) -> str:
|
||||
text = strip_release_tail(raw)
|
||||
idx = first_noise_index(text)
|
||||
if idx is not None:
|
||||
text = text[:idx]
|
||||
return spaced(text)
|
||||
|
||||
|
||||
def clean_title(raw: str) -> str:
|
||||
text = trim_noise(raw)
|
||||
text = YEAR_RE.sub(" ", text)
|
||||
text = EPISODE_RE.sub(" ", text)
|
||||
text = ALT_EPISODE_RE.sub(" ", text)
|
||||
text = SEASON_RE.sub(" ", text)
|
||||
return spaced(text) or "Unknown"
|
||||
|
||||
|
||||
def clean_episode_title(raw: str) -> str:
|
||||
text = trim_noise(raw)
|
||||
text = YEAR_RE.sub(" ", text)
|
||||
return spaced(text) or "Episode"
|
||||
|
||||
|
||||
def parent_candidate(path: Path) -> str:
|
||||
parent = path.parent
|
||||
if parent.name.lower() in {"subs", "subtitles", "sub"}:
|
||||
parent = parent.parent
|
||||
name = parent.name
|
||||
if not name or name in {".", "/"}:
|
||||
return ""
|
||||
return name
|
||||
|
||||
|
||||
def movie_title_source(path: Path, stem: str) -> str:
|
||||
parent = parent_candidate(path)
|
||||
if YEAR_RE.search(parent):
|
||||
return parent
|
||||
if YEAR_RE.search(stem):
|
||||
return stem
|
||||
if parent and first_noise_index(parent) is None and not EPISODE_RE.search(parent):
|
||||
return parent
|
||||
return stem
|
||||
|
||||
|
||||
def parse_media(path: str) -> dict:
|
||||
p = Path(path)
|
||||
stem = p.stem
|
||||
quality_match = QUALITY_RE.search(stem) or QUALITY_RE.search(parent_candidate(p))
|
||||
year_source = stem if YEAR_RE.search(stem) else parent_candidate(p)
|
||||
year_match = YEAR_RE.search(year_source)
|
||||
episode_match = EPISODE_RE.search(stem)
|
||||
alt_match = ALT_EPISODE_RE.search(stem)
|
||||
season_match = SEASON_RE.search(stem)
|
||||
|
||||
media_type = "movie"
|
||||
season = None
|
||||
episode = None
|
||||
multi_episode = ""
|
||||
episode_title = ""
|
||||
|
||||
if episode_match:
|
||||
media_type = "episode"
|
||||
season = int(episode_match.group(1))
|
||||
episode = int(episode_match.group(2))
|
||||
if episode_match.group(3):
|
||||
multi_episode = f"-E{int(episode_match.group(3)):02d}"
|
||||
title = clean_title(stem[:episode_match.start()])
|
||||
episode_title = clean_episode_title(stem[episode_match.end():])
|
||||
elif alt_match:
|
||||
media_type = "episode"
|
||||
season = int(alt_match.group(1))
|
||||
episode = int(alt_match.group(2))
|
||||
if alt_match.group(4):
|
||||
multi_episode = f"-E{int(alt_match.group(4)):02d}"
|
||||
title = clean_title(stem[:alt_match.start()])
|
||||
episode_title = clean_episode_title(stem[alt_match.end():])
|
||||
elif season_match:
|
||||
media_type = "season"
|
||||
season = int(season_match.group(1))
|
||||
title = clean_title(stem[:season_match.start()] or parent_candidate(p) or stem)
|
||||
else:
|
||||
title = clean_title(movie_title_source(p, stem))
|
||||
|
||||
return {
|
||||
"source": str(p),
|
||||
"title": title,
|
||||
"year": int(year_match.group(1)) if year_match else None,
|
||||
"quality": f" - {quality_match.group(1).replace('.', ' ')}" if quality_match else "",
|
||||
"type": media_type,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
"multi_episode": multi_episode,
|
||||
"episode_title": episode_title if media_type == "episode" else "",
|
||||
"extension": p.suffix.lower(),
|
||||
}
|
||||
59
dist/sortarr/backend/sortarr/releases.py
vendored
Normal file
59
dist/sortarr/backend/sortarr/releases.py
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
def library_releases(library: dict | None) -> list[dict]:
|
||||
releases = []
|
||||
for show in ((library or {}).get("collections") or {}).get("series", []):
|
||||
for season in show.get("seasons", []):
|
||||
for episode in season.get("episodes", []):
|
||||
if episode.get("status") not in {"missing", "upcoming"}:
|
||||
continue
|
||||
releases.append({
|
||||
"provider": "Library",
|
||||
"title": show.get("metadata", {}).get("title") or show.get("title"),
|
||||
"episode_title": episode.get("title"),
|
||||
"season": episode.get("season"),
|
||||
"episode": episode.get("episode"),
|
||||
"date": episode.get("air_date"),
|
||||
"type": "tv",
|
||||
"status": episode.get("status"),
|
||||
"poster": show.get("metadata", {}).get("poster"),
|
||||
"library_key": show.get("key"),
|
||||
})
|
||||
return sorted(releases, key=lambda item: (item.get("date") or "9999-99-99", item.get("title") or ""))
|
||||
|
||||
|
||||
def fetch_releases(config: dict, library: dict | None = None) -> list[dict]:
|
||||
releases: list[dict] = library_releases(library)
|
||||
for provider in config.get("release_providers", []):
|
||||
if not provider.get("enabled", True):
|
||||
continue
|
||||
try:
|
||||
with urlopen(provider["url"], timeout=8) as response:
|
||||
body = response.read()
|
||||
if provider.get("type") == "json":
|
||||
data = json.loads(body.decode())
|
||||
for item in data[:30] if isinstance(data, list) else []:
|
||||
show = item.get("show", item)
|
||||
releases.append({
|
||||
"provider": provider["name"],
|
||||
"title": show.get("name"),
|
||||
"date": item.get("airdate") or item.get("premiered"),
|
||||
"type": "tv",
|
||||
})
|
||||
else:
|
||||
root = ET.fromstring(body)
|
||||
for item in root.findall(".//item")[:30]:
|
||||
releases.append({
|
||||
"provider": provider["name"],
|
||||
"title": (item.findtext("title") or "").strip(),
|
||||
"date": (item.findtext("pubDate") or "").strip(),
|
||||
"type": "movie",
|
||||
})
|
||||
except Exception as exc:
|
||||
releases.append({"provider": provider.get("name"), "error": str(exc)})
|
||||
return releases
|
||||
104
dist/sortarr/backend/sortarr/scanner.py
vendored
Normal file
104
dist/sortarr/backend/sortarr/scanner.py
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .downloads import downloads_snapshot
|
||||
from .organizer import execute_bundle_plan, plan_bundle
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Scanner(threading.Thread):
|
||||
def __init__(self, config: dict, store):
|
||||
super().__init__(daemon=True)
|
||||
self.config = config
|
||||
self.store = store
|
||||
self.stop_event = threading.Event()
|
||||
self.scan_lock = threading.Lock()
|
||||
self.seen_sizes: dict[str, tuple[int, int]] = {}
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
|
||||
def is_candidate(self, path: Path) -> bool:
|
||||
app = self.config["app"]
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.suffix.lower() in app.get("incomplete_suffixes", []):
|
||||
return False
|
||||
return path.suffix.lower() in set(app.get("media_extensions", []))
|
||||
|
||||
def is_stable(self, path: Path) -> bool:
|
||||
stat = path.stat()
|
||||
current = (stat.st_size, int(stat.st_mtime))
|
||||
previous = self.seen_sizes.get(str(path))
|
||||
self.seen_sizes[str(path)] = current
|
||||
age = time.time() - stat.st_mtime
|
||||
return previous == current and age >= int(self.config["app"].get("settle_seconds", 90))
|
||||
|
||||
def scan_once(self) -> list[dict]:
|
||||
if not self.scan_lock.acquire(blocking=False):
|
||||
return self.store.snapshot().get("organizer", {}).get("queue", [])
|
||||
try:
|
||||
return self._scan_once()
|
||||
finally:
|
||||
self.scan_lock.release()
|
||||
|
||||
def request_scan(self) -> bool:
|
||||
if self.scan_lock.locked():
|
||||
return False
|
||||
thread = threading.Thread(target=self.scan_once, daemon=True)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def _scan_once(self) -> list[dict]:
|
||||
downloads = Path(self.config["paths"]["downloads"])
|
||||
downloads.mkdir(parents=True, exist_ok=True)
|
||||
plans: list[dict] = []
|
||||
state = self.store.snapshot()
|
||||
previous_items = {item.get("source"): item for item in state.get("items", [])}
|
||||
snapshot = downloads_snapshot(self.config, state)
|
||||
metadata_budget = int(self.config["app"].get("organization_metadata_budget_seconds", 25))
|
||||
metadata_deadline = time.time() + metadata_budget
|
||||
for bundle in snapshot.get("bundles", []):
|
||||
path = Path(bundle["media"]["path"])
|
||||
if not self.is_candidate(path) or not self.is_stable(path):
|
||||
continue
|
||||
try:
|
||||
plan = plan_bundle(self.config, bundle, metadata_enabled=time.time() < metadata_deadline)
|
||||
result = execute_bundle_plan(self.config, plan)
|
||||
plans.append(result)
|
||||
self.store.set_organizer_queue(plans)
|
||||
item = {
|
||||
"source": str(path),
|
||||
"destination": result.get("destination"),
|
||||
"title": result["media"]["title"],
|
||||
"type": result["media"]["type"],
|
||||
"status": result.get("result") or result["status"],
|
||||
"drive": result.get("drive"),
|
||||
"confidence": result.get("confidence"),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
self.store.upsert_item(item)
|
||||
previous = previous_items.get(str(path), {})
|
||||
if (
|
||||
previous.get("destination") != item.get("destination")
|
||||
or previous.get("status") != item.get("status")
|
||||
or previous.get("confidence") != item.get("confidence")
|
||||
):
|
||||
self.store.add_event("info", f"{item['status']}: {path.name}", path=str(path), confidence=item.get("confidence"))
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to organize %s", path)
|
||||
self.store.add_event("error", str(exc), path=str(path))
|
||||
self.store.set_plans(plans)
|
||||
self.store.set_organizer_queue(plans)
|
||||
return plans
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
self.scan_once()
|
||||
interval = int(self.config["app"].get("scan_interval_seconds", 20))
|
||||
self.stop_event.wait(interval)
|
||||
53
dist/sortarr/backend/sortarr/storage.py
vendored
Normal file
53
dist/sortarr/backend/sortarr/storage.py
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def disk_usage(path: str) -> dict:
|
||||
usage = os.statvfs(path)
|
||||
total = usage.f_frsize * usage.f_blocks
|
||||
free = usage.f_frsize * usage.f_bavail
|
||||
used = total - free
|
||||
return {"total": total, "used": used, "free": free}
|
||||
|
||||
|
||||
def drive_stats(config: dict) -> list[dict]:
|
||||
stats = []
|
||||
for drive in config.get("drives", []):
|
||||
path = Path(drive["path"])
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
usage = disk_usage(str(path))
|
||||
stats.append({**drive, **usage})
|
||||
return stats
|
||||
|
||||
|
||||
def find_existing_home(config: dict, title: str) -> str | None:
|
||||
normalized = title.lower()
|
||||
for drive in config.get("drives", []):
|
||||
root = Path(drive["path"])
|
||||
for folder in ("Movies", "Shows"):
|
||||
base = root / folder
|
||||
if not base.exists():
|
||||
continue
|
||||
for child in base.iterdir():
|
||||
if child.is_dir() and child.name.lower().startswith(normalized):
|
||||
return str(root)
|
||||
return None
|
||||
|
||||
|
||||
def choose_drive(config: dict, title: str) -> dict:
|
||||
existing = find_existing_home(config, title)
|
||||
if existing:
|
||||
for drive in config.get("drives", []):
|
||||
if drive["path"] == existing:
|
||||
return drive
|
||||
candidates = []
|
||||
for drive in drive_stats(config):
|
||||
min_free = int(drive.get("min_free_gb", 0)) * 1024**3
|
||||
if drive["free"] >= min_free:
|
||||
candidates.append(drive)
|
||||
if not candidates:
|
||||
raise RuntimeError("No media drive has the configured minimum free space")
|
||||
return max(candidates, key=lambda d: d["free"])
|
||||
|
||||
73
dist/sortarr/backend/sortarr/store.py
vendored
Normal file
73
dist/sortarr/backend/sortarr/store.py
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class JsonStore:
|
||||
def __init__(self, data_dir: str):
|
||||
self.path = Path(data_dir) / "state.json"
|
||||
self.lock = threading.RLock()
|
||||
self.state: dict[str, Any] = {
|
||||
"events": [],
|
||||
"items": [],
|
||||
"plans": [],
|
||||
"organizer": {"queue": [], "updated_at": None},
|
||||
"library": None,
|
||||
"settings": {},
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
with self.lock:
|
||||
if self.path.exists():
|
||||
self.state.update(json.loads(self.path.read_text()))
|
||||
|
||||
def save(self) -> None:
|
||||
with self.lock:
|
||||
self.state["updated_at"] = time.time()
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(self.state, indent=2, sort_keys=True))
|
||||
tmp.replace(self.path)
|
||||
|
||||
def add_event(self, level: str, message: str, **fields: Any) -> None:
|
||||
with self.lock:
|
||||
event = {"time": time.time(), "level": level, "message": message, **fields}
|
||||
self.state.setdefault("events", []).insert(0, event)
|
||||
self.state["events"] = self.state["events"][:500]
|
||||
self.save()
|
||||
|
||||
def upsert_item(self, item: dict[str, Any]) -> None:
|
||||
with self.lock:
|
||||
items = self.state.setdefault("items", [])
|
||||
key = item.get("destination") or item.get("source")
|
||||
for idx, existing in enumerate(items):
|
||||
if (existing.get("destination") or existing.get("source")) == key:
|
||||
items[idx] = {**existing, **item}
|
||||
break
|
||||
else:
|
||||
items.append(item)
|
||||
self.save()
|
||||
|
||||
def set_plans(self, plans: list[dict[str, Any]]) -> None:
|
||||
with self.lock:
|
||||
self.state["plans"] = plans[:200]
|
||||
self.save()
|
||||
|
||||
def set_organizer_queue(self, queue: list[dict[str, Any]]) -> None:
|
||||
with self.lock:
|
||||
self.state["organizer"] = {"queue": queue[:500], "updated_at": time.time()}
|
||||
self.save()
|
||||
|
||||
def set_library(self, library: dict[str, Any]) -> None:
|
||||
with self.lock:
|
||||
self.state["library"] = library
|
||||
self.save()
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self.lock:
|
||||
return json.loads(json.dumps(self.state))
|
||||
98
dist/sortarr/backend/sortarr/tools.py
vendored
Normal file
98
dist/sortarr/backend/sortarr/tools.py
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def subtitle_audit(config: dict, library: dict | None) -> dict:
|
||||
media_extensions = set(config["app"].get("media_extensions", []))
|
||||
subtitle_extensions = config["app"].get("subtitle_extensions", [])
|
||||
missing = []
|
||||
present = 0
|
||||
unknown = 0
|
||||
for item in (library or {}).get("items", []):
|
||||
path = Path(item["path"])
|
||||
if path.suffix.lower() not in media_extensions:
|
||||
continue
|
||||
if item.get("has_subtitles") is True:
|
||||
present += 1
|
||||
elif "has_subtitles" not in item:
|
||||
unknown += 1
|
||||
else:
|
||||
missing.append({
|
||||
"name": item["name"],
|
||||
"path": str(path),
|
||||
"drive": item.get("drive"),
|
||||
"expected": [f"{path.stem}{ext}" for ext in subtitle_extensions[:3]],
|
||||
})
|
||||
return {
|
||||
"checked": present + len(missing) + unknown,
|
||||
"with_subtitles": present,
|
||||
"unknown_count": unknown,
|
||||
"missing_count": len(missing),
|
||||
"missing": missing[:500],
|
||||
"generated_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def transcode_plan(config: dict, library: dict | None) -> dict:
|
||||
targets = []
|
||||
for item in (library or {}).get("items", []):
|
||||
path = Path(item["path"])
|
||||
if path.suffix.lower() == ".mp4":
|
||||
continue
|
||||
output = path.with_suffix(".mp4")
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-i",
|
||||
str(path),
|
||||
"-map",
|
||||
"0",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
str(output),
|
||||
]
|
||||
targets.append({
|
||||
"name": item["name"],
|
||||
"source": str(path),
|
||||
"output": str(output),
|
||||
"drive": item.get("drive"),
|
||||
"command": command,
|
||||
})
|
||||
return {
|
||||
"ffmpeg_available": shutil.which("ffmpeg") is not None,
|
||||
"count": len(targets),
|
||||
"targets": targets[:100],
|
||||
"generated_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
def run_next_transcode(config: dict, library: dict | None) -> dict:
|
||||
plan = transcode_plan(config, library)
|
||||
if not plan["targets"]:
|
||||
return {**plan, "status": "empty"}
|
||||
if not plan["ffmpeg_available"]:
|
||||
return {**plan, "status": "ffmpeg-unavailable"}
|
||||
if config["app"].get("dry_run"):
|
||||
return {**plan, "status": "dry-run"}
|
||||
target = plan["targets"][0]
|
||||
completed = subprocess.run(target["command"], capture_output=True, text=True, timeout=60 * 60)
|
||||
return {
|
||||
**plan,
|
||||
"status": "completed" if completed.returncode == 0 else "failed",
|
||||
"ran": target,
|
||||
"returncode": completed.returncode,
|
||||
"stderr": completed.stderr[-4000:],
|
||||
}
|
||||
19
dist/sortarr/config/app.toml
vendored
Normal file
19
dist/sortarr/config/app.toml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Host-editable Sortarr configuration. Values here override backend/default-config/app.toml.
|
||||
# Environment variables in .env override common runtime values such as dry-run and intervals.
|
||||
|
||||
[app]
|
||||
dry_run = true
|
||||
scan_interval_seconds = 20
|
||||
settle_seconds = 90
|
||||
log_level = "INFO"
|
||||
library_scan_max_files = 20000
|
||||
library_scan_timeout_seconds = 8
|
||||
|
||||
[theme]
|
||||
default = "slate"
|
||||
allow_custom_css = true
|
||||
custom_css_path = "/config/custom-theme.css"
|
||||
|
||||
[metadata]
|
||||
tmdb_enabled = true
|
||||
tmdb_language = "en-US"
|
||||
6
dist/sortarr/config/custom-theme.css
vendored
Normal file
6
dist/sortarr/config/custom-theme.css
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/* Optional host-editable theme overrides. Loaded by the dashboard when enabled. */
|
||||
:root {
|
||||
/* --bg: #0f1115; */
|
||||
/* --accent: #5cc8ff; */
|
||||
}
|
||||
|
||||
56
dist/sortarr/docker-compose.yaml
vendored
Normal file
56
dist/sortarr/docker-compose.yaml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
container_name: sortarr-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${SORTARR_WEB_PORT:-8088}:80"
|
||||
volumes:
|
||||
- ./web/src:/usr/share/nginx/html:ro
|
||||
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
environment:
|
||||
- TZ=${SORTARR_TZ:-Etc/UTC}
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: sortarr-backend
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-m", "sortarr.healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "${SORTARR_API_PORT:-8099}:8099"
|
||||
volumes:
|
||||
- ${DOWNLOADS_PATH:-./downloads}:/downloads
|
||||
- ${CONFIG_PATH:-./config}:/config
|
||||
- ${LOGS_PATH:-./logs}:/logs
|
||||
- ${DATA_PATH:-./data}:/data
|
||||
- ${DRIVE1_PATH:-./media/drive1}:/media/drive1
|
||||
- ${DRIVE2_PATH:-./media/drive2}:/media/drive2
|
||||
- ${DRIVE3_PATH:-./media/drive3}:/media/drive3
|
||||
- ${DRIVE4_PATH:-./media/drive4}:/media/drive4
|
||||
environment:
|
||||
- TZ=${SORTARR_TZ:-Etc/UTC}
|
||||
- SORTARR_HOST=${SORTARR_HOST:-0.0.0.0}
|
||||
- SORTARR_API_PORT=8099
|
||||
- SORTARR_CONFIG=/config/app.toml
|
||||
- SORTARR_DEFAULT_CONFIG=/app/default-config/app.toml
|
||||
- SORTARR_DATA_DIR=/data
|
||||
- SORTARR_LOG_DIR=/logs
|
||||
- SORTARR_CACHE_DIR=/data/cache
|
||||
- SORTARR_DRY_RUN=${SORTARR_DRY_RUN:-false}
|
||||
- SORTARR_LOG_LEVEL=${SORTARR_LOG_LEVEL:-INFO}
|
||||
- SORTARR_SCAN_INTERVAL_SECONDS=${SORTARR_SCAN_INTERVAL_SECONDS:-20}
|
||||
- SORTARR_SETTLE_SECONDS=${SORTARR_SETTLE_SECONDS:-90}
|
||||
- SORTARR_MIN_FREE_GB=${SORTARR_MIN_FREE_GB:-20}
|
||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||
- TMDB_BEARER_TOKEN=${TMDB_BEARER_TOKEN:-}
|
||||
70
dist/sortarr/docs/api.md
vendored
Normal file
70
dist/sortarr/docs/api.md
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
# API
|
||||
|
||||
All endpoints are served by the backend service and proxied by nginx under `/api`.
|
||||
|
||||
## `GET /api/health`
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
## `GET /api/config`
|
||||
|
||||
Returns public runtime configuration with secrets removed.
|
||||
|
||||
## `GET /api/dashboard`
|
||||
|
||||
Returns JSON state, drive usage, cached library files, cached extension breakdowns, and dry-run status. This endpoint does not scan the full media filesystem.
|
||||
|
||||
## `POST /api/scan`
|
||||
|
||||
Runs one scanner pass immediately. In dry-run mode this only records plans.
|
||||
|
||||
## `POST /api/library/scan`
|
||||
|
||||
Refreshes the cached library index. The scan only enters direct child folders of each media drive named `Movies`, `TV`, or `TV Shows`.
|
||||
|
||||
## `GET /api/downloads`
|
||||
|
||||
Returns current files under `/downloads` plus recent Sortarr plans or moves whose source was under `/downloads`.
|
||||
|
||||
## `GET /api/releases`
|
||||
|
||||
Returns missing/upcoming TV episodes derived from the cached library metadata, then appends any explicitly enabled public release providers.
|
||||
|
||||
## `GET /api/media/probe`
|
||||
|
||||
Runs `ffprobe` for a selected media file under configured media/download roots and returns detected video, audio, and subtitle streams.
|
||||
|
||||
## `POST /api/media/tracks`
|
||||
|
||||
Remuxes a selected media file to set an audio/subtitle stream as default or remove an embedded audio/subtitle stream. In dry-run mode it returns the ffmpeg command without modifying the file.
|
||||
|
||||
## `GET /api/theme/custom.css`
|
||||
|
||||
Serves host-editable custom CSS from `/config/custom-theme.css`.
|
||||
|
||||
## `POST /api/settings`
|
||||
|
||||
Updates runtime settings used by the current backend process. Supported keys:
|
||||
|
||||
- `dry_run`
|
||||
- `scan_interval_seconds`
|
||||
- `settle_seconds`
|
||||
- `library_scan_max_files`
|
||||
- `library_scan_timeout_seconds`
|
||||
- `log_level`
|
||||
|
||||
## `GET /api/tools/subtitles`
|
||||
|
||||
Audits the cached library index for media files missing sidecar subtitles. Run `POST /api/library/scan` first for current subtitle data.
|
||||
|
||||
## `GET /api/tools/transcoder`
|
||||
|
||||
Builds a transcode queue for cached indexed media that is not already `.mp4`.
|
||||
|
||||
## `POST /api/tools/transcoder/run-next`
|
||||
|
||||
Runs the next queued ffmpeg transcode when `dry_run` is disabled. In dry-run mode it reports what would run.
|
||||
251
dist/sortarr/docs/architecture.md
vendored
Normal file
251
dist/sortarr/docs/architecture.md
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
# Sortarr Project Info
|
||||
|
||||
Purpose: self-hosted Jellyfin ecosystem organizer and dashboard, fully editable and Docker Compose runnable. It watches downloads, plans/moves media into Jellyfin-friendly folders across four media drives, displays storage/library/download/release status, and exposes configurable tools such as subtitle audit and ffmpeg transcoding.
|
||||
|
||||
## Runtime
|
||||
|
||||
- Root: `/home/drop/jellyfin/scripts/sortarr`
|
||||
- Web UI: `http://localhost:8088` or host LAN IP on port `8088`
|
||||
- Backend API: port `8099`
|
||||
- Compose files: `compose.yaml`, `compose.override.yaml`, `compose.prod.yaml`
|
||||
- Env file: `.env`
|
||||
- Default dry-run: enabled via `SORTARR_DRY_RUN=true`
|
||||
- Active containers: `sortarr-web`, `sortarr-backend`
|
||||
- Known unrelated/orphan container: `sortarr` may still appear restarting from an older compose shape.
|
||||
|
||||
## Host Paths
|
||||
|
||||
Configured in `.env`:
|
||||
|
||||
- Downloads: `/home/drop/jellyfin/downloads` mounted as `/downloads`
|
||||
- Media drive 1: `/home/drop/jellyfin/mediashare1` mounted as `/media/drive1`
|
||||
- Media drive 2: `/home/drop/jellyfin/mediashare2` mounted as `/media/drive2`
|
||||
- Media drive 3: `/home/drop/jellyfin/mediashare3` mounted as `/media/drive3`
|
||||
- Media drive 4: `/home/drop/jellyfin/mediashare4` mounted as `/media/drive4`
|
||||
- Config: `/home/drop/jellyfin/scripts/sortarr/config`
|
||||
- Logs: `/home/drop/jellyfin/scripts/sortarr/logs`
|
||||
- Data/state: `/home/drop/jellyfin/scripts/sortarr/data`
|
||||
|
||||
## Architecture
|
||||
|
||||
- `web`: nginx serves static HTML/CSS/JS from `web/src` and proxies `/api/*` to backend.
|
||||
- `backend`: Python 3.12 stdlib HTTP API plus background scanner thread. Backend image installs `ffmpeg`.
|
||||
- Optional profiles:
|
||||
- `redis` profile `cache`
|
||||
- `postgres` profile `database`
|
||||
- `media-tools` profile `tools`
|
||||
|
||||
No frontend framework and no backend web framework are used. This is intentional for editability.
|
||||
|
||||
## Important Files
|
||||
|
||||
- `.env.example`: sample deployment variables.
|
||||
- `.env`: real local deployment paths and runtime values. Ignored by git.
|
||||
- `compose.yaml`: main stack.
|
||||
- `compose.override.yaml`: dev bind mounts and debug defaults.
|
||||
- `compose.prod.yaml`: prod restart/dry-run defaults.
|
||||
- `backend/default-config/app.toml`: full default config.
|
||||
- `config/app.toml`: host-editable override config.
|
||||
- `config/custom-theme.css`: host-editable CSS token overrides.
|
||||
- `backend/sortarr/app.py`: API server and route handlers.
|
||||
- `backend/sortarr/config.py`: TOML/env config loading and merging.
|
||||
- `backend/sortarr/scanner.py`: 24/7 downloads scanner thread.
|
||||
- `backend/sortarr/parser.py`: filename media parser.
|
||||
- `backend/sortarr/organizer.py`: destination planning, collision handling, move execution, NFO writing.
|
||||
- `backend/sortarr/storage.py`: drive stats and drive selection.
|
||||
- `backend/sortarr/library.py`: explicit library scan/indexing and Movies/TV collection grouping.
|
||||
- `backend/sortarr/metadata.py`: optional TMDb metadata lookup for covers, summaries, and TV episode lists.
|
||||
- `backend/sortarr/media_probe.py`: safe ffprobe wrapper for audio/subtitle/video stream details.
|
||||
- `backend/sortarr/tools.py`: subtitle audit and transcoder tools.
|
||||
- `backend/sortarr/downloads.py`: current `/downloads` listing and recent moved/planned download history.
|
||||
- `backend/sortarr/releases.py`: free RSS/JSON upcoming release providers.
|
||||
- `backend/sortarr/store.py`: JSON state store in `data/state.json`.
|
||||
- `web/src/index.html`: app shell and page markup.
|
||||
- `web/src/app.js`: hash router, API calls, rendering, settings/tools behavior.
|
||||
- `web/src/styles.css`: layout/design system.
|
||||
- `web/src/themes.css`: 10 editable theme presets.
|
||||
- `docs/*.md`: API/config/operations docs.
|
||||
|
||||
## Configuration Model
|
||||
|
||||
Config precedence:
|
||||
|
||||
1. `backend/default-config/app.toml`
|
||||
2. `config/app.toml`
|
||||
3. `.env` variables passed into Compose
|
||||
4. Runtime settings saved in `data/state.json` under `settings`
|
||||
|
||||
Key config areas:
|
||||
|
||||
- `[app]`: dry-run, scan interval, settle time, log level, extensions, incomplete suffixes, library scan limits, cache size cap.
|
||||
- `[paths]`: downloads/data/logs/cache container paths.
|
||||
- `[[drives]]`: four media drives with id/name/path/min-free-space.
|
||||
- `[library]`: folder and filename templates, collision policy, permissions mode.
|
||||
- `[metadata]`: NFO behavior and optional TMDb credentials/settings.
|
||||
- `[[release_providers]]`: free RSS/JSON providers.
|
||||
- `[theme]`: default theme and custom CSS.
|
||||
|
||||
Runtime Settings page can update:
|
||||
|
||||
- `dry_run`
|
||||
- `scan_interval_seconds`
|
||||
- `settle_seconds`
|
||||
- `library_scan_max_files`
|
||||
- `library_scan_timeout_seconds`
|
||||
- `log_level`
|
||||
|
||||
## Media Organizer Behavior
|
||||
|
||||
Background scanner watches `/downloads` continuously.
|
||||
|
||||
Safety:
|
||||
|
||||
- Ignores incomplete suffixes such as `.part`, `.!qB`, `.tmp`, `.crdownload`.
|
||||
- Requires files to be stable for `settle_seconds`.
|
||||
- Dry-run plans moves without moving.
|
||||
- Actual moves go through a temporary `.sorting` path before final rename.
|
||||
- Collision policies: `keep-both`, `skip`, `replace`.
|
||||
- Events and plans are stored in `data/state.json`.
|
||||
|
||||
Parsing:
|
||||
|
||||
- Detects movies, episodes, seasons, and multi-episode releases.
|
||||
- Recognizes `S01E02`, `S01E02E03`, and `1x02` style episode patterns.
|
||||
- Extracts year and quality tokens where present.
|
||||
|
||||
Drive choice:
|
||||
|
||||
1. Checks whether the title already has a home under `Movies` or `Shows`.
|
||||
2. If no home exists, picks eligible drive with most free space.
|
||||
3. Enforces `min_free_gb`.
|
||||
|
||||
Naming:
|
||||
|
||||
- Movies: `Movies/{title} ({year})/{title} ({year}){quality}{ext}`
|
||||
- Episodes: `Shows/{title}/Season {season:02d}/{title} - SxxExx - Episode{quality}{ext}`
|
||||
- Templates are editable in TOML.
|
||||
|
||||
## Library Indexing
|
||||
|
||||
Regular dashboard refresh does not walk the media filesystem.
|
||||
|
||||
Library indexing is explicit:
|
||||
|
||||
- UI button: Library page -> `Scan library`
|
||||
- API: `POST /api/library/scan`
|
||||
- Scans only direct child folders of each media drive named:
|
||||
- `Movies`
|
||||
- `Shows`
|
||||
- `TV`
|
||||
- `TV Shows`
|
||||
|
||||
The library scanner skips system/recycle folders and has timeout/file-count limits. Results are cached in `data/state.json` and used by dashboard/tools.
|
||||
|
||||
Current cache fields include:
|
||||
|
||||
- drive stats
|
||||
- indexed media items split by `Movies` and `TV`/`TV Shows` roots
|
||||
- collection groups for movies and TV series
|
||||
- optional TMDb posters, overviews, and TV season episode metadata
|
||||
- extension breakdown
|
||||
- scanned file count
|
||||
- truncation flag
|
||||
- per-media `has_subtitles` when available from scan
|
||||
|
||||
## Frontend Pages
|
||||
|
||||
The UI uses hash routing in `web/src/app.js`.
|
||||
|
||||
Routes:
|
||||
|
||||
- `#/overview`: storage, file type breakdown, recent events.
|
||||
- `#/library`: poster grid with All/Movies/TV Shows tabs, series/episode drilldown, missing/upcoming episode state, and media stream inspection.
|
||||
- `#/downloads`: current `/downloads` media bundles with matching subtitles/sidecars plus recent Sortarr plans/moves from `/downloads`.
|
||||
- `#/releases`: missing/upcoming library episodes plus configured public providers.
|
||||
- `#/tools`: transcoder, subtitle audit, duplicate finder placeholder.
|
||||
- `#/settings`: appearance controls, descriptive runtime controls, raw config details.
|
||||
|
||||
Theme system:
|
||||
|
||||
- Theme choices live on the Settings page and persist in `localStorage`.
|
||||
- Compact density toggle persists in `localStorage`.
|
||||
- Presets: `slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`.
|
||||
- Tokens live in `web/src/themes.css`; host overrides in `config/custom-theme.css`.
|
||||
|
||||
## Backend API
|
||||
|
||||
- `GET /api/health`: healthcheck.
|
||||
- `GET /api/config`: public config with secrets removed.
|
||||
- `GET /api/dashboard`: state + cached library + drive stats; no filesystem library scan.
|
||||
- `POST /api/scan`: run one downloads scan now.
|
||||
- `POST /api/library/scan`: refresh cached library index.
|
||||
- `GET /api/downloads`: current `/downloads` files plus recent planned/moved download history.
|
||||
- `GET /api/releases`: upcoming releases.
|
||||
- `GET /api/media/probe`: ffprobe stream details for a selected file.
|
||||
- `POST /api/media/tracks`: dry-run or execute ffmpeg remux track default/removal changes.
|
||||
- `GET /api/theme/custom.css`: custom CSS.
|
||||
- `POST /api/settings`: update runtime settings.
|
||||
- `GET /api/tools/subtitles`: subtitle audit from cached library data.
|
||||
- `GET /api/tools/transcoder`: build ffmpeg transcode queue from cached library.
|
||||
- `POST /api/tools/transcoder/run-next`: run next ffmpeg transcode if dry-run is disabled.
|
||||
|
||||
## Tools
|
||||
|
||||
Subtitle audit:
|
||||
|
||||
- Uses cached library index, not live filesystem probes.
|
||||
- Requires a fresh library scan for accurate `has_subtitles`.
|
||||
- Reports checked count, with-subtitles count, missing count, unknown count, and missing examples.
|
||||
|
||||
Transcoder:
|
||||
|
||||
- Backend image installs `ffmpeg`.
|
||||
- Queue includes cached indexed media not already `.mp4`.
|
||||
- Output path is source path with `.mp4` suffix.
|
||||
- Command uses `libx264`, `aac`, and `mov_text`.
|
||||
- In dry-run mode, `run-next` reports without executing.
|
||||
- With dry-run disabled, runs one job synchronously with a 1 hour timeout.
|
||||
|
||||
Duplicate finder:
|
||||
|
||||
- UI placeholder only at time of writing.
|
||||
|
||||
## Release Providers
|
||||
|
||||
No paid API dependency.
|
||||
|
||||
Bundled providers, disabled by default so the Releases page stays centered on the local library:
|
||||
|
||||
- TMDb RSS upcoming movies.
|
||||
- TVMaze public schedule JSON.
|
||||
|
||||
Provider logic is in `backend/sortarr/releases.py`; add new RSS/JSON adapters there and configure in TOML.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
python -m compileall backend/sortarr
|
||||
node --check web/src/app.js
|
||||
docker compose config
|
||||
docker compose up -d --build
|
||||
docker exec sortarr-backend python -m sortarr.healthcheck
|
||||
docker exec sortarr-backend ffmpeg -version
|
||||
```
|
||||
|
||||
Endpoint checks from inside backend:
|
||||
|
||||
```bash
|
||||
docker exec sortarr-backend python -c "from urllib.request import urlopen; print(urlopen('http://127.0.0.1:8099/api/health').status)"
|
||||
docker exec sortarr-backend python -c "from urllib.request import urlopen; import json; print(json.load(urlopen('http://127.0.0.1:8099/api/tools/transcoder'))['transcoder']['ffmpeg_available'])"
|
||||
```
|
||||
|
||||
## Current Caveats / Next Good Tasks
|
||||
|
||||
- Settings are runtime/persisted in JSON state but not written back into `config/app.toml`.
|
||||
- Transcoding runs synchronously; future improvement should add a job queue with progress/cancel/history.
|
||||
- Duplicate finder is a placeholder.
|
||||
- Subtitle audit only becomes exact after a fresh manual library scan because it relies on cached `has_subtitles`.
|
||||
- Library scan only checks direct child folders named `Movies`, `TV`, or `TV Shows` under each media drive.
|
||||
- Backend is stdlib HTTP server; fine for self-hosting behind LAN/reverse proxy, but add auth before exposing publicly.
|
||||
77
dist/sortarr/docs/configuration.md
vendored
Normal file
77
dist/sortarr/docs/configuration.md
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
# Configuration
|
||||
|
||||
Configuration is layered in this order:
|
||||
|
||||
1. `backend/default-config/app.toml`
|
||||
2. `config/app.toml`
|
||||
3. `.env` variables passed into Docker Compose
|
||||
|
||||
The backend deep-merges TOML files and then applies environment overrides for common deployment values.
|
||||
|
||||
## Organizer Settings
|
||||
|
||||
`[app]`
|
||||
|
||||
- `dry_run`: plan without moving files.
|
||||
- `scan_interval_seconds`: worker polling interval.
|
||||
- `settle_seconds`: minimum file age before processing.
|
||||
- `stable_checks`: reserved for stricter stability policies.
|
||||
- `incomplete_suffixes`: suffixes ignored while downloads are still active.
|
||||
- `media_extensions`: media files eligible for organizing.
|
||||
- `subtitle_extensions`: subtitle files visible to the scanner.
|
||||
- `library_scan_max_files`: maximum files indexed by the manual library scan.
|
||||
- `library_scan_timeout_seconds`: timeout for the manual library scan.
|
||||
- `cache_max_bytes`: maximum server-side cache size. Defaults to 20GB.
|
||||
|
||||
`[library]`
|
||||
|
||||
- `movie_folder`: destination folder template for movies.
|
||||
- `series_folder`: destination folder template for shows.
|
||||
- `movie_file`: Jellyfin-friendly movie filename template.
|
||||
- `episode_file`: Jellyfin-friendly episode filename template.
|
||||
- `collision`: `keep-both`, `skip`, or `replace`.
|
||||
- `duplicate`: reserved duplicate policy hook.
|
||||
- `permissions_mode`: final file mode after a move.
|
||||
|
||||
## Drives
|
||||
|
||||
Each `[[drives]]` entry has:
|
||||
|
||||
- `id`: stable machine name.
|
||||
- `name`: dashboard display name.
|
||||
- `path`: mounted drive path inside the container.
|
||||
- `min_free_gb`: minimum free space required before the drive is eligible.
|
||||
|
||||
Drive selection first checks whether the title already has a home under `Movies` or `Shows`. If not, it selects the eligible drive with the most free space.
|
||||
|
||||
## Themes
|
||||
|
||||
Bundled presets live in `web/src/themes.css`. The current presets are:
|
||||
|
||||
`slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`.
|
||||
|
||||
Runtime custom CSS is loaded from `/config/custom-theme.css` when `[theme].allow_custom_css` is enabled. Override any token:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--accent: #5cc8ff;
|
||||
--radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
## Release Providers
|
||||
|
||||
`[[release_providers]]` supports pluggable free sources:
|
||||
|
||||
- `type = "rss"` for RSS/Atom-style feeds.
|
||||
- `type = "json"` for simple public JSON endpoints.
|
||||
|
||||
Provider code is isolated in `backend/sortarr/releases.py` so new adapters can be added without touching the UI.
|
||||
|
||||
## TMDb Metadata
|
||||
|
||||
Set `TMDB_API_KEY` or `TMDB_BEARER_TOKEN` in `.env` to enrich manual library scans with TMDb posters, overviews, release dates, and TV season episode data. Without credentials, Sortarr still groups local media and shows placeholder covers.
|
||||
|
||||
## Server Cache
|
||||
|
||||
Sortarr stores reusable TMDb and ffprobe results under `/data/cache`. The default cache cap is 20GB via `[app].cache_max_bytes`; older cache files are pruned when new cache entries are written.
|
||||
62
dist/sortarr/docs/operations.md
vendored
Normal file
62
dist/sortarr/docs/operations.md
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
# Operations
|
||||
|
||||
## Dry Run First
|
||||
|
||||
Keep this in `.env` until destination paths look correct:
|
||||
|
||||
```bash
|
||||
SORTARR_DRY_RUN=true
|
||||
```
|
||||
|
||||
Then switch to:
|
||||
|
||||
```bash
|
||||
SORTARR_DRY_RUN=false
|
||||
```
|
||||
|
||||
Restart:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Backend logs are written to `/logs/sortarr.log` in the container and to the host path configured by `LOGS_PATH`.
|
||||
|
||||
## Backups
|
||||
|
||||
Back up:
|
||||
|
||||
- `.env`
|
||||
- `config/`
|
||||
- `data/state.json`
|
||||
- `logs/` if you need historical audit trails
|
||||
|
||||
Media files are not stored inside containers.
|
||||
|
||||
## Updating
|
||||
|
||||
Because all source is mounted or copied from this project, update by editing files and rebuilding:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Transcoding
|
||||
|
||||
The backend image includes `ffmpeg`. The dashboard Tools page can build a queue from the cached library index and run the next conversion. Keep dry-run enabled while checking output paths; actual transcoding only runs when `SORTARR_DRY_RUN=false` or dry-run is disabled from the runtime Settings page.
|
||||
|
||||
## Track Editing
|
||||
|
||||
The Library detail panel can inspect a selected file with `ffprobe` and remux embedded audio/subtitle streams to set defaults or remove tracks. Dry-run mode returns the planned `ffmpeg` command only. Disable dry-run only after confirming the command and keep media backups for any bulk edits.
|
||||
|
||||
## Cache
|
||||
|
||||
Reusable metadata and ffprobe results are cached under `/data/cache`. The default cap is 20GB and pruning removes oldest cache files first.
|
||||
|
||||
## Recovery
|
||||
|
||||
Sortarr moves through a temporary `.sorting` file before final placement. If a container stops mid-move, check the destination folder for `*.sorting` files and compare against `/downloads`.
|
||||
|
||||
The app intentionally avoids deleting source folders and does not run destructive cleanup by default.
|
||||
4
dist/sortarr/web/Dockerfile
vendored
Normal file
4
dist/sortarr/web/Dockerfile
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM nginx:1.27-alpine
|
||||
COPY src /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
20
dist/sortarr/web/nginx.conf
vendored
Normal file
20
dist/sortarr/web/nginx.conf
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8099/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
1006
dist/sortarr/web/src/app.js
vendored
Normal file
1006
dist/sortarr/web/src/app.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
158
dist/sortarr/web/src/index.html
vendored
Normal file
158
dist/sortarr/web/src/index.html
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
<!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">×</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>
|
||||
822
dist/sortarr/web/src/styles.css
vendored
Normal file
822
dist/sortarr/web/src/styles.css
vendored
Normal file
@@ -0,0 +1,822 @@
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: calc(15px - (var(--compact, 0) * 1px));
|
||||
}
|
||||
.app-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: calc(20px * var(--density));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; margin-bottom: 28px; }
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 800;
|
||||
}
|
||||
.brand small, #statusLine, .muted { color: var(--muted); }
|
||||
nav { display: grid; gap: 6px; }
|
||||
nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
nav a.active, nav a:hover { background: var(--surface-2); color: var(--text); }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
select, input, button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:disabled { cursor: wait; opacity: .62; }
|
||||
main { padding: 24px; display: grid; gap: 24px; align-content: start; }
|
||||
.topbar, .section-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { font-size: 28px; }
|
||||
h2 { font-size: 17px; }
|
||||
h3 { font-size: 14px; color: var(--muted); font-weight: 700; }
|
||||
.actions { display: flex; gap: 10px; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.overview-grid { grid-template-columns: 1.3fr 1fr 1fr; }
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: calc(18px * var(--density));
|
||||
}
|
||||
.storage-list, .event-list, .download-list, .bars { display: grid; gap: 12px; margin-top: 16px; }
|
||||
.storage-card { display: grid; gap: 8px; }
|
||||
.meter { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; }
|
||||
.meter span { display: block; height: 100%; background: var(--accent); }
|
||||
.kv { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; }
|
||||
.bar-row { display: grid; grid-template-columns: 72px 1fr 44px; gap: 10px; align-items: center; }
|
||||
.event { border-left: 3px solid var(--accent); padding-left: 10px; color: var(--muted); }
|
||||
.event.error { border-color: var(--bad); }
|
||||
.segmented {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.segmented button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.segmented button.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
|
||||
}
|
||||
.segmented span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.table-wrap { overflow: auto; margin-top: 16px; max-height: 68vh; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
td:first-child {
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.download, .release {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.download.warning { border-color: var(--warn); }
|
||||
.poster-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.poster-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.poster-card.active .poster,
|
||||
.poster-card:hover .poster {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.poster {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.poster-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--surface-2), var(--surface));
|
||||
color: var(--accent);
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.poster-card strong,
|
||||
.poster-card small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.poster-card small { color: var(--muted); }
|
||||
.media-detail {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.detail-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 22px;
|
||||
}
|
||||
.detail-poster {
|
||||
align-self: start;
|
||||
}
|
||||
.detail-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-block,
|
||||
.season-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.season-list details {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.season-list summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.episode-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.episode {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--good);
|
||||
}
|
||||
.episode.missing { border-left-color: var(--bad); }
|
||||
.episode.upcoming { border-left-color: var(--warn); }
|
||||
.episode p {
|
||||
margin-top: 5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.episode-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.probe-output {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.stream-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.stream-grid section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 12px;
|
||||
}
|
||||
.stream-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.track-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.track-actions button {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.downloads-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.downloads-layout article { min-width: 0; }
|
||||
.queue-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.queue-summary span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.queue-summary strong {
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
}
|
||||
.download small, .download span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.download.bundle {
|
||||
background: var(--surface);
|
||||
}
|
||||
.bundle-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.bundle-head div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.subtitle-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.subtitle-chips span {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.download.loose {
|
||||
opacity: .82;
|
||||
}
|
||||
.organizer-card {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.organizer-card.needs-review,
|
||||
.organizer-card.dry-run {
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
.organizer-card.low-confidence,
|
||||
.organizer-card.skipped {
|
||||
border-left-color: var(--bad);
|
||||
}
|
||||
.organizer-card.moved {
|
||||
border-left-color: var(--good);
|
||||
}
|
||||
.confidence {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.confidence.good { color: var(--good); }
|
||||
.confidence.warn { color: var(--warn); }
|
||||
.confidence.bad { color: var(--bad); }
|
||||
.plan-paths {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.plan-paths small {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.plan-paths b {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.subtitle-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plan-actions button {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.release-grid, .tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.release img {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.release.missing { border-color: var(--bad); }
|
||||
.release.upcoming { border-color: var(--warn); }
|
||||
.release a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.tool-output { margin-top: 18px; display: grid; gap: 12px; }
|
||||
.tool-output h3 { margin: 0; font-size: 15px; }
|
||||
code {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
}
|
||||
.settings-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, .45fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.settings-hero h3 { margin: 0 0 6px; }
|
||||
.settings-notice {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.settings-notice:not(:empty) { display: block; }
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
max-width: 1180px;
|
||||
}
|
||||
.settings-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.settings-card-head::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.settings-card-head h3 {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
.settings-card-head p {
|
||||
margin: 0;
|
||||
}
|
||||
.settings-card-head > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(260px, 520px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
padding: 16px;
|
||||
}
|
||||
.setting-row:last-child { border-bottom: 0; }
|
||||
.setting-rich {
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
}
|
||||
.setting-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 620px;
|
||||
}
|
||||
.setting-copy > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.setting-copy small,
|
||||
.setting-rich small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.setting-path {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.setting-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.setting-control.wide {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.setting-row input[type="number"], .setting-row select { width: 132px; }
|
||||
.setting-row input[type="text"],
|
||||
.setting-row input[type="password"],
|
||||
.setting-row textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 10px;
|
||||
}
|
||||
.setting-row textarea {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
.setting-row input[type="checkbox"] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-self: center;
|
||||
}
|
||||
.switch {
|
||||
justify-self: end;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
}
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
.switch span {
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
}
|
||||
.switch span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: transform .15s ease, background .15s ease;
|
||||
}
|
||||
.switch input:checked + span {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--surface-2));
|
||||
}
|
||||
.switch input:checked + span::after {
|
||||
transform: translateX(20px);
|
||||
background: var(--accent);
|
||||
}
|
||||
.range-control {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.range-control input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.range-control span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.compound-control {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.compound-control input,
|
||||
.compound-control select {
|
||||
min-width: 0;
|
||||
}
|
||||
.compound-control label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.compound-control .inline-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.compound-control .span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
max-width: 980px;
|
||||
}
|
||||
.theme-option {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.theme-option.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
.theme-swatch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
width: 42px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
.theme-swatch i,
|
||||
.theme-swatch b,
|
||||
.theme-swatch em {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.theme-swatch i { background: var(--surface); }
|
||||
.theme-swatch b { background: var(--surface-2); }
|
||||
.theme-swatch em {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--accent);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: min(420px, calc(100vw - 36px));
|
||||
}
|
||||
.toast {
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
.toast.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.toast.success { border-left-color: var(--good); }
|
||||
.toast.error { border-left-color: var(--bad); }
|
||||
@media (max-width: 900px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.overview-grid { grid-template-columns: 1fr; }
|
||||
.downloads-layout { grid-template-columns: 1fr; }
|
||||
.detail-shell { grid-template-columns: 1fr; }
|
||||
.detail-poster { max-width: 220px; }
|
||||
.episode { grid-template-columns: 1fr; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.actions, .pager { flex-wrap: wrap; }
|
||||
.settings-hero { grid-template-columns: 1fr; }
|
||||
.settings-card-head { flex-direction: column; }
|
||||
.setting-row { grid-template-columns: 1fr; gap: 14px; }
|
||||
.range-control { min-width: 0; }
|
||||
.setting-row input[type="text"],
|
||||
.setting-row input[type="password"],
|
||||
.setting-row textarea,
|
||||
.compound-control {
|
||||
width: 100%;
|
||||
}
|
||||
.compound-control { grid-template-columns: 1fr; }
|
||||
.bundle-head { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal.active { display: flex; align-items: center; justify-content: center; }
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal-shell {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.42);
|
||||
animation: modal-slide 0.24s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
@keyframes modal-slide {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: var(--surface-2);
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
font-size: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.modal-content {
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.badge.accent { background: var(--accent); color: var(--bg); border: none; }
|
||||
|
||||
/* Multi-version Card Indicator */
|
||||
.poster-card { position: relative; }
|
||||
.card-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 5;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Track actions enhanced */
|
||||
.track-actions { display: flex; gap: 6px; margin-top: 4px; }
|
||||
.track-actions button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.track-actions button:hover { border-color: var(--accent); }
|
||||
|
||||
.track-actions button.danger:hover { border-color: var(--bad); color: var(--bad); }
|
||||
.stream-row { padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.stream-row:last-child { border-bottom: none; }
|
||||
.circle-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.identify-panel { margin: 16px 0; background: var(--surface-2); }
|
||||
.identify-results { display: grid; gap: 8px; margin-top: 16px; max-height: 300px; overflow-y: auto; }
|
||||
.identify-result { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.mini-poster { width: 60px; border-radius: 4px; flex-shrink: 0; }
|
||||
.small { font-size: 12px; }
|
||||
134
dist/sortarr/web/src/themes.css
vendored
Normal file
134
dist/sortarr/web/src/themes.css
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
:root,
|
||||
[data-theme="slate"] {
|
||||
--bg: #111318;
|
||||
--surface: #191d24;
|
||||
--surface-2: #222833;
|
||||
--text: #eef2f7;
|
||||
--muted: #96a1af;
|
||||
--border: #303846;
|
||||
--accent: #60a5fa;
|
||||
--good: #34d399;
|
||||
--warn: #fbbf24;
|
||||
--bad: #f87171;
|
||||
--radius: 8px;
|
||||
--density: 1;
|
||||
--font: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="midnight"] {
|
||||
--bg: #080b12;
|
||||
--surface: #121826;
|
||||
--surface-2: #1b2740;
|
||||
--text: #f8fafc;
|
||||
--muted: #93a4bd;
|
||||
--border: #293550;
|
||||
--accent: #22d3ee;
|
||||
--good: #4ade80;
|
||||
--warn: #facc15;
|
||||
--bad: #fb7185;
|
||||
}
|
||||
|
||||
[data-theme="graphite"] {
|
||||
--bg: #151515;
|
||||
--surface: #202020;
|
||||
--surface-2: #2b2b2b;
|
||||
--text: #f5f5f5;
|
||||
--muted: #b2b2b2;
|
||||
--border: #3a3a3a;
|
||||
--accent: #a3e635;
|
||||
--good: #86efac;
|
||||
--warn: #fde047;
|
||||
--bad: #fca5a5;
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
--bg: #202632;
|
||||
--surface: #2c3444;
|
||||
--surface-2: #374155;
|
||||
--text: #eceff4;
|
||||
--muted: #c0c9d8;
|
||||
--border: #4c566a;
|
||||
--accent: #88c0d0;
|
||||
--good: #a3be8c;
|
||||
--warn: #ebcb8b;
|
||||
--bad: #bf616a;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
--bg: #1d1b26;
|
||||
--surface: #282a36;
|
||||
--surface-2: #343746;
|
||||
--text: #f8f8f2;
|
||||
--muted: #c7bfdc;
|
||||
--border: #44475a;
|
||||
--accent: #bd93f9;
|
||||
--good: #50fa7b;
|
||||
--warn: #f1fa8c;
|
||||
--bad: #ff5555;
|
||||
}
|
||||
|
||||
[data-theme="solar"] {
|
||||
--bg: #f4f0df;
|
||||
--surface: #fffaf0;
|
||||
--surface-2: #eee8d5;
|
||||
--text: #273238;
|
||||
--muted: #657b83;
|
||||
--border: #d5cdb6;
|
||||
--accent: #268bd2;
|
||||
--good: #2aa198;
|
||||
--warn: #b58900;
|
||||
--bad: #dc322f;
|
||||
}
|
||||
|
||||
[data-theme="forest"] {
|
||||
--bg: #101812;
|
||||
--surface: #18251b;
|
||||
--surface-2: #213326;
|
||||
--text: #eef7ed;
|
||||
--muted: #a7b9a6;
|
||||
--border: #314638;
|
||||
--accent: #7ddf64;
|
||||
--good: #22c55e;
|
||||
--warn: #eab308;
|
||||
--bad: #ef4444;
|
||||
}
|
||||
|
||||
[data-theme="marine"] {
|
||||
--bg: #081417;
|
||||
--surface: #102225;
|
||||
--surface-2: #183236;
|
||||
--text: #edfdfd;
|
||||
--muted: #9fc5c7;
|
||||
--border: #28494e;
|
||||
--accent: #2dd4bf;
|
||||
--good: #5eead4;
|
||||
--warn: #fcd34d;
|
||||
--bad: #f97373;
|
||||
}
|
||||
|
||||
[data-theme="ember"] {
|
||||
--bg: #171111;
|
||||
--surface: #241818;
|
||||
--surface-2: #362221;
|
||||
--text: #fff7ed;
|
||||
--muted: #d7b6a2;
|
||||
--border: #513530;
|
||||
--accent: #fb923c;
|
||||
--good: #84cc16;
|
||||
--warn: #facc15;
|
||||
--bad: #f43f5e;
|
||||
}
|
||||
|
||||
[data-theme="paper"] {
|
||||
--bg: #f7f8fa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef1f5;
|
||||
--text: #151a22;
|
||||
--muted: #5f6b7a;
|
||||
--border: #d6dce5;
|
||||
--accent: #2563eb;
|
||||
--good: #16a34a;
|
||||
--warn: #ca8a04;
|
||||
--bad: #dc2626;
|
||||
}
|
||||
|
||||
73
docs/api.md
Normal file
73
docs/api.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# API
|
||||
|
||||
All endpoints are served by the backend service and proxied by nginx under `/api`.
|
||||
|
||||
## `GET /api/health`
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
## `GET /api/config`
|
||||
|
||||
Returns public runtime configuration with secrets removed.
|
||||
|
||||
## `GET /api/dashboard`
|
||||
|
||||
Returns JSON state, drive usage, cached library files, cached extension breakdowns, and dry-run status. This endpoint does not scan the full media filesystem.
|
||||
|
||||
## `POST /api/scan`
|
||||
|
||||
Runs one scanner pass immediately. In dry-run mode this only records plans.
|
||||
|
||||
## `POST /api/library/scan`
|
||||
|
||||
Refreshes the cached library index. The scan only enters direct child folders of each media drive named `Movies`, `TV`, or `TV Shows`.
|
||||
|
||||
## `GET /api/library`
|
||||
|
||||
Returns the cached library summary and grouped movie/series collections for the Library page. Raw indexed file items stay server-side to keep routine dashboard refreshes small.
|
||||
|
||||
## `GET /api/downloads`
|
||||
|
||||
Returns current files under `/downloads` plus recent Sortarr plans or moves whose source was under `/downloads`.
|
||||
|
||||
## `GET /api/releases`
|
||||
|
||||
Returns missing/upcoming TV episodes derived from the cached library metadata, then appends any explicitly enabled public release providers.
|
||||
|
||||
## `GET /api/media/probe`
|
||||
|
||||
Runs `ffprobe` for a selected media file under configured media/download roots and returns detected video, audio, and subtitle streams.
|
||||
|
||||
## `POST /api/media/tracks`
|
||||
|
||||
Remuxes a selected media file to set an audio/subtitle stream as default or remove an embedded audio/subtitle stream. In dry-run mode it returns the ffmpeg command without modifying the file.
|
||||
|
||||
## `GET /api/theme/custom.css`
|
||||
|
||||
Serves host-editable custom CSS from `/config/custom-theme.css`.
|
||||
|
||||
## `POST /api/settings`
|
||||
|
||||
Updates runtime settings used by the current backend process. Settings can be sent as nested sections such as `{"app": {"dry_run": true}}` or as legacy top-level app keys.
|
||||
|
||||
Supported sections include `app`, `paths`, `library`, `metadata`, `theme`, `drives`, and `release_providers`. Runtime settings are saved in `/data/state.json`; they are not written back into TOML.
|
||||
|
||||
## `GET /api/tools/subtitles`
|
||||
|
||||
Audits the cached library index for media files missing sidecar subtitles. Run `POST /api/library/scan` first for current subtitle data.
|
||||
|
||||
## `GET /api/tools/transcoder`
|
||||
|
||||
Builds a transcode queue for cached indexed media that is not already `.mp4`.
|
||||
|
||||
## `POST /api/tools/transcoder/run-next`
|
||||
|
||||
Runs the next queued ffmpeg transcode when `dry_run` is disabled. In dry-run mode it reports what would run.
|
||||
|
||||
## `GET /api/tools/duplicates`
|
||||
|
||||
Reports duplicate movie or series groups from the cached library index.
|
||||
79
docs/configuration.md
Normal file
79
docs/configuration.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Configuration
|
||||
|
||||
Configuration is layered in this order:
|
||||
|
||||
1. `backend/default-config/app.toml`
|
||||
2. `config/app.toml`
|
||||
3. `.env` variables passed into Docker Compose
|
||||
|
||||
The backend deep-merges TOML files and then applies environment overrides for common deployment values.
|
||||
|
||||
## Organizer Settings
|
||||
|
||||
`[app]`
|
||||
|
||||
- `dry_run`: plan without moving files.
|
||||
- `scan_interval_seconds`: worker polling interval.
|
||||
- `settle_seconds`: minimum file age before processing.
|
||||
- `stable_checks`: number of matching size/mtime observations required before a file is considered stable.
|
||||
- `incomplete_suffixes`: suffixes ignored while downloads are still active.
|
||||
- `media_extensions`: media files eligible for organizing.
|
||||
- `subtitle_extensions`: subtitle files visible to the scanner.
|
||||
- `extra_keywords`: filename terms ignored by the organizer, such as samples and trailers.
|
||||
- `library_scan_max_files`: maximum files indexed by the manual library scan.
|
||||
- `library_scan_timeout_seconds`: timeout for the manual library scan.
|
||||
- `cache_max_bytes`: maximum server-side cache size. Defaults to 20GB.
|
||||
|
||||
`[library]`
|
||||
|
||||
- `movie_folder`: destination folder template for movies.
|
||||
- `series_folder`: destination folder template for shows.
|
||||
- `movie_file`: Jellyfin-friendly movie filename template.
|
||||
- `episode_file`: Jellyfin-friendly episode filename template.
|
||||
- `collision`: `keep-both`, `skip`, or `replace`.
|
||||
- `duplicate`: reserved duplicate policy hook.
|
||||
- `permissions_mode`: final file mode after a move.
|
||||
- `directory_mode`: directory mode applied to created destination folders.
|
||||
|
||||
## Drives
|
||||
|
||||
Each `[[drives]]` entry has:
|
||||
|
||||
- `id`: stable machine name.
|
||||
- `name`: dashboard display name.
|
||||
- `path`: mounted drive path inside the container.
|
||||
- `min_free_gb`: minimum free space required before the drive is eligible.
|
||||
|
||||
Drive selection first checks whether the title already has a home under `Movies` or `Shows`. If not, it selects the eligible drive with the most free space.
|
||||
|
||||
## Themes
|
||||
|
||||
Bundled presets live in `web/src/themes.css`. The current presets are:
|
||||
|
||||
`slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`.
|
||||
|
||||
Runtime custom CSS is loaded from `/config/custom-theme.css` when `[theme].allow_custom_css` is enabled. Override any token:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--accent: #5cc8ff;
|
||||
--radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
## Release Providers
|
||||
|
||||
`[[release_providers]]` supports pluggable free sources:
|
||||
|
||||
- `type = "rss"` for RSS/Atom-style feeds.
|
||||
- `type = "json"` for simple public JSON endpoints.
|
||||
|
||||
Provider code is isolated in `backend/sortarr/releases.py` so new adapters can be added without touching the UI.
|
||||
|
||||
## TMDb Metadata
|
||||
|
||||
Set `TMDB_API_KEY` or `TMDB_BEARER_TOKEN` in `.env` to enrich manual library scans with TMDb posters, overviews, release dates, and TV season episode data. Without credentials, Sortarr still groups local media and shows placeholder covers.
|
||||
|
||||
## Server Cache
|
||||
|
||||
Sortarr stores reusable TMDb and ffprobe results under `/data/cache`. The default cache cap is 20GB via `[app].cache_max_bytes`; older cache files are pruned when new cache entries are written.
|
||||
62
docs/operations.md
Normal file
62
docs/operations.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Operations
|
||||
|
||||
## Dry Run First
|
||||
|
||||
Keep this in `.env` until destination paths look correct:
|
||||
|
||||
```bash
|
||||
SORTARR_DRY_RUN=true
|
||||
```
|
||||
|
||||
Then switch to:
|
||||
|
||||
```bash
|
||||
SORTARR_DRY_RUN=false
|
||||
```
|
||||
|
||||
Restart:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Backend logs are written to `/logs/sortarr.log` in the container and to the host path configured by `LOGS_PATH`.
|
||||
|
||||
## Backups
|
||||
|
||||
Back up:
|
||||
|
||||
- `.env`
|
||||
- `config/`
|
||||
- `data/state.json`
|
||||
- `logs/` if you need historical audit trails
|
||||
|
||||
Media files are not stored inside containers.
|
||||
|
||||
## Updating
|
||||
|
||||
Because all source is mounted or copied from this project, update by editing files and rebuilding:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Transcoding
|
||||
|
||||
The backend image includes `ffmpeg`. The dashboard Tools page can build a queue from the cached library index and run the next conversion. Keep dry-run enabled while checking output paths; actual transcoding only runs when `SORTARR_DRY_RUN=false` or dry-run is disabled from the runtime Settings page.
|
||||
|
||||
## Track Editing
|
||||
|
||||
The Library detail panel can inspect a selected file with `ffprobe` and remux embedded audio/subtitle streams to set defaults or remove tracks. Dry-run mode returns the planned `ffmpeg` command only. Disable dry-run only after confirming the command and keep media backups for any bulk edits.
|
||||
|
||||
## Cache
|
||||
|
||||
Reusable metadata and ffprobe results are cached under `/data/cache`. The default cap is 20GB and pruning removes oldest cache files first.
|
||||
|
||||
## Recovery
|
||||
|
||||
Sortarr moves through a temporary `.sorting` file before final placement. If a container stops mid-move, check the destination folder for `*.sorting` files and compare against `/downloads`.
|
||||
|
||||
The app intentionally avoids deleting source folders and does not run destructive cleanup by default.
|
||||
251
proj-info.md
Normal file
251
proj-info.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Sortarr Project Info
|
||||
|
||||
Purpose: self-hosted Jellyfin ecosystem organizer and dashboard, fully editable and Docker Compose runnable. It watches downloads, plans/moves media into Jellyfin-friendly folders across four media drives, displays storage/library/download/release status, and exposes configurable tools such as subtitle audit and ffmpeg transcoding.
|
||||
|
||||
## Runtime
|
||||
|
||||
- Root: `/home/drop/jellyfin/scripts/sortarr`
|
||||
- Web UI: `http://localhost:8088` or host LAN IP on port `8088`
|
||||
- Backend API: port `8099`
|
||||
- Compose files: `compose.yaml`, `compose.override.yaml`, `compose.prod.yaml`
|
||||
- Env file: `.env`
|
||||
- Default dry-run: enabled via `SORTARR_DRY_RUN=true`
|
||||
- Active containers: `sortarr-web`, `sortarr-backend`
|
||||
- Known unrelated/orphan container: `sortarr` may still appear restarting from an older compose shape.
|
||||
|
||||
## Host Paths
|
||||
|
||||
Configured in `.env`:
|
||||
|
||||
- Downloads: `/home/drop/jellyfin/downloads` mounted as `/downloads`
|
||||
- Media drive 1: `/home/drop/jellyfin/mediashare1` mounted as `/media/drive1`
|
||||
- Media drive 2: `/home/drop/jellyfin/mediashare2` mounted as `/media/drive2`
|
||||
- Media drive 3: `/home/drop/jellyfin/mediashare3` mounted as `/media/drive3`
|
||||
- Media drive 4: `/home/drop/jellyfin/mediashare4` mounted as `/media/drive4`
|
||||
- Config: `/home/drop/jellyfin/scripts/sortarr/config`
|
||||
- Logs: `/home/drop/jellyfin/scripts/sortarr/logs`
|
||||
- Data/state: `/home/drop/jellyfin/scripts/sortarr/data`
|
||||
|
||||
## Architecture
|
||||
|
||||
- `web`: nginx serves static HTML/CSS/JS from `web/src` and proxies `/api/*` to backend.
|
||||
- `backend`: Python 3.12 stdlib HTTP API plus background scanner thread. Backend image installs `ffmpeg`.
|
||||
- Optional profiles:
|
||||
- `redis` profile `cache`
|
||||
- `postgres` profile `database`
|
||||
- `media-tools` profile `tools`
|
||||
|
||||
No frontend framework and no backend web framework are used. This is intentional for editability.
|
||||
|
||||
## Important Files
|
||||
|
||||
- `.env.example`: sample deployment variables.
|
||||
- `.env`: real local deployment paths and runtime values. Ignored by git.
|
||||
- `compose.yaml`: main stack.
|
||||
- `compose.override.yaml`: dev bind mounts and debug defaults.
|
||||
- `compose.prod.yaml`: prod restart/dry-run defaults.
|
||||
- `backend/default-config/app.toml`: full default config.
|
||||
- `config/app.toml`: host-editable override config.
|
||||
- `config/custom-theme.css`: host-editable CSS token overrides.
|
||||
- `backend/sortarr/app.py`: API server and route handlers.
|
||||
- `backend/sortarr/config.py`: TOML/env config loading and merging.
|
||||
- `backend/sortarr/scanner.py`: 24/7 downloads scanner thread.
|
||||
- `backend/sortarr/parser.py`: filename media parser.
|
||||
- `backend/sortarr/organizer.py`: destination planning, collision handling, move execution, NFO writing.
|
||||
- `backend/sortarr/storage.py`: drive stats and drive selection.
|
||||
- `backend/sortarr/library.py`: explicit library scan/indexing and Movies/TV collection grouping.
|
||||
- `backend/sortarr/metadata.py`: optional TMDb metadata lookup for covers, summaries, and TV episode lists.
|
||||
- `backend/sortarr/media_probe.py`: safe ffprobe wrapper for audio/subtitle/video stream details.
|
||||
- `backend/sortarr/tools.py`: subtitle audit and transcoder tools.
|
||||
- `backend/sortarr/downloads.py`: current `/downloads` listing and recent moved/planned download history.
|
||||
- `backend/sortarr/releases.py`: free RSS/JSON upcoming release providers.
|
||||
- `backend/sortarr/store.py`: JSON state store in `data/state.json`.
|
||||
- `web/src/index.html`: app shell and page markup.
|
||||
- `web/src/app.js`: hash router, API calls, rendering, settings/tools behavior.
|
||||
- `web/src/styles.css`: layout/design system.
|
||||
- `web/src/themes.css`: 10 editable theme presets.
|
||||
- `docs/*.md`: API/config/operations docs.
|
||||
|
||||
## Configuration Model
|
||||
|
||||
Config precedence:
|
||||
|
||||
1. `backend/default-config/app.toml`
|
||||
2. `config/app.toml`
|
||||
3. `.env` variables passed into Compose
|
||||
4. Runtime settings saved in `data/state.json` under `settings`
|
||||
|
||||
Key config areas:
|
||||
|
||||
- `[app]`: dry-run, scan interval, settle time, log level, extensions, incomplete suffixes, library scan limits, cache size cap.
|
||||
- `[paths]`: downloads/data/logs/cache container paths.
|
||||
- `[[drives]]`: four media drives with id/name/path/min-free-space.
|
||||
- `[library]`: folder and filename templates, collision policy, permissions mode.
|
||||
- `[metadata]`: NFO behavior and optional TMDb credentials/settings.
|
||||
- `[[release_providers]]`: free RSS/JSON providers.
|
||||
- `[theme]`: default theme and custom CSS.
|
||||
|
||||
Runtime Settings page can update:
|
||||
|
||||
- `dry_run`
|
||||
- `scan_interval_seconds`
|
||||
- `settle_seconds`
|
||||
- `library_scan_max_files`
|
||||
- `library_scan_timeout_seconds`
|
||||
- `log_level`
|
||||
|
||||
## Media Organizer Behavior
|
||||
|
||||
Background scanner watches `/downloads` continuously.
|
||||
|
||||
Safety:
|
||||
|
||||
- Ignores incomplete suffixes such as `.part`, `.!qB`, `.tmp`, `.crdownload`.
|
||||
- Requires files to be stable for `settle_seconds`.
|
||||
- Dry-run plans moves without moving.
|
||||
- Actual moves go through a temporary `.sorting` path before final rename.
|
||||
- Collision policies: `keep-both`, `skip`, `replace`.
|
||||
- Events and plans are stored in `data/state.json`.
|
||||
|
||||
Parsing:
|
||||
|
||||
- Detects movies, episodes, seasons, and multi-episode releases.
|
||||
- Recognizes `S01E02`, `S01E02E03`, and `1x02` style episode patterns.
|
||||
- Extracts year and quality tokens where present.
|
||||
|
||||
Drive choice:
|
||||
|
||||
1. Checks whether the title already has a home under `Movies` or `Shows`.
|
||||
2. If no home exists, picks eligible drive with most free space.
|
||||
3. Enforces `min_free_gb`.
|
||||
|
||||
Naming:
|
||||
|
||||
- Movies: `Movies/{title} ({year})/{title} ({year}){quality}{ext}`
|
||||
- Episodes: `Shows/{title}/Season {season:02d}/{title} - SxxExx - Episode{quality}{ext}`
|
||||
- Templates are editable in TOML.
|
||||
|
||||
## Library Indexing
|
||||
|
||||
Regular dashboard refresh does not walk the media filesystem.
|
||||
|
||||
Library indexing is explicit:
|
||||
|
||||
- UI button: Library page -> `Scan library`
|
||||
- API: `POST /api/library/scan`
|
||||
- Scans only direct child folders of each media drive named:
|
||||
- `Movies`
|
||||
- `Shows`
|
||||
- `TV`
|
||||
- `TV Shows`
|
||||
|
||||
The library scanner skips system/recycle folders and has timeout/file-count limits. Results are cached in `data/state.json` and used by dashboard/tools.
|
||||
|
||||
Current cache fields include:
|
||||
|
||||
- drive stats
|
||||
- indexed media items split by `Movies` and `TV`/`TV Shows` roots
|
||||
- collection groups for movies and TV series
|
||||
- optional TMDb posters, overviews, and TV season episode metadata
|
||||
- extension breakdown
|
||||
- scanned file count
|
||||
- truncation flag
|
||||
- per-media `has_subtitles` when available from scan
|
||||
|
||||
## Frontend Pages
|
||||
|
||||
The UI uses hash routing in `web/src/app.js`.
|
||||
|
||||
Routes:
|
||||
|
||||
- `#/overview`: storage, file type breakdown, recent events.
|
||||
- `#/library`: poster grid with All/Movies/TV Shows tabs, series/episode drilldown, missing/upcoming episode state, and media stream inspection.
|
||||
- `#/downloads`: current `/downloads` media bundles with matching subtitles/sidecars plus recent Sortarr plans/moves from `/downloads`.
|
||||
- `#/releases`: missing/upcoming library episodes plus configured public providers.
|
||||
- `#/tools`: transcoder, subtitle audit, duplicate finder placeholder.
|
||||
- `#/settings`: appearance controls, descriptive runtime controls, raw config details.
|
||||
|
||||
Theme system:
|
||||
|
||||
- Theme choices live on the Settings page and persist in `localStorage`.
|
||||
- Compact density toggle persists in `localStorage`.
|
||||
- Presets: `slate`, `midnight`, `graphite`, `nord`, `dracula`, `solar`, `forest`, `marine`, `ember`, `paper`.
|
||||
- Tokens live in `web/src/themes.css`; host overrides in `config/custom-theme.css`.
|
||||
|
||||
## Backend API
|
||||
|
||||
- `GET /api/health`: healthcheck.
|
||||
- `GET /api/config`: public config with secrets removed.
|
||||
- `GET /api/dashboard`: state + cached library + drive stats; no filesystem library scan.
|
||||
- `POST /api/scan`: run one downloads scan now.
|
||||
- `POST /api/library/scan`: refresh cached library index.
|
||||
- `GET /api/downloads`: current `/downloads` files plus recent planned/moved download history.
|
||||
- `GET /api/releases`: upcoming releases.
|
||||
- `GET /api/media/probe`: ffprobe stream details for a selected file.
|
||||
- `POST /api/media/tracks`: dry-run or execute ffmpeg remux track default/removal changes.
|
||||
- `GET /api/theme/custom.css`: custom CSS.
|
||||
- `POST /api/settings`: update runtime settings.
|
||||
- `GET /api/tools/subtitles`: subtitle audit from cached library data.
|
||||
- `GET /api/tools/transcoder`: build ffmpeg transcode queue from cached library.
|
||||
- `POST /api/tools/transcoder/run-next`: run next ffmpeg transcode if dry-run is disabled.
|
||||
|
||||
## Tools
|
||||
|
||||
Subtitle audit:
|
||||
|
||||
- Uses cached library index, not live filesystem probes.
|
||||
- Requires a fresh library scan for accurate `has_subtitles`.
|
||||
- Reports checked count, with-subtitles count, missing count, unknown count, and missing examples.
|
||||
|
||||
Transcoder:
|
||||
|
||||
- Backend image installs `ffmpeg`.
|
||||
- Queue includes cached indexed media not already `.mp4`.
|
||||
- Output path is source path with `.mp4` suffix.
|
||||
- Command uses `libx264`, `aac`, and `mov_text`.
|
||||
- In dry-run mode, `run-next` reports without executing.
|
||||
- With dry-run disabled, runs one job synchronously with a 1 hour timeout.
|
||||
|
||||
Duplicate finder:
|
||||
|
||||
- Reports duplicate title groups from the cached library index.
|
||||
|
||||
## Release Providers
|
||||
|
||||
No paid API dependency.
|
||||
|
||||
Bundled providers, disabled by default so the Releases page stays centered on the local library:
|
||||
|
||||
- TMDb RSS upcoming movies.
|
||||
- TVMaze public schedule JSON.
|
||||
|
||||
Provider logic is in `backend/sortarr/releases.py`; add new RSS/JSON adapters there and configure in TOML.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
python -m compileall backend/sortarr
|
||||
node --check web/src/app.js
|
||||
docker compose config
|
||||
docker compose up -d --build
|
||||
docker exec sortarr-backend python -m sortarr.healthcheck
|
||||
docker exec sortarr-backend ffmpeg -version
|
||||
```
|
||||
|
||||
Endpoint checks from inside backend:
|
||||
|
||||
```bash
|
||||
docker exec sortarr-backend python -c "from urllib.request import urlopen; print(urlopen('http://127.0.0.1:8099/api/health').status)"
|
||||
docker exec sortarr-backend python -c "from urllib.request import urlopen; import json; print(json.load(urlopen('http://127.0.0.1:8099/api/tools/transcoder'))['transcoder']['ffmpeg_available'])"
|
||||
```
|
||||
|
||||
## Current Caveats / Next Good Tasks
|
||||
|
||||
- Settings are runtime/persisted in JSON state but not written back into `config/app.toml`.
|
||||
- Transcoding runs synchronously; future improvement should add a job queue with progress/cancel/history.
|
||||
- Duplicate finder reports duplicate title groups from the cached library index.
|
||||
- Subtitle audit only becomes exact after a fresh manual library scan because it relies on cached `has_subtitles`.
|
||||
- Library scan only checks direct child folders named `Movies`, `TV`, or `TV Shows` under each media drive.
|
||||
- Backend is stdlib HTTP server; fine for self-hosting behind LAN/reverse proxy, but add auth before exposing publicly.
|
||||
4
web/Dockerfile
Normal file
4
web/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM nginx:1.27-alpine
|
||||
COPY src /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
32
web/nginx.conf
Normal file
32
web/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types application/json application/javascript text/css text/plain;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8099/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /app.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
969
web/src/app.js
Normal file
969
web/src/app.js
Normal file
@@ -0,0 +1,969 @@
|
||||
const themes = ["slate", "midnight", "graphite", "nord", "dracula", "solar", "forest", "marine", "ember", "paper"];
|
||||
const themeLabels = {
|
||||
slate: "Slate",
|
||||
midnight: "Midnight",
|
||||
graphite: "Graphite",
|
||||
nord: "Nord",
|
||||
dracula: "Dracula",
|
||||
solar: "Solar",
|
||||
forest: "Forest",
|
||||
marine: "Marine",
|
||||
ember: "Ember",
|
||||
paper: "Paper",
|
||||
};
|
||||
const settingsGroups = [
|
||||
{
|
||||
title: "Organizer",
|
||||
description: "Controls how Sortarr watches /downloads, decides what is safe to move, and handles uncertain matches.",
|
||||
fields: [
|
||||
["app.dry_run", "Dry-run mode", "checkbox", "Plan files without moving them. Disable only when destinations and confidence scores look correct."],
|
||||
["app.scan_interval_seconds", "Scan interval", "range", "How often the background scanner checks /downloads.", { min: 5, max: 300, step: 5, unit: "sec" }],
|
||||
["app.settle_seconds", "File settle time", "range", "How long a file must remain unchanged before Sortarr can plan or move it.", { min: 10, max: 1800, step: 10, unit: "sec" }],
|
||||
["app.stable_checks", "Stable checks", "range", "Number of matching size/mtime observations expected before a file is considered stable.", { min: 1, max: 8, step: 1, unit: "checks" }],
|
||||
["app.auto_move_min_confidence", "Auto-move confidence", "range", "Plans at or above this score can move automatically when dry-run is off.", { min: 50, max: 100, step: 1, unit: "%" }],
|
||||
["app.review_min_confidence", "Review confidence", "range", "Plans at or above this score stay in the review queue instead of being treated as low confidence.", { min: 0, max: 100, step: 1, unit: "%" }],
|
||||
["app.organization_metadata_budget_seconds", "Metadata budget", "range", "Maximum total TMDb lookup time per organizer pass before Sortarr falls back to filename-only planning.", { min: 0, max: 120, step: 5, unit: "sec" }],
|
||||
["app.organization_metadata_timeout_seconds", "Metadata timeout", "range", "Maximum time a single TMDb request can wait.", { min: 1, max: 15, step: 1, unit: "sec" }],
|
||||
["app.metadata_parallelism", "Metadata parallelism", "range", "How many TMDb lookups a library scan can run at the same time.", { min: 1, max: 12, step: 1, unit: "workers" }],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Scanning",
|
||||
description: "Limits for library indexing and file classification.",
|
||||
fields: [
|
||||
["app.library_scan_max_files", "Library scan file limit", "range", "Maximum filesystem entries inspected by a manual library scan.", { min: 1000, max: 250000, step: 1000, unit: "files" }],
|
||||
["app.library_scan_timeout_seconds", "Library scan timeout", "range", "Maximum runtime for a manual library scan before returning a partial result.", { min: 3, max: 180, step: 1, unit: "sec" }],
|
||||
["app.cache_max_bytes", "Server cache limit", "range", "Maximum cache size for server-side metadata/probe data.", { min: 1073741824, max: 21474836480, step: 1073741824, unit: "bytes" }],
|
||||
["app.media_extensions", "Media extensions", "list", "Extensions treated as media files in /downloads."],
|
||||
["app.subtitle_extensions", "Subtitle extensions", "list", "Extensions packaged with matching movies and episodes."],
|
||||
["app.incomplete_suffixes", "Incomplete suffixes", "list", "Suffixes ignored while downloads are still active."],
|
||||
["app.extra_keywords", "Extra ignore keywords", "list", "Filename terms that identify extras rather than primary media."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Paths",
|
||||
description: "Container paths used by the backend. Host bind mounts are still controlled by Docker compose and .env.",
|
||||
fields: [
|
||||
["paths.downloads", "Downloads path", "text", "Container path Sortarr watches for new downloads."],
|
||||
["paths.data", "Data path", "text", "Container path for state and runtime data."],
|
||||
["paths.logs", "Logs path", "text", "Container path for backend logs."],
|
||||
["paths.cache", "Cache path", "text", "Container path for metadata and probe caches."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Library Naming",
|
||||
description: "Templates used when Sortarr creates destination folders and filenames.",
|
||||
fields: [
|
||||
["library.movie_folder", "Movie folder", "text", "Folder template for movies."],
|
||||
["library.series_folder", "Series folder", "text", "Folder template for TV episodes."],
|
||||
["library.movie_file", "Movie filename", "text", "Filename template for movies."],
|
||||
["library.episode_file", "Episode filename", "text", "Filename template for TV episodes."],
|
||||
["library.subtitle_file", "Subtitle filename", "text", "Filename template for packaged subtitles."],
|
||||
["library.unknown_folder", "Unknown folder", "text", "Fallback folder for media that cannot be confidently classified."],
|
||||
["library.collision", "File collision policy", "select", "What to do when the destination file already exists.", { options: ["keep-both", "skip", "replace"] }],
|
||||
["library.duplicate", "Duplicate policy", "select", "How duplicate titles should be handled.", { options: ["skip", "keep-both"] }],
|
||||
["library.permissions_mode", "File permissions", "text", "Octal mode applied to moved media files."],
|
||||
["library.directory_mode", "Directory permissions", "text", "Octal mode intended for created library folders."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Metadata",
|
||||
description: "TMDb and local metadata behavior.",
|
||||
fields: [
|
||||
["metadata.tmdb_enabled", "TMDb enabled", "checkbox", "Allow Sortarr to enrich plans and library items with TMDb data."],
|
||||
["metadata.write_nfo", "Write NFO files", "checkbox", "Write simple NFO metadata beside moved files."],
|
||||
["metadata.prefer_existing_nfo", "Prefer existing NFO", "checkbox", "Use existing local NFO data before online metadata when available."],
|
||||
["metadata.provider_order", "Provider order", "list", "Metadata providers in priority order."],
|
||||
["metadata.tmdb_api_key", "TMDb API key", "text", "TMDb v3 API key used for lookups. This is stored in /data/state.json when saved here."],
|
||||
["metadata.tmdb_bearer_token", "TMDb bearer token", "text", "Optional TMDb v4 bearer token. This is stored in /data/state.json when saved here."],
|
||||
["metadata.tmdb_language", "TMDb language", "text", "Language code used for TMDb requests, such as en-US."],
|
||||
["metadata.tmdb_image_base", "TMDb image base", "text", "Base URL used for poster and backdrop images."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Appearance",
|
||||
description: "Dashboard theme and custom CSS behavior.",
|
||||
fields: [
|
||||
["theme.default", "Default theme", "select", "Theme used when a browser has not chosen one locally.", { options: themes }],
|
||||
["theme.allow_custom_css", "Allow custom CSS", "checkbox", "Serve /config/custom-theme.css when present."],
|
||||
["theme.custom_css_path", "Custom CSS path", "text", "Container path for optional custom dashboard CSS."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Logging",
|
||||
description: "Backend diagnostics.",
|
||||
fields: [
|
||||
["app.log_level", "Log level", "select", "Controls backend verbosity.", { options: ["DEBUG", "INFO", "WARNING", "ERROR"] }],
|
||||
["app.name", "Application name", "text", "Display/runtime name for this Sortarr instance."],
|
||||
],
|
||||
},
|
||||
];
|
||||
const state = {
|
||||
dashboard: null,
|
||||
config: null,
|
||||
downloads: null,
|
||||
library: null,
|
||||
releases: [],
|
||||
route: "overview",
|
||||
libraryTab: "all",
|
||||
libraryLimit: 120,
|
||||
selectedMedia: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const bytes = (value = 0) => {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let idx = 0;
|
||||
while (size >= 1024 && idx < units.length - 1) {
|
||||
size /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${size.toFixed(idx ? 1 : 0)} ${units[idx]}`;
|
||||
};
|
||||
const date = (seconds) => seconds ? new Date(seconds * 1000).toLocaleString() : "";
|
||||
const mediaLabel = (kind) => kind === "tv" ? "TV Shows" : kind === "movie" ? "Movies" : "Other";
|
||||
const esc = (value = "") => String(value).replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
|
||||
function toast(message, type = "info") {
|
||||
const host = $("toastHost");
|
||||
if (!host) return;
|
||||
const item = document.createElement("div");
|
||||
item.className = `toast ${type}`;
|
||||
item.textContent = message;
|
||||
host.appendChild(item);
|
||||
setTimeout(() => item.classList.add("visible"), 10);
|
||||
setTimeout(() => {
|
||||
item.classList.remove("visible");
|
||||
setTimeout(() => item.remove(), 180);
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem("sortarr-theme", theme);
|
||||
renderThemeOptions();
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, options);
|
||||
if (!response.ok) throw new Error(`${path} returned ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function routeFromHash() {
|
||||
return (location.hash.replace("#/", "") || "overview").split("?")[0];
|
||||
}
|
||||
|
||||
function renderRoute() {
|
||||
state.route = routeFromHash();
|
||||
document.querySelectorAll(".page").forEach((page) => page.classList.remove("active"));
|
||||
document.querySelectorAll("nav a").forEach((link) => link.classList.toggle("active", link.dataset.route === state.route));
|
||||
const page = $(`page-${state.route}`);
|
||||
(page || $("page-overview")).classList.add("active");
|
||||
if (state.route === "library" && !state.library) {
|
||||
loadLibrary().catch((error) => toast(`Library load failed: ${error.message}`, "error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
state.dashboard = await api("/api/dashboard");
|
||||
renderDashboard();
|
||||
if (state.downloads) renderDownloads();
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
const payload = await api("/api/library");
|
||||
state.library = payload.library;
|
||||
renderLibraryStatus();
|
||||
renderLibraryTabs();
|
||||
renderLibrary();
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
state.config = await api("/api/config");
|
||||
$("configView").textContent = JSON.stringify(state.config, null, 2);
|
||||
renderSettings();
|
||||
if (!localStorage.getItem("sortarr-theme") && state.config.theme?.default) {
|
||||
setTheme(state.config.theme.default);
|
||||
} else {
|
||||
renderThemeOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDownloads() {
|
||||
const payload = await api("/api/downloads");
|
||||
state.downloads = payload.downloads;
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
async function loadReleases() {
|
||||
const payload = await api("/api/releases");
|
||||
state.releases = payload.releases || [];
|
||||
renderReleases();
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const data = state.dashboard;
|
||||
$("statusLine").textContent = data.dry_run ? "Dry-run mode is active" : "Organizer is allowed to move files";
|
||||
$("storageCards").innerHTML = data.library.drives.map((drive) => {
|
||||
const pct = drive.total ? Math.round((drive.used / drive.total) * 100) : 0;
|
||||
return `<div class="storage-card">
|
||||
<strong>${drive.name}</strong>
|
||||
<div class="meter"><span style="width:${pct}%"></span></div>
|
||||
<div class="kv"><span>${bytes(drive.used)} used</span><span>${bytes(drive.free)} free</span></div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const extensions = Object.entries(data.library.extensions);
|
||||
const max = Math.max(...extensions.map(([, count]) => count), 1);
|
||||
$("extensionBreakdown").innerHTML = extensions.slice(0, 12).map(([ext, count]) => `
|
||||
<div class="bar-row"><span>${ext}</span><div class="meter"><span style="width:${(count / max) * 100}%"></span></div><span>${count}</span></div>
|
||||
`).join("") || "<p class='muted'>No files indexed yet.</p>";
|
||||
|
||||
renderLibraryStatus();
|
||||
|
||||
$("events").innerHTML = data.state.events.slice(0, 12).map((event) => `
|
||||
<div class="event ${event.level}"><strong>${event.message}</strong><br><small>${date(event.time)}</small></div>
|
||||
`).join("") || "<p class='muted'>No organizer events yet.</p>";
|
||||
|
||||
if (state.route === "library" && state.library) {
|
||||
renderLibraryTabs();
|
||||
renderLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
function activeLibrary() {
|
||||
return state.library || state.dashboard?.library || {};
|
||||
}
|
||||
|
||||
function renderLibraryStatus() {
|
||||
const library = activeLibrary();
|
||||
const counts = library.counts || {};
|
||||
$("libraryStatus").textContent = library.scanned_files
|
||||
? `Indexed ${counts.total || 0} media files across ${counts.movies || 0} movies and ${counts.tv || 0} TV items from ${library.scanned_files} scanned files${library.truncated ? " before the configured scan limit or timeout" : ""}.`
|
||||
: "Library has not been scanned yet. Use Scan library to index Movies, TV, and TV Shows folders.";
|
||||
}
|
||||
|
||||
function libraryCollections() {
|
||||
const filter = $("libraryFilter").value.toLowerCase();
|
||||
const collections = activeLibrary().collections || { movies: [], series: [] };
|
||||
const all = [
|
||||
...collections.movies.map((item) => ({ ...item, library: "movie" })),
|
||||
...collections.series.map((item) => ({ ...item, library: "tv" })),
|
||||
];
|
||||
return all.filter((item) => {
|
||||
const meta = item.metadata || {};
|
||||
const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab;
|
||||
const matchesFilter = [item.title, meta.title, meta.overview, item.year, mediaLabel(item.library)].join(" ").toLowerCase().includes(filter);
|
||||
return matchesTab && matchesFilter;
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibraryTabs() {
|
||||
const collectionCounts = activeLibrary().collections || {};
|
||||
const tabs = [
|
||||
["all", "All", (collectionCounts.movies?.length || 0) + (collectionCounts.series?.length || 0)],
|
||||
["movie", "Movies", collectionCounts.movies?.length || 0],
|
||||
["tv", "TV Shows", collectionCounts.series?.length || 0],
|
||||
];
|
||||
$("libraryTabs").innerHTML = tabs.map(([key, label, count]) => `
|
||||
<button class="${state.libraryTab === key ? "active" : ""}" data-library-tab="${key}">${label}<span>${count}</span></button>
|
||||
`).join("");
|
||||
document.querySelectorAll("[data-library-tab]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.libraryTab = button.dataset.libraryTab;
|
||||
state.libraryLimit = 120;
|
||||
renderLibraryTabs();
|
||||
renderLibrary();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibrary() {
|
||||
const rows = libraryCollections();
|
||||
const visible = rows.slice(0, state.libraryLimit);
|
||||
$("libraryGrid").innerHTML = visible.map((item) => mediaCard(item)).join("") || "<p class='muted'>No matching media.</p>";
|
||||
document.querySelectorAll("[data-media-key]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectMedia(button.dataset.mediaKey));
|
||||
});
|
||||
$("libraryPager").innerHTML = rows.length > state.libraryLimit
|
||||
? `<span class="muted">Showing ${visible.length} of ${rows.length} matching titles.</span><button id="libraryMoreButton">Show 120 more</button>`
|
||||
: `<span class="muted">Showing ${visible.length} matching titles.</span>`;
|
||||
const more = $("libraryMoreButton");
|
||||
if (more) {
|
||||
more.addEventListener("click", () => {
|
||||
state.libraryLimit += 120;
|
||||
renderLibrary();
|
||||
});
|
||||
}
|
||||
if (!state.selectedMedia && visible[0]) {
|
||||
selectMedia(visible[0].key, false);
|
||||
} else if (state.selectedMedia) {
|
||||
renderMediaDetail(state.selectedMedia);
|
||||
}
|
||||
}
|
||||
|
||||
function mediaCard(item) {
|
||||
const meta = item.metadata || {};
|
||||
const title = meta.title || item.title;
|
||||
const subtitle = item.library === "tv"
|
||||
? `${item.seasons?.length || 0} seasons, ${item.files?.length || 0} files`
|
||||
: `${item.year || meta.release_date || ""} ${item.versions?.length > 1 ? `- ${item.versions.length} versions` : ""}`;
|
||||
const versionBadge = item.library === "movie" && (item.versions?.length || item.files?.length || 0) > 1
|
||||
? `<span class="version-badge" title="${esc((item.versions?.length || item.files?.length || 0) + " versions")}">${item.versions?.length || item.files?.length}</span>`
|
||||
: "";
|
||||
const cover = meta.poster
|
||||
? `<img src="${esc(meta.poster)}" alt="">`
|
||||
: `<span class="poster-placeholder">${esc(title.slice(0, 1) || "?")}</span>`;
|
||||
return `<button class="poster-card ${state.selectedMedia?.key === item.key ? "active" : ""}" data-media-key="${esc(item.key)}">
|
||||
<span class="poster">${cover}${versionBadge}</span>
|
||||
<strong>${esc(title)}</strong>
|
||||
<small>${esc(subtitle)}</small>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function findMedia(key) {
|
||||
const collections = activeLibrary().collections || { movies: [], series: [] };
|
||||
return [...collections.movies, ...collections.series].find((item) => item.key === key);
|
||||
}
|
||||
|
||||
function selectMedia(key, scroll = true) {
|
||||
const item = findMedia(key);
|
||||
if (!item) return;
|
||||
state.selectedMedia = item;
|
||||
document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key));
|
||||
renderMediaDetail(item);
|
||||
if (scroll) $("libraryDetail").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function renderMediaDetail(item) {
|
||||
const meta = item.metadata || {};
|
||||
const files = item.files || [];
|
||||
const title = meta.title || item.title;
|
||||
const cover = meta.poster ? `<img src="${esc(meta.poster)}" alt="">` : `<span class="poster-placeholder">${esc(title.slice(0, 1) || "?")}</span>`;
|
||||
const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item);
|
||||
$("libraryDetail").innerHTML = `<article class="detail-shell">
|
||||
<div class="poster detail-poster">${cover}</div>
|
||||
<div class="detail-body">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>${esc(title)}</h2>
|
||||
<p class="muted">${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}</p>
|
||||
</div>
|
||||
${files[0] ? `<button data-probe-path="${esc(files[0].path)}">Inspect media</button>` : ""}
|
||||
</div>
|
||||
${meta.overview ? `<p>${esc(meta.overview)}</p>` : ""}
|
||||
${detail}
|
||||
<div id="probeOutput" class="probe-output"></div>
|
||||
</div>
|
||||
</article>`;
|
||||
document.querySelectorAll("[data-probe-path]").forEach((button) => {
|
||||
button.addEventListener("click", () => inspectMedia(button.dataset.probePath));
|
||||
});
|
||||
}
|
||||
|
||||
function renderMovieDetail(item) {
|
||||
const versions = item.versions || (item.files || []).map((file) => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
drive: file.drive,
|
||||
size: file.size,
|
||||
tags: [],
|
||||
}));
|
||||
return `<div class="detail-block">
|
||||
<h3>${versions.length > 1 ? `${versions.length} Versions` : "Local File"}</h3>
|
||||
<div class="download-list">${versions.map(versionRow).join("")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function versionRow(version) {
|
||||
const tags = (version.tags || []).map((tag) => `<span>${esc(tag)}</span>`).join("");
|
||||
return `<div class="download">
|
||||
<strong>${esc(version.name || "")}</strong>
|
||||
<div class="kv"><span>${esc(version.drive || "")}</span><span>${bytes(version.size)}</span></div>
|
||||
${tags ? `<div class="subtitle-chips">${tags}</div>` : ""}
|
||||
<small class="muted">${esc(version.path || "")}</small>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSeriesDetail(item) {
|
||||
return `<div class="season-list">${(item.seasons || []).map((season) => `
|
||||
<details open>
|
||||
<summary>Season ${season.season || "Unknown"} <span class="muted">${season.episodes.length} episodes</span></summary>
|
||||
<div class="episode-list">${season.episodes.map((episode) => `
|
||||
<article class="episode ${episode.status}">
|
||||
<div>
|
||||
<strong>${episode.episode ? `E${String(episode.episode).padStart(2, "0")} - ` : ""}${esc(episode.title || "Episode")}</strong>
|
||||
<small class="muted">${episode.air_date || ""} ${episode.status !== "present" ? episode.status : ""}</small>
|
||||
${episode.overview ? `<p class="muted">${esc(episode.overview)}</p>` : ""}
|
||||
</div>
|
||||
<div class="episode-actions">
|
||||
${(episode.files || []).map((file) => `<button data-probe-path="${esc(file.path)}">Inspect</button>`).join("") || "<span class='muted'>No file</span>"}
|
||||
</div>
|
||||
</article>
|
||||
`).join("")}</div>
|
||||
</details>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function fileRow(file) {
|
||||
return `<div class="download">
|
||||
<strong>${esc(file.name)}</strong>
|
||||
<div class="kv"><span>${esc(file.drive || "")}</span><span>${bytes(file.size)}</span></div>
|
||||
<small class="muted">${esc(file.path)}</small>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function inspectMedia(path) {
|
||||
const output = $("probeOutput");
|
||||
output.innerHTML = "<p class='muted'>Inspecting media streams...</p>";
|
||||
const payload = await api(`/api/media/probe?path=${encodeURIComponent(path)}`);
|
||||
const media = payload.media;
|
||||
state.currentProbePath = path;
|
||||
output.innerHTML = `<div class="detail-block">
|
||||
<h3>Media Info</h3>
|
||||
<div class="stream-grid">
|
||||
<section><strong>Video</strong>${streamRows(media.video)}</section>
|
||||
<section><strong>Audio Tracks</strong>${streamRows(media.audio)}</section>
|
||||
<section><strong>Subtitles</strong>${streamRows(media.subtitles)}</section>
|
||||
</div>
|
||||
<p class="muted">Track edits remux the selected file. Dry-run mode reports the command without changing the file.</p>
|
||||
</div>`;
|
||||
document.querySelectorAll("[data-track-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex)));
|
||||
});
|
||||
}
|
||||
|
||||
function streamRows(streams = []) {
|
||||
return streams.map((stream) => {
|
||||
const tags = stream.tags || {};
|
||||
return `<div class="stream-row">
|
||||
<span>${esc(stream.codec_name || stream.codec_type || "unknown")}</span>
|
||||
<small>${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""}</small>
|
||||
${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? `<span class="track-actions">
|
||||
<button data-track-action="set-default" data-stream-index="${stream.index}">Set default</button>
|
||||
<button data-track-action="remove" data-stream-index="${stream.index}">Remove</button>
|
||||
</span>` : ""}
|
||||
</div>`;
|
||||
}).join("") || "<p class='muted'>None detected.</p>";
|
||||
}
|
||||
|
||||
async function editTrack(path, action, streamIndex) {
|
||||
const output = $("probeOutput");
|
||||
const payload = await api("/api/media/tracks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, action, stream_index: streamIndex }),
|
||||
});
|
||||
const result = payload.media;
|
||||
output.insertAdjacentHTML("afterbegin", `<div class="event ${result.status === "failed" ? "error" : ""}">
|
||||
<strong>Track edit: ${esc(result.status)}</strong>
|
||||
${result.command ? `<code>${esc(result.command.join(" "))}</code>` : ""}
|
||||
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
|
||||
</div>`);
|
||||
if (result.status === "updated") {
|
||||
await inspectMedia(path);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDownloads() {
|
||||
const downloads = state.downloads;
|
||||
if (!downloads) return;
|
||||
$("downloadsStatus").textContent = downloads.error
|
||||
? `Cannot read ${downloads.path}: ${downloads.error}`
|
||||
: `${downloads.counts.current} files in ${downloads.path}, ${downloads.counts.media} media files, ${downloads.counts.subtitles || 0} subtitle files, ${downloads.counts.incomplete} incomplete files. Total size: ${bytes(downloads.total_size)}.`;
|
||||
const queue = state.dashboard?.state?.organizer?.queue || [];
|
||||
const queueCounts = queue.reduce((acc, plan) => {
|
||||
const key = plan.status || plan.result || "planned";
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
$("organizerSummary").innerHTML = [
|
||||
["ready", queueCounts.ready || 0],
|
||||
["review", queueCounts["needs-review"] || 0],
|
||||
["held", queueCounts.held || 0],
|
||||
["dry-run", queueCounts["dry-run"] || 0],
|
||||
["moved", queueCounts.moved || 0],
|
||||
].map(([label, count]) => `<span><strong>${count}</strong>${label}</span>`).join("");
|
||||
$("organizerRows").innerHTML = queue.slice(0, 100).map(organizerCard).join("") || "<p class='muted'>No organizer plans yet. Run scan or wait for the background scanner.</p>";
|
||||
document.querySelectorAll("[data-plan-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => updateOrganizerPlan(button.dataset.planAction, button.dataset.planId));
|
||||
});
|
||||
$("downloadRows").innerHTML = [
|
||||
...(downloads.bundles || []).slice(0, 150).map((bundle) => downloadBundle(bundle)),
|
||||
...(downloads.loose || []).slice(0, 80).map((item) => `
|
||||
<div class="download loose ${item.is_incomplete ? "warning" : ""}">
|
||||
<strong>${esc(item.relative_path)}</strong>
|
||||
<div class="kv"><span>${item.is_incomplete ? "Incomplete" : item.is_subtitle ? "Loose subtitle" : "Sidecar file"}</span><span>${bytes(item.size)}</span></div>
|
||||
<small class="muted">Modified ${date(item.modified)}</small>
|
||||
</div>
|
||||
`),
|
||||
].join("") || "<p class='muted'>/downloads is currently empty.</p>";
|
||||
$("recentDownloadRows").innerHTML = downloads.recent.slice(0, 100).map((item) => `
|
||||
<div class="download">
|
||||
<strong>${item.title || item.source}</strong>
|
||||
<span class="muted">${item.status} ${item.type || "item"}${item.drive ? ` to ${item.drive}` : ""}</span>
|
||||
<small>${item.destination || item.source}</small>
|
||||
<small class="muted">${date(item.updated_at)}</small>
|
||||
</div>
|
||||
`).join("") || "<p class='muted'>No recent Sortarr plans or moves from /downloads yet.</p>";
|
||||
}
|
||||
|
||||
function organizerCard(plan) {
|
||||
const status = plan.status || plan.result;
|
||||
const result = plan.result && plan.result !== plan.status ? ` (${plan.result})` : "";
|
||||
const confidenceClass = plan.confidence >= 90 ? "good" : plan.confidence >= 60 ? "warn" : "bad";
|
||||
const metaSource = plan.metadata?.source === "tmdb" ? "TMDb matched" : "Filename parsed";
|
||||
const label = plan.media?.type === "episode" && plan.media?.season
|
||||
? `TV episode S${String(plan.media.season).padStart(2, "0")}E${String(plan.media.episode).padStart(2, "0")}`
|
||||
: plan.media?.type === "movie" ? `Movie ${plan.media?.year || ""}` : esc(plan.media?.type || "");
|
||||
return `<div class="download organizer-card ${status}">
|
||||
<div class="bundle-head">
|
||||
<div>
|
||||
<strong>${esc(plan.media?.title || plan.source)}</strong>
|
||||
<small class="muted">${esc(label)} - ${esc(metaSource)}</small>
|
||||
${plan.media?.episode_title ? `<small>${esc(plan.media.episode_title)}</small>` : ""}
|
||||
</div>
|
||||
<span class="confidence ${confidenceClass}">${plan.confidence || 0}%</span>
|
||||
</div>
|
||||
<div class="plan-paths">
|
||||
<small><b>From</b>${esc(plan.source || "")}</small>
|
||||
<small><b>To</b>${esc(plan.destination || "No destination planned")}</small>
|
||||
</div>
|
||||
<div class="subtitle-chips">${(plan.reasons || []).map((reason) => `<span>${esc(reason)}</span>`).join("")}</div>
|
||||
<div class="kv"><span>${esc(`${status || ""}${result}`)}</span><span>${(plan.subtitles || []).length} subtitles</span></div>
|
||||
${(plan.subtitles || []).length ? `<div class="subtitle-list">${plan.subtitles.map((subtitle) => `<small>${esc(subtitle.language || "und")} -> ${esc(subtitle.destination || "not planned")}</small>`).join("")}</div>` : ""}
|
||||
<div class="plan-actions">
|
||||
<button data-plan-action="approve" data-plan-id="${esc(plan.id)}">Approve</button>
|
||||
<button data-plan-action="skip" data-plan-id="${esc(plan.id)}">Skip</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function updateOrganizerPlan(action, id) {
|
||||
await api(`/api/organizer/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
await Promise.all([loadDashboard(), loadDownloads()]);
|
||||
}
|
||||
|
||||
function downloadBundle(bundle) {
|
||||
const media = bundle.media;
|
||||
return `<div class="download bundle ${media.is_incomplete ? "warning" : ""}">
|
||||
<div class="bundle-head">
|
||||
<div>
|
||||
<strong>${esc(media.name)}</strong>
|
||||
<small class="muted">${esc(media.folder || "/downloads")}</small>
|
||||
</div>
|
||||
<span>${bytes(bundle.size)}</span>
|
||||
</div>
|
||||
<div class="kv"><span>Media file</span><span>${date(media.modified)}</span></div>
|
||||
<div class="subtitle-chips">
|
||||
${(bundle.subtitles || []).map((subtitle) => `<span>${esc(subtitle.name)}</span>`).join("") || "<small class='muted'>No matching subtitles found</small>"}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderReleases() {
|
||||
$("releaseRows").innerHTML = state.releases.map((item) => `
|
||||
<div class="release ${item.status || ""}">
|
||||
${item.poster ? `<img src="${esc(item.poster)}" alt="">` : ""}
|
||||
<strong>${esc(item.title || item.error || "Unknown")}</strong>
|
||||
${item.episode_title ? `<span>${esc(`S${String(item.season).padStart(2, "0")}E${String(item.episode).padStart(2, "0")} - ${item.episode_title}`)}</span>` : ""}
|
||||
<span class="muted">${esc(item.status || item.provider || "")}</span>
|
||||
<span>${esc(item.date || item.type || "")}</span>
|
||||
${item.library_key ? `<a href="#/library" data-release-key="${esc(item.library_key)}">Open in library</a>` : ""}
|
||||
</div>
|
||||
`).join("") || "<p class='muted'>No release providers returned data.</p>";
|
||||
document.querySelectorAll("[data-release-key]").forEach((link) => {
|
||||
link.addEventListener("click", () => setTimeout(() => selectMedia(link.dataset.releaseKey), 50));
|
||||
});
|
||||
}
|
||||
|
||||
function renderThemeOptions() {
|
||||
const wrap = $("themeOptions");
|
||||
if (!wrap) return;
|
||||
const current = localStorage.getItem("sortarr-theme") || state.config?.theme?.default || "slate";
|
||||
wrap.innerHTML = themes.map((theme) => `
|
||||
<button class="theme-option ${current === theme ? "active" : ""}" data-theme-choice="${theme}">
|
||||
<span class="theme-swatch" data-theme="${theme}"><i></i><b></b><em></em></span>
|
||||
<strong>${themeLabels[theme]}</strong>
|
||||
</button>
|
||||
`).join("");
|
||||
document.querySelectorAll("[data-theme-choice]").forEach((button) => {
|
||||
button.addEventListener("click", () => setTheme(button.dataset.themeChoice));
|
||||
});
|
||||
}
|
||||
|
||||
function getPath(root, path) {
|
||||
return path.split(".").reduce((value, key) => value?.[key], root);
|
||||
}
|
||||
|
||||
function setPath(root, path, value) {
|
||||
const keys = path.split(".");
|
||||
let target = root;
|
||||
keys.slice(0, -1).forEach((key) => {
|
||||
target[key] = target[key] || {};
|
||||
target = target[key];
|
||||
});
|
||||
target[keys[keys.length - 1]] = value;
|
||||
}
|
||||
|
||||
function fieldMeta(tuple) {
|
||||
const [path, label, type, help, options = {}] = tuple;
|
||||
return { path, label, type, help, ...options };
|
||||
}
|
||||
|
||||
function settingField(meta) {
|
||||
const value = getPath(state.config, meta.path);
|
||||
const id = meta.path.replaceAll(".", "__");
|
||||
const body = `<div class="setting-copy">
|
||||
<div>
|
||||
<strong>${meta.label}</strong>
|
||||
<small class="setting-path">${esc(meta.path)}</small>
|
||||
</div>
|
||||
<small>${meta.help}</small>
|
||||
</div>`;
|
||||
if (meta.type === "checkbox") {
|
||||
return `<article class="setting-row setting-rich">${body}<label class="switch"><input data-setting="${esc(meta.path)}" type="checkbox" ${value ? "checked" : ""}><span></span></label></article>`;
|
||||
}
|
||||
if (meta.type === "select") {
|
||||
const options = (meta.options || []).map((option) => `<option value="${esc(option)}" ${value === option ? "selected" : ""}>${esc(themeLabels[option] || option)}</option>`);
|
||||
return `<article class="setting-row setting-rich">${body}<div class="setting-control"><select data-setting="${esc(meta.path)}">${options.join("")}</select></div></article>`;
|
||||
}
|
||||
if (meta.type === "list") {
|
||||
return `<article class="setting-row setting-rich">${body}<div class="setting-control wide"><textarea data-setting="${esc(meta.path)}" data-setting-type="list" rows="2">${esc((value || []).join(", "))}</textarea><small>Comma separated</small></div></article>`;
|
||||
}
|
||||
if (meta.type === "text") {
|
||||
return `<article class="setting-row setting-rich">${body}<div class="setting-control wide"><input data-setting="${esc(meta.path)}" type="text" value="${esc(value ?? "")}"></div></article>`;
|
||||
}
|
||||
return `<article class="setting-row setting-rich">${body}<div class="range-control">
|
||||
<input data-setting="${esc(meta.path)}" data-range-for="${id}" type="range" min="${meta.min}" max="${meta.max}" step="${meta.step}" value="${value ?? meta.min}">
|
||||
<span><input data-setting="${esc(meta.path)}" data-number-for="${id}" type="number" min="${meta.min}" max="${meta.max}" step="${meta.step}" value="${value ?? meta.min}"><small>${meta.unit}</small></span>
|
||||
</div></article>`;
|
||||
}
|
||||
|
||||
function syncSettingControls() {
|
||||
document.querySelectorAll("[data-range-for]").forEach((range) => {
|
||||
range.addEventListener("input", () => {
|
||||
const number = document.querySelector(`[data-number-for="${range.dataset.rangeFor}"]`);
|
||||
if (number) number.value = range.value;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-number-for]").forEach((number) => {
|
||||
number.addEventListener("input", () => {
|
||||
const range = document.querySelector(`[data-range-for="${number.dataset.numberFor}"]`);
|
||||
if (range) range.value = number.value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderDriveSettings() {
|
||||
const drives = state.config?.drives || [];
|
||||
return `<details class="settings-card" open>
|
||||
<summary class="settings-card-head">
|
||||
<div>
|
||||
<h3>Storage Drives</h3>
|
||||
<p class="muted">Destination drives Sortarr can choose when moving organized media.</p>
|
||||
</div>
|
||||
<span>${drives.length} drives</span>
|
||||
</summary>
|
||||
<div class="settings-grid">${drives.map((drive, idx) => `
|
||||
<div class="setting-row setting-rich drive-setting" data-drive-index="${idx}">
|
||||
<div class="setting-copy">
|
||||
<div>
|
||||
<strong>${esc(drive.name || drive.id || `Drive ${idx + 1}`)}</strong>
|
||||
<small class="setting-path">drives[${idx}]</small>
|
||||
</div>
|
||||
<small>Drive identity, container path, and minimum free-space reserve.</small>
|
||||
</div>
|
||||
<span class="compound-control">
|
||||
<label><small>ID</small><input data-drive-field="id" type="text" value="${esc(drive.id || "")}"></label>
|
||||
<label><small>Name</small><input data-drive-field="name" type="text" value="${esc(drive.name || "")}"></label>
|
||||
<label class="span-2"><small>Container path</small><input data-drive-field="path" type="text" value="${esc(drive.path || "")}"></label>
|
||||
<label><small>Reserve</small><input data-drive-field="min_free_gb" type="number" min="0" step="1" value="${drive.min_free_gb ?? 20}"></label>
|
||||
</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function collectDriveSettings() {
|
||||
return [...document.querySelectorAll("[data-drive-index]")].map((row) => ({
|
||||
id: row.querySelector('[data-drive-field="id"]').value,
|
||||
name: row.querySelector('[data-drive-field="name"]').value,
|
||||
path: row.querySelector('[data-drive-field="path"]').value,
|
||||
min_free_gb: Number(row.querySelector('[data-drive-field="min_free_gb"]').value),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderReleaseProviderSettings() {
|
||||
const providers = state.config?.release_providers || [];
|
||||
return `<details class="settings-card" open>
|
||||
<summary class="settings-card-head">
|
||||
<div>
|
||||
<h3>Release Providers</h3>
|
||||
<p class="muted">Sources used by the Releases tab for upcoming or missing media context.</p>
|
||||
</div>
|
||||
<span>${providers.length} providers</span>
|
||||
</summary>
|
||||
<div class="settings-grid">${providers.map((provider, idx) => `
|
||||
<div class="setting-row setting-rich provider-setting" data-provider-index="${idx}">
|
||||
<div class="setting-copy">
|
||||
<div>
|
||||
<strong>${esc(provider.name || provider.id || `Provider ${idx + 1}`)}</strong>
|
||||
<small class="setting-path">release_providers[${idx}]</small>
|
||||
</div>
|
||||
<small>Enable status, provider type, and feed URL.</small>
|
||||
</div>
|
||||
<span class="compound-control">
|
||||
<label class="inline-check"><input data-provider-field="enabled" type="checkbox" ${provider.enabled ? "checked" : ""}>Enabled</label>
|
||||
<label><small>ID</small><input data-provider-field="id" type="text" value="${esc(provider.id || "")}"></label>
|
||||
<label><small>Name</small><input data-provider-field="name" type="text" value="${esc(provider.name || "")}"></label>
|
||||
<label><small>Type</small><select data-provider-field="type">
|
||||
${["rss", "json"].map((type) => `<option value="${type}" ${provider.type === type ? "selected" : ""}>${type}</option>`).join("")}
|
||||
</select></label>
|
||||
<label class="span-2"><small>URL</small><input data-provider-field="url" type="text" value="${esc(provider.url || "")}"></label>
|
||||
</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function collectReleaseProviderSettings() {
|
||||
return [...document.querySelectorAll("[data-provider-index]")].map((row) => ({
|
||||
id: row.querySelector('[data-provider-field="id"]').value,
|
||||
name: row.querySelector('[data-provider-field="name"]').value,
|
||||
enabled: row.querySelector('[data-provider-field="enabled"]').checked,
|
||||
type: row.querySelector('[data-provider-field="type"]').value,
|
||||
url: row.querySelector('[data-provider-field="url"]').value,
|
||||
}));
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
if (!state.config) return;
|
||||
$("settingsForm").innerHTML = settingsGroups.map((group) => `
|
||||
<details class="settings-card" open>
|
||||
<summary class="settings-card-head">
|
||||
<div>
|
||||
<h3>${esc(group.title)}</h3>
|
||||
<p class="muted">${esc(group.description)}</p>
|
||||
</div>
|
||||
<span>${group.fields.length} settings</span>
|
||||
</summary>
|
||||
<div class="settings-grid">${group.fields.map((field) => settingField(fieldMeta(field))).join("")}</div>
|
||||
</details>
|
||||
`).join("") + renderDriveSettings() + renderReleaseProviderSettings();
|
||||
syncSettingControls();
|
||||
renderThemeOptions();
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const button = $("settingsSaveButton");
|
||||
const notice = $("settingsNotice");
|
||||
button.disabled = true;
|
||||
button.textContent = "Saving...";
|
||||
if (notice) notice.textContent = "";
|
||||
const updates = {};
|
||||
try {
|
||||
document.querySelectorAll("[data-setting]").forEach((field) => {
|
||||
const path = field.dataset.setting;
|
||||
if (!path) return;
|
||||
if (field.type === "checkbox") {
|
||||
setPath(updates, path, field.checked);
|
||||
} else if (field.type === "range" || field.type === "number") {
|
||||
setPath(updates, path, Number(field.value));
|
||||
} else if (field.dataset.settingType === "list") {
|
||||
setPath(updates, path, field.value.split(",").map((item) => item.trim()).filter(Boolean));
|
||||
} else {
|
||||
if ((path === "metadata.tmdb_api_key" || path === "metadata.tmdb_bearer_token") && field.value === "********") {
|
||||
return;
|
||||
}
|
||||
setPath(updates, path, field.value);
|
||||
}
|
||||
});
|
||||
updates.drives = collectDriveSettings();
|
||||
updates.release_providers = collectReleaseProviderSettings();
|
||||
const payload = await api("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
state.config = payload.config;
|
||||
$("configView").textContent = JSON.stringify(state.config, null, 2);
|
||||
renderSettings();
|
||||
await loadDashboard();
|
||||
const message = "Settings saved. Run a library scan to refresh TMDb covers and episode metadata.";
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, "success");
|
||||
} catch (error) {
|
||||
const message = `Settings save failed: ${error.message}`;
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "Save settings";
|
||||
}
|
||||
}
|
||||
|
||||
async function testTmdb() {
|
||||
const button = $("tmdbTestButton");
|
||||
const notice = $("settingsNotice");
|
||||
button.disabled = true;
|
||||
button.textContent = "Testing...";
|
||||
if (notice) notice.textContent = "Testing TMDb API credentials...";
|
||||
try {
|
||||
const payload = await api("/api/metadata/tmdb/test", { method: "POST" });
|
||||
const result = payload.tmdb || {};
|
||||
const details = result.ok && result.image_base
|
||||
? ` Poster images available from ${result.image_base}.`
|
||||
: "";
|
||||
const message = `${result.ok ? "TMDb API test passed." : "TMDb API test failed."} ${result.message || ""}${details}`;
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, result.ok ? "success" : "error");
|
||||
} catch (error) {
|
||||
const message = `TMDb API test failed: ${error.message}`;
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "TMDb API Test";
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolOutput(title, rows) {
|
||||
$("toolOutput").innerHTML = `<h3>${esc(title)}</h3>${rows}`;
|
||||
}
|
||||
|
||||
async function loadTranscoder() {
|
||||
const payload = await api("/api/tools/transcoder");
|
||||
const plan = payload.transcoder;
|
||||
renderToolOutput("Transcode Queue", `
|
||||
<p class="muted">${plan.count} conversion candidates. ffmpeg ${plan.ffmpeg_available ? "is available" : "is not available"}.</p>
|
||||
<div class="download-list">${plan.targets.slice(0, 20).map((item) => `
|
||||
<div class="download"><strong>${esc(item.name)}</strong><span class="muted">${esc(item.output)}</span><code>${esc(item.command.join(" "))}</code></div>
|
||||
`).join("") || "<p class='muted'>No transcode candidates found.</p>"}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function runNextTranscode() {
|
||||
const payload = await api("/api/tools/transcoder/run-next", { method: "POST" });
|
||||
const result = payload.transcoder;
|
||||
renderToolOutput("Transcoder Result", `
|
||||
<p class="muted">Status: ${result.status}. ${result.count || 0} candidates in queue.</p>
|
||||
${result.ran ? `<div class="download"><strong>${esc(result.ran.name)}</strong><span>${esc(result.ran.output)}</span></div>` : ""}
|
||||
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
|
||||
`);
|
||||
}
|
||||
|
||||
async function runSubtitleAudit() {
|
||||
const payload = await api("/api/tools/subtitles");
|
||||
const audit = payload.audit;
|
||||
renderToolOutput("Subtitle Audit", `
|
||||
<p class="muted">Checked ${audit.checked} indexed media files. ${audit.missing_count} missing subtitles. ${audit.unknown_count || 0} need a fresh library scan.</p>
|
||||
<div class="download-list">${audit.missing.slice(0, 50).map((item) => `
|
||||
<div class="download"><strong>${esc(item.name)}</strong><span class="muted">${esc(item.path)}</span><small>Expected: ${esc(item.expected.join(", "))}</small></div>
|
||||
`).join("") || "<p class='muted'>Every indexed media file has a sidecar subtitle.</p>"}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function runDuplicateFinder() {
|
||||
const payload = await api("/api/tools/duplicates");
|
||||
const result = payload.duplicates;
|
||||
renderToolOutput("Duplicate Finder", `
|
||||
<p class="muted">${result.count} duplicate title groups found in the cached library index.</p>
|
||||
<div class="download-list">${result.duplicates.slice(0, 50).map((group) => `
|
||||
<div class="download">
|
||||
<strong>${esc(group.title || group.key || "Unknown")}</strong>
|
||||
<span class="muted">${group.count} files, ${bytes(group.total_size)}</span>
|
||||
${(group.files || []).map((file) => `<small>${esc(file.path)} (${bytes(file.size)})</small>`).join("")}
|
||||
</div>
|
||||
`).join("") || "<p class='muted'>No duplicate title groups found.</p>"}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function runScan() {
|
||||
$("scanButton").disabled = true;
|
||||
try {
|
||||
const scan = await api("/api/scan", { method: "POST" });
|
||||
$("downloadsStatus").textContent = scan.started ? "Scan started. Organizer queue will update as files are parsed." : "A scan is already running. Showing the latest queue.";
|
||||
await Promise.all([loadDashboard(), loadDownloads()]);
|
||||
setTimeout(() => Promise.all([loadDashboard(), loadDownloads()]).catch(() => {}), 2500);
|
||||
} finally {
|
||||
$("scanButton").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLibrary() {
|
||||
$("libraryScanButton").disabled = true;
|
||||
$("libraryStatus").textContent = "Scanning Movies, TV, and TV Shows folders...";
|
||||
try {
|
||||
const payload = await api("/api/library/scan", { method: "POST" });
|
||||
state.library = payload.library;
|
||||
state.dashboard.library = {
|
||||
...state.dashboard.library,
|
||||
counts: payload.library.counts,
|
||||
drives: payload.library.drives,
|
||||
extensions: payload.library.extensions,
|
||||
scanned_files: payload.library.scanned_files,
|
||||
truncated: payload.library.truncated,
|
||||
};
|
||||
state.libraryLimit = 120;
|
||||
state.selectedMedia = null;
|
||||
renderDashboard();
|
||||
await loadReleases();
|
||||
} finally {
|
||||
$("libraryScanButton").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showStartupError(error) {
|
||||
const message = error?.message || String(error);
|
||||
const status = $("statusLine");
|
||||
if (status) status.textContent = `Frontend startup failed: ${message}`;
|
||||
toast(`Frontend startup failed: ${message}`, "error");
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
function init() {
|
||||
setTheme(localStorage.getItem("sortarr-theme") || "slate");
|
||||
window.addEventListener("hashchange", renderRoute);
|
||||
renderRoute();
|
||||
$("refreshButton").addEventListener("click", loadDashboard);
|
||||
$("scanButton").addEventListener("click", runScan);
|
||||
$("libraryScanButton").addEventListener("click", scanLibrary);
|
||||
$("downloadsRefresh").addEventListener("click", loadDownloads);
|
||||
$("releaseRefresh").addEventListener("click", loadReleases);
|
||||
$("libraryFilter").addEventListener("input", () => {
|
||||
state.libraryLimit = 500;
|
||||
renderLibrary();
|
||||
});
|
||||
$("settingsSaveButton").addEventListener("click", saveSettings);
|
||||
$("tmdbTestButton").addEventListener("click", testTmdb);
|
||||
$("transcoderPlanButton").addEventListener("click", loadTranscoder);
|
||||
$("transcoderRunButton").addEventListener("click", runNextTranscode);
|
||||
$("subtitleAuditButton").addEventListener("click", runSubtitleAudit);
|
||||
$("duplicateButton").addEventListener("click", runDuplicateFinder);
|
||||
Promise.allSettled([loadConfig(), loadDashboard(), loadDownloads(), loadReleases()]).then((results) => {
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
if (failed && !state.dashboard) {
|
||||
showStartupError(failed.reason);
|
||||
}
|
||||
});
|
||||
setInterval(loadDashboard, 30000);
|
||||
}
|
||||
|
||||
try {
|
||||
init();
|
||||
} catch (error) {
|
||||
showStartupError(error);
|
||||
}
|
||||
151
web/src/index.html
Normal file
151
web/src/index.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!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">
|
||||
<h2>Library Contents</h2>
|
||||
<div class="actions">
|
||||
<input id="libraryFilter" placeholder="Filter library">
|
||||
<button id="libraryScanButton">Scan library</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="libraryStatus" class="muted"></p>
|
||||
<div id="libraryTabs" class="segmented"></div>
|
||||
<div id="libraryGrid" class="poster-grid"></div>
|
||||
<div id="libraryPager" class="pager"></div>
|
||||
<div id="libraryDetail" class="media-detail"></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="toastHost" class="toast-host" aria-live="polite"></div>
|
||||
<script src="/app.js?v=20260514-3"></script>
|
||||
</body>
|
||||
</html>
|
||||
729
web/src/styles.css
Normal file
729
web/src/styles.css
Normal file
@@ -0,0 +1,729 @@
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: calc(15px - (var(--compact, 0) * 1px));
|
||||
}
|
||||
.app-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: calc(20px * var(--density));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; margin-bottom: 28px; }
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 800;
|
||||
}
|
||||
.brand small, #statusLine, .muted { color: var(--muted); }
|
||||
nav { display: grid; gap: 6px; }
|
||||
nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
nav a.active, nav a:hover { background: var(--surface-2); color: var(--text); }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
select, input, button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:disabled { cursor: wait; opacity: .62; }
|
||||
main { padding: 24px; display: grid; gap: 24px; align-content: start; }
|
||||
.topbar, .section-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { font-size: 28px; }
|
||||
h2 { font-size: 17px; }
|
||||
h3 { font-size: 14px; color: var(--muted); font-weight: 700; }
|
||||
.actions { display: flex; gap: 10px; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.overview-grid { grid-template-columns: 1.3fr 1fr 1fr; }
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: calc(18px * var(--density));
|
||||
}
|
||||
.storage-list, .event-list, .download-list, .bars { display: grid; gap: 12px; margin-top: 16px; }
|
||||
.storage-card { display: grid; gap: 8px; }
|
||||
.meter { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; }
|
||||
.meter span { display: block; height: 100%; background: var(--accent); }
|
||||
.kv { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; }
|
||||
.bar-row { display: grid; grid-template-columns: 72px 1fr 44px; gap: 10px; align-items: center; }
|
||||
.event { border-left: 3px solid var(--accent); padding-left: 10px; color: var(--muted); }
|
||||
.event.error { border-color: var(--bad); }
|
||||
.segmented {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.segmented button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.segmented button.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
|
||||
}
|
||||
.segmented span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.table-wrap { overflow: auto; margin-top: 16px; max-height: 68vh; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
td:first-child {
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.download, .release {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.download.warning { border-color: var(--warn); }
|
||||
.poster-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.poster-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.poster-card.active .poster,
|
||||
.poster-card:hover .poster {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.poster {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
.poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.poster-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--surface-2), var(--surface));
|
||||
color: var(--accent);
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.poster-card strong,
|
||||
.poster-card small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.poster-card small { color: var(--muted); }
|
||||
.version-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast, #08111f);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
|
||||
}
|
||||
.media-detail {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.detail-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 22px;
|
||||
}
|
||||
.detail-poster {
|
||||
align-self: start;
|
||||
}
|
||||
.detail-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-block,
|
||||
.season-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.season-list details {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.season-list summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.episode-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.episode {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--good);
|
||||
}
|
||||
.episode.missing { border-left-color: var(--bad); }
|
||||
.episode.upcoming { border-left-color: var(--warn); }
|
||||
.episode p {
|
||||
margin-top: 5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.episode-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.probe-output {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.stream-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.stream-grid section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 12px;
|
||||
}
|
||||
.stream-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.track-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.track-actions button {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.downloads-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.downloads-layout article { min-width: 0; }
|
||||
.queue-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.queue-summary span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.queue-summary strong {
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
}
|
||||
.download small, .download span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.download.bundle {
|
||||
background: var(--surface);
|
||||
}
|
||||
.bundle-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.bundle-head div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.subtitle-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.subtitle-chips span {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.download.loose {
|
||||
opacity: .82;
|
||||
}
|
||||
.organizer-card {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.organizer-card.needs-review,
|
||||
.organizer-card.dry-run {
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
.organizer-card.low-confidence,
|
||||
.organizer-card.skipped {
|
||||
border-left-color: var(--bad);
|
||||
}
|
||||
.organizer-card.moved {
|
||||
border-left-color: var(--good);
|
||||
}
|
||||
.confidence {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.confidence.good { color: var(--good); }
|
||||
.confidence.warn { color: var(--warn); }
|
||||
.confidence.bad { color: var(--bad); }
|
||||
.plan-paths {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.plan-paths small {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.plan-paths b {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.subtitle-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plan-actions button {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.release-grid, .tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.release img {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.release.missing { border-color: var(--bad); }
|
||||
.release.upcoming { border-color: var(--warn); }
|
||||
.release a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.tool-output { margin-top: 18px; display: grid; gap: 12px; }
|
||||
.tool-output h3 { margin: 0; font-size: 15px; }
|
||||
code {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
}
|
||||
.settings-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, .45fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.settings-hero h3 { margin: 0 0 6px; }
|
||||
.settings-notice {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.settings-notice:not(:empty) { display: block; }
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
max-width: 1180px;
|
||||
}
|
||||
.settings-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.settings-card-head::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.settings-card-head h3 {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
.settings-card-head p {
|
||||
margin: 0;
|
||||
}
|
||||
.settings-card-head > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(260px, 520px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
padding: 16px;
|
||||
}
|
||||
.setting-row:last-child { border-bottom: 0; }
|
||||
.setting-rich {
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
}
|
||||
.setting-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 620px;
|
||||
}
|
||||
.setting-copy > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.setting-copy small,
|
||||
.setting-rich small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.setting-path {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.setting-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.setting-control.wide {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.setting-row input[type="number"], .setting-row select { width: 132px; }
|
||||
.setting-row input[type="text"],
|
||||
.setting-row input[type="password"],
|
||||
.setting-row textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 10px;
|
||||
}
|
||||
.setting-row textarea {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
.setting-row input[type="checkbox"] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-self: center;
|
||||
}
|
||||
.switch {
|
||||
justify-self: end;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
}
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
.switch span {
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
}
|
||||
.switch span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: transform .15s ease, background .15s ease;
|
||||
}
|
||||
.switch input:checked + span {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--surface-2));
|
||||
}
|
||||
.switch input:checked + span::after {
|
||||
transform: translateX(20px);
|
||||
background: var(--accent);
|
||||
}
|
||||
.range-control {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.range-control input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.range-control span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.compound-control {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.compound-control input,
|
||||
.compound-control select {
|
||||
min-width: 0;
|
||||
}
|
||||
.compound-control label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.compound-control .inline-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.compound-control .span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
max-width: 980px;
|
||||
}
|
||||
.theme-option {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.theme-option.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
.theme-swatch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
width: 42px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
.theme-swatch i,
|
||||
.theme-swatch b,
|
||||
.theme-swatch em {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.theme-swatch i { background: var(--surface); }
|
||||
.theme-swatch b { background: var(--surface-2); }
|
||||
.theme-swatch em {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--accent);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: min(420px, calc(100vw - 36px));
|
||||
}
|
||||
.toast {
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
.toast.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.toast.success { border-left-color: var(--good); }
|
||||
.toast.error { border-left-color: var(--bad); }
|
||||
@media (max-width: 900px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.overview-grid { grid-template-columns: 1fr; }
|
||||
.downloads-layout { grid-template-columns: 1fr; }
|
||||
.detail-shell { grid-template-columns: 1fr; }
|
||||
.detail-poster { max-width: 220px; }
|
||||
.episode { grid-template-columns: 1fr; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.actions, .pager { flex-wrap: wrap; }
|
||||
.settings-hero { grid-template-columns: 1fr; }
|
||||
.settings-card-head { flex-direction: column; }
|
||||
.setting-row { grid-template-columns: 1fr; gap: 14px; }
|
||||
.range-control { min-width: 0; }
|
||||
.setting-row input[type="text"],
|
||||
.setting-row input[type="password"],
|
||||
.setting-row textarea,
|
||||
.compound-control {
|
||||
width: 100%;
|
||||
}
|
||||
.compound-control { grid-template-columns: 1fr; }
|
||||
.bundle-head { flex-direction: column; }
|
||||
}
|
||||
134
web/src/themes.css
Normal file
134
web/src/themes.css
Normal file
@@ -0,0 +1,134 @@
|
||||
:root,
|
||||
[data-theme="slate"] {
|
||||
--bg: #111318;
|
||||
--surface: #191d24;
|
||||
--surface-2: #222833;
|
||||
--text: #eef2f7;
|
||||
--muted: #96a1af;
|
||||
--border: #303846;
|
||||
--accent: #60a5fa;
|
||||
--good: #34d399;
|
||||
--warn: #fbbf24;
|
||||
--bad: #f87171;
|
||||
--radius: 8px;
|
||||
--density: 1;
|
||||
--font: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="midnight"] {
|
||||
--bg: #080b12;
|
||||
--surface: #121826;
|
||||
--surface-2: #1b2740;
|
||||
--text: #f8fafc;
|
||||
--muted: #93a4bd;
|
||||
--border: #293550;
|
||||
--accent: #22d3ee;
|
||||
--good: #4ade80;
|
||||
--warn: #facc15;
|
||||
--bad: #fb7185;
|
||||
}
|
||||
|
||||
[data-theme="graphite"] {
|
||||
--bg: #151515;
|
||||
--surface: #202020;
|
||||
--surface-2: #2b2b2b;
|
||||
--text: #f5f5f5;
|
||||
--muted: #b2b2b2;
|
||||
--border: #3a3a3a;
|
||||
--accent: #a3e635;
|
||||
--good: #86efac;
|
||||
--warn: #fde047;
|
||||
--bad: #fca5a5;
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
--bg: #202632;
|
||||
--surface: #2c3444;
|
||||
--surface-2: #374155;
|
||||
--text: #eceff4;
|
||||
--muted: #c0c9d8;
|
||||
--border: #4c566a;
|
||||
--accent: #88c0d0;
|
||||
--good: #a3be8c;
|
||||
--warn: #ebcb8b;
|
||||
--bad: #bf616a;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
--bg: #1d1b26;
|
||||
--surface: #282a36;
|
||||
--surface-2: #343746;
|
||||
--text: #f8f8f2;
|
||||
--muted: #c7bfdc;
|
||||
--border: #44475a;
|
||||
--accent: #bd93f9;
|
||||
--good: #50fa7b;
|
||||
--warn: #f1fa8c;
|
||||
--bad: #ff5555;
|
||||
}
|
||||
|
||||
[data-theme="solar"] {
|
||||
--bg: #f4f0df;
|
||||
--surface: #fffaf0;
|
||||
--surface-2: #eee8d5;
|
||||
--text: #273238;
|
||||
--muted: #657b83;
|
||||
--border: #d5cdb6;
|
||||
--accent: #268bd2;
|
||||
--good: #2aa198;
|
||||
--warn: #b58900;
|
||||
--bad: #dc322f;
|
||||
}
|
||||
|
||||
[data-theme="forest"] {
|
||||
--bg: #101812;
|
||||
--surface: #18251b;
|
||||
--surface-2: #213326;
|
||||
--text: #eef7ed;
|
||||
--muted: #a7b9a6;
|
||||
--border: #314638;
|
||||
--accent: #7ddf64;
|
||||
--good: #22c55e;
|
||||
--warn: #eab308;
|
||||
--bad: #ef4444;
|
||||
}
|
||||
|
||||
[data-theme="marine"] {
|
||||
--bg: #081417;
|
||||
--surface: #102225;
|
||||
--surface-2: #183236;
|
||||
--text: #edfdfd;
|
||||
--muted: #9fc5c7;
|
||||
--border: #28494e;
|
||||
--accent: #2dd4bf;
|
||||
--good: #5eead4;
|
||||
--warn: #fcd34d;
|
||||
--bad: #f97373;
|
||||
}
|
||||
|
||||
[data-theme="ember"] {
|
||||
--bg: #171111;
|
||||
--surface: #241818;
|
||||
--surface-2: #362221;
|
||||
--text: #fff7ed;
|
||||
--muted: #d7b6a2;
|
||||
--border: #513530;
|
||||
--accent: #fb923c;
|
||||
--good: #84cc16;
|
||||
--warn: #facc15;
|
||||
--bad: #f43f5e;
|
||||
}
|
||||
|
||||
[data-theme="paper"] {
|
||||
--bg: #f7f8fa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef1f5;
|
||||
--text: #151a22;
|
||||
--muted: #5f6b7a;
|
||||
--border: #d6dce5;
|
||||
--accent: #2563eb;
|
||||
--good: #16a34a;
|
||||
--warn: #ca8a04;
|
||||
--bad: #dc2626;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user