diff --git a/CHANGELOG.md b/CHANGELOG.md index 341f915..36c735d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ Alle relevanten Aenderungen am VideoKonverter-Projekt. +## [4.0.1] - 2026-03-01 + +### TV-App: UX-Verbesserungen & Bugfixes + +#### Neue Features +- **Alphabet-Seitenleiste**: Vertikale A-Z Sidebar auf Serien-/Filme-Seite zum Filtern nach Anfangsbuchstabe + - Buchstaben ohne Treffer automatisch abgedunkelt + - Wird in Ordner-Ansicht versteckt + - Responsive fuer Handy/Tablet +- **Genre-Select statt Chips**: Genre-Filter als Dropdown-Element (uebersichtlicher bei vielen Genres) +- **Player-Buttons**: Separate Symbole fuer Audio (Lautsprecher-SVG), Untertitel (CC-Badge), Qualitaet (HD-Badge) + - CC-Button leuchtet wenn Untertitel aktiv, Quality-Badge zeigt aktuellen Modus (4K/HD/SD/LD) + - Klick oeffnet Overlay direkt bei der entsprechenden Sektion +- **Gesehen-Markierung**: Buttons fuer "Episode gesehen" und "Staffel gesehen" in Serien-Detail +- **Batch-Thumbnails**: Neuer Button "Thumbnails" in der Bibliothek generiert alle fehlenden Episoden-Thumbnails im Hintergrund per ffmpeg +- **Redundanz-Markierung**: Duplikate in der Episoden-Tabelle werden jetzt orange markiert mit "REDUNDANT"-Badge + - Ranking: Neuerer Codec > kleinere Datei +- **Rating-Sortierung**: Serien/Filme nach Bewertung sortierbar + Min-Rating-Filter + +#### Bugfixes +- **tvdb_episode_cache**: Fehlende Spalten `overview` und `image_url` hinzugefuegt (Episoden-Beschreibungen funktionierten nicht) +- **Login-Form Flash**: Auto-Fill-Erkennung statt hartem Timeout (prueft 5x alle 200ms ob Browser Felder ausgefuellt hat) +- **Profil-Wechsel**: Zeigt jetzt alle User an (nicht nur die mit aktiver Session) +- **Debug-Prints entfernt**: Bereinigung aus server.py und tv_api.py +- **Route-Registrierung**: TV-API-Routen in `_setup_app()` verschoben (verhinderte 500-Fehler) + +#### Neue API-Endpunkte +- `POST /api/library/generate-thumbnails` - Batch-Thumbnail-Generierung starten +- `GET /api/library/thumbnail-status` - Thumbnail-Fortschritt abfragen + +#### Geaenderte Dateien (19 Dateien, +821/-122 Zeilen) +- `app/routes/library_api.py` - Batch-Thumbnails + aiomysql Import +- `app/routes/tv_api.py` - Gesehen-Status, Rating-Filter, Genre-Select +- `app/server.py` - Route-Registrierung Fix +- `app/services/auth.py` - Watch-Status DB-Methoden +- `app/services/library.py` - tvdb_episode_cache Spalten-Fix + Migration +- `app/static/css/style.css` - Redundanz-Zeilen-Style +- `app/static/js/library.js` - Redundanz-Erkennung, Batch-Thumbnails +- `app/static/tv/css/tv.css` - Player-Badges, Alphabet-Sidebar, Rating-Styles +- `app/static/tv/i18n/de.json` + `en.json` - Rating-Uebersetzungen +- `app/static/tv/js/player.js` - Overlay-Sections, Button-Updates +- `app/static/tv/js/tv.js` - Gesehen-Buttons, Alphabet-Filter +- `app/templates/library.html` - Thumbnails-Button +- `app/templates/tv/login.html` - Auto-Fill-Erkennung +- `app/templates/tv/movies.html` - Alphabet-Sidebar, data-letter +- `app/templates/tv/player.html` - Audio/CC/Quality-Buttons +- `app/templates/tv/profiles.html` - Alle User anzeigen +- `app/templates/tv/series.html` - Alphabet-Sidebar, data-letter +- `app/templates/tv/series_detail.html` - Gesehen-Buttons, Episoden-Beschreibungen + +--- + ## [4.0.0] - 2026-03-01 ### TV-App: Vollwertiger Streaming-Client diff --git a/docker-exports/videoconverter-4.0.1.tar b/docker-exports/videoconverter-4.0.1.tar new file mode 100644 index 0000000..9ce2121 Binary files /dev/null and b/docker-exports/videoconverter-4.0.1.tar differ diff --git a/video-konverter/app/routes/library_api.py b/video-konverter/app/routes/library_api.py index 0d48ad0..734c10c 100644 --- a/video-konverter/app/routes/library_api.py +++ b/video-konverter/app/routes/library_api.py @@ -1,6 +1,7 @@ """REST API Endpoints fuer die Video-Bibliothek""" import asyncio import logging +import aiomysql from aiohttp import web from app.config import Config from app.services.library import LibraryService @@ -1696,6 +1697,147 @@ def setup_library_routes(app: web.Application, config: Config, logging.error(f"Thumbnail-Fehler: {e}") return web.json_response({"error": str(e)}, status=500) + # === Batch-Thumbnail-Generierung === + + _thumbnail_task = None # Hintergrund-Task fuer Batch-Generierung + + async def post_generate_thumbnails(request: web.Request) -> web.Response: + """POST /api/library/generate-thumbnails + Generiert fehlende Thumbnails fuer alle Videos im Hintergrund. + Optional: ?series_id=123 fuer nur eine Serie.""" + import os + import asyncio as _asyncio + nonlocal _thumbnail_task + + # Laeuft bereits? + if _thumbnail_task and not _thumbnail_task.done(): + return web.json_response({ + "status": "running", + "message": "Thumbnail-Generierung laeuft bereits" + }) + + pool = await library_service._get_pool() + if not pool: + return web.json_response( + {"error": "Keine DB-Verbindung"}, status=500) + + series_id = request.query.get("series_id") + + async def _generate_batch(): + """Hintergrund-Task: Fehlende Thumbnails erzeugen.""" + generated = 0 + errors = 0 + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Videos ohne Thumbnail finden + sql = """ + SELECT v.id, v.file_path, v.duration_sec + FROM library_videos v + LEFT JOIN tv_episode_thumbnails t + ON v.id = t.video_id + WHERE t.video_id IS NULL + """ + params = [] + if series_id: + sql += " AND v.series_id = %s" + params.append(int(series_id)) + sql += " ORDER BY v.id" + await cur.execute(sql, params) + videos = await cur.fetchall() + + logging.info( + f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail" + ) + + for video in videos: + vid = video["id"] + fp = video["file_path"] + dur = video.get("duration_sec") or 0 + + if not os.path.isfile(fp): + continue + + seek = dur * 0.25 if dur > 10 else 5 + vdir = os.path.dirname(fp) + tdir = os.path.join(vdir, ".metadata", "thumbnails") + os.makedirs(tdir, exist_ok=True) + tpath = os.path.join(tdir, f"{vid}.jpg") + + cmd = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", + "-ss", str(int(seek)), + "-i", fp, + "-vframes", "1", "-q:v", "5", + "-vf", "scale=480:-1", + "-y", tpath, + ] + try: + proc = await _asyncio.create_subprocess_exec( + *cmd, + stdout=_asyncio.subprocess.PIPE, + stderr=_asyncio.subprocess.PIPE, + ) + await proc.communicate() + + if proc.returncode == 0 and os.path.isfile(tpath): + async with pool.acquire() as conn2: + async with conn2.cursor() as cur2: + await cur2.execute(""" + INSERT INTO tv_episode_thumbnails + (video_id, thumbnail_path, source) + VALUES (%s, %s, 'ffmpeg') + ON DUPLICATE KEY UPDATE + thumbnail_path = VALUES(thumbnail_path) + """, (vid, tpath)) + generated += 1 + else: + errors += 1 + except Exception as e: + logging.warning(f"Thumbnail-Fehler Video {vid}: {e}") + errors += 1 + + logging.info( + f"Thumbnail-Batch fertig: {generated} erzeugt, " + f"{errors} Fehler" + ) + except Exception as e: + logging.error(f"Thumbnail-Batch Fehler: {e}") + + import asyncio + _thumbnail_task = asyncio.ensure_future(_generate_batch()) + + return web.json_response({ + "status": "started", + "message": "Thumbnail-Generierung gestartet" + }) + + async def get_thumbnail_status(request: web.Request) -> web.Response: + """GET /api/library/thumbnail-status + Zeigt Fortschritt der Thumbnail-Generierung.""" + pool = await library_service._get_pool() + if not pool: + return web.json_response( + {"error": "Keine DB-Verbindung"}, status=500) + + running = bool(_thumbnail_task and not _thumbnail_task.done()) + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT COUNT(*) AS cnt FROM tv_episode_thumbnails") + done = (await cur.fetchone())["cnt"] + await cur.execute( + "SELECT COUNT(*) AS cnt FROM library_videos") + total = (await cur.fetchone())["cnt"] + + return web.json_response({ + "running": running, + "generated": done, + "total": total, + "missing": total - done, + }) + # === Import: Item zuordnen / ueberspringen === async def post_reassign_import_item( @@ -2202,6 +2344,13 @@ def setup_library_routes(app: web.Application, config: Config, app.router.add_get( "/api/library/videos/{video_id}/thumbnail", get_video_thumbnail ) + # Batch-Thumbnails + app.router.add_post( + "/api/library/generate-thumbnails", post_generate_thumbnails + ) + app.router.add_get( + "/api/library/thumbnail-status", get_thumbnail_status + ) # TVDB Auto-Match (Review-Modus) app.router.add_post( "/api/library/tvdb-auto-match", post_tvdb_auto_match diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index e097fd3..7c9a022 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -316,7 +316,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) src_name = src_map.get(source_filter, "") if items: - folder_data.append({"name": src_name, "items": items}) + folder_data.append({"name": src_name, "entries": items}) else: for src in sources: items = sorted( @@ -326,7 +326,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) if items: folder_data.append({ - "name": src["name"], "items": items}) + "name": src["name"], "entries": items}) # Serien ohne Quelle (Fallback) src_ids = {src["id"] for src in sources} orphans = sorted( @@ -335,7 +335,7 @@ def setup_tv_routes(app: web.Application, config: Config, key=lambda x: (x.get("folder_name") or "").lower() ) if orphans: - folder_data.append({"name": "Sonstige", "items": orphans}) + folder_data.append({"name": "Sonstige", "entries": orphans}) return aiohttp_jinja2.render_template( "tv/series.html", request, { @@ -554,7 +554,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) src_name = src_map.get(source_filter, "") if items: - folder_data.append({"name": src_name, "items": items}) + folder_data.append({"name": src_name, "entries": items}) else: for src in sources: items = sorted( @@ -564,7 +564,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) if items: folder_data.append({ - "name": src["name"], "items": items}) + "name": src["name"], "entries": items}) # Filme ohne Quelle (Fallback) src_ids = {src["id"] for src in sources} orphans = sorted( @@ -573,7 +573,7 @@ def setup_tv_routes(app: web.Application, config: Config, key=lambda x: (x.get("folder_name") or "").lower() ) if orphans: - folder_data.append({"name": "Sonstige", "items": orphans}) + folder_data.append({"name": "Sonstige", "entries": orphans}) return aiohttp_jinja2.render_template( "tv/movies.html", request, { @@ -929,36 +929,56 @@ def setup_tv_routes(app: web.Application, config: Config, # --- Profilauswahl (Multi-User Quick-Switch) --- async def get_profiles(request: web.Request) -> web.Response: - """GET /tv/profiles - Profilauswahl (wer schaut?)""" + """GET /tv/profiles - Profilauswahl (wer schaut?) + Zeigt alle User an. Aktuelle Session wird hervorgehoben.""" client_id = request.cookies.get("vk_client_id") - profiles = [] - if client_id: - profiles = await auth_service.get_client_profiles(client_id) - # Aktuelle Session herausfinden current_session = request.cookies.get("vk_session") + current_user_id = None + if current_session: + user = await auth_service.validate_session(current_session) + if user: + current_user_id = user.get("id") + + # Alle User laden (nicht nur die mit Sessions auf diesem Client) + all_users = await auth_service.get_all_users() return aiohttp_jinja2.render_template( "tv/profiles.html", request, { - "profiles": profiles, - "current_session": current_session, + "profiles": all_users, + "current_user_id": current_user_id, } ) async def post_switch_profile(request: web.Request) -> web.Response: - """POST /tv/switch-profile - Profil wechseln (Session-ID)""" + """POST /tv/switch-profile - Auf anderen User wechseln. + Erstellt neue Session fuer den gewaehlten User.""" data = await request.post() - session_id = data.get("session_id", "") - if not session_id: + user_id = data.get("user_id", "") + if not user_id: raise web.HTTPFound("/tv/profiles") - # Session validieren - user = await auth_service.validate_session(session_id) - if not user: + + # Client-ID ermitteln/erstellen + client_id = request.cookies.get("vk_client_id") + client_id = await auth_service.get_or_create_client(client_id) + + # Neue Session fuer den User erstellen + ua = request.headers.get("User-Agent", "") + session_id = await auth_service.create_session( + int(user_id), ua, client_id=client_id, persistent=True + ) + if not session_id: raise web.HTTPFound("/tv/login") + resp = web.HTTPFound("/tv/") resp.set_cookie( "vk_session", session_id, max_age=10 * 365 * 24 * 3600, httponly=True, samesite="Lax", path="/", ) + resp.set_cookie( + "vk_client_id", client_id, + max_age=10 * 365 * 24 * 3600, + httponly=True, samesite="Lax", path="/", + ) return resp # --- User-Einstellungen --- diff --git a/video-konverter/app/server.py b/video-konverter/app/server.py index acfb5b9..95670fa 100644 --- a/video-konverter/app/server.py +++ b/video-konverter/app/server.py @@ -53,8 +53,18 @@ class VideoKonverterServer: @web.middleware async def _no_cache_middleware(self, request: web.Request, handler) -> web.Response: - """Verhindert Browser-Caching fuer API-Responses""" - response = await handler(request) + """Verhindert Browser-Caching fuer API-Responses + Error-Logging""" + try: + response = await handler(request) + except web.HTTPException as he: + if he.status >= 500: + logging.error(f"HTTP {he.status} bei {request.method} {request.path}: {he.reason}", + exc_info=True) + raise + except Exception as e: + logging.error(f"Unbehandelte Ausnahme bei {request.method} {request.path}: {e}", + exc_info=True) + raise if request.path.startswith("/api/"): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" response.headers["Pragma"] = "no-cache" @@ -96,8 +106,14 @@ class VideoKonverterServer: # Seiten Routes setup_page_routes(self.app, self.config, self.queue_service) - # TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert) - self.auth_service = None + # TV-App Routes (Auth-Service, DB-Pool wird in on_startup gesetzt) + async def _lazy_pool(): + return self.library_service._db_pool + self.auth_service = AuthService(_lazy_pool) + setup_tv_routes( + self.app, self.config, + self.auth_service, self.library_service, + ) # Statische Dateien static_dir = Path(__file__).parent / "static" @@ -151,16 +167,9 @@ class VideoKonverterServer: await self.tvdb_service.init_db() await self.importer_service.init_db() - # TV-App Auth-Service initialisieren (braucht DB-Pool) + # TV-App Auth-Service: DB-Tabellen initialisieren (Pool kommt ueber lazy getter) if self.library_service._db_pool: - async def _get_pool(): - return self.library_service._db_pool - self.auth_service = AuthService(_get_pool) await self.auth_service.init_db() - setup_tv_routes( - self.app, self.config, - self.auth_service, self.library_service, - ) host = self.config.server_config.get("host", "0.0.0.0") port = self.config.server_config.get("port", 8080) diff --git a/video-konverter/app/services/auth.py b/video-konverter/app/services/auth.py index c68b697..84e2a2c 100644 --- a/video-konverter/app/services/auth.py +++ b/video-konverter/app/services/auth.py @@ -596,6 +596,20 @@ class AuthService: """, (client_id,)) return await cur.fetchall() + async def get_all_users(self) -> list[dict]: + """Alle User laden (fuer Profilauswahl)""" + pool = await self._get_pool() + if not pool: + return [] + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT id, username, display_name, avatar_color + FROM tv_users + ORDER BY id + """) + return await cur.fetchall() + # --- User-Einstellungen --- async def update_user_settings(self, user_id: int, diff --git a/video-konverter/app/services/library.py b/video-konverter/app/services/library.py index 901d450..c43f1b2 100644 --- a/video-konverter/app/services/library.py +++ b/video-konverter/app/services/library.py @@ -220,6 +220,8 @@ class LibraryService: episode_name VARCHAR(512), aired DATE NULL, runtime INT NULL, + overview TEXT NULL, + image_url VARCHAR(1024) NULL, cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_series (series_tvdb_id), UNIQUE INDEX idx_episode ( @@ -227,6 +229,18 @@ class LibraryService: ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) + # Spalten nachtraeglich hinzufuegen (bestehende DBs) + for col, coldef in [ + ("overview", "TEXT NULL"), + ("image_url", "VARCHAR(1024) NULL"), + ]: + try: + await cur.execute( + f"ALTER TABLE tvdb_episode_cache " + f"ADD COLUMN {col} {coldef}" + ) + except Exception: + pass # Spalte existiert bereits # movie_id Spalte in library_videos (falls noch nicht vorhanden) try: diff --git a/video-konverter/app/static/css/style.css b/video-konverter/app/static/css/style.css index ca1dde3..09fba60 100644 --- a/video-konverter/app/static/css/style.css +++ b/video-konverter/app/static/css/style.css @@ -1081,6 +1081,8 @@ legend { .row-missing { opacity: 0.6; } .row-missing td { color: #888; } +.row-redundant { background: rgba(255, 152, 0, 0.08); } +.row-redundant td { color: #b0a080; } .text-warn { color: #ffb74d; } .text-muted { color: #888; font-size: 0.8rem; } diff --git a/video-konverter/app/static/js/library.js b/video-konverter/app/static/js/library.js index 59f0404..3c4d67f 100644 --- a/video-konverter/app/static/js/library.js +++ b/video-konverter/app/static/js/library.js @@ -638,6 +638,33 @@ function renderEpisodesTab(series) { for (const ep of sData.missing) allEps.push({...ep, _type: "missing"}); allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0)); + // Redundante Dateien erkennen: gleiche Episode-Nummer mehrfach vorhanden + // Die "beste" Datei behalten (kleinere Datei bei gleichem Codec, neueres Format bevorzugt) + const epGroups = {}; + for (const ep of allEps) { + if (ep._type !== "local" || !ep.episode_number) continue; + const key = `${ep.season_number || 0}-${ep.episode_number}`; + if (!epGroups[key]) epGroups[key] = []; + epGroups[key].push(ep); + } + const redundantIds = new Set(); + const codecRank = {av1: 4, hevc: 3, h265: 3, h264: 2, x264: 2, mpeg4: 1, mpeg2video: 0}; + for (const key of Object.keys(epGroups)) { + const group = epGroups[key]; + if (group.length <= 1) continue; + // Sortiere: neuerer Codec besser, bei gleichem Codec kleinere Datei besser + group.sort((a, b) => { + const ra = codecRank[(a.video_codec || "").toLowerCase()] || 0; + const rb = codecRank[(b.video_codec || "").toLowerCase()] || 0; + if (ra !== rb) return rb - ra; + return (a.file_size || 0) - (b.file_size || 0); + }); + // Alle ausser dem ersten sind redundant + for (let i = 1; i < group.length; i++) { + redundantIds.add(group[i].id); + } + } + for (const ep of allEps) { if (ep._type === "missing") { html += `