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>
This commit is contained in:
parent
d65ca027e0
commit
ea5a81cd17
15 changed files with 1055 additions and 136 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -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
|
||||||
|
|
|
||||||
203
app/config.py
203
app/config.py
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">▶</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">✕</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">▶</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">✕</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">▶</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">✕</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">📁</span>
|
<span class="fb-icon">📁</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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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, ">");
|
.replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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()">×</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()">×</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 %}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue