Compare commits

...

2 commits

Author SHA1 Message Date
ff04bb2e9e Startseite auf Bibliothek geaendert
/ leitet jetzt auf /library weiter, Dashboard unter /dashboard erreichbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:43:03 +01:00
ea5a81cd17 v2.4.0: Video-Player, Import-Zuordnung, Loeschen, Audio-Fix
- Video-Player mit ffmpeg-Transcoding (EAC3/DTS/AC3 -> AAC)
- Play-Buttons in allen Ansichten (Serien, Filme, Ordner)
- Delete-Buttons fuer einzelne Videos (DB + Datei)
- Import: Nicht-erkannte Dateien per Modal zuordnen/ueberspringen
- Import: Start blockiert wenn ungeloeste Items vorhanden
- Audio channelmap Fix: 5.1(side) -> 5.1 fuer libopus
- ENV-Variablen: VK_* Prefix (VK_DB_HOST, VK_MODE etc.)
- WebSocket: Server-Log Push statt HTTP-Polling
- Ordner-Loeschen Fix im Filebrowser
- Import: Duplikat-Erkennung bei erneutem Scan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:35:37 +01:00
16 changed files with 1062 additions and 138 deletions

View file

@ -17,6 +17,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ENV LIBVA_DRIVER_NAME=iHD ENV LIBVA_DRIVER_NAME=iHD
ENV LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri ENV LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri
# VideoKonverter Defaults (ueberschreibbar per docker run -e / Unraid UI)
ENV VK_DB_HOST=localhost
ENV VK_DB_PORT=3306
ENV VK_DB_USER=video
ENV VK_DB_PASSWORD=""
ENV VK_DB_NAME=video_converter
ENV VK_MODE=cpu
ENV VK_PORT=8080
ENV VK_LOG_LEVEL=INFO
WORKDIR /opt/video-konverter WORKDIR /opt/video-konverter
# Python-Abhaengigkeiten # Python-Abhaengigkeiten

View file

@ -1,4 +1,16 @@
"""Konfigurationsmanagement - Singleton fuer Settings und Presets""" """Konfigurationsmanagement - Singleton fuer Settings und Presets
Alle wichtigen Settings koennen per Umgebungsvariable ueberschrieben werden.
ENV-Variablen haben IMMER Vorrang vor settings.yaml.
Mapping (VK_ Prefix):
Datenbank: VK_DB_HOST, VK_DB_PORT, VK_DB_USER, VK_DB_PASSWORD, VK_DB_NAME
Encoding: VK_MODE (cpu/gpu/auto), VK_GPU_DEVICE, VK_MAX_JOBS, VK_DEFAULT_PRESET
Server: VK_PORT, VK_HOST, VK_EXTERNAL_URL
Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false)
Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4)
Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR)
"""
import os import os
import logging import logging
import yaml import yaml
@ -6,9 +18,107 @@ from pathlib import Path
from typing import Optional from typing import Optional
from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler
# Mapping: ENV-Variable -> (settings-pfad, typ)
# Pfad als Tuple: ("section", "key")
_ENV_MAP: dict[str, tuple[tuple[str, str], type]] = {
"VK_DB_HOST": (("database", "host"), str),
"VK_DB_PORT": (("database", "port"), int),
"VK_DB_USER": (("database", "user"), str),
"VK_DB_PASSWORD": (("database", "password"), str),
"VK_DB_NAME": (("database", "database"), str),
"VK_MODE": (("encoding", "mode"), str),
"VK_GPU_DEVICE": (("encoding", "gpu_device"), str),
"VK_MAX_JOBS": (("encoding", "max_parallel_jobs"), int),
"VK_DEFAULT_PRESET": (("encoding", "default_preset"), str),
"VK_PORT": (("server", "port"), int),
"VK_HOST": (("server", "host"), str),
"VK_EXTERNAL_URL": (("server", "external_url"), str),
"VK_TVDB_API_KEY": (("library", "tvdb_api_key"), str),
"VK_TVDB_LANGUAGE": (("library", "tvdb_language"), str),
"VK_LIBRARY_ENABLED": (("library", "enabled"), bool),
"VK_TARGET_CONTAINER": (("files", "target_container"), str),
"VK_LOG_LEVEL": (("logging", "level"), str),
}
# Rueckwaertskompatibilitaet
_ENV_ALIASES: dict[str, str] = {
"VIDEO_KONVERTER_MODE": "VK_MODE",
}
# Default-Settings wenn keine settings.yaml existiert
_DEFAULT_SETTINGS: dict = {
"database": {
"host": "localhost",
"port": 3306,
"user": "video",
"password": "",
"database": "video_converter",
},
"encoding": {
"mode": "cpu",
"default_preset": "cpu_av1",
"gpu_device": "/dev/dri/renderD128",
"gpu_driver": "iHD",
"max_parallel_jobs": 1,
},
"server": {
"host": "0.0.0.0",
"port": 8080,
"external_url": "",
"use_https": False,
"websocket_path": "/ws",
},
"files": {
"delete_source": False,
"recursive_scan": True,
"scan_extensions": [".mkv", ".mp4", ".avi", ".wmv", ".vob", ".ts", ".m4v", ".flv", ".mov"],
"target_container": "webm",
"target_folder": "same",
},
"audio": {
"bitrate_map": {2: "128k", 6: "320k", 8: "450k"},
"default_bitrate": "192k",
"default_codec": "libopus",
"keep_channels": True,
"languages": ["ger", "eng", "und"],
},
"subtitle": {
"codec_blacklist": ["hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"],
"languages": ["ger", "eng"],
},
"library": {
"enabled": True,
"import_default_mode": "copy",
"import_naming_pattern": "{series} - S{season:02d}E{episode:02d} - {title}.{ext}",
"import_season_pattern": "Season {season:02d}",
"scan_interval_hours": 0,
"tvdb_api_key": "",
"tvdb_language": "deu",
"tvdb_pin": "",
},
"cleanup": {
"enabled": False,
"delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"],
"keep_extensions": [".srt"],
"exclude_patterns": ["readme*", "*.md"],
},
"logging": {
"level": "INFO",
"file": "server.log",
"rotation": "time",
"backup_count": 7,
"max_size_mb": 10,
},
"statistics": {
"cleanup_days": 365,
"max_entries": 5000,
},
}
class Config: class Config:
"""Laedt und verwaltet settings.yaml und presets.yaml""" """Laedt und verwaltet settings.yaml und presets.yaml.
ENV-Variablen (VK_*) ueberschreiben YAML-Werte."""
_instance: Optional['Config'] = None _instance: Optional['Config'] = None
def __new__(cls) -> 'Config': def __new__(cls) -> 'Config':
@ -28,6 +138,7 @@ class Config:
self._data_path = self._base_path.parent / "data" self._data_path = self._base_path.parent / "data"
# Verzeichnisse sicherstellen # Verzeichnisse sicherstellen
self._cfg_path.mkdir(parents=True, exist_ok=True)
self._log_path.mkdir(parents=True, exist_ok=True) self._log_path.mkdir(parents=True, exist_ok=True)
self._data_path.mkdir(parents=True, exist_ok=True) self._data_path.mkdir(parents=True, exist_ok=True)
@ -38,55 +149,89 @@ class Config:
self._apply_env_overrides() self._apply_env_overrides()
def _load_settings(self) -> None: def _load_settings(self) -> None:
"""Laedt settings.yaml""" """Laedt settings.yaml oder erzeugt Defaults"""
import copy
settings_file = self._cfg_path / "settings.yaml" settings_file = self._cfg_path / "settings.yaml"
if settings_file.exists():
try: try:
with open(settings_file, "r", encoding="utf-8") as f: with open(settings_file, "r", encoding="utf-8") as f:
self.settings = yaml.safe_load(f) or {} self.settings = yaml.safe_load(f) or {}
logging.info(f"Settings geladen: {settings_file}") logging.info(f"Settings geladen: {settings_file}")
except FileNotFoundError: except Exception as e:
logging.error(f"Settings nicht gefunden: {settings_file}") logging.error(f"Settings lesen fehlgeschlagen: {e}")
self.settings = {} self.settings = copy.deepcopy(_DEFAULT_SETTINGS)
else:
# Keine settings.yaml -> Defaults verwenden und speichern
logging.info("Keine settings.yaml gefunden - erzeuge Defaults")
self.settings = copy.deepcopy(_DEFAULT_SETTINGS)
self._save_yaml(settings_file, self.settings)
def _load_presets(self) -> None: def _load_presets(self) -> None:
"""Laedt presets.yaml""" """Laedt presets.yaml"""
presets_file = self._cfg_path / "presets.yaml" presets_file = self._cfg_path / "presets.yaml"
if presets_file.exists():
try: try:
with open(presets_file, "r", encoding="utf-8") as f: with open(presets_file, "r", encoding="utf-8") as f:
self.presets = yaml.safe_load(f) or {} self.presets = yaml.safe_load(f) or {}
logging.info(f"Presets geladen: {presets_file}") logging.info(f"Presets geladen: {presets_file}")
except FileNotFoundError: except Exception as e:
logging.error(f"Presets nicht gefunden: {presets_file}") logging.error(f"Presets lesen fehlgeschlagen: {e}")
self.presets = {}
else:
logging.warning("Keine presets.yaml gefunden - verwende leere Presets")
self.presets = {} self.presets = {}
def _apply_env_overrides(self) -> None: def _apply_env_overrides(self) -> None:
"""Umgebungsvariablen ueberschreiben Settings""" """Umgebungsvariablen (VK_*) ueberschreiben Settings.
env_mode = os.environ.get("VIDEO_KONVERTER_MODE") Unterstuetzt auch alte Variablennamen per Alias-Mapping."""
if env_mode and env_mode in ("cpu", "gpu", "auto"): applied = []
self.settings.setdefault("encoding", {})["mode"] = env_mode
logging.info(f"Encoding-Modus per Umgebungsvariable: {env_mode}") # Aliase aufloesen (z.B. VIDEO_KONVERTER_MODE -> VK_MODE)
for old_name, new_name in _ENV_ALIASES.items():
if old_name in os.environ and new_name not in os.environ:
os.environ[new_name] = os.environ[old_name]
for env_key, ((section, key), val_type) in _ENV_MAP.items():
raw = os.environ.get(env_key)
if raw is None:
continue
# Typ-Konvertierung
try:
if val_type is bool:
value = raw.lower() in ("true", "1", "yes", "on")
elif val_type is int:
value = int(raw)
else:
value = raw
except (ValueError, TypeError):
logging.warning(f"ENV {env_key}={raw!r} - ungueliger Wert, uebersprungen")
continue
self.settings.setdefault(section, {})[key] = value
applied.append(f"{env_key}={value}")
if applied:
logging.info(f"ENV-Overrides angewendet: {', '.join(applied)}")
@staticmethod
def _save_yaml(path: Path, data: dict) -> None:
"""Schreibt dict als YAML in Datei"""
try:
with open(path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False,
indent=2, allow_unicode=True)
logging.info(f"YAML gespeichert: {path}")
except Exception as e:
logging.error(f"YAML speichern fehlgeschlagen ({path}): {e}")
def save_settings(self) -> None: def save_settings(self) -> None:
"""Schreibt aktuelle Settings zurueck in settings.yaml""" """Schreibt aktuelle Settings zurueck in settings.yaml"""
settings_file = self._cfg_path / "settings.yaml" self._save_yaml(self._cfg_path / "settings.yaml", self.settings)
try:
with open(settings_file, "w", encoding="utf-8") as f:
yaml.dump(self.settings, f, default_flow_style=False,
indent=2, allow_unicode=True)
logging.info("Settings gespeichert")
except Exception as e:
logging.error(f"Settings speichern fehlgeschlagen: {e}")
def save_presets(self) -> None: def save_presets(self) -> None:
"""Schreibt Presets zurueck in presets.yaml""" """Schreibt Presets zurueck in presets.yaml"""
presets_file = self._cfg_path / "presets.yaml" self._save_yaml(self._cfg_path / "presets.yaml", self.presets)
try:
with open(presets_file, "w", encoding="utf-8") as f:
yaml.dump(self.presets, f, default_flow_style=False,
indent=2, allow_unicode=True)
logging.info("Presets gespeichert")
except Exception as e:
logging.error(f"Presets speichern fehlgeschlagen: {e}")
def setup_logging(self) -> None: def setup_logging(self) -> None:
"""Konfiguriert Logging mit Rotation""" """Konfiguriert Logging mit Rotation"""

