From 99730f2f8fe8e655e3ed7c921931095c7f70ad75 Mon Sep 17 00:00:00 2001 From: data Date: Sat, 28 Feb 2026 09:26:19 +0100 Subject: [PATCH] feat: VideoKonverter v3.1 - TV-App, Auth, Tizen, Log-API TV-App (/tv/): - Login mit bcrypt-Passwort-Hashing und DB-Sessions (30 Tage) - Home (Weiterschauen, Serien, Filme), Serien-Detail mit Staffeln - Film-Uebersicht und Detail, Fullscreen Video-Player - Suche mit Live-Ergebnissen, Watch-Progress (alle 10s gespeichert) - D-Pad/Fernbedienung-Navigation (FocusManager, Samsung Tizen Keys) - PWA: manifest.json, Service Worker, Icons fuer Handy/Tablet - Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade) Admin-Erweiterungen: - QR-Code fuer TV-App URL - User-Verwaltung (CRUD) mit Rechte-Konfiguration - Log-API: GET /api/log?lines=100&level=INFO Tizen-App (tizen-app/): - Wrapper-App fuer Samsung Smart TVs (.wgt Paket) - Einmalige Server-IP Eingabe, danach automatische Verbindung - Installationsanleitung (INSTALL.md) Bug-Fixes: - executeImport: Job-ID vor resetImport() gesichert - cursor(aiomysql.DictCursor) statt cursor(dict) - DB-Spalten width/height statt video_width/video_height Co-Authored-By: Claude Opus 4.6 --- requirements.txt | 2 + tizen-app/INSTALL.md | 157 +++++ tizen-app/VideoKonverter.wgt | Bin 0 -> 3976 bytes tizen-app/config.xml | 30 + tizen-app/icon.png | Bin 0 -> 1456 bytes tizen-app/index.html | 136 ++++ video-konverter/app/routes/api.py | 78 +++ video-konverter/app/routes/tv_api.py | 594 ++++++++++++++++++ video-konverter/app/server.py | 17 + video-konverter/app/services/auth.py | 393 ++++++++++++ video-konverter/app/static/js/library.js | 5 +- video-konverter/app/static/tv/css/tv.css | 471 ++++++++++++++ .../app/static/tv/icons/icon-192.png | Bin 0 -> 1456 bytes .../app/static/tv/icons/icon-512.png | Bin 0 -> 4800 bytes video-konverter/app/static/tv/js/player.js | 276 ++++++++ video-konverter/app/static/tv/js/tv.js | 235 +++++++ video-konverter/app/static/tv/manifest.json | 23 + video-konverter/app/static/tv/sw.js | 68 ++ video-konverter/app/templates/admin.html | 200 +++++- video-konverter/app/templates/tv/base.html | 51 ++ video-konverter/app/templates/tv/home.html | 87 +++ video-konverter/app/templates/tv/login.html | 40 ++ .../app/templates/tv/movie_detail.html | 49 ++ video-konverter/app/templates/tv/movies.html | 26 + video-konverter/app/templates/tv/player.html | 42 ++ video-konverter/app/templates/tv/search.html | 59 ++ video-konverter/app/templates/tv/series.html | 26 + .../app/templates/tv/series_detail.html | 76 +++ video-konverter/requirements.txt | 2 + 29 files changed, 3141 insertions(+), 2 deletions(-) create mode 100644 tizen-app/INSTALL.md create mode 100644 tizen-app/VideoKonverter.wgt create mode 100644 tizen-app/config.xml create mode 100644 tizen-app/icon.png create mode 100644 tizen-app/index.html create mode 100644 video-konverter/app/routes/tv_api.py create mode 100644 video-konverter/app/services/auth.py create mode 100644 video-konverter/app/static/tv/css/tv.css create mode 100644 video-konverter/app/static/tv/icons/icon-192.png create mode 100644 video-konverter/app/static/tv/icons/icon-512.png create mode 100644 video-konverter/app/static/tv/js/player.js create mode 100644 video-konverter/app/static/tv/js/tv.js create mode 100644 video-konverter/app/static/tv/manifest.json create mode 100644 video-konverter/app/static/tv/sw.js create mode 100644 video-konverter/app/templates/tv/base.html create mode 100644 video-konverter/app/templates/tv/home.html create mode 100644 video-konverter/app/templates/tv/login.html create mode 100644 video-konverter/app/templates/tv/movie_detail.html create mode 100644 video-konverter/app/templates/tv/movies.html create mode 100644 video-konverter/app/templates/tv/player.html create mode 100644 video-konverter/app/templates/tv/search.html create mode 100644 video-konverter/app/templates/tv/series.html create mode 100644 video-konverter/app/templates/tv/series_detail.html 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 0000000000000000000000000000000000000000..089f8bf2ce4739db9e8a07b0f320c9d4e292a0cf GIT binary patch literal 3976 zcmZ{ncTiK`zQsc|Gzkz5Mw)aG6Hq~V2MwVU5RhPi5JK<0NRck^ql$C_0tzZcnn)Gt zAOccD?;UA^fIPgvH}^Ted*AF?d!I9NX3hR%%~{`%Hj<1S3;+OtfO3S1$su6c(|{BJ zpril*r~xbh3r7c>wH3_E-cBDy1t2TUBUt~IyB7_BgnWez0QlGT1|{P-Aq<*4yJ@It zPoj3Y92~TeL&2EC-~aM04ORfZE5xoIFC@p!*Vw>k z#4T}$YJ*Dh7n;+`&bUZR{#GJ5RoRV~Zvchoml~S!=u7A!50dMyhp9HRDo#t%<(DM; z+6g>giXMCL*l4W9TvXUk>NaYz#yetA9PAySk^|2VXo4$c?3pWRd- zU?aK_ZEw>Hk^@XpHK&Ap;(ig6@Y&66|4h}#QM*&;^Nf4-qqjh@?8jH&$fX`+Wg=aM zg*eR)p9SFTr04h;F?A!H-XVl?6l343$49WUkVSnj*`*=Zo_I>9@?a}elf9HHcBy0c zwYO#9M!oKLT-eYRyuT(?615}7))MT@rYNHSkbfl0IU#2%6g|6_Mtd%ofi3A1 z%$=-xZW<82KcqAo8BQc8+A))J?WNiII}$aa5nKzOD!O$ABI0WU=>~WL7KD~6J>ImM zz1Hh+`l@%6K}LEOhd0D{k|Qxaa@#OP&KSBpXFzES9aCakN^xzx!F?VXC$Ym_St@9j ziEF8gJ>F9TJwkq;jMw~dDiq}!<4l`n8d;&Dh4R|Jw96Gsx0Wx`A}+f4lFBv1{)APU z+GVUjuPwL7#wX%xMWgn=li75UOggFbhpiW>T%`Xynbrn`X{OoDjgq8^p~0I3m5uySe*wHT)>$K-6d~aeb$W7n5$Wcf zVZL|?xqyc)_c*DBJY;WZU_U?GgfgZ_b%!)B(HpsW1Kj9z-_y1{wGQ!EnT(bDX=Un- zEC+{AWz72*yg%ST4G_4$`m&8-nP)S?S0MD^O+3{ZRMSt@XG$S%_g)KClft*Xo~~6S zxDH)C$f+G@LIuGi{2sSs{n_^uy*AGFFQf-jx5{4Z+u&%}B0NhPKa{WPhVC{QC+pVG1<}Ktd2=L z5>+a;=JomcdGzfD9K#oZdtcUQR(s_7z# z7TAPDczHo%upG-aUv4d$XZAF1aAgHTLM+vD{{Y_I%UBo}&wMebPt1<=;(Pn3KQ_DE zg1)99%3@$FyxP)Hjgo2iCC)sP)WEWisfOJCp7@i7F0Jc4vMVK0JvkL?0c0aQ!@YcS zu0V=AxRopXUQKzun|fMM?s{XIkngOo){~XXyBTc8_or2$F$(fs;EbFNdPyUMgZ_@k z3msQ?_p-8;srqj%WNS1xc-25}l(uiwQ0I6CfcG`O1W$D$`lZ&g-M@S=2zTxJw$?2*!~}cgNGo}h#}Xul zkqoQvre`rDYnK^eQ2&bU`1DkmT#zi4L(96qNjO(b&(T5hOLe|+K*joS$ef?%9!|qJTm(A^>!%p+Yv{~m z-h_HZa6qwj%W#ExtcRtjHT<$hO1oQ)-s8jj9hr2HPV%xAoY6TO4(;i9>9ZlIq_K78 zio|T~ao5CRXkAHxBeL}JUXwrFt~)g0*N@ua{PW2{n_||PU@6!v171MgxQ3%Qj_zBW zZ->YE-Nup$7Ez+POKxZ0QQA5E|Mfu2MGq{r^~Hz+b8@iy*L>1* z(Ez{oj~38C`Ks%Af`1+O+^W`Qdr4q@XWCUP2@wn?gz2i1D0n|$5mYxO8_Z=^X64ZK zsg=$*^6rp#la4B-ieS1c%M1kbP=r$hff1zR_RQDtB(tP1(Z{yPKCbCbPfeEuS6PHp z&b!=(T3J9-zav~u`T3%4>|gF83+~U@0P9SrM_HGckcE$H+v9!;m;Zz<^3sQi2vOdL z8OYj-YcD|yA?QQ?-u}e?lv`&qhvBVVNm2qWvENV-2&9|f&tAgMk`K%BNB%GR&C zDMP;Jzk6P^L})Y7O?0t}I^Qf>O5w;UiDis2=6Rv}W-z}z*lG(KA9Nc0%)h>}uQ4T7 z)YV#ID-3%Llj~)#zkMGg&?S3J@5^y*s-Cp|Wx)x7YECVh`^Qg2+hkg3HZ}8PxcC|5$xTAiZnI=SDI3CO z?gc4Y;k#LCV(iB|$=g%s zW5zm!qOLvQO~`4x*>TJFS(;4Q^|dZ}hcV2+MkKfh*(Kz9ld|vdNP;{DTboQ~bzeF` zm&DMaR(_f?PFYZUxRiEfVvbXdB2F~p(*CA1gYFG1-KF53TEU+U?=2wmpqPd^;Gnz$!Hl7r?HqIjfz%8W`364Dbi6ygMdzmC%@h#ucwDu)Q?6$V`o zPcSQ*BO~2AwQUC(vaPR$1P`aZ;}|Fm1VDpTi1T@Y()a{QX51Divcg*o4r*34lpI#= zY`g2^WecRGiXN3*<3Qf0Ln9ClMq-l+m)!lrM%fv0<;e;GnDsFH!P4X9WOiD?B7dX# zY>Zh);Op_r;yZ6f9-JPF%CpVjsm)AHQxy#WQzI*q{G*ExZ396IB^t*(uk;K+(IcUK z{^k6aRQjMKEOvQAK0yTgG#~NQf}ypm3wYgJsJf;4a;APbsN0RBOA&67l@cVZE@n5wXT*fZ=yPb@BA;J;4 zF(G@N0x~eN<@q&D$?Xp)TPpX(Vr;|Anpm#{wM{X|6`>Os;dA|2SO2fS6t&8{i3VHz zKT>>{m10tD257_`Mx>Q_kEpLDqQ(_^R^Nu*a0vb4lqAE`#III=cUeZAP-T=7A~PZC z3Cew^h|&027_w6Jsf2lMdNvICdJceT2Em)@C|+xV?_>WCBoR~P`FA)4QwSNP87Ck>|zJ7)^{tt$7$I3CWpv~#r~TVDlf zMU4CV?l5)PWCK#`U`JU@0$+&>9RyYSqts%r5aasfoU;dA&9)S_qn&$LxH($ZNja_Tt_tmXGL3W-6J5*w*i1%d{{e2VTnK0{vfq=x;0MGDy`FiFv%d8!Z2bG|6h&Gahl#T5Fqfq7x zIt2hoF0>i+`{_?6rTCjk|M9>7)9*jo;ZMJ`3)cOUBrg2^3q}0t_n#f~x8LuZZ3Yp5 f7k>Y4qzkwI(ni`y3d-MKlU=N>i}UH_-*5i|*0U-> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8abcc6851b876adebd08462cb24483155b5184aa GIT binary patch literal 1456 zcmYjRdrVVz6#n&kEw@k}rJ^0LUdpRF7b=3HhEhfWL3v0VDok1f)1eqd9UxG8xjdv< zdB{L?Jc_Uhh!ZqE7zh+41&0a<2#5mOZ5SD70pS9rUHNNE&LiK+cTRGWb3RFMps%66 zxjq2E&~Gn4M3?1jj*xXR*#?l6e}eD6i)4MT*qjdvf%DI(%j!?+&;bObVL{N zq_3B#=Ay=2Qj>CLivR7X=9VC!td0_mS|yoMt<$8kf@khzk%9bzjD5Ye&cBw9HFIe> z?uKL;V4-+zRqsb&Datj2oG<1tEdc$=}3wZ{VzxiBy zV9teDuwhi5VrG_WmM6#CM%&^>zG?R(d%#|&Vcy_>)~k#ce=DMt^1(WOupUWj;+XujoHX7 ziWx{IlAy*b$Sr$}nmw@_>dHWF*?y$}Dp$2F~{QIRuSlxs?NFM?8{J0a$Li zK;H(BL@H3$ung$i;vz}~k2RbJ^zCsGQXymw2ZGGGD*7mbBD9;f7?0mj3UFGxGlcAg z=O`$f_S6=W^Em;aT_P+6TTuKMRFi=$Qz}?2A`leO+7CfB`7-9R7cirzfg6XCVW{E* z0`vT9#}HT^>*|Ao)|#&%(x-p`8iGaKdNd?e3Tmqmb4gK;h8^$$ZiUZqlszTDC`!FF=RHiKwpo^gJ=-;R5TUO^ zU`_g`gXp%ytQg|o;f719;s9X55HGa?LP>xI-xWS%a_xL_W9r?x7nY9}^Bc=P<0@9){r z-rwEb-DB@m_@bVd+mv{?F8RD*75<)@Gv1HAo-bRhR>)p-&?{EoZ8p@q-6;Sshnt=q zA>AF1c^{3!&t2}e?@Cy`?@ij@61UPr!QDSkpUSgPy#q~~TwGFR^x^)2=P$?39MbFM pq&`2};TL%h998sOTHuCh_0oQm#C~^&H>6Wy;O8C4zxA~c{}&f^ZUz7V literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8abcc6851b876adebd08462cb24483155b5184aa GIT binary patch literal 1456 zcmYjRdrVVz6#n&kEw@k}rJ^0LUdpRF7b=3HhEhfWL3v0VDok1f)1eqd9UxG8xjdv< zdB{L?Jc_Uhh!ZqE7zh+41&0a<2#5mOZ5SD70pS9rUHNNE&LiK+cTRGWb3RFMps%66 zxjq2E&~Gn4M3?1jj*xXR*#?l6e}eD6i)4MT*qjdvf%DI(%j!?+&;bObVL{N zq_3B#=Ay=2Qj>CLivR7X=9VC!td0_mS|yoMt<$8kf@khzk%9bzjD5Ye&cBw9HFIe> z?uKL;V4-+zRqsb&Datj2oG<1tEdc$=}3wZ{VzxiBy zV9teDuwhi5VrG_WmM6#CM%&^>zG?R(d%#|&Vcy_>)~k#ce=DMt^1(WOupUWj;+XujoHX7 ziWx{IlAy*b$Sr$}nmw@_>dHWF*?y$}Dp$2F~{QIRuSlxs?NFM?8{J0a$Li zK;H(BL@H3$ung$i;vz}~k2RbJ^zCsGQXymw2ZGGGD*7mbBD9;f7?0mj3UFGxGlcAg z=O`$f_S6=W^Em;aT_P+6TTuKMRFi=$Qz}?2A`leO+7CfB`7-9R7cirzfg6XCVW{E* z0`vT9#}HT^>*|Ao)|#&%(x-p`8iGaKdNd?e3Tmqmb4gK;h8^$$ZiUZqlszTDC`!FF=RHiKwpo^gJ=-;R5TUO^ zU`_g`gXp%ytQg|o;f719;s9X55HGa?LP>xI-xWS%a_xL_W9r?x7nY9}^Bc=P<0@9){r z-rwEb-DB@m_@bVd+mv{?F8RD*75<)@Gv1HAo-bRhR>)p-&?{EoZ8p@q-6;Sshnt=q zA>AF1c^{3!&t2}e?@Cy`?@ij@61UPr!QDSkpUSgPy#q~~TwGFR^x^)2=P$?39MbFM pq&`2};TL%h998sOTHuCh_0oQm#C~^&H>6Wy;O8C4zxA~c{}&f^ZUz7V literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b71098c1c537ccce0c12ba46d1225444e8ed23f0 GIT binary patch literal 4800 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 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