Initial commit
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user