Compare commits
No commits in common. "ff04bb2e9ecdeb51fa4803af61b924a261a73c5a" and "d65ca027e02ff4a69e9cc0beae9c911538e49777" have entirely different histories.
ff04bb2e9e
...
d65ca027e0
16 changed files with 138 additions and 1062 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -17,16 +17,6 @@ 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,16 +1,4 @@
|
||||||
"""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
|
||||||
|
|
@ -18,107 +6,9 @@ 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':
|
||||||
|
|
@ -138,7 +28,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -149,89 +38,55 @@ class Config:
|
||||||
self._apply_env_overrides()
|
self._apply_env_overrides()
|
||||||
|
|
||||||
def _load_settings(self) -> None:
|
def _load_settings(self) -> None:
|
||||||
"""Laedt settings.yaml oder erzeugt Defaults"""
|
"""Laedt settings.yaml"""
|
||||||
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 Exception as e:
|
except FileNotFoundError:
|
||||||
logging.error(f"Settings lesen fehlgeschlagen: {e}")
|
logging.error(f"Settings nicht gefunden: {settings_file}")
|
||||||
self.settings = copy.deepcopy(_DEFAULT_SETTINGS)
|
self.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 Exception as e:
|
except FileNotFoundError:
|
||||||
logging.error(f"Presets lesen fehlgeschlagen: {e}")
|
logging.error(f"Presets nicht gefunden: {presets_file}")
|
||||||
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 (VK_*) ueberschreiben Settings.
|
"""Umgebungsvariablen ueberschreiben Settings"""
|
||||||
Unterstuetzt auch alte Variablennamen per Alias-Mapping."""
|
env_mode = os.environ.get("VIDEO_KONVERTER_MODE")
|
||||||
applied = []
|
if env_mode and env_mode in ("cpu", "gpu", "auto"):
|
||||||
|
self.settings.setdefault("encoding", {})["mode"] = env_mode
|
||||||
# Aliase aufloesen (z.B. VIDEO_KONVERTER_MODE -> VK_MODE)
|
logging.info(f"Encoding-Modus per Umgebungsvariable: {env_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"""
|
||||||
self._save_yaml(self._cfg_path / "settings.yaml", self.settings)
|
settings_file = self._cfg_path / "settings.yaml"
|
||||||
|
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"""
|
||||||
self._save_yaml(self._cfg_path / "presets.yaml", self.presets)
|
presets_file = self._cfg_path / "presets.yaml"
|
||||||
|
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,5 +1,4 @@
|
||||||
"""REST API Endpoints"""
|
"""REST API Endpoints"""
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -8,13 +7,11 @@ 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,
|
scanner: ScannerService) -> None:
|
||||||
ws_manager: WebSocketManager = None) -> None:
|
|
||||||
"""Registriert alle API-Routes"""
|
"""Registriert alle API-Routes"""
|
||||||
|
|
||||||
# --- Job-Management ---
|
# --- Job-Management ---
|
||||||
|
|
@ -338,33 +335,42 @@ 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 via WebSocket ---
|
# --- Logs ---
|
||||||
|
|
||||||
class WebSocketLogHandler(logging.Handler):
|
# In-Memory Log-Buffer
|
||||||
"""Pusht Logs direkt per WebSocket an alle Clients"""
|
_log_buffer = []
|
||||||
def __init__(self, ws_mgr: WebSocketManager):
|
_log_id = 0
|
||||||
super().__init__()
|
_MAX_LOGS = 200
|
||||||
self._ws_manager = ws_mgr
|
|
||||||
|
|
||||||
|
class WebLogHandler(logging.Handler):
|
||||||
|
"""Handler der Logs an den Buffer sendet"""
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
if not self._ws_manager or not self._ws_manager.clients:
|
nonlocal _log_id
|
||||||
return
|
_log_id += 1
|
||||||
try:
|
entry = {
|
||||||
loop = asyncio.get_running_loop()
|
"id": _log_id,
|
||||||
loop.create_task(
|
"level": record.levelname,
|
||||||
self._ws_manager.broadcast_log(
|
"message": record.getMessage(),
|
||||||
record.levelname, record.getMessage()
|
"time": record.created,
|
||||||
)
|
}
|
||||||
)
|
_log_buffer.append(entry)
|
||||||
except RuntimeError:
|
# Buffer begrenzen
|
||||||
pass
|
while len(_log_buffer) > _MAX_LOGS:
|
||||||
|
_log_buffer.pop(0)
|
||||||
|
|
||||||
if ws_manager:
|
# Handler registrieren
|
||||||
ws_log_handler = WebSocketLogHandler(ws_manager)
|
web_handler = WebLogHandler()
|
||||||
ws_log_handler.setLevel(logging.INFO)
|
web_handler.setLevel(logging.INFO)
|
||||||
logging.getLogger().addHandler(ws_log_handler)
|
logging.getLogger().addHandler(web_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,19 +597,6 @@ 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:
|
||||||
|
|
@ -842,7 +829,6 @@ 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()
|
||||||
|
|
@ -915,25 +901,9 @@ 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 (onerror fuer SMB/CIFS Permission-Probleme)
|
# Ordner loeschen
|
||||||
def _rm_error(func, path, exc_info):
|
|
||||||
"""Bei Permission-Fehler: Schreibrechte setzen und nochmal versuchen"""
|
|
||||||
import stat
|
|
||||||
try:
|
try:
|
||||||
os.chmod(path, stat.S_IRWXU)
|
shutil.rmtree(folder_path)
|
||||||
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}")
|
||||||
|
|
@ -1247,158 +1217,6 @@ 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)
|
||||||
|
|
@ -1412,9 +1230,6 @@ 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)
|
||||||
|
|
@ -1510,18 +1325,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -146,13 +146,8 @@ 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("/", redirect_to_library)
|
app.router.add_get("/", dashboard)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,6 @@ 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,8 +60,7 @@ 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,14 +133,6 @@ 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 "Unbekannte Serie",
|
tvdb_name or series_name or "Unbekannt",
|
||||||
season, episode,
|
season, episode,
|
||||||
tvdb_ep_title or "",
|
tvdb_ep_title or "",
|
||||||
ext,
|
ext,
|
||||||
|
|
@ -456,21 +456,14 @@ class ImporterService:
|
||||||
# Season-Ordner
|
# Season-Ordner
|
||||||
season_dir = season_pattern.format(season=s)
|
season_dir = season_pattern.format(season=s)
|
||||||
|
|
||||||
# Dateiname - kein Titel: ohne Titel-Teil, sonst mit
|
# Dateiname
|
||||||
try:
|
try:
|
||||||
if title:
|
|
||||||
filename = pattern.format(
|
filename = pattern.format(
|
||||||
series=series, season=s, episode=e,
|
series=series, season=s, episode=e,
|
||||||
title=title, ext=ext
|
title=title or "Unbekannt", ext=ext
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Ohne Titel: "Serie - S01E03.ext"
|
|
||||||
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
|
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
if title:
|
filename = f"{series} - S{s:02d}E{e:02d} - {title or 'Unbekannt'}.{ext}"
|
||||||
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 ['<', '>', ':', '"', '|', '?', '*']:
|
||||||
|
|
@ -645,34 +638,6 @@ 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(
|
||||||
|
|
@ -940,118 +905,6 @@ 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,15 +385,8 @@ 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:
|
||||||
os.chmod(path, stat.S_IRWXU)
|
shutil.rmtree(folder_path)
|
||||||
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 "
|
||||||
|
|
@ -416,56 +409,6 @@ 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"""
|
||||||
|
|
@ -1657,15 +1600,8 @@ 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:
|
||||||
os.chmod(path, stat.S_IRWXU)
|
shutil.rmtree(folder_path)
|
||||||
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,54 +1441,6 @@ 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,13 +262,7 @@ 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>';
|
||||||
|
|
||||||
|
|
@ -287,8 +281,7 @@ 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) ===
|
||||||
|
|
@ -313,7 +306,6 @@ 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>
|
||||||
|
|
@ -323,11 +315,7 @@ 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>
|
<td><button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button></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>';
|
||||||
|
|
@ -569,18 +557,13 @@ 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(epTitle)}</td>
|
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(ep.episode_title || ep.file_name || "-")}</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>
|
<td><button class="btn-small btn-primary" onclick="convertVideo(${ep.id})">Conv</button></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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -848,7 +831,6 @@ 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>
|
||||||
|
|
@ -856,11 +838,7 @@ 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>
|
<td><button class="btn-small btn-primary" onclick="convertVideo(${v.id})">Conv</button></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>';
|
||||||
|
|
@ -1914,10 +1892,10 @@ function importBrowse(path) {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren
|
// Unterordner
|
||||||
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" onclick="importFolderClick('${escapeHtml(f.path)}', this)">
|
html += `<div class="import-browser-folder" ondblclick="importBrowse('${escapeHtml(f.path)}')" onclick="importSelectFolder('${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>
|
||||||
|
|
@ -1938,23 +1916,6 @@ 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(
|
||||||
|
|
@ -2034,10 +1995,8 @@ 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 UND keine pending Items
|
// Start-Button nur wenn keine ungeloesten Konflikte
|
||||||
const hasUnresolved = items.some(i =>
|
const hasUnresolved = items.some(i => i.status === "conflict" && !i.user_action);
|
||||||
(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) {
|
||||||
|
|
@ -2053,13 +2012,11 @@ 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() : "-";
|
||||||
|
|
@ -2075,17 +2032,13 @@ 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") {
|
||||||
actionHtml = `
|
// TVDB-Suchfeld fuer manuelles Matching
|
||||||
<button class="btn-small btn-primary" onclick="openImportAssignModal(${item.id}, '${escapeAttr(sourceName)}')">Zuordnen</button>
|
actionHtml = `<button class="btn-small btn-secondary" onclick="openImportTvdbSearch(${item.id})">TVDB suchen</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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowClass = item.status === "conflict" ? "row-conflict"
|
html += `<tr class="${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>
|
||||||
|
|
@ -2112,115 +2065,38 @@ function resolveImportConflict(itemId, action) {
|
||||||
.catch(e => alert("Fehler: " + e));
|
.catch(e => alert("Fehler: " + e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Import-Zuordnungs-Modal ===
|
function openImportTvdbSearch(itemId) {
|
||||||
|
// Einfaches Prompt fuer TVDB-Suche
|
||||||
let _assignItemId = null;
|
const query = prompt("TVDB-Serienname eingeben:");
|
||||||
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.error) { results.innerHTML = `<div class="loading-msg">${escapeHtml(data.error)}</div>`; return; }
|
if (!data.results || !data.results.length) { alert("Keine Ergebnisse"); return; }
|
||||||
if (!data.results || !data.results.length) { results.innerHTML = '<div class="loading-msg">Keine Ergebnisse</div>'; return; }
|
// Erste 5 anzeigen
|
||||||
results.innerHTML = data.results.slice(0, 8).map(r => `
|
const choices = data.results.slice(0, 5).map((r, i) =>
|
||||||
<div class="tvdb-result" onclick="selectAssignSeries(${r.tvdb_id}, '${escapeAttr(r.name)}')">
|
`${i + 1}. ${r.name} (${r.year || "?"})`
|
||||||
${r.poster ? `<img src="${r.poster}" alt="" class="tvdb-thumb">` : ""}
|
).join("\n");
|
||||||
<div>
|
const choice = prompt(`Ergebnisse:\n${choices}\n\nNummer eingeben:`);
|
||||||
<strong>${escapeHtml(r.name)}</strong>
|
if (!choice) return;
|
||||||
<span class="text-muted">${r.year || ""}</span>
|
const idx = parseInt(choice) - 1;
|
||||||
<p class="tvdb-overview">${escapeHtml((r.overview || "").substring(0, 120))}</p>
|
if (idx < 0 || idx >= data.results.length) return;
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join("");
|
|
||||||
})
|
|
||||||
.catch(e => { results.innerHTML = `<div class="loading-msg">Fehler: ${e}</div>`; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAssignSeries(tvdbId, name) {
|
const selected = data.results[idx];
|
||||||
_assignTvdbId = tvdbId;
|
fetch(`/api/library/import/items/${itemId}`, {
|
||||||
_assignSeriesName = name;
|
method: "PUT",
|
||||||
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({
|
||||||
series_name: seriesName,
|
tvdb_series_id: selected.tvdb_id,
|
||||||
season: season,
|
tvdb_series_name: selected.name,
|
||||||
episode: episode,
|
status: "matched",
|
||||||
tvdb_id: _assignTvdbId || null,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
|
||||||
.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())
|
.then(() => refreshImportPreview())
|
||||||
.catch(e => alert("Fehler: " + e));
|
.catch(e => alert("Fehler: " + e));
|
||||||
|
})
|
||||||
|
.catch(e => alert("Fehler: " + e));
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshImportPreview() {
|
function refreshImportPreview() {
|
||||||
|
|
@ -2398,71 +2274,3 @@ 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,11 +32,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<h1>VideoKonverter</h1>
|
<h1>VideoKonverter</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a>
|
<a href="/" class="nav-link {% if request.path == '/' %}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,6 +54,7 @@
|
||||||
// === 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");
|
||||||
|
|
@ -127,36 +128,25 @@
|
||||||
.replace(/>/g, ">");
|
.replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log-Empfang per WebSocket (kein Polling mehr)
|
// Log-Polling vom Server
|
||||||
// WebSocket sendet {data_log: {level, message}} - wird in websocket.js
|
async function pollLogs() {
|
||||||
// oder hier abgefangen, je nachdem welche Seite geladen ist.
|
|
||||||
let _logWs = null;
|
|
||||||
|
|
||||||
function connectLogWebSocket() {
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
const url = `${proto}//${location.host}/ws`;
|
|
||||||
_logWs = new WebSocket(url);
|
|
||||||
|
|
||||||
_logWs.onmessage = function(event) {
|
|
||||||
try {
|
try {
|
||||||
const packet = JSON.parse(event.data);
|
const r = await fetch(`/api/logs?since=${lastLogId}`);
|
||||||
if (packet.data_log) {
|
const data = await r.json();
|
||||||
addNotification(packet.data_log.message, packet.data_log.level);
|
|
||||||
|
if (data.logs && data.logs.length) {
|
||||||
|
for (const log of data.logs) {
|
||||||
|
addNotification(log.message, log.level);
|
||||||
|
if (log.id > lastLogId) lastLogId = log.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// JSON-Parse-Fehler ignorieren
|
// Ignorieren falls Endpoint nicht existiert
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
_logWs.onclose = function() {
|
|
||||||
setTimeout(connectLogWebSocket, 5000);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur Log-WebSocket starten wenn kein globaler WS existiert (Dashboard hat eigenen)
|
// Polling starten
|
||||||
if (!window.WS_URL) {
|
setInterval(pollLogs, 2000);
|
||||||
connectLogWebSocket();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -457,64 +457,6 @@
|
||||||
</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:
|
||||||
- "${VK_PORT:-8080}:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
# Konfiguration (persistent)
|
# Konfiguration (persistent)
|
||||||
- ./app/cfg:/opt/video-konverter/app/cfg
|
- ./app/cfg:/opt/video-konverter/app/cfg
|
||||||
|
|
@ -25,27 +25,8 @@ 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
|
||||||
|
|
||||||
|
|
@ -58,7 +39,7 @@ services:
|
||||||
container_name: video-konverter-cpu
|
container_name: video-konverter-cpu
|
||||||
user: "${PUID:-99}:${PGID:-100}"
|
user: "${PUID:-99}:${PGID:-100}"
|
||||||
ports:
|
ports:
|
||||||
- "${VK_PORT:-8080}:8080"
|
- "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
|
||||||
|
|
@ -66,21 +47,6 @@ 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:
|
||||||
# === VideoKonverter Konfiguration (VK_*) ===
|
- VIDEO_KONVERTER_MODE=cpu
|
||||||
# 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