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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-25 11:03:39 +01:00
parent 178f11872e
commit 7ba24a097a
7 changed files with 760 additions and 37 deletions

View file

@ -22,11 +22,16 @@ Web-basierter Video-Konverter mit GPU-Beschleunigung (Intel VAAPI), Video-Biblio
### Video-Bibliothek ### Video-Bibliothek
- **Ordner-Scan**: Konfigurierbare Scan-Pfade fuer Serien und Filme - **Ordner-Scan**: Konfigurierbare Scan-Pfade fuer Serien und Filme
- **Serien-Erkennung**: Automatisch via Ordnerstruktur (`S01E01`, `1x02`, `Staffel/Season XX`) - **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 - **Film-Erkennung**: Ein Video pro Ordner = Film, Ordnername als Filmtitel
- **ffprobe-Analyse**: Codec, Aufloesung, Bitrate, Audio-Spuren, Untertitel, HDR/10-Bit - **ffprobe-Analyse**: Codec, Aufloesung, Bitrate, Audio-Spuren, Untertitel, HDR/10-Bit
- **TVDB-Integration**: Serien + Filme, Poster, Episoden-Titel, fehlende Episoden - **TVDB-Integration**: Serien + Filme, Poster, Episoden-Titel, fehlende Episoden
- **TVDB Review-Modal**: Vorschlaege pruefen statt blindem Auto-Match, manuelle Suche - **TVDB Review-Modal**: Vorschlaege pruefen statt blindem Auto-Match, manuelle Suche
- **TVDB-Sprachkonfiguration**: Metadaten in Deutsch, Englisch oder anderen Sprachen - **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 - **Filter**: Video-Codec, Aufloesung, Container, Audio-Sprache, Kanaele, 10-Bit
- **Duplikat-Finder**: Gleiche Episode in verschiedenen Formaten erkennen - **Duplikat-Finder**: Gleiche Episode in verschiedenen Formaten erkennen
- **Import-Service**: Videos einsortieren mit Serien-Erkennung und TVDB-Lookup - **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/videos` | Videos filtern (siehe Filter-Params) |
| GET | `/api/library/series` | Alle Serien | | GET | `/api/library/series` | Alle Serien |
| GET | `/api/library/series/{id}` | Serie mit Episoden | | 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}/tvdb-match` | TVDB-ID zuordnen |
| POST | `/api/library/series/{id}/convert` | Alle Episoden konvertieren | | POST | `/api/library/series/{id}/convert` | Alle Episoden konvertieren |
| GET | `/api/library/series/{id}/convert-status` | Codec-Status der Serie | | 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_lang=ger # Audio-Sprache
&audio_channels=6 # Kanal-Anzahl (2=Stereo, 6=5.1) &audio_channels=6 # Kanal-Anzahl (2=Stereo, 6=5.1)
&is_10bit=1 # Nur 10-Bit &is_10bit=1 # Nur 10-Bit
&not_converted=1 # Nicht im Zielformat (Container + Codec)
&exclude_codec=av1 # Codec ausschliessen
&exclude_container=webm # Container ausschliessen
&search=breaking # Dateiname-Suche &search=breaking # Dateiname-Suche
&sort=file_size # Sortierung &sort=file_size # Sortierung
&order=desc # asc | desc &order=desc # asc | desc
@ -319,7 +333,7 @@ Jedes Video mit vollstaendigen ffprobe-Metadaten:
- Video: Codec, Aufloesung, Framerate, Bitrate, 10-Bit, HDR - Video: Codec, Aufloesung, Framerate, Bitrate, 10-Bit, HDR
- Audio: JSON-Array mit Spuren (`[{"codec":"eac3","lang":"ger","channels":6,"bitrate":256000}]`) - Audio: JSON-Array mit Spuren (`[{"codec":"eac3","lang":"ger","channels":6,"bitrate":256000}]`)
- Untertitel: JSON-Array (`[{"codec":"subrip","lang":"ger"}]`) - 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 ### tvdb_episode_cache
Zwischenspeicher fuer TVDB-Episodendaten (Serie, Staffel, Episode, Name, Ausstrahlung). Zwischenspeicher fuer TVDB-Episodendaten (Serie, Staffel, Episode, Name, Ausstrahlung).

