Initial commit: Filebot Media Browser
Vue 3 + FastAPI Webapp zur Anzeige von Filebot/n8n-Medienmetadaten aus PostgreSQL. Enthält Frontend, API, Nginx-Config, SQL-Migrationen und Docker-Compose-Setup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
config/config.yaml
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Projekt-Kontext für KI-Assistenten (Filebot Media Browser)
|
||||||
|
|
||||||
|
Diese Datei fasst zusammen, **was gebaut wurde** und wie das Projekt betrieben wird. Bei weiteren Änderungen zuerst hier und in der `README.md` nachschlagen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
Interne Web-App im Heimnetz zum Anzeigen und Verwalten von **Filebot/n8n-Medienmetadaten** aus PostgreSQL. Schwerpunkt: **Original- vs. neuem Dateinamen** nebeneinander (Rename-Kontrolle), plus technische Metadaten (Codec, Bitrate, Auflösung, …).
|
||||||
|
|
||||||
|
**Produktiv nur auf dem Server** unter `/opt/nginx_filebot` – nicht lokal auf dem Mac entwickeln/ausführen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastruktur
|
||||||
|
|
||||||
|
| Komponente | Details |
|
||||||
|
|------------|---------|
|
||||||
|
| Server-Pfad | `/opt/nginx_filebot` |
|
||||||
|
| Docker Compose | Projektname `nginx_filebot` |
|
||||||
|
| Web | Nginx, Port **8080** → Container 80 |
|
||||||
|
| API | FastAPI (Python 3.12), intern `api:8000` |
|
||||||
|
| PostgreSQL | Host typisch `192.168.30.186` |
|
||||||
|
| Datenbank | `n8n_filebot` (Name in config) |
|
||||||
|
| Schema | **`n8n`** (nicht `public`!) |
|
||||||
|
| Tabelle | **`movie_files`** (nicht `n8n_filebot`!) |
|
||||||
|
| Vollqualifizierter Name | `"n8n"."movie_files"` |
|
||||||
|
|
||||||
|
Config: `config/config.yaml` (nur auf Server, nicht committen). Beispiel: `config/config.example.yaml`.
|
||||||
|
|
||||||
|
**Wichtig:** Feld `schema` in YAML funktioniert, intern heißt es `db_schema` (Pydantic-Konflikt mit `BaseModel.schema`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- **Frontend:** Vue 3, TypeScript, Vite, TanStack Table
|
||||||
|
- **Backend:** FastAPI, asyncpg
|
||||||
|
- **Webserver:** Nginx (Multi-Stage-Build: Node baut Vue, Nginx liefert aus)
|
||||||
|
- **DB:** PostgreSQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Spalten (relevant)
|
||||||
|
|
||||||
|
Standard-Spalten aus n8n/Filebot (siehe `api/main.py` → `COLUMNS`), u. a.:
|
||||||
|
|
||||||
|
- `id`, `file_name_original`, `file_name_new`
|
||||||
|
- `movie_rating`, `movie_votes`
|
||||||
|
- Video/Audio: `standard_video_format`, `video_codec_library`, `audio_codec`, `container_format`, Bitraten, Auflösung, …
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
**Zusätzlich per Migration:**
|
||||||
|
|
||||||
|
| Spalte | Typ | Bedeutung |
|
||||||
|
|--------|-----|-----------|
|
||||||
|
| `seen_at` | `TIMESTAMPTZ NULL` | `NULL` = ungesehen; gesetzt = in UI als gesehen markiert |
|
||||||
|
| `cover_url` | `TEXT NULL` | Poster-URL (z. B. TVMaze), erste Tabellenspalte |
|
||||||
|
|
||||||
|
SQL-Dateien: `sql/001_add_seen_at.sql`, `sql/002_add_cover_url.sql`
|
||||||
|
|
||||||
|
**n8n:** Bei INSERT/UPDATE `seen_at` und `cover_url` nicht ungewollt überschreiben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI-Funktionen (Stand Projektabschluss)
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- **Desktop:** Tabelle mit vielen Spalten; **erste Spalte Cover** (ca. 76×114 px), dann Dateinamen-Paar
|
||||||
|
- **Mobile (≤900px):** Karten mit Cover links
|
||||||
|
- **Helles Dark-Theme** (`frontend/src/styles.css`, CSS-Variablen)
|
||||||
|
|
||||||
|
### Dateinamen
|
||||||
|
|
||||||
|
- Immer **Original** und **Neu** sichtbar (`NamePair.vue`)
|
||||||
|
- Kein „Rename prüfen“-Badge mehr (Heuristik entfernt)
|
||||||
|
|
||||||
|
### Filter (`FilterBar.vue`)
|
||||||
|
|
||||||
|
- Textsuche in Original + Neu
|
||||||
|
- **Buttons** (keine Dropdowns): Format, Codec, Container
|
||||||
|
- **Datum/Uhrzeit Von–Bis** mit Kalender-Popup + HH:MM-Dropdowns (`DateTimeRangeFilter.vue`)
|
||||||
|
- **Gesehen:** „Nur ungesehen“, „Alle als gesehen“, Zähler „X neu“
|
||||||
|
- Aktualisieren / Zurücksetzen
|
||||||
|
|
||||||
|
### Gesehen-Status
|
||||||
|
|
||||||
|
- Ungesehene Zeilen: blauer linker Rand + Hintergrund (`row-unseen` / `card-unseen`)
|
||||||
|
- **Tippen auf Zeile** (nicht Scroll): Toggle gesehen/ungesehen via `PATCH /api/records/{id}/seen`
|
||||||
|
- Mülleimer löscht Eintrag (kein Toggle)
|
||||||
|
|
||||||
|
### Tabelle
|
||||||
|
|
||||||
|
- Sortierung Standard: `created_at` absteigend (neueste oben)
|
||||||
|
- Schnellfilter in geladenen Daten
|
||||||
|
- **Löschen:** roter Mülleimer pro Zeile → `DELETE /api/records/{id}`
|
||||||
|
|
||||||
|
### Cover
|
||||||
|
|
||||||
|
- `CoverImage.vue`: lazy load, Platzhalter bei Fehler, URL-Bereinigung (extra `"` entfernen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-Endpunkte
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---------|------|--------------|
|
||||||
|
| GET | `/api/health` | DB-Ping |
|
||||||
|
| GET | `/api/status` | Diagnose (Config, Tabellenliste bei Fehler) |
|
||||||
|
| GET | `/api/columns` | Spaltenliste |
|
||||||
|
| GET | `/api/records` | Daten; Query: `search`, `format`, `codec`, `container`, `created_from`, `created_to`, `unseen_only` |
|
||||||
|
| GET | `/api/filters` | Distinct-Werte für Filter-Buttons |
|
||||||
|
| PATCH | `/api/records/{id}/seen` | Body: `{ "seen": true \| false }` |
|
||||||
|
| POST | `/api/records/mark-all-seen` | Alle gefilterten als gesehen |
|
||||||
|
| DELETE | `/api/records/{id}` | Zeile löschen |
|
||||||
|
|
||||||
|
Nginx proxied `/api/` → `http://api:8000/api/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/nginx_filebot/
|
||||||
|
├── docker-compose.yml # absolute Pfade für Server
|
||||||
|
├── config/
|
||||||
|
│ ├── config.example.yaml
|
||||||
|
│ └── config.yaml # nur Server
|
||||||
|
├── api/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── config.py
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── scripts/list-tables.py
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── App.vue
|
||||||
|
│ ├── api.ts
|
||||||
|
│ ├── components/ # DataTable, FilterBar, CoverImage, …
|
||||||
|
│ ├── composables/ # (useSeenObserver entfernt)
|
||||||
|
│ └── utils/
|
||||||
|
├── nginx/
|
||||||
|
│ ├── Dockerfile # Multi-Stage Frontend-Build
|
||||||
|
│ └── nginx.conf
|
||||||
|
├── sql/ # DB-Migrationen
|
||||||
|
├── PROMPT.md # diese Datei
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment (Kurz)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/nginx_filebot
|
||||||
|
docker compose up -d --build
|
||||||
|
# Browser: http://<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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
   
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY config.py main.py ./
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
ENV CONFIG_PATH=/app/config/config.yaml
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseConfig(BaseModel):
|
||||||
|
host: str = "192.168.30.186"
|
||||||
|
port: int = 5432
|
||||||
|
name: str = "postgres"
|
||||||
|
user: str = "postgres"
|
||||||
|
password: str = ""
|
||||||
|
table: str = "movie_files"
|
||||||
|
# "schema" ist in YAML erlaubt; intern db_schema (Pydantic-Konflikt mit BaseModel.schema)
|
||||||
|
db_schema: str = Field(
|
||||||
|
default="public",
|
||||||
|
validation_alias=AliasChoices("db_schema", "schema"),
|
||||||
|
)
|
||||||
|
ssl: bool = False
|
||||||
|
|
||||||
|
@field_validator("table", "db_schema")
|
||||||
|
@classmethod
|
||||||
|
def validate_identifiers(cls, v: str) -> str:
|
||||||
|
if not IDENT_RE.match(v):
|
||||||
|
raise ValueError(f"Ungültiger Datenbank-Identifier: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qualified_table(self) -> str:
|
||||||
|
return f'"{self.db_schema}"."{self.table}"'
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseModel):
|
||||||
|
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str | Path | None = None) -> tuple[AppConfig, Path]:
|
||||||
|
config_path = Path(path or "/app/config/config.yaml")
|
||||||
|
if not config_path.is_file():
|
||||||
|
return AppConfig(), config_path
|
||||||
|
|
||||||
|
with config_path.open(encoding="utf-8") as f:
|
||||||
|
raw: dict[str, Any] = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
return AppConfig.model_validate(raw), config_path
|
||||||
+668
@@ -0,0 +1,668 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
from asyncpg import PostgresError
|
||||||
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from config import load_config
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("filebot-api")
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
"id",
|
||||||
|
"file_name_original",
|
||||||
|
"file_name_new",
|
||||||
|
"object_type",
|
||||||
|
"cover_url",
|
||||||
|
"movie_rating",
|
||||||
|
"movie_votes",
|
||||||
|
"video_compression_format",
|
||||||
|
"video_codec_library",
|
||||||
|
"audio_codec",
|
||||||
|
"container_format",
|
||||||
|
"standard_video_format",
|
||||||
|
"exact_video_format",
|
||||||
|
"fourk_resolution",
|
||||||
|
"audio_codec_profile",
|
||||||
|
"audio_channel_format",
|
||||||
|
"audio_channel_count",
|
||||||
|
"audio_channel_layout",
|
||||||
|
"video_resolution",
|
||||||
|
"video_width",
|
||||||
|
"video_height",
|
||||||
|
"video_bitdepth",
|
||||||
|
"high_dynamic_range",
|
||||||
|
"dolby_vision",
|
||||||
|
"overall_bitrate",
|
||||||
|
"video_bitrate",
|
||||||
|
"audio_bitrate",
|
||||||
|
"created_at",
|
||||||
|
"seen_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SetSeenBody(BaseModel):
|
||||||
|
seen: bool
|
||||||
|
|
||||||
|
pool: asyncpg.Pool | None = None
|
||||||
|
qualified_table: str = '"public"."n8n_filebot"'
|
||||||
|
config_path: Path = Path("/app/config/config.yaml")
|
||||||
|
config_loaded_from_file: bool = False
|
||||||
|
db_settings: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_value(val: Any) -> Any:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, Decimal):
|
||||||
|
return float(val)
|
||||||
|
if isinstance(val, (datetime, date)):
|
||||||
|
return val.isoformat()
|
||||||
|
if isinstance(val, UUID):
|
||||||
|
return str(val)
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_cover_url(val: Any) -> str | None:
|
||||||
|
"""Cover-URL aus DB/n8n bereinigen (JSON-Strings, Anführungszeichen, eingebettete URLs)."""
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, dict):
|
||||||
|
for key in ("url", "cover_url", "medium", "original", "poster", "href", "src"):
|
||||||
|
if key in val and val[key]:
|
||||||
|
nested = normalize_cover_url(val[key])
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
return None
|
||||||
|
if isinstance(val, list) and val:
|
||||||
|
return normalize_cover_url(val[0])
|
||||||
|
if not isinstance(val, str):
|
||||||
|
val = str(val)
|
||||||
|
s = val.strip().strip('"').strip("'")
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.startswith("{") or s.startswith("["):
|
||||||
|
try:
|
||||||
|
return normalize_cover_url(json.loads(s))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
match = re.search(r"https?://[^\s\"'<>]+", s)
|
||||||
|
if match:
|
||||||
|
return match.group(0).rstrip(".,)")
|
||||||
|
if s.startswith("http://") or s.startswith("https://"):
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_dict(record: asyncpg.Record) -> dict[str, Any]:
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
for col in COLUMNS:
|
||||||
|
val = serialize_value(record[col])
|
||||||
|
if col == "cover_url":
|
||||||
|
val = normalize_cover_url(val)
|
||||||
|
data[col] = val
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def build_record_conditions(
|
||||||
|
*,
|
||||||
|
search: str | None = None,
|
||||||
|
format_filter: str | None = None,
|
||||||
|
codec: str | None = None,
|
||||||
|
container: str | None = None,
|
||||||
|
created_from: str | None = None,
|
||||||
|
created_to: str | None = None,
|
||||||
|
unseen_only: bool = False,
|
||||||
|
object_type: str | None = None,
|
||||||
|
) -> tuple[list[str], list[Any]]:
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if search:
|
||||||
|
conditions.append(
|
||||||
|
f"(file_name_original ILIKE ${idx} OR file_name_new ILIKE ${idx})"
|
||||||
|
)
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if format_filter:
|
||||||
|
conditions.append(f"standard_video_format = ${idx}")
|
||||||
|
params.append(format_filter)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if codec:
|
||||||
|
conditions.append(f"video_codec_library = ${idx}")
|
||||||
|
params.append(codec)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if container:
|
||||||
|
conditions.append(f"container_format = ${idx}")
|
||||||
|
params.append(container)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if created_from:
|
||||||
|
conditions.append(f"created_at >= ${idx}")
|
||||||
|
params.append(parse_filter_datetime(created_from, "von"))
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if created_to:
|
||||||
|
conditions.append(f"created_at <= ${idx}")
|
||||||
|
params.append(parse_filter_datetime(created_to, "bis"))
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if unseen_only:
|
||||||
|
conditions.append("seen_at IS NULL")
|
||||||
|
|
||||||
|
if object_type:
|
||||||
|
conditions.append(f"LOWER(TRIM(object_type::text)) = ${idx}")
|
||||||
|
params.append(object_type.strip().lower())
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
return conditions, params
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filter_datetime(value: str, param_name: str) -> datetime:
|
||||||
|
try:
|
||||||
|
normalized = value.strip().replace("Z", "+00:00")
|
||||||
|
dt = datetime.fromisoformat(normalized)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, detail=f"Ungültiges Datum ({param_name})") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global pool, qualified_table, config_path, config_loaded_from_file, db_settings
|
||||||
|
|
||||||
|
cfg_path = os.getenv("CONFIG_PATH", "/app/config/config.yaml")
|
||||||
|
cfg, config_path = load_config(cfg_path)
|
||||||
|
config_loaded_from_file = config_path.is_file()
|
||||||
|
db = cfg.database
|
||||||
|
qualified_table = db.qualified_table
|
||||||
|
db_settings = {
|
||||||
|
"host": db.host,
|
||||||
|
"port": db.port,
|
||||||
|
"database": db.name,
|
||||||
|
"user": db.user,
|
||||||
|
"table": db.table,
|
||||||
|
"db_schema": db.db_schema,
|
||||||
|
"qualified_table": db.qualified_table,
|
||||||
|
"config_file": str(config_path),
|
||||||
|
"config_exists": config_loaded_from_file,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not config_loaded_from_file:
|
||||||
|
logger.warning("Config-Datei fehlt: %s – Standardwerte aktiv", config_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pool = await asyncpg.create_pool(
|
||||||
|
host=db.host,
|
||||||
|
port=db.port,
|
||||||
|
database=db.name,
|
||||||
|
user=db.user,
|
||||||
|
password=db.password,
|
||||||
|
ssl=db.ssl,
|
||||||
|
min_size=1,
|
||||||
|
max_size=5,
|
||||||
|
command_timeout=30,
|
||||||
|
)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.fetchval("SELECT 1")
|
||||||
|
logger.info(
|
||||||
|
"PostgreSQL verbunden: %s:%s/%s → %s",
|
||||||
|
db.host,
|
||||||
|
db.port,
|
||||||
|
db.name,
|
||||||
|
qualified_table,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("PostgreSQL-Verbindung fehlgeschlagen: %s", exc)
|
||||||
|
pool = None
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if pool:
|
||||||
|
await pool.close()
|
||||||
|
pool = None
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Filebot Media Browser", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(PostgresError)
|
||||||
|
async def postgres_error_handler(_request: Request, exc: PostgresError):
|
||||||
|
logger.exception("PostgreSQL-Fehler: %s", exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={"detail": f"Datenbankfehler: {exc}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def generic_error_handler(_request: Request, exc: Exception):
|
||||||
|
if isinstance(exc, HTTPException):
|
||||||
|
raise exc
|
||||||
|
logger.exception("Unerwarteter Fehler: %s", exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": f"Serverfehler: {type(exc).__name__}: {exc}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.fetchval("SELECT 1")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
async def status():
|
||||||
|
"""Diagnose: Config + DB + Tabellenzugriff."""
|
||||||
|
info: dict[str, Any] = {
|
||||||
|
"database": db_settings,
|
||||||
|
"pool": pool is not None,
|
||||||
|
"table": qualified_table,
|
||||||
|
}
|
||||||
|
if pool is None:
|
||||||
|
info["error"] = "Kein Connection-Pool – Zugangsdaten oder Netzwerk prüfen"
|
||||||
|
return info
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
info["ping"] = await conn.fetchval("SELECT 1")
|
||||||
|
count = await conn.fetchval(f"SELECT COUNT(*) FROM {qualified_table}")
|
||||||
|
info["row_count"] = count
|
||||||
|
except PostgresError as exc:
|
||||||
|
info["error"] = str(exc)
|
||||||
|
if pool is not None:
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
schema = db_settings.get("db_schema", "public")
|
||||||
|
in_schema = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = $1
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
""",
|
||||||
|
schema,
|
||||||
|
)
|
||||||
|
similar = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT table_schema, table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_type = 'BASE TABLE'
|
||||||
|
AND (
|
||||||
|
table_name ILIKE '%filebot%'
|
||||||
|
OR table_name ILIKE '%file%'
|
||||||
|
)
|
||||||
|
ORDER BY table_schema, table_name
|
||||||
|
LIMIT 30
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
info["tables_in_configured_schema"] = [r["table_name"] for r in in_schema]
|
||||||
|
info["similar_tables"] = [
|
||||||
|
f'{r["table_schema"]}.{r["table_name"]}' for r in similar
|
||||||
|
]
|
||||||
|
except PostgresError:
|
||||||
|
pass
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/columns")
|
||||||
|
async def get_columns():
|
||||||
|
return {"columns": COLUMNS}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/records/meta")
|
||||||
|
async def get_records_meta(
|
||||||
|
search: str | None = Query(None),
|
||||||
|
format_filter: str | None = Query(None, alias="format"),
|
||||||
|
codec: str | None = None,
|
||||||
|
container: str | None = None,
|
||||||
|
created_from: str | None = Query(None),
|
||||||
|
created_to: str | None = Query(None),
|
||||||
|
unseen_only: bool = Query(False),
|
||||||
|
object_type: str | None = None,
|
||||||
|
):
|
||||||
|
"""Leichtgewichtiger Check für Auto-Refresh (ohne volle Datensätze)."""
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
conditions, params = build_record_conditions(
|
||||||
|
search=search,
|
||||||
|
format_filter=format_filter,
|
||||||
|
codec=codec,
|
||||||
|
container=container,
|
||||||
|
created_from=created_from,
|
||||||
|
created_to=created_to,
|
||||||
|
unseen_only=unseen_only,
|
||||||
|
object_type=object_type,
|
||||||
|
)
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
unseen_conditions = [*conditions, "seen_at IS NULL"]
|
||||||
|
unseen_where = f"WHERE {' AND '.join(unseen_conditions)}"
|
||||||
|
else:
|
||||||
|
unseen_where = "WHERE seen_at IS NULL"
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total,
|
||||||
|
COALESCE(MAX(id), 0)::int AS max_id,
|
||||||
|
MAX(created_at) AS latest_created_at,
|
||||||
|
(
|
||||||
|
SELECT md5(COALESCE(string_agg(sig, '|'), ''))
|
||||||
|
FROM (
|
||||||
|
SELECT (
|
||||||
|
id::text || ':' ||
|
||||||
|
COALESCE(cover_url, '') || ':' ||
|
||||||
|
COALESCE(file_name_new, '') || ':' ||
|
||||||
|
COALESCE(object_type::text, '') || ':' ||
|
||||||
|
COALESCE(seen_at::text, '')
|
||||||
|
) AS sig
|
||||||
|
FROM {qualified_table}
|
||||||
|
{where}
|
||||||
|
ORDER BY created_at DESC NULLS LAST, id DESC
|
||||||
|
LIMIT 300
|
||||||
|
) recent
|
||||||
|
) AS revision
|
||||||
|
FROM {qualified_table}
|
||||||
|
{where}
|
||||||
|
""",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
unseen_total = await conn.fetchval(
|
||||||
|
f"SELECT COUNT(*)::int FROM {qualified_table} {unseen_where}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
latest = row["latest_created_at"]
|
||||||
|
return {
|
||||||
|
"total": row["total"],
|
||||||
|
"unseen_total": unseen_total,
|
||||||
|
"max_id": row["max_id"],
|
||||||
|
"latest_created_at": serialize_value(latest) if latest else None,
|
||||||
|
"revision": row["revision"] or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cover")
|
||||||
|
async def proxy_cover(url: str = Query(..., min_length=8)):
|
||||||
|
"""Cover-Bilder über die API laden (Hotlink/Referrer-Probleme umgehen)."""
|
||||||
|
normalized = normalize_cover_url(url)
|
||||||
|
if not normalized:
|
||||||
|
raise HTTPException(400, detail="Ungültige Cover-URL")
|
||||||
|
parsed = urlparse(normalized)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise HTTPException(400, detail="Ungültige Cover-URL")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
normalized,
|
||||||
|
headers={"User-Agent": "FilebotMediaBrowser/1.0"},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(502, detail=f"Cover nicht ladbar: {exc}") from exc
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(502, detail=f"Cover nicht ladbar ({resp.status_code})")
|
||||||
|
|
||||||
|
content_type = resp.headers.get("content-type", "image/jpeg")
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
content_type = "image/jpeg"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=resp.content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={"Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/records")
|
||||||
|
async def get_records(
|
||||||
|
search: str | None = Query(None, description="Suche in Dateinamen"),
|
||||||
|
format_filter: str | None = Query(None, alias="format"),
|
||||||
|
codec: str | None = None,
|
||||||
|
container: str | None = None,
|
||||||
|
created_from: str | None = Query(None, description="created_at >= (ISO)"),
|
||||||
|
created_to: str | None = Query(None, description="created_at <= (ISO)"),
|
||||||
|
unseen_only: bool = Query(False, description="Nur ungesehene Einträge"),
|
||||||
|
object_type: str | None = None,
|
||||||
|
limit: int = Query(5000, le=10000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
conditions, params = build_record_conditions(
|
||||||
|
search=search,
|
||||||
|
format_filter=format_filter,
|
||||||
|
codec=codec,
|
||||||
|
container=container,
|
||||||
|
created_from=created_from,
|
||||||
|
created_to=created_to,
|
||||||
|
unseen_only=unseen_only,
|
||||||
|
object_type=object_type,
|
||||||
|
)
|
||||||
|
idx = len(params) + 1
|
||||||
|
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
cols = ", ".join(COLUMNS)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT {cols}
|
||||||
|
FROM {qualified_table}
|
||||||
|
{where}
|
||||||
|
ORDER BY created_at DESC NULLS LAST, id DESC
|
||||||
|
LIMIT ${idx} OFFSET ${idx + 1}
|
||||||
|
"""
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
count_query = f"SELECT COUNT(*) FROM {qualified_table} {where}"
|
||||||
|
count_params = params[:-2]
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
unseen_conditions = [*conditions, "seen_at IS NULL"]
|
||||||
|
unseen_where = f"WHERE {' AND '.join(unseen_conditions)}"
|
||||||
|
else:
|
||||||
|
unseen_where = "WHERE seen_at IS NULL"
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(query, *params)
|
||||||
|
total = await conn.fetchval(count_query, *count_params)
|
||||||
|
unseen_total = await conn.fetchval(
|
||||||
|
f"SELECT COUNT(*) FROM {qualified_table} {unseen_where}",
|
||||||
|
*count_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"records": [row_to_dict(r) for r in rows],
|
||||||
|
"total": total,
|
||||||
|
"unseen_total": unseen_total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/records/{record_id}/seen")
|
||||||
|
async def set_record_seen(record_id: int, body: SetSeenBody):
|
||||||
|
"""Einzelnen Eintrag als gesehen (seen=true) oder ungesehen (seen=false) markieren."""
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
if body.seen:
|
||||||
|
sql = f"""
|
||||||
|
UPDATE {qualified_table}
|
||||||
|
SET seen_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, seen_at
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
sql = f"""
|
||||||
|
UPDATE {qualified_table}
|
||||||
|
SET seen_at = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, seen_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(sql, record_id)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, detail=f"Eintrag mit ID {record_id} nicht gefunden")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"seen_at": serialize_value(row["seen_at"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/records/mark-all-seen")
|
||||||
|
async def mark_all_seen(
|
||||||
|
search: str | None = Query(None),
|
||||||
|
format_filter: str | None = Query(None, alias="format"),
|
||||||
|
codec: str | None = None,
|
||||||
|
container: str | None = None,
|
||||||
|
created_from: str | None = None,
|
||||||
|
created_to: str | None = None,
|
||||||
|
object_type: str | None = None,
|
||||||
|
):
|
||||||
|
"""Alle sichtbaren (gefilterten) Einträge als gesehen markieren."""
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
conditions, params = build_record_conditions(
|
||||||
|
search=search,
|
||||||
|
format_filter=format_filter,
|
||||||
|
codec=codec,
|
||||||
|
container=container,
|
||||||
|
created_from=created_from,
|
||||||
|
created_to=created_to,
|
||||||
|
unseen_only=False,
|
||||||
|
object_type=object_type,
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
where = f"WHERE {' AND '.join(conditions)} AND seen_at IS NULL"
|
||||||
|
else:
|
||||||
|
where = "WHERE seen_at IS NULL"
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
f"UPDATE {qualified_table} SET seen_at = NOW() {where}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = int(result.split()[-1]) if result else 0
|
||||||
|
return {"marked": count}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/filters")
|
||||||
|
async def get_filter_options():
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
formats = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT standard_video_format AS value
|
||||||
|
FROM {qualified_table}
|
||||||
|
WHERE standard_video_format IS NOT NULL
|
||||||
|
AND standard_video_format::text <> ''
|
||||||
|
ORDER BY 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
codecs = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT video_codec_library AS value
|
||||||
|
FROM {qualified_table}
|
||||||
|
WHERE video_codec_library IS NOT NULL
|
||||||
|
AND video_codec_library::text <> ''
|
||||||
|
ORDER BY 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
containers = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT container_format AS value
|
||||||
|
FROM {qualified_table}
|
||||||
|
WHERE container_format IS NOT NULL
|
||||||
|
AND container_format::text <> ''
|
||||||
|
ORDER BY 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
audio_codecs = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT audio_codec AS value
|
||||||
|
FROM {qualified_table}
|
||||||
|
WHERE audio_codec IS NOT NULL
|
||||||
|
AND audio_codec::text <> ''
|
||||||
|
ORDER BY 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
object_types = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT LOWER(TRIM(object_type::text)) AS value
|
||||||
|
FROM {qualified_table}
|
||||||
|
WHERE object_type IS NOT NULL
|
||||||
|
AND TRIM(object_type::text) <> ''
|
||||||
|
ORDER BY 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"formats": [r["value"] for r in formats],
|
||||||
|
"codecs": [r["value"] for r in codecs],
|
||||||
|
"containers": [r["value"] for r in containers],
|
||||||
|
"audio_codecs": [r["value"] for r in audio_codecs],
|
||||||
|
"object_types": [r["value"] for r in object_types],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/records/{record_id}")
|
||||||
|
async def delete_record(record_id: int):
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(503, detail="Datenbank nicht verbunden")
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
f"DELETE FROM {qualified_table} WHERE id = $1",
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise HTTPException(404, detail=f"Eintrag mit ID {record_id} nicht gefunden")
|
||||||
|
|
||||||
|
return {"deleted": True, "id": record_id}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
asyncpg==0.30.0
|
||||||
|
pyyaml==6.0.2
|
||||||
|
pydantic==2.10.4
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
httpx==0.28.1
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tabellen auflisten. Im Container: python /app/scripts/list-tables.py"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tables(conn: asyncpg.Connection, db_name: str, schema_hint: str, table_hint: str) -> None:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT table_schema, table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_type = 'BASE TABLE'
|
||||||
|
AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
ORDER BY table_schema, table_name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(f"\n=== Datenbank: {db_name} ({len(rows)} Tabellen) ===")
|
||||||
|
if not rows:
|
||||||
|
print(" (leer – keine Benutzer-Tabellen)")
|
||||||
|
return
|
||||||
|
for r in rows:
|
||||||
|
mark = ""
|
||||||
|
if r["table_schema"] == schema_hint and r["table_name"] == table_hint:
|
||||||
|
mark = " <-- Config-Ziel"
|
||||||
|
print(f" {r['table_schema']}.{r['table_name']}{mark}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
config_path = Path(sys.argv[1] if len(sys.argv) > 1 else "/app/config/config.yaml")
|
||||||
|
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||||
|
c = raw.get("database", raw)
|
||||||
|
schema_hint = c.get("db_schema") or c.get("schema") or "public"
|
||||||
|
table_hint = c.get("table", "")
|
||||||
|
|
||||||
|
print(f"Host: {c['host']}:{c['port']}")
|
||||||
|
print(f"Config: DB={c['name']} Schema={schema_hint} Tabelle={table_hint}")
|
||||||
|
|
||||||
|
# Konfigurierte Datenbank
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host=c["host"],
|
||||||
|
port=c["port"],
|
||||||
|
database=c["name"],
|
||||||
|
user=c["user"],
|
||||||
|
password=c["password"],
|
||||||
|
)
|
||||||
|
await list_tables(conn, c["name"], schema_hint, table_hint)
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
# Alle Datenbanken auflisten und nach filebot-Tabellen suchen
|
||||||
|
print("\n=== Suche 'filebot' in allen Datenbanken ===")
|
||||||
|
admin = await asyncpg.connect(
|
||||||
|
host=c["host"],
|
||||||
|
port=c["port"],
|
||||||
|
database="postgres",
|
||||||
|
user=c["user"],
|
||||||
|
password=c["password"],
|
||||||
|
)
|
||||||
|
dbs = await admin.fetch(
|
||||||
|
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"
|
||||||
|
)
|
||||||
|
for db in dbs:
|
||||||
|
name = db["datname"]
|
||||||
|
if name in ("template0", "template1"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
c2 = await asyncpg.connect(
|
||||||
|
host=c["host"],
|
||||||
|
port=c["port"],
|
||||||
|
database=name,
|
||||||
|
user=c["user"],
|
||||||
|
password=c["password"],
|
||||||
|
)
|
||||||
|
hits = await c2.fetch(
|
||||||
|
"""
|
||||||
|
SELECT table_schema, table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_type = 'BASE TABLE'
|
||||||
|
AND table_name ILIKE '%filebot%'
|
||||||
|
ORDER BY 1, 2
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if hits:
|
||||||
|
print(f"\n DB '{name}':")
|
||||||
|
for h in hits:
|
||||||
|
print(f" {h['table_schema']}.{h['table_name']}")
|
||||||
|
await c2.close()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" DB '{name}': kein Zugriff ({exc})")
|
||||||
|
await admin.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Executable
+9
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
@@ -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;
|
||||||
@@ -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)';
|
||||||
@@ -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)';
|
||||||
Reference in New Issue
Block a user