Compare commits
7
Commits
84b7529aa4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a7a997f15 | ||
|
|
50548d44c4 | ||
|
|
14c9bef002 | ||
|
|
3442659973 | ||
|
|
edfd7105b7 | ||
|
|
b5366783b1 | ||
|
|
944ca9a308 |
@@ -14,13 +14,13 @@ Web-Oberfläche zur Anzeige und Verwaltung von Medien-Metadaten aus **PostgreSQL
|
|||||||
- [Voraussetzungen](#voraussetzungen)
|
- [Voraussetzungen](#voraussetzungen)
|
||||||
- [Installation auf dem Server](#installation-auf-dem-server)
|
- [Installation auf dem Server](#installation-auf-dem-server)
|
||||||
- [Konfiguration](#konfiguration)
|
- [Konfiguration](#konfiguration)
|
||||||
- [Datenbank](#datenbank)
|
- [Datenbank & Migrationen](#datenbank--migrationen)
|
||||||
|
- [n8n Workflows](#n8n-workflows)
|
||||||
- [Bedienung](#bedienung)
|
- [Bedienung](#bedienung)
|
||||||
- [API-Referenz](#api-referenz)
|
- [API-Referenz](#api-referenz)
|
||||||
- [Projektstruktur](#projektstruktur)
|
- [Projektstruktur](#projektstruktur)
|
||||||
- [Entwicklung & Updates](#entwicklung--updates)
|
- [Entwicklung & Updates](#entwicklung--updates)
|
||||||
- [Fehlerbehebung](#fehlerbehebung)
|
- [Fehlerbehebung](#fehlerbehebung)
|
||||||
- [Hinweise für n8n](#hinweise-für-n8n)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,12 +28,12 @@ Web-Oberfläche zur Anzeige und Verwaltung von Medien-Metadaten aus **PostgreSQL
|
|||||||
|
|
||||||
Die Anwendung läuft als **zwei Docker-Container** auf einem Linux-Server:
|
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
|
1. **web** – Nginx liefert die Vue-App aus und leitet `/api` an das Backend weiter
|
||||||
2. **api** – FastAPI liest Daten aus PostgreSQL
|
2. **api** – FastAPI liest Daten aus PostgreSQL
|
||||||
|
|
||||||
**Produktiv-Pfad auf dem Server:** `/opt/nginx_filebot`
|
**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.
|
> Auf dem Entwicklungs-PC ist kein Node.js oder Python für den Betrieb nötig. Code anpassen, mit `scripts/deploy.sh` auf den Server übertragen, dort `docker compose up -d --build` ausführen.
|
||||||
|
|
||||||
Für KI-Assistenten / Projekt-Kontext: siehe [`PROMPT.md`](PROMPT.md).
|
Für KI-Assistenten / Projekt-Kontext: siehe [`PROMPT.md`](PROMPT.md).
|
||||||
|
|
||||||
@@ -43,29 +43,30 @@ Für KI-Assistenten / Projekt-Kontext: siehe [`PROMPT.md`](PROMPT.md).
|
|||||||
|
|
||||||
### Medien-Übersicht
|
### Medien-Übersicht
|
||||||
|
|
||||||
- **Cover** aus `cover_url` (z. B. TVMaze-Poster) in der ersten Spalte / auf Karten
|
- **Cover** – primär aus der Datenbank (`cover_image` BYTEA), Fallback auf externe URL via Proxy
|
||||||
- **Original-** und **Neu-Dateiname** immer sichtbar
|
- **Original-** und **Neu-Dateiname** immer sichtbar (Rename-Kontrolle)
|
||||||
- Technische Spalten: Rating, Format, Auflösung, Codecs, Bitraten, Container, Erstellungsdatum
|
- Technische Spalten: Rating, Format, Auflösung, Codecs, Bitraten, Container, Erstellungsdatum
|
||||||
- **Neueste Einträge zuerst** (Sortierung nach `created_at`)
|
- **Neueste Einträge zuerst** (Sortierung nach `created_at`)
|
||||||
|
|
||||||
### Filter
|
### Filter
|
||||||
|
|
||||||
- Volltextsuche in beiden Dateinamen
|
- Volltextsuche in beiden Dateinamen
|
||||||
- **Button-Filter** für Videoformat, Codec und Container (nebeneinander auf Desktop, gestapelt auf Mobil)
|
- **Button-Filter** für Typ (Movie/Episode), Videoformat, Codec und Container
|
||||||
- **Zeitraum** „Von / Bis“ mit Kalender und Uhrzeit (HH:MM)
|
- **Zeitraum** „Von / Bis" mit Kalender und Uhrzeit (HH:MM)
|
||||||
- Schnellfilter in der Tabelle (clientseitig in geladenen Daten)
|
- Schnellfilter in der Tabelle (clientseitig in geladenen Daten)
|
||||||
|
|
||||||
### Gesehen-Status
|
### Gesehen-Status
|
||||||
|
|
||||||
- Ungesehene Zeilen sind **hervorgehoben** (blauer Akzent)
|
- Ungesehene Zeilen sind **hervorgehoben** (blauer Akzent)
|
||||||
- **Tippen auf eine Zeile** markiert sie als gesehen oder ungesehen
|
- **Tippen auf eine Zeile** markiert sie als gesehen oder ungesehen
|
||||||
- Filter **„Nur ungesehen“** und Aktion **„Alle als gesehen“** (für aktuelle Filterauswahl)
|
- Filter **„Nur ungesehen"** und Aktion **„Alle als gesehen"** (für aktuelle Filterauswahl)
|
||||||
- Zähler **„X neu“** im Header und in den Filtern
|
- Zähler **„X neu"** im Header
|
||||||
|
|
||||||
### Weitere Aktionen
|
### Weitere Aktionen
|
||||||
|
|
||||||
- **Löschen** einzelner Einträge (Mülleimer, mit Bestätigung)
|
- **Löschen** einzelner Einträge (Mülleimer, mit Bestätigung)
|
||||||
- Responsives Layout: **Tabelle** (Desktop) / **Karten** (Mobil, ≤900px Breite)
|
- **Live-Aktualisierung** alle 5 Sekunden (pausiert im Hintergrund-Tab)
|
||||||
|
- Responsives Layout: **Tabelle** (Desktop) / **Karten** (Mobil, ≤900px)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ Für KI-Assistenten / Projekt-Kontext: siehe [`PROMPT.md`](PROMPT.md).
|
|||||||
▼
|
▼
|
||||||
┌─────────────────┐ /api/* ┌─────────────────┐
|
┌─────────────────┐ /api/* ┌─────────────────┐
|
||||||
│ nginx (web) │ ───────────────►│ FastAPI (api) │
|
│ nginx (web) │ ───────────────►│ FastAPI (api) │
|
||||||
│ Port 8080 │ │ Port 8000 │
|
│ Port 8090 │ │ Port 8000 │
|
||||||
│ Vue static │ └────────┬────────┘
|
│ Vue static │ └────────┬────────┘
|
||||||
└─────────────────┘ │
|
└─────────────────┘ │
|
||||||
▼
|
▼
|
||||||
@@ -92,8 +93,6 @@ Für KI-Assistenten / Projekt-Kontext: siehe [`PROMPT.md`](PROMPT.md).
|
|||||||
| `web` | `nginx/Dockerfile` (Node → Nginx) | Statische Dateien, Reverse Proxy |
|
| `web` | `nginx/Dockerfile` (Node → Nginx) | Statische Dateien, Reverse Proxy |
|
||||||
| `api` | `api/Dockerfile` | REST-API, DB-Zugriff |
|
| `api` | `api/Dockerfile` | REST-API, DB-Zugriff |
|
||||||
|
|
||||||
Compose-Datei nutzt **relative Pfade** – immer aus `/opt/nginx_filebot` starten.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
@@ -101,34 +100,24 @@ Compose-Datei nutzt **relative Pfade** – immer aus `/opt/nginx_filebot` starte
|
|||||||
- Linux-Server mit **Docker** und **Docker Compose v2**
|
- Linux-Server mit **Docker** und **Docker Compose v2**
|
||||||
- Erreichbare **PostgreSQL**-Instanz (im LAN, z. B. `192.168.30.186:5432`)
|
- Erreichbare **PostgreSQL**-Instanz (im LAN, z. B. `192.168.30.186:5432`)
|
||||||
- Tabelle **`n8n.movie_files`** in Datenbank **`n8n_filebot`** (anpassbar in Config)
|
- Tabelle **`n8n.movie_files`** in Datenbank **`n8n_filebot`** (anpassbar in Config)
|
||||||
- Optional: Spalten `seen_at` und `cover_url` (siehe [Datenbank](#datenbank))
|
- Alle SQL-Migrationen ausgeführt (siehe [Datenbank & Migrationen](#datenbank--migrationen))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation auf dem Server
|
## Installation auf dem Server
|
||||||
|
|
||||||
Ausführliche Anleitung inkl. **SMB-Gruppe** und Umzug: [`docs/DEPLOY-NEUER-SERVER.md`](docs/DEPLOY-NEUER-SERVER.md).
|
Ausführliche Anleitung inkl. SMB-Gruppe und Umzug: [`docs/DEPLOY-NEUER-SERVER.md`](docs/DEPLOY-NEUER-SERVER.md).
|
||||||
|
|
||||||
### 1. Projekt ablegen
|
### 1. Projekt ablegen
|
||||||
|
|
||||||
```bash
|
```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
|
sudo mkdir -p /opt/nginx_filebot
|
||||||
|
git clone https://git.vogt.de.com/vogto/filebot-media-browser.git /opt/nginx_filebot
|
||||||
# Variante A: Git
|
|
||||||
git clone <repository-url> /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
|
### 2. Datenbank vorbereiten
|
||||||
|
|
||||||
Siehe Abschnitt [Datenbank](#datenbank) – Migrationen ausführen.
|
Alle Migrationen ausführen (siehe [Datenbank & Migrationen](#datenbank--migrationen)).
|
||||||
|
|
||||||
### 3. Konfiguration anlegen
|
### 3. Konfiguration anlegen
|
||||||
|
|
||||||
@@ -149,19 +138,17 @@ docker compose up -d --build
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
curl -s http://localhost:8080/api/health
|
curl -s http://localhost:8090/api/health
|
||||||
# Erwartung: {"status":"ok"}
|
# Erwartung: {"status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Im Browser: **`http://<Server-IP>:8080`**
|
Im Browser: **`http://<Server-IP>:8090`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Datei: **`/opt/nginx_filebot/config/config.yaml`** (nicht versionieren, in `.gitignore`).
|
Datei: **`config/config.yaml`** (nicht versioniert, in `.gitignore`).
|
||||||
|
|
||||||
Beispiel (produktive Werte aus dem Projekt):
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
database:
|
database:
|
||||||
@@ -177,88 +164,126 @@ database:
|
|||||||
|
|
||||||
| Parameter | Bedeutung |
|
| Parameter | Bedeutung |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| `name` | **Datenbank** (Catalog), nicht Tabellenname |
|
| `name` | Datenbank (Catalog) |
|
||||||
| `table` | Tabellenname innerhalb des Schemas |
|
| `table` | Tabellenname innerhalb des Schemas |
|
||||||
| `db_schema` | Schema (bei euch typisch `n8n`) |
|
| `db_schema` | Schema (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
|
## Datenbank & Migrationen
|
||||||
|
|
||||||
### Tabelle und Schema
|
### Tabelle
|
||||||
|
|
||||||
| Einstellung | Typischer Wert |
|
| Einstellung | Wert |
|
||||||
|-------------|----------------|
|
|-------------|------|
|
||||||
| Datenbank | `n8n_filebot` |
|
| Datenbank | `n8n_filebot` |
|
||||||
| Schema | `n8n` |
|
| Schema | `n8n` |
|
||||||
| Tabelle | `movie_files` |
|
| Tabelle | `movie_files` |
|
||||||
|
| Vollqualifiziert | `"n8n"."movie_files"` |
|
||||||
|
|
||||||
Tabellen prüfen:
|
### Migrationen ausführen
|
||||||
|
|
||||||
|
Alle SQL-Dateien im Verzeichnis `sql/` der Reihe nach ausführen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec api python /app/scripts/list-tables.py
|
cd /opt/nginx_filebot
|
||||||
|
|
||||||
|
for f in sql/00*.sql; do
|
||||||
|
echo "==> $f"
|
||||||
|
psql -h 192.168.30.186 -U n8n_filebot -d n8n_filebot -f "$f"
|
||||||
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migration: `seen_at`
|
| Datei | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| `001_add_seen_at.sql` | Spalte `seen_at TIMESTAMPTZ` + Index |
|
||||||
|
| `002_add_cover_url.sql` | Spalte `cover_url TEXT` |
|
||||||
|
| `003_add_object_type.sql` | Spalte `object_type TEXT` |
|
||||||
|
| `004_add_cover_image.sql` | Spalten `cover_image BYTEA` + `cover_mime_type TEXT` |
|
||||||
|
|
||||||
```bash
|
### Gesehen-Status
|
||||||
psql -h 192.168.30.186 -U n8n_filebot -d n8n_filebot \
|
|
||||||
-f /opt/nginx_filebot/sql/001_add_seen_at.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder manuell:
|
- `seen_at IS NULL` → **ungesehen** (blau hervorgehoben)
|
||||||
|
|
||||||
```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**
|
- gesetzt → **gesehen**
|
||||||
|
|
||||||
### Migration: `cover_url`
|
### Cover-Bilder
|
||||||
|
|
||||||
|
- `cover_url` – externe Poster-URL (z. B. TVMaze, OMDB)
|
||||||
|
- `cover_image` – Bild-Binärdaten direkt in der DB (BYTEA)
|
||||||
|
- `cover_mime_type` – MIME-Typ des gespeicherten Bildes (z. B. `image/jpeg`)
|
||||||
|
|
||||||
|
Die UI lädt Cover **primär aus der DB** (`/api/records/{id}/cover`), Fallback auf den URL-Proxy (`/api/cover?url=…`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## n8n Workflows
|
||||||
|
|
||||||
|
### Workflow 1: FileBot (Haupt-Workflow)
|
||||||
|
|
||||||
|
Wird von Filebot per Webhook ausgelöst und schreibt Medien-Metadaten in die DB. Danach läuft alle 2 Minuten ein Schedule-Trigger, der fehlende Cover-URLs nachlädt und die Cover-Bilder herunterlädt.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
|
||||||
```bash
|
|
||||||
psql -h 192.168.30.186 -U n8n_filebot -d n8n_filebot \
|
|
||||||
-f /opt/nginx_filebot/sql/002_add_cover_url.sql
|
|
||||||
```
|
```
|
||||||
|
Webhook (Filebot) → Insert rows (Metadaten)
|
||||||
|
|
||||||
|
Schedule (alle 2 Min) → Select (cover_url IS NULL)
|
||||||
|
→ Loop → Switch (Movie / Episode)
|
||||||
|
Movie → HTTP omdbapi → Edit Fields → Download Cover → Prepare Data → Speichere Cover in DB
|
||||||
|
Episode → If thetvdb_id → HTTP tvmaze → Edit Fields1 → Download Cover → Prepare Data → Speichere Cover in DB
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig für n8n:**
|
||||||
|
- `seen_at` und `cover_image` beim INSERT/UPDATE **nicht** überschreiben
|
||||||
|
- `cover_url` bleibt immer erhalten (auch wenn der Bild-Download fehlschlägt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Workflow 2: Cover Backfill
|
||||||
|
|
||||||
|
Einmaliger / regelmäßiger Workflow zum Nachfüllen von `cover_image` für Einträge, die bereits eine `cover_url` haben, aber noch kein Bild in der DB.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Manuell / Stündlich
|
||||||
|
→ Select (cover_image IS NULL AND cover_url IS NOT NULL)
|
||||||
|
→ Loop (Batch 5)
|
||||||
|
→ HTTP Download (cover_url, continueOnFail)
|
||||||
|
→ Code (Base64 extrahieren)
|
||||||
|
→ Execute Query (UPDATE cover_image, cover_mime_type)
|
||||||
|
→ Loop (weiter)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Wenn der Workflow wegen Timeout abbricht (viele Einträge), beim erneuten Ausführen macht er automatisch dort weiter, wo er aufgehört hat – der SELECT lädt nur noch Einträge mit `cover_image IS NULL`.
|
||||||
|
|
||||||
|
Tabellengröße prüfen:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
ALTER TABLE n8n.movie_files
|
SELECT
|
||||||
ADD COLUMN IF NOT EXISTS cover_url TEXT NULL;
|
COUNT(*) AS zeilen,
|
||||||
|
COUNT(cover_image) AS mit_bild,
|
||||||
|
pg_size_pretty(pg_total_relation_size('"n8n"."movie_files"')) AS gesamt
|
||||||
|
FROM n8n.movie_files;
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Bedienung
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
1. Seite öffnen → Daten werden geladen (Status „DB verbunden“).
|
1. Seite öffnen → Daten werden geladen (Status „DB verbunden").
|
||||||
2. Filter oben nutzen; **Aktualisieren** lädt neu, **Zurücksetzen** löscht Filter.
|
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.
|
3. **Live-Aktualisierung:** Alle 5 Sekunden werden die Daten still neu geladen. Im Hintergrund-Tab pausiert das Polling.
|
||||||
4. **Cover:** Bilder werden über `/api/cover` geladen (Proxy), falls externe Poster (z. B. TVMaze) blockiert werden.
|
4. **Zeile antippen/klicken** → Gesehen-Status umschalten.
|
||||||
5. **Zeile antippen/klicken** → Gesehen-Status umschalten.
|
5. **Mülleimer** → Eintrag löschen (mit Bestätigung).
|
||||||
6. **Mülleimer** → Eintrag löschen (mit Bestätigung).
|
6. Spaltenköpfe klicken zum Sortieren; Schnellfilter unter der Filterleiste.
|
||||||
7. Spaltenköpfe klicken zum Sortieren; Schnellfilter unter der Filterleiste.
|
|
||||||
|
|
||||||
### Mobil
|
### Mobil
|
||||||
|
|
||||||
- Gleiche Logik in **Kartenform** mit Cover links.
|
- Gleiche Logik in **Kartenform** mit Cover links.
|
||||||
- Filter untereinander.
|
- Filter untereinander gestapelt.
|
||||||
|
|
||||||
### Gesehen-Status
|
### Gesehen-Status
|
||||||
|
|
||||||
@@ -266,81 +291,28 @@ Die API liest alle in `api/main.py` → `COLUMNS` definierten Felder (entspreche
|
|||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Zeile antippen (ungesehen) | `seen_at` = jetzt |
|
| Zeile antippen (ungesehen) | `seen_at` = jetzt |
|
||||||
| Zeile antippen (gesehen) | `seen_at` = NULL |
|
| Zeile antippen (gesehen) | `seen_at` = NULL |
|
||||||
| „Nur ungesehen“ | Zeigt nur Einträge ohne `seen_at` |
|
| „Nur ungesehen" | Zeigt nur Einträge ohne `seen_at` |
|
||||||
| „Alle als gesehen“ | Setzt `seen_at` für alle **aktuell gefilterten** Einträge |
|
| „Alle als gesehen" | Setzt `seen_at` für alle aktuell gefilterten Einträge |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API-Referenz
|
## API-Referenz
|
||||||
|
|
||||||
Basis-URL hinter Nginx: `http://<host>:8080/api`
|
Basis-URL: `http://<host>:8090/api`
|
||||||
|
|
||||||
### `GET /health`
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
```json
|
| GET | `/health` | DB-Ping |
|
||||||
{ "status": "ok" }
|
| GET | `/status` | Diagnose (Config, Tabellenliste bei Fehler) |
|
||||||
```
|
| GET | `/columns` | Liste aller geladenen Spalten |
|
||||||
|
| GET | `/filters` | Distinct-Werte für Filter-Buttons |
|
||||||
### `GET /status`
|
| GET | `/records` | Datensätze; Query: `search`, `format`, `codec`, `container`, `object_type`, `created_from`, `created_to`, `unseen_only`, `limit`, `offset` |
|
||||||
|
| GET | `/records/meta` | Leichtgewichtiger Check für Auto-Refresh (total, unseen_total, revision) |
|
||||||
Diagnose: Config-Pfad, qualifizierte Tabelle, Zeilenanzahl, bei Fehler Tabellenlisten.
|
| GET | `/records/{id}/cover` | Cover-Bild direkt aus DB (BYTEA); 404 wenn kein Bild gespeichert |
|
||||||
|
| GET | `/cover?url=…` | Cover-Proxy für externe URLs (umgeht Hotlink-Sperren) |
|
||||||
### `GET /records`
|
| PATCH | `/records/{id}/seen` | Body: `{"seen": true\|false}` |
|
||||||
|
| POST | `/records/mark-all-seen` | Alle gefilterten Einträge als gesehen markieren |
|
||||||
Query-Parameter:
|
| DELETE | `/records/{id}` | Eintrag löschen |
|
||||||
|
|
||||||
| 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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -349,65 +321,74 @@ Löscht einen Datensatz anhand der `id`.
|
|||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── PROMPT.md # KI-/Projekt-Kontext
|
├── PROMPT.md # KI-/Projekt-Kontext
|
||||||
├── README.md
|
├── README.md
|
||||||
├── config/
|
├── config/
|
||||||
│ └── config.example.yaml
|
│ └── config.example.yaml
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── main.py # REST-Endpunkte, COLUMNS
|
│ ├── main.py # REST-Endpunkte, COLUMNS
|
||||||
│ ├── config.py # YAML-Config, db_schema
|
│ ├── config.py # YAML-Config, db_schema
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ └── scripts/
|
│ └── scripts/
|
||||||
│ └── list-tables.py # Diagnose im Container
|
│ └── list-tables.py # Diagnose im Container
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── vite.config.ts
|
│ ├── vite.config.ts
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── App.vue
|
│ ├── App.vue
|
||||||
│ ├── api.ts
|
│ ├── api.ts
|
||||||
│ ├── styles.css # Theme (helles Dark-UI)
|
│ ├── types.ts
|
||||||
|
│ ├── styles.css
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── DataTable.vue
|
│ │ ├── DataTable.vue
|
||||||
│ │ ├── MobileCards.vue
|
│ │ ├── MobileCards.vue
|
||||||
│ │ ├── FilterBar.vue
|
│ │ ├── FilterBar.vue
|
||||||
│ │ ├── CoverImage.vue
|
│ │ ├── CoverImage.vue # DB-Bild primär, URL-Proxy als Fallback
|
||||||
│ │ ├── NamePair.vue
|
│ │ ├── NamePair.vue
|
||||||
│ │ ├── DateTimeRangeFilter.vue
|
│ │ ├── DateTimeRangeFilter.vue
|
||||||
│ │ └── DeleteButton.vue
|
│ │ └── DeleteButton.vue
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ └── useAutoRefresh.ts
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
|
│ ├── coverUrl.ts
|
||||||
│ ├── datetime.ts
|
│ ├── datetime.ts
|
||||||
|
│ ├── names.ts
|
||||||
|
│ ├── objectType.ts
|
||||||
|
│ ├── preserveScroll.ts
|
||||||
│ └── seen.ts
|
│ └── seen.ts
|
||||||
├── nginx/
|
├── nginx/
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── nginx.conf
|
│ └── nginx.conf
|
||||||
|
├── scripts/
|
||||||
|
│ └── deploy.sh # Einmaliges SSH-Login, alle Dateien übertragen
|
||||||
└── sql/
|
└── sql/
|
||||||
├── 001_add_seen_at.sql
|
├── 001_add_seen_at.sql
|
||||||
└── 002_add_cover_url.sql
|
├── 002_add_cover_url.sql
|
||||||
|
├── 003_add_object_type.sql
|
||||||
|
└── 004_add_cover_image.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entwicklung & Updates
|
## Entwicklung & Updates
|
||||||
|
|
||||||
### Code aktualisieren
|
### Dateien auf den Server übertragen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/Users/vogto/Documents/ClaudeCode/Webseite Filebot"
|
||||||
|
bash scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script baut die Container anschließend automatisch neu.
|
||||||
|
|
||||||
|
### Manuell (einzelne Dateien)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/nginx_filebot
|
cd /opt/nginx_filebot
|
||||||
git pull # falls Git
|
docker compose up -d --build # alles
|
||||||
docker compose up -d --build
|
docker compose up -d --build web # nur Frontend
|
||||||
```
|
docker compose up -d --build api # nur API
|
||||||
|
|
||||||
Nur Frontend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build web
|
|
||||||
```
|
|
||||||
|
|
||||||
Nur API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build api
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
@@ -417,19 +398,6 @@ docker compose logs -f api
|
|||||||
docker compose logs -f web
|
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
|
## Fehlerbehebung
|
||||||
@@ -437,19 +405,17 @@ cd frontend && npm install && npm run dev
|
|||||||
### API-Fehler / leere Seite
|
### API-Fehler / leere Seite
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/nginx_filebot
|
|
||||||
docker compose logs api --tail 80
|
docker compose logs api --tail 80
|
||||||
curl -s http://localhost:8080/api/status | python3 -m json.tool
|
curl -s http://localhost:8090/api/status | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
| Symptom | Ursache | Lösung |
|
| Symptom | Ursache | Lösung |
|
||||||
|---------|---------|--------|
|
|---------|---------|--------|
|
||||||
| `relation "n8n.n8n_filebot" does not exist` | Falscher Tabellenname | `table: "movie_files"` in config.yaml |
|
| `relation "..." does not exist` | Falscher Tabellenname oder Schema | `table: "movie_files"`, `db_schema: "n8n"` in config.yaml |
|
||||||
| `relation "public...." does not exist` | Schema falsch | `db_schema: "n8n"` |
|
| Spalte fehlt (API 500) | Migration nicht ausgeführt | SQL in `sql/` ausführen |
|
||||||
| Spalte `seen_at` / `cover_url` fehlt | Migration fehlt | SQL in `sql/` ausführen |
|
| `503` | DB nicht erreichbar | `nc -zv 192.168.30.186 5432` vom Server prüfen |
|
||||||
| `API-Fehler: 503` | DB nicht erreichbar | `nc -zv <host> 5432` vom Server und aus Container |
|
| Cover fehlt | `cover_image IS NULL` | Backfill-Workflow in n8n ausführen |
|
||||||
| 0 Einträge | Filter zu streng / leere DB | Filter zurücksetzen, `SELECT COUNT(*)` prüfen |
|
| Altes Design | Kein Rebuild | `docker compose up -d --build web` + Hard-Reload |
|
||||||
| Altes Design | Cache / kein Rebuild | `docker compose up -d --build web` + Hard-Reload |
|
|
||||||
|
|
||||||
### Netzwerk vom Container zur DB
|
### Netzwerk vom Container zur DB
|
||||||
|
|
||||||
@@ -460,21 +426,3 @@ s = socket.create_connection(('192.168.30.186', 5432), timeout=5)
|
|||||||
print('OK'); s.close()
|
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.
|
|
||||||
|
|||||||
+48
@@ -53,6 +53,7 @@ COLUMNS = [
|
|||||||
"audio_bitrate",
|
"audio_bitrate",
|
||||||
"created_at",
|
"created_at",
|
||||||
"seen_at",
|
"seen_at",
|
||||||
|
"cover_mime_type",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -452,6 +453,31 @@ async def proxy_cover(url: str = Query(..., min_length=8)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/records/{record_id}/cover")
|
||||||
|
async def get_record_cover(record_id: int):
|
||||||
|
"""Cover-Bild aus DB liefern (BYTEA). 404 wenn kein Bild gespeichert."""
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"SELECT cover_image, cover_mime_type FROM {qualified_table} WHERE id = $1",
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, detail="Eintrag nicht gefunden")
|
||||||
|
if not row["cover_image"]:
|
||||||
|
raise HTTPException(404, detail="Kein Bild gespeichert")
|
||||||
|
|
||||||
|
mime = row["cover_mime_type"] or "image/jpeg"
|
||||||
|
return Response(
|
||||||
|
content=bytes(row["cover_image"]),
|
||||||
|
media_type=mime,
|
||||||
|
headers={"Cache-Control": "public, max-age=604800"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/records")
|
@app.get("/api/records")
|
||||||
async def get_records(
|
async def get_records(
|
||||||
search: str | None = Query(None, description="Suche in Dateinamen"),
|
search: str | None = Query(None, description="Suche in Dateinamen"),
|
||||||
@@ -518,6 +544,28 @@ async def get_records(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SetCoverUrlBody(BaseModel):
|
||||||
|
cover_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/records/{record_id}/cover_url")
|
||||||
|
async def set_record_cover_url(record_id: int, body: SetCoverUrlBody):
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
url = body.cover_url.strip()
|
||||||
|
if not url:
|
||||||
|
raise HTTPException(422, detail="cover_url darf nicht leer sein")
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE {qualified_table} SET cover_url = $1, cover_image = NULL, cover_mime_type = NULL WHERE id = $2 RETURNING id, cover_url",
|
||||||
|
url,
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, detail=f"Eintrag mit ID {record_id} nicht gefunden")
|
||||||
|
return {"id": row["id"], "cover_url": row["cover_url"]}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/records/{record_id}/seen")
|
@app.patch("/api/records/{record_id}/seen")
|
||||||
async def set_record_seen(record_id: int, body: SetSeenBody):
|
async def set_record_seen(record_id: int, body: SetSeenBody):
|
||||||
"""Einzelnen Eintrag als gesehen (seen=true) oder ungesehen (seen=false) markieren."""
|
"""Einzelnen Eintrag als gesehen (seen=true) oder ungesehen (seen=false) markieren."""
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {
|
|||||||
fetchRecords,
|
fetchRecords,
|
||||||
markAllSeen,
|
markAllSeen,
|
||||||
setRecordSeen,
|
setRecordSeen,
|
||||||
|
updateCoverUrl,
|
||||||
} from './api'
|
} from './api'
|
||||||
import type { FilterOptions, MediaRecord } from './types'
|
import type { FilterOptions, MediaRecord } from './types'
|
||||||
import { isUnseen } from './utils/seen'
|
import { isUnseen } from './utils/seen'
|
||||||
import { useAutoRefresh } from './composables/useAutoRefresh'
|
import { useAutoRefresh } from './composables/useAutoRefresh'
|
||||||
import { captureScroll, restoreScroll } from './utils/preserveScroll'
|
import { captureScroll, restoreScroll } from './utils/preserveScroll'
|
||||||
import FilterBar from './components/FilterBar.vue'
|
import FilterBar from './components/FilterBar.vue'
|
||||||
|
import CoverUrlModal from './components/CoverUrlModal.vue'
|
||||||
import { boundToIso, emptyDateTimeBound, type DateTimeBound } from './utils/datetime'
|
import { boundToIso, emptyDateTimeBound, type DateTimeBound } from './utils/datetime'
|
||||||
import DataTable from './components/DataTable.vue'
|
import DataTable from './components/DataTable.vue'
|
||||||
import MobileCards from './components/MobileCards.vue'
|
import MobileCards from './components/MobileCards.vue'
|
||||||
@@ -39,6 +41,7 @@ let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
const deletingId = ref<number | null>(null)
|
const deletingId = ref<number | null>(null)
|
||||||
const togglingSeenId = ref<number | null>(null)
|
const togglingSeenId = ref<number | null>(null)
|
||||||
|
const coverEditRecord = ref<MediaRecord | null>(null)
|
||||||
|
|
||||||
const autoRefreshEnabled = ref(true)
|
const autoRefreshEnabled = ref(true)
|
||||||
const autoRefreshPaused = computed(
|
const autoRefreshPaused = computed(
|
||||||
@@ -170,6 +173,21 @@ async function handleMarkAllSeen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCoverSave(recordId: number, url: string) {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await updateCoverUrl(recordId, url)
|
||||||
|
const record = records.value.find((r) => r.id === recordId)
|
||||||
|
if (record) {
|
||||||
|
record.cover_url = result.cover_url
|
||||||
|
record.cover_mime_type = null
|
||||||
|
}
|
||||||
|
coverEditRecord.value = null
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Cover-URL konnte nicht gespeichert werden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
const row = records.value.find((r) => r.id === id)
|
const row = records.value.find((r) => r.id === id)
|
||||||
const label = row?.file_name_new || row?.file_name_original || `ID ${id}`
|
const label = row?.file_name_new || row?.file_name_original || `ID ${id}`
|
||||||
@@ -285,6 +303,7 @@ onBeforeUnmount(() => {
|
|||||||
:toggling-seen-id="togglingSeenId"
|
:toggling-seen-id="togglingSeenId"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@toggle-seen="handleToggleSeen"
|
@toggle-seen="handleToggleSeen"
|
||||||
|
@edit-cover="(r) => (coverEditRecord = r)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isMobile" class="view-mobile">
|
<div v-show="isMobile" class="view-mobile">
|
||||||
@@ -294,11 +313,19 @@ onBeforeUnmount(() => {
|
|||||||
:toggling-seen-id="togglingSeenId"
|
:toggling-seen-id="togglingSeenId"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@toggle-seen="handleToggleSeen"
|
@toggle-seen="handleToggleSeen"
|
||||||
|
@edit-cover="(r) => (coverEditRecord = r)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CoverUrlModal
|
||||||
|
:open="coverEditRecord !== null"
|
||||||
|
:record-id="coverEditRecord?.id ?? 0"
|
||||||
|
@close="coverEditRecord = null"
|
||||||
|
@save="handleCoverSave"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ export async function markAllSeen(params: FetchParams = {}): Promise<{ marked: n
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCoverUrl(
|
||||||
|
id: number,
|
||||||
|
coverUrl: string,
|
||||||
|
): Promise<{ id: number; cover_url: string }> {
|
||||||
|
const res = await fetch(`/api/records/${id}/cover_url`, {
|
||||||
|
...fetchOpts,
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cover_url: coverUrl }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res))
|
||||||
|
return res.json() as Promise<{ id: number; cover_url: string }>
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRecord(id: number): Promise<void> {
|
export async function deleteRecord(id: number): Promise<void> {
|
||||||
const res = await fetch(`/api/records/${id}`, { ...fetchOpts, method: 'DELETE' })
|
const res = await fetch(`/api/records/${id}`, { ...fetchOpts, method: 'DELETE' })
|
||||||
if (!res.ok) throw new Error(await parseApiError(res))
|
if (!res.ok) throw new Error(await parseApiError(res))
|
||||||
|
|||||||
@@ -4,17 +4,39 @@ import { coverImageSrc } from '../utils/coverUrl'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
url: string | null | undefined
|
url: string | null | undefined
|
||||||
|
id?: number | null
|
||||||
|
hasCoverInDb?: boolean | null
|
||||||
size?: 'table' | 'card'
|
size?: 'table' | 'card'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const failed = ref(false)
|
// Ob das DB-Bild bereits fehlgeschlagen ist → Fallback auf URL
|
||||||
|
const dbFailed = ref(false)
|
||||||
|
const urlFailed = ref(false)
|
||||||
|
|
||||||
const src = computed(() => coverImageSrc(props.url))
|
const src = computed(() => {
|
||||||
|
if (dbFailed.value) {
|
||||||
|
// Fallback: URL-Proxy
|
||||||
|
return coverImageSrc(props.url, null, false)
|
||||||
|
}
|
||||||
|
return coverImageSrc(props.url, props.id, props.hasCoverInDb)
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = computed(() => dbFailed.value && urlFailed.value)
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
if (!dbFailed.value && props.id && props.hasCoverInDb) {
|
||||||
|
// DB-Bild fehlgeschlagen → Fallback auf URL versuchen
|
||||||
|
dbFailed.value = true
|
||||||
|
} else {
|
||||||
|
urlFailed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.url,
|
() => [props.id, props.url, props.hasCoverInDb],
|
||||||
() => {
|
() => {
|
||||||
failed.value = false
|
dbFailed.value = false
|
||||||
|
urlFailed.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -29,7 +51,7 @@ watch(
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@error="failed = true"
|
@error="onError"
|
||||||
/>
|
/>
|
||||||
<div v-else class="cover__placeholder" aria-hidden="true">
|
<div v-else class="cover__placeholder" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ recordId: number; open: boolean }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: [recordId: number, url: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const url = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (v) { url.value = ''; error.value = '' }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
const trimmed = url.value.trim()
|
||||||
|
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||||
|
error.value = 'Bitte eine gültige URL eingeben (http:// oder https://)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('save', props.recordId, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="modal-backdrop" @click="onBackdrop">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<h2 class="modal__title">Cover-URL festlegen</h2>
|
||||||
|
<p class="modal__hint">URL des Cover-Bildes eingeben. Das Bild wird sofort als Vorschau geladen und von n8n in die Datenbank übernommen.</p>
|
||||||
|
<input
|
||||||
|
v-model="url"
|
||||||
|
class="modal__input"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
autofocus
|
||||||
|
@keydown.enter="onSave"
|
||||||
|
@keydown.esc="emit('close')"
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="modal__error">{{ error }}</p>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn btn--ghost" @click="emit('close')">Abbrechen</button>
|
||||||
|
<button class="btn btn--primary" @click="onSave">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: min(480px, 90vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--danger, #e05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: none;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,8 +26,13 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [id: number]
|
delete: [id: number]
|
||||||
'toggle-seen': [record: MediaRecord]
|
'toggle-seen': [record: MediaRecord]
|
||||||
|
'edit-cover': [record: MediaRecord]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function needsCover(record: MediaRecord): boolean {
|
||||||
|
return !record.cover_mime_type && (!record.cover_url || record.cover_url === 'N/A')
|
||||||
|
}
|
||||||
|
|
||||||
function onRowClick(record: MediaRecord, event: MouseEvent) {
|
function onRowClick(record: MediaRecord, event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.closest('button, a, input, select')) return
|
if (target.closest('button, a, input, select')) return
|
||||||
@@ -47,10 +52,21 @@ const columns: ColumnDef<MediaRecord>[] = [
|
|||||||
accessorKey: 'cover_url',
|
accessorKey: 'cover_url',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
h(CoverImage, {
|
h('div', { class: 'cover-cell' }, [
|
||||||
url: row.original.cover_url,
|
h(CoverImage, {
|
||||||
size: 'table',
|
url: row.original.cover_url,
|
||||||
}),
|
id: row.original.id,
|
||||||
|
hasCoverInDb: Boolean(row.original.cover_mime_type),
|
||||||
|
size: 'table',
|
||||||
|
}),
|
||||||
|
needsCover(row.original)
|
||||||
|
? h('button', {
|
||||||
|
class: 'cover-edit-btn',
|
||||||
|
title: 'Cover-URL eingeben',
|
||||||
|
onClick: (e: Event) => { e.stopPropagation(); emit('edit-cover', row.original) },
|
||||||
|
}, '+')
|
||||||
|
: null,
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'object_type',
|
id: 'object_type',
|
||||||
@@ -362,6 +378,33 @@ td {
|
|||||||
padding-right: 0.35rem;
|
padding-right: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-cell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-edit-btn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-cell:hover .cover-edit-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.th-type,
|
.th-type,
|
||||||
.td-type {
|
.td-type {
|
||||||
width: 88px;
|
width: 88px;
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [id: number]
|
delete: [id: number]
|
||||||
'toggle-seen': [record: MediaRecord]
|
'toggle-seen': [record: MediaRecord]
|
||||||
|
'edit-cover': [record: MediaRecord]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function needsCover(record: MediaRecord): boolean {
|
||||||
|
return !record.cover_mime_type && (!record.cover_url || record.cover_url === 'N/A')
|
||||||
|
}
|
||||||
|
|
||||||
function onCardClick(record: MediaRecord, event: MouseEvent) {
|
function onCardClick(record: MediaRecord, event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.closest('button, a, input, select, dl')) return
|
if (target.closest('button, a, input, select, dl')) return
|
||||||
@@ -74,7 +79,15 @@ const detailFields: { key: keyof MediaRecord; label: string }[] = [
|
|||||||
@click="onCardClick(row, $event)"
|
@click="onCardClick(row, $event)"
|
||||||
>
|
>
|
||||||
<div class="card__hero">
|
<div class="card__hero">
|
||||||
<CoverImage :url="row.cover_url" size="card" />
|
<div class="cover-wrap">
|
||||||
|
<CoverImage :url="row.cover_url" :id="row.id" :has-cover-in-db="Boolean(row.cover_mime_type)" size="card" />
|
||||||
|
<button
|
||||||
|
v-if="needsCover(row)"
|
||||||
|
class="cover-edit-btn"
|
||||||
|
title="Cover-URL eingeben"
|
||||||
|
@click.stop="emit('edit-cover', row)"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
<div class="card__hero-text">
|
<div class="card__hero-text">
|
||||||
<header class="card__header">
|
<header class="card__header">
|
||||||
<div class="card__meta">
|
<div class="card__meta">
|
||||||
@@ -153,6 +166,27 @@ const detailFields: { key: keyof MediaRecord; label: string }[] = [
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-edit-btn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.card__hero-text {
|
.card__hero-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface MediaRecord {
|
|||||||
file_name_new: string | null
|
file_name_new: string | null
|
||||||
object_type: string | null
|
object_type: string | null
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
|
cover_mime_type: string | null
|
||||||
movie_rating: string | number | null
|
movie_rating: string | number | null
|
||||||
movie_votes: number | null
|
movie_votes: number | null
|
||||||
video_compression_format: string | null
|
video_compression_format: string | null
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** Cover über API-Proxy laden (Hotlink/Referrer-Probleme). */
|
/** Cover-URL aus DB-URL bereinigen (für Fallback-Proxy). */
|
||||||
export function coverImageSrc(url: string | null | undefined): string | null {
|
function cleanCoverUrl(url: string | null | undefined): string | null {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
const cleaned = url.trim().replace(/^["']+|["']+$/g, '')
|
const cleaned = url.trim().replace(/^["']+|["']+$/g, '')
|
||||||
if (!cleaned) return null
|
if (!cleaned) return null
|
||||||
@@ -8,3 +8,18 @@ export function coverImageSrc(url: string | null | undefined): string | null {
|
|||||||
}
|
}
|
||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primäre Quelle: Bild aus DB (/api/records/{id}/cover).
|
||||||
|
* Fallback-URL für den Fall dass kein Bild in DB gespeichert ist.
|
||||||
|
*/
|
||||||
|
export function coverImageSrc(
|
||||||
|
url: string | null | undefined,
|
||||||
|
id?: number | null,
|
||||||
|
hasCoverInDb?: boolean | null,
|
||||||
|
): string | null {
|
||||||
|
if (id && hasCoverInDb) {
|
||||||
|
return `/api/records/${id}/cover`
|
||||||
|
}
|
||||||
|
return cleanCoverUrl(url)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Einmal Passwort eingeben, alle Dateien übertragen, dann neu bauen.
|
||||||
|
# Aufruf: bash scripts/deploy.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HOST="root@192.168.30.135"
|
||||||
|
REMOTE="/opt/nginx_filebot"
|
||||||
|
SOCKET="/tmp/ssh-filebot-$$"
|
||||||
|
|
||||||
|
echo "Verbinde mit $HOST …"
|
||||||
|
ssh -MNf -o ControlPath="$SOCKET" -o ControlPersist=60 "$HOST"
|
||||||
|
|
||||||
|
scp_remote() {
|
||||||
|
scp -o ControlPath="$SOCKET" "$1" "$HOST:$REMOTE/$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Übertrage Dateien …"
|
||||||
|
scp_remote "api/main.py" "api/main.py"
|
||||||
|
scp_remote "frontend/src/types.ts" "frontend/src/types.ts"
|
||||||
|
scp_remote "frontend/src/api.ts" "frontend/src/api.ts"
|
||||||
|
scp_remote "frontend/src/utils/coverUrl.ts" "frontend/src/utils/coverUrl.ts"
|
||||||
|
scp_remote "frontend/src/components/CoverImage.vue" "frontend/src/components/CoverImage.vue"
|
||||||
|
scp_remote "frontend/src/components/DataTable.vue" "frontend/src/components/DataTable.vue"
|
||||||
|
scp_remote "frontend/src/components/MobileCards.vue" "frontend/src/components/MobileCards.vue"
|
||||||
|
scp_remote "frontend/src/components/CoverUrlModal.vue" "frontend/src/components/CoverUrlModal.vue"
|
||||||
|
scp_remote "frontend/src/App.vue" "frontend/src/App.vue"
|
||||||
|
|
||||||
|
echo "Baue Docker-Container neu …"
|
||||||
|
ssh -o ControlPath="$SOCKET" "$HOST" "cd $REMOTE && docker compose up -d --build"
|
||||||
|
|
||||||
|
ssh -O exit -o ControlPath="$SOCKET" "$HOST" 2>/dev/null || true
|
||||||
|
echo "Fertig."
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Cover-Bild als Binary in der DB speichern
|
||||||
|
ALTER TABLE n8n.movie_files
|
||||||
|
ADD COLUMN IF NOT EXISTS cover_image BYTEA,
|
||||||
|
ADD COLUMN IF NOT EXISTS cover_mime_type TEXT;
|
||||||
Reference in New Issue
Block a user