View file

@ -64,6 +64,25 @@ library:
tvdb_api_key: 5db8defd-41cd-4e0d-a637-ac0a96cbedd9 tvdb_api_key: 5db8defd-41cd-4e0d-a637-ac0a96cbedd9
tvdb_language: deu tvdb_language: deu
tvdb_pin: '' 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: logging:
backup_count: 7 backup_count: 7
file: server.log file: server.log

View file

@ -119,7 +119,8 @@ def setup_library_routes(app: web.Application, config: Config,
"video_codec", "min_width", "max_width", "video_codec", "min_width", "max_width",
"container", "audio_lang", "audio_channels", "container", "audio_lang", "audio_channels",
"has_subtitle", "is_10bit", "sort", "order", "has_subtitle", "is_10bit", "sort", "order",
"search"): "search", "not_converted", "exclude_container",
"exclude_codec"):
val = request.query.get(key) val = request.query.get(key)
if val: if val:
filters[key] = 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) missing = await library_service.get_missing_episodes(series_id)
return web.json_response({"missing": missing}) 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 === # === TVDB ===
async def post_tvdb_match(request: web.Request) -> web.Response: 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 {"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 === # === 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 # Pfade
app.router.add_get("/api/library/paths", get_paths) app.router.add_get("/api/library/paths", get_paths)
app.router.add_post("/api/library/paths", post_path) 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( app.router.add_get(
"/api/library/series/{series_id}/missing", get_missing_episodes "/api/library/series/{series_id}/missing", get_missing_episodes
) )
app.router.add_get(
"/api/library/missing-episodes", get_all_missing_episodes
)
# TVDB # TVDB
app.router.add_post( app.router.add_post(
"/api/library/series/{series_id}/tvdb-match", post_tvdb_match "/api/library/series/{series_id}/tvdb-match", post_tvdb_match

View file

@ -19,8 +19,14 @@ if TYPE_CHECKING:
# Regex fuer Serien-Erkennung # Regex fuer Serien-Erkennung
# S01E02, s01e02, S1E2 # S01E02, s01e02, S1E2
RE_SXXEXX = re.compile(r'[Ss](\d{1,2})[Ee](\d{1,3})') 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 # 1x02, 01x02
RE_XXxXX = re.compile(r'(\d{1,2})x(\d{2,3})') 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" # Staffel/Season Ordner: "Season 01", "Staffel 1", "S01"
RE_SEASON_DIR = re.compile( RE_SEASON_DIR = re.compile(
r'^(?:Season|Staffel|S)\s*(\d{1,2})$', re.IGNORECASE r'^(?:Season|Staffel|S)\s*(\d{1,2})$', re.IGNORECASE
@ -224,6 +230,16 @@ class LibraryService:
except Exception: except Exception:
pass # Spalte existiert bereits 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") logging.info("Bibliotheks-Tabellen initialisiert")
except Exception as e: except Exception as e:
logging.error(f"Bibliotheks-Tabellen erstellen fehlgeschlagen: {e}") logging.error(f"Bibliotheks-Tabellen erstellen fehlgeschlagen: {e}")
@ -956,7 +972,7 @@ class LibraryService:
return False return False
# Serien-Info aus Dateiname/Pfad parsen # 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 file_path, base_path
) )
@ -996,13 +1012,14 @@ class LibraryService:
library_path_id, series_id, movie_id, library_path_id, series_id, movie_id,
file_path, file_name, file_path, file_name,
file_size, season_number, episode_number, file_size, season_number, episode_number,
episode_end, episode_title,
video_codec, width, height, resolution, video_codec, width, height, resolution,
frame_rate, video_bitrate, is_10bit, frame_rate, video_bitrate, is_10bit,
audio_tracks, subtitle_tracks, audio_tracks, subtitle_tracks,
container, duration_sec container, duration_sec
) VALUES ( ) 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, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
) )
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
file_size = VALUES(file_size), file_size = VALUES(file_size),
@ -1010,6 +1027,8 @@ class LibraryService:
movie_id = VALUES(movie_id), movie_id = VALUES(movie_id),
season_number = VALUES(season_number), season_number = VALUES(season_number),
episode_number = VALUES(episode_number), episode_number = VALUES(episode_number),
episode_end = VALUES(episode_end),
episode_title = VALUES(episode_title),
video_codec = VALUES(video_codec), video_codec = VALUES(video_codec),
width = VALUES(width), width = VALUES(width),
height = VALUES(height), height = VALUES(height),
@ -1026,6 +1045,7 @@ class LibraryService:
path_id, series_id, movie_id, path_id, series_id, movie_id,
file_path, file_name, file_path, file_name,
file_size, season_num, episode_num, file_size, season_num, episode_num,
episode_end, episode_title,
video_codec, width, height, resolution, video_codec, width, height, resolution,
frame_rate, video_bitrate, is_10bit, frame_rate, video_bitrate, is_10bit,
audio_tracks, subtitle_tracks, audio_tracks, subtitle_tracks,
@ -1039,24 +1059,46 @@ class LibraryService:
def _parse_episode_info(self, file_path: str, def _parse_episode_info(self, file_path: str,
base_path: str) -> tuple[Optional[int], base_path: str) -> tuple[Optional[int],
Optional[int]]: Optional[int],
"""Staffel- und Episodennummer aus Pfad/Dateiname extrahieren""" 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) file_name = os.path.basename(file_path)
rel_path = os.path.relpath(file_path, base_path) rel_path = os.path.relpath(file_path, base_path)
name_no_ext = os.path.splitext(file_name)[0]
# 1. S01E02 im Dateinamen season_num = None
m = RE_SXXEXX.search(file_name) episode_num = None
if m: episode_end = None
return int(m.group(1)), int(m.group(2)) episode_title = None
# 2. 1x02 im Dateinamen # 1. S01E02 oder Doppel-Episode S01E01E02 im Dateinamen
m = RE_XXxXX.search(file_name) m = RE_SXXEXX_MULTI.search(file_name)
if m: if m:
return int(m.group(1)), int(m.group(2)) 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))
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())
# 3. Staffel aus Ordnername + fuehrende Nummer # 3. Staffel aus Ordnername + fuehrende Nummer
if season_num is None:
parts = rel_path.replace("\\", "/").split("/") parts = rel_path.replace("\\", "/").split("/")
season_num = None
for part in parts[:-1]: # Ordner durchsuchen for part in parts[:-1]: # Ordner durchsuchen
m = RE_SEASON_DIR.match(part) m = RE_SEASON_DIR.match(part)
if m: if m:
@ -1066,28 +1108,81 @@ class LibraryService:
# Episodennummer aus fuehrender Nummer im Dateinamen # Episodennummer aus fuehrender Nummer im Dateinamen
m = RE_LEADING_NUM.match(file_name) m = RE_LEADING_NUM.match(file_name)
if m and season_num is not None: if m and season_num is not None:
return season_num, int(m.group(1)) episode_num = int(m.group(1))
# Titel ist der Rest nach der Nummer
episode_title = m.group(2).rsplit(".", 1)[0].strip()
if season_num is not None: return season_num, episode_num, episode_end, episode_title
return season_num, None
return None, None 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: 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() pool = await self._get_pool()
if not pool: if not pool:
return return
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor() as cur:
await cur.execute( # Anzahl der abgedeckten Episoden berechnen
"SELECT COUNT(*) FROM library_videos " # Einzel-Episode: episode_end IS NULL -> zaehlt als 1
"WHERE series_id = %s AND episode_number IS NOT NULL", # Doppel-Episode: episode_end - episode_number + 1
(series_id,) 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() 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( await cur.execute(
"UPDATE library_series SET local_episodes = %s, " "UPDATE library_series SET local_episodes = %s, "
@ -1165,6 +1260,25 @@ class LibraryService:
where_clauses.append("v.file_name LIKE %s") where_clauses.append("v.file_name LIKE %s")
params.append(f"%{filters['search']}%") 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 = "" where_sql = ""
if where_clauses: if where_clauses:
where_sql = "WHERE " + " AND ".join(where_clauses) where_sql = "WHERE " + " AND ".join(where_clauses)
@ -1352,6 +1466,8 @@ class LibraryService:
tvdb_id = row["tvdb_id"] tvdb_id = row["tvdb_id"]
# Fehlende = TVDB-Episoden die nicht lokal vorhanden sind # Fehlende = TVDB-Episoden die nicht lokal vorhanden sind
# Beruecksichtigt Doppel-Episoden (episode_end):
# E01E02 deckt sowohl E01 als auch E02 ab
await cur.execute(""" await cur.execute("""
SELECT tc.season_number, tc.episode_number, SELECT tc.season_number, tc.episode_number,
tc.episode_name, tc.aired tc.episode_name, tc.aired
@ -1361,7 +1477,14 @@ class LibraryService:
SELECT 1 FROM library_videos lv SELECT 1 FROM library_videos lv
WHERE lv.series_id = %s WHERE lv.series_id = %s
AND lv.season_number = tc.season_number 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 AND tc.season_number > 0
ORDER BY tc.season_number, tc.episode_number ORDER BY tc.season_number, tc.episode_number
@ -1372,6 +1495,99 @@ class LibraryService:
logging.error(f"Fehlende Episoden laden fehlgeschlagen: {e}") logging.error(f"Fehlende Episoden laden fehlgeschlagen: {e}")
return [] 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, async def update_series_tvdb(self, series_id: int,
tvdb_id: int) -> bool: tvdb_id: int) -> bool:
"""TVDB-ID einer Serie zuordnen""" """TVDB-ID einer Serie zuordnen"""

View file

@ -719,6 +719,99 @@ legend {
margin-right: 0.3rem; 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 */ /* Tabs */
.library-tabs { .library-tabs {
display: flex; display: flex;

View file

@ -17,6 +17,7 @@ let cleanData = [];
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
loadStats(); loadStats();
loadFilterPresets();
loadLibraryPaths(); loadLibraryPaths();
}); });
@ -979,6 +980,9 @@ function debounceMovieTvdbSearch() {
// === Filter === // === Filter ===
let filterPresets = {};
let defaultView = "all";
function buildFilterParams() { function buildFilterParams() {
const params = new URLSearchParams(); const params = new URLSearchParams();
const search = document.getElementById("filter-search").value.trim(); const search = document.getElementById("filter-search").value.trim();
@ -992,6 +996,7 @@ function buildFilterParams() {
const audioCh = document.getElementById("filter-audio-ch").value; const audioCh = document.getElementById("filter-audio-ch").value;
if (audioCh) params.set("audio_channels", audioCh); if (audioCh) params.set("audio_channels", audioCh);
if (document.getElementById("filter-10bit").checked) params.set("is_10bit", "1"); 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; const resolution = document.getElementById("filter-resolution").value;
if (resolution) params.set("min_width", resolution); if (resolution) params.set("min_width", resolution);
const sort = document.getElementById("filter-sort").value; const sort = document.getElementById("filter-sort").value;
@ -1002,6 +1007,8 @@ function buildFilterParams() {
} }
function applyFilters() { function applyFilters() {
// Preset-Auswahl zuruecksetzen wenn manuell gefiltert wird
document.getElementById("filter-preset").value = "";
for (const pid of Object.keys(sectionStates)) { for (const pid of Object.keys(sectionStates)) {
sectionStates[pid].page = 1; sectionStates[pid].page = 1;
loadSectionData(parseInt(pid)); loadSectionData(parseInt(pid));
@ -1013,6 +1020,243 @@ function debounceFilter() {
filterTimeout = setTimeout(applyFilters, 400); 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 = '<div class="loading-msg">Lade fehlende Episoden...</div>';
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 = '<div class="loading-msg">Keine fehlenden Episoden gefunden. Alle Serien sind vollstaendig!</div>';
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 = `<div class="missing-episodes-view">
<h3>Fehlende Episoden (${data.total} insgesamt)</h3>`;
for (const series of Object.values(bySeries)) {
html += `<div class="missing-series-block">
<div class="missing-series-header">
${series.poster_url ? `<img src="${series.poster_url}" class="missing-poster" alt="">` : ''}
<h4>${series.series_title}</h4>
<span class="missing-count">${series.episodes.length} fehlend</span>
</div>
<div class="missing-episodes-list">`;
for (const ep of series.episodes) {
const aired = ep.aired ? ` (${ep.aired})` : '';
html += `<div class="missing-episode">
<span class="ep-num">S${String(ep.season_number).padStart(2,'0')}E${String(ep.episode_number).padStart(2,'0')}</span>
<span class="ep-name">${ep.episode_name || 'Unbekannt'}${aired}</span>
</div>`;
}
html += `</div></div>`;
}
// Pagination
if (data.pages > 1) {
html += '<div class="missing-pagination">';
if (page > 1) {
html += `<button class="btn-small" onclick="loadMissingEpisodes(${page - 1})">Zurueck</button>`;
}
html += ` Seite ${page} von ${data.pages} `;
if (page < data.pages) {
html += `<button class="btn-small" onclick="loadMissingEpisodes(${page + 1})">Weiter</button>`;
}
html += '</div>';
}
html += '</div>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`;
}
}
// 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 === // === Scan ===
function startScan() { function startScan() {

View file

@ -53,6 +53,21 @@
<aside class="library-filters" id="filters"> <aside class="library-filters" id="filters">
<h3>Filter</h3> <h3>Filter</h3>
<!-- Schnellfilter / Presets -->
<div class="filter-group filter-presets">
<label>Schnellfilter</label>
<select id="filter-preset" onchange="applyPreset()">
<option value="">-- Alle anzeigen --</option>
<option value="not_converted">Nicht konvertiert</option>
<option value="old_formats">Alte Formate (kein AV1)</option>
<option value="missing_episodes">Fehlende Episoden</option>
</select>
<div class="preset-actions">
<button class="btn-small" onclick="saveCurrentFilter()" title="Aktuellen Filter speichern">Speichern</button>
<button class="btn-small" onclick="setAsDefault()" title="Als Standard setzen">Standard</button>
</div>
</div>
<div class="filter-group"> <div class="filter-group">
<label>Suche</label> <label>Suche</label>
<input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()"> <input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()">
@ -117,6 +132,10 @@
<label><input type="checkbox" id="filter-10bit" onchange="applyFilters()"> Nur 10-Bit</label> <label><input type="checkbox" id="filter-10bit" onchange="applyFilters()"> Nur 10-Bit</label>
</div> </div>
<div class="filter-group">
<label><input type="checkbox" id="filter-not-converted" onchange="applyFilters()"> Nicht konvertiert</label>
</div>
<div class="filter-group"> <div class="filter-group">
<label>Sortierung</label> <label>Sortierung</label>
<select id="filter-sort" onchange="applyFilters()"> <select id="filter-sort" onchange="applyFilters()">
@ -132,6 +151,10 @@
<option value="desc">Absteigend</option> <option value="desc">Absteigend</option>
</select> </select>
</div> </div>
<div class="filter-group">
<button class="btn-secondary btn-block" onclick="resetFilters()">Filter zuruecksetzen</button>
</div>
</aside> </aside>
<!-- Hauptbereich: Dynamische Bereiche pro Library-Pfad --> <!-- Hauptbereich: Dynamische Bereiche pro Library-Pfad -->