Initial commit: Filebot Media Browser

Vue 3 + FastAPI Webapp zur Anzeige von Filebot/n8n-Medienmetadaten aus PostgreSQL.
Enthält Frontend, API, Nginx-Config, SQL-Migrationen und Docker-Compose-Setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Housemann
2026-07-11 05:38:37 +02:00
co-authored by Claude Sonnet 4.6
commit 3f253ffe81
45 changed files with 4791 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
frontend/dist/
config/config.yaml
.env
__pycache__/
*.pyc
.DS_Store
+201
View File
@@ -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 VonBis** 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://<server-ip>: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
+480
View File
@@ -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 <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
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://<Server-IP>: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://<host>: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 <host> 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.
+18
View File
@@ -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"]
+49
View File
@@ -0,0 +1,49 @@
import re
from pathlib import Path
from typing import Any
import yaml
from pydantic import AliasChoices, BaseModel, Field, field_validator
IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
class DatabaseConfig(BaseModel):
host: str = "192.168.30.186"
port: int = 5432
name: str = "postgres"
user: str = "postgres"
password: str = ""
table: str = "movie_files"
# "schema" ist in YAML erlaubt; intern db_schema (Pydantic-Konflikt mit BaseModel.schema)
db_schema: str = Field(
default="public",
validation_alias=AliasChoices("db_schema", "schema"),
)
ssl: bool = False
@field_validator("table", "db_schema")
@classmethod
def validate_identifiers(cls, v: str) -> str:
if not IDENT_RE.match(v):
raise ValueError(f"Ungültiger Datenbank-Identifier: {v}")
return v
@property
def qualified_table(self) -> str:
return f'"{self.db_schema}"."{self.table}"'
class AppConfig(BaseModel):
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
def load_config(path: str | Path | None = None) -> tuple[AppConfig, Path]:
config_path = Path(path or "/app/config/config.yaml")
if not config_path.is_file():
return AppConfig(), config_path
with config_path.open(encoding="utf-8") as f:
raw: dict[str, Any] = yaml.safe_load(f) or {}
return AppConfig.model_validate(raw), config_path
+668
View File
@@ -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}
+7
View File
@@ -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
+97
View File
@@ -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())
+9
View File
@@ -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"
+31
View File
@@ -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
+176
View File
@@ -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 <repository-url> /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 <DB_HOST> -U <USER> -d n8n_filebot -f /opt/nginx_filebot/sql/001_add_seen_at.sql
psql -h <DB_HOST> -U <USER> -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://<NEUER_SERVER_IP>: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
```
+21
View File
@@ -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://<server-ip>:8090/`.
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Filebot Media Browser">
<defs>
<linearGradient id="fb-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#fb-grad)"/>
<text
x="32"
y="40"
text-anchor="middle"
fill="#ffffff"
font-family="Outfit, system-ui, -apple-system, sans-serif"
font-weight="700"
font-size="22"
>FB</text>
</svg>

After

Width:  |  Height:  |  Size: 574 B

