Initial commit: Filebot Media Browser
Vue 3 + FastAPI Webapp zur Anzeige von Filebot/n8n-Medienmetadaten aus PostgreSQL. Enthält Frontend, API, Nginx-Config, SQL-Migrationen und Docker-Compose-Setup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY config.py main.py ./
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV CONFIG_PATH=/app/config/config.yaml
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,49 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
|
||||
IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
host: str = "192.168.30.186"
|
||||
port: int = 5432
|
||||
name: str = "postgres"
|
||||
user: str = "postgres"
|
||||
password: str = ""
|
||||
table: str = "movie_files"
|
||||
# "schema" ist in YAML erlaubt; intern db_schema (Pydantic-Konflikt mit BaseModel.schema)
|
||||
db_schema: str = Field(
|
||||
default="public",
|
||||
validation_alias=AliasChoices("db_schema", "schema"),
|
||||
)
|
||||
ssl: bool = False
|
||||
|
||||
@field_validator("table", "db_schema")
|
||||
@classmethod
|
||||
def validate_identifiers(cls, v: str) -> str:
|
||||
if not IDENT_RE.match(v):
|
||||
raise ValueError(f"Ungültiger Datenbank-Identifier: {v}")
|
||||
return v
|
||||
|
||||
@property
|
||||
def qualified_table(self) -> str:
|
||||
return f'"{self.db_schema}"."{self.table}"'
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
||||
|
||||
|
||||
def load_config(path: str | Path | None = None) -> tuple[AppConfig, Path]:
|
||||
config_path = Path(path or "/app/config/config.yaml")
|
||||
if not config_path.is_file():
|
||||
return AppConfig(), config_path
|
||||
|
||||
with config_path.open(encoding="utf-8") as f:
|
||||
raw: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
|
||||
return AppConfig.model_validate(raw), config_path
|
||||
+668
@@ -0,0 +1,668 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
from asyncpg import PostgresError
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import load_config
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("filebot-api")
|
||||
|
||||
COLUMNS = [
|
||||
"id",
|
||||
"file_name_original",
|
||||
"file_name_new",
|
||||
"object_type",
|
||||
"cover_url",
|
||||
"movie_rating",
|
||||
"movie_votes",
|
||||
"video_compression_format",
|
||||
"video_codec_library",
|
||||
"audio_codec",
|
||||
"container_format",
|
||||
"standard_video_format",
|
||||
"exact_video_format",
|
||||
"fourk_resolution",
|
||||
"audio_codec_profile",
|
||||
"audio_channel_format",
|
||||
"audio_channel_count",
|
||||
"audio_channel_layout",
|
||||
"video_resolution",
|
||||
"video_width",
|
||||
"video_height",
|
||||
"video_bitdepth",
|
||||
"high_dynamic_range",
|
||||
"dolby_vision",
|
||||
"overall_bitrate",
|
||||
"video_bitrate",
|
||||
"audio_bitrate",
|
||||
"created_at",
|
||||
"seen_at",
|
||||
]
|
||||
|
||||
|
||||
class SetSeenBody(BaseModel):
|
||||
seen: bool
|
||||
|
||||
pool: asyncpg.Pool | None = None
|
||||
qualified_table: str = '"public"."n8n_filebot"'
|
||||
config_path: Path = Path("/app/config/config.yaml")
|
||||
config_loaded_from_file: bool = False
|
||||
db_settings: dict[str, Any] = {}
|
||||
|
||||
|
||||
def serialize_value(val: Any) -> Any:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, Decimal):
|
||||
return float(val)
|
||||
if isinstance(val, (datetime, date)):
|
||||
return val.isoformat()
|
||||
if isinstance(val, UUID):
|
||||
return str(val)
|
||||
return val
|
||||
|
||||
|
||||
def normalize_cover_url(val: Any) -> str | None:
|
||||
"""Cover-URL aus DB/n8n bereinigen (JSON-Strings, Anführungszeichen, eingebettete URLs)."""
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, dict):
|
||||
for key in ("url", "cover_url", "medium", "original", "poster", "href", "src"):
|
||||
if key in val and val[key]:
|
||||
nested = normalize_cover_url(val[key])
|
||||
if nested:
|
||||
return nested
|
||||
return None
|
||||
if isinstance(val, list) and val:
|
||||
return normalize_cover_url(val[0])
|
||||
if not isinstance(val, str):
|
||||
val = str(val)
|
||||
s = val.strip().strip('"').strip("'")
|
||||
if not s:
|
||||
return None
|
||||
if s.startswith("{") or s.startswith("["):
|
||||
try:
|
||||
return normalize_cover_url(json.loads(s))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
match = re.search(r"https?://[^\s\"'<>]+", s)
|
||||
if match:
|
||||
return match.group(0).rstrip(".,)")
|
||||
if s.startswith("http://") or s.startswith("https://"):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def row_to_dict(record: asyncpg.Record) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {}
|
||||
for col in COLUMNS:
|
||||
val = serialize_value(record[col])
|
||||
if col == "cover_url":
|
||||
val = normalize_cover_url(val)
|
||||
data[col] = val
|
||||
return data
|
||||
|
||||
|
||||
def build_record_conditions(
|
||||
*,
|
||||
search: str | None = None,
|
||||
format_filter: str | None = None,
|
||||
codec: str | None = None,
|
||||
container: str | None = None,
|
||||
created_from: str | None = None,
|
||||
created_to: str | None = None,
|
||||
unseen_only: bool = False,
|
||||
object_type: str | None = None,
|
||||
) -> tuple[list[str], list[Any]]:
|
||||
conditions: list[str] = []
|
||||
params: list[Any] = []
|
||||
idx = 1
|
||||
|
||||
if search:
|
||||
conditions.append(
|
||||
f"(file_name_original ILIKE ${idx} OR file_name_new ILIKE ${idx})"
|
||||
)
|
||||
params.append(f"%{search}%")
|
||||
idx += 1
|
||||
|
||||
if format_filter:
|
||||
conditions.append(f"standard_video_format = ${idx}")
|
||||
params.append(format_filter)
|
||||
idx += 1
|
||||
|
||||
if codec:
|
||||
conditions.append(f"video_codec_library = ${idx}")
|
||||
params.append(codec)
|
||||
idx += 1
|
||||
|
||||
if container:
|
||||
conditions.append(f"container_format = ${idx}")
|
||||
params.append(container)
|
||||
idx += 1
|
||||
|
||||
if created_from:
|
||||
conditions.append(f"created_at >= ${idx}")
|
||||
params.append(parse_filter_datetime(created_from, "von"))
|
||||
idx += 1
|
||||
|
||||
if created_to:
|
||||
conditions.append(f"created_at <= ${idx}")
|
||||
params.append(parse_filter_datetime(created_to, "bis"))
|
||||
idx += 1
|
||||
|
||||
if unseen_only:
|
||||
conditions.append("seen_at IS NULL")
|
||||
|
||||
if object_type:
|
||||
conditions.append(f"LOWER(TRIM(object_type::text)) = ${idx}")
|
||||
params.append(object_type.strip().lower())
|
||||
idx += 1
|
||||
|
||||
return conditions, params
|
||||
|
||||
|
||||
def parse_filter_datetime(value: str, param_name: str) -> datetime:
|
||||
try:
|
||||
normalized = value.strip().replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, detail=f"Ungültiges Datum ({param_name})") from exc
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global pool, qualified_table, config_path, config_loaded_from_file, db_settings
|
||||
|
||||
cfg_path = os.getenv("CONFIG_PATH", "/app/config/config.yaml")
|
||||
cfg, config_path = load_config(cfg_path)
|
||||
config_loaded_from_file = config_path.is_file()
|
||||
db = cfg.database
|
||||
qualified_table = db.qualified_table
|
||||
db_settings = {
|
||||
"host": db.host,
|
||||
"port": db.port,
|
||||
"database": db.name,
|
||||
"user": db.user,
|
||||
"table": db.table,
|
||||
"db_schema": db.db_schema,
|
||||
"qualified_table": db.qualified_table,
|
||||
"config_file": str(config_path),
|
||||
"config_exists": config_loaded_from_file,
|
||||
}
|
||||
|
||||
if not config_loaded_from_file:
|
||||
logger.warning("Config-Datei fehlt: %s – Standardwerte aktiv", config_path)
|
||||
|
||||
try:
|
||||
pool = await asyncpg.create_pool(
|
||||
host=db.host,
|
||||
port=db.port,
|
||||
database=db.name,
|
||||
user=db.user,
|
||||
password=db.password,
|
||||
ssl=db.ssl,
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
command_timeout=30,
|
||||
)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.fetchval("SELECT 1")
|
||||
logger.info(
|
||||
"PostgreSQL verbunden: %s:%s/%s → %s",
|
||||
db.host,
|
||||
db.port,
|
||||
db.name,
|
||||
qualified_table,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("PostgreSQL-Verbindung fehlgeschlagen: %s", exc)
|
||||
pool = None
|
||||
|
||||
yield
|
||||
|
||||
if pool:
|
||||
await pool.close()
|
||||
pool = None
|
||||
|
||||
|
||||
app = FastAPI(title="Filebot Media Browser", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(PostgresError)
|
||||
async def postgres_error_handler(_request: Request, exc: PostgresError):
|
||||
logger.exception("PostgreSQL-Fehler: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"detail": f"Datenbankfehler: {exc}"},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_error_handler(_request: Request, exc: Exception):
|
||||
if isinstance(exc, HTTPException):
|
||||
raise exc
|
||||
logger.exception("Unerwarteter Fehler: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": f"Serverfehler: {type(exc).__name__}: {exc}"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
if pool is None:
|
||||
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||
async with pool.acquire() as conn:
|
||||
await conn.fetchval("SELECT 1")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def status():
|
||||
"""Diagnose: Config + DB + Tabellenzugriff."""
|
||||
info: dict[str, Any] = {
|
||||
"database": db_settings,
|
||||
"pool": pool is not None,
|
||||
"table": qualified_table,
|
||||
}
|
||||
if pool is None:
|
||||
info["error"] = "Kein Connection-Pool – Zugangsdaten oder Netzwerk prüfen"
|
||||
return info
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
info["ping"] = await conn.fetchval("SELECT 1")
|
||||
count = await conn.fetchval(f"SELECT COUNT(*) FROM {qualified_table}")
|
||||
info["row_count"] = count
|
||||
except PostgresError as exc:
|
||||
info["error"] = str(exc)
|
||||
if pool is not None:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
schema = db_settings.get("db_schema", "public")
|
||||
in_schema = await conn.fetch(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = $1
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
""",
|
||||
schema,
|
||||
)
|
||||
similar = await conn.fetch(
|
||||
"""
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND (
|
||||
table_name ILIKE '%filebot%'
|
||||
OR table_name ILIKE '%file%'
|
||||
)
|
||||
ORDER BY table_schema, table_name
|
||||
LIMIT 30
|
||||
"""
|
||||
)
|
||||
info["tables_in_configured_schema"] = [r["table_name"] for r in in_schema]
|
||||
info["similar_tables"] = [
|
||||
f'{r["table_schema"]}.{r["table_name"]}' for r in similar
|
||||
]
|
||||
except PostgresError:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
@app.get("/api/columns")
|
||||
async def get_columns():
|
||||
return {"columns": COLUMNS}
|
||||
|
||||
|
||||
@app.get("/api/records/meta")
|
||||
async def get_records_meta(
|
||||
search: str | None = Query(None),
|
||||
format_filter: str | None = Query(None, alias="format"),
|
||||
codec: str | None = None,
|
||||
container: str | None = None,
|
||||
created_from: str | None = Query(None),
|
||||
created_to: str | None = Query(None),
|
||||
unseen_only: bool = Query(False),
|
||||
object_type: str | None = None,
|
||||
):
|
||||
"""Leichtgewichtiger Check für Auto-Refresh (ohne volle Datensätze)."""
|
||||
if pool is None:
|
||||
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||
|
||||
conditions, params = build_record_conditions(
|
||||
search=search,
|
||||
format_filter=format_filter,
|
||||
codec=codec,
|
||||
container=container,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
unseen_only=unseen_only,
|
||||
object_type=object_type,
|
||||
)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
if conditions:
|
||||
unseen_conditions = [*conditions, "seen_at IS NULL"]
|
||||
unseen_where = f"WHERE {' AND '.join(unseen_conditions)}"
|
||||
else:
|
||||
unseen_where = "WHERE seen_at IS NULL"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COALESCE(MAX(id), 0)::int AS max_id,
|
||||
MAX(created_at) AS latest_created_at,
|
||||
(
|
||||
SELECT md5(COALESCE(string_agg(sig, '|'), ''))
|
||||
FROM (
|
||||
SELECT (
|
||||
id::text || ':' ||
|
||||
COALESCE(cover_url, '') || ':' ||
|
||||
COALESCE(file_name_new, '') || ':' ||
|
||||
COALESCE(object_type::text, '') || ':' ||
|
||||
COALESCE(seen_at::text, '')
|
||||
) AS sig
|
||||
FROM {qualified_table}
|
||||
{where}
|
||||
ORDER BY created_at DESC NULLS LAST, id DESC
|
||||
LIMIT 300
|
||||
) recent
|
||||
) AS revision
|
||||
FROM {qualified_table}
|
||||
{where}
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
unseen_total = await conn.fetchval(
|
||||
f"SELECT COUNT(*)::int FROM {qualified_table} {unseen_where}",
|
||||
*params,
|
||||
)
|
||||
|
||||
latest = row["latest_created_at"]
|
||||
return {
|
||||
"total": row["total"],
|
||||
"unseen_total": unseen_total,
|
||||
"max_id": row["max_id"],
|
||||
"latest_created_at": serialize_value(latest) if latest else None,
|
||||
"revision": row["revision"] or "",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/cover")
|
||||
async def proxy_cover(url: str = Query(..., min_length=8)):
|
||||
"""Cover-Bilder über die API laden (Hotlink/Referrer-Probleme umgehen)."""
|
||||
normalized = normalize_cover_url(url)
|
||||
if not normalized:
|
||||
raise HTTPException(400, detail="Ungültige Cover-URL")
|
||||
parsed = urlparse(normalized)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(400, detail="Ungültige Cover-URL")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
normalized,
|
||||
headers={"User-Agent": "FilebotMediaBrowser/1.0"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, detail=f"Cover nicht ladbar: {exc}") from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, detail=f"Cover nicht ladbar ({resp.status_code})")
|
||||
|
||||
content_type = resp.headers.get("content-type", "image/jpeg")
|
||||
if not content_type.startswith("image/"):
|
||||
content_type = "image/jpeg"
|
||||
|
||||
return Response(
|
||||
content=resp.content,
|
||||
media_type=content_type,
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/records")
|
||||
async def get_records(
|
||||
search: str | None = Query(None, description="Suche in Dateinamen"),
|
||||
format_filter: str | None = Query(None, alias="format"),
|
||||
codec: str | None = None,
|
||||
container: str | None = None,
|
||||
created_from: str | None = Query(None, description="created_at >= (ISO)"),
|
||||
created_to: str | None = Query(None, description="created_at <= (ISO)"),
|
||||
unseen_only: bool = Query(False, description="Nur ungesehene Einträge"),
|
||||
object_type: str | None = None,
|
||||
limit: int = Query(5000, le=10000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
if pool is None:
|
||||
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||
|
||||
conditions, params = build_record_conditions(
|
||||
search=search,
|
||||
format_filter=format_filter,
|
||||
codec=codec,
|
||||
container=container,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
unseen_only=unseen_only,
|
||||
object_type=object_type,
|
||||
)
|
||||
idx = len(params) + 1
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
cols = ", ".join(COLUMNS)
|
||||
|
||||
query = f"""
|
||||
SELECT {cols}
|
||||
FROM {qualified_table}
|
||||
{where}
|
||||
ORDER BY created_at DESC NULLS LAST, id DESC
|
||||
LIMIT ${idx} OFFSET ${idx + 1}
|
||||
"""
|
||||
params.extend([limit, offset])
|
||||
|
||||
count_query = f"SELECT COUNT(*) FROM {qualified_table} {where}"
|
||||
count_params = params[:-2]
|
||||
|
||||
if conditions:
|
||||
unseen_conditions = [*conditions, "seen_at IS NULL"]
|
||||
unseen_where = f"WHERE {' AND '.join(unseen_conditions)}"
|
||||
else:
|
||||
unseen_where = "WHERE seen_at IS NULL"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query, *params)
|
||||
total = await conn.fetchval(count_query, *count_params)
|
||||
unseen_total = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {qualified_table} {unseen_where}",
|
||||
*count_params,
|
||||
)
|
||||
|
||||
return {
|
||||
"records": [row_to_dict(r) for r in rows],
|
||||
"total": total,
|
||||
"unseen_total": unseen_total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/api/records/{record_id}/seen")
|
||||
async def set_record_seen(record_id: int, body: SetSeenBody):
|
||||
"""Einzelnen Eintrag als gesehen (seen=true) oder ungesehen (seen=false) markieren."""
|
||||
if pool is None:
|
||||
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||
|
||||
if body.seen:
|
||||
sql = f"""
|
||||
UPDATE {qualified_table}
|
||||
SET seen_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, seen_at
|
||||
"""
|
||||
else:
|
||||
sql = f"""
|
||||
UPDATE {qualified_table}
|
||||
SET seen_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING id, seen_at
|
||||
"""
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql, record_id)
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(404, detail=f"Eintrag mit ID {record_id} nicht gefunden")
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"seen_at": serialize_value(row["seen_at"]),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/records/mark-all-seen")
|
||||
async def mark_all_seen(
|
||||
search: str | None = Query(None),
|
||||
format_filter: str | None = Query(None, alias="format"),
|
||||
codec: str | None = None,
|
||||
container: str | None = None,
|
||||
created_from: str | None = None,
|
||||
created_to: str | None = None,
|
||||
object_type: str | None = None,
|
||||
):
|
||||
"""Alle sichtbaren (gefilterten) Einträge als gesehen markieren."""
|
||||
if pool is None:
|
||||
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||
|
||||
conditions, params = build_record_conditions(
|
||||
search=search,
|
||||
format_filter=format_filter,
|
||||
codec=codec,
|
||||
container=container,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
unseen_only=False,
|
||||
object_type=object_type,
|
||||
)
|
||||
if conditions:
|
||||
where = f"WHERE {' AND '.join(conditions)} AND seen_at IS NULL"
|
||||
else:
|
||||
where = "WHERE seen_at IS NULL"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
f"UPDATE {qualified_table} SET seen_at = NOW() {where}",
|
||||
*params,
|
||||
)
|
||||
|
||||
count = int(result.split()[-1]) if result else 0
|
||||
return {"marked": count}
|
||||
|
||||
|
||||
@app.get("/api/filters")
|
||||
async def get_filter_options():
|
||||
if pool is None:
|
||||
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
formats = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT standard_video_format AS value
|
||||
FROM {qualified_table}
|
||||
WHERE standard_video_format IS NOT NULL
|
||||
AND standard_video_format::text <> ''
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
codecs = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT video_codec_library AS value
|
||||
FROM {qualified_table}
|
||||
WHERE video_codec_library IS NOT NULL
|
||||
AND video_codec_library::text <> ''
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
containers = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT container_format AS value
|
||||
FROM {qualified_table}
|
||||
WHERE container_format IS NOT NULL
|
||||
AND container_format::text <> ''
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
audio_codecs = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT audio_codec AS value
|
||||
FROM {qualified_table}
|
||||
WHERE audio_codec IS NOT NULL
|
||||
AND audio_codec::text <> ''
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
object_types = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT LOWER(TRIM(object_type::text)) AS value
|
||||
FROM {qualified_table}
|
||||
WHERE object_type IS NOT NULL
|
||||
AND TRIM(object_type::text) <> ''
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
|
||||
return {
|
||||
"formats": [r["value"] for r in formats],
|
||||
"codecs": [r["value"] for r in codecs],
|
||||
"containers": [r["value"] for r in containers],
|
||||
"audio_codecs": [r["value"] for r in audio_codecs],
|
||||
"object_types": [r["value"] for r in object_types],
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/records/{record_id}")
|
||||
async def delete_record(record_id: int):
|
||||
if pool is None:
|
||||
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
f"DELETE FROM {qualified_table} WHERE id = $1",
|
||||
record_id,
|
||||
)
|
||||
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(404, detail=f"Eintrag mit ID {record_id} nicht gefunden")
|
||||
|
||||
return {"deleted": True, "id": record_id}
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
asyncpg==0.30.0
|
||||
pyyaml==6.0.2
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
httpx==0.28.1
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tabellen auflisten. Im Container: python /app/scripts/list-tables.py"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
import yaml
|
||||
|
||||
|
||||
async def list_tables(conn: asyncpg.Connection, db_name: str, schema_hint: str, table_hint: str) -> None:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY table_schema, table_name
|
||||
"""
|
||||
)
|
||||
print(f"\n=== Datenbank: {db_name} ({len(rows)} Tabellen) ===")
|
||||
if not rows:
|
||||
print(" (leer – keine Benutzer-Tabellen)")
|
||||
return
|
||||
for r in rows:
|
||||
mark = ""
|
||||
if r["table_schema"] == schema_hint and r["table_name"] == table_hint:
|
||||
mark = " <-- Config-Ziel"
|
||||
print(f" {r['table_schema']}.{r['table_name']}{mark}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
config_path = Path(sys.argv[1] if len(sys.argv) > 1 else "/app/config/config.yaml")
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
c = raw.get("database", raw)
|
||||
schema_hint = c.get("db_schema") or c.get("schema") or "public"
|
||||
table_hint = c.get("table", "")
|
||||
|
||||
print(f"Host: {c['host']}:{c['port']}")
|
||||
print(f"Config: DB={c['name']} Schema={schema_hint} Tabelle={table_hint}")
|
||||
|
||||
# Konfigurierte Datenbank
|
||||
conn = await asyncpg.connect(
|
||||
host=c["host"],
|
||||
port=c["port"],
|
||||
database=c["name"],
|
||||
user=c["user"],
|
||||
password=c["password"],
|
||||
)
|
||||
await list_tables(conn, c["name"], schema_hint, table_hint)
|
||||
await conn.close()
|
||||
|
||||
# Alle Datenbanken auflisten und nach filebot-Tabellen suchen
|
||||
print("\n=== Suche 'filebot' in allen Datenbanken ===")
|
||||
admin = await asyncpg.connect(
|
||||
host=c["host"],
|
||||
port=c["port"],
|
||||
database="postgres",
|
||||
user=c["user"],
|
||||
password=c["password"],
|
||||
)
|
||||
dbs = await admin.fetch(
|
||||
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"
|
||||
)
|
||||
for db in dbs:
|
||||
name = db["datname"]
|
||||
if name in ("template0", "template1"):
|
||||
continue
|
||||
try:
|
||||
c2 = await asyncpg.connect(
|
||||
host=c["host"],
|
||||
port=c["port"],
|
||||
database=name,
|
||||
user=c["user"],
|
||||
password=c["password"],
|
||||
)
|
||||
hits = await c2.fetch(
|
||||
"""
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND table_name ILIKE '%filebot%'
|
||||
ORDER BY 1, 2
|
||||
"""
|
||||
)
|
||||
if hits:
|
||||
print(f"\n DB '{name}':")
|
||||
for h in hits:
|
||||
print(f" {h['table_schema']}.{h['table_name']}")
|
||||
await c2.close()
|
||||
except Exception as exc:
|
||||
print(f" DB '{name}': kein Zugriff ({exc})")
|
||||
await admin.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user