View file

@ -1,4 +1,5 @@
"""REST API Endpoints""" """REST API Endpoints"""
import asyncio
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@ -7,11 +8,13 @@ from app.config import Config
from app.services.queue import QueueService from app.services.queue import QueueService
from app.services.scanner import ScannerService from app.services.scanner import ScannerService
from app.services.encoder import EncoderService from app.services.encoder import EncoderService
from app.routes.ws import WebSocketManager
def setup_api_routes(app: web.Application, config: Config, def setup_api_routes(app: web.Application, config: Config,
queue_service: QueueService, queue_service: QueueService,
scanner: ScannerService) -> None: scanner: ScannerService,
ws_manager: WebSocketManager = None) -> None:
"""Registriert alle API-Routes""" """Registriert alle API-Routes"""
# --- Job-Management --- # --- Job-Management ---
@ -335,42 +338,33 @@ def setup_api_routes(app: web.Application, config: Config,
"jobs": [{"id": j.id, "file": j.media.source_filename} for j in jobs], "jobs": [{"id": j.id, "file": j.media.source_filename} for j in jobs],
}) })
# --- Logs --- # --- Logs via WebSocket ---
# In-Memory Log-Buffer class WebSocketLogHandler(logging.Handler):
_log_buffer = [] """Pusht Logs direkt per WebSocket an alle Clients"""
_log_id = 0 def __init__(self, ws_mgr: WebSocketManager):
_MAX_LOGS = 200 super().__init__()
self._ws_manager = ws_mgr
class WebLogHandler(logging.Handler):
"""Handler der Logs an den Buffer sendet"""
def emit(self, record): def emit(self, record):
nonlocal _log_id if not self._ws_manager or not self._ws_manager.clients:
_log_id += 1 return
entry = { try:
"id": _log_id, loop = asyncio.get_running_loop()
"level": record.levelname, loop.create_task(
"message": record.getMessage(), self._ws_manager.broadcast_log(
"time": record.created, record.levelname, record.getMessage()
} )
_log_buffer.append(entry) )
# Buffer begrenzen except RuntimeError:
while len(_log_buffer) > _MAX_LOGS: pass
_log_buffer.pop(0)
# Handler registrieren if ws_manager:
web_handler = WebLogHandler() ws_log_handler = WebSocketLogHandler(ws_manager)
web_handler.setLevel(logging.INFO) ws_log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(web_handler) logging.getLogger().addHandler(ws_log_handler)
async def get_logs(request: web.Request) -> web.Response:
"""GET /api/logs?since=123 - Logs seit ID"""
since = int(request.query.get("since", 0))
logs = [l for l in _log_buffer if l["id"] > since]
return web.json_response({"logs": logs})
# --- Routes registrieren --- # --- Routes registrieren ---
app.router.add_get("/api/logs", get_logs)
app.router.add_get("/api/browse", get_browse) app.router.add_get("/api/browse", get_browse)
app.router.add_post("/api/upload", post_upload) app.router.add_post("/api/upload", post_upload)
app.router.add_post("/api/convert", post_convert) app.router.add_post("/api/convert", post_convert)

View file

