diff --git a/requirements.txt b/requirements.txt index 8a0330c..f1ef111 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ jinja2>=3.1.0 PyYAML>=6.0 aiomysql>=0.2.0 tvdb-v4-official>=1.1.0 +bcrypt>=4.0 +qrcode[pil]>=7.0 diff --git a/tizen-app/INSTALL.md b/tizen-app/INSTALL.md new file mode 100644 index 0000000..7bf1af3 --- /dev/null +++ b/tizen-app/INSTALL.md @@ -0,0 +1,157 @@ +# VideoKonverter - Samsung Tizen TV Installation + +Die VideoKonverter TV-App auf einem Samsung Smart TV (Tizen) installieren. + +## Voraussetzungen + +- Samsung Smart TV mit Tizen OS (ab 2017) +- PC und TV im gleichen Netzwerk +- Samsung Developer Account (kostenlos): https://developer.samsung.com/ +- Tizen Studio auf dem PC + +## Schritt 1: Tizen Studio installieren + +Download: https://developer.tizen.org/development/tizen-studio/download + +### Linux (Manjaro/Arch) + +```bash +# Installer herunterladen und ausfuehren +chmod +x web-ide_Tizen_Studio_*.bin +./web-ide_Tizen_Studio_*.bin + +# Nach Installation: Tools liegen unter ~/tizen-studio/ +# Package Manager oeffnen und installieren: +# - Tizen SDK Tools +# - Samsung TV Extensions (Extension SDK Tab) +# - Samsung Certificate Extension (Extension SDK Tab) +``` + +### Wichtige Pfade nach Installation + +``` +~/tizen-studio/tools/sdb # Smart Development Bridge (wie adb) +~/tizen-studio/tools/ide/bin/tizen # CLI-Tool +~/tizen-studio/ide/TizenStudio # IDE starten +``` + +## Schritt 2: Samsung Developer Zertifikat erstellen + +Das Zertifikat signiert die App fuer deinen TV. Ohne Zertifikat verweigert der TV die Installation. + +1. Tizen Studio IDE starten +2. **Tools > Certificate Manager** oeffnen +3. **"+" klicken** > **Samsung** waehlen (nicht Tizen!) +4. **TV** als Geraetetyp waehlen +5. Samsung Developer Account Daten eingeben +6. Zertifikat wird erstellt und gespeichert + +**WICHTIG:** Zertifikat sichern! Bei App-Updates muss das gleiche Zertifikat verwendet werden. + +## Schritt 3: TV vorbereiten (Developer Mode) + +### Ueber TV-Menue + +1. TV einschalten +2. **Apps** oeffnen (Home > Apps) +3. Im Apps-Bereich die Ziffern **12345** eingeben (bei neueren Fernbedienungen evtl. ueber das virtuelle Nummernfeld) +4. Developer Mode **ON** schalten +5. **Host PC IP** eingeben (IP des PCs mit Tizen Studio) +6. TV neustarten + +### Alternative (neuere TVs ab 2024/Tizen 8) + +Falls der 12345-Trick nicht funktioniert: +- **Einstellungen > Allgemein > System-Manager** nach Developer Mode suchen +- Oder direkt ueber Tizen Studio Device Manager verbinden (siehe Schritt 4) + +## Schritt 4: TV verbinden + +1. **TV-IP herausfinden:** TV > Einstellungen > Allgemein > Netzwerk > IP-Adresse +2. In Tizen Studio: **Tools > Device Manager** oeffnen +3. **Remote Device Manager** > TV-IP eingeben > Verbinden +4. TV sollte in der Geraete-Liste erscheinen +5. **Rechtsklick auf TV > "Permit to install applications"** + +### Oder per Kommandozeile + +```bash +# Verbinden +~/tizen-studio/tools/sdb connect + +# Pruefen +~/tizen-studio/tools/sdb devices +``` + +## Schritt 5: App installieren + +### Option A: Ueber Tizen Studio IDE (empfohlen) + +1. Device Manager: TV ist verbunden +2. **Rechtsklick auf TV > "Install app"** +3. `VideoKonverter.wgt` auswaehlen +4. Installation laeuft automatisch + +### Option B: Per Kommandozeile + +```bash +cd /pfad/zu/tizen-app/ +~/tizen-studio/tools/ide/bin/tizen install -n VideoKonverter.wgt -t +``` + +Der TV-Name wird mit `sdb devices` angezeigt. + +### Option C: Docker (ohne Tizen Studio) + +Falls Tizen Studio zu aufwaendig ist - das Georift Docker-Image hat alles drin: + +```bash +# Generisches WGT installieren (ohne Tizen Studio auf dem PC) +docker run --rm -v $(pwd):/app georift/install-jellyfin-tizen \ + --wgt /app/VideoKonverter.wgt +``` + +Siehe: https://github.com/Georift/install-jellyfin-tizen + +## Schritt 6: App starten + +1. App erscheint als **"VideoKonverter"** im Apps-Menue des TVs +2. Beim **ersten Start**: Server-IP eingeben (z.B. `192.168.155.12:8080`) +3. Die IP wird gespeichert - beim naechsten Start verbindet die App automatisch +4. Login mit TV-App Benutzerdaten (erstellt in der Admin-Oberflaeche) + +## Wie funktioniert die App? + +Die Tizen-App ist nur ein **duenner Wrapper**. Sie macht nichts ausser: + +1. Beim ersten Start die Server-Adresse abfragen +2. Weiterleiten auf `http:///tv/` +3. Ab dann kommt alles vom Docker-Container + +**Vorteil:** Bei Software-Updates muss nur der Docker-Container aktualisiert werden. +Die App auf dem TV muss NICHT neu installiert werden. + +## Fehlerbehebung + +### TV wird nicht gefunden +- Sind PC und TV im gleichen Netzwerk/VLAN? +- Ist Developer Mode auf dem TV aktiviert? +- Firewall auf dem PC deaktiviert/Port 26101 offen? + +### Installation schlaegt fehl +- Zertifikat korrekt erstellt? (Samsung, nicht Tizen) +- "Permit to install applications" ausgefuehrt? +- Alte Version erst deinstallieren: `sdb shell 0 vd_appuninstall vkTVApp001.VideoKonverter` + +### App startet nicht / weisser Bildschirm +- Server laeuft? `curl http://:8080/tv/` +- Richtige IP eingegeben? +- Browser-Cache auf TV leeren: App deinstallieren und neu installieren + +## Links + +- Samsung Developer Portal: https://developer.samsung.com/smarttv/develop +- Tizen Studio Download: https://developer.tizen.org/development/tizen-studio/download +- Samsung TV Quick-Start Guide: https://developer.samsung.com/smarttv/develop/getting-started/quick-start-guide.html +- Jellyfin Tizen (aehnliches Projekt): https://github.com/jellyfin/jellyfin-tizen +- Samsung-Jellyfin-Installer (GUI): https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer diff --git a/tizen-app/VideoKonverter.wgt b/tizen-app/VideoKonverter.wgt new file mode 100644 index 0000000..089f8bf Binary files /dev/null and b/tizen-app/VideoKonverter.wgt differ diff --git a/tizen-app/config.xml b/tizen-app/config.xml new file mode 100644 index 0000000..a5f2828 --- /dev/null +++ b/tizen-app/config.xml @@ -0,0 +1,30 @@ + + + + VideoKonverter + VideoKonverter TV-App - Serien und Filme streamen + + data IT solution - Eduard Wisch + + + + + + + + + + + + + + + + + + + + + diff --git a/tizen-app/icon.png b/tizen-app/icon.png new file mode 100644 index 0000000..8abcc68 Binary files /dev/null and b/tizen-app/icon.png differ diff --git a/tizen-app/index.html b/tizen-app/index.html new file mode 100644 index 0000000..c3b7ab2 --- /dev/null +++ b/tizen-app/index.html @@ -0,0 +1,136 @@ + + + + + + VideoKonverter + + + +
+

VideoKonverter TV

+

Server-Adresse eingeben:

+ +
+ +

Die Adresse wird gespeichert und beim naechsten Start automatisch geladen.