+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Filebot Media Browser</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+21
View File
@@ -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"
}
}
+452
View File
@@ -0,0 +1,452 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
checkHealth,
deleteRecord,
fetchFilterOptions,
fetchRecords,
markAllSeen,
setRecordSeen,
} from './api'
import type { FilterOptions, MediaRecord } from './types'
import { isUnseen } from './utils/seen'
import { useAutoRefresh } from './composables/useAutoRefresh'
import { captureScroll, restoreScroll } from './utils/preserveScroll'
import FilterBar from './components/FilterBar.vue'
import { boundToIso, emptyDateTimeBound, type DateTimeBound } from './utils/datetime'
import DataTable from './components/DataTable.vue'
import MobileCards from './components/MobileCards.vue'
const records = ref<MediaRecord[]>([])
const filterOptions = ref<FilterOptions | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const dbOk = ref<boolean | null>(null)
const dbStatusDetail = ref<string | null>(null)
const search = ref('')
const format = ref('')
const codec = ref('')
const container = ref('')
const objectType = ref('')
const dateFrom = ref<DateTimeBound>(emptyDateTimeBound())
const dateTo = ref<DateTimeBound>(emptyDateTimeBound())
const unseenOnly = ref(false)
const unseenTotal = ref(0)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const isMobile = ref(false)
const deletingId = ref<number | null>(null)
const togglingSeenId = ref<number | null>(null)
const autoRefreshEnabled = ref(true)
const autoRefreshPaused = computed(
() => deletingId.value !== null || togglingSeenId.value !== null
)
function updateViewport() {
isMobile.value = window.matchMedia('(max-width: 900px)').matches
}
function fetchParams() {
return {
search: search.value || undefined,
format: format.value || undefined,
codec: codec.value || undefined,
container: container.value || undefined,
objectType: objectType.value || undefined,
createdFrom: boundToIso(dateFrom.value),
createdTo: boundToIso(dateTo.value),
unseenOnly: unseenOnly.value || undefined,
}
}
async function applyRecordsFromApi(options?: { preserveScroll?: boolean }) {
const snap = options?.preserveScroll ? captureScroll() : null
const dataRes = await fetchRecords(fetchParams())
records.value = dataRes.records
unseenTotal.value = dataRes.unseen_total ?? 0
if (snap) await restoreScroll(snap)
}
async function silentRefresh() {
try {
await applyRecordsFromApi({ preserveScroll: true })
} catch {
/* Hintergrund-Refresh Fehler nicht anzeigen */
}
}
const autoRefresh = useAutoRefresh(silentRefresh, {
enabled: autoRefreshEnabled,
paused: autoRefreshPaused,
})
async function loadData(options?: { silent?: boolean; preserveScroll?: boolean }) {
const silent = options?.silent ?? false
const preserveScroll = options?.preserveScroll ?? silent
if (!silent) loading.value = true
error.value = null
try {
const [_, filtersRes] = await Promise.all([
applyRecordsFromApi({ preserveScroll }),
fetchFilterOptions(),
])
filterOptions.value = filtersRes
} catch (e) {
if (!silent) {
error.value = e instanceof Error ? e.message : 'Unbekannter Fehler'
}
} finally {
if (!silent) loading.value = false
}
}
const lastSyncedLabel = computed(() => {
const t = autoRefresh.lastSyncedAt.value
if (!t) return 'Live'
return `Live · ${t.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`
})
function scheduleLoad() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(loadData, 350)
}
function resetFilters() {
search.value = ''
format.value = ''
codec.value = ''
container.value = ''
objectType.value = ''
dateFrom.value = emptyDateTimeBound()
dateTo.value = emptyDateTimeBound()
unseenOnly.value = false
loadData()
}
function applySeenUpdate(id: number, seenAt: string | null, wasUnseen: boolean) {
const row = records.value.find((r) => r.id === id)
if (!row) return
row.seen_at = seenAt
if (wasUnseen && seenAt) {
unseenTotal.value = Math.max(0, unseenTotal.value - 1)
} else if (!wasUnseen && !seenAt) {
unseenTotal.value += 1
}
if (unseenOnly.value && seenAt) {
records.value = records.value.filter((r) => r.id !== id)
}
}
async function handleToggleSeen(record: MediaRecord) {
const wasUnseen = isUnseen(record)
togglingSeenId.value = record.id
error.value = null
try {
const result = await setRecordSeen(record.id, wasUnseen)
applySeenUpdate(record.id, result.seen_at, wasUnseen)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Status konnte nicht geändert werden'
} finally {
togglingSeenId.value = null
}
}
async function handleMarkAllSeen() {
if (!window.confirm('Alle angezeigten (gefilterten) Einträge als gesehen markieren?')) return
error.value = null
try {
const result = await markAllSeen(fetchParams())
await loadData()
if (result.marked === 0) {
error.value = null
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Markieren fehlgeschlagen'
}
}
async function handleDelete(id: number) {
const row = records.value.find((r) => r.id === id)
const label = row?.file_name_new || row?.file_name_original || `ID ${id}`
if (!window.confirm(`Eintrag wirklich löschen?\n\n${label}`)) return
deletingId.value = id
error.value = null
try {
await deleteRecord(id)
if (row && isUnseen(row)) {
unseenTotal.value = Math.max(0, unseenTotal.value - 1)
}
records.value = records.value.filter((r) => r.id !== id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Löschen fehlgeschlagen'
} finally {
deletingId.value = null
}
}
watch([format, codec, container, objectType, dateFrom, dateTo, unseenOnly], () => loadData(), {
deep: true,
})
watch(search, () => scheduleLoad())
onMounted(async () => {
updateViewport()
window.addEventListener('resize', updateViewport)
const health = await checkHealth()
dbOk.value = health.ok
dbStatusDetail.value = health.detail ?? null
await loadData()
autoRefresh.mount()
})
onBeforeUnmount(() => {
autoRefresh.unmount()
window.removeEventListener('resize', updateViewport)
})
</script>
<template>
<div class="app">
<header class="header">
<div class="header__brand">
<div class="header__logo">FB</div>
<div>
<h1>Filebot Media Browser</h1>
<p class="header__sub">Rename-Kontrolle &amp; Medien-Metadaten</p>
</div>
</div>
<div class="header__right">
<span v-if="unseenTotal > 0" class="header__badge badge badge-new">
{{ unseenTotal }} neu
</span>
<div
class="header__sync"
title="Tabelle wird alle 5 Sekunden automatisch aktualisiert"
>
<span
class="header__sync-dot"
:class="{ 'header__sync-dot--active': autoRefresh.isRefreshing.value }"
/>
{{ lastSyncedLabel }}
</div>
<div
class="header__status"
:class="dbOk === false ? 'header__status--err' : ''"
:title="dbStatusDetail ?? undefined"
>
<span class="header__dot" />
{{
dbOk === null
? '…'
: dbOk
? 'DB verbunden'
: dbStatusDetail || 'DB nicht erreichbar'
}}
</div>
</div>
</header>
<main class="main">
<FilterBar
v-model:search="search"
v-model:format="format"
v-model:codec="codec"
v-model:container="container"
v-model:object-type="objectType"
v-model:date-from="dateFrom"
v-model:date-to="dateTo"
v-model:unseen-only="unseenOnly"
:options="filterOptions"
:loading="loading"
:total="records.length"
:unseen-total="unseenTotal"
@refresh="() => loadData({ preserveScroll: true })"
@reset="resetFilters"
@mark-all-seen="handleMarkAllSeen"
/>
<div v-if="error" class="alert alert--error">{{ error }}</div>
<div v-else-if="loading && records.length === 0" class="loading">
<div class="loading__spinner" />
Daten werden geladen
</div>
<template v-else>
<div v-show="!isMobile" class="view-desktop">
<DataTable
:data="records"
:deleting-id="deletingId"
:toggling-seen-id="togglingSeenId"
@delete="handleDelete"
@toggle-seen="handleToggleSeen"
/>
</div>
<div v-show="isMobile" class="view-mobile">
<MobileCards
:data="records"
:deleting-id="deletingId"
:toggling-seen-id="togglingSeenId"
@delete="handleDelete"
@toggle-seen="handleToggleSeen"
/>
</div>
</template>
</main>
</div>
</template>
<style scoped>
.app {
max-width: 1600px;
margin: 0 auto;
padding: 1.25rem 1rem 2rem;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header__brand {
display: flex;
align-items: center;
gap: 1rem;
}
.header__logo {
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 1rem;
letter-spacing: -0.02em;
}
.header h1 {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.header__sub {
margin: 0.15rem 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.header__right {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.header__badge {
font-size: 0.8rem;
}
.header__sync {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.75rem;
color: var(--text-muted);
padding: 0.35rem 0.65rem;
border-radius: 999px;
border: 1px solid var(--border);
}
.header__sync-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
opacity: 0.85;
}
.header__sync-dot--active {
animation: pulse 0.8s ease infinite;
}
.header__status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--success);
padding: 0.4rem 0.75rem;
background: rgba(52, 211, 153, 0.1);
border-radius: 999px;
}
.header__status--err {
color: var(--danger);
background: var(--danger-bg);
}
.header__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s ease infinite;
}
@keyframes pulse {
50% { opacity: 0.5; }
}
.main {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.alert {
padding: 1rem;
border-radius: var(--radius-sm);
}
.alert--error {
background: var(--danger-bg);
color: var(--danger);
border: 1px solid rgba(248, 113, 113, 0.3);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: var(--text-muted);
}
.loading__spinner {
width: 22px;
height: 22px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+95
View File
@@ -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<string> {
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<RecordsResponse> {
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<FilterOptions> {
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<void> {
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' }
}
}
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { coverImageSrc } from '../utils/coverUrl'
const props = defineProps<{
url: string | null | undefined
size?: 'table' | 'card'
}>()
const failed = ref(false)
const src = computed(() => coverImageSrc(props.url))
watch(
() => props.url,
() => {
failed.value = false
}
)
</script>
<template>
<div class="cover" :class="`cover--${size ?? 'table'}`">
<img
v-if="src && !failed"
:key="src"
:src="src"
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
@error="failed = true"
/>
<div v-else class="cover__placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="8.5" cy="10" r="1.5" fill="currentColor" stroke="none" />
<path d="m21 16-5.5-5.5a2 2 0 0 0-2.8 0L7 16" />
</svg>
</div>
</div>
</template>
<style scoped>
.cover {
flex-shrink: 0;
}
.cover--table img,
.cover--table .cover__placeholder {
width: 76px;
height: 114px;
}
.cover--card img,
.cover--card .cover__placeholder {
width: 64px;
height: 96px;
}
.cover img {
display: block;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
}
.cover__placeholder {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px dashed var(--border);
background: var(--bg-elevated);
color: var(--text-muted);
}
.cover__placeholder svg {
width: 1.75rem;
height: 1.75rem;
opacity: 0.5;
}
</style>
+415
View File
@@ -0,0 +1,415 @@
<script setup lang="ts">
import {
FlexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useVueTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from '@tanstack/vue-table'
import { computed, h, ref } from 'vue'
import type { MediaRecord } from '../types'
import { isUnseen } from '../utils/seen'
import CoverImage from './CoverImage.vue'
import NamePair from './NamePair.vue'
import ObjectTypeBadge from './ObjectTypeBadge.vue'
import DeleteButton from './DeleteButton.vue'
const props = defineProps<{
data: MediaRecord[]
deletingId?: number | null
togglingSeenId?: number | null
}>()
const emit = defineEmits<{
delete: [id: number]
'toggle-seen': [record: MediaRecord]
}>()
function onRowClick(record: MediaRecord, event: MouseEvent) {
const target = event.target as HTMLElement
if (target.closest('button, a, input, select')) return
emit('toggle-seen', record)
}
const defaultSorting: SortingState = [{ id: 'created_at', desc: true }]
const sorting = ref<SortingState>([...defaultSorting])
const columnFilters = ref<ColumnFiltersState>([])
const globalFilter = ref('')
const columns: ColumnDef<MediaRecord>[] = [
{
id: 'cover',
header: 'Cover',
accessorKey: 'cover_url',
enableSorting: false,
cell: ({ row }) =>
h(CoverImage, {
url: row.original.cover_url,
size: 'table',
}),
},
{
id: 'object_type',
header: 'Typ',
accessorKey: 'object_type',
enableSorting: false,
cell: ({ row }) =>
h(ObjectTypeBadge, {
value: row.original.object_type,
}),
},
{
id: 'names',
header: 'Dateinamen (Original → Neu)',
accessorFn: (row) => `${row.file_name_original} ${row.file_name_new}`,
cell: ({ row }) =>
h(NamePair, {
original: row.original.file_name_original,
renamed: row.original.file_name_new,
}),
enableSorting: false,
},
{
accessorKey: 'movie_rating',
header: 'Rating',
cell: (info) => {
const v = info.getValue()
if (!v) return '—'
return h('span', { class: 'rating' }, String(v))
},
},
{ accessorKey: 'movie_votes', header: 'Votes' },
{ accessorKey: 'standard_video_format', header: 'Format' },
{ accessorKey: 'video_resolution', header: 'Auflösung' },
{ accessorKey: 'video_compression_format', header: 'Kompression' },
{ accessorKey: 'video_codec_library', header: 'Codec' },
{ accessorKey: 'audio_codec', header: 'Audio' },
{ accessorKey: 'audio_channel_layout', header: 'Kanäle' },
{ accessorKey: 'container_format', header: 'Container' },
{ accessorKey: 'overall_bitrate', header: 'Gesamt' },
{ accessorKey: 'video_bitrate', header: 'Video' },
{ accessorKey: 'audio_bitrate', header: 'Audio BR' },
{
accessorKey: 'created_at',
header: 'Erstellt',
sortingFn: (rowA, rowB) => {
const ta = rowA.original.created_at
? new Date(rowA.original.created_at).getTime()
: 0
const tb = rowB.original.created_at
? new Date(rowB.original.created_at).getTime()
: 0
if (ta !== tb) return ta - tb
return (rowA.original.id ?? 0) - (rowB.original.id ?? 0)
},
cell: (info) => {
const v = info.getValue() as string | null
if (!v) return '—'
try {
return new Date(v).toLocaleString('de-DE')
} catch {
return v
}
},
},
{
id: 'actions',
header: '',
enableSorting: false,
cell: ({ row }) =>
h(
'div',
{ onClick: (e: Event) => e.stopPropagation() },
[
h(DeleteButton, {
loading: props.deletingId === row.original.id,
onClick: () => emit('delete', row.original.id),
}),
]
),
},
]
function isFixedColumn(columnId: string): boolean {
return columnId === 'cover' || columnId === 'names' || columnId === 'object_type'
}
function isShrinkColumn(columnId: string): boolean {
return !isFixedColumn(columnId)
}
const table = useVueTable({
get data() {
return props.data
},
getRowId: (row) => String(row.id),
columns,
state: {
get sorting() {
return sorting.value
},
get columnFilters() {
return columnFilters.value
},
get globalFilter() {
return globalFilter.value
},
},
onSortingChange: (updater) => {
sorting.value = typeof updater === 'function' ? updater(sorting.value) : updater
},
onColumnFiltersChange: (updater) => {
columnFilters.value =
typeof updater === 'function' ? updater(columnFilters.value) : updater
},
onGlobalFilterChange: (updater) => {
globalFilter.value =
typeof updater === 'function' ? updater(globalFilter.value) : updater
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const q = String(filterValue).toLowerCase()
if (!q) return true
const r = row.original
const haystack = [
r.file_name_original,
r.file_name_new,
r.standard_video_format,
r.video_codec_library,
r.audio_codec,
r.container_format,
r.object_type,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(q)
},
})
const displayRows = computed(() => table.getRowModel().rows)
defineExpose({ setGlobalFilter: (v: string) => { globalFilter.value = v } })
</script>
<template>
<div class="table-wrap">
<div class="table-toolbar">
<input
v-model="globalFilter"
type="search"
class="table-toolbar__search"
placeholder="Schnellfilter in geladenen Daten…"
/>
<span class="table-toolbar__count">{{ displayRows.length }} Zeilen</span>
</div>
<div class="table-scroll">
<table class="media-table">
<thead>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<th
v-for="header in headerGroup.headers"
:key="header.id"
:class="{
sortable: header.column.getCanSort(),
'th-cover': header.column.id === 'cover',
'th-type': header.column.id === 'object_type',
'th-names': header.column.id === 'names',
'th-shrink': isShrinkColumn(header.column.id),
}"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span v-if="header.column.getIsSorted()" class="sort-icon">
{{ header.column.getIsSorted() === 'asc' ? '' : '' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in displayRows"
:key="row.original.id"
class="row-clickable"
:title="isUnseen(row.original) ? 'Tippen: als gesehen markieren' : 'Tippen: als ungesehen markieren'"
:class="{
'row-unseen': isUnseen(row.original),
'row-busy': togglingSeenId === row.original.id,
}"
@click="onRowClick(row.original, $event)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="{
'td-cover': cell.column.id === 'cover',
'td-type': cell.column.id === 'object_type',
'td-names': cell.column.id === 'names',
'td-actions': cell.column.id === 'actions',
'td-shrink': !isFixedColumn(cell.column.id),
}"
>
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.table-wrap {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.table-toolbar__search {
flex: 1;
min-width: 200px;
}
.table-toolbar__count {
font-size: 0.8rem;
color: var(--text-muted);
margin-left: auto;
}
.table-scroll {
overflow: auto;
max-height: calc(100vh - 248px);
overflow-anchor: auto;
}
.media-table {
width: max-content;
min-width: 100%;
table-layout: auto;
border-collapse: collapse;
font-size: 0.85rem;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-card);
}
th {
text-align: left;
padding: 0.65rem 0.5rem;
font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
color: var(--text);
}
.sort-icon {
margin-left: 0.25rem;
color: var(--accent);
}
td {
padding: 0.65rem 0.5rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
/* Cover: feste Breite */
.th-cover,
.td-cover {
width: 88px;
padding-left: 0.5rem;
padding-right: 0.35rem;
}
.th-type,
.td-type {
width: 88px;
text-align: center;
vertical-align: middle;
}
/* Dateinamen: feste Breite (einzige „reservierte“ Spalte) */
.th-names,
.td-names {
width: 360px;
min-width: 280px;
max-width: 420px;
vertical-align: top;
}
/* Metadaten: automatische Breite nach Inhalt */
.th-shrink,
.td-shrink {
white-space: nowrap;
padding-left: 0.45rem;
padding-right: 0.45rem;
}
.td-actions {
text-align: center;
vertical-align: middle;
}
tbody tr.row-clickable {
cursor: pointer;
}
tbody tr:hover {
background: var(--bg-hover);
}
tbody tr.row-unseen:hover {
background: rgba(99, 102, 241, 0.22);
}
tbody tr.row-busy {
opacity: 0.65;
pointer-events: none;
}
.rating {
font-weight: 700;
color: var(--warning);
}
</style>
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { DateTimeBound } from '../utils/datetime'
import {
formatBoundLabel,
getCalendarDays,
getMonthLabel,
getWeekdayLabels,
HOURS,
MINUTES,
toIsoDate,
} from '../utils/datetime'
const props = defineProps<{
from: DateTimeBound
to: DateTimeBound
compact?: boolean
}>()
const emit = defineEmits<{
'update:from': [value: DateTimeBound]
'update:to': [value: DateTimeBound]
}>()
const openPicker = ref<'from' | 'to' | null>(null)
const viewYear = ref(new Date().getFullYear())
const viewMonth = ref(new Date().getMonth())
const rootEl = ref<HTMLElement | null>(null)
const weekdays = getWeekdayLabels()
const calendarDays = computed(() => getCalendarDays(viewYear.value, viewMonth.value))
function updateBound(side: 'from' | 'to', patch: Partial<DateTimeBound>) {
const current = side === 'from' ? props.from : props.to
const next = { ...current, ...patch }
if (side === 'from') emit('update:from', next)
else emit('update:to', next)
}
function openCalendar(side: 'from' | 'to') {
const bound = side === 'from' ? props.from : props.to
if (bound.date) {
const [y, m] = bound.date.split('-').map(Number)
viewYear.value = y
viewMonth.value = m - 1
} else {
const now = new Date()
viewYear.value = now.getFullYear()
viewMonth.value = now.getMonth()
}
openPicker.value = openPicker.value === side ? null : side
}
function pickDate(side: 'from' | 'to', date: string) {
updateBound(side, { date })
openPicker.value = null
}
function prevMonth() {
if (viewMonth.value === 0) {
viewMonth.value = 11
viewYear.value -= 1
} else {
viewMonth.value -= 1
}
}
function nextMonth() {
if (viewMonth.value === 11) {
viewMonth.value = 0
viewYear.value += 1
} else {
viewMonth.value += 1
}
}
function clearBound(side: 'from' | 'to') {
emit(`update:${side}`, { date: '', hour: '00', minute: '00' })
if (openPicker.value === side) openPicker.value = null
}
function onDocumentClick(e: MouseEvent) {
if (!openPicker.value || !rootEl.value) return
if (!rootEl.value.contains(e.target as Node)) openPicker.value = null
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
</script>
<template>
<div ref="rootEl" class="dt-range" :class="{ 'dt-range--compact': compact }">
<div
v-for="side in (['from', 'to'] as const)"
:key="side"
class="dt-range__col"
>
<span class="dt-range__label">{{ side === 'from' ? 'Von' : 'Bis' }}</span>
<div class="dt-range__controls">
<div class="dt-range__date-wrap">
<button
type="button"
class="dt-range__date-btn"
:class="{ 'dt-range__date-btn--active': (side === 'from' ? from : to).date }"
@click.stop="openCalendar(side)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
<span>{{ formatBoundLabel(side === 'from' ? from : to) }}</span>
</button>
<div
v-if="openPicker === side"
class="dt-calendar"
@click.stop
>
<div class="dt-calendar__head">
<button type="button" class="dt-calendar__nav" aria-label="Vorheriger Monat" @click="prevMonth"></button>
<span>{{ getMonthLabel(viewMonth) }} {{ viewYear }}</span>
<button type="button" class="dt-calendar__nav" aria-label="Nächster Monat" @click="nextMonth"></button>
</div>
<div class="dt-calendar__weekdays">
<span v-for="wd in weekdays" :key="wd">{{ wd }}</span>
</div>
<div class="dt-calendar__days">
<button
v-for="(cell, i) in calendarDays"
:key="`${cell.date}-${i}`"
type="button"
class="dt-calendar__day"
:class="{
'dt-calendar__day--muted': !cell.currentMonth,
'dt-calendar__day--selected': (side === 'from' ? from : to).date === cell.date,
'dt-calendar__day--today': cell.date === toIsoDate(new Date()),
}"
@click="pickDate(side, cell.date)"
>
{{ cell.day }}
</button>
</div>
</div>
</div>
<div class="dt-range__time">
<select
:value="side === 'from' ? from.hour : to.hour"
class="dt-range__time-select"
:disabled="!(side === 'from' ? from.date : to.date)"
@change="updateBound(side, { hour: ($event.target as HTMLSelectElement).value })"
>
<option v-for="h in HOURS" :key="h" :value="h">{{ h }}</option>
</select>
<span class="dt-range__colon">:</span>
<select
:value="side === 'from' ? from.minute : to.minute"
class="dt-range__time-select"
:disabled="!(side === 'from' ? from.date : to.date)"
@change="updateBound(side, { minute: ($event.target as HTMLSelectElement).value })"
>
<option v-for="m in MINUTES" :key="m" :value="m">{{ m }}</option>
</select>
</div>
<button
v-if="(side === 'from' ? from : to).date"
type="button"
class="dt-range__clear"
title="Zurücksetzen"
@click="clearBound(side)"
>
×
</button>
</div>
</div>
</div>
</template>
<style scoped>
.dt-range {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 700px) {
.dt-range {
grid-template-columns: 1fr;
}
}
.dt-range__label {
display: block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.45rem;
}
.dt-range__controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.dt-range__date-wrap {
position: relative;
flex: 1;
min-width: 140px;
}
.dt-range__date-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.dt-range__date-btn svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.dt-range__date-btn--active,
.dt-range__date-btn:hover {
color: var(--text);
border-color: var(--border-strong);
background: var(--bg-hover);
}
.dt-range__date-btn--active {
border-color: var(--accent);
}
.dt-calendar {
position: absolute;
top: calc(100% + 0.35rem);
left: 0;
z-index: 50;
width: min(100%, 280px);
padding: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.dt-calendar__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.65rem;
font-weight: 600;
font-size: 0.9rem;
}
.dt-calendar__nav {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--text);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.dt-calendar__nav:hover {
background: var(--bg-hover);
}
.dt-calendar__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 0.35rem;
font-size: 0.65rem;
font-weight: 700;
color: var(--text-muted);
text-align: center;
}
.dt-calendar__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.dt-calendar__day {
aspect-ratio: 1;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
font-size: 0.8rem;
cursor: pointer;
}
.dt-calendar__day:hover {
background: var(--bg-hover);
}
.dt-calendar__day--muted {
color: var(--text-muted);
opacity: 0.45;
}
.dt-calendar__day--today {
border: 1px solid var(--accent);
}
.dt-calendar__day--selected {
background: var(--accent);
color: #fff;
font-weight: 700;
}
.dt-range__time {
display: flex;
align-items: center;
gap: 0.2rem;
}
.dt-range__time-select {
width: 3.4rem;
padding: 0.5rem 0.35rem;
text-align: center;
font-family: var(--mono);
font-size: 0.85rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b93a7' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.25rem center;
padding-right: 1.1rem;
}
.dt-range__time-select:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dt-range__colon {
font-family: var(--mono);
font-weight: 700;
color: var(--text-muted);
}
.dt-range__clear {
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
}
.dt-range__clear:hover {
color: var(--danger);
border-color: rgba(248, 113, 113, 0.4);
}
/* Kompakt: Von/Bis in einer Zeile, weniger Höhe */
.dt-range--compact {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.65rem;
flex: 1;
min-width: 0;
}
.dt-range--compact .dt-range__col {
display: flex;
align-items: center;
gap: 0.3rem;
flex: 0 1 auto;
}
.dt-range--compact .dt-range__label {
margin-bottom: 0;
min-width: 1.75rem;
font-size: 0.62rem;
padding-top: 0;
}
.dt-range--compact .dt-range__controls {
gap: 0.28rem;
}
.dt-range--compact .dt-range__date-wrap {
min-width: 6.5rem;
flex: 0 1 8.5rem;
}
.dt-range--compact .dt-range__date-btn {
padding: 0.22rem 0.4rem;
font-size: 0.72rem;
gap: 0.3rem;
}
.dt-range--compact .dt-range__date-btn svg {
width: 0.85rem;
height: 0.85rem;
}
.dt-range--compact .dt-range__date-btn span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dt-range--compact .dt-range__time-select {
width: 2.85rem;
padding: 0.22rem 0.2rem;
padding-right: 0.95rem;
font-size: 0.72rem;
}
.dt-range--compact .dt-range__clear {
width: 1.5rem;
height: 1.5rem;
font-size: 1rem;
}
.dt-range--compact .dt-calendar {
width: min(100vw - 2rem, 260px);
padding: 0.5rem;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
defineProps<{
loading?: boolean
}>()
defineEmits<{
click: []
}>()
</script>
<template>
<button
type="button"
class="btn-delete"
:disabled="loading"
title="Eintrag löschen"
aria-label="Eintrag löschen"
@click="$emit('click')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 6h18M8 6V4h8v2M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
</template>
<style scoped>
.btn-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: var(--radius-sm);
background: rgba(248, 113, 113, 0.1);
color: var(--danger);
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
transform 0.1s;
flex-shrink: 0;
}
.btn-delete svg {
width: 1.1rem;
height: 1.1rem;
}
.btn-delete:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.22);
border-color: var(--danger);
}
.btn-delete:active:not(:disabled) {
transform: scale(0.95);
}
.btn-delete:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
+391
View File
@@ -0,0 +1,391 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FilterOptions } from '../types'
import type { DateTimeBound } from '../utils/datetime'
import { objectTypeFilterLabel } from '../utils/objectType'
import DateTimeRangeFilter from './DateTimeRangeFilter.vue'
const props = defineProps<{
search: string
format: string
codec: string
container: string
objectType: string
dateFrom: DateTimeBound
dateTo: DateTimeBound
unseenOnly: boolean
options: FilterOptions | null
loading: boolean
total: number
unseenTotal: number
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:format': [value: string]
'update:codec': [value: string]
'update:container': [value: string]
'update:objectType': [value: string]
'update:dateFrom': [value: DateTimeBound]
'update:dateTo': [value: DateTimeBound]
'update:unseenOnly': [value: boolean]
refresh: []
reset: []
'mark-all-seen': []
}>()
const objectTypeOptions = computed(() => {
const fromDb = props.options?.object_types ?? []
if (fromDb.length) return fromDb
return ['movie', 'episode']
})
function toggle(
field: 'format' | 'codec' | 'container' | 'objectType',
current: string,
value: string
) {
const next = current === value ? '' : value
if (field === 'format') emit('update:format', next)
else if (field === 'codec') emit('update:codec', next)
else if (field === 'objectType') emit('update:objectType', next)
else emit('update:container', next)
}
</script>
<template>
<section class="filters">
<div class="filters__search">
<svg class="filters__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
:value="search"
type="search"
class="filters__search-input"
placeholder="Suche in Original- und neuem Dateinamen…"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
</div>
<div class="filters__chips">
<div class="chip-group">
<span class="chip-group__label">Typ</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !objectType }"
@click="emit('update:objectType', '')"
>
Alle
</button>
<button
v-for="t in objectTypeOptions"
:key="t"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': objectType === t }"
@click="toggle('objectType', objectType, t)"
>
{{ objectTypeFilterLabel(t) }}
</button>
</div>
</div>
<div class="chip-group">
<span class="chip-group__label">Format</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !format }"
@click="emit('update:format', '')"
>
Alle
</button>
<button
v-for="f in options?.formats ?? []"
:key="f"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': format === f }"
@click="toggle('format', format, f)"
>
{{ f }}
</button>
</div>
</div>
<div class="chip-group">
<span class="chip-group__label">Codec</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !codec }"
@click="emit('update:codec', '')"
>
Alle
</button>
<button
v-for="c in options?.codecs ?? []"
:key="c"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': codec === c }"
@click="toggle('codec', codec, c)"
>
{{ c }}
</button>
</div>
</div>
<div class="chip-group">
<span class="chip-group__label">Container</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !container }"
@click="emit('update:container', '')"
>
Alle
</button>
<button
v-for="c in options?.containers ?? []"
:key="c"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': container === c }"
@click="toggle('container', container, c)"
>
{{ c }}
</button>
</div>
</div>
</div>
<div class="filters__bottom">
<div class="filters__bottom-left">
<span class="chip-group__label chip-group__label--inline">Zeitraum</span>
<DateTimeRangeFilter
compact
:from="dateFrom"
:to="dateTo"
@update:from="emit('update:dateFrom', $event)"
@update:to="emit('update:dateTo', $event)"
/>
</div>
<div class="filters__bottom-mid">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': unseenOnly }"
title="Zeile antippen zum Umschalten"
@click="emit('update:unseenOnly', !unseenOnly)"
>
Nur ungesehen
</button>
<button
type="button"
class="chip-btn"
:disabled="loading || unseenTotal === 0"
@click="emit('mark-all-seen')"
>
Alle gesehen
</button>
<span v-if="unseenTotal > 0" class="badge badge-new">{{ unseenTotal }} neu</span>
</div>
<div class="filters__bottom-right">
<button type="button" class="chip-btn chip-btn--action" :disabled="loading" @click="emit('refresh')">
</button>
<button type="button" class="chip-btn chip-btn--action" title="Filter zurücksetzen" @click="emit('reset')">
Zurücksetzen
</button>
<span class="filters__stats">
<strong>{{ total }}</strong> Einträge
</span>
</div>
</div>
</section>
</template>
<style scoped>
.filters {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.filters__search {
position: relative;
}
.filters__search-input {
width: 100%;
padding: 0.4rem 0.65rem 0.4rem 2.15rem;
font-size: 0.82rem;
}
.filters__icon {
position: absolute;
left: 0.65rem;
top: 50%;
transform: translateY(-50%);
width: 0.95rem;
height: 0.95rem;
color: var(--text-muted);
pointer-events: none;
}
/* Typ, Format, Codec, Container 2×2 Desktop, 1 Spalte Mobil */
.filters__chips {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem 0.75rem;
}
@media (min-width: 1100px) {
.filters__chips {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.chip-group {
display: flex;
align-items: flex-start;
gap: 0.35rem;
min-width: 0;
}
.chip-group__label {
flex-shrink: 0;
width: 4.25rem;
padding-top: 0.22rem;
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
line-height: 1.2;
}
.chip-group__label--inline {
width: auto;
padding-top: 0.35rem;
margin-right: 0.15rem;
}
.chip-group__btns {
display: flex;
flex-wrap: wrap;
gap: 0.28rem;
min-width: 0;
}
.chip-btn {
padding: 0.22rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 600;
line-height: 1.25;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.chip-btn:hover:not(:disabled) {
border-color: var(--border-strong);
color: var(--text);
background: var(--bg-hover);
}
.chip-btn--on {
background: rgba(99, 102, 241, 0.35);
border-color: var(--accent-hover);
color: #fff;
}
.chip-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chip-btn--action {
font-size: 0.72rem;
padding: 0.22rem 0.5rem;
}
/* Zeitraum + Gesehen + Aktionen in einer Zeile */
.filters__bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.65rem;
padding-top: 0.35rem;
border-top: 1px solid var(--border);
}
.filters__bottom-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
flex: 1 1 280px;
min-width: 0;
}
.filters__bottom-mid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.28rem;
flex: 0 1 auto;
}
.filters__bottom-right {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.28rem;
margin-left: auto;
flex: 0 1 auto;
}
.filters__stats {
font-size: 0.72rem;
color: var(--text-muted);
white-space: nowrap;
}
.filters__stats strong {
color: var(--text);
}
.filters__new {
color: var(--accent-hover);
}
@media (max-width: 700px) {
.filters__bottom-right {
margin-left: 0;
width: 100%;
justify-content: space-between;
}
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { MediaRecord } from '../types'
import { formatDisplay } from '../utils/names'
import { isUnseen } from '../utils/seen'
import CoverImage from './CoverImage.vue'
import NamePair from './NamePair.vue'
import ObjectTypeBadge from './ObjectTypeBadge.vue'
import DeleteButton from './DeleteButton.vue'
const props = defineProps<{
data: MediaRecord[]
deletingId?: number | null
togglingSeenId?: number | null
}>()
const emit = defineEmits<{
delete: [id: number]
'toggle-seen': [record: MediaRecord]
}>()
function onCardClick(record: MediaRecord, event: MouseEvent) {
const target = event.target as HTMLElement
if (target.closest('button, a, input, select, dl')) return
emit('toggle-seen', record)
}
const expandedId = ref<number | null>(null)
function sortByNewest(list: MediaRecord[]): MediaRecord[] {
return [...list].sort((a, b) => {
const ta = a.created_at ? new Date(a.created_at).getTime() : 0
const tb = b.created_at ? new Date(b.created_at).getTime() : 0
if (tb !== ta) return tb - ta
return (b.id ?? 0) - (a.id ?? 0)
})
}
const filtered = computed(() => sortByNewest(props.data))
function toggle(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
const detailFields: { key: keyof MediaRecord; label: string }[] = [
{ key: 'movie_votes', label: 'Votes' },
{ key: 'video_compression_format', label: 'Kompression' },
{ key: 'video_codec_library', label: 'Video-Codec' },
{ key: 'audio_codec', label: 'Audio-Codec' },
{ key: 'audio_codec_profile', label: 'Audio-Profil' },
{ key: 'audio_channel_layout', label: 'Layout' },
{ key: 'video_width', label: 'Breite' },
{ key: 'video_height', label: 'Höhe' },
{ key: 'video_bitdepth', label: 'Bit-Tiefe' },
{ key: 'overall_bitrate', label: 'Gesamt-Bitrate' },
{ key: 'video_bitrate', label: 'Video-Bitrate' },
{ key: 'audio_bitrate', label: 'Audio-Bitrate' },
{ key: 'created_at', label: 'Erstellt' },
]
</script>
<template>
<div class="mobile">
<div class="mobile__list">
<article
v-for="row in filtered"
:key="row.id"
class="card card-clickable"
:title="isUnseen(row) ? 'Tippen: als gesehen markieren' : 'Tippen: als ungesehen markieren'"
:class="{
'card-unseen': isUnseen(row),
'card-busy': togglingSeenId === row.id,
}"
@click="onCardClick(row, $event)"
>
<div class="card__hero">
<CoverImage :url="row.cover_url" size="card" />
<div class="card__hero-text">
<header class="card__header">
<div class="card__meta">
<ObjectTypeBadge :value="row.object_type" />
<span v-if="row.standard_video_format" class="badge badge-format">
{{ row.standard_video_format }}
</span>
<span v-if="row.movie_rating" class="card__rating">{{ row.movie_rating }}</span>
</div>
<div class="card__header-end">
<span class="card__resolution">{{ formatDisplay(row.video_resolution) }}</span>
<DeleteButton
:loading="deletingId === row.id"
@click="emit('delete', row.id)"
/>
</div>
</header>
<NamePair
:original="row.file_name_original"
:renamed="row.file_name_new"
/>
</div>
</div>
<div class="card__chips">
<span>{{ formatDisplay(row.video_codec_library) }}</span>
<span>{{ formatDisplay(row.audio_codec) }}</span>
<span>{{ formatDisplay(row.container_format) }}</span>
</div>
<button type="button" class="card__toggle" @click="toggle(row.id)">
{{ expandedId === row.id ? 'Weniger' : 'Technische Details' }}
</button>
<dl v-if="expandedId === row.id" class="card__details">
<template v-for="field in detailFields" :key="field.key">
<dt>{{ field.label }}</dt>
<dd>{{ formatDisplay(row[field.key]) }}</dd>
</template>
</dl>
</article>
<p v-if="filtered.length === 0" class="mobile__empty">Keine Einträge gefunden.</p>
</div>
</div>
</template>
<style scoped>
.mobile {
display: flex;
flex-direction: column;
max-height: calc(100vh - 248px);
overflow-y: auto;
}
.mobile__list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.card__hero {
display: flex;
gap: 0.85rem;
align-items: flex-start;
}
.card__hero-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.card-clickable {
cursor: pointer;
}
.card-busy {
opacity: 0.65;
pointer-events: none;
}
.card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
}
.card__header-end {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.card__meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.card__rating {
font-weight: 700;
color: var(--warning);
font-size: 0.95rem;
}
.card__resolution {
font-size: 0.8rem;
color: var(--text-muted);
font-family: var(--mono);
}
.card__chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.card__chips span {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: var(--bg-card);
border-radius: 6px;
color: var(--text-muted);
font-family: var(--mono);
}
.card__toggle {
background: none;
border: none;
color: var(--accent-hover);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
padding: 0;
text-align: left;
}
.card__details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem 1rem;
margin: 0;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
font-size: 0.8rem;
}
.card__details dt {
color: var(--text-muted);
margin: 0;
}
.card__details dd {
margin: 0;
font-family: var(--mono);
word-break: break-all;
}
.mobile__empty {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { formatDisplay } from '../utils/names'
defineProps<{
original: string | null | undefined
renamed: string | null | undefined
compact?: boolean
}>()
</script>
<template>
<div class="name-pair" :class="{ 'name-pair--compact': compact }">
<div class="name-pair__row">
<span class="name-pair__label">{{ compact ? 'O' : 'Original' }}</span>
<span class="name-cell name-cell--original">{{ formatDisplay(original) }}</span>
</div>
<div class="name-pair__row">
<span class="name-pair__label">{{ compact ? 'N' : 'Neu' }}</span>
<span class="name-cell name-cell--new">{{ formatDisplay(renamed) }}</span>
</div>
</div>
</template>
<style scoped>
.name-pair {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.name-pair__row {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.name-pair__label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.name-pair--compact {
gap: 0.35rem;
}
.name-pair--compact .name-pair__label {
display: inline-block;
min-width: 1.1rem;
}
.name-pair--compact .name-cell {
font-size: 0.8rem;
line-height: 1.25;
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { normalizeObjectType, objectTypeLabel } from '../utils/objectType'
const props = defineProps<{
value: string | null | undefined
}>()
const kind = computed(() => normalizeObjectType(props.value))
const label = computed(() => objectTypeLabel(props.value))
</script>
<template>
<span
v-if="kind !== 'unknown'"
class="badge object-type-badge"
:class="`object-type-badge--${kind}`"
>
{{ label }}
</span>
<span v-else-if="value?.trim()" class="badge object-type-badge object-type-badge--unknown">
{{ value.trim() }}
</span>
<span v-else class="object-type-badge--empty"></span>
</template>
<style scoped>
.object-type-badge {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.object-type-badge--movie {
background: rgba(99, 102, 241, 0.35);
color: #e0e7ff;
border: 1px solid rgba(129, 140, 248, 0.45);
}
.object-type-badge--episode {
background: rgba(52, 211, 153, 0.2);
color: #a7f3d0;
border: 1px solid rgba(52, 211, 153, 0.4);
}
.object-type-badge--unknown {
background: rgba(255, 255, 255, 0.08);
color: var(--text-muted);
border: 1px solid var(--border);
}
.object-type-badge--empty {
color: var(--text-muted);
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,72 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
export const AUTO_REFRESH_INTERVAL_MS = 5000
export function useAutoRefresh(
onRefresh: () => void | Promise<void>,
options: {
enabled: Ref<boolean>
paused: Ref<boolean>
}
) {
const lastSyncedAt = ref<Date | null>(null)
const isRefreshing = ref(false)
let timer: ReturnType<typeof setInterval> | 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,
}
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
createApp(App).mount('#app')
+175
View File
@@ -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;
}
+47
View File
@@ -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
}
+10
View File
@@ -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
}
+104
View File
@@ -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)
}
+25
View File
@@ -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)
}
+22
View File
@@ -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)
}
+25
View File
@@ -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<void> {
await nextTick()
requestAnimationFrame(() => {
const table = document.querySelector('.table-scroll')
if (table instanceof HTMLElement) {
table.scrollTop = snapshot.tableTop
}
window.scrollTo(0, snapshot.windowY)
})
}
+9
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+23
View File
@@ -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"]
}
+18
View File
@@ -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',
},
})
+16
View File
@@ -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
+24
View File
@@ -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;
}
}
+36
View File
@@ -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."
+15
View File
@@ -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;
+6
View File
@@ -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)';
+6
View File
@@ -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)';