@ -597,6 +597,19 @@ def setup_library_routes(app: web.Application, config: Config,
dupes = await library_service.find_duplicates() dupes = await library_service.find_duplicates()
return web.json_response({"duplicates": dupes}) return web.json_response({"duplicates": dupes})
# === Video loeschen ===
async def delete_video(request: web.Request) -> web.Response:
"""DELETE /api/library/videos/{video_id}?delete_file=1"""
video_id = int(request.match_info["video_id"])
delete_file = request.query.get("delete_file") == "1"
result = await library_service.delete_video(
video_id, delete_file=delete_file
)
if result.get("error"):
return web.json_response(result, status=404)
return web.json_response(result)
# === Konvertierung aus Bibliothek === # === Konvertierung aus Bibliothek ===
async def post_convert_video(request: web.Request) -> web.Response: async def post_convert_video(request: web.Request) -> web.Response:
@ -829,6 +842,7 @@ def setup_library_routes(app: web.Application, config: Config,
Body: {folder_path: "/mnt/.../Season 01"} Body: {folder_path: "/mnt/.../Season 01"}
ACHTUNG: Unwiderruflich! ACHTUNG: Unwiderruflich!
""" """
import os
import shutil import shutil
try: try:
data = await request.json() data = await request.json()
@ -901,9 +915,25 @@ def setup_library_routes(app: web.Application, config: Config,
except Exception as e: except Exception as e:
errors.append(f"DB-Fehler: {e}") errors.append(f"DB-Fehler: {e}")
# Ordner loeschen # Ordner loeschen (onerror fuer SMB/CIFS Permission-Probleme)
def _rm_error(func, path, exc_info):
"""Bei Permission-Fehler: Schreibrechte setzen und nochmal versuchen"""
import stat
try: try:
shutil.rmtree(folder_path) os.chmod(path, stat.S_IRWXU)
func(path)
except Exception as e2:
errors.append(f"{path}: {e2}")
try:
shutil.rmtree(folder_path, onerror=_rm_error)
if os.path.exists(folder_path):
# Ordner existiert noch -> nicht alles geloescht
logging.warning(
f"Ordner teilweise geloescht: {folder_path} "
f"({len(errors)} Fehler)"
)
else:
logging.info(f"Ordner geloescht: {folder_path}") logging.info(f"Ordner geloescht: {folder_path}")
except Exception as e: except Exception as e:
logging.error(f"Ordner loeschen fehlgeschlagen: {e}") logging.error(f"Ordner loeschen fehlgeschlagen: {e}")
@ -1217,6 +1247,158 @@ def setup_library_routes(app: web.Application, config: Config,
{"error": "Ungueltige Aktion"}, status=400 {"error": "Ungueltige Aktion"}, status=400
) )
# === Video-Streaming ===
async def get_stream_video(request: web.Request) -> web.StreamResponse:
"""GET /api/library/videos/{video_id}/stream?t=0
Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC).
Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.).
Optional: ?t=120 fuer Seeking auf Sekunde 120."""
import os
import asyncio as _asyncio
import shlex
video_id = int(request.match_info["video_id"])
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500
)
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT file_path FROM library_videos WHERE id = %s",
(video_id,)
)
row = await cur.fetchone()
if not row:
return web.json_response(
{"error": "Video nicht gefunden"}, status=404
)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
file_path = row[0]
if not os.path.isfile(file_path):
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404
)
# Seek-Position (Sekunden) aus Query-Parameter
seek_sec = float(request.query.get("t", "0"))
# ffmpeg-Kommando: Video copy, Audio -> AAC Stereo, MP4-Container
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
]
if seek_sec > 0:
cmd += ["-ss", str(seek_sec)]
cmd += [
"-i", file_path,
"-c:v", "copy",
"-c:a", "aac", "-ac", "2", "-b:a", "192k",
"-movflags", "frag_keyframe+empty_moov+faststart",
"-f", "mp4",
"pipe:1",
]
resp = web.StreamResponse(
status=200,
headers={
"Content-Type": "video/mp4",
"Cache-Control": "no-cache",
"Transfer-Encoding": "chunked",
},
)
await resp.prepare(request)
proc = None
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
chunk_size = 256 * 1024 # 256 KB
while True:
chunk = await proc.stdout.read(chunk_size)
if not chunk:
break
try:
await resp.write(chunk)
except (ConnectionResetError, ConnectionAbortedError):
# Client hat Verbindung geschlossen
break
except Exception as e:
logging.error(f"Stream-Fehler: {e}")
finally:
if proc and proc.returncode is None:
proc.kill()
await proc.wait()
await resp.write_eof()
return resp
# === Import: Item zuordnen / ueberspringen ===
async def post_reassign_import_item(
request: web.Request,
) -> web.Response:
"""POST /api/library/import/items/{item_id}/reassign
Weist einem nicht-erkannten Item eine Serie zu."""
if not importer_service:
return web.json_response(
{"error": "Import-Service nicht verfuegbar"}, status=500
)
item_id = int(request.match_info["item_id"])
try:
data = await request.json()
except Exception:
return web.json_response(
{"error": "Ungueltiges JSON"}, status=400
)
series_name = data.get("series_name", "").strip()
season = data.get("season")
episode = data.get("episode")
tvdb_id = data.get("tvdb_id")
if not series_name or season is None or episode is None:
return web.json_response(
{"error": "series_name, season und episode erforderlich"},
status=400,
)
result = await importer_service.reassign_item(
item_id, series_name,
int(season), int(episode),
int(tvdb_id) if tvdb_id else None
)
if result.get("error"):
return web.json_response(result, status=400)
return web.json_response(result)
async def post_skip_import_item(
request: web.Request,
) -> web.Response:
"""POST /api/library/import/items/{item_id}/skip"""
if not importer_service:
return web.json_response(
{"error": "Import-Service nicht verfuegbar"}, status=500
)
item_id = int(request.match_info["item_id"])
success = await importer_service.skip_item(item_id)
if success:
return web.json_response({"message": "Item uebersprungen"})
return web.json_response(
{"error": "Fehlgeschlagen"}, status=400
)
# === Routes registrieren === # === Routes registrieren ===
# Pfade # Pfade
app.router.add_get("/api/library/paths", get_paths) app.router.add_get("/api/library/paths", get_paths)
@ -1230,6 +1412,9 @@ def setup_library_routes(app: web.Application, config: Config,
# Videos / Filme # Videos / Filme
app.router.add_get("/api/library/videos", get_videos) app.router.add_get("/api/library/videos", get_videos)
app.router.add_get("/api/library/movies", get_movies) app.router.add_get("/api/library/movies", get_movies)
app.router.add_delete(
"/api/library/videos/{video_id}", delete_video
)
# Serien # Serien
app.router.add_get("/api/library/series", get_series) app.router.add_get("/api/library/series", get_series)
app.router.add_get("/api/library/series/{series_id}", get_series_detail) app.router.add_get("/api/library/series/{series_id}", get_series_detail)
@ -1325,6 +1510,18 @@ def setup_library_routes(app: web.Application, config: Config,
app.router.add_put( app.router.add_put(
"/api/library/import/items/{item_id}/resolve", put_resolve_conflict "/api/library/import/items/{item_id}/resolve", put_resolve_conflict
) )
app.router.add_post(
"/api/library/import/items/{item_id}/reassign",
post_reassign_import_item,
)
app.router.add_post(
"/api/library/import/items/{item_id}/skip",
post_skip_import_item,
)
# Video-Streaming
app.router.add_get(
"/api/library/videos/{video_id}/stream", get_stream_video
)
# TVDB Auto-Match (Review-Modus) # TVDB Auto-Match (Review-Modus)
app.router.add_post( app.router.add_post(
"/api/library/tvdb-auto-match", post_tvdb_auto_match "/api/library/tvdb-auto-match", post_tvdb_auto_match

View file

@ -146,8 +146,13 @@ def setup_page_routes(app: web.Application, config: Config,
"format_time": MediaFile.format_time, "format_time": MediaFile.format_time,
} }
async def redirect_to_library(request: web.Request):
"""GET / -> Weiterleitung zur Bibliothek"""
raise web.HTTPFound("/library")
# Routes registrieren # Routes registrieren
app.router.add_get("/", dashboard) app.router.add_get("/", redirect_to_library)
app.router.add_get("/dashboard", dashboard)
app.router.add_get("/library", library) app.router.add_get("/library", library)
app.router.add_get("/admin", admin) app.router.add_get("/admin", admin)
app.router.add_get("/statistics", statistics) app.router.add_get("/statistics", statistics)

View file

@ -83,6 +83,12 @@ class WebSocketManager:
"""Sendet Fortschritts-Update fuer einen Job""" """Sendet Fortschritts-Update fuer einen Job"""
await self.broadcast({"data_flow": job.to_dict_progress()}) await self.broadcast({"data_flow": job.to_dict_progress()})
async def broadcast_log(self, level: str, message: str) -> None:
"""Sendet Log-Nachricht an alle Clients"""
await self.broadcast({
"data_log": {"level": level, "message": message}
})
async def _handle_message(self, data: dict) -> None: async def _handle_message(self, data: dict) -> None:
"""Verarbeitet eingehende WebSocket-Nachrichten""" """Verarbeitet eingehende WebSocket-Nachrichten"""
if not self.queue_service: if not self.queue_service:

View file

@ -60,7 +60,8 @@ class VideoKonverterServer:
# API Routes # API Routes
setup_api_routes( setup_api_routes(
self.app, self.config, self.queue_service, self.scanner self.app, self.config, self.queue_service, self.scanner,
self.ws_manager
) )
# Bibliothek API Routes # Bibliothek API Routes

View file

@ -133,6 +133,14 @@ class EncoderService:
if keep_channels: if keep_channels:
cmd.extend([f"-ac:{audio_idx}", str(channels)]) cmd.extend([f"-ac:{audio_idx}", str(channels)])
# Channel-Layout normalisieren fuer libopus
# EAC3/AC3 mit 5.1(side) Layout fuehrt zu Encoder-Fehler
if codec == "libopus" and channels == 6:
cmd.extend([
f"-filter:a:{audio_idx}",
"channelmap=channel_layout=5.1",
])
audio_idx += 1 audio_idx += 1
return cmd return cmd

View file

@ -302,7 +302,7 @@ class ImporterService:
pattern = job.get("naming_pattern") or self._naming_pattern pattern = job.get("naming_pattern") or self._naming_pattern
season_pattern = job.get("season_pattern") or self._season_pattern season_pattern = job.get("season_pattern") or self._season_pattern
target_dir, target_file = self._build_target( target_dir, target_file = self._build_target(
tvdb_name or series_name or "Unbekannt", tvdb_name or series_name or "Unbekannte Serie",
season, episode, season, episode,
tvdb_ep_title or "", tvdb_ep_title or "",
ext, ext,
@ -456,14 +456,21 @@ class ImporterService:
# Season-Ordner # Season-Ordner
season_dir = season_pattern.format(season=s) season_dir = season_pattern.format(season=s)
# Dateiname # Dateiname - kein Titel: ohne Titel-Teil, sonst mit
try: try:
if title:
filename = pattern.format( filename = pattern.format(
series=series, season=s, episode=e, series=series, season=s, episode=e,
title=title or "Unbekannt", ext=ext title=title, ext=ext
) )
else:
# Ohne Titel: "Serie - S01E03.ext"
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
except (KeyError, ValueError): except (KeyError, ValueError):
filename = f"{series} - S{s:02d}E{e:02d} - {title or 'Unbekannt'}.{ext}" if title:
filename = f"{series} - S{s:02d}E{e:02d} - {title}.{ext}"
else:
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
# Ungueltige Zeichen entfernen # Ungueltige Zeichen entfernen
for ch in ['<', '>', ':', '"', '|', '?', '*']: for ch in ['<', '>', ':', '"', '|', '?', '*']:
@ -638,6 +645,34 @@ class ImporterService:
# Zielordner erstellen # Zielordner erstellen
os.makedirs(target_dir, exist_ok=True) os.makedirs(target_dir, exist_ok=True)
# Alte Dateien fuer dieselbe Episode aufraeumen
# (z.B. "S01E03 - Unbekannt.mkv" wenn jetzt "S01E03 - Willkür.mkv" kommt)
season = item.get("detected_season")
episode = item.get("detected_episode")
if season is not None and episode is not None and os.path.isdir(target_dir):
ep_pattern = f"S{season:02d}E{episode:02d}"
for existing in os.listdir(target_dir):
existing_path = os.path.join(target_dir, existing)
if (existing != target_file
and ep_pattern in existing
and os.path.isfile(existing_path)):
logging.info(
f"Import: Alte Episode-Datei entfernt: {existing}"
)
os.remove(existing_path)
# Auch aus library_videos loeschen
if self._db_pool:
try:
async with self._db_pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"DELETE FROM library_videos "
"WHERE file_path = %s",
(existing_path,)
)
except Exception:
pass
# Fortschritt-Tracking in DB setzen # Fortschritt-Tracking in DB setzen
if job_id and self._db_pool: if job_id and self._db_pool:
await self._update_file_progress( await self._update_file_progress(
@ -905,6 +940,118 @@ class ImporterService:
logging.error(f"Import-Item aktualisieren fehlgeschlagen: {e}") logging.error(f"Import-Item aktualisieren fehlgeschlagen: {e}")
return False return False
async def reassign_item(self, item_id: int,
series_name: str,
season: int, episode: int,
tvdb_id: int = None) -> dict:
"""Weist einem pending-Item eine Serie/Staffel/Episode zu.
Berechnet automatisch den Zielpfad und holt ggf. TVDB-Episodentitel.
"""
if not self._db_pool:
return {"error": "Keine DB-Verbindung"}
try:
async with self._db_pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# Item laden
await cur.execute(
"SELECT i.*, j.target_library_id, j.naming_pattern, "
"j.season_pattern FROM import_items i "
"JOIN import_jobs j ON j.id = i.import_job_id "
"WHERE i.id = %s", (item_id,)
)
item = await cur.fetchone()
if not item:
return {"error": "Item nicht gefunden"}
# Library-Pfad laden
await cur.execute(
"SELECT * FROM library_paths WHERE id = %s",
(item["target_library_id"],)
)
lib_path = await cur.fetchone()
if not lib_path:
return {"error": "Ziel-Library nicht gefunden"}
# TVDB-Name und Episodentitel holen
tvdb_name = series_name
tvdb_ep_title = ""
if tvdb_id and self.tvdb.is_configured:
# Serien-Info von TVDB holen
try:
info = await self.tvdb.get_series_info(tvdb_id)
if info and info.get("name"):
tvdb_name = info["name"]
except Exception:
pass
# Episodentitel holen
tvdb_ep_title = await self._get_episode_title(
tvdb_id, season, episode
)
# Zielpfad berechnen
ext = os.path.splitext(item["source_file"])[1].lstrip(".")
pattern = item.get("naming_pattern") or self._naming_pattern
season_pattern = item.get("season_pattern") or self._season_pattern
target_dir, target_file = self._build_target(
tvdb_name or series_name,
season, episode,
tvdb_ep_title or "",
ext,
lib_path["path"],
pattern, season_pattern
)
# In DB aktualisieren
async with self._db_pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
UPDATE import_items SET
detected_series = %s,
detected_season = %s,
detected_episode = %s,
tvdb_series_id = %s,
tvdb_series_name = %s,
tvdb_episode_title = %s,
target_path = %s,
target_filename = %s,
status = 'matched'
WHERE id = %s
""", (
series_name, season, episode,
tvdb_id, tvdb_name, tvdb_ep_title,
target_dir, target_file, item_id,
))
return {
"ok": True,
"target_dir": target_dir,
"target_file": target_file,
"tvdb_name": tvdb_name,
"tvdb_ep_title": tvdb_ep_title,
}
except Exception as e:
logging.error(f"Import-Item zuordnen fehlgeschlagen: {e}")
return {"error": str(e)}
async def skip_item(self, item_id: int) -> bool:
"""Markiert ein Item als uebersprungen"""
if not self._db_pool:
return False
try:
async with self._db_pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"UPDATE import_items SET status = 'skipped', "
"conflict_reason = 'Manuell uebersprungen' "
"WHERE id = %s", (item_id,)
)
return True
except Exception:
return False
async def get_all_jobs(self) -> list: async def get_all_jobs(self) -> list:
"""Liste aller Import-Jobs (neueste zuerst)""" """Liste aller Import-Jobs (neueste zuerst)"""
if not self._db_pool: if not self._db_pool:

View file

@ -385,8 +385,15 @@ class LibraryService:
# Dateisystem loeschen wenn gewuenscht # Dateisystem loeschen wenn gewuenscht
if delete_files and folder_path and os.path.isdir(folder_path): if delete_files and folder_path and os.path.isdir(folder_path):
import shutil import shutil
import stat
def _rm_error(func, path, exc_info):
try: try:
shutil.rmtree(folder_path) os.chmod(path, stat.S_IRWXU)
func(path)
except Exception:
pass
try:
shutil.rmtree(folder_path, onerror=_rm_error)
result["deleted_folder"] = folder_path result["deleted_folder"] = folder_path
logging.info( logging.info(
f"Serie {series_id} komplett geloescht " f"Serie {series_id} komplett geloescht "
@ -409,6 +416,56 @@ class LibraryService:
logging.error(f"Serie loeschen fehlgeschlagen: {e}") logging.error(f"Serie loeschen fehlgeschlagen: {e}")
return {"error": str(e)} return {"error": str(e)}
async def delete_video(self, video_id: int,
delete_file: bool = False) -> dict:
"""Einzelnes Video loeschen (DB + optional Datei)"""
pool = await self._get_pool()
if not pool:
return {"error": "Keine DB-Verbindung"}
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT file_path FROM library_videos WHERE id = %s",
(video_id,)
)
row = await cur.fetchone()
if not row:
return {"error": "Video nicht gefunden"}
file_path = row[0]
# Aus DB loeschen
await cur.execute(
"DELETE FROM library_videos WHERE id = %s",
(video_id,)
)
result = {"success": True, "file_path": file_path}
# Datei loeschen wenn gewuenscht
if delete_file and file_path and os.path.isfile(file_path):
try:
os.remove(file_path)
result["file_deleted"] = True
logging.info(f"Video geloescht: {file_path}")
except Exception as e:
result["file_error"] = str(e)
logging.error(
f"Video-Datei loeschen fehlgeschlagen: "
f"{file_path}: {e}"
)
elif delete_file:
result["file_deleted"] = False
result["file_error"] = "Datei nicht gefunden"
return result
except Exception as e:
logging.error(f"Video loeschen fehlgeschlagen: {e}")
return {"error": str(e)}
async def get_movies(self, filters: dict = None, async def get_movies(self, filters: dict = None,
page: int = 1, limit: int = 50) -> dict: page: int = 1, limit: int = 50) -> dict:
"""Nur Filme (keine Serien) abfragen""" """Nur Filme (keine Serien) abfragen"""
@ -1600,8 +1657,15 @@ class LibraryService:
if delete_files and folder_path and os.path.isdir(folder_path): if delete_files and folder_path and os.path.isdir(folder_path):
import shutil import shutil
import stat
def _rm_error(func, path, exc_info):
try: try:
shutil.rmtree(folder_path) os.chmod(path, stat.S_IRWXU)
func(path)
except Exception:
pass
try:
shutil.rmtree(folder_path, onerror=_rm_error)
result["deleted_folder"] = folder_path result["deleted_folder"] = folder_path
except Exception as e: except Exception as e:
result["folder_error"] = str(e) result["folder_error"] = str(e)

View file

@ -1441,6 +1441,54 @@ legend {
} }
.row-conflict { background: #2a1a10 !important; } .row-conflict { background: #2a1a10 !important; }
.row-conflict:hover { background: #332010 !important; } .row-conflict:hover { background: #332010 !important; }
.row-pending { background: #2a1020 !important; }
.row-pending:hover { background: #331030 !important; }
/* === Play-Button === */
.btn-play {
background: #2a7a2a;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
}
.btn-play:hover { background: #3a9a3a; }
/* === Video-Player Modal === */
.player-overlay {
z-index: 10000;
background: rgba(0, 0, 0, 0.95);
}
.player-container {
width: 95vw;
max-width: 1400px;
display: flex;
flex-direction: column;
}
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.8rem;
color: #fff;
font-size: 0.9rem;
}
.player-header .btn-close {
font-size: 1.5rem;
color: #aaa;
background: none;
border: none;
cursor: pointer;
}
.player-header .btn-close:hover { color: #fff; }
#player-video {
width: 100%;
max-height: 85vh;
background: #000;
border-radius: 4px;
}
/* === TVDB Review-Modal === */ /* === TVDB Review-Modal === */
.tvdb-review-list { .tvdb-review-list {

View file

@ -262,7 +262,13 @@ function loadSectionSeries(pathId) {
// === Ordner pro Bereich === // === Ordner pro Bereich ===
let _browserLoading = false;
function loadSectionBrowser(pathId, subPath) { function loadSectionBrowser(pathId, subPath) {
// Doppelklick-Schutz: Zweiten Aufruf ignorieren solange geladen wird
if (_browserLoading) return;
_browserLoading = true;
const content = document.getElementById("content-" + pathId); const content = document.getElementById("content-" + pathId);
content.innerHTML = '<div class="loading-msg">Lade Ordner...</div>'; content.innerHTML = '<div class="loading-msg">Lade Ordner...</div>';
@ -281,7 +287,8 @@ function loadSectionBrowser(pathId, subPath) {
html += renderBrowser(data.folders || [], data.videos || [], pathId); html += renderBrowser(data.folders || [], data.videos || [], pathId);
content.innerHTML = html; content.innerHTML = html;
}) })
.catch(() => { content.innerHTML = '<div class="loading-msg">Fehler</div>'; }); .catch(() => { content.innerHTML = '<div class="loading-msg">Fehler</div>'; })
.finally(() => { _browserLoading = false; });
} }
// === Video-Tabelle (gemeinsam genutzt) === // === Video-Tabelle (gemeinsam genutzt) ===
@ -306,6 +313,7 @@ function renderVideoTable(items) {
const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-"; const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-";
const is10bit = v.is_10bit ? ' <span class="tag hdr">10bit</span>' : ""; const is10bit = v.is_10bit ? ' <span class="tag hdr">10bit</span>' : "";
const vidTitle = v.file_name || "Video";
html += `<tr> html += `<tr>
<td class="td-name" title="${escapeHtml(v.file_path || '')}">${escapeHtml(v.file_name || "-")}</td> <td class="td-name" title="${escapeHtml(v.file_path || '')}">${escapeHtml(v.file_name || "-")}</td>
<td>${res}${is10bit}</td> <td>${res}${is10bit}</td>
@ -315,7 +323,11 @@ function renderVideoTable(items) {
<td>${formatSize(v.file_size || 0)}</td> <td>${formatSize(v.file_size || 0)}</td>
<td>${formatDuration(v.duration_sec || 0)}</td> <td>${formatDuration(v.duration_sec || 0)}</td>
<td><span class="tag">${(v.container || "-").toUpperCase()}</span></td> <td><span class="tag">${(v.container || "-").toUpperCase()}</span></td>
<td><button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button></td> <td>
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(vidTitle)})" title="Abspielen">&#9654;</button>
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, ${escapeAttr(vidTitle)})" title="Loeschen">&#10005;</button>
</td>
</tr>`; </tr>`;
} }
html += '</tbody></table>'; html += '</tbody></table>';
@ -557,13 +569,18 @@ function renderEpisodesTab(series) {
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`; return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
}).join(" "); }).join(" ");
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-"; const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
const epTitle = ep.episode_title || ep.file_name || "Episode";
html += `<tr> html += `<tr>
<td>${ep.episode_number || "-"}</td> <td>${ep.episode_number || "-"}</td>
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(ep.episode_title || ep.file_name || "-")}</td> <td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td>
<td>${res}</td> <td>${res}</td>
<td><span class="tag codec">${ep.video_codec || "-"}</span></td> <td><span class="tag codec">${ep.video_codec || "-"}</span></td>
<td class="td-audio">${audioInfo || "-"}</td> <td class="td-audio">${audioInfo || "-"}</td>
<td><button class="btn-small btn-primary" onclick="convertVideo(${ep.id})">Conv</button></td> <td>
<button class="btn-small btn-play" onclick="playVideo(${ep.id}, ${escapeAttr(epTitle)})" title="Abspielen">&#9654;</button>
<button class="btn-small btn-primary" onclick="convertVideo(${ep.id})">Conv</button>
<button class="btn-small btn-danger" onclick="deleteVideo(${ep.id}, ${escapeAttr(epTitle)}, 'series')" title="Loeschen">&#10005;</button>
</td>
</tr>`; </tr>`;
} }
} }
@ -831,6 +848,7 @@ function openMovieDetail(movieId) {
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`; return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
}).join(" "); }).join(" ");
const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-"; const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-";
const movieTitle = v.file_name || "Video";
html += `<tr> html += `<tr>
<td class="td-name" title="${escapeHtml(v.file_path || '')}">${escapeHtml(v.file_name || "-")}</td> <td class="td-name" title="${escapeHtml(v.file_path || '')}">${escapeHtml(v.file_name || "-")}</td>
<td>${res}${v.is_10bit ? ' <span class="tag hdr">10bit</span>' : ''}</td> <td>${res}${v.is_10bit ? ' <span class="tag hdr">10bit</span>' : ''}</td>
@ -838,7 +856,11 @@ function openMovieDetail(movieId) {
<td class="td-audio">${audioInfo || "-"}</td> <td class="td-audio">${audioInfo || "-"}</td>
<td>${formatSize(v.file_size || 0)}</td> <td>${formatSize(v.file_size || 0)}</td>
<td>${formatDuration(v.duration_sec || 0)}</td> <td>${formatDuration(v.duration_sec || 0)}</td>
<td><button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button></td> <td>
<button class="btn-small btn-play" onclick="playVideo(${v.id}, ${escapeAttr(movieTitle)})" title="Abspielen">&#9654;</button>
<button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button>
<button class="btn-small btn-danger" onclick="deleteVideo(${v.id}, ${escapeAttr(movieTitle)}, 'movie')" title="Loeschen">&#10005;</button>
</td>
</tr>`; </tr>`;
} }
html += '</tbody></table>'; html += '</tbody></table>';
@ -1892,10 +1914,10 @@ function importBrowse(path) {
</div>`; </div>`;
} }
// Unterordner // Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren
for (const f of (data.folders || [])) { for (const f of (data.folders || [])) {
const meta = f.video_count > 0 ? `${f.video_count} Videos` : ""; const meta = f.video_count > 0 ? `${f.video_count} Videos` : "";
html += `<div class="import-browser-folder" ondblclick="importBrowse('${escapeHtml(f.path)}')" onclick="importSelectFolder('${escapeHtml(f.path)}', this)"> html += `<div class="import-browser-folder" onclick="importFolderClick('${escapeHtml(f.path)}', this)">
<span class="fb-icon">&#128193;</span> <span class="fb-icon">&#128193;</span>
<span class="fb-name">${escapeHtml(f.name)}</span> <span class="fb-name">${escapeHtml(f.name)}</span>
<span class="fb-meta">${meta}</span> <span class="fb-meta">${meta}</span>
@ -1916,6 +1938,23 @@ function importBrowse(path) {
}); });
} }
// Klick-Handler: Einfachklick = auswaehlen, Doppelklick = navigieren
let _importClickTimer = null;
function importFolderClick(path, el) {
if (_importClickTimer) {
// Zweiter Klick innerhalb 300ms -> Doppelklick -> navigieren
clearTimeout(_importClickTimer);
_importClickTimer = null;
importBrowse(path);
} else {
// Erster Klick -> kurz warten ob Doppelklick kommt
_importClickTimer = setTimeout(() => {
_importClickTimer = null;
importSelectFolder(path, el);
}, 250);
}
}
function importSelectFolder(path, el) { function importSelectFolder(path, el) {
// Vorherige Auswahl entfernen // Vorherige Auswahl entfernen
document.querySelectorAll(".import-browser-folder.selected").forEach( document.querySelectorAll(".import-browser-folder.selected").forEach(
@ -1995,8 +2034,10 @@ function renderImportItems(data) {
document.getElementById("import-info").textContent = document.getElementById("import-info").textContent =
`${items.length} Dateien: ${matched} erkannt, ${conflicts} Konflikte, ${pending} offen`; `${items.length} Dateien: ${matched} erkannt, ${conflicts} Konflikte, ${pending} offen`;
// Start-Button nur wenn keine ungeloesten Konflikte // Start-Button nur wenn keine ungeloesten Konflikte UND keine pending Items
const hasUnresolved = items.some(i => i.status === "conflict" && !i.user_action); const hasUnresolved = items.some(i =>
(i.status === "conflict" && !i.user_action) || i.status === "pending"
);
document.getElementById("btn-start-import").disabled = hasUnresolved; document.getElementById("btn-start-import").disabled = hasUnresolved;
if (!items.length) { if (!items.length) {
@ -2012,11 +2053,13 @@ function renderImportItems(data) {
const statusClass = item.status === "conflict" ? "status-badge warn" const statusClass = item.status === "conflict" ? "status-badge warn"
: item.status === "matched" ? "status-badge ok" : item.status === "matched" ? "status-badge ok"
: item.status === "done" ? "status-badge ok" : item.status === "done" ? "status-badge ok"
: item.status === "pending" ? "status-badge error"
: "status-badge"; : "status-badge";
const statusText = item.status === "conflict" ? "Konflikt" const statusText = item.status === "conflict" ? "Konflikt"
: item.status === "matched" ? "OK" : item.status === "matched" ? "OK"
: item.status === "done" ? "Fertig" : item.status === "done" ? "Fertig"
: item.status === "skipped" ? "Uebersprungen" : item.status === "skipped" ? "Uebersprungen"
: item.status === "pending" ? "Nicht erkannt"
: item.status; : item.status;
const sourceName = item.source_file ? item.source_file.split("/").pop() : "-"; const sourceName = item.source_file ? item.source_file.split("/").pop() : "-";
@ -2032,13 +2075,17 @@ function renderImportItems(data) {
<button class="btn-small btn-secondary" onclick="resolveImportConflict(${item.id}, 'rename')">Umbenennen</button> <button class="btn-small btn-secondary" onclick="resolveImportConflict(${item.id}, 'rename')">Umbenennen</button>
`; `;
} else if (item.status === "pending") { } else if (item.status === "pending") {
// TVDB-Suchfeld fuer manuelles Matching actionHtml = `
actionHtml = `<button class="btn-small btn-secondary" onclick="openImportTvdbSearch(${item.id})">TVDB suchen</button>`; <button class="btn-small btn-primary" onclick="openImportAssignModal(${item.id}, '${escapeAttr(sourceName)}')">Zuordnen</button>
<button class="btn-small btn-secondary" onclick="skipImportItem(${item.id})">Skip</button>
`;
} else if (item.user_action) { } else if (item.user_action) {
actionHtml = `<span class="tag">${item.user_action}</span>`; actionHtml = `<span class="tag">${item.user_action}</span>`;
} }
html += `<tr class="${item.status === 'conflict' ? 'row-conflict' : ''}"> const rowClass = item.status === "conflict" ? "row-conflict"
: item.status === "pending" ? "row-pending" : "";
html += `<tr class="${rowClass}">
<td class="td-name" title="${escapeHtml(item.source_file || '')}">${escapeHtml(sourceName)}</td> <td class="td-name" title="${escapeHtml(item.source_file || '')}">${escapeHtml(sourceName)}</td>
<td>${escapeHtml(item.tvdb_series_name || item.detected_series || "-")}</td> <td>${escapeHtml(item.tvdb_series_name || item.detected_series || "-")}</td>
<td>${se}</td> <td>${se}</td>
@ -2065,37 +2112,114 @@ function resolveImportConflict(itemId, action) {
.catch(e => alert("Fehler: " + e)); .catch(e => alert("Fehler: " + e));
} }
function openImportTvdbSearch(itemId) { // === Import-Zuordnungs-Modal ===
// Einfaches Prompt fuer TVDB-Suche
const query = prompt("TVDB-Serienname eingeben:"); let _assignItemId = null;
let _assignTvdbId = null;
let _assignSeriesName = "";
let _assignSearchTimer = null;
function openImportAssignModal(itemId, filename) {
_assignItemId = itemId;
_assignTvdbId = null;
_assignSeriesName = "";
const modal = document.getElementById("import-assign-modal");
modal.style.display = "flex";
document.getElementById("import-assign-filename").textContent = filename;
document.getElementById("import-assign-search").value = "";
document.getElementById("import-assign-results").innerHTML = "";
document.getElementById("import-assign-selected").style.display = "none";
document.getElementById("import-assign-season").value = "";
document.getElementById("import-assign-episode").value = "";
document.getElementById("import-assign-search").focus();
}
function closeImportAssignModal() {
document.getElementById("import-assign-modal").style.display = "none";
_assignItemId = null;
}
function debounceAssignSearch() {
if (_assignSearchTimer) clearTimeout(_assignSearchTimer);
_assignSearchTimer = setTimeout(searchAssignTvdb, 500);
}
function searchAssignTvdb() {
const query = document.getElementById("import-assign-search").value.trim();
if (!query) return; if (!query) return;
const results = document.getElementById("import-assign-results");
results.innerHTML = '<div class="loading-msg">Suche...</div>';
fetch(`/api/tvdb/search?q=${encodeURIComponent(query)}`) fetch(`/api/tvdb/search?q=${encodeURIComponent(query)}`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (!data.results || !data.results.length) { alert("Keine Ergebnisse"); return; } if (data.error) { results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`; return; }
// Erste 5 anzeigen if (!data.results || !data.results.length) { results.innerHTML = '<div class="loading-msg">Keine Ergebnisse</div>'; return; }
const choices = data.results.slice(0, 5).map((r, i) => results.innerHTML = data.results.slice(0, 8).map(r => `
`${i + 1}. ${r.name} (${r.year || "?"})` <div class="tvdb-result" onclick="selectAssignSeries(${r.tvdb_id}, '${escapeAttr(r.name)}')">
).join("\n"); ${r.poster ? `<img src="${r.poster}" alt="" class="tvdb-thumb">` : ""}
const choice = prompt(`Ergebnisse:\n${choices}\n\nNummer eingeben:`); <div>
if (!choice) return; <strong>${escapeHtml(r.name)}</strong>
const idx = parseInt(choice) - 1; <span class="text-muted">${r.year || ""}</span>
if (idx < 0 || idx >= data.results.length) return; <p class="tvdb-overview">${escapeHtml((r.overview || "").substring(0, 120))}</p>
</div>
</div>
`).join("");
})
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
}
const selected = data.results[idx]; function selectAssignSeries(tvdbId, name) {
fetch(`/api/library/import/items/${itemId}`, { _assignTvdbId = tvdbId;
method: "PUT", _assignSeriesName = name;
document.getElementById("import-assign-results").innerHTML = "";
document.getElementById("import-assign-selected").style.display = "";
document.getElementById("import-assign-selected-name").textContent = name;
document.getElementById("import-assign-search").value = "";
}
function submitImportAssign() {
if (!_assignItemId) return;
const season = parseInt(document.getElementById("import-assign-season").value);
const episode = parseInt(document.getElementById("import-assign-episode").value);
const manualName = document.getElementById("import-assign-search").value.trim();
const seriesName = _assignSeriesName || manualName;
if (!seriesName) { alert("Serie auswaehlen oder Namen eingeben"); return; }
if (isNaN(season) || isNaN(episode)) { alert("Staffel und Episode eingeben"); return; }
const btn = document.querySelector("#import-assign-modal .btn-primary");
btn.disabled = true;
btn.textContent = "Zuordne...";
fetch(`/api/library/import/items/${_assignItemId}/reassign`, {
method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({ body: JSON.stringify({
tvdb_series_id: selected.tvdb_id, series_name: seriesName,
tvdb_series_name: selected.name, season: season,
status: "matched", episode: episode,
tvdb_id: _assignTvdbId || null,
}), }),
}) })
.then(() => refreshImportPreview()) .then(r => r.json())
.catch(e => alert("Fehler: " + e)); .then(data => {
btn.disabled = false;
btn.textContent = "Zuordnen";
if (data.error) { alert("Fehler: " + data.error); return; }
closeImportAssignModal();
refreshImportPreview();
}) })
.catch(e => { btn.disabled = false; btn.textContent = "Zuordnen"; alert("Fehler: " + e); });
}
function skipImportItem(itemId) {
fetch(`/api/library/import/items/${itemId}/skip`, {method: "POST"})
.then(r => r.json())
.then(() => refreshImportPreview())
.catch(e => alert("Fehler: " + e)); .catch(e => alert("Fehler: " + e));
} }
@ -2274,3 +2398,71 @@ function cleanSearchTitle(title) {
.replace(/\s*(720p|1080p|2160p|4k|bluray|bdrip|webrip|web-dl|hdtv|x264|x265|hevc|aac|dts|remux)\s*/gi, ' ') .replace(/\s*(720p|1080p|2160p|4k|bluray|bdrip|webrip|web-dl|hdtv|x264|x265|hevc|aac|dts|remux)\s*/gi, ' ')
.trim(); .trim();
} }
// === Video-Player ===
let _playerVideoId = null;
function playVideo(videoId, title) {
const modal = document.getElementById("player-modal");
const video = document.getElementById("player-video");
document.getElementById("player-title").textContent = title || "Video";
_playerVideoId = videoId;
// Alte Quelle stoppen
video.pause();
video.removeAttribute("src");
video.load();
// Neue Quelle setzen (ffmpeg-Transcoding-Stream)
video.src = `/api/library/videos/${videoId}/stream`;
modal.style.display = "flex";
video.play().catch(() => {
// Autoplay blockiert - User muss manuell starten
});
}
function closePlayer() {
const video = document.getElementById("player-video");
video.pause();
video.removeAttribute("src");
video.load();
_playerVideoId = null;
document.getElementById("player-modal").style.display = "none";
}
// ESC schliesst den Player
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") {
const player = document.getElementById("player-modal");
if (player && player.style.display === "flex") {
closePlayer();
e.stopPropagation();
}
}
});
// === Video loeschen ===
function deleteVideo(videoId, title, context) {
if (!confirm(`"${title}" wirklich loeschen?\n\nDatei wird unwiderruflich entfernt!`)) return;
fetch(`/api/library/videos/${videoId}?delete_file=1`, {method: "DELETE"})
.then(r => r.json())
.then(data => {
if (data.error) { showToast("Fehler: " + data.error, "error"); return; }
showToast("Video geloescht", "success");
// Ansicht aktualisieren
if (context === "series" && currentSeriesId) {
openSeriesDetail(currentSeriesId);
} else if (context === "movie" && currentMovieId) {
openMovieDetail(currentMovieId);
} else {
reloadAllSections();
}
loadStats();
})
.catch(e => showToast("Fehler: " + e, "error"));
}

View file

@ -32,6 +32,11 @@ function connectWebSocket() {
updateActiveConversions(packet.data_convert); updateActiveConversions(packet.data_convert);
} else if (packet.data_queue !== undefined) { } else if (packet.data_queue !== undefined) {
updateQueue(packet.data_queue); updateQueue(packet.data_queue);
} else if (packet.data_log !== undefined) {
// Log-Nachrichten ans Benachrichtigungs-System weiterleiten
if (typeof addNotification === "function") {
addNotification(packet.data_log.message, packet.data_log.level);
}
} }
} catch (e) { } catch (e) {
console.error("WebSocket Nachricht parsen fehlgeschlagen:", e); console.error("WebSocket Nachricht parsen fehlgeschlagen:", e);

View file

@ -14,7 +14,7 @@
<h1>VideoKonverter</h1> <h1>VideoKonverter</h1>
</div> </div>
<nav> <nav>
<a href="/" class="nav-link {% if request.path == '/' %}active{% endif %}">Dashboard</a> <a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a>
<a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a> <a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a>
<a href="/admin" class="nav-link {% if request.path == '/admin' %}active{% endif %}">Einstellungen</a> <a href="/admin" class="nav-link {% if request.path == '/admin' %}active{% endif %}">Einstellungen</a>
<a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a> <a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a>
@ -54,7 +54,6 @@
// === Benachrichtigungs-System === // === Benachrichtigungs-System ===
const notifications = []; const notifications = [];
let unreadErrors = 0; let unreadErrors = 0;
let lastLogId = 0;
function toggleNotificationPanel() { function toggleNotificationPanel() {
const panel = document.getElementById("notification-panel"); const panel = document.getElementById("notification-panel");
@ -128,25 +127,36 @@
.replace(/>/g, "&gt;"); .replace(/>/g, "&gt;");
} }
// Log-Polling vom Server // Log-Empfang per WebSocket (kein Polling mehr)
async function pollLogs() { // WebSocket sendet {data_log: {level, message}} - wird in websocket.js
try { // oder hier abgefangen, je nachdem welche Seite geladen ist.
const r = await fetch(`/api/logs?since=${lastLogId}`); let _logWs = null;
const data = await r.json();
if (data.logs && data.logs.length) { function connectLogWebSocket() {
for (const log of data.logs) { const proto = location.protocol === "https:" ? "wss:" : "ws:";
addNotification(log.message, log.level); const url = `${proto}//${location.host}/ws`;
if (log.id > lastLogId) lastLogId = log.id; _logWs = new WebSocket(url);
}
_logWs.onmessage = function(event) {
try {
const packet = JSON.parse(event.data);
if (packet.data_log) {
addNotification(packet.data_log.message, packet.data_log.level);
} }
} catch (e) { } catch (e) {
// Ignorieren falls Endpoint nicht existiert // JSON-Parse-Fehler ignorieren
} }
};
_logWs.onclose = function() {
setTimeout(connectLogWebSocket, 5000);
};
} }
// Polling starten // Nur Log-WebSocket starten wenn kein globaler WS existiert (Dashboard hat eigenen)
setInterval(pollLogs, 2000); if (!window.WS_URL) {
connectLogWebSocket();
}
</script> </script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}

View file

@ -457,6 +457,64 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Video-Player Modal -->
<div id="player-modal" class="modal-overlay player-overlay" style="display:none">
<div class="player-container">
<div class="player-header">
<span id="player-title">Video</span>
<button class="btn-close" onclick="closePlayer()">&times;</button>
</div>
<video id="player-video" controls preload="metadata">
Dein Browser unterstuetzt kein HTML5-Video.
</video>
</div>
</div>
<!-- Import-Zuordnungs-Modal -->
<div id="import-assign-modal" class="modal-overlay" style="display:none">
<div class="modal modal-small">
<div class="modal-header">
<h2>Datei zuordnen</h2>
<button class="btn-close" onclick="closeImportAssignModal()">&times;</button>
</div>
<div class="modal-body" style="padding:1rem">
<div class="text-muted" style="margin-bottom:0.8rem;font-size:0.85rem">
Datei: <strong id="import-assign-filename"></strong>
</div>
<div class="form-group">
<label>Serie suchen (TVDB)</label>
<input type="text" id="import-assign-search" placeholder="Serienname..."
oninput="debounceAssignSearch()"
style="width:100%;background:#252525;color:#ddd;border:1px solid #333;border-radius:5px;padding:0.4rem 0.6rem">
</div>
<div id="import-assign-results" class="tvdb-results" style="max-height:200px;overflow-y:auto"></div>
<div id="import-assign-selected" style="display:none; margin:0.5rem 0; padding:0.5rem; background:#1a3a1a; border:1px solid #2a5a2a; border-radius:5px">
Ausgewaehlt: <strong id="import-assign-selected-name"></strong>
<button class="btn-small btn-secondary" onclick="selectAssignSeries(null, '')" style="float:right;font-size:0.7rem">Loesen</button>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.5rem; margin-top:0.8rem">
<div class="form-group">
<label>Staffel</label>
<input type="number" id="import-assign-season" min="0" max="99" placeholder="1"
style="width:100%;background:#252525;color:#ddd;border:1px solid #333;border-radius:5px;padding:0.4rem 0.6rem">
</div>
<div class="form-group">
<label>Episode</label>
<input type="number" id="import-assign-episode" min="0" max="999" placeholder="1"
style="width:100%;background:#252525;color:#ddd;border:1px solid #333;border-radius:5px;padding:0.4rem 0.6rem">
</div>
</div>
<div class="form-actions" style="margin-top:1rem">
<button class="btn-primary" onclick="submitImportAssign()">Zuordnen</button>
<button class="btn-secondary" onclick="closeImportAssignModal()">Abbrechen</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View file

@ -8,7 +8,7 @@ services:
restart: unless-stopped restart: unless-stopped
user: "${PUID:-99}:${PGID:-100}" user: "${PUID:-99}:${PGID:-100}"
ports: ports:
- "8080:8080" - "${VK_PORT:-8080}:8080"
volumes: volumes:
# Konfiguration (persistent) # Konfiguration (persistent)
- ./app/cfg:/opt/video-konverter/app/cfg - ./app/cfg:/opt/video-konverter/app/cfg
@ -25,8 +25,27 @@ services:
group_add: group_add:
- "video" - "video"
environment: environment:
# GPU-Treiber
- LIBVA_DRIVER_NAME=iHD - LIBVA_DRIVER_NAME=iHD
- LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri - LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri
# === VideoKonverter Konfiguration (VK_*) ===
# Alle Werte ueberschreiben die settings.yaml
# Datenbank
- VK_DB_HOST=${VK_DB_HOST:-192.168.155.11}
- VK_DB_PORT=${VK_DB_PORT:-3306}
- VK_DB_USER=${VK_DB_USER:-video}
- VK_DB_PASSWORD=${VK_DB_PASSWORD:-8715}
- VK_DB_NAME=${VK_DB_NAME:-video_converter}
# Encoding
- VK_MODE=gpu
- VK_GPU_DEVICE=${VK_GPU_DEVICE:-/dev/dri/renderD128}
- VK_MAX_JOBS=${VK_MAX_JOBS:-1}
- VK_DEFAULT_PRESET=${VK_DEFAULT_PRESET:-gpu_av1}
# Library / TVDB
- VK_TVDB_API_KEY=${VK_TVDB_API_KEY:-}
- VK_TVDB_LANGUAGE=${VK_TVDB_LANGUAGE:-deu}
# Logging
- VK_LOG_LEVEL=${VK_LOG_LEVEL:-INFO}
profiles: profiles:
- gpu - gpu
@ -39,7 +58,7 @@ services:
container_name: video-konverter-cpu container_name: video-konverter-cpu
user: "${PUID:-99}:${PGID:-100}" user: "${PUID:-99}:${PGID:-100}"
ports: ports:
- "8080:8080" - "${VK_PORT:-8080}:8080"
volumes: volumes:
- ./app/cfg:/opt/video-konverter/app/cfg - ./app/cfg:/opt/video-konverter/app/cfg
- ./data:/opt/video-konverter/data - ./data:/opt/video-konverter/data
@ -47,6 +66,21 @@ services:
# /mnt 1:1 durchreichen - Pfade identisch zum Host # /mnt 1:1 durchreichen - Pfade identisch zum Host
- /mnt:/mnt:rw - /mnt:/mnt:rw
environment: environment:
- VIDEO_KONVERTER_MODE=cpu # === VideoKonverter Konfiguration (VK_*) ===
# Datenbank
- VK_DB_HOST=${VK_DB_HOST:-192.168.155.11}
- VK_DB_PORT=${VK_DB_PORT:-3306}
- VK_DB_USER=${VK_DB_USER:-video}
- VK_DB_PASSWORD=${VK_DB_PASSWORD:-8715}
- VK_DB_NAME=${VK_DB_NAME:-video_converter}
# Encoding
- VK_MODE=cpu
- VK_MAX_JOBS=${VK_MAX_JOBS:-1}
- VK_DEFAULT_PRESET=${VK_DEFAULT_PRESET:-cpu_av1}
# Library / TVDB
- VK_TVDB_API_KEY=${VK_TVDB_API_KEY:-}
- VK_TVDB_LANGUAGE=${VK_TVDB_LANGUAGE:-deu}
# Logging
- VK_LOG_LEVEL=${VK_LOG_LEVEL:-INFO}
profiles: profiles:
- cpu - cpu