e032a9df1c
- jobs.meta (Migration in beiden db.py): Disc-Metadaten wandern in den Job —
Quelle fuer Job-Detail-Popup (GET /jobs/{id}/detail), Ordner-Benennung, NFO.
- medien.py (Worker, mit Tests): Zielordner 'Titel (Jahr)' statt Job-UUID;
movie.nfo/tvshow.nfo (Kodi-Schema, kodi.wiki/view/NFO_files) + poster.jpg
fuer jellyfin/emby/kodi; plex nur Benennung; Kollision -> Job-ID-Suffix.
- notify.py (identisch in API+Worker, mit Tests): Webhook bei Job-Ende —
Discord ({content}, discord.com/developers), Slack ({text},
api.slack.com/messaging/webhooks), ntfy (Rohtext + ?title=, docs.ntfy.sh),
generisches JSON. Vorher war das Setting ein Placebo: nichts sendete je.
POST /notifications/test beweist die Anbindung sofort.
- mounts.py: NT_STATUS-Fehler -> handelbarer Klartext (ACCESS_DENIED ohne
Credentials = Gast-Abfrage verweigert), Timeout-Meldung, mount-Hinweis
bei error(13). Mit Tests.
- tasks.py: Platz-Check per Disc-Groesse (ioctl BLKGETSIZE64) VOR dem Rip;
Arbeitsverzeichnis workDir (unter /app/media, z.B. NAS) statt fix
/app/temp/raw — 4K-UHD-Rohdaten (bis 100 GB) sprengen sonst die VM-Platte;
MakeMKV-Key aus UI-Settings (~/.MakeMKV/settings.conf, Format wie
entrypoint.sh) gilt ab dem naechsten Rip ohne Rebuild.
- caps.py: Werkzeug-Versionen (MakeMKV aus ENV MAKEMKV_VERSION im Image,
makemkvcon hat keinen --version-Schalter lt. usage.txt; HandBrakeCLI
--version lt. handbrake.fr/docs) + Key-Quelle; workers.info-Spalte.
- /browse liefert jetzt auch DATEIEN (Name + Groesse) — der 'leere'
bluray-Ordner war voll, der Browser zeigte nur Unterordner.
- GET /system/info: Versionen, Plattenplatz, Key-/Webhook-Status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
172 lines
5.0 KiB
Python
172 lines
5.0 KiB
Python
"""Job- und Log-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
|
|
|
|
Die Tabellendefinition existiert bewusst identisch in API und Worker
|
|
(docker/api/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
|
|
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
|
|
"""
|
|
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import (
|
|
Column,
|
|
DateTime,
|
|
Integer,
|
|
MetaData,
|
|
String,
|
|
Table,
|
|
Text,
|
|
create_engine,
|
|
)
|
|
|
|
DATABASE_URL = os.getenv(
|
|
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
|
|
)
|
|
|
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
metadata = MetaData()
|
|
|
|
jobs = Table(
|
|
"jobs",
|
|
metadata,
|
|
Column("id", String(36), primary_key=True),
|
|
Column("disc_type", String(16)),
|
|
Column("device", String(64)),
|
|
Column("title", String(255)),
|
|
Column("status", String(16), nullable=False, server_default="pending"),
|
|
Column("progress", Integer, nullable=False, server_default="0"),
|
|
Column("output_path", Text),
|
|
Column("target_dir", String(255)),
|
|
Column("error", Text),
|
|
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
|
|
Column("created_at", DateTime(timezone=True)),
|
|
Column("finished_at", DateTime(timezone=True)),
|
|
)
|
|
|
|
logs = Table(
|
|
"logs",
|
|
metadata,
|
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
|
Column("ts", DateTime(timezone=True)),
|
|
Column("level", String(16)),
|
|
Column("source", String(32)),
|
|
Column("message", Text),
|
|
)
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def init_db() -> None:
|
|
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.
|
|
|
|
Die ALTERs stehen identisch in docker/api/db.py — wer zuerst startet,
|
|
migriert; der andere findet die Spalten dann bereits vor.
|
|
"""
|
|
metadata.create_all(engine)
|
|
with engine.begin() as conn:
|
|
conn.exec_driver_sql(
|
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
|
|
)
|
|
conn.exec_driver_sql(
|
|
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
|
|
)
|
|
conn.exec_driver_sql(
|
|
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
|
|
)
|
|
|
|
|
|
settings_table = Table(
|
|
"settings",
|
|
metadata,
|
|
Column("key", String(64), primary_key=True),
|
|
Column("value", Text),
|
|
)
|
|
|
|
workers = Table(
|
|
"workers",
|
|
metadata,
|
|
Column("name", String(128), primary_key=True),
|
|
Column("encoders", Text),
|
|
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
|
|
Column("last_seen", DateTime(timezone=True)),
|
|
)
|
|
|
|
|
|
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
|
|
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
|
|
import json
|
|
|
|
payload = json.dumps(encoder_liste)
|
|
info_payload = json.dumps(info or {})
|
|
with engine.begin() as conn:
|
|
vorhanden = conn.execute(
|
|
workers.select().where(workers.c.name == name)
|
|
).first()
|
|
if vorhanden:
|
|
conn.execute(
|
|
workers.update().where(workers.c.name == name).values(
|
|
encoders=payload, info=info_payload, last_seen=utcnow()
|
|
)
|
|
)
|
|
else:
|
|
conn.execute(
|
|
workers.insert().values(
|
|
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
|
|
)
|
|
)
|
|
|
|
|
|
def get_settings(key: str = "ui") -> dict:
|
|
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
|
|
import json
|
|
|
|
from sqlalchemy import select
|
|
|
|
try:
|
|
with engine.connect() as conn:
|
|
zeile = conn.execute(
|
|
select(settings_table.c.value).where(settings_table.c.key == key)
|
|
).first()
|
|
if zeile and zeile[0]:
|
|
return json.loads(zeile[0])
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def get_job(job_id: str) -> dict:
|
|
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
|
|
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
|
|
from sqlalchemy import select
|
|
|
|
with engine.connect() as conn:
|
|
zeile = conn.execute(
|
|
select(jobs).where(jobs.c.id == job_id)
|
|
).mappings().first()
|
|
return dict(zeile) if zeile else None
|
|
|
|
|
|
def get_job_status(job_id: str) -> str:
|
|
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
|
from sqlalchemy import select
|
|
|
|
with engine.connect() as conn:
|
|
zeile = conn.execute(
|
|
select(jobs.c.status).where(jobs.c.id == job_id)
|
|
).first()
|
|
return zeile[0] if zeile else ""
|
|
|
|
|
|
def update_job(job_id: str, **fields) -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
|
|
|
|
|
def add_log(level: str, source: str, message: str) -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
|
|
)
|