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:
parent
178f11872e
commit
7ba24a097a
7 changed files with 760 additions and 37 deletions
18
README.md
18
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,24 +1059,46 @@ 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))
|
||||
season_num = None
|
||||
episode_num = None
|
||||
episode_end = None
|
||||
episode_title = None
|
||||
|
||||
# 2. 1x02 im Dateinamen
|
||||
m = RE_XXxXX.search(file_name)
|
||||
# 1. S01E02 oder Doppel-Episode S01E01E02 im Dateinamen
|
||||
m = RE_SXXEXX_MULTI.search(file_name)
|
||||
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
|
||||
if season_num is None:
|
||||
parts = rel_path.replace("\\", "/").split("/")
|
||||
season_num = None
|
||||
for part in parts[:-1]: # Ordner durchsuchen
|
||||
m = RE_SEASON_DIR.match(part)
|
||||
if m:
|
||||
|
|
@ -1066,28 +1108,81 @@ class LibraryService:
|
|||
# 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))
|
||||
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, None
|
||||
return season_num, episode_num, episode_end, episode_title
|
||||
|
||||
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:
|
||||
"""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"""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '<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 ===
|
||||
|
||||
function startScan() {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,21 @@
|
|||
<aside class="library-filters" id="filters">
|
||||
<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">
|
||||
<label>Suche</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label><input type="checkbox" id="filter-not-converted" onchange="applyFilters()"> Nicht konvertiert</label>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Sortierung</label>
|
||||
<select id="filter-sort" onchange="applyFilters()">
|
||||
|
|
@ -132,6 +151,10 @@
|
|||
<option value="desc">Absteigend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="btn-secondary btn-block" onclick="resetFilters()">Filter zuruecksetzen</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Hauptbereich: Dynamische Bereiche pro Library-Pfad -->
|
||||
|
|
|
|||
Loading…
Reference in a new issue