+
+ + + + diff --git a/video-konverter/app/routes/api.py b/video-konverter/app/routes/api.py index 0ee8b91..b3f6c54 100644 --- a/video-konverter/app/routes/api.py +++ b/video-konverter/app/routes/api.py @@ -384,7 +384,85 @@ def setup_api_routes(app: web.Application, config: Config, ws_log_handler.setLevel(logging.INFO) logging.getLogger().addHandler(ws_log_handler) + # --- Server-Log lesen --- + + async def get_log(request: web.Request) -> web.Response: + """ + GET /api/log?lines=100&level=INFO + Gibt die letzten N Zeilen des Server-Logs zurueck. + """ + lines = int(request.query.get("lines", 100)) + level_filter = request.query.get("level", "").upper() + lines = min(lines, 5000) # Max 5000 Zeilen + + log_dir = Path(__file__).parent.parent.parent / "logs" + log_file = log_dir / "server.log" + + # Fallback: Aus dem logging-Handler lesen + log_entries = [] + + if log_file.exists(): + try: + with open(log_file, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + # Letzte N Zeilen + recent = all_lines[-lines:] if len(all_lines) > lines else all_lines + for line in recent: + line = line.rstrip() + if level_filter and level_filter not in line: + continue + log_entries.append(line) + except Exception as e: + return web.json_response( + {"error": f"Log lesen fehlgeschlagen: {e}"}, status=500 + ) + else: + # Kein Log-File: aus dem MemoryHandler lesen (falls vorhanden) + for handler in logging.getLogger().handlers: + if isinstance(handler, _MemoryLogHandler): + entries = handler.get_entries(lines) + for entry in entries: + if level_filter and level_filter not in entry: + continue + log_entries.append(entry) + break + + if not log_entries: + log_entries.append("Keine Log-Datei gefunden unter: " + str(log_file)) + + return web.json_response({ + "lines": log_entries, + "count": len(log_entries), + "source": str(log_file) if log_file.exists() else "memory", + }) + + # In-Memory Log-Handler (fuer Zugriff ohne Datei) + class _MemoryLogHandler(logging.Handler): + """Speichert die letzten N Log-Eintraege im Speicher""" + def __init__(self, max_entries: int = 2000): + super().__init__() + self._entries = [] + self._max = max_entries + + def emit(self, record): + msg = self.format(record) + self._entries.append(msg) + if len(self._entries) > self._max: + self._entries = self._entries[-self._max:] + + def get_entries(self, n: int = 100) -> list[str]: + return self._entries[-n:] + + # Memory-Handler installieren + _mem_handler = _MemoryLogHandler(2000) + _mem_handler.setLevel(logging.DEBUG) + _mem_handler.setFormatter(logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s" + )) + logging.getLogger().addHandler(_mem_handler) + # --- Routes registrieren --- + app.router.add_get("/api/log", get_log) app.router.add_get("/api/browse", get_browse) app.router.add_post("/api/upload", post_upload) app.router.add_post("/api/convert", post_convert) diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py new file mode 100644 index 0000000..5911b8b --- /dev/null +++ b/video-konverter/app/routes/tv_api.py @@ -0,0 +1,594 @@ +"""TV-App Routes - Seiten und API fuer Streaming-Frontend""" +import io +import json +import logging +from functools import wraps +from aiohttp import web +import aiohttp_jinja2 +import aiomysql + +from app.config import Config +from app.services.auth import AuthService +from app.services.library import LibraryService + + +def setup_tv_routes(app: web.Application, config: Config, + auth_service: AuthService, + library_service: LibraryService) -> None: + """Registriert alle TV-App Routes""" + + # --- Auth-Hilfsfunktionen --- + + async def get_tv_user(request: web.Request) -> dict | None: + """Prueft Session-Cookie, gibt User zurueck oder None""" + session_id = request.cookies.get("vk_session") + if not session_id: + return None + return await auth_service.validate_session(session_id) + + def require_auth(handler): + """Decorator: Leitet auf Login um wenn nicht eingeloggt""" + @wraps(handler) + async def wrapper(request): + user = await get_tv_user(request) + if not user: + raise web.HTTPFound("/tv/login") + request["tv_user"] = user + return await handler(request) + return wrapper + + # --- Login / Logout --- + + async def get_login(request: web.Request) -> web.Response: + """GET /tv/login - Login-Seite""" + # Bereits eingeloggt? -> Weiterleiten + user = await get_tv_user(request) + if user: + raise web.HTTPFound("/tv/") + return aiohttp_jinja2.render_template( + "tv/login.html", request, {"error": None} + ) + + async def post_login(request: web.Request) -> web.Response: + """POST /tv/login - Login verarbeiten""" + data = await request.post() + username = data.get("username", "").strip() + password = data.get("password", "") + + if not username or not password: + return aiohttp_jinja2.render_template( + "tv/login.html", request, + {"error": "Benutzername und Passwort eingeben"} + ) + + user = await auth_service.verify_login(username, password) + if not user: + return aiohttp_jinja2.render_template( + "tv/login.html", request, + {"error": "Falscher Benutzername oder Passwort"} + ) + + # Session erstellen + ua = request.headers.get("User-Agent", "") + session_id = await auth_service.create_session(user["id"], ua) + + resp = web.HTTPFound("/tv/") + resp.set_cookie( + "vk_session", session_id, + max_age=30 * 24 * 3600, # 30 Tage + httponly=True, + samesite="Lax", + path="/", + ) + return resp + + async def get_logout(request: web.Request) -> web.Response: + """GET /tv/logout - Session loeschen""" + session_id = request.cookies.get("vk_session") + if session_id: + await auth_service.delete_session(session_id) + resp = web.HTTPFound("/tv/login") + resp.del_cookie("vk_session", path="/") + return resp + + # --- TV-Seiten --- + + @require_auth + async def get_home(request: web.Request) -> web.Response: + """GET /tv/ - Startseite""" + user = request["tv_user"] + + # Daten laden + series = [] + movies = [] + continue_watching = [] + + pool = library_service._db_pool + if pool: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Serien laden (mit Berechtigungspruefung) + if user.get("can_view_series"): + series_query = """ + SELECT s.id, s.title, s.folder_name, s.poster_url, + s.genres, s.tvdb_id, + COUNT(v.id) as episode_count + FROM library_series s + LEFT JOIN library_videos v ON v.series_id = s.id + """ + params = [] + if user.get("allowed_paths"): + placeholders = ",".join( + ["%s"] * len(user["allowed_paths"])) + series_query += ( + f" WHERE s.library_path_id IN ({placeholders})" + ) + params = user["allowed_paths"] + series_query += ( + " GROUP BY s.id ORDER BY s.title LIMIT 20" + ) + await cur.execute(series_query, params) + series = await cur.fetchall() + + # Filme laden + if user.get("can_view_movies"): + movies_query = """ + SELECT m.id, m.title, m.folder_name, m.poster_url, + m.year, m.genres + FROM library_movies m + """ + params = [] + if user.get("allowed_paths"): + placeholders = ",".join( + ["%s"] * len(user["allowed_paths"])) + movies_query += ( + f" WHERE m.library_path_id IN ({placeholders})" + ) + params = user["allowed_paths"] + movies_query += " ORDER BY m.title LIMIT 20" + await cur.execute(movies_query, params) + movies = await cur.fetchall() + + # Weiterschauen + continue_watching = await auth_service.get_continue_watching( + user["id"] + ) + + return aiohttp_jinja2.render_template( + "tv/home.html", request, { + "user": user, + "active": "home", + "series": series, + "movies": movies, + "continue_watching": continue_watching, + } + ) + + @require_auth + async def get_series_list(request: web.Request) -> web.Response: + """GET /tv/series - Alle Serien""" + user = request["tv_user"] + if not user.get("can_view_series"): + raise web.HTTPFound("/tv/") + + series = [] + pool = library_service._db_pool + if pool: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + query = """ + SELECT s.id, s.title, s.folder_name, s.poster_url, + s.genres, s.tvdb_id, s.overview, + COUNT(v.id) as episode_count + FROM library_series s + LEFT JOIN library_videos v ON v.series_id = s.id + """ + params = [] + if user.get("allowed_paths"): + placeholders = ",".join( + ["%s"] * len(user["allowed_paths"])) + query += ( + f" WHERE s.library_path_id IN ({placeholders})" + ) + params = user["allowed_paths"] + query += " GROUP BY s.id ORDER BY s.title" + await cur.execute(query, params) + series = await cur.fetchall() + + return aiohttp_jinja2.render_template( + "tv/series.html", request, { + "user": user, + "active": "series", + "series": series, + } + ) + + @require_auth + async def get_series_detail(request: web.Request) -> web.Response: + """GET /tv/series/{id} - Serien-Detail mit Staffeln""" + user = request["tv_user"] + if not user.get("can_view_series"): + raise web.HTTPFound("/tv/") + + series_id = int(request.match_info["id"]) + + series = None + seasons = {} + pool = library_service._db_pool + if pool: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT id, title, folder_name, poster_url, + overview, genres, tvdb_id + FROM library_series WHERE id = %s + """, (series_id,)) + series = await cur.fetchone() + + if series: + await cur.execute(""" + SELECT id, file_name, season_number, + episode_number, episode_title, + duration_sec, file_size, + width, height, video_codec, + container + FROM library_videos + WHERE series_id = %s + ORDER BY season_number, episode_number, file_name + """, (series_id,)) + episodes = await cur.fetchall() + + for ep in episodes: + sn = ep.get("season_number") or 0 + if sn not in seasons: + seasons[sn] = [] + seasons[sn].append(ep) + + if not series: + raise web.HTTPFound("/tv/series") + + return aiohttp_jinja2.render_template( + "tv/series_detail.html", request, { + "user": user, + "active": "series", + "series": series, + "seasons": dict(sorted(seasons.items())), + } + ) + + @require_auth + async def get_movies_list(request: web.Request) -> web.Response: + """GET /tv/movies - Alle Filme""" + user = request["tv_user"] + if not user.get("can_view_movies"): + raise web.HTTPFound("/tv/") + + movies = [] + pool = library_service._db_pool + if pool: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + query = """ + SELECT m.id, m.title, m.folder_name, m.poster_url, + m.year, m.genres, m.overview + FROM library_movies m + """ + params = [] + if user.get("allowed_paths"): + placeholders = ",".join( + ["%s"] * len(user["allowed_paths"])) + query += ( + f" WHERE m.library_path_id IN ({placeholders})" + ) + params = user["allowed_paths"] + query += " ORDER BY m.title" + await cur.execute(query, params) + movies = await cur.fetchall() + + return aiohttp_jinja2.render_template( + "tv/movies.html", request, { + "user": user, + "active": "movies", + "movies": movies, + } + ) + + @require_auth + async def get_movie_detail(request: web.Request) -> web.Response: + """GET /tv/movies/{id} - Film-Detail""" + user = request["tv_user"] + if not user.get("can_view_movies"): + raise web.HTTPFound("/tv/") + + movie_id = int(request.match_info["id"]) + movie = None + videos = [] + pool = library_service._db_pool + if pool: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT id, title, folder_name, poster_url, + year, overview, genres + FROM library_movies WHERE id = %s + """, (movie_id,)) + movie = await cur.fetchone() + + if movie: + await cur.execute(""" + SELECT id, file_name, duration_sec, file_size, + width, height, video_codec, + container + FROM library_videos WHERE movie_id = %s + """, (movie_id,)) + videos = await cur.fetchall() + + if not movie: + raise web.HTTPFound("/tv/movies") + + return aiohttp_jinja2.render_template( + "tv/movie_detail.html", request, { + "user": user, + "active": "movies", + "movie": movie, + "videos": videos, + } + ) + + @require_auth + async def get_player(request: web.Request) -> web.Response: + """GET /tv/player?v={video_id} - Video-Player""" + user = request["tv_user"] + video_id = int(request.query.get("v", 0)) + if not video_id: + raise web.HTTPFound("/tv/") + + # Wiedergabe-Position laden + progress = await auth_service.get_progress(user["id"], video_id) + start_pos = 0 + if progress and not progress.get("completed"): + start_pos = progress.get("position_sec", 0) + + # Video-Info laden + video = None + pool = library_service._db_pool + if pool: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT v.id, v.file_name, v.duration_sec, + s.title as series_title, + v.season_number, v.episode_number, + v.episode_title + FROM library_videos v + LEFT JOIN library_series s ON v.series_id = s.id + WHERE v.id = %s + """, (video_id,)) + video = await cur.fetchone() + + if not video: + raise web.HTTPFound("/tv/") + + # Titel zusammenbauen + title = video.get("file_name", "Video") + if video.get("series_title"): + sn = video.get("season_number", 0) + en = video.get("episode_number", 0) + ep_title = video.get("episode_title", "") + title = f"{video['series_title']} - S{sn:02d}E{en:02d}" + if ep_title: + title += f" - {ep_title}" + + return aiohttp_jinja2.render_template( + "tv/player.html", request, { + "user": user, + "video": video, + "title": title, + "start_pos": start_pos, + } + ) + + @require_auth + async def get_search(request: web.Request) -> web.Response: + """GET /tv/search?q=... - Suchseite""" + user = request["tv_user"] + query = request.query.get("q", "").strip() + results_series = [] + results_movies = [] + + if query and len(query) >= 2: + pool = library_service._db_pool + if pool: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + search_term = f"%{query}%" + + if user.get("can_view_series"): + await cur.execute(""" + SELECT id, title, folder_name, poster_url, genres + FROM library_series + WHERE title LIKE %s OR folder_name LIKE %s + ORDER BY title LIMIT 50 + """, (search_term, search_term)) + results_series = await cur.fetchall() + + if user.get("can_view_movies"): + await cur.execute(""" + SELECT id, title, folder_name, poster_url, + year, genres + FROM library_movies + WHERE title LIKE %s OR folder_name LIKE %s + ORDER BY title LIMIT 50 + """, (search_term, search_term)) + results_movies = await cur.fetchall() + + return aiohttp_jinja2.render_template( + "tv/search.html", request, { + "user": user, + "active": "search", + "query": query, + "series": results_series, + "movies": results_movies, + } + ) + + # --- TV-API Endpoints --- + + @require_auth + async def post_watch_progress(request: web.Request) -> web.Response: + """POST /tv/api/watch-progress - Position speichern""" + user = request["tv_user"] + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Ungueltiges JSON"}, status=400) + + video_id = data.get("video_id") + position = data.get("position_sec", 0) + duration = data.get("duration_sec", 0) + + if not video_id: + return web.json_response( + {"error": "video_id fehlt"}, status=400) + + await auth_service.save_progress( + user["id"], video_id, position, duration + ) + return web.json_response({"ok": True}) + + @require_auth + async def get_watch_progress(request: web.Request) -> web.Response: + """GET /tv/api/watch-progress/{video_id}""" + user = request["tv_user"] + video_id = int(request.match_info["video_id"]) + progress = await auth_service.get_progress(user["id"], video_id) + return web.json_response(progress or {"position_sec": 0}) + + # --- QR-Code --- + + async def get_qrcode(request: web.Request) -> web.Response: + """GET /api/tv/qrcode - QR-Code als PNG""" + try: + import qrcode + except ImportError: + return web.json_response( + {"error": "qrcode nicht installiert"}, status=500) + + # URL ermitteln + srv = config.server_config + ext_url = srv.get("external_url", "") + if ext_url: + proto = "https" if srv.get("use_https") else "http" + base = f"{proto}://{ext_url}" + else: + base = f"http://{request.host}" + tv_url = f"{base}/tv/" + + qr = qrcode.QRCode(version=1, box_size=10, border=4) + qr.add_data(tv_url) + qr.make(fit=True) + img = qr.make_image(fill_color="white", back_color="#0f0f0f") + + buf = io.BytesIO() + img.save(buf, format="PNG") + return web.Response( + body=buf.getvalue(), content_type="image/png" + ) + + async def get_tv_url(request: web.Request) -> web.Response: + """GET /api/tv/url - TV-App URL als JSON""" + srv = config.server_config + ext_url = srv.get("external_url", "") + if ext_url: + proto = "https" if srv.get("use_https") else "http" + base = f"{proto}://{ext_url}" + else: + base = f"http://{request.host}" + return web.json_response({"url": f"{base}/tv/"}) + + # --- User-Verwaltung (fuer Admin-UI) --- + + async def get_users(request: web.Request) -> web.Response: + """GET /api/tv/users - Alle User auflisten""" + users = await auth_service.list_users() + return web.json_response({"users": users}) + + async def post_user(request: web.Request) -> web.Response: + """POST /api/tv/users - Neuen User erstellen""" + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Ungueltiges JSON"}, status=400) + + username = data.get("username", "").strip() + password = data.get("password", "") + if not username or not password: + return web.json_response( + {"error": "Username und Passwort noetig"}, status=400) + + user_id = await auth_service.create_user( + username=username, + password=password, + display_name=data.get("display_name", ""), + is_admin=data.get("is_admin", False), + can_view_series=data.get("can_view_series", True), + can_view_movies=data.get("can_view_movies", True), + allowed_paths=data.get("allowed_paths"), + ) + + if not user_id: + return web.json_response( + {"error": "User konnte nicht erstellt werden " + "(Name bereits vergeben?)"}, status=400) + + return web.json_response({"id": user_id, "message": "User erstellt"}) + + async def put_user(request: web.Request) -> web.Response: + """PUT /api/tv/users/{id} - User aendern""" + user_id = int(request.match_info["id"]) + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Ungueltiges JSON"}, status=400) + + success = await auth_service.update_user(user_id, **data) + if success: + return web.json_response({"message": "User aktualisiert"}) + return web.json_response( + {"error": "Aktualisierung fehlgeschlagen"}, status=400) + + async def delete_user(request: web.Request) -> web.Response: + """DELETE /api/tv/users/{id} - User loeschen""" + user_id = int(request.match_info["id"]) + success = await auth_service.delete_user(user_id) + if success: + return web.json_response({"message": "User geloescht"}) + return web.json_response( + {"error": "User nicht gefunden"}, status=404) + + # --- Routes registrieren --- + + # TV-Seiten (mit Auth via Decorator) + app.router.add_get("/tv/login", get_login) + app.router.add_post("/tv/login", post_login) + app.router.add_get("/tv/logout", get_logout) + app.router.add_get("/tv/", get_home) + app.router.add_get("/tv/series", get_series_list) + app.router.add_get("/tv/series/{id}", get_series_detail) + app.router.add_get("/tv/movies", get_movies_list) + app.router.add_get("/tv/movies/{id}", get_movie_detail) + app.router.add_get("/tv/player", get_player) + app.router.add_get("/tv/search", get_search) + + # TV-API (Watch-Progress) + app.router.add_post("/tv/api/watch-progress", post_watch_progress) + app.router.add_get( + "/tv/api/watch-progress/{video_id}", get_watch_progress) + + # Admin-API (QR-Code, User-Verwaltung) + app.router.add_get("/api/tv/qrcode", get_qrcode) + app.router.add_get("/api/tv/url", get_tv_url) + app.router.add_get("/api/tv/users", get_users) + app.router.add_post("/api/tv/users", post_user) + app.router.add_put("/api/tv/users/{id}", put_user) + app.router.add_delete("/api/tv/users/{id}", delete_user) diff --git a/video-konverter/app/server.py b/video-konverter/app/server.py index e4ec55a..b090943 100644 --- a/video-konverter/app/server.py +++ b/video-konverter/app/server.py @@ -14,9 +14,11 @@ from app.services.library import LibraryService from app.services.tvdb import TVDBService from app.services.cleaner import CleanerService from app.services.importer import ImporterService +from app.services.auth import AuthService from app.routes.api import setup_api_routes from app.routes.library_api import setup_library_routes from app.routes.pages import setup_page_routes +from app.routes.tv_api import setup_tv_routes class VideoKonverterServer: @@ -88,6 +90,9 @@ class VideoKonverterServer: # Seiten Routes setup_page_routes(self.app, self.config, self.queue_service) + # TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert) + self.auth_service = None + # Statische Dateien static_dir = Path(__file__).parent / "static" if static_dir.exists(): @@ -140,6 +145,17 @@ class VideoKonverterServer: await self.tvdb_service.init_db() await self.importer_service.init_db() + # TV-App Auth-Service initialisieren (braucht DB-Pool) + if self.library_service._db_pool: + async def _get_pool(): + return self.library_service._db_pool + self.auth_service = AuthService(_get_pool) + await self.auth_service.init_db() + setup_tv_routes( + self.app, self.config, + self.auth_service, self.library_service, + ) + host = self.config.server_config.get("host", "0.0.0.0") port = self.config.server_config.get("port", 8080) logging.info(f"Server bereit auf http://{host}:{port}") @@ -166,6 +182,7 @@ class VideoKonverterServer: f" Bibliothek: http://{host}:{port}/library\n" f" Admin: http://{host}:{port}/admin\n" f" Statistik: http://{host}:{port}/statistics\n" + f" TV-App: http://{host}:{port}/tv/\n" f" WebSocket: ws://{host}:{port}/ws\n" f" API: http://{host}:{port}/api/convert (POST)" ) diff --git a/video-konverter/app/services/auth.py b/video-konverter/app/services/auth.py new file mode 100644 index 0000000..067a24b --- /dev/null +++ b/video-konverter/app/services/auth.py @@ -0,0 +1,393 @@ +"""Authentifizierung und User-Verwaltung fuer die TV-App""" +import json +import logging +import secrets +import time +from typing import Optional + +import aiomysql +import bcrypt + + +class AuthService: + """Verwaltet TV-User, Sessions und Berechtigungen""" + + def __init__(self, db_pool_getter): + self._get_pool = db_pool_getter + + async def init_db(self) -> None: + """Erstellt DB-Tabellen fuer TV-Auth""" + pool = await self._get_pool() + if not pool: + logging.error("Auth: Kein DB-Pool verfuegbar") + return + + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS tv_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(128), + is_admin TINYINT DEFAULT 0, + can_view_series TINYINT DEFAULT 1, + can_view_movies TINYINT DEFAULT 1, + allowed_paths JSON DEFAULT NULL, + last_login TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_username (username) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS tv_sessions ( + id VARCHAR(64) PRIMARY KEY, + user_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + user_agent VARCHAR(512), + FOREIGN KEY (user_id) REFERENCES tv_users(id) ON DELETE CASCADE, + INDEX idx_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS tv_watch_progress ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + video_id INT NOT NULL, + position_sec DOUBLE DEFAULT 0, + duration_sec DOUBLE DEFAULT 0, + completed TINYINT DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + UNIQUE INDEX idx_user_video (user_id, video_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # Standard-Admin erstellen falls keine User existieren + await self._ensure_default_admin() + logging.info("TV-Auth: DB-Tabellen initialisiert") + + async def _ensure_default_admin(self) -> None: + """Erstellt admin/admin falls keine User existieren""" + pool = await self._get_pool() + if not pool: + return + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT COUNT(*) FROM tv_users") + row = await cur.fetchone() + if row[0] == 0: + await self.create_user( + "admin", "admin", + display_name="Administrator", + is_admin=True + ) + logging.info("TV-Auth: Standard-Admin erstellt (admin/admin)") + + # --- User-CRUD --- + + async def create_user(self, username: str, password: str, + display_name: str = None, is_admin: bool = False, + can_view_series: bool = True, + can_view_movies: bool = True, + allowed_paths: list = None) -> Optional[int]: + """Erstellt neuen User, gibt ID zurueck""" + pw_hash = bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + paths_json = json.dumps(allowed_paths) if allowed_paths else None + + pool = await self._get_pool() + if not pool: + return None + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO tv_users + (username, password_hash, display_name, is_admin, + can_view_series, can_view_movies, allowed_paths) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, (username, pw_hash, display_name, int(is_admin), + int(can_view_series), int(can_view_movies), paths_json)) + return cur.lastrowid + except Exception as e: + logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}") + return None + + async def update_user(self, user_id: int, **kwargs) -> bool: + """Aktualisiert User-Felder (password, display_name, Rechte)""" + pool = await self._get_pool() + if not pool: + return False + + updates = [] + values = [] + + if "password" in kwargs and kwargs["password"]: + pw_hash = bcrypt.hashpw( + kwargs["password"].encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + updates.append("password_hash = %s") + values.append(pw_hash) + + for field in ("display_name", "is_admin", + "can_view_series", "can_view_movies"): + if field in kwargs: + updates.append(f"{field} = %s") + val = kwargs[field] + if isinstance(val, bool): + val = int(val) + values.append(val) + + if "allowed_paths" in kwargs: + updates.append("allowed_paths = %s") + ap = kwargs["allowed_paths"] + values.append(json.dumps(ap) if ap else None) + + if not updates: + return False + + values.append(user_id) + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + f"UPDATE tv_users SET {', '.join(updates)} WHERE id = %s", + tuple(values) + ) + return True + except Exception as e: + logging.error(f"TV-Auth: User aktualisieren fehlgeschlagen: {e}") + return False + + async def delete_user(self, user_id: int) -> bool: + """Loescht User und alle Sessions""" + pool = await self._get_pool() + if not pool: + return False + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM tv_users WHERE id = %s", (user_id,) + ) + return cur.rowcount > 0 + except Exception as e: + logging.error(f"TV-Auth: User loeschen fehlgeschlagen: {e}") + return False + + async def list_users(self) -> list[dict]: + """Gibt alle User zurueck (ohne Passwort-Hash)""" + pool = await self._get_pool() + if not pool: + return [] + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT id, username, display_name, is_admin, + can_view_series, can_view_movies, allowed_paths, + last_login, created_at + FROM tv_users ORDER BY id + """) + rows = await cur.fetchall() + for row in rows: + # JSON-Feld parsen + if row.get("allowed_paths") and isinstance( + row["allowed_paths"], str): + row["allowed_paths"] = json.loads(row["allowed_paths"]) + # Timestamps als String + for k in ("last_login", "created_at"): + if row.get(k) and hasattr(row[k], "isoformat"): + row[k] = str(row[k]) + return rows + + async def get_user(self, user_id: int) -> Optional[dict]: + """Einzelnen User laden""" + pool = await self._get_pool() + if not pool: + return None + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT id, username, display_name, is_admin, + can_view_series, can_view_movies, allowed_paths, + last_login, created_at + FROM tv_users WHERE id = %s + """, (user_id,)) + row = await cur.fetchone() + if row and row.get("allowed_paths") and isinstance( + row["allowed_paths"], str): + row["allowed_paths"] = json.loads(row["allowed_paths"]) + return row + + # --- Login / Sessions --- + + async def verify_login(self, username: str, password: str) -> Optional[dict]: + """Prueft Credentials, gibt User-Dict zurueck oder None""" + pool = await self._get_pool() + if not pool: + return None + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM tv_users WHERE username = %s", + (username,) + ) + user = await cur.fetchone() + if not user: + return None + + if not bcrypt.checkpw( + password.encode("utf-8"), + user["password_hash"].encode("utf-8") + ): + return None + + # last_login aktualisieren + await cur.execute( + "UPDATE tv_users SET last_login = NOW() WHERE id = %s", + (user["id"],) + ) + del user["password_hash"] + return user + + async def create_session(self, user_id: int, + user_agent: str = "") -> str: + """Erstellt Session, gibt Token zurueck""" + session_id = secrets.token_urlsafe(48) + pool = await self._get_pool() + if not pool: + return "" + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO tv_sessions (id, user_id, user_agent) + VALUES (%s, %s, %s) + """, (session_id, user_id, user_agent[:512] if user_agent else "")) + return session_id + + async def validate_session(self, session_id: str) -> Optional[dict]: + """Prueft Session, gibt User-Dict zurueck oder None""" + if not session_id: + return None + pool = await self._get_pool() + if not pool: + return None + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT u.id, u.username, u.display_name, u.is_admin, + u.can_view_series, u.can_view_movies, u.allowed_paths + FROM tv_sessions s + JOIN tv_users u ON s.user_id = u.id + WHERE s.id = %s + AND s.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY) + """, (session_id,)) + user = await cur.fetchone() + + if user: + # Session-Aktivitaet aktualisieren + await cur.execute( + "UPDATE tv_sessions SET last_active = NOW() " + "WHERE id = %s", (session_id,) + ) + if user.get("allowed_paths") and isinstance( + user["allowed_paths"], str): + user["allowed_paths"] = json.loads( + user["allowed_paths"]) + return user + + async def delete_session(self, session_id: str) -> None: + """Logout: Session loeschen""" + pool = await self._get_pool() + if not pool: + return + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM tv_sessions WHERE id = %s", (session_id,) + ) + + async def cleanup_old_sessions(self) -> int: + """Loescht Sessions aelter als 30 Tage""" + pool = await self._get_pool() + if not pool: + return 0 + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM tv_sessions " + "WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)" + ) + return cur.rowcount + + # --- Watch-Progress --- + + async def save_progress(self, user_id: int, video_id: int, + position_sec: float, + duration_sec: float = 0) -> None: + """Speichert Wiedergabe-Position""" + completed = 1 if (duration_sec > 0 and + position_sec / duration_sec > 0.9) else 0 + pool = await self._get_pool() + if not pool: + return + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO tv_watch_progress + (user_id, video_id, position_sec, duration_sec, completed) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + position_sec = VALUES(position_sec), + duration_sec = VALUES(duration_sec), + completed = VALUES(completed) + """, (user_id, video_id, position_sec, duration_sec, completed)) + + async def get_progress(self, user_id: int, + video_id: int) -> Optional[dict]: + """Liest Wiedergabe-Position""" + pool = await self._get_pool() + if not pool: + return None + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT position_sec, duration_sec, completed + FROM tv_watch_progress + WHERE user_id = %s AND video_id = %s + """, (user_id, video_id)) + return await cur.fetchone() + + async def get_continue_watching(self, user_id: int, + limit: int = 20) -> list[dict]: + """Gibt 'Weiterschauen' Liste zurueck (nicht fertig, zuletzt gesehen)""" + pool = await self._get_pool() + if not pool: + return [] + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT wp.video_id, wp.position_sec, wp.duration_sec, + wp.updated_at, + v.file_name, v.file_path, + v.duration_sec as video_duration, + v.width, v.height, v.video_codec, + s.id as series_id, s.title as series_title, + s.poster_url as series_poster + FROM tv_watch_progress wp + JOIN library_videos v ON wp.video_id = v.id + LEFT JOIN library_series s ON v.series_id = s.id + WHERE wp.user_id = %s AND wp.completed = 0 + AND wp.position_sec > 10 + ORDER BY wp.updated_at DESC + LIMIT %s + """, (user_id, limit)) + rows = await cur.fetchall() + for row in rows: + if row.get("updated_at") and hasattr( + row["updated_at"], "isoformat"): + row["updated_at"] = str(row["updated_at"]) + return rows diff --git a/video-konverter/app/static/js/library.js b/video-konverter/app/static/js/library.js index 79d3f2d..59f0404 100644 --- a/video-konverter/app/static/js/library.js +++ b/video-konverter/app/static/js/library.js @@ -2831,12 +2831,15 @@ let _importWsActive = false; // WebSocket liefert Updates? async function executeImport() { if (!currentImportJobId) return; + // Job-ID merken bevor resetImport() sie loescht + const jobId = currentImportJobId; + // Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken closeImportModal(); resetImport(); // Starte Import (non-blocking - Server antwortet sofort) - fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"}); + fetch(`/api/library/import/${jobId}/execute`, {method: "POST"}); } // WebSocket-Handler fuer Import-Fortschritt diff --git a/video-konverter/app/static/tv/css/tv.css b/video-konverter/app/static/tv/css/tv.css new file mode 100644 index 0000000..1a10f28 --- /dev/null +++ b/video-konverter/app/static/tv/css/tv.css @@ -0,0 +1,471 @@ +/* VideoKonverter TV - Streaming-Frontend */ +/* Optimiert fuer TV (Fernbedienung), Handy, Tablet */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f0f0f; + --bg-card: #1a1a1a; + --bg-hover: #252525; + --bg-input: #1e1e1e; + --text: #e0e0e0; + --text-muted: #888; + --accent: #64b5f6; + --accent-hover: #90caf9; + --danger: #ef5350; + --success: #66bb6a; + --radius: 8px; + --focus-ring: 3px solid var(--accent); +} + +html { + font-size: clamp(14px, 1.2vw, 20px); +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + line-height: 1.5; + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); text-decoration: none; } + +/* === Focus-Management fuer D-Pad === */ +[data-focusable]:focus { + outline: var(--focus-ring); + outline-offset: 4px; + z-index: 10; +} + +/* === Navigation === */ +.tv-nav { + position: sticky; + top: 0; + z-index: 100; + background: rgba(15, 15, 15, 0.95); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1.5rem; + border-bottom: 1px solid #222; +} +.tv-nav-links { display: flex; gap: 0.3rem; } +.tv-nav-right { display: flex; align-items: center; gap: 0.8rem; } +.tv-nav-item { + color: var(--text-muted); + padding: 0.5rem 1rem; + border-radius: var(--radius); + font-size: 0.95rem; + transition: background 0.2s, color 0.2s; + white-space: nowrap; +} +.tv-nav-item:hover, .tv-nav-item:focus { background: var(--bg-hover); color: var(--text); } +.tv-nav-item.active { color: var(--text); background: var(--bg-card); font-weight: 600; } +.tv-nav-user { color: var(--text-muted); font-size: 0.85rem; } +.tv-nav-logout { color: var(--danger); font-size: 0.85rem; } + +/* === Main Content === */ +.tv-main { padding: 1.5rem; max-width: 1600px; margin: 0 auto; } + +/* === Sections === */ +.tv-section { margin-bottom: 2rem; } +.tv-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.8rem; } +.tv-section-title { font-size: 1.3rem; font-weight: 600; margin-bottom: 0.5rem; } +.tv-section-more { font-size: 0.85rem; color: var(--accent); padding: 0.3rem 0.6rem; border-radius: var(--radius); } +.tv-section-more:focus { outline: var(--focus-ring); } +.tv-page-title { font-size: 1.6rem; font-weight: 700; margin-bottom: 1rem; } + +/* === Horizontale Scroll-Reihen (Netflix-Style) === */ +.tv-row { + display: flex; + gap: 12px; + overflow-x: auto; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + padding: 8px 0; + -webkit-overflow-scrolling: touch; +} +.tv-row::-webkit-scrollbar { height: 4px; } +.tv-row::-webkit-scrollbar-track { background: transparent; } +.tv-row::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } +.tv-row .tv-card { + scroll-snap-align: start; + flex-shrink: 0; + width: 180px; +} +.tv-row .tv-card-wide { width: 260px; } + +/* === Poster-Grid === */ +.tv-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +/* === Poster-Karten === */ +.tv-card { + display: block; + background: var(--bg-card); + border-radius: var(--radius); + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; + cursor: pointer; +} +.tv-card:hover, .tv-card:focus { + transform: scale(1.04); + box-shadow: 0 4px 20px rgba(0,0,0,0.5); +} +.tv-card:focus { outline: var(--focus-ring); outline-offset: 2px; } + +.tv-card-img { + width: 100%; + aspect-ratio: 2/3; + object-fit: cover; + display: block; + background: #222; +} +.tv-card-placeholder { + width: 100%; + aspect-ratio: 2/3; + display: flex; + align-items: center; + justify-content: center; + background: #1e1e1e; + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; + padding: 0.5rem; +} +.tv-card-info { padding: 0.5rem 0.6rem; } +.tv-card-title { + display: block; + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} +.tv-card-meta { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Wiedergabe-Fortschritt auf Karte */ +.tv-card-progress { + height: 3px; + background: #333; +} +.tv-card-progress-bar { + height: 100%; + background: var(--accent); + transition: width 0.3s; +} + +/* === Detail-Ansicht === */ +.tv-detail-header { + display: flex; + gap: 1.5rem; + margin-bottom: 1.5rem; +} +.tv-detail-poster { + width: 200px; + border-radius: var(--radius); + flex-shrink: 0; + object-fit: cover; +} +.tv-detail-info { flex: 1; min-width: 0; } +.tv-detail-genres { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem; } +.tv-detail-year { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.3rem; } +.tv-detail-overview { + font-size: 0.9rem; + line-height: 1.6; + color: #ccc; + max-height: 8rem; + overflow: hidden; + margin-bottom: 1rem; +} +.tv-detail-actions { margin-top: 1rem; } + +/* Play-Button (gross, fuer TV) */ +.tv-play-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.8rem 2rem; + background: var(--accent); + color: #000; + font-size: 1.1rem; + font-weight: 700; + border: none; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s; +} +.tv-play-btn:hover, .tv-play-btn:focus { + background: var(--accent-hover); + outline: var(--focus-ring); + outline-offset: 4px; +} + +/* === Staffel-Tabs === */ +.tv-tabs { + display: flex; + gap: 0.3rem; + margin-bottom: 1rem; + overflow-x: auto; + padding-bottom: 4px; +} +.tv-tab { + padding: 0.5rem 1.2rem; + background: var(--bg-card); + color: var(--text-muted); + border: 1px solid #333; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.9rem; + white-space: nowrap; + transition: background 0.2s, color 0.2s; +} +.tv-tab:hover, .tv-tab:focus { background: var(--bg-hover); color: var(--text); } +.tv-tab.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; } + +/* === Episoden-Liste === */ +.tv-episode-list { display: flex; flex-direction: column; gap: 2px; } +.tv-episode { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.8rem 1rem; + background: var(--bg-card); + border-radius: var(--radius); + transition: background 0.2s; + color: var(--text); +} +.tv-episode:hover, .tv-episode:focus { background: var(--bg-hover); } +.tv-episode:focus { outline: var(--focus-ring); outline-offset: -2px; } + +.tv-episode-num { color: var(--text-muted); font-weight: 600; min-width: 3rem; font-size: 0.9rem; } +.tv-episode-title { flex: 1; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tv-episode-meta { color: var(--text-muted); font-size: 0.8rem; white-space: nowrap; } +.tv-episode-play { color: var(--accent); font-size: 1.2rem; } + +/* === Suche === */ +.tv-search-form { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } +.tv-search-input { + flex: 1; + padding: 0.8rem 1rem; + background: var(--bg-input); + border: 1px solid #333; + border-radius: var(--radius); + color: var(--text); + font-size: 1rem; +} +.tv-search-input:focus { border-color: var(--accent); outline: none; } +.tv-search-btn { + padding: 0.8rem 1.5rem; + background: var(--accent); + color: #000; + border: none; + border-radius: var(--radius); + font-weight: 600; + cursor: pointer; + font-size: 1rem; +} + +/* === Empty State === */ +.tv-empty { + text-align: center; + color: var(--text-muted); + padding: 3rem 1rem; + font-size: 1rem; +} + +/* === Login === */ +.login-body { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--bg); +} +.login-container { width: 100%; max-width: 400px; padding: 1rem; } +.login-card { + background: var(--bg-card); + border-radius: 12px; + padding: 2.5rem; + text-align: center; +} +.login-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 0.2rem; } +.login-subtitle { color: var(--text-muted); margin-bottom: 1.5rem; font-size: 1rem; } +.login-error { + background: rgba(239, 83, 80, 0.15); + color: var(--danger); + padding: 0.6rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.9rem; +} +.login-form { text-align: left; } +.login-field { margin-bottom: 1rem; } +.login-field label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.3rem; } +.login-field input { + width: 100%; + padding: 0.8rem 1rem; + background: var(--bg-input); + border: 1px solid #333; + border-radius: var(--radius); + color: var(--text); + font-size: 1rem; +} +.login-field input:focus { border-color: var(--accent); outline: none; } +.login-btn { + width: 100%; + padding: 0.9rem; + background: var(--accent); + color: #000; + border: none; + border-radius: var(--radius); + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + margin-top: 0.5rem; +} +.login-btn:hover, .login-btn:focus { background: var(--accent-hover); } + +/* === Video-Player === */ +.player-body { + background: #000; + overflow: hidden; +} +.player-wrapper { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + background: #000; +} +.player-wrapper video { + flex: 1; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} +.player-header { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 1rem 1.5rem; + background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent); + display: flex; + align-items: center; + gap: 1rem; + z-index: 10; + transition: opacity 0.3s; +} +.player-back { + color: var(--text); + font-size: 1rem; + padding: 0.4rem 0.8rem; + border-radius: var(--radius); +} +.player-back:focus { outline: var(--focus-ring); } +.player-title { + color: var(--text); + font-size: 1rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 0.5rem 1.5rem 1rem; + background: linear-gradient(to top, rgba(0,0,0,0.8), transparent); + z-index: 10; + transition: opacity 0.3s; +} +.player-progress { + height: 6px; + background: rgba(255,255,255,0.2); + border-radius: 3px; + cursor: pointer; + margin-bottom: 0.5rem; +} +.player-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.2s; + pointer-events: none; +} +.player-buttons { + display: flex; + align-items: center; + gap: 0.8rem; +} +.player-btn { + background: none; + border: none; + color: var(--text); + font-size: 1.4rem; + cursor: pointer; + padding: 0.4rem; + border-radius: var(--radius); +} +.player-btn:focus { outline: var(--focus-ring); } +.player-time { color: var(--text-muted); font-size: 0.85rem; } +.player-spacer { flex: 1; } + +/* Controls ausblenden nach Inaktivitaet */ +.player-hide-controls .player-header, +.player-hide-controls .player-controls { + opacity: 0; + pointer-events: none; +} + +/* === Responsive === */ +@media (max-width: 768px) { + .tv-nav { padding: 0.4rem 0.8rem; } + .tv-nav-item { padding: 0.4rem 0.6rem; font-size: 0.85rem; } + .tv-main { padding: 1rem; } + .tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } + .tv-row .tv-card { width: 140px; } + .tv-row .tv-card-wide { width: 200px; } + .tv-detail-header { flex-direction: column; } + .tv-detail-poster { width: 150px; } + .tv-page-title { font-size: 1.3rem; } + .tv-nav-user { display: none; } +} + +@media (max-width: 480px) { + .tv-nav-links { gap: 0; } + .tv-nav-item { padding: 0.3rem 0.5rem; font-size: 0.8rem; } + .tv-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); } + .tv-row .tv-card { width: 120px; } + .tv-detail-poster { width: 120px; } +} + +/* TV/Desktop (grosse Bildschirme) */ +@media (min-width: 1280px) { + .tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } + .tv-row .tv-card { width: 200px; } + .tv-row .tv-card-wide { width: 300px; } + .tv-episode { padding: 1rem 1.5rem; } + .tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; } +} diff --git a/video-konverter/app/static/tv/icons/icon-192.png b/video-konverter/app/static/tv/icons/icon-192.png new file mode 100644 index 0000000..8abcc68 Binary files /dev/null and b/video-konverter/app/static/tv/icons/icon-192.png differ diff --git a/video-konverter/app/static/tv/icons/icon-512.png b/video-konverter/app/static/tv/icons/icon-512.png new file mode 100644 index 0000000..b71098c Binary files /dev/null and b/video-konverter/app/static/tv/icons/icon-512.png differ diff --git a/video-konverter/app/static/tv/js/player.js b/video-konverter/app/static/tv/js/player.js new file mode 100644 index 0000000..69d2deb --- /dev/null +++ b/video-konverter/app/static/tv/js/player.js @@ -0,0 +1,276 @@ +/** + * VideoKonverter TV - Video-Player + * Fullscreen-Player mit Tastatur/Fernbedienung-Steuerung + * Speichert Watch-Progress automatisch + */ + +let videoEl = null; +let videoId = 0; +let videoDuration = 0; +let progressBar = null; +let timeDisplay = null; +let playBtn = null; +let controlsTimer = null; +let saveTimer = null; +let controlsVisible = true; + +/** + * Player initialisieren + * @param {number} id - Video-ID + * @param {number} startPos - Startposition in Sekunden + * @param {number} duration - Video-Dauer in Sekunden (Fallback) + */ +function initPlayer(id, startPos, duration) { + videoId = id; + videoDuration = duration; + + videoEl = document.getElementById("player-video"); + progressBar = document.getElementById("player-progress-bar"); + timeDisplay = document.getElementById("player-time"); + playBtn = document.getElementById("btn-play"); + + if (!videoEl) return; + + // Stream-URL setzen (ffmpeg-Transcoding Endpoint) + const streamUrl = `/api/library/videos/${id}/stream` + + (startPos > 0 ? `?t=${Math.floor(startPos)}` : ""); + videoEl.src = streamUrl; + + // Events + videoEl.addEventListener("timeupdate", onTimeUpdate); + videoEl.addEventListener("play", onPlay); + videoEl.addEventListener("pause", onPause); + videoEl.addEventListener("ended", onEnded); + videoEl.addEventListener("loadedmetadata", () => { + if (videoEl.duration && isFinite(videoEl.duration)) { + videoDuration = videoEl.duration; + } + }); + + // Klick auf Video -> Play/Pause + videoEl.addEventListener("click", togglePlay); + + // Controls UI + playBtn.addEventListener("click", togglePlay); + document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen); + + // Progress-Bar klickbar fuer Seeking + document.getElementById("player-progress").addEventListener("click", onProgressClick); + + // Tastatur-Steuerung + document.addEventListener("keydown", onKeyDown); + + // Maus/Touch-Bewegung -> Controls anzeigen + document.addEventListener("mousemove", showControls); + document.addEventListener("touchstart", showControls); + + // Controls nach 4 Sekunden ausblenden + scheduleHideControls(); + + // Watch-Progress alle 10 Sekunden speichern + saveTimer = setInterval(saveProgress, 10000); +} + +// === Playback-Controls === + +function togglePlay() { + if (!videoEl) return; + if (videoEl.paused) { + videoEl.play(); + } else { + videoEl.pause(); + } +} + +function onPlay() { + if (playBtn) playBtn.innerHTML = "❚❚"; // Pause-Symbol + scheduleHideControls(); +} + +function onPause() { + if (playBtn) playBtn.innerHTML = "▶"; // Play-Symbol + showControls(); + // Sofort speichern bei Pause + saveProgress(); +} + +function onEnded() { + // Video fertig -> als "completed" speichern + saveProgress(true); + // Zurueck navigieren nach 2 Sekunden + setTimeout(() => { + window.history.back(); + }, 2000); +} + +// === Seeking === + +function seekRelative(seconds) { + if (!videoEl) return; + const newTime = Math.max(0, Math.min( + videoEl.currentTime + seconds, + videoEl.duration || videoDuration + )); + // Neue Stream-URL mit Zeitstempel + const wasPlaying = !videoEl.paused; + videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`; + if (wasPlaying) videoEl.play(); + showControls(); +} + +function onProgressClick(e) { + if (!videoEl) return; + const rect = e.currentTarget.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const dur = videoEl.duration || videoDuration; + if (!dur) return; + const newTime = pct * dur; + // Neue Stream-URL mit Zeitstempel + const wasPlaying = !videoEl.paused; + videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`; + if (wasPlaying) videoEl.play(); + showControls(); +} + +// === Zeit-Anzeige und Progress === + +function onTimeUpdate() { + if (!videoEl) return; + const current = videoEl.currentTime; + const dur = videoEl.duration || videoDuration; + + // Progress-Bar + if (progressBar && dur > 0) { + progressBar.style.width = ((current / dur) * 100) + "%"; + } + + // Zeit-Anzeige + if (timeDisplay) { + timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur); + } +} + +function formatTime(sec) { + if (!sec || !isFinite(sec)) return "0:00"; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = Math.floor(sec % 60); + if (h > 0) { + return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); + } + return m + ":" + String(s).padStart(2, "0"); +} + +// === Controls Ein-/Ausblenden === + +function showControls() { + const wrapper = document.getElementById("player-wrapper"); + if (wrapper) wrapper.classList.remove("player-hide-controls"); + controlsVisible = true; + scheduleHideControls(); +} + +function hideControls() { + if (!videoEl || videoEl.paused) return; + const wrapper = document.getElementById("player-wrapper"); + if (wrapper) wrapper.classList.add("player-hide-controls"); + controlsVisible = false; +} + +function scheduleHideControls() { + if (controlsTimer) clearTimeout(controlsTimer); + controlsTimer = setTimeout(hideControls, 4000); +} + +// === Fullscreen === + +function toggleFullscreen() { + const wrapper = document.getElementById("player-wrapper"); + if (!document.fullscreenElement) { + (wrapper || document.documentElement).requestFullscreen().catch(() => {}); + } else { + document.exitFullscreen().catch(() => {}); + } +} + +// === Tastatur-Steuerung === + +function onKeyDown(e) { + // Samsung Tizen Remote Keys + const keyMap = { + 10009: "Escape", + 10182: "Escape", + 415: "Play", + 19: "Pause", + 413: "Stop", + 417: "FastForward", + 412: "Rewind", + }; + const key = keyMap[e.keyCode] || e.key; + + switch (key) { + case " ": + case "Enter": + case "Play": + case "Pause": + togglePlay(); + e.preventDefault(); + break; + case "ArrowLeft": + case "Rewind": + seekRelative(-10); + e.preventDefault(); + break; + case "ArrowRight": + case "FastForward": + seekRelative(10); + e.preventDefault(); + break; + case "ArrowUp": + // Lautstaerke hoch (falls vom Browser unterstuetzt) + if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1); + showControls(); + e.preventDefault(); + break; + case "ArrowDown": + // Lautstaerke runter + if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1); + showControls(); + e.preventDefault(); + break; + case "Escape": + case "Backspace": + case "Stop": + // Zurueck navigieren + saveProgress(); + setTimeout(() => window.history.back(), 100); + e.preventDefault(); + break; + case "f": + toggleFullscreen(); + e.preventDefault(); + break; + } +} + +// === Watch-Progress speichern === + +function saveProgress(completed) { + if (!videoId || !videoEl) return; + const pos = videoEl.currentTime || 0; + const dur = videoEl.duration || videoDuration || 0; + if (pos < 5 && !completed) return; // Erst ab 5 Sekunden speichern + + fetch("/tv/api/watch-progress", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + video_id: videoId, + position_sec: pos, + duration_sec: dur, + }), + }).catch(() => {}); // Fehler ignorieren (nicht kritisch) +} + +// Beim Verlassen der Seite speichern +window.addEventListener("beforeunload", () => saveProgress()); diff --git a/video-konverter/app/static/tv/js/tv.js b/video-konverter/app/static/tv/js/tv.js new file mode 100644 index 0000000..3e3ecd6 --- /dev/null +++ b/video-konverter/app/static/tv/js/tv.js @@ -0,0 +1,235 @@ +/** + * VideoKonverter TV - Focus-Manager und Navigation + * D-Pad Navigation fuer TV-Fernbedienungen (Samsung Tizen, Android TV) + * + Lazy-Loading fuer Poster-Bilder + */ + +// === Focus-Manager === + +class FocusManager { + constructor() { + this._enabled = true; + this._currentFocus = null; + + // Tastatur-Events abfangen + document.addEventListener("keydown", (e) => this._onKeyDown(e)); + + // Initiales Focus-Element setzen + requestAnimationFrame(() => this._initFocus()); + } + + _initFocus() { + // Erstes fokussierbares Element finden (nicht autofocus Inputs) + const autofocusEl = document.querySelector("[autofocus]"); + if (autofocusEl) { + autofocusEl.focus(); + return; + } + const first = document.querySelector("[data-focusable]"); + if (first) first.focus(); + } + + _onKeyDown(e) { + if (!this._enabled) return; + + // Samsung Tizen Remote Key-Codes mappen + const keyMap = { + 37: "ArrowLeft", 38: "ArrowUp", 39: "ArrowRight", 40: "ArrowDown", + 13: "Enter", 27: "Escape", 8: "Backspace", + // Samsung Tizen spezifisch + 10009: "Escape", // RETURN-Taste + 10182: "Escape", // EXIT-Taste + }; + const key = keyMap[e.keyCode] || e.key; + + switch (key) { + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": + this._navigate(key, e); + break; + case "Enter": + this._activate(e); + break; + case "Escape": + case "Backspace": + this._goBack(e); + break; + } + } + + _navigate(direction, e) { + const active = document.activeElement; + // Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation) + if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { + if (direction === "ArrowLeft" || direction === "ArrowRight") return; + } + + const focusables = this._getFocusableElements(); + if (!focusables.length) return; + + // Aktuelles Element + const currentIdx = focusables.indexOf(active); + if (currentIdx === -1) { + // Kein fokussiertes Element -> erstes waehlen + focusables[0].focus(); + e.preventDefault(); + return; + } + + // Naechstes Element in Richtung finden (Nearest-Neighbor) + const current = active.getBoundingClientRect(); + const cx = current.left + current.width / 2; + const cy = current.top + current.height / 2; + + let bestEl = null; + let bestDist = Infinity; + + for (const el of focusables) { + if (el === active) continue; + const rect = el.getBoundingClientRect(); + // Element muss sichtbar sein + if (rect.width === 0 || rect.height === 0) continue; + + const ex = rect.left + rect.width / 2; + const ey = rect.top + rect.height / 2; + + // Pruefen ob Element in der richtigen Richtung liegt + const dx = ex - cx; + const dy = ey - cy; + + let valid = false; + switch (direction) { + case "ArrowUp": valid = dy < -5; break; + case "ArrowDown": valid = dy > 5; break; + case "ArrowLeft": valid = dx < -5; break; + case "ArrowRight": valid = dx > 5; break; + } + if (!valid) continue; + + // Distanz berechnen (gewichtet: Hauptrichtung weniger, Querrichtung mehr) + let dist; + if (direction === "ArrowUp" || direction === "ArrowDown") { + dist = Math.abs(dy) + Math.abs(dx) * 3; + } else { + dist = Math.abs(dx) + Math.abs(dy) * 3; + } + + if (dist < bestDist) { + bestDist = dist; + bestEl = el; + } + } + + if (bestEl) { + bestEl.focus(); + // Ins Sichtfeld scrollen + bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + e.preventDefault(); + } + } + + _activate(e) { + const active = document.activeElement; + if (!active || active === document.body) return; + + // Links, Buttons -> Click ausfuehren + if (active.tagName === "A" || active.tagName === "BUTTON") { + // Natuerliches Enter-Verhalten beibehalten + return; + } + + // Andere fokussierbare Elemente -> Click simulieren + if (active.hasAttribute("data-focusable")) { + active.click(); + e.preventDefault(); + } + } + + _goBack(e) { + const active = document.activeElement; + // In Input-Feldern: Escape = Blur, Backspace = natuerlich + if (active && active.tagName === "INPUT") { + if (e.key === "Escape") { + active.blur(); + e.preventDefault(); + } + return; + } + + // Zurueck navigieren + if (window.history.length > 1) { + window.history.back(); + e.preventDefault(); + } + } + + _getFocusableElements() { + // Alle sichtbaren fokussierbaren Elemente + const elements = document.querySelectorAll("[data-focusable]"); + return Array.from(elements).filter(el => { + if (el.offsetParent === null && el.style.position !== "fixed") return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + } +} + +// === Horizontale Scroll-Reihen: Scroll per Pfeiltaste === + +function initRowScroll() { + document.querySelectorAll(".tv-row").forEach(row => { + // Maus-Rad horizontal scrollen + row.addEventListener("wheel", (e) => { + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + e.preventDefault(); + row.scrollLeft += e.deltaY; + } + }, { passive: false }); + }); +} + +// === Lazy-Loading fuer Poster (IntersectionObserver) === + +function initLazyLoad() { + // Browser-natives loading="lazy" wird bereits verwendet + // Zusaetzlich: Placeholder-Klasse entfernen nach Laden + document.querySelectorAll("img.tv-card-img").forEach(img => { + if (img.complete) return; + img.style.opacity = "0"; + img.style.transition = "opacity 0.3s"; + img.addEventListener("load", () => { + img.style.opacity = "1"; + }, { once: true }); + img.addEventListener("error", () => { + // Fehlerhaftes Bild: Placeholder anzeigen + img.style.display = "none"; + const placeholder = document.createElement("div"); + placeholder.className = "tv-card-placeholder"; + placeholder.textContent = img.alt || "?"; + img.parentNode.insertBefore(placeholder, img); + }, { once: true }); + }); +} + +// === Navigation: Aktiven Tab highlighten === + +function initNavHighlight() { + const path = window.location.pathname; + document.querySelectorAll(".tv-nav-item").forEach(item => { + const href = item.getAttribute("href"); + if (href === path || (href !== "/tv/" && path.startsWith(href))) { + item.classList.add("active"); + } + }); +} + +// === Init === + +document.addEventListener("DOMContentLoaded", () => { + window.focusManager = new FocusManager(); + initRowScroll(); + initLazyLoad(); + initNavHighlight(); +}); diff --git a/video-konverter/app/static/tv/manifest.json b/video-konverter/app/static/tv/manifest.json new file mode 100644 index 0000000..806ac98 --- /dev/null +++ b/video-konverter/app/static/tv/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "VideoKonverter TV", + "short_name": "VK TV", + "description": "Video-Streaming aus deiner Bibliothek", + "start_url": "/tv/", + "scope": "/tv/", + "display": "standalone", + "orientation": "any", + "background_color": "#0f0f0f", + "theme_color": "#0f0f0f", + "icons": [ + { + "src": "/static/tv/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/tv/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/video-konverter/app/static/tv/sw.js b/video-konverter/app/static/tv/sw.js new file mode 100644 index 0000000..c5c47e0 --- /dev/null +++ b/video-konverter/app/static/tv/sw.js @@ -0,0 +1,68 @@ +/** + * VideoKonverter TV - Service Worker (minimal) + * Ermoeglicht PWA-Installation auf Handys und Tablets + * Kein Offline-Caching noetig (Streaming braucht Netzwerk) + */ + +const CACHE_NAME = "vk-tv-v1"; +const STATIC_ASSETS = [ + "/static/tv/css/tv.css", + "/static/tv/js/tv.js", + "/static/tv/js/player.js", + "/static/tv/icons/icon-192.png", +]; + +// Installation: Statische Assets cachen +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(STATIC_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// Aktivierung: Alte Caches aufraemen +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(k => k !== CACHE_NAME) + .map(k => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +// Fetch: Network-First Strategie (Streaming braucht immer Netzwerk) +self.addEventListener("fetch", (event) => { + // Nur GET-Requests cachen + if (event.request.method !== "GET") return; + + // Streaming/API nie cachen + const url = new URL(event.request.url); + if (url.pathname.startsWith("/api/") || url.pathname.includes("/stream")) { + return; + } + + // Statische Assets: Cache-First + if (url.pathname.startsWith("/static/tv/")) { + event.respondWith( + caches.match(event.request) + .then(cached => cached || fetch(event.request) + .then(response => { + const clone = response.clone(); + caches.open(CACHE_NAME) + .then(cache => cache.put(event.request, clone)); + return response; + }) + ) + ); + return; + } + + // Alles andere: Network-First + event.respondWith( + fetch(event.request) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/video-konverter/app/templates/admin.html b/video-konverter/app/templates/admin.html index c1739be..f2b0b89 100644 --- a/video-konverter/app/templates/admin.html +++ b/video-konverter/app/templates/admin.html @@ -243,6 +243,55 @@ + +
+

TV-App / Streaming

+
+ +
+ QR-Code +

QR-Code scannen oder Link oeffnen

+
+ /tv/ +
+
+ +
+

Benutzer

+
+
Lade Benutzer...
+
+ +
+

Neuer Benutzer

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+
+
+
+

Encoding-Presets

@@ -338,6 +387,155 @@ function scanPath(pathId) { .catch(e => showToast("Fehler: " + e, "error")); } -document.addEventListener("DOMContentLoaded", loadLibraryPaths); +// === TV-App User-Verwaltung === + +function tvLoadUsers() { + fetch("/api/tv/users") + .then(r => r.json()) + .then(data => { + const container = document.getElementById("tv-users-list"); + const users = data.users || []; + if (!users.length) { + container.innerHTML = '
Keine Benutzer vorhanden
'; + return; + } + container.innerHTML = users.map(u => ` +
+
+ ${escapeHtml(u.display_name || u.username)} + @${escapeHtml(u.username)} + ${u.is_admin ? 'Admin' : ''} + ${u.can_view_series ? 'Serien' : ''} + ${u.can_view_movies ? 'Filme' : ''} + ${u.last_login ? '
Letzter Login: ' + u.last_login + '' : ''} +
+
+ + +
+
+ `).join(""); + }) + .catch(() => { + document.getElementById("tv-users-list").innerHTML = + '
TV-App nicht verfuegbar (DB-Verbindung fehlt?)
'; + }); +} + +function escapeHtml(str) { + if (!str) return ""; + return str.replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); +} +function escapeAttr(str) { + if (!str) return ""; + return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"'); +} + +function tvCreateUser() { + const username = document.getElementById("tv-new-username").value.trim(); + const displayName = document.getElementById("tv-new-display").value.trim(); + const password = document.getElementById("tv-new-password").value; + if (!username || !password) { + showToast("Benutzername und Passwort noetig", "error"); + return; + } + fetch("/api/tv/users", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + username: username, + password: password, + display_name: displayName || username, + is_admin: document.getElementById("tv-new-admin").checked, + can_view_series: document.getElementById("tv-new-series").checked, + can_view_movies: document.getElementById("tv-new-movies").checked, + }), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + } else { + document.getElementById("tv-new-username").value = ""; + document.getElementById("tv-new-display").value = ""; + document.getElementById("tv-new-password").value = ""; + showToast("Benutzer erstellt", "success"); + tvLoadUsers(); + } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +async function tvDeleteUser(userId, username) { + if (!await showConfirm(`Benutzer "${username}" wirklich loeschen?`, {title: "Benutzer loeschen", okText: "Loeschen", icon: "danger", danger: true})) return; + fetch("/api/tv/users/" + userId, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + } else { + showToast("Benutzer geloescht", "success"); + tvLoadUsers(); + } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +async function tvEditUser(userId) { + // User-Daten laden, dann Edit-Dialog anzeigen + const resp = await fetch("/api/tv/users").then(r => r.json()); + const user = (resp.users || []).find(u => u.id === userId); + if (!user) return; + + const newPass = prompt("Neues Passwort (leer lassen um beizubehalten):"); + if (newPass === null) return; // Abgebrochen + + const updates = {}; + if (newPass) updates.password = newPass; + + const newSeries = confirm("Serien anzeigen?"); + const newMovies = confirm("Filme anzeigen?"); + const newAdmin = confirm("Admin-Rechte?"); + + updates.can_view_series = newSeries; + updates.can_view_movies = newMovies; + updates.is_admin = newAdmin; + + fetch("/api/tv/users/" + userId, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(updates), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + } else { + showToast("Benutzer aktualisiert", "success"); + tvLoadUsers(); + } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// TV-URL laden +function tvLoadUrl() { + fetch("/api/tv/url") + .then(r => r.json()) + .then(data => { + const link = document.getElementById("tv-link"); + if (link && data.url) { + link.href = data.url; + link.textContent = data.url; + } + }) + .catch(() => {}); +} + +document.addEventListener("DOMContentLoaded", () => { + loadLibraryPaths(); + tvLoadUsers(); + tvLoadUrl(); +}); {% endblock %} diff --git a/video-konverter/app/templates/tv/base.html b/video-konverter/app/templates/tv/base.html new file mode 100644 index 0000000..a43cfd8 --- /dev/null +++ b/video-konverter/app/templates/tv/base.html @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + {% block title %}VideoKonverter TV{% endblock %} + + + {% if user is defined and user %} + + {% endif %} + +
+ {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + + + diff --git a/video-konverter/app/templates/tv/home.html b/video-konverter/app/templates/tv/home.html new file mode 100644 index 0000000..a67fbfa --- /dev/null +++ b/video-konverter/app/templates/tv/home.html @@ -0,0 +1,87 @@ +{% extends "tv/base.html" %} +{% block title %}Startseite - VideoKonverter TV{% endblock %} + +{% block content %} + +{% if continue_watching %} +
+

Weiterschauen

+ +
+{% endif %} + + +{% if series %} +
+
+

Serien

+ Alle anzeigen +
+ +
+{% endif %} + + +{% if movies %} +
+
+

Filme

+ Alle anzeigen +
+ +
+{% endif %} + +{% if not series and not movies %} +
+

Noch keine Inhalte in der Bibliothek.

+

Fuege Serien oder Filme ueber die Admin-Oberflaeche hinzu.

+
+{% endif %} +{% endblock %} diff --git a/video-konverter/app/templates/tv/login.html b/video-konverter/app/templates/tv/login.html new file mode 100644 index 0000000..06a68ff --- /dev/null +++ b/video-konverter/app/templates/tv/login.html @@ -0,0 +1,40 @@ + + + + + + + + Login - VideoKonverter TV + + + + + diff --git a/video-konverter/app/templates/tv/movie_detail.html b/video-konverter/app/templates/tv/movie_detail.html new file mode 100644 index 0000000..f482591 --- /dev/null +++ b/video-konverter/app/templates/tv/movie_detail.html @@ -0,0 +1,49 @@ +{% extends "tv/base.html" %} +{% block title %}{{ movie.title or movie.folder_name }} - VideoKonverter TV{% endblock %} + +{% block content %} +
+
+ {% if movie.poster_url %} + + {% endif %} +
+

{{ movie.title or movie.folder_name }}

+ {% if movie.year %} +

{{ movie.year }}

+ {% endif %} + {% if movie.genres %} +

{{ movie.genres }}

+ {% endif %} + {% if movie.overview %} +

{{ movie.overview }}

+ {% endif %} + + {% if videos %} + + {% endif %} +
+
+ + {% if videos|length > 1 %} +

Versionen

+ + {% endif %} +
+{% endblock %} diff --git a/video-konverter/app/templates/tv/movies.html b/video-konverter/app/templates/tv/movies.html new file mode 100644 index 0000000..8943dc4 --- /dev/null +++ b/video-konverter/app/templates/tv/movies.html @@ -0,0 +1,26 @@ +{% extends "tv/base.html" %} +{% block title %}Filme - VideoKonverter TV{% endblock %} + +{% block content %} +
+

Filme

+ + {% if not movies %} +
Keine Filme vorhanden.
+ {% endif %} +
+{% endblock %} diff --git a/video-konverter/app/templates/tv/player.html b/video-konverter/app/templates/tv/player.html new file mode 100644 index 0000000..d43ea6c --- /dev/null +++ b/video-konverter/app/templates/tv/player.html @@ -0,0 +1,42 @@ + + + + + + + + {{ title }} - VideoKonverter TV + + +
+ +
+ ❮ Zurueck + {{ title }} +
+ + + + + +
+
+
+
+
+ + 0:00 / 0:00 + + +
+
+
+ + + + + diff --git a/video-konverter/app/templates/tv/search.html b/video-konverter/app/templates/tv/search.html new file mode 100644 index 0000000..3a4e640 --- /dev/null +++ b/video-konverter/app/templates/tv/search.html @@ -0,0 +1,59 @@ +{% extends "tv/base.html" %} +{% block title %}Suche - VideoKonverter TV{% endblock %} + +{% block content %} +
+

Suche

+
+ + +
+ + {% if query %} + + {% if series %} +

Serien ({{ series|length }})

+ + {% endif %} + + + {% if movies %} +

Filme ({{ movies|length }})

+ + {% endif %} + + {% if not series and not movies %} +
Keine Ergebnisse fuer «{{ query }}»
+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/video-konverter/app/templates/tv/series.html b/video-konverter/app/templates/tv/series.html new file mode 100644 index 0000000..ba1ec25 --- /dev/null +++ b/video-konverter/app/templates/tv/series.html @@ -0,0 +1,26 @@ +{% extends "tv/base.html" %} +{% block title %}Serien - VideoKonverter TV{% endblock %} + +{% block content %} +
+

Serien

+ + {% if not series %} +
Keine Serien vorhanden.
+ {% endif %} +
+{% endblock %} diff --git a/video-konverter/app/templates/tv/series_detail.html b/video-konverter/app/templates/tv/series_detail.html new file mode 100644 index 0000000..c615ffd --- /dev/null +++ b/video-konverter/app/templates/tv/series_detail.html @@ -0,0 +1,76 @@ +{% extends "tv/base.html" %} +{% block title %}{{ series.title or series.folder_name }} - VideoKonverter TV{% endblock %} + +{% block content %} +
+ +
+ {% if series.poster_url %} + + {% endif %} +
+

{{ series.title or series.folder_name }}

+ {% if series.genres %} +

{{ series.genres }}

+ {% endif %} + {% if series.overview %} +

{{ series.overview }}

+ {% endif %} +
+
+ + + {% if seasons %} +
+ {% for sn in seasons.keys() %} + + {% endfor %} +
+ + + {% for sn, episodes in seasons.items() %} + + {% endfor %} + {% else %} +
Keine Episoden vorhanden.
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/video-konverter/requirements.txt b/video-konverter/requirements.txt index 8a0330c..f1ef111 100644 --- a/video-konverter/requirements.txt +++ b/video-konverter/requirements.txt @@ -4,3 +4,5 @@ jinja2>=3.1.0 PyYAML>=6.0 aiomysql>=0.2.0 tvdb-v4-official>=1.1.0 +bcrypt>=4.0 +qrcode[pil]>=7.0