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