refactor(snot): Rewrite to postgrest API

This commit is contained in:
abdus committed 2026-09-28 04:15:45 +02:00
1 parent 2bbf6b36d4
commit 4bc948d77d
1 file changed
+200 -185
+200 -185
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env -S uv run --script #!/usr/bin/env -S uv run --script
# /// script # /// script
# dependencies = ["psycopg[binary]", "click", "httpx"] # dependencies = ["click", "httpx"]
# /// # ///
import argparse
import contextlib import contextlib
import dataclasses import dataclasses
import datetime import datetime
@@ -19,22 +18,22 @@ import socket
import subprocess import subprocess
import threading import threading
import time import time
import typing from collections.abc import Callable
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from functools import partial, wraps from functools import partial, wraps
from os import getenv from os import getenv
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any
from urllib.parse import parse_qs from urllib.parse import parse_qs
from wsgiref.simple_server import WSGIRequestHandler, make_server from wsgiref.simple_server import WSGIRequestHandler, make_server
import click import click
import httpx import httpx
import psycopg
from psycopg import sql as pgsql
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
HandlerFunc = Callable[["Request"], "Response"] HandlerFunc = Callable[["Request"], "Response"]
client = httpx.Client(base_url="http://klein:18543", timeout=30)
@dataclasses.dataclass @dataclasses.dataclass
@@ -70,8 +69,7 @@ def ffprobe(video_path: Path) -> FfprobeResult:
str(video_path), str(video_path),
], ],
text=True, text=True,
stdout=subprocess.PIPE, capture_output=True,
stderr=subprocess.PIPE,
) )
try: try:
proc.check_returncode() proc.check_returncode()
@@ -79,9 +77,7 @@ def ffprobe(video_path: Path) -> FfprobeResult:
logging.error(f"failed to run ffprobe. stderr={e.stderr}") logging.error(f"failed to run ffprobe. stderr={e.stderr}")
raise raise
output: dict = json.loads(proc.stdout) output: dict = json.loads(proc.stdout)
video_stream: dict = [ video_stream: dict = [s for s in output.get("streams", []) if s.get("codec_type") == "video"][0]
s for s in output.get("streams", []) if s.get("codec_type") == "video"
][0]
video_format: dict = output.get("format", {}) video_format: dict = output.get("format", {})
width, height = video_stream.get("width"), video_stream.get("height") width, height = video_stream.get("width"), video_stream.get("height")
codec = video_stream.get("codec_name") codec = video_stream.get("codec_name")
@@ -94,9 +90,7 @@ def ffprobe(video_path: Path) -> FfprobeResult:
tags = video_format.get("tags", {}) tags = video_format.get("tags", {})
try: try:
sample_w, sample_h = list( sample_w, sample_h = list(map(int, video_stream["sample_aspect_ratio"].split(":")))
map(int, video_stream["sample_aspect_ratio"].split(":"))
)
sample_ar = (sample_w / sample_h) or 1.0 sample_ar = (sample_w / sample_h) or 1.0
except: except:
sample_ar = 1.0 sample_ar = 1.0
@@ -151,9 +145,7 @@ def retry(max_attempts: int = 3, delay: float = 1.0):
except Exception as e: except Exception as e:
last_exception = e last_exception = e
if attempt < max_attempts: if attempt < max_attempts:
logging.warning( logging.warning(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
f"Attempt {attempt} failed: {e}. Retrying in {delay}s..."
)
time.sleep(delay) time.sleep(delay)
else: else:
logging.error(f"All {max_attempts} attempts failed.") logging.error(f"All {max_attempts} attempts failed.")
@@ -164,49 +156,55 @@ def retry(max_attempts: int = 3, delay: float = 1.0):
return decorator return decorator
def video_exists(con: psycopg.Connection, file_path: str) -> bool: def url_from_path(input_path: str) -> str:
stmt = con.execute( return input_path.replace("/mnt/box/files/", "https://u201686.your-storagebox.de/files/")
"UPDATE videos SET last_seen_at = NOW() WHERE file_path = %s RETURNING true AS exists",
[file_path],
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"},
) )
row = stmt.fetchone() response.raise_for_status()
if not row: return bool(response.json())
return False
return row["exists"]
def save_video( def save_video(
con: psycopg.Connection,
category: str, category: str,
file_path: str, file_path: str,
ffprobe_data: dict, ffprobe_data: dict,
video_hash: str, video_hash: str,
): ):
sql = """ response = client.post(
INSERT INTO videos(category, file_path, ffprobe, hash_partial) "/videos",
VALUES (%s, %s, %s, %s) params={"on_conflict": "file_path", "select": "file_path"},
ON CONFLICT(file_path) DO UPDATE SET json={
last_seen_at = NOW();""" "category": category,
con.execute( "file_path": file_path,
sql, "ffprobe": ffprobe_data,
[ "hash_partial": video_hash,
category, },
file_path, headers={"Prefer": "resolution=ignore-duplicates,return=representation"},
json.dumps(ffprobe_data),
video_hash,
],
) )
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(con: psycopg.Connection, file_path: str, stderr: str): def save_error(file_path: str, stderr: str) -> None:
sql = """INSERT INTO scan_state(file_path, stderr) VALUES (%s, %s) ON conflict do nothing""" response = client.post(
con.execute( "/scan_state",
sql, json={"file_path": file_path, "stderr": stderr},
[ headers={"Prefer": "resolution=ignore-duplicates"},
file_path,
stderr,
],
) )
response.raise_for_status()
def category_for_file(file_path: Path) -> str: def category_for_file(file_path: Path) -> str:
@@ -218,7 +216,7 @@ def category_for_file(file_path: Path) -> str:
return "local" return "local"
def scan_videos(con: psycopg.Connection, video_paths: list[Path]) -> None: def scan_videos(video_paths: list[Path]) -> None:
new_videos = [] new_videos = []
for i, f in enumerate(video_paths): for i, f in enumerate(video_paths):
if "/_picks/" in str(f): if "/_picks/" in str(f):
@@ -226,7 +224,7 @@ def scan_videos(con: psycopg.Connection, video_paths: list[Path]) -> None:
progress = f"[{i + 1}/{len(video_paths)}]" progress = f"[{i + 1}/{len(video_paths)}]"
if f.is_dir(): if f.is_dir():
# Recursively find files in directory # Recursively find files in directory
scan_videos(con, list(f.glob("**/*"))) scan_videos(list(f.glob("**/*")))
continue continue
if f.suffix.lower() not in [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm"]: if f.suffix.lower() not in [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm"]:
@@ -234,7 +232,7 @@ def scan_videos(con: psycopg.Connection, video_paths: list[Path]) -> None:
file_path = str(f.absolute()) file_path = str(f.absolute())
if video_exists(con, file_path=file_path): if video_exists(file_path=file_path):
logging.debug(f"{progress} video already exists {f=}") logging.debug(f"{progress} video already exists {f=}")
continue continue
@@ -243,27 +241,25 @@ def scan_videos(con: psycopg.Connection, video_paths: list[Path]) -> None:
probed = ffprobe(f) probed = ffprobe(f)
except Exception as e: except Exception as e:
logging.error(f"Failed to probe {f}: {e}") logging.error(f"Failed to probe {f}: {e}")
save_error(con, file_path=file_path, stderr=str(e)) save_error(file_path=file_path, stderr=str(e))
continue continue
file_hash = hash_partial(f) file_hash = hash_partial(f)
save_video( save_video(
category=category_for_file(f), category=category_for_file(f),
con=con,
file_path=file_path, file_path=file_path,
ffprobe_data=probed.__dict__, ffprobe_data=probed.__dict__,
video_hash=file_hash, video_hash=file_hash,
) )
logging.info(f"{progress} saved: {f.name}") logging.info(f"{progress} saved: {f.name}")
new_videos.append(file_path) new_videos.append(file_path)
con.commit()
if new_videos: if new_videos:
parse_releases(con, new_videos) parse_releases(new_videos)
def parse_releases(con: psycopg.Connection, file_paths: list[str]) -> None: def parse_releases(file_paths: list[str]) -> None:
api_key = os.getenv("OPENROUTER_API_KEY") api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key: if not api_key:
logging.warning("OPENROUTER_API_KEY not set, skipping filename parsing") logging.warning("OPENROUTER_API_KEY not set, skipping filename parsing")
@@ -272,9 +268,7 @@ def parse_releases(con: psycopg.Connection, file_paths: list[str]) -> None:
# Chunk file_paths by 20 items to avoid prompt token limits # Chunk file_paths by 20 items to avoid prompt token limits
for i, chunk in enumerate(chunked(file_paths, 20), 1): for i, chunk in enumerate(chunked(file_paths, 20), 1):
filenames = [Path(p).name for p in chunk] filenames = [Path(p).name for p in chunk]
logging.info( logging.info(f"Parsing filenames for {len(filenames)} videos (chunk {i}) via OpenRouter...")
f"Parsing filenames for {len(filenames)} videos (chunk {i}) via OpenRouter..."
)
try: try:
parsed_data = parse_filenames_with_ai(filenames, api_key) parsed_data = parse_filenames_with_ai(filenames, api_key)
@@ -294,16 +288,15 @@ def parse_releases(con: psycopg.Connection, file_paths: list[str]) -> None:
# Update the release column # Update the release column
release_info = {k: v for k, v in item.items() if k != "filename"} release_info = {k: v for k, v in item.items() if k != "filename"}
con.execute( response = client.patch(
"UPDATE videos SET release = %s WHERE file_path = %s", "/videos",
[json.dumps(release_info), full_path], params={"file_path": f"eq.{full_path}"},
json={"release": release_info},
) )
con.commit() response.raise_for_status()
logging.info(f"Successfully updated release info for chunk {i}") logging.info(f"Successfully updated release info for chunk {i}")
except Exception as e: except Exception as e:
logging.error( logging.error(f"Failed to parse filenames or update database for chunk {i}: {e}")
f"Failed to parse filenames or update database for chunk {i}: {e}"
)
@retry(max_attempts=3, delay=0.1) @retry(max_attempts=3, delay=0.1)
@@ -357,11 +350,7 @@ HookupHotshot.26.02.06.Episode.453.Shrooms.Q.XXX.720p.HEVC.x265.PRT.mp4 -> studi
# Clean output: omit empty strings and empty arrays # Clean output: omit empty strings and empty arrays
cleaned_data = [] cleaned_data = []
for entry in parsed_data: for entry in parsed_data:
cleaned_entry = { cleaned_entry = {k: v for k, v in entry.items() if v != "" and not (isinstance(v, list) and not v)}
k: v
for k, v in entry.items()
if v != "" and not (isinstance(v, list) and not v)
}
actors = cleaned_entry.get("actors", []) actors = cleaned_entry.get("actors", [])
if isinstance(actors, list) and len(actors) > 0: if isinstance(actors, list) and len(actors) > 0:
actors = [a for a in actors if a and "@" not in a] actors = [a for a in actors if a and "@" not in a]
@@ -523,51 +512,58 @@ class TinyAPI:
thread.join(timeout=5) thread.join(timeout=5)
@contextlib.contextmanager def handle_search(req: Request) -> Response:
def connect_db() -> typing.Generator[psycopg.Connection, typing.Any, typing.Any]:
with psycopg.connect(
"postgres://abdus:abdus@db.abdus.dev:5444/snot?sslmode=disable"
) as conn:
conn.row_factory = psycopg.rows.dict_row
yield conn
def handle_search(req: Request, conn: psycopg.Connection) -> Response:
query: str = req.payload.get("query", "") query: str = req.payload.get("query", "")
if not query: if not query:
return Response.error("Query is empty") return Response.error("Query is empty")
where_sql, where_params = filter_to_where(query) params = filter_to_postgrest(query)
params.update(
query_stmt = pgsql.SQL( {
""" "select": "id,file_path,file_name,actors,size_bytes,duration_human,created_at,ffprobe",
with q as (select "order": "created_at.desc,id.desc",
id, }
filename_from_path(file_path) as file_name,
file_path,
actors,
size_bytes / 1048576 as size_mb,
(ffprobe->>'width') || 'x' || (ffprobe->>'height') as resolution,
duration_human,
created_at
from videos)
select * from q
where {filter}
order by created_at desc
"""
).format(filter=pgsql.SQL(where_sql))
with conn.cursor() as cursor:
rows = cursor.execute(query_stmt, where_params).fetchall()
return Response.from_json(
[
{
**row,
"download_url": "https://u201686:T6672ICVoWAedECH@u201686.your-storagebox.de/files"
+ row["file_path"].replace("/mnt/box/files", ""),
}
for row in rows
]
) )
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:
response = client.get(
"/videos",
params={**params, "limit": "1000", "offset": str(len(rows))},
headers={"Prefer": "count=exact"} if total is None else None,
)
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]]: def tokenize_filter_groups(filter_query: str) -> list[list[str]]:
@@ -587,58 +583,72 @@ def tokenize_filter_groups(filter_query: str) -> list[list[str]]:
return [group for group in groups if group] return [group for group in groups if group]
def token_to_condition(token: str) -> tuple[str | None, list[typing.Any]]: def token_to_conditions(token: str) -> list[str]:
if ":" in token: if ":" in token:
key, value = token.split(":", 1) key, value = token.split(":", 1)
key = key.strip().lower() key = key.strip().lower()
value = value.strip() value = value.strip()
if not value: if not value:
return None, [] return []
if key == "file_name": if key == "file_name":
return "file_name ILIKE %s", [f"%{value}%"] return [f"file_name.ilike.{postgrest_quote(f'%{value}%')}"]
if key == "size_mb": if key == "size_mb":
match = re.match( match = re.match(r"^(>=|<=|>|<|=)?\s*(\d+(?:\.\d+)?)$", value, re.IGNORECASE)
r"^(>=|<=|>|<|=)?\s*(\d+(?:\.\d+)?)$", value, re.IGNORECASE
)
if not match: if not match:
return None, [] return []
op = match.group(1) or "=" op = match.group(1) or "="
num = float(match.group(2)) size_mb = Decimal(match.group(2))
return f"size_mb {op} %s", [num] 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": if key == "actor":
return "actors @> %s::text[]", [[value]] array_value = value.replace("\\", "\\\\").replace('"', '\\"')
return [f'actors.cs.{{"{array_value}"}}']
return None, [] return []
return "file_name ILIKE %s", [f"%{token}%"] return [f"file_name.ilike.{postgrest_quote(f'%{token}%')}"]
def filter_to_where(filter_query: str) -> tuple[str, list[typing.Any]]: 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) groups = tokenize_filter_groups(filter_query=filter_query)
if not groups: if not groups:
return "TRUE", [] return {}
or_clauses: list[str] = [] or_clauses: list[str] = []
params: list[typing.Any] = []
for group in groups: for group in groups:
and_clauses: list[str] = [] and_clauses: list[str] = []
for token in group: for token in group:
clause, clause_params = token_to_condition(token=token) and_clauses.extend(token_to_conditions(token=token))
if not clause:
continue
and_clauses.append(clause)
params.extend(clause_params)
if and_clauses: if and_clauses:
or_clauses.append("(" + " AND ".join(and_clauses) + ")") or_clauses.append("and(" + ",".join(and_clauses) + ")")
if not or_clauses: if not or_clauses:
return "TRUE", [] return {}
return " OR ".join(or_clauses), params return {"or": "(" + ",".join(or_clauses) + ")"}
def handle_home(req: Request, query: str = "") -> Response: def handle_home(req: Request, query: str = "") -> Response:
@@ -680,12 +690,8 @@ def handle_assets(req: Request, base_path: Path) -> Response:
def run_htmlpopup(address: str): def run_htmlpopup(address: str):
exe_path = shutil.which("htmlpopup") exe_path = shutil.which("htmlpopup")
if not exe_path: if not exe_path:
raise RuntimeError( raise RuntimeError("htmlpopup executable not found in PATH. Please install it to use the HTML popup feature.")
"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)
)
proc = subprocess.Popen(
[exe_path, "--title", "s·m·u·t", address], text=True, stdout=subprocess.PIPE
)
try: try:
yield yield
proc.wait() proc.wait()
@@ -711,21 +717,20 @@ def cli(ctx: click.Context):
@click.option("--query", type=str, default="", help="Initial query string") @click.option("--query", type=str, default="", help="Initial query string")
def serve(port: int, query: str): def serve(port: int, query: str):
"""Start the web UI""" """Start the web UI"""
with connect_db() as conn: handlers = {
handlers = { "POST /search": handle_search,
"POST /search": partial(handle_search, conn=conn), "GET /": partial(handle_home, query=query),
"GET /": partial(handle_home, query=query), "GET /.*": partial(handle_assets, base_path=Path(__file__).parent),
"GET /.*": partial(handle_assets, base_path=Path(__file__).parent), }
} api = TinyAPI(handlers=handlers)
api = TinyAPI(handlers=handlers)
with api.serve(port=port) as server_url: with api.serve(port=port) as server_url:
logging.info(f"Server running at {server_url}") logging.info(f"Server running at {server_url}")
logging.info("Available endpoints:") logging.info("Available endpoints:")
for path in handlers.keys(): for path in handlers:
logging.info(f" {path}") logging.info(f" {path}")
with run_htmlpopup(server_url): with run_htmlpopup(server_url):
logging.info("HTML popup started. Press Ctrl+C to stop.") logging.info("HTML popup started. Press Ctrl+C to stop.")
@cli.command() @cli.command()
@@ -736,8 +741,7 @@ def scan(paths: list[Path]):
click.echo("No paths provided to scan.") click.echo("No paths provided to scan.")
return return
with connect_db() as con: scan_videos(video_paths=list(paths))
scan_videos(con=con, video_paths=list(paths))
@cli.command("mark-deletion") @cli.command("mark-deletion")
@@ -748,37 +752,48 @@ def mark_deletion(paths: list[Path]):
click.echo("No paths provided.") click.echo("No paths provided.")
return return
with connect_db() as con: for path in paths:
for path in paths: file_name = path.name
file_name = path.name response = client.patch(
rows = con.execute( "/videos",
"UPDATE videos SET marked_for_deletion_at = NOW() WHERE file_name = %s RETURNING file_path", params={
[file_name], "file_name": f"eq.{postgrest_quote(file_name)}",
).fetchall() "select": "file_path",
if rows: },
for row in rows: json={"marked_for_deletion_at": "now"},
click.echo(f"Marked for deletion: {row['file_path']}") headers={"Prefer": "return=representation"},
else: )
click.echo(f"Not found in database: {file_name}", err=True) response.raise_for_status()
continue 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(): if not click.get_text_stream("stdin").isatty():
continue continue
stem = path.stem stem = path.stem
siblings = con.execute( siblings = fetch_videos(
"SELECT file_path, file_name FROM videos WHERE file_name != %s AND file_name LIKE %s AND marked_for_deletion_at IS NULL", params={
[file_name, f"{stem}.%"], "select": "file_path,file_name",
).fetchall() "file_name": f"like.{postgrest_quote(f'{stem}.%')}",
for sibling in siblings: "marked_for_deletion_at": "is.null",
if click.confirm(f" Also mark {sibling['file_name']}?", default=False): "order": "file_path.asc",
con.execute( }
"UPDATE videos SET marked_for_deletion_at = NOW() WHERE file_path = %s", )
[sibling["file_path"]], siblings = [row for row in siblings if row["file_name"] != file_name]
) for sibling in siblings:
click.echo(f" Marked for deletion: {sibling['file_path']}") if click.confirm(f" Also mark {sibling['file_name']}?", default=False):
response = client.patch(
con.commit() "/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__": if __name__ == "__main__":