#!/usr/bin/env -S uv run --script # /// script # dependencies = ["click", "httpx"] # /// import contextlib import dataclasses import datetime import hashlib import itertools import json import logging import os import re import shlex import shutil import socket import subprocess import threading import time from collections.abc import Callable from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from functools import partial, wraps from os import getenv from pathlib import Path from typing import Any from urllib.parse import parse_qs from wsgiref.simple_server import WSGIRequestHandler, make_server import click import httpx logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") HandlerFunc = Callable[["Request"], "Response"] client = httpx.Client(base_url="http://klein:18543", timeout=30) @dataclasses.dataclass class FfprobeResult: duration_sec: float duration_human: str codec: str fps: float size_bytes: int width: int height: int bitrate: int container: str ar: float sample_ar: float tags: dict[str, str] @property def resolution(self) -> str: return f"{self.width}x{self.height}" def ffprobe(video_path: Path) -> FfprobeResult: proc = subprocess.run( args=[ "ffprobe", "-v", "error", "-show_streams", "-show_format", "-print_format", "json", str(video_path), ], text=True, capture_output=True, ) try: proc.check_returncode() except subprocess.CalledProcessError as e: logging.error(f"failed to run ffprobe. stderr={e.stderr}") raise output: dict = json.loads(proc.stdout) video_stream: dict = [s for s in output.get("streams", []) if s.get("codec_type") == "video"][0] video_format: dict = output.get("format", {}) width, height = video_stream.get("width"), video_stream.get("height") codec = video_stream.get("codec_name") duration = float(video_format.get("duration", 0)) fps_long = eval(video_stream.get("r_frame_rate", "0")) fps = float(f"{fps_long:.3f}") if fps_long else 0.0 size = int(video_format.get("size", 0)) duration_time = datetime.timedelta(seconds=int(duration)) bitrate = int(video_stream.get("bit_rate", video_format.get("bit_rate", 0))) tags = video_format.get("tags", {}) try: sample_w, sample_h = list(map(int, video_stream["sample_aspect_ratio"].split(":"))) sample_ar = (sample_w / sample_h) or 1.0 except: sample_ar = 1.0 return FfprobeResult( duration_sec=duration, duration_human=str(duration_time), codec=codec, fps=fps, size_bytes=size, width=width, bitrate=bitrate, height=height, container=video_path.suffix.lstrip(".").lower(), ar=width / height if height else 1.0, sample_ar=sample_ar, tags=tags, ) def hash_partial(f: Path) -> str: sha1 = hashlib.sha1() chunk_size = 1024 * 1024 * 10 # 10MB chunk size total_read = 0 with f.open("rb") as file: while chunk := file.read(chunk_size): total_read += len(chunk) sha1.update(chunk) break # Only reads the first 10MB return f"sha1:{total_read}:{sha1.hexdigest()}" def chunked(iterable, n): it = iter(iterable) while True: chunk = tuple(itertools.islice(it, n)) if not chunk: return yield chunk def retry(max_attempts: int = 3, delay: float = 1.0): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): last_exception = None for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception as e: last_exception = e if attempt < max_attempts: logging.warning(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...") time.sleep(delay) else: logging.error(f"All {max_attempts} attempts failed.") raise last_exception return wrapper return decorator def url_from_path(input_path: str) -> str: return input_path.replace("/mnt/box/files/", "https://u201686.your-storagebox.de/files/") def video_exists(file_path: str) -> bool: response = client.patch( "/videos", params={"file_path": f"eq.{file_path}", "select": "file_path"}, json={"last_seen_at": "now"}, headers={"Prefer": "return=representation"}, ) response.raise_for_status() return bool(response.json()) def save_video( category: str, file_path: str, ffprobe_data: dict, video_hash: str, ): response = client.post( "/videos", params={"on_conflict": "file_path", "select": "file_path"}, json={ "category": category, "file_path": file_path, "ffprobe": ffprobe_data, "hash_partial": video_hash, }, headers={"Prefer": "resolution=ignore-duplicates,return=representation"}, ) response.raise_for_status() if not response.json(): response = client.patch( "/videos", params={"file_path": f"eq.{file_path}"}, json={"last_seen_at": "now"}, ) response.raise_for_status() def save_error(file_path: str, stderr: str) -> None: response = client.post( "/scan_state", json={"file_path": file_path, "stderr": stderr}, headers={"Prefer": "resolution=ignore-duplicates"}, ) response.raise_for_status() def category_for_file(file_path: Path) -> str: s = str(file_path) if s.startswith("/Volumes/"): return f"downloaded.{file_path.parts[1].lower()}" if s.startswith("/mnt/box"): return "box" return "local" def scan_videos(video_paths: list[Path]) -> None: new_videos = [] for i, f in enumerate(video_paths): if "/_picks/" in str(f): continue progress = f"[{i + 1}/{len(video_paths)}]" if f.is_dir(): # Recursively find files in directory scan_videos(list(f.glob("**/*"))) continue if f.suffix.lower() not in [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm"]: continue file_path = str(f.absolute()) if video_exists(file_path=file_path): logging.debug(f"{progress} video already exists {f=}") continue logging.info(f"{progress} probing {f=}") try: probed = ffprobe(f) except Exception as e: logging.error(f"Failed to probe {f}: {e}") save_error(file_path=file_path, stderr=str(e)) continue file_hash = hash_partial(f) save_video( category=category_for_file(f), file_path=file_path, ffprobe_data=probed.__dict__, video_hash=file_hash, ) logging.info(f"{progress} saved: {f.name}") new_videos.append(file_path) if new_videos: parse_releases(new_videos) def parse_releases(file_paths: list[str]) -> None: api_key = os.getenv("OPENROUTER_API_KEY") if not api_key: logging.warning("OPENROUTER_API_KEY not set, skipping filename parsing") return # Chunk file_paths by 20 items to avoid prompt token limits for i, chunk in enumerate(chunked(file_paths, 20), 1): filenames = [Path(p).name for p in chunk] logging.info(f"Parsing filenames for {len(filenames)} videos (chunk {i}) via OpenRouter...") try: parsed_data = parse_filenames_with_ai(filenames, api_key) for item in parsed_data: actors = item.get("actors") if not isinstance(actors, list) or len(actors) == 0: continue fname = item.get("filename") if not fname: continue # Find the full path that matches this filename in the current chunk full_path = next((p for p in chunk if Path(p).name == fname), None) if not full_path: continue # Update the release column release_info = {k: v for k, v in item.items() if k != "filename"} response = client.patch( "/videos", params={"file_path": f"eq.{full_path}"}, json={"release": release_info}, ) response.raise_for_status() logging.info(f"Successfully updated release info for chunk {i}") except Exception as e: logging.error(f"Failed to parse filenames or update database for chunk {i}: {e}") @retry(max_attempts=3, delay=0.1) def parse_filenames_with_ai(filenames: list[str], api_key: str) -> list[dict]: system_prompt = """you are an expert in parsing file names. your task is to parse the actors, studio and date, title out of filenames give me a JSON array with these fields [{filename, studio, released_at, title, actors: [...]}}]. Omit the missing / empty fields. Actor names are usually 2 words (name and last name) but sometimes they only contain a single word. Ignore male names. Title is the remaining part after studio, date, actors; and don't usually contain the actor names. Ignore the quality and category indicators. output only valid JSON without any wrappers or quotes. for example: InTheCrack.E1890.Casey.Norhman.Provence.XXX.1080p.HEVC.x265.PRT.mp4 -> studio=InTheCrack, actors=["Casey Norhman"], title=E1890 BlackedRaw.26.05.16.Agatha.Vega.And.Ella.Hughes.Knockout.Babes.Fuck.Two.Cops.On.Duty.XXX.1080p.HEVC.x265.PRT.torrent -> {studio=BlackedRaw, actors=["Agatha Vega", "Ella Hughes"], title="Knockout Babes Fuck Two Cops On Duty", released_at=2026-05-16} StepSiblingsCaught.26.05.14.Nata.Gold.XXX.720p.HEVC.x265.PRT.mp4 -> studio=StepSiblingsCaught, released_at=2026-05-14, actors=["Nata Gold"] HookupHotshot.26.02.06.Episode.453.Shrooms.Q.XXX.720p.HEVC.x265.PRT.mp4 -> studio=HookupHotshot, released_at=2026-02-06, actors=["Shrooms Q"], title="Episode 453" """ user_prompt = "\n".join(filenames) response = httpx.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {api_key}", }, json={ "model": "mistralai/ministral-3b-2512", "prompt_cache_key": "file_parsing", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "temperature": 0, }, timeout=20, ) response.raise_for_status() result = response.json() content = result["choices"][0]["message"]["content"].strip() # Remove potential markdown code blocks if content.startswith("```"): content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.MULTILINE) parsed_data = json.loads(content) if not isinstance(parsed_data, list): parsed_data = [parsed_data] # Clean output: omit empty strings and empty arrays cleaned_data = [] for entry in parsed_data: cleaned_entry = {k: v for k, v in entry.items() if v != "" and not (isinstance(v, list) and not v)} actors = cleaned_entry.get("actors", []) if isinstance(actors, list) and len(actors) > 0: actors = [a for a in actors if a and "@" not in a] cleaned_entry["actors"] = actors cleaned_data.append(cleaned_entry) return cleaned_data @dataclasses.dataclass @dataclasses.dataclass class Request: path: str method: str payload: dict[str, Any] query: dict[str, str] environ: dict[str, Any] @classmethod def from_environ(cls, environ: dict) -> "Request": method = environ["REQUEST_METHOD"].upper() query = parse_qs(environ.get("QUERY_STRING", ""), keep_blank_values=True) payload = {} content_type = environ.get("CONTENT_TYPE", "") if method == "POST" and "application/json" in content_type: try: length = int(environ.get("CONTENT_LENGTH", 0)) if length > 0: payload = json.loads(environ["wsgi.input"].read(length)) except (ValueError, json.JSONDecodeError): pass return cls( path=environ["PATH_INFO"], method=method, payload=payload, query={k: v[0] for k, v in query.items()}, environ=environ, ) @dataclasses.dataclass class Response: status: str headers: list[tuple[str, str]] body: Any @classmethod def error(cls, message: str) -> "Response": return cls( status="400 Bad Request", headers=[("Content-Type", "application/json")], body=json.dumps({"error": message}) + "\n", ) @classmethod def from_html(cls, html: str) -> "Response": return cls( status="200 OK", headers=[("Content-Type", "text/html; charset=utf-8")], body=html, ) @classmethod def from_json(cls, data: Any) -> "Response": def _default(obj): if isinstance(obj, (datetime.date, datetime.datetime)): return obj.isoformat() raise TypeError(f"Object of type {type(obj)} is not JSON serializable") return cls( status="200 OK", headers=[("Content-Type", "application/json")], body=json.dumps(data, default=_default) + "\n", ) @classmethod def from_exception(cls, e: Exception) -> "Response": return cls( status="500 Internal Server Error", headers=[("Content-Type", "application/json")], body=json.dumps({"error": str(e)}) + "\n", ) class TinyAPI: def __init__(self, handlers: dict[str, HandlerFunc]): self.routes = self._prepare_routes(handlers) def _prepare_routes(self, handlers: dict[str, HandlerFunc]): routes = [] for k, h in handlers.items(): parts = k.split(maxsplit=1) method = parts[0].upper() path = parts[1].rstrip("/") or "/" try: routes.append((method, re.compile(f"^{path}$"), h)) except re.error as e: raise ValueError(f"Invalid regex pattern '{path}': {e}") return routes @staticmethod def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) return s.getsockname()[1] def __call__(self, environ: dict, start_response: Callable): req = Request.from_environ(environ) path = req.path.rstrip("/") or "/" handler = None for method, pattern, h in self.routes: if req.method == method and pattern.match(path): handler = h break if not handler: res = Response.from_json({"error": "Not Found"}) res.status = "404 Not Found" else: try: res = handler(req) except Exception as e: logging.exception("Handler crash") res = Response.from_exception(e) body = res.body if isinstance(res.body, bytes) else res.body.encode("utf-8") headers = res.headers + [("Content-Length", str(len(body)))] start_response(res.status, headers) return [body] @contextlib.contextmanager def serve(self, host: str = "localhost", port: int = 0): if port == 0: port = self.find_free_port() class LoggedRequestHandler(WSGIRequestHandler): def log_message(self, format: str, *args: Any) -> None: # args usually contains (request_line, status_code, size) # We redirect to our configured logger instead of sys.stderr logging.info("%s - %s", self.address_string(), format % args) server = make_server(host, port, self, handler_class=LoggedRequestHandler) url = f"http://{host}:{port}" thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() logging.info(f"Serving on {url}") try: yield url finally: logging.info("Shutting down server...") server.shutdown() server.server_close() thread.join(timeout=5) def handle_search(req: Request) -> Response: query: str = req.payload.get("query", "") if not query: return Response.error("Query is empty") params = filter_to_postgrest(query) params.update( { "select": "id,file_path,file_name,actors,size_bytes,duration_human,created_at,ffprobe", "order": "created_at.desc,id.desc", } ) rows = fetch_videos(params=params) results = [] for row in rows: probe = row.get("ffprobe") or {} width = probe.get("width") height = probe.get("height") resolution = f"{width}x{height}" if width is not None and height is not None else None results.append( { "id": row["id"], "file_name": row["file_name"], "file_path": row["file_path"], "actors": row.get("actors"), "size_mb": row["size_bytes"] // 1048576, "resolution": resolution, "duration_human": row.get("duration_human"), "created_at": row.get("created_at"), "download_url": url_from_path(input_path=row["file_path"]), } ) return Response.from_json(results) def fetch_videos(params: dict[str, str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] total: int | None = None while total is None or len(rows) < total: try: response = client.get( "/videos", params={**params, "limit": "1000", "offset": str(len(rows))}, headers={"Prefer": "count=exact"} if total is None else None, ) except httpx.ConnectError as e: raise RuntimeError(f"Cannot reach database at {client.base_url} ({e}). Is the server running and reachable?") from e response.raise_for_status() page = response.json() if total is None: total = int(response.headers["Content-Range"].rsplit("/", 1)[1]) if not page: break rows.extend(page) return rows def tokenize_filter_groups(filter_query: str) -> list[list[str]]: lexer = shlex.shlex(filter_query, posix=True, punctuation_chars="|") lexer.commenters = "" lexer.whitespace_split = True tokens = list(lexer) groups: list[list[str]] = [[]] for token in tokens: if token == "|": if groups[-1]: groups.append([]) continue groups[-1].append(token) return [group for group in groups if group] def token_to_conditions(token: str) -> list[str]: if ":" in token: key, value = token.split(":", 1) key = key.strip().lower() value = value.strip() if not value: return [] if key == "file_name": return [f"file_name.ilike.{postgrest_quote(f'%{value}%')}"] if key == "size_mb": match = re.match(r"^(>=|<=|>|<|=)?\s*(\d+(?:\.\d+)?)$", value, re.IGNORECASE) if not match: return [] op = match.group(1) or "=" size_mb = Decimal(match.group(2)) floor_mb = int(size_mb.to_integral_value(rounding=ROUND_FLOOR)) ceil_mb = int(size_mb.to_integral_value(rounding=ROUND_CEILING)) if op == ">": return [f"size_bytes.gte.{(floor_mb + 1) * 1048576}"] if op == ">=": return [f"size_bytes.gte.{ceil_mb * 1048576}"] if op == "<": return [f"size_bytes.lt.{ceil_mb * 1048576}"] if op == "<=": return [f"size_bytes.lt.{(floor_mb + 1) * 1048576}"] if size_mb != floor_mb: return ["size_bytes.gt.0", "size_bytes.lt.0"] return [ f"size_bytes.gte.{floor_mb * 1048576}", f"size_bytes.lt.{(floor_mb + 1) * 1048576}", ] if key == "actor": array_value = value.replace("\\", "\\\\").replace('"', '\\"') return [f'actors.cs.{{"{array_value}"}}'] return [] return [f"file_name.ilike.{postgrest_quote(f'%{token}%')}"] def postgrest_quote(value: str) -> str: if re.search(r"[,(){}\\\"]", value): return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' return value def filter_to_postgrest(filter_query: str) -> dict[str, str]: groups = tokenize_filter_groups(filter_query=filter_query) if not groups: return {} or_clauses: list[str] = [] for group in groups: and_clauses: list[str] = [] for token in group: and_clauses.extend(token_to_conditions(token=token)) if and_clauses: or_clauses.append("and(" + ",".join(and_clauses) + ")") if not or_clauses: return {} return {"or": "(" + ",".join(or_clauses) + ")"} def handle_home(req: Request, query: str = "") -> Response: template_file = Path(__file__).parent / "snot.html" html = template_file.read_text() if query: injected_json = json.dumps({"query": query}) html = f"\n" + html return Response.from_html(html) def handle_assets(req: Request, base_path: Path) -> Response: asset_path = base_path / req.path.lstrip("/") if not asset_path.exists() or not asset_path.is_file(): return Response( status="404 Not Found", headers=[("Content-Type", "text/plain")], body="Asset not found\n", ) content_type = "text/plain" if asset_path.suffix == ".js": content_type = "application/javascript" elif asset_path.suffix == ".css": content_type = "text/css" elif asset_path.suffix in [".html", ".htm"]: content_type = "text/html" elif asset_path.suffix == ".json": content_type = "application/json" return Response( status="200 OK", headers=[("Content-Type", content_type)], body=asset_path.read_text(), ) @contextlib.contextmanager def run_htmlpopup(address: str): exe_path = shutil.which("htmlpopup") if not exe_path: raise RuntimeError("htmlpopup executable not found in PATH. Please install it to use the HTML popup feature.") proc = subprocess.Popen([exe_path, "--title", "s·m·u·t", address], text=True, stdout=subprocess.PIPE) try: yield proc.wait() finally: proc.terminate() @click.group(invoke_without_command=True) @click.pass_context def cli(ctx: click.Context): """Snot - Video Browser and Scanner""" if ctx.invoked_subcommand is None: ctx.invoke(serve) @cli.command() @click.option( "--port", type=int, default=int(getenv("PORT", "0")), help="Port to run the server on", ) @click.option("--query", type=str, default="", help="Initial query string") def serve(port: int, query: str): """Start the web UI""" handlers = { "POST /search": handle_search, "GET /": partial(handle_home, query=query), "GET /.*": partial(handle_assets, base_path=Path(__file__).parent), } api = TinyAPI(handlers=handlers) with api.serve(port=port) as server_url: logging.info(f"Server running at {server_url}") logging.info("Available endpoints:") for path in handlers: logging.info(f" {path}") with run_htmlpopup(server_url): logging.info("HTML popup started. Press Ctrl+C to stop.") @cli.command() @click.argument("paths", nargs=-1, type=Path) def scan(paths: list[Path]): """Scan video files and update the database""" if not paths: click.echo("No paths provided to scan.") return scan_videos(video_paths=list(paths)) @cli.command("mark-deletion") @click.argument("paths", nargs=-1, type=Path) def mark_deletion(paths: list[Path]): """Mark video files for deletion by setting marked_for_deletion_at""" if not paths: click.echo("No paths provided.") return for path in paths: file_name = path.name response = client.patch( "/videos", params={ "file_name": f"eq.{postgrest_quote(file_name)}", "select": "file_path", }, json={"marked_for_deletion_at": "now"}, headers={"Prefer": "return=representation"}, ) response.raise_for_status() rows = response.json() if rows: for row in rows: click.echo(f"Marked for deletion: {row['file_path']}") else: click.echo(f"Not found in database: {file_name}", err=True) continue if not click.get_text_stream("stdin").isatty(): continue stem = path.stem siblings = fetch_videos( params={ "select": "file_path,file_name", "file_name": f"like.{postgrest_quote(f'{stem}.%')}", "marked_for_deletion_at": "is.null", "order": "file_path.asc", } ) siblings = [row for row in siblings if row["file_name"] != file_name] for sibling in siblings: if click.confirm(f" Also mark {sibling['file_name']}?", default=False): response = client.patch( "/videos", params={"file_path": f"eq.{sibling['file_path']}"}, json={"marked_for_deletion_at": "now"}, ) response.raise_for_status() click.echo(f" Marked for deletion: {sibling['file_path']}") if __name__ == "__main__": cli()