From 7ba24a097ab9c2b2b5ceda5c4d1cefc0d8d6bd17 Mon Sep 17 00:00:00 2001 From: data Date: Wed, 25 Feb 2026 11:03:39 +0100 Subject: [PATCH] feat: Filter-Presets, Doppel-Episoden, Fehlende Episoden Ansicht - Schnellfilter mit vordefinierten Presets (Nicht konvertiert, Alte Formate, Fehlende Episoden) - Eigene Filter-Presets speichern und als Standard-Ansicht setzen - Doppel-Episoden-Erkennung (S01E01E02, S01E01-E02, 1x01-02) - episode_end Spalte fuer Multi-Episoden-Dateien - Episoden-Titel aus Dateinamen extrahieren (Qualitaets-Tags entfernt) - Fehlende Episoden Filter zeigt alle fehlenden Episoden aller Serien - not_converted Filter fuer Videos nicht im Zielformat - API-Endpoints fuer Filter-Presets und fehlende Episoden - Doppel-Episoden werden bei fehlenden Episoden korrekt beruecksichtigt - Bugfix: Schnellfilter-Dropdown behielt alle 4 festen Optionen Co-Authored-By: Claude Opus 4.5 --- README.md | 18 ++- app/cfg/settings.yaml | 19 +++ app/routes/library_api.py | 116 ++++++++++++++- app/services/library.py | 284 ++++++++++++++++++++++++++++++++----- app/static/css/style.css | 93 ++++++++++++ app/static/js/library.js | 244 +++++++++++++++++++++++++++++++ app/templates/library.html | 23 +++ 7 files changed, 760 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index e9a0957..5294fc9 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,16 @@ Web-basierter Video-Konverter mit GPU-Beschleunigung (Intel VAAPI), Video-Biblio ### Video-Bibliothek - **Ordner-Scan**: Konfigurierbare Scan-Pfade fuer Serien und Filme - **Serien-Erkennung**: Automatisch via Ordnerstruktur (`S01E01`, `1x02`, `Staffel/Season XX`) +- **Doppel-Episoden**: Erkennung von Multi-Episoden-Dateien (`S01E01E02`, `S01E01-E02`, `1x01-02`) +- **Episoden-Titel**: Automatische Extraktion aus Dateinamen (ohne Qualitaets-Tags) - **Film-Erkennung**: Ein Video pro Ordner = Film, Ordnername als Filmtitel - **ffprobe-Analyse**: Codec, Aufloesung, Bitrate, Audio-Spuren, Untertitel, HDR/10-Bit - **TVDB-Integration**: Serien + Filme, Poster, Episoden-Titel, fehlende Episoden - **TVDB Review-Modal**: Vorschlaege pruefen statt blindem Auto-Match, manuelle Suche - **TVDB-Sprachkonfiguration**: Metadaten in Deutsch, Englisch oder anderen Sprachen +- **Schnellfilter**: Vordefinierte Filter (Nicht konvertiert, Alte Formate, Fehlende Episoden) +- **Filter-Presets**: Eigene Filter speichern und als Standard setzen +- **Fehlende Episoden**: Uebersicht aller fehlenden Episoden ueber alle Serien - **Filter**: Video-Codec, Aufloesung, Container, Audio-Sprache, Kanaele, 10-Bit - **Duplikat-Finder**: Gleiche Episode in verschiedenen Formaten erkennen - **Import-Service**: Videos einsortieren mit Serien-Erkennung und TVDB-Lookup @@ -247,7 +252,13 @@ Web-UI: http://localhost:8080 | GET | `/api/library/videos` | Videos filtern (siehe Filter-Params) | | GET | `/api/library/series` | Alle Serien | | GET | `/api/library/series/{id}` | Serie mit Episoden | -| GET | `/api/library/series/{id}/missing` | Fehlende Episoden | +| GET | `/api/library/series/{id}/missing` | Fehlende Episoden einer Serie | +| GET | `/api/library/missing-episodes` | Alle fehlenden Episoden (paginated) | +| GET | `/api/library/filter-presets` | Filter-Presets laden | +| POST | `/api/library/filter-presets` | Neues Preset speichern | +| PUT | `/api/library/filter-presets` | Presets aktualisieren | +| DELETE | `/api/library/filter-presets/{id}` | Preset loeschen | +| PUT | `/api/library/default-view` | Standard-Ansicht setzen | | POST | `/api/library/series/{id}/tvdb-match` | TVDB-ID zuordnen | | POST | `/api/library/series/{id}/convert` | Alle Episoden konvertieren | | GET | `/api/library/series/{id}/convert-status` | Codec-Status der Serie | @@ -296,6 +307,9 @@ Web-UI: http://localhost:8080 &audio_lang=ger # Audio-Sprache &audio_channels=6 # Kanal-Anzahl (2=Stereo, 6=5.1) &is_10bit=1 # Nur 10-Bit +¬_converted=1 # Nicht im Zielformat (Container + Codec) +&exclude_codec=av1 # Codec ausschliessen +&exclude_container=webm # Container ausschliessen &search=breaking # Dateiname-Suche &sort=file_size # Sortierung &order=desc # asc | desc @@ -319,7 +333,7 @@ Jedes Video mit vollstaendigen ffprobe-Metadaten: - Video: Codec, Aufloesung, Framerate, Bitrate, 10-Bit, HDR - Audio: JSON-Array mit Spuren (`[{"codec":"eac3","lang":"ger","channels":6,"bitrate":256000}]`) - Untertitel: JSON-Array (`[{"codec":"subrip","lang":"ger"}]`) -- Serien: Staffel/Episode-Nummer, Episoden-Titel +- Serien: Staffel/Episode-Nummer, episode_end (fuer Doppel-Episoden), Episoden-Titel ### tvdb_episode_cache Zwischenspeicher fuer TVDB-Episodendaten (Serie, Staffel, Episode, Name, Ausstrahlung). diff --git a/app/cfg/settings.yaml b/app/cfg/settings.yaml index 813ecbe..b0d194b 100644 --- a/app/cfg/settings.yaml +++ b/app/cfg/settings.yaml @@ -64,6 +64,25 @@ library: tvdb_api_key: 5db8defd-41cd-4e0d-a637-ac0a96cbedd9 tvdb_language: deu tvdb_pin: '' + # Standard-Filter beim Laden der Bibliothek + default_view: all # all, not_converted, missing_episodes + # Gespeicherte Filter-Presets + filter_presets: + nicht_konvertiert: + name: Nicht konvertiert + not_converted: true + fehlende_episoden: + name: Fehlende Episoden + show_missing: true + nur_av1: + name: Nur AV1 + video_codec: av1 + nur_hevc: + name: Nur HEVC + video_codec: hevc + alte_formate: + name: Alte Formate (kein AV1/HEVC) + exclude_codec: av1 logging: backup_count: 7 file: server.log diff --git a/app/routes/library_api.py b/app/routes/library_api.py index b2064a3..1e97405 100644 --- a/app/routes/library_api.py +++ b/app/routes/library_api.py @@ -119,7 +119,8 @@ def setup_library_routes(app: web.Application, config: Config, "video_codec", "min_width", "max_width", "container", "audio_lang", "audio_channels", "has_subtitle", "is_10bit", "sort", "order", - "search"): + "search", "not_converted", "exclude_container", + "exclude_codec"): val = request.query.get(key) if val: filters[key] = val @@ -183,6 +184,20 @@ def setup_library_routes(app: web.Application, config: Config, missing = await library_service.get_missing_episodes(series_id) return web.json_response({"missing": missing}) + async def get_all_missing_episodes(request: web.Request) -> web.Response: + """GET /api/library/missing-episodes?path_id=&page=&limit= + Alle fehlenden Episoden aller Serien (fuer Filter-Ansicht).""" + path_id = request.query.get("path_id") + page = int(request.query.get("page", 1)) + limit = int(request.query.get("limit", 50)) + + result = await library_service.get_all_missing_episodes( + int(path_id) if path_id else None, + page, + limit + ) + return web.json_response(result) + # === TVDB === async def post_tvdb_match(request: web.Request) -> web.Response: @@ -1399,7 +1414,103 @@ def setup_library_routes(app: web.Application, config: Config, {"error": "Fehlgeschlagen"}, status=400 ) + # === Filter-Presets === + + async def get_filter_presets(request: web.Request) -> web.Response: + """GET /api/library/filter-presets""" + lib_cfg = config.settings.get("library", {}) + presets = lib_cfg.get("filter_presets", {}) + default_view = lib_cfg.get("default_view", "all") + return web.json_response({ + "presets": presets, + "default_view": default_view, + }) + + async def put_filter_presets(request: web.Request) -> web.Response: + """PUT /api/library/filter-presets - Presets speichern""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + presets = data.get("presets", {}) + default_view = data.get("default_view") + + if "library" not in config.settings: + config.settings["library"] = {} + if presets: + config.settings["library"]["filter_presets"] = presets + if default_view: + config.settings["library"]["default_view"] = default_view + config.save_settings() + return web.json_response({"message": "Filter-Presets gespeichert"}) + + async def post_filter_preset(request: web.Request) -> web.Response: + """POST /api/library/filter-presets - Neues Preset hinzufuegen""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + preset_id = data.get("id", "").strip() + preset_name = data.get("name", "").strip() + filters = data.get("filters", {}) + + if not preset_id or not preset_name: + return web.json_response( + {"error": "id und name erforderlich"}, status=400 + ) + + if "library" not in config.settings: + config.settings["library"] = {} + if "filter_presets" not in config.settings["library"]: + config.settings["library"]["filter_presets"] = {} + + config.settings["library"]["filter_presets"][preset_id] = { + "name": preset_name, + **filters, + } + config.save_settings() + return web.json_response({"message": f"Preset '{preset_name}' gespeichert"}) + + async def delete_filter_preset(request: web.Request) -> web.Response: + """DELETE /api/library/filter-presets/{preset_id}""" + preset_id = request.match_info["preset_id"] + presets = config.settings.get("library", {}).get("filter_presets", {}) + if preset_id in presets: + del config.settings["library"]["filter_presets"][preset_id] + config.save_settings() + return web.json_response({"message": "Preset geloescht"}) + return web.json_response({"error": "Preset nicht gefunden"}, status=404) + + async def put_default_view(request: web.Request) -> web.Response: + """PUT /api/library/default-view - Standard-Ansicht setzen""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + default_view = data.get("default_view", "all") + if "library" not in config.settings: + config.settings["library"] = {} + config.settings["library"]["default_view"] = default_view + config.save_settings() + return web.json_response({ + "message": f"Standard-Ansicht auf '{default_view}' gesetzt" + }) + # === Routes registrieren === + # Filter-Presets + app.router.add_get("/api/library/filter-presets", get_filter_presets) + app.router.add_put("/api/library/filter-presets", put_filter_presets) + app.router.add_post("/api/library/filter-presets", post_filter_preset) + app.router.add_delete( + "/api/library/filter-presets/{preset_id}", delete_filter_preset + ) + app.router.add_put("/api/library/default-view", put_default_view) # Pfade app.router.add_get("/api/library/paths", get_paths) app.router.add_post("/api/library/paths", post_path) @@ -1424,6 +1535,9 @@ def setup_library_routes(app: web.Application, config: Config, app.router.add_get( "/api/library/series/{series_id}/missing", get_missing_episodes ) + app.router.add_get( + "/api/library/missing-episodes", get_all_missing_episodes + ) # TVDB app.router.add_post( "/api/library/series/{series_id}/tvdb-match", post_tvdb_match diff --git a/app/services/library.py b/app/services/library.py index e0f8cee..64bfdf0 100644 --- a/app/services/library.py +++ b/app/services/library.py @@ -19,8 +19,14 @@ if TYPE_CHECKING: # Regex fuer Serien-Erkennung # S01E02, s01e02, S1E2 RE_SXXEXX = re.compile(r'[Ss](\d{1,2})[Ee](\d{1,3})') +# Doppel-Episoden: S01E01E02, S01E01-E02, S01E01+E02 +RE_SXXEXX_MULTI = re.compile( + r'[Ss](\d{1,2})[Ee](\d{1,3})(?:[-+]?[Ee](\d{1,3}))?' +) # 1x02, 01x02 RE_XXxXX = re.compile(r'(\d{1,2})x(\d{2,3})') +# Doppel-Episoden: 1x01-02, 1x01+02 +RE_XXxXX_MULTI = re.compile(r'(\d{1,2})x(\d{2,3})(?:[-+](\d{2,3}))?') # Staffel/Season Ordner: "Season 01", "Staffel 1", "S01" RE_SEASON_DIR = re.compile( r'^(?:Season|Staffel|S)\s*(\d{1,2})$', re.IGNORECASE @@ -224,6 +230,16 @@ class LibraryService: except Exception: pass # Spalte existiert bereits + # episode_end Spalte fuer Doppel-Episoden (E01E02 in einer Datei) + try: + await cur.execute( + "ALTER TABLE library_videos " + "ADD COLUMN episode_end INT NULL AFTER episode_number" + ) + logging.info("episode_end Spalte hinzugefuegt") + except Exception: + pass # Spalte existiert bereits + logging.info("Bibliotheks-Tabellen initialisiert") except Exception as e: logging.error(f"Bibliotheks-Tabellen erstellen fehlgeschlagen: {e}") @@ -956,7 +972,7 @@ class LibraryService: return False # Serien-Info aus Dateiname/Pfad parsen - season_num, episode_num = self._parse_episode_info( + season_num, episode_num, episode_end, episode_title = self._parse_episode_info( file_path, base_path ) @@ -996,13 +1012,14 @@ class LibraryService: library_path_id, series_id, movie_id, file_path, file_name, file_size, season_number, episode_number, + episode_end, episode_title, video_codec, width, height, resolution, frame_rate, video_bitrate, is_10bit, audio_tracks, subtitle_tracks, container, duration_sec ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) ON DUPLICATE KEY UPDATE file_size = VALUES(file_size), @@ -1010,6 +1027,8 @@ class LibraryService: movie_id = VALUES(movie_id), season_number = VALUES(season_number), episode_number = VALUES(episode_number), + episode_end = VALUES(episode_end), + episode_title = VALUES(episode_title), video_codec = VALUES(video_codec), width = VALUES(width), height = VALUES(height), @@ -1026,6 +1045,7 @@ class LibraryService: path_id, series_id, movie_id, file_path, file_name, file_size, season_num, episode_num, + episode_end, episode_title, video_codec, width, height, resolution, frame_rate, video_bitrate, is_10bit, audio_tracks, subtitle_tracks, @@ -1039,55 +1059,130 @@ class LibraryService: def _parse_episode_info(self, file_path: str, base_path: str) -> tuple[Optional[int], - Optional[int]]: - """Staffel- und Episodennummer aus Pfad/Dateiname extrahieren""" + Optional[int], + Optional[int], + Optional[str]]: + """Staffel-, Episodennummer(n) und Episodentitel aus Pfad/Dateiname extrahieren. + + Gibt zurueck: (season_number, episode_number, episode_end, episode_title) + episode_end ist die End-Episode bei Doppel-Episoden (z.B. E01E02 -> end=2) + """ file_name = os.path.basename(file_path) rel_path = os.path.relpath(file_path, base_path) + name_no_ext = os.path.splitext(file_name)[0] - # 1. S01E02 im Dateinamen - m = RE_SXXEXX.search(file_name) - if m: - return int(m.group(1)), int(m.group(2)) - - # 2. 1x02 im Dateinamen - m = RE_XXxXX.search(file_name) - if m: - return int(m.group(1)), int(m.group(2)) - - # 3. Staffel aus Ordnername + fuehrende Nummer - parts = rel_path.replace("\\", "/").split("/") season_num = None - for part in parts[:-1]: # Ordner durchsuchen - m = RE_SEASON_DIR.match(part) + episode_num = None + episode_end = None + episode_title = None + + # 1. S01E02 oder Doppel-Episode S01E01E02 im Dateinamen + m = RE_SXXEXX_MULTI.search(file_name) + if m: + season_num = int(m.group(1)) + episode_num = int(m.group(2)) + if m.group(3): + episode_end = int(m.group(3)) + # Titel extrahieren: Alles nach "SxxExx(-Exx) - " etc. + episode_title = self._extract_episode_title(name_no_ext, m.end()) + + # 2. 1x02 oder 1x01-02 im Dateinamen + if season_num is None: + m = RE_XXxXX_MULTI.search(file_name) if m: season_num = int(m.group(1)) - break + episode_num = int(m.group(2)) + if m.group(3): + episode_end = int(m.group(3)) + episode_title = self._extract_episode_title(name_no_ext, m.end()) - # Episodennummer aus fuehrender Nummer im Dateinamen - m = RE_LEADING_NUM.match(file_name) - if m and season_num is not None: - return season_num, int(m.group(1)) + # 3. Staffel aus Ordnername + fuehrende Nummer + if season_num is None: + parts = rel_path.replace("\\", "/").split("/") + for part in parts[:-1]: # Ordner durchsuchen + m = RE_SEASON_DIR.match(part) + if m: + season_num = int(m.group(1)) + break - if season_num is not None: - return season_num, None + # Episodennummer aus fuehrender Nummer im Dateinamen + m = RE_LEADING_NUM.match(file_name) + if m and season_num is not None: + episode_num = int(m.group(1)) + # Titel ist der Rest nach der Nummer + episode_title = m.group(2).rsplit(".", 1)[0].strip() - return None, None + return season_num, episode_num, episode_end, episode_title + + def _extract_episode_title(self, name_no_ext: str, + pos_after_episode: int) -> Optional[str]: + """Extrahiert Episodentitel aus Dateinamen nach SxxExx. + + Beispiele: + "Tulsa King - S01E01 - Nach Westen, alter Mann" -> "Nach Westen, alter Mann" + "Serie.S01E02.Titel.der.Episode.720p" -> "Titel der Episode" + "Serie - S01E03" -> None + """ + if pos_after_episode >= len(name_no_ext): + return None + + rest = name_no_ext[pos_after_episode:] + + # Fuehrende Trennzeichen entfernen (-, _, ., Leerzeichen) + rest = rest.lstrip(" -_.") + + if not rest: + return None + + # Qualitaets-/Release-Tags am Ende entfernen + # z.B. "720p", "1080p", "2160p", "x264", "HEVC", "WEB-DL" etc. + quality_pattern = re.compile( + r'[\s._-]*(720p|1080p|2160p|4k|hdtv|webrip|web-dl|bluray|' + r'bdrip|x264|x265|hevc|h264|h265|aac|dts|ac3|' + r'proper|repack|german|english|dubbed|dl|' + r'web|hdr|sdr|10bit|remux).*$', + re.IGNORECASE + ) + rest = quality_pattern.sub('', rest) + + # Punkte/Underscores durch Leerzeichen ersetzen (Scene-Releases) + # Aber nur wenn keine normalen Leerzeichen vorhanden + if ' ' not in rest and ('.' in rest or '_' in rest): + rest = rest.replace('.', ' ').replace('_', ' ') + + # Mehrfach-Leerzeichen und Trailing entfernen + rest = re.sub(r'\s+', ' ', rest).strip(' -_.') + + return rest if rest else None async def _update_series_counts(self, series_id: int) -> None: - """Aktualisiert die lokalen Episoden-Zaehler einer Serie""" + """Aktualisiert die lokalen Episoden-Zaehler einer Serie. + + Beruecksichtigt Doppel-Episoden (episode_end): + Eine Datei mit E01E02 zaehlt als 2 Episoden. + """ pool = await self._get_pool() if not pool: return try: async with pool.acquire() as conn: async with conn.cursor() as cur: - await cur.execute( - "SELECT COUNT(*) FROM library_videos " - "WHERE series_id = %s AND episode_number IS NOT NULL", - (series_id,) - ) + # Anzahl der abgedeckten Episoden berechnen + # Einzel-Episode: episode_end IS NULL -> zaehlt als 1 + # Doppel-Episode: episode_end - episode_number + 1 + await cur.execute(""" + SELECT SUM( + CASE + WHEN episode_end IS NOT NULL + THEN episode_end - episode_number + 1 + ELSE 1 + END + ) as episode_count + FROM library_videos + WHERE series_id = %s AND episode_number IS NOT NULL + """, (series_id,)) row = await cur.fetchone() - local_count = row[0] if row else 0 + local_count = row[0] if row and row[0] else 0 await cur.execute( "UPDATE library_series SET local_episodes = %s, " @@ -1165,6 +1260,25 @@ class LibraryService: where_clauses.append("v.file_name LIKE %s") params.append(f"%{filters['search']}%") + # Filter: Nicht im Zielformat (Container + Codec) + if filters.get("not_converted"): + target_container = self.config.target_container # z.B. "webm" + # Videos die NICHT im Zielformat sind + where_clauses.append( + "(v.container != %s OR v.video_codec NOT IN ('av1', 'hevc'))" + ) + params.append(target_container) + + # Filter: Nur bestimmter Container NICHT + if filters.get("exclude_container"): + where_clauses.append("v.container != %s") + params.append(filters["exclude_container"]) + + # Filter: Nur bestimmter Codec NICHT + if filters.get("exclude_codec"): + where_clauses.append("v.video_codec != %s") + params.append(filters["exclude_codec"]) + where_sql = "" if where_clauses: where_sql = "WHERE " + " AND ".join(where_clauses) @@ -1352,6 +1466,8 @@ class LibraryService: tvdb_id = row["tvdb_id"] # Fehlende = TVDB-Episoden die nicht lokal vorhanden sind + # Beruecksichtigt Doppel-Episoden (episode_end): + # E01E02 deckt sowohl E01 als auch E02 ab await cur.execute(""" SELECT tc.season_number, tc.episode_number, tc.episode_name, tc.aired @@ -1361,7 +1477,14 @@ class LibraryService: SELECT 1 FROM library_videos lv WHERE lv.series_id = %s AND lv.season_number = tc.season_number - AND lv.episode_number = tc.episode_number + AND ( + lv.episode_number = tc.episode_number + OR ( + lv.episode_end IS NOT NULL + AND tc.episode_number >= lv.episode_number + AND tc.episode_number <= lv.episode_end + ) + ) ) AND tc.season_number > 0 ORDER BY tc.season_number, tc.episode_number @@ -1372,6 +1495,99 @@ class LibraryService: logging.error(f"Fehlende Episoden laden fehlgeschlagen: {e}") return [] + async def get_all_missing_episodes(self, path_id: int = None, + page: int = 1, + limit: int = 50) -> dict: + """Alle fehlenden Episoden aller Serien laden (fuer Filter-Ansicht). + + Gibt virtuelle Eintraege zurueck mit: + - series_id, series_title, poster_url + - season_number, episode_number, episode_name, aired + - is_missing = True (Marker fuer Frontend) + """ + pool = await self._get_pool() + if not pool: + return {"items": [], "total": 0, "page": page} + + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Basis-Query: Alle fehlenden Episoden mit Serien-Info + path_filter = "" + params = [] + if path_id: + path_filter = "AND ls.library_path_id = %s" + params.append(path_id) + + # Count-Query + await cur.execute(f""" + SELECT COUNT(*) as cnt + FROM tvdb_episode_cache tc + JOIN library_series ls ON tc.series_tvdb_id = ls.tvdb_id + WHERE tc.season_number > 0 + {path_filter} + AND NOT EXISTS ( + SELECT 1 FROM library_videos lv + WHERE lv.series_id = ls.id + AND lv.season_number = tc.season_number + AND ( + lv.episode_number = tc.episode_number + OR ( + lv.episode_end IS NOT NULL + AND tc.episode_number >= lv.episode_number + AND tc.episode_number <= lv.episode_end + ) + ) + ) + """, params) + total = (await cur.fetchone())["cnt"] + + # Daten-Query mit Pagination + offset = (page - 1) * limit + await cur.execute(f""" + SELECT + ls.id as series_id, + ls.title as series_title, + ls.poster_url, + ls.folder_path, + tc.season_number, + tc.episode_number, + tc.episode_name, + tc.aired, + 1 as is_missing + FROM tvdb_episode_cache tc + JOIN library_series ls ON tc.series_tvdb_id = ls.tvdb_id + WHERE tc.season_number > 0 + {path_filter} + AND NOT EXISTS ( + SELECT 1 FROM library_videos lv + WHERE lv.series_id = ls.id + AND lv.season_number = tc.season_number + AND ( + lv.episode_number = tc.episode_number + OR ( + lv.episode_end IS NOT NULL + AND tc.episode_number >= lv.episode_number + AND tc.episode_number <= lv.episode_end + ) + ) + ) + ORDER BY ls.title, tc.season_number, tc.episode_number + LIMIT %s OFFSET %s + """, params + [limit, offset]) + rows = await cur.fetchall() + + items = [self._serialize_row(r) for r in rows] + return { + "items": items, + "total": total, + "page": page, + "pages": (total + limit - 1) // limit if limit else 1, + } + except Exception as e: + logging.error(f"Fehlende Episoden (alle) laden fehlgeschlagen: {e}") + return {"items": [], "total": 0, "page": page} + async def update_series_tvdb(self, series_id: int, tvdb_id: int) -> bool: """TVDB-ID einer Serie zuordnen""" diff --git a/app/static/css/style.css b/app/static/css/style.css index a546464..a303141 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -719,6 +719,99 @@ legend { margin-right: 0.3rem; } +/* Filter Presets */ +.filter-presets select { + margin-bottom: 0.4rem; +} +.preset-actions { + display: flex; + gap: 0.3rem; + margin-top: 0.3rem; +} +.preset-actions button { + flex: 1; + font-size: 0.65rem; + padding: 0.2rem 0.3rem; +} +.btn-block { + width: 100%; +} + +/* Fehlende Episoden Ansicht */ +.missing-episodes-view { + padding: 1rem; +} +.missing-episodes-view h3 { + margin-bottom: 1rem; + color: #f44336; +} +.missing-series-block { + background: #1e1e1e; + border-radius: 8px; + margin-bottom: 1rem; + overflow: hidden; +} +.missing-series-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.8rem 1rem; + background: #252525; + border-bottom: 1px solid #333; +} +.missing-series-header h4 { + flex: 1; + margin: 0; + font-size: 1rem; +} +.missing-poster { + width: 40px; + height: 60px; + object-fit: cover; + border-radius: 4px; +} +.missing-count { + background: #f44336; + color: #fff; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: bold; +} +.missing-episodes-list { + padding: 0.5rem 1rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.4rem; +} +.missing-episode { + display: flex; + gap: 0.5rem; + padding: 0.3rem 0.5rem; + background: #2a2a2a; + border-radius: 4px; + font-size: 0.8rem; +} +.missing-episode .ep-num { + color: #f44336; + font-weight: bold; + min-width: 60px; +} +.missing-episode .ep-name { + color: #ccc; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.missing-pagination { + text-align: center; + padding: 1rem; + color: #888; +} +.missing-pagination button { + margin: 0 0.5rem; +} + /* Tabs */ .library-tabs { display: flex; diff --git a/app/static/js/library.js b/app/static/js/library.js index 0f37c32..11f761d 100644 --- a/app/static/js/library.js +++ b/app/static/js/library.js @@ -17,6 +17,7 @@ let cleanData = []; document.addEventListener("DOMContentLoaded", function () { loadStats(); + loadFilterPresets(); loadLibraryPaths(); }); @@ -979,6 +980,9 @@ function debounceMovieTvdbSearch() { // === Filter === +let filterPresets = {}; +let defaultView = "all"; + function buildFilterParams() { const params = new URLSearchParams(); const search = document.getElementById("filter-search").value.trim(); @@ -992,6 +996,7 @@ function buildFilterParams() { const audioCh = document.getElementById("filter-audio-ch").value; if (audioCh) params.set("audio_channels", audioCh); if (document.getElementById("filter-10bit").checked) params.set("is_10bit", "1"); + if (document.getElementById("filter-not-converted").checked) params.set("not_converted", "1"); const resolution = document.getElementById("filter-resolution").value; if (resolution) params.set("min_width", resolution); const sort = document.getElementById("filter-sort").value; @@ -1002,6 +1007,8 @@ function buildFilterParams() { } function applyFilters() { + // Preset-Auswahl zuruecksetzen wenn manuell gefiltert wird + document.getElementById("filter-preset").value = ""; for (const pid of Object.keys(sectionStates)) { sectionStates[pid].page = 1; loadSectionData(parseInt(pid)); @@ -1013,6 +1020,243 @@ function debounceFilter() { filterTimeout = setTimeout(applyFilters, 400); } +// Filter-Presets laden +async function loadFilterPresets() { + try { + const resp = await fetch("/api/library/filter-presets"); + const data = await resp.json(); + filterPresets = data.presets || {}; + defaultView = data.default_view || "all"; + + // Preset-Dropdown befuellen + const select = document.getElementById("filter-preset"); + // Bestehende Custom-Presets entfernen (4 feste Optionen behalten: Alle, Nicht konvertiert, Alte Formate, Fehlende Episoden) + while (select.options.length > 4) { + select.remove(4); + } + // Gespeicherte Presets hinzufuegen + for (const [id, preset] of Object.entries(filterPresets)) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = preset.name || id; + select.appendChild(opt); + } + + // Standard-Ansicht anwenden + if (defaultView && defaultView !== "all") { + applyPreset(defaultView); + } + } catch (e) { + console.error("Filter-Presets laden fehlgeschlagen:", e); + } +} + +// Preset anwenden +let showMissingMode = false; + +function applyPreset(presetId) { + const id = presetId || document.getElementById("filter-preset").value; + + // Alle Filter zuruecksetzen + resetFiltersQuiet(); + showMissingMode = false; + + if (!id) { + // "Alle anzeigen" + applyFilters(); + return; + } + + // Eingebaute Presets + if (id === "not_converted") { + document.getElementById("filter-not-converted").checked = true; + } else if (id === "old_formats") { + // Alte Formate = alles ausser AV1 + document.getElementById("filter-codec").value = ""; + document.getElementById("filter-not-converted").checked = true; + } else if (id === "missing_episodes") { + // Fehlende Episoden - spezieller Modus + showMissingMode = true; + loadMissingEpisodes(); + document.getElementById("filter-preset").value = id; + return; + } else if (filterPresets[id]) { + // Custom Preset + const p = filterPresets[id]; + if (p.video_codec) document.getElementById("filter-codec").value = p.video_codec; + if (p.container) document.getElementById("filter-container").value = p.container; + if (p.min_width) document.getElementById("filter-resolution").value = p.min_width; + if (p.audio_lang) document.getElementById("filter-audio-lang").value = p.audio_lang; + if (p.is_10bit) document.getElementById("filter-10bit").checked = true; + if (p.not_converted) document.getElementById("filter-not-converted").checked = true; + if (p.show_missing) { + showMissingMode = true; + loadMissingEpisodes(); + document.getElementById("filter-preset").value = id; + return; + } + } + + document.getElementById("filter-preset").value = id; + + for (const pid of Object.keys(sectionStates)) { + sectionStates[pid].page = 1; + loadSectionData(parseInt(pid)); + } +} + +// Fehlende Episoden laden und anzeigen +let missingPage = 1; +async function loadMissingEpisodes(page = 1) { + missingPage = page; + const container = document.getElementById("library-content"); + container.innerHTML = '
Lade fehlende Episoden...
'; + + try { + const resp = await fetch(`/api/library/missing-episodes?page=${page}&limit=50`); + const data = await resp.json(); + + if (!data.items || data.items.length === 0) { + container.innerHTML = '
Keine fehlenden Episoden gefunden. Alle Serien sind vollstaendig!
'; + return; + } + + // Gruppiere nach Serie + const bySeries = {}; + for (const ep of data.items) { + const key = ep.series_id; + if (!bySeries[key]) { + bySeries[key] = { + series_id: ep.series_id, + series_title: ep.series_title, + poster_url: ep.poster_url, + episodes: [] + }; + } + bySeries[key].episodes.push(ep); + } + + let html = `
+

Fehlende Episoden (${data.total} insgesamt)

`; + + for (const series of Object.values(bySeries)) { + html += `
+
+ ${series.poster_url ? `` : ''} +

${series.series_title}

+ ${series.episodes.length} fehlend +
+
`; + + for (const ep of series.episodes) { + const aired = ep.aired ? ` (${ep.aired})` : ''; + html += `
+ S${String(ep.season_number).padStart(2,'0')}E${String(ep.episode_number).padStart(2,'0')} + ${ep.episode_name || 'Unbekannt'}${aired} +
`; + } + + html += `
`; + } + + // Pagination + if (data.pages > 1) { + html += '
'; + if (page > 1) { + html += ``; + } + html += ` Seite ${page} von ${data.pages} `; + if (page < data.pages) { + html += ``; + } + html += '
'; + } + + html += '
'; + container.innerHTML = html; + + } catch (e) { + container.innerHTML = `
Fehler: ${e}
`; + } +} + +// Filter zuruecksetzen (ohne Reload) +function resetFiltersQuiet() { + document.getElementById("filter-search").value = ""; + document.getElementById("filter-codec").value = ""; + document.getElementById("filter-container").value = ""; + document.getElementById("filter-resolution").value = ""; + document.getElementById("filter-audio-lang").value = ""; + document.getElementById("filter-audio-ch").value = ""; + document.getElementById("filter-10bit").checked = false; + document.getElementById("filter-not-converted").checked = false; + document.getElementById("filter-sort").value = "file_name"; + document.getElementById("filter-order").value = "asc"; +} + +// Filter zuruecksetzen (mit Reload) +function resetFilters() { + resetFiltersQuiet(); + document.getElementById("filter-preset").value = ""; + applyFilters(); +} + +// Aktuellen Filter als Preset speichern +async function saveCurrentFilter() { + const name = prompt("Name fuer diesen Filter:"); + if (!name) return; + + const id = name.toLowerCase().replace(/[^a-z0-9]/g, "_"); + const filters = {}; + + const codec = document.getElementById("filter-codec").value; + if (codec) filters.video_codec = codec; + const container = document.getElementById("filter-container").value; + if (container) filters.container = container; + const resolution = document.getElementById("filter-resolution").value; + if (resolution) filters.min_width = resolution; + const audioLang = document.getElementById("filter-audio-lang").value; + if (audioLang) filters.audio_lang = audioLang; + if (document.getElementById("filter-10bit").checked) filters.is_10bit = true; + if (document.getElementById("filter-not-converted").checked) filters.not_converted = true; + + try { + const resp = await fetch("/api/library/filter-presets", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({id, name, filters}), + }); + if (resp.ok) { + showToast(`Filter "${name}" gespeichert`, "success"); + loadFilterPresets(); + } else { + showToast("Fehler beim Speichern", "error"); + } + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +// Aktuellen Filter als Standard setzen +async function setAsDefault() { + const presetId = document.getElementById("filter-preset").value || "all"; + try { + const resp = await fetch("/api/library/default-view", { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({default_view: presetId}), + }); + if (resp.ok) { + defaultView = presetId; + showToast("Standard-Ansicht gespeichert", "success"); + } else { + showToast("Fehler beim Speichern", "error"); + } + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + // === Scan === function startScan() { diff --git a/app/templates/library.html b/app/templates/library.html index 5b6dfaf..1089b45 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -53,6 +53,21 @@