commit 3f253ffe817bec97d3105e88ec3ba62614ccf890 Author: Housemann <40449280+Housemann@users.noreply.github.com> Date: Sat Jul 11 05:38:37 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ca034f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +frontend/dist/ +config/config.yaml +.env +__pycache__/ +*.pyc +.DS_Store diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..48dbafe --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,201 @@ +# Projekt-Kontext für KI-Assistenten (Filebot Media Browser) + +Diese Datei fasst zusammen, **was gebaut wurde** und wie das Projekt betrieben wird. Bei weiteren Änderungen zuerst hier und in der `README.md` nachschlagen. + +--- + +## Zweck + +Interne Web-App im Heimnetz zum Anzeigen und Verwalten von **Filebot/n8n-Medienmetadaten** aus PostgreSQL. Schwerpunkt: **Original- vs. neuem Dateinamen** nebeneinander (Rename-Kontrolle), plus technische Metadaten (Codec, Bitrate, Auflösung, …). + +**Produktiv nur auf dem Server** unter `/opt/nginx_filebot` – nicht lokal auf dem Mac entwickeln/ausführen. + +--- + +## Infrastruktur + +| Komponente | Details | +|------------|---------| +| Server-Pfad | `/opt/nginx_filebot` | +| Docker Compose | Projektname `nginx_filebot` | +| Web | Nginx, Port **8080** → Container 80 | +| API | FastAPI (Python 3.12), intern `api:8000` | +| PostgreSQL | Host typisch `192.168.30.186` | +| Datenbank | `n8n_filebot` (Name in config) | +| Schema | **`n8n`** (nicht `public`!) | +| Tabelle | **`movie_files`** (nicht `n8n_filebot`!) | +| Vollqualifizierter Name | `"n8n"."movie_files"` | + +Config: `config/config.yaml` (nur auf Server, nicht committen). Beispiel: `config/config.example.yaml`. + +**Wichtig:** Feld `schema` in YAML funktioniert, intern heißt es `db_schema` (Pydantic-Konflikt mit `BaseModel.schema`). + +--- + +## Tech-Stack + +- **Frontend:** Vue 3, TypeScript, Vite, TanStack Table +- **Backend:** FastAPI, asyncpg +- **Webserver:** Nginx (Multi-Stage-Build: Node baut Vue, Nginx liefert aus) +- **DB:** PostgreSQL + +--- + +## Datenbank-Spalten (relevant) + +Standard-Spalten aus n8n/Filebot (siehe `api/main.py` → `COLUMNS`), u. a.: + +- `id`, `file_name_original`, `file_name_new` +- `movie_rating`, `movie_votes` +- Video/Audio: `standard_video_format`, `video_codec_library`, `audio_codec`, `container_format`, Bitraten, Auflösung, … +- `created_at` + +**Zusätzlich per Migration:** + +| Spalte | Typ | Bedeutung | +|--------|-----|-----------| +| `seen_at` | `TIMESTAMPTZ NULL` | `NULL` = ungesehen; gesetzt = in UI als gesehen markiert | +| `cover_url` | `TEXT NULL` | Poster-URL (z. B. TVMaze), erste Tabellenspalte | + +SQL-Dateien: `sql/001_add_seen_at.sql`, `sql/002_add_cover_url.sql` + +**n8n:** Bei INSERT/UPDATE `seen_at` und `cover_url` nicht ungewollt überschreiben. + +--- + +## UI-Funktionen (Stand Projektabschluss) + +### Layout + +- **Desktop:** Tabelle mit vielen Spalten; **erste Spalte Cover** (ca. 76×114 px), dann Dateinamen-Paar +- **Mobile (≤900px):** Karten mit Cover links +- **Helles Dark-Theme** (`frontend/src/styles.css`, CSS-Variablen) + +### Dateinamen + +- Immer **Original** und **Neu** sichtbar (`NamePair.vue`) +- Kein „Rename prüfen“-Badge mehr (Heuristik entfernt) + +### Filter (`FilterBar.vue`) + +- Textsuche in Original + Neu +- **Buttons** (keine Dropdowns): Format, Codec, Container +- **Datum/Uhrzeit Von–Bis** mit Kalender-Popup + HH:MM-Dropdowns (`DateTimeRangeFilter.vue`) +- **Gesehen:** „Nur ungesehen“, „Alle als gesehen“, Zähler „X neu“ +- Aktualisieren / Zurücksetzen + +### Gesehen-Status + +- Ungesehene Zeilen: blauer linker Rand + Hintergrund (`row-unseen` / `card-unseen`) +- **Tippen auf Zeile** (nicht Scroll): Toggle gesehen/ungesehen via `PATCH /api/records/{id}/seen` +- Mülleimer löscht Eintrag (kein Toggle) + +### Tabelle + +- Sortierung Standard: `created_at` absteigend (neueste oben) +- Schnellfilter in geladenen Daten +- **Löschen:** roter Mülleimer pro Zeile → `DELETE /api/records/{id}` + +### Cover + +- `CoverImage.vue`: lazy load, Platzhalter bei Fehler, URL-Bereinigung (extra `"` entfernen) + +--- + +## API-Endpunkte + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET | `/api/health` | DB-Ping | +| GET | `/api/status` | Diagnose (Config, Tabellenliste bei Fehler) | +| GET | `/api/columns` | Spaltenliste | +| GET | `/api/records` | Daten; Query: `search`, `format`, `codec`, `container`, `created_from`, `created_to`, `unseen_only` | +| GET | `/api/filters` | Distinct-Werte für Filter-Buttons | +| PATCH | `/api/records/{id}/seen` | Body: `{ "seen": true \| false }` | +| POST | `/api/records/mark-all-seen` | Alle gefilterten als gesehen | +| DELETE | `/api/records/{id}` | Zeile löschen | + +Nginx proxied `/api/` → `http://api:8000/api/`. + +--- + +## Projektstruktur + +```text +/opt/nginx_filebot/ +├── docker-compose.yml # absolute Pfade für Server +├── config/ +│ ├── config.example.yaml +│ └── config.yaml # nur Server +├── api/ +│ ├── main.py +│ ├── config.py +│ ├── Dockerfile +│ └── scripts/list-tables.py +├── frontend/ +│ └── src/ +│ ├── App.vue +│ ├── api.ts +│ ├── components/ # DataTable, FilterBar, CoverImage, … +│ ├── composables/ # (useSeenObserver entfernt) +│ └── utils/ +├── nginx/ +│ ├── Dockerfile # Multi-Stage Frontend-Build +│ └── nginx.conf +├── sql/ # DB-Migrationen +├── PROMPT.md # diese Datei +└── README.md +``` + +--- + +## Deployment (Kurz) + +```bash +cd /opt/nginx_filebot +docker compose up -d --build +# Browser: http://:8080 +``` + +Nach Frontend-Änderungen: `docker compose up -d --build web` +Nach API-Änderungen: `docker compose up -d --build api` +Oft: komplett `--build` + +--- + +## Typische Fehler (bereits aufgetreten) + +1. **`relation "n8n_filebot" does not exist`** → Tabelle heißt `movie_files`, DB `n8n_filebot` +2. **`schema: n8n` ignoriert** → `db_schema` / API-Fix mit `validation_alias` +3. **Leere DB-Liste** → falsche Datenbank oder leere Tabelle +4. **API 500 ohne `seen_at`/`cover_url`** → SQL-Migration ausführen +5. **Design ändert sich nicht** → `web` neu bauen + Browser Hard-Reload + +Diagnose: + +```bash +docker compose logs api --tail 50 +curl -s http://localhost:8080/api/status +docker compose exec api python /app/scripts/list-tables.py +``` + +--- + +## Konventionen für weitere Entwicklung + +- Antworten an Nutzer oft **auf Deutsch** +- Keine Commits unless asked +- `config.yaml` nie ins Git +- Server-only Deployment kommunizieren +- Nach Änderungen: welche Dateien hochladen + `docker compose up -d --build` +- Minimale Diffs, bestehenden Stil beibehalten (Vue 3 Composition API, FastAPI async) + +--- + +## Nutzer-Präferenzen (aus Session) + +- Rename-Kontrolle: `file_name_original` + `file_name_new` immer sichtbar +- Filter als Buttons, nebeneinander (mobil gestapelt) +- Gesehen per **Tap**, nicht Scroll +- Cover in erster Spalte, etwas höhere Zeilen +- Etwas **helleres** UI-Theme diff --git a/README.md b/README.md new file mode 100644 index 0000000..abcbade --- /dev/null +++ b/README.md @@ -0,0 +1,480 @@ +# Filebot Media Browser + +Web-Oberfläche zur Anzeige und Verwaltung von Medien-Metadaten aus **PostgreSQL** (Filebot / n8n). Fokus: **Original- und neuer Dateiname** zum Prüfen von Renames, plus Cover, Filtern und Gesehen-Status. + +![Stack](https://img.shields.io/badge/Vue-3-42b883) ![FastAPI](https://img.shields.io/badge/FastAPI-009688) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791) ![Docker](https://img.shields.io/badge/Docker-2496ED) + +--- + +## Inhaltsverzeichnis + +- [Überblick](#überblick) +- [Features](#features) +- [Architektur](#architektur) +- [Voraussetzungen](#voraussetzungen) +- [Installation auf dem Server](#installation-auf-dem-server) +- [Konfiguration](#konfiguration) +- [Datenbank](#datenbank) +- [Bedienung](#bedienung) +- [API-Referenz](#api-referenz) +- [Projektstruktur](#projektstruktur) +- [Entwicklung & Updates](#entwicklung--updates) +- [Fehlerbehebung](#fehlerbehebung) +- [Hinweise für n8n](#hinweise-für-n8n) + +--- + +## Überblick + +Die Anwendung läuft als **zwei Docker-Container** auf einem Linux-Server: + +1. **web** – Nginx liefert die Vue-App aus und leitet `/api` an das Backend weiter +2. **api** – FastAPI liest Daten aus PostgreSQL + +**Produktiv-Pfad auf dem Server:** `/opt/nginx_filebot` + +> Auf dem Entwicklungs-PC (z. B. Mac) ist **kein** Node.js oder Python für den Betrieb nötig. Code anpassen, auf den Server kopieren, dort `docker compose up -d --build` ausführen. + +Für KI-Assistenten / Projekt-Kontext: siehe [`PROMPT.md`](PROMPT.md). + +--- + +## Features + +### Medien-Übersicht + +- **Cover** aus `cover_url` (z. B. TVMaze-Poster) in der ersten Spalte / auf Karten +- **Original-** und **Neu-Dateiname** immer sichtbar +- Technische Spalten: Rating, Format, Auflösung, Codecs, Bitraten, Container, Erstellungsdatum +- **Neueste Einträge zuerst** (Sortierung nach `created_at`) + +### Filter + +- Volltextsuche in beiden Dateinamen +- **Button-Filter** für Videoformat, Codec und Container (nebeneinander auf Desktop, gestapelt auf Mobil) +- **Zeitraum** „Von / Bis“ mit Kalender und Uhrzeit (HH:MM) +- Schnellfilter in der Tabelle (clientseitig in geladenen Daten) + +### Gesehen-Status + +- Ungesehene Zeilen sind **hervorgehoben** (blauer Akzent) +- **Tippen auf eine Zeile** markiert sie als gesehen oder ungesehen +- Filter **„Nur ungesehen“** und Aktion **„Alle als gesehen“** (für aktuelle Filterauswahl) +- Zähler **„X neu“** im Header und in den Filtern + +### Weitere Aktionen + +- **Löschen** einzelner Einträge (Mülleimer, mit Bestätigung) +- Responsives Layout: **Tabelle** (Desktop) / **Karten** (Mobil, ≤900px Breite) + +--- + +## Architektur + +```text + Browser + │ + ▼ +┌─────────────────┐ /api/* ┌─────────────────┐ +│ nginx (web) │ ───────────────►│ FastAPI (api) │ +│ Port 8080 │ │ Port 8000 │ +│ Vue static │ └────────┬────────┘ +└─────────────────┘ │ + ▼ + ┌─────────────────┐ + │ PostgreSQL │ + │ n8n.movie_files│ + └─────────────────┘ +``` + +| Container | Image-Build | Aufgabe | +|-----------|-------------|---------| +| `web` | `nginx/Dockerfile` (Node → Nginx) | Statische Dateien, Reverse Proxy | +| `api` | `api/Dockerfile` | REST-API, DB-Zugriff | + +Compose-Datei nutzt **relative Pfade** – immer aus `/opt/nginx_filebot` starten. + +--- + +## Voraussetzungen + +- Linux-Server mit **Docker** und **Docker Compose v2** +- Erreichbare **PostgreSQL**-Instanz (im LAN, z. B. `192.168.30.186:5432`) +- Tabelle **`n8n.movie_files`** in Datenbank **`n8n_filebot`** (anpassbar in Config) +- Optional: Spalten `seen_at` und `cover_url` (siehe [Datenbank](#datenbank)) + +--- + +## Installation auf dem Server + +Ausführliche Anleitung inkl. **SMB-Gruppe** und Umzug: [`docs/DEPLOY-NEUER-SERVER.md`](docs/DEPLOY-NEUER-SERVER.md). + +### 1. Projekt ablegen + +```bash +# Ein User (vogto): Rechte auf ganz /opt, nicht nur ein Unterordner +sudo groupadd -f opt-docker +sudo usermod -aG opt-docker,docker vogto +sudo chown vogto:opt-docker /opt && sudo chmod 2775 /opt +sudo mkdir -p /opt/nginx_filebot + +# Variante A: Git +git clone /opt/nginx_filebot + +# Variante B: rsync vom Entwicklungsrechner +# rsync -avz --exclude node_modules --exclude config/config.yaml \ +# ./ user@server:/opt/nginx_filebot/ +``` + +### 2. Datenbank vorbereiten + +Siehe Abschnitt [Datenbank](#datenbank) – Migrationen ausführen. + +### 3. Konfiguration anlegen + +```bash +cd /opt/nginx_filebot +cp config/config.example.yaml config/config.yaml +nano config/config.yaml +``` + +### 4. Starten + +```bash +cd /opt/nginx_filebot +docker compose up -d --build +``` + +### 5. Prüfen + +```bash +docker compose ps +curl -s http://localhost:8080/api/health +# Erwartung: {"status":"ok"} +``` + +Im Browser: **`http://:8080`** + +--- + +## Konfiguration + +Datei: **`/opt/nginx_filebot/config/config.yaml`** (nicht versionieren, in `.gitignore`). + +Beispiel (produktive Werte aus dem Projekt): + +```yaml +database: + host: "192.168.30.186" + port: 5432 + name: "n8n_filebot" # PostgreSQL-Datenbankname + user: "n8n_filebot" + password: "geheim" + table: "movie_files" # Tabellenname (ohne Schema) + db_schema: "n8n" # PostgreSQL-Schema + ssl: false +``` + +| Parameter | Bedeutung | +|-----------|-----------| +| `name` | **Datenbank** (Catalog), nicht Tabellenname | +| `table` | Tabellenname innerhalb des Schemas | +| `db_schema` | Schema (bei euch typisch `n8n`) | +| `schema` | Alias für `db_schema` in YAML (optional) | + +Die API verbindet sich mit: `"n8n"."movie_files"` (in Anführungszeichen, case-sensitiv). + +--- + +## Datenbank + +### Tabelle und Schema + +| Einstellung | Typischer Wert | +|-------------|----------------| +| Datenbank | `n8n_filebot` | +| Schema | `n8n` | +| Tabelle | `movie_files` | + +Tabellen prüfen: + +```bash +docker compose exec api python /app/scripts/list-tables.py +``` + +### Migration: `seen_at` + +```bash +psql -h 192.168.30.186 -U n8n_filebot -d n8n_filebot \ + -f /opt/nginx_filebot/sql/001_add_seen_at.sql +``` + +Oder manuell: + +```sql +ALTER TABLE n8n.movie_files + ADD COLUMN IF NOT EXISTS seen_at TIMESTAMPTZ NULL; + +CREATE INDEX IF NOT EXISTS idx_movie_files_seen_at + ON n8n.movie_files (seen_at); +``` + +- `seen_at IS NULL` → **ungesehen** (hervorgehoben in der UI) +- gesetzt → **gesehen** + +### Migration: `cover_url` + +```bash +psql -h 192.168.30.186 -U n8n_filebot -d n8n_filebot \ + -f /opt/nginx_filebot/sql/002_add_cover_url.sql +``` + +```sql +ALTER TABLE n8n.movie_files + ADD COLUMN IF NOT EXISTS cover_url TEXT NULL; +``` + +Beispiel-URL: `https://static.tvmaze.com/uploads/images/medium_portrait/200/502332.jpg` +(Doppelte Anführungszeichen in der DB werden in der UI bereinigt.) + +### Weitere Spalten + +Die API liest alle in `api/main.py` → `COLUMNS` definierten Felder (entsprechen dem n8n/Filebot-Export). Fehlende Spalten führen zu API-Fehlern – dann Spalte in PostgreSQL ergänzen oder `COLUMNS` anpassen. + +--- + +## Bedienung + +### Desktop + +1. Seite öffnen → Daten werden geladen (Status „DB verbunden“). +2. Filter oben nutzen; **Aktualisieren** lädt neu, **Zurücksetzen** löscht Filter. +3. **Live-Aktualisierung:** Alle 5 Sekunden werden die Daten still neu geladen (ohne Seiten-Reload). Im Header: „Live · HH:MM:SS“. Im Hintergrund-Tab pausiert das Polling. +4. **Cover:** Bilder werden über `/api/cover` geladen (Proxy), falls externe Poster (z. B. TVMaze) blockiert werden. +5. **Zeile antippen/klicken** → Gesehen-Status umschalten. +6. **Mülleimer** → Eintrag löschen (mit Bestätigung). +7. Spaltenköpfe klicken zum Sortieren; Schnellfilter unter der Filterleiste. + +### Mobil + +- Gleiche Logik in **Kartenform** mit Cover links. +- Filter untereinander. + +### Gesehen-Status + +| Aktion | Effekt | +|--------|--------| +| Zeile antippen (ungesehen) | `seen_at` = jetzt | +| Zeile antippen (gesehen) | `seen_at` = NULL | +| „Nur ungesehen“ | Zeigt nur Einträge ohne `seen_at` | +| „Alle als gesehen“ | Setzt `seen_at` für alle **aktuell gefilterten** Einträge | + +--- + +## API-Referenz + +Basis-URL hinter Nginx: `http://:8080/api` + +### `GET /health` + +```json +{ "status": "ok" } +``` + +### `GET /status` + +Diagnose: Config-Pfad, qualifizierte Tabelle, Zeilenanzahl, bei Fehler Tabellenlisten. + +### `GET /records` + +Query-Parameter: + +| Parameter | Beschreibung | +|-----------|--------------| +| `search` | ILIKE in `file_name_original` und `file_name_new` | +| `format` | `standard_video_format` | +| `codec` | `video_codec_library` | +| `container` | `container_format` | +| `created_from` | ISO-Datum/Zeit, `created_at >=` | +| `created_to` | ISO-Datum/Zeit, `created_at <=` | +| `unseen_only` | `true` → nur `seen_at IS NULL` | +| `limit`, `offset` | Pagination (Standard limit=5000) | + +Antwort enthält u. a. `records`, `total`, `unseen_total`. + +### `GET /records/meta` + +Gleiche Query-Parameter wie `GET /records` (ohne Pagination). Leichtgewichtige Antwort für Auto-Refresh: + +```json +{ + "total": 120, + "unseen_total": 3, + "max_id": 456, + "latest_created_at": "2026-06-04T12:00:00+00:00", + "revision": "a1b2c3…" +} +``` + +### `GET /cover` + +Query: `url` (HTTP(S)-Poster-URL). Liefert das Bild über die API (umgeht Hotlink-/Referrer-Sperren). + +### `GET /filters` + +Distinct-Werte für Format-, Codec- und Container-Buttons. + +### `PATCH /records/{id}/seen` + +Body: + +```json +{ "seen": true } +``` + +oder `{ "seen": false }` (setzt `seen_at` auf NULL). + +### `POST /records/mark-all-seen` + +Markiert alle Einträge, die zu den **gleichen Query-Filtern** wie `GET /records` passen (ohne `unseen_only`). + +### `DELETE /records/{id}` + +Löscht einen Datensatz anhand der `id`. + +--- + +## Projektstruktur + +```text +. +├── docker-compose.yml +├── PROMPT.md # KI-/Projekt-Kontext +├── README.md +├── config/ +│ └── config.example.yaml +├── api/ +│ ├── main.py # REST-Endpunkte, COLUMNS +│ ├── config.py # YAML-Config, db_schema +│ ├── Dockerfile +│ ├── requirements.txt +│ └── scripts/ +│ └── list-tables.py # Diagnose im Container +├── frontend/ +│ ├── package.json +│ ├── vite.config.ts +│ └── src/ +│ ├── App.vue +│ ├── api.ts +│ ├── styles.css # Theme (helles Dark-UI) +│ ├── components/ +│ │ ├── DataTable.vue +│ │ ├── MobileCards.vue +│ │ ├── FilterBar.vue +│ │ ├── CoverImage.vue +│ │ ├── NamePair.vue +│ │ ├── DateTimeRangeFilter.vue +│ │ └── DeleteButton.vue +│ └── utils/ +│ ├── datetime.ts +│ └── seen.ts +├── nginx/ +│ ├── Dockerfile +│ └── nginx.conf +└── sql/ + ├── 001_add_seen_at.sql + └── 002_add_cover_url.sql +``` + +--- + +## Entwicklung & Updates + +### Code aktualisieren + +```bash +cd /opt/nginx_filebot +git pull # falls Git +docker compose up -d --build +``` + +Nur Frontend: + +```bash +docker compose up -d --build web +``` + +Nur API: + +```bash +docker compose up -d --build api +``` + +### Logs + +```bash +docker compose logs -f api +docker compose logs -f web +``` + +### Optional: lokale Entwicklung (nicht für Produktion) + +Nur wenn bewusst gewünscht – normaler Workflow ist Server-only. + +```bash +# API +cd api && pip install -r requirements.txt +CONFIG_PATH=../config/config.yaml uvicorn main:app --reload --port 8000 + +# Frontend (proxied in vite.config.ts nach :8000) +cd frontend && npm install && npm run dev +``` + +--- + +## Fehlerbehebung + +### API-Fehler / leere Seite + +```bash +cd /opt/nginx_filebot +docker compose logs api --tail 80 +curl -s http://localhost:8080/api/status | python3 -m json.tool +``` + +| Symptom | Ursache | Lösung | +|---------|---------|--------| +| `relation "n8n.n8n_filebot" does not exist` | Falscher Tabellenname | `table: "movie_files"` in config.yaml | +| `relation "public...." does not exist` | Schema falsch | `db_schema: "n8n"` | +| Spalte `seen_at` / `cover_url` fehlt | Migration fehlt | SQL in `sql/` ausführen | +| `API-Fehler: 503` | DB nicht erreichbar | `nc -zv 5432` vom Server und aus Container | +| 0 Einträge | Filter zu streng / leere DB | Filter zurücksetzen, `SELECT COUNT(*)` prüfen | +| Altes Design | Cache / kein Rebuild | `docker compose up -d --build web` + Hard-Reload | + +### Netzwerk vom Container zur DB + +```bash +docker compose exec api python -c " +import socket +s = socket.create_connection(('192.168.30.186', 5432), timeout=5) +print('OK'); s.close() +" +``` + +### Port ändern + +In `docker-compose.yml` z. B. `"80:80"` oder `"8888:80"` statt `"8080:80"`. + +--- + +## Hinweise für n8n + +- **INSERT:** Neue Zeilen haben `seen_at = NULL` → erscheinen als „neu“. +- **UPDATE:** `seen_at` und `cover_url` nicht mit überschreiben, wenn der Status erhalten bleiben soll. +- Datenbank-Workflow und Tabellenname (`movie_files`, Schema `n8n`) mit der `config.yaml` abstimmen. + +--- + +## Lizenz / Nutzung + +Privates Heimnetz-Projekt. Anpassungen nach Bedarf; `config.yaml` mit Passwörtern nicht in öffentliche Repositories committen. diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..79ae546 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..a6e5dd0 --- /dev/null +++ b/api/config.py @@ -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 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..3a2e28a --- /dev/null +++ b/api/main.py @@ -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} diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..9081d6c --- /dev/null +++ b/api/requirements.txt @@ -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 diff --git a/api/scripts/list-tables.py b/api/scripts/list-tables.py new file mode 100644 index 0000000..9e3f2d3 --- /dev/null +++ b/api/scripts/list-tables.py @@ -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()) diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100755 index 0000000..6c2039f --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,9 @@ +# Speicherort auf dem Server: /opt/docker/nginx_filebot/config/config.yaml + +database: + host: "192.168.30.186" + port: 5432 + name: "deine_datenbank" + user: "dein_benutzer" + password: "dein_passwort" + table: "n8n_filebot" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..561b796 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +# Deployment: cd /opt/nginx_filebot && docker compose up -d --build +# Pfade relativ zum Projektordner (funktioniert überall unter /opt/nginx_filebot) + +name: nginx_filebot + +services: + api: + build: ./api + restart: unless-stopped + volumes: + - ./config/config.yaml:/app/config/config.yaml:ro + environment: + CONFIG_PATH: /app/config/config.yaml + networks: + - filebot-net + + web: + build: + context: . + dockerfile: nginx/Dockerfile + ports: + - "8090:80" + depends_on: + - api + restart: unless-stopped + networks: + - filebot-net + +networks: + filebot-net: + driver: bridge diff --git a/docs/DEPLOY-NEUER-SERVER.md b/docs/DEPLOY-NEUER-SERVER.md new file mode 100644 index 0000000..5877263 --- /dev/null +++ b/docs/DEPLOY-NEUER-SERVER.md @@ -0,0 +1,176 @@ +# Deployment auf neuem Server (`/opt/nginx_filebot`) + +Anleitung für einen **neuen** Linux-Server oder Umzug vom alten Pfad `/opt/docker/nginx_filebot`. + +Einziger User: **`vogto`** (SSH, SMB, Docker). + +--- + +## 1. Rechte für `/opt` (einmalig) + +Gruppe **`opt-docker`** nur für Samba (`force group`) und einheitliche Gruppenrechte; Owner überall **`vogto`**. + +```bash +sudo groupadd -f opt-docker +sudo usermod -aG opt-docker,docker vogto +``` + +`vogto` danach **neu anmelden** (SMB trennen/neu verbinden), damit Gruppen greifen. + +```bash +# Gesamtes /opt: vogto + Gruppe opt-docker, setgid (neue Dateien erben die Gruppe) +sudo chown vogto:opt-docker /opt +sudo chmod 2775 /opt + +# Projektordner (erbt Owner/Gruppe von /opt) +sudo mkdir -p /opt/nginx_filebot +``` + +| Pfad | Owner | Modus | +|------|--------|--------| +| **`/opt`** | `vogto:opt-docker` | `2775` | +| **`/opt/nginx_filebot`** | (erbt von `/opt`) | (erbt) | + +Weitere Docker-Projekte später nur anlegen – **kein** extra `chown` nötig: + +```bash +sudo mkdir -p /opt/anderes_projekt +# bleibt vogto:opt-docker durch setgid auf /opt +``` + +`config/config.yaml`: + +```bash +chmod 640 /opt/nginx_filebot/config/config.yaml +``` + +### Samba (Beispiel) + +```ini +[opt] + path = /opt + browseable = yes + read only = no + valid users = vogto + force user = vogto + force group = opt-docker + create mask = 0664 + directory mask = 2775 +``` + +```bash +sudo testparm +sudo systemctl reload smbd +``` + +Nach größeren Kopien/Rechten von root: + +```bash +sudo chown -R vogto:opt-docker /opt +sudo find /opt -type d -exec chmod 2775 {} \; +sudo find /opt -type f -exec chmod 664 {} \; +``` + +--- + +## 2. Projekt auf den neuen Server bringen + +### Variante A: Git + +```bash +git clone /opt/nginx_filebot +``` + +### Variante B: rsync vom Mac / altem Server + +```bash +rsync -avz --exclude node_modules --exclude '.git' \ + --exclude config/config.yaml \ + ./ vogto@NEUER_SERVER:/opt/nginx_filebot/ + +scp config/config.example.yaml vogto@NEUER_SERVER:/opt/nginx_filebot/config/config.yaml +``` + +### Variante C: Umzug vom alten Server + +```bash +# Alt +cd /opt/docker/nginx_filebot && docker compose down + +# Kopieren +rsync -avz user@ALTER_SERVER:/opt/docker/nginx_filebot/ /opt/nginx_filebot/ + +# Rechte (ganzes /opt oder nur Projekt) +sudo chown -R vogto:opt-docker /opt +``` + +**Wichtig:** `config/config.yaml` vom alten Server mitnehmen. + +--- + +## 3. Konfiguration & Datenbank + +```bash +cd /opt/nginx_filebot +cp config/config.example.yaml config/config.yaml # falls nötig +nano config/config.yaml +``` + +PostgreSQL vom neuen Server erreichbar? Migrationen: + +```bash +psql -h -U -d n8n_filebot -f /opt/nginx_filebot/sql/001_add_seen_at.sql +psql -h -U -d n8n_filebot -f /opt/nginx_filebot/sql/002_add_cover_url.sql +``` + +--- + +## 4. Docker starten + +```bash +cd /opt/nginx_filebot +docker compose up -d --build +curl -s http://localhost:8080/api/health +``` + +Browser: `http://:8080` + +--- + +## 5. Checkliste + +| Schritt | Erledigt | +|---------|----------| +| `vogto` in `opt-docker` und `docker` | ☐ | +| `/opt` → `vogto:opt-docker`, `2775` | ☐ | +| Samba `force user` / `force group` | ☐ | +| Repo unter `/opt/nginx_filebot` | ☐ | +| `config/config.yaml` | ☐ | +| `docker compose up -d --build` | ☐ | + +--- + +## 6. Häufige Probleme + +| Symptom | Lösung | +|---------|--------| +| `open Dockerfile: no such file` (web) | `nginx/Dockerfile` fehlt auf dem Server – siehe unten | +| `load metadata for python:3.12-slim` | Internet/Docker Hub: `docker pull python:3.12-slim` | +| SMB: nicht schreiben | `id vogto`, `chown -R vogto:opt-docker /opt`, neu anmelden | +| `permission denied` | `sudo chown -R vogto:opt-docker /opt` | +| Docker ohne sudo | `usermod -aG docker vogto`, neu anmelden | +| API 503 | DB-Host/Firewall, `curl …/api/status` | + +### Build-Fehler: Dockerfile fehlt + +```bash +ls -la /opt/nginx_filebot/nginx/Dockerfile +sh /opt/nginx_filebot/scripts/verify-deploy-files.sh +``` + +Wenn `FEHLT`: kompletten Ordner `nginx/` (und ggf. `frontend/`, `api/`) erneut hochladen. Danach: + +```bash +cd /opt/nginx_filebot +docker compose up -d --build +``` diff --git a/docs/dashboard-assets/README.md b/docs/dashboard-assets/README.md new file mode 100644 index 0000000..cf41f58 --- /dev/null +++ b/docs/dashboard-assets/README.md @@ -0,0 +1,21 @@ +# Logo für Media-Server-Dashboard (`/opt/nginx/html`) + +## Datei kopieren + +```bash +cp /opt/nginx_filebot/docs/dashboard-assets/filebot-media-browser.svg \ + /opt/nginx/html/filebot-media-browser.svg +``` + +## In `index.html` (apps-Array) + +```javascript +{ + name: "FileBot Media Browser", + port: 8090, + icon: "/filebot-media-browser.svg", + color: "#6366f1", +}, +``` + +Port und Pfad an deine Umgebung anpassen. Die Karte verlinkt automatisch auf `http://:8090/`. diff --git a/docs/dashboard-assets/filebot-media-browser.svg b/docs/dashboard-assets/filebot-media-browser.svg new file mode 100644 index 0000000..439b18a --- /dev/null +++ b/docs/dashboard-assets/filebot-media-browser.svg @@ -0,0 +1,18 @@ + + + + + + + + + FB + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c14d27b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + Filebot Media Browser + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e21a669 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "filebot-browser", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/vue-table": "^8.20.5", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.2", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..82aae04 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..6478fdf --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,95 @@ +import type { FilterOptions, RecordsResponse } from './types' + +export interface FetchParams { + search?: string + format?: string + codec?: string + container?: string + createdFrom?: string + createdTo?: string + unseenOnly?: boolean + objectType?: string +} + +const fetchOpts: RequestInit = { cache: 'no-store' } + +async function parseApiError(res: Response): Promise { + try { + const data = (await res.json()) as { detail?: unknown } + const d = data.detail + if (typeof d === 'string') return d + if (Array.isArray(d)) { + return d + .map((item) => (typeof item === 'object' && item && 'msg' in item ? String((item as { msg: string }).msg) : String(item))) + .join('; ') + } + } catch { + /* ignore */ + } + return `API-Fehler: ${res.status}` +} + +function buildRecordsQuery(params: FetchParams = {}): URLSearchParams { + const q = new URLSearchParams() + if (params.search) q.set('search', params.search) + if (params.format) q.set('format', params.format) + if (params.codec) q.set('codec', params.codec) + if (params.container) q.set('container', params.container) + if (params.createdFrom) q.set('created_from', params.createdFrom) + if (params.createdTo) q.set('created_to', params.createdTo) + if (params.unseenOnly) q.set('unseen_only', 'true') + if (params.objectType) q.set('object_type', params.objectType) + return q +} + +export async function fetchRecords(params: FetchParams = {}): Promise { + const q = buildRecordsQuery(params) + q.set('_', String(Date.now())) + const res = await fetch(`/api/records?${q}`, fetchOpts) + if (!res.ok) throw new Error(await parseApiError(res)) + return res.json() +} + +export async function fetchFilterOptions(): Promise { + const res = await fetch('/api/filters', fetchOpts) + if (!res.ok) throw new Error(await parseApiError(res)) + return res.json() +} + +export async function setRecordSeen( + id: number, + seen: boolean +): Promise<{ id: number; seen_at: string | null }> { + const res = await fetch(`/api/records/${id}/seen`, { + ...fetchOpts, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seen }), + }) + if (!res.ok) throw new Error(await parseApiError(res)) + return res.json() +} + +export async function markAllSeen(params: FetchParams = {}): Promise<{ marked: number }> { + const res = await fetch(`/api/records/mark-all-seen?${buildRecordsQuery(params)}`, { + ...fetchOpts, + method: 'POST', + }) + if (!res.ok) throw new Error(await parseApiError(res)) + return res.json() +} + +export async function deleteRecord(id: number): Promise { + const res = await fetch(`/api/records/${id}`, { ...fetchOpts, method: 'DELETE' }) + if (!res.ok) throw new Error(await parseApiError(res)) +} + +export async function checkHealth(): Promise<{ ok: boolean; detail?: string }> { + try { + const res = await fetch('/api/health', fetchOpts) + if (res.ok) return { ok: true } + return { ok: false, detail: await parseApiError(res) } + } catch (e) { + return { ok: false, detail: e instanceof Error ? e.message : 'Netzwerkfehler' } + } +} diff --git a/frontend/src/components/CoverImage.vue b/frontend/src/components/CoverImage.vue new file mode 100644 index 0000000..ea54833 --- /dev/null +++ b/frontend/src/components/CoverImage.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue new file mode 100644 index 0000000..094e975 --- /dev/null +++ b/frontend/src/components/DataTable.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/frontend/src/components/DateTimeRangeFilter.vue b/frontend/src/components/DateTimeRangeFilter.vue new file mode 100644 index 0000000..e41ff8d --- /dev/null +++ b/frontend/src/components/DateTimeRangeFilter.vue @@ -0,0 +1,447 @@ + + + + + diff --git a/frontend/src/components/DeleteButton.vue b/frontend/src/components/DeleteButton.vue new file mode 100644 index 0000000..6382cd5 --- /dev/null +++ b/frontend/src/components/DeleteButton.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/components/FilterBar.vue b/frontend/src/components/FilterBar.vue new file mode 100644 index 0000000..9fa51ef --- /dev/null +++ b/frontend/src/components/FilterBar.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/frontend/src/components/MobileCards.vue b/frontend/src/components/MobileCards.vue new file mode 100644 index 0000000..75d3800 --- /dev/null +++ b/frontend/src/components/MobileCards.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/frontend/src/components/NamePair.vue b/frontend/src/components/NamePair.vue new file mode 100644 index 0000000..2a50820 --- /dev/null +++ b/frontend/src/components/NamePair.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/components/ObjectTypeBadge.vue b/frontend/src/components/ObjectTypeBadge.vue new file mode 100644 index 0000000..1b6d0e3 --- /dev/null +++ b/frontend/src/components/ObjectTypeBadge.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/composables/useAutoRefresh.ts b/frontend/src/composables/useAutoRefresh.ts new file mode 100644 index 0000000..a4ee805 --- /dev/null +++ b/frontend/src/composables/useAutoRefresh.ts @@ -0,0 +1,72 @@ +import { onBeforeUnmount, ref, type Ref } from 'vue' + +export const AUTO_REFRESH_INTERVAL_MS = 5000 + +export function useAutoRefresh( + onRefresh: () => void | Promise, + options: { + enabled: Ref + paused: Ref + } +) { + const lastSyncedAt = ref(null) + const isRefreshing = ref(false) + + let timer: ReturnType | null = null + let running = false + + async function tick() { + if (options.paused.value || !options.enabled.value || running) return + if (document.visibilityState === 'hidden') return + + running = true + isRefreshing.value = true + try { + await onRefresh() + lastSyncedAt.value = new Date() + } catch { + /* stiller Fehler – nächster Versuch */ + } finally { + running = false + isRefreshing.value = false + } + } + + function start() { + stop() + timer = setInterval(() => { + void tick() + }, AUTO_REFRESH_INTERVAL_MS) + } + + function stop() { + if (timer) { + clearInterval(timer) + timer = null + } + } + + function onVisibility() { + if (document.visibilityState === 'visible') { + void tick() + } + } + + function mount() { + start() + document.addEventListener('visibilitychange', onVisibility) + } + + function unmount() { + stop() + document.removeEventListener('visibilitychange', onVisibility) + } + + return { + lastSyncedAt, + isRefreshing, + tick, + mount, + unmount, + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..fdbdce5 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles.css' + +createApp(App).mount('#app') diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..55d087b --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,175 @@ +:root { + --bg: #2a3142; + --bg-elevated: #343d52; + --bg-card: #3f4a63; + --bg-hover: #4a5672; + --bg-input: #455068; + --border: rgba(255, 255, 255, 0.14); + --border-strong: rgba(255, 255, 255, 0.24); + --text: #f8f9fc; + --text-muted: #b4bdd1; + --accent: #6366f1; + --accent-hover: #a5b4fc; + --accent-glow: rgba(99, 102, 241, 0.35); + --success: #34d399; + --warning: #fbbf24; + --danger: #f87171; + --danger-bg: rgba(248, 113, 113, 0.18); + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 10px 40px rgba(0, 0, 0, 0.18); + --font: 'DM Sans', system-ui, sans-serif; + --mono: 'JetBrains Mono', ui-monospace, monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 15px; +} + +body { + margin: 0; + font-family: var(--font); + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; + background-image: + radial-gradient(ellipse 90% 55% at 50% -15%, rgba(99, 102, 241, 0.2), transparent), + radial-gradient(ellipse 70% 45% at 100% 0%, rgba(52, 211, 153, 0.1), transparent), + linear-gradient(180deg, #2f3648 0%, var(--bg) 45%); +} + +#app { + min-height: 100vh; +} + +button, +input, +select { + font-family: inherit; +} + +input, +select { + background: var(--bg-input); + border: 1px solid var(--border-strong); + color: var(--text); + border-radius: var(--radius-sm); + padding: 0.55rem 0.85rem; + font-size: 0.9rem; + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); + background: var(--bg-card); +} + +input::placeholder { + color: var(--text-muted); +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 1rem; + border-radius: var(--radius-sm); + border: none; + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-ghost { + background: var(--bg-card); + color: var(--text-muted); + border: 1px solid var(--border-strong); +} + +.btn-ghost:hover { + color: var(--text); + background: var(--bg-hover); + border-color: var(--border-strong); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-format { + background: rgba(99, 102, 241, 0.28); + color: var(--accent-hover); +} + +.badge-warn { + background: var(--danger-bg); + color: var(--danger); +} + +.badge-ok { + background: rgba(52, 211, 153, 0.22); + color: var(--success); +} + +.name-cell { + font-family: var(--mono); + font-size: 0.8rem; + line-height: 1.45; + word-break: break-all; +} + +.name-cell--original { + color: var(--text-muted); +} + +.name-cell--new { + color: var(--text); +} + +.row-unseen td, +.card-unseen { + background: rgba(99, 102, 241, 0.14); +} + +.row-unseen td:first-child { + box-shadow: inset 4px 0 0 var(--accent-hover); +} + +.card-unseen { + border-color: rgba(129, 140, 248, 0.45); + box-shadow: inset 4px 0 0 var(--accent-hover); +} + +.badge-new { + background: rgba(99, 102, 241, 0.35); + color: #e0e7ff; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..563597b --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,47 @@ +export interface MediaRecord { + id: number + file_name_original: string | null + file_name_new: string | null + object_type: string | null + cover_url: string | null + movie_rating: string | number | null + movie_votes: number | null + video_compression_format: string | null + video_codec_library: string | null + audio_codec: string | null + container_format: string | null + standard_video_format: string | null + exact_video_format: string | null + fourk_resolution: string | null + audio_codec_profile: string | null + audio_channel_format: string | null + audio_channel_count: string | null + audio_channel_layout: string | null + video_resolution: string | null + video_width: number | null + video_height: number | null + video_bitdepth: number | null + high_dynamic_range: string | null + dolby_vision: string | null + overall_bitrate: string | null + video_bitrate: string | null + audio_bitrate: string | null + created_at: string | null + seen_at: string | null +} + +export interface FilterOptions { + formats: string[] + codecs: string[] + containers: string[] + audio_codecs: string[] + object_types: string[] +} + +export interface RecordsResponse { + records: MediaRecord[] + total: number + unseen_total: number + limit: number + offset: number +} diff --git a/frontend/src/utils/coverUrl.ts b/frontend/src/utils/coverUrl.ts new file mode 100644 index 0000000..a77f60d --- /dev/null +++ b/frontend/src/utils/coverUrl.ts @@ -0,0 +1,10 @@ +/** Cover über API-Proxy laden (Hotlink/Referrer-Probleme). */ +export function coverImageSrc(url: string | null | undefined): string | null { + if (!url) return null + const cleaned = url.trim().replace(/^["']+|["']+$/g, '') + if (!cleaned) return null + if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) { + return `/api/cover?url=${encodeURIComponent(cleaned)}` + } + return cleaned +} diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts new file mode 100644 index 0000000..003e504 --- /dev/null +++ b/frontend/src/utils/datetime.ts @@ -0,0 +1,104 @@ +export interface DateTimeBound { + date: string + hour: string + minute: string +} + +export const emptyDateTimeBound = (): DateTimeBound => ({ + date: '', + hour: '00', + minute: '00', +}) + +export function boundToIso(bound: DateTimeBound): string | undefined { + if (!bound.date) return undefined + const local = new Date( + `${bound.date}T${bound.hour.padStart(2, '0')}:${bound.minute.padStart(2, '0')}:00` + ) + if (Number.isNaN(local.getTime())) return undefined + return local.toISOString() +} + +export function formatBoundLabel(bound: DateTimeBound): string { + if (!bound.date) return 'Datum wählen' + return `${formatDateDe(bound.date)} ${bound.hour.padStart(2, '0')}:${bound.minute.padStart(2, '0')}` +} + +export function formatDateDe(isoDate: string): string { + const [y, m, d] = isoDate.split('-').map(Number) + if (!y || !m || !d) return isoDate + return new Date(y, m - 1, d).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) +} + +export function pad2(n: number): string { + return String(n).padStart(2, '0') +} + +export const HOURS = Array.from({ length: 24 }, (_, i) => pad2(i)) +export const MINUTES = Array.from({ length: 60 }, (_, i) => pad2(i)) + +const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] +const MONTHS = [ + 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', +] + +export function getWeekdayLabels(): string[] { + return WEEKDAYS +} + +export function getMonthLabel(monthIndex: number): string { + return MONTHS[monthIndex] ?? '' +} + +/** Kalendertage inkl. Auffüllung für Mo-start Grid */ +export function getCalendarDays(year: number, month: number) { + const first = new Date(year, month, 1) + const last = new Date(year, month + 1, 0) + let startPad = first.getDay() - 1 + if (startPad < 0) startPad = 6 + + const days: { date: string; day: number; currentMonth: boolean }[] = [] + + for (let i = startPad - 1; i >= 0; i--) { + const d = new Date(year, month, -i) + days.push({ + date: toIsoDate(d), + day: d.getDate(), + currentMonth: false, + }) + } + + for (let d = 1; d <= last.getDate(); d++) { + days.push({ + date: toIsoDate(new Date(year, month, d)), + day: d, + currentMonth: true, + }) + } + + const totalCells = Math.ceil((startPad + last.getDate()) / 7) * 7 + let nextDay = 1 + while (days.length < totalCells) { + const d = new Date(year, month + 1, nextDay++) + days.push({ + date: toIsoDate(d), + day: d.getDate(), + currentMonth: false, + }) + } + + return days +} + +export function toIsoDate(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +} + +export function isBoundActive(bound: DateTimeBound): boolean { + return Boolean(bound.date) +} diff --git a/frontend/src/utils/names.ts b/frontend/src/utils/names.ts new file mode 100644 index 0000000..0fba6b7 --- /dev/null +++ b/frontend/src/utils/names.ts @@ -0,0 +1,25 @@ +/** Normalisiert für groben Vergleich (Punkte, Klammern, Leerzeichen). */ +export function normalizeFileName(name: string | null | undefined): string { + if (!name) return '' + return name + .toLowerCase() + .replace(/[._\-()[\]]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +export function namesLikelyMatch( + original: string | null | undefined, + renamed: string | null | undefined +): boolean { + const a = normalizeFileName(original) + const b = normalizeFileName(renamed) + if (!a || !b) return a === b + if (a === b) return true + return a.includes(b) || b.includes(a) +} + +export function formatDisplay(value: unknown): string { + if (value === null || value === undefined || value === '') return '—' + return String(value) +} diff --git a/frontend/src/utils/objectType.ts b/frontend/src/utils/objectType.ts new file mode 100644 index 0000000..4a208ea --- /dev/null +++ b/frontend/src/utils/objectType.ts @@ -0,0 +1,22 @@ +export type ObjectTypeKind = 'movie' | 'episode' | 'unknown' + +export function normalizeObjectType(value: string | null | undefined): ObjectTypeKind { + const s = (value ?? '').toLowerCase().trim() + if (!s) return 'unknown' + if (s === 'movie' || s === 'film' || s === 'movies') return 'movie' + if (s === 'episode' || s === 'episodes' || s.includes('episode')) return 'episode' + return 'unknown' +} + +export function objectTypeLabel(value: string | null | undefined): string { + const kind = normalizeObjectType(value) + if (kind === 'movie') return 'Film' + if (kind === 'episode') return 'Episode' + const raw = value?.trim() + return raw || '—' +} + +/** Anzeige auf Filter-Buttons (DB-Wert → lesbar). */ +export function objectTypeFilterLabel(dbValue: string): string { + return objectTypeLabel(dbValue) +} diff --git a/frontend/src/utils/preserveScroll.ts b/frontend/src/utils/preserveScroll.ts new file mode 100644 index 0000000..b4eab54 --- /dev/null +++ b/frontend/src/utils/preserveScroll.ts @@ -0,0 +1,25 @@ +import { nextTick } from 'vue' + +export interface ScrollSnapshot { + windowY: number + tableTop: number +} + +export function captureScroll(): ScrollSnapshot { + const table = document.querySelector('.table-scroll') + return { + windowY: window.scrollY, + tableTop: table instanceof HTMLElement ? table.scrollTop : 0, + } +} + +export async function restoreScroll(snapshot: ScrollSnapshot): Promise { + await nextTick() + requestAnimationFrame(() => { + const table = document.querySelector('.table-scroll') + if (table instanceof HTMLElement) { + table.scrollTop = snapshot.tableTop + } + window.scrollTo(0, snapshot.windowY) + }) +} diff --git a/frontend/src/utils/seen.ts b/frontend/src/utils/seen.ts new file mode 100644 index 0000000..815c4b2 --- /dev/null +++ b/frontend/src/utils/seen.ts @@ -0,0 +1,9 @@ +import type { MediaRecord } from '../types' + +export function isUnseen(record: MediaRecord): boolean { + return record.seen_at == null || record.seen_at === '' +} + +export function countUnseen(records: MediaRecord[]): number { + return records.filter(isUnseen).length +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..ff43a26 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..268a119 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "jsx": "preserve", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9848df0 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + }, +}) diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..49568f6 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS frontend-build + +WORKDIR /app + +COPY frontend/package.json ./ +RUN npm install + +COPY frontend/index.html frontend/vite.config.ts frontend/tsconfig.json frontend/tsconfig.app.json ./ +COPY frontend/src ./src + +RUN npm run build + +FROM nginx:1.27-alpine + +COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=frontend-build /app/dist /usr/share/nginx/html diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..bb1fea3 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; + + location /api/ { + proxy_pass http://api:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + proxy_buffering on; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/scripts/verify-deploy-files.sh b/scripts/verify-deploy-files.sh new file mode 100644 index 0000000..37e282e --- /dev/null +++ b/scripts/verify-deploy-files.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Auf dem Server: sh /opt/nginx_filebot/scripts/verify-deploy-files.sh +set -e +ROOT="${1:-/opt/nginx_filebot}" +missing=0 + +check() { + if [ -f "$ROOT/$1" ] || [ -d "$ROOT/$1" ]; then + printf 'OK %s\n' "$1" + else + printf 'FEHLT %s\n' "$1" + missing=1 + fi +} + +echo "Prüfe Deploy-Dateien unter: $ROOT" +echo "" + +check docker-compose.yml +check api/Dockerfile +check api/main.py +check api/requirements.txt +check nginx/Dockerfile +check nginx/nginx.conf +check frontend/package.json +check frontend/vite.config.ts +check config/config.yaml + +if [ "$missing" -eq 1 ]; then + echo "" + echo "→ Fehlende Dateien vom Repo nach $ROOT kopieren (rsync/git)." + exit 1 +fi + +echo "" +echo "Alle Pflichtdateien vorhanden." diff --git a/sql/001_add_seen_at.sql b/sql/001_add_seen_at.sql new file mode 100644 index 0000000..0e215b3 --- /dev/null +++ b/sql/001_add_seen_at.sql @@ -0,0 +1,15 @@ +-- Gesehen-Status für Filebot Media Browser +-- Tabelle: n8n.movie_files in Datenbank n8n_filebot (anpassen falls nötig) + +ALTER TABLE n8n.movie_files + ADD COLUMN IF NOT EXISTS seen_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN n8n.movie_files.seen_at IS + 'NULL = noch nicht gesehen; gesetzt = Zeitpunkt der Ansicht in der Web-UI'; + +CREATE INDEX IF NOT EXISTS idx_movie_files_seen_at + ON n8n.movie_files (seen_at); + +CREATE INDEX IF NOT EXISTS idx_movie_files_unseen + ON n8n.movie_files (created_at DESC) + WHERE seen_at IS NULL; diff --git a/sql/002_add_cover_url.sql b/sql/002_add_cover_url.sql new file mode 100644 index 0000000..f34dadf --- /dev/null +++ b/sql/002_add_cover_url.sql @@ -0,0 +1,6 @@ +-- Optional: falls cover_url noch nicht existiert +ALTER TABLE n8n.movie_files + ADD COLUMN IF NOT EXISTS cover_url TEXT NULL; + +COMMENT ON COLUMN n8n.movie_files.cover_url IS + 'Poster/Cover-URL (z.B. TVMaze medium_portrait)'; diff --git a/sql/003_add_object_type.sql b/sql/003_add_object_type.sql new file mode 100644 index 0000000..84405c3 --- /dev/null +++ b/sql/003_add_object_type.sql @@ -0,0 +1,6 @@ +-- Optional: falls object_type noch nicht existiert +ALTER TABLE n8n.movie_files + ADD COLUMN IF NOT EXISTS object_type TEXT NULL; + +COMMENT ON COLUMN n8n.movie_files.object_type IS + 'movie oder episode (Filebot/n8n)';