feat: VideoKonverter v4.0.1 - UX-Verbesserungen, Batch-Thumbnails, Bugfixes
- Alphabet-Seitenleiste (A-Z) auf Serien-/Filme-Seite - Separate Player-Buttons fuer Audio/Untertitel/Qualitaet - Batch-Thumbnail-Generierung per Button in der Bibliothek - Redundante Dateien in Episoden-Tabelle orange markiert - Gesehen-Markierung per Episode/Staffel - Genre-Filter als Select-Element statt Chips - Fix: tvdb_episode_cache fehlende Spalten (overview, image_url) - Fix: Login Auto-Fill-Erkennung statt Flash - Fix: Profil-Wechsel zeigt alle User Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
61ca20bf8b
commit
c7151e8bd1
21 changed files with 873 additions and 122 deletions
52
CHANGELOG.md
52
CHANGELOG.md
|
|
@ -2,6 +2,58 @@
|
||||||
|
|
||||||
Alle relevanten Aenderungen am VideoKonverter-Projekt.
|
Alle relevanten Aenderungen am VideoKonverter-Projekt.
|
||||||
|
|
||||||
|
## [4.0.1] - 2026-03-01
|
||||||
|
|
||||||
|
### TV-App: UX-Verbesserungen & Bugfixes
|
||||||
|
|
||||||
|
#### Neue Features
|
||||||
|
- **Alphabet-Seitenleiste**: Vertikale A-Z Sidebar auf Serien-/Filme-Seite zum Filtern nach Anfangsbuchstabe
|
||||||
|
- Buchstaben ohne Treffer automatisch abgedunkelt
|
||||||
|
- Wird in Ordner-Ansicht versteckt
|
||||||
|
- Responsive fuer Handy/Tablet
|
||||||
|
- **Genre-Select statt Chips**: Genre-Filter als Dropdown-Element (uebersichtlicher bei vielen Genres)
|
||||||
|
- **Player-Buttons**: Separate Symbole fuer Audio (Lautsprecher-SVG), Untertitel (CC-Badge), Qualitaet (HD-Badge)
|
||||||
|
- CC-Button leuchtet wenn Untertitel aktiv, Quality-Badge zeigt aktuellen Modus (4K/HD/SD/LD)
|
||||||
|
- Klick oeffnet Overlay direkt bei der entsprechenden Sektion
|
||||||
|
- **Gesehen-Markierung**: Buttons fuer "Episode gesehen" und "Staffel gesehen" in Serien-Detail
|
||||||
|
- **Batch-Thumbnails**: Neuer Button "Thumbnails" in der Bibliothek generiert alle fehlenden Episoden-Thumbnails im Hintergrund per ffmpeg
|
||||||
|
- **Redundanz-Markierung**: Duplikate in der Episoden-Tabelle werden jetzt orange markiert mit "REDUNDANT"-Badge
|
||||||
|
- Ranking: Neuerer Codec > kleinere Datei
|
||||||
|
- **Rating-Sortierung**: Serien/Filme nach Bewertung sortierbar + Min-Rating-Filter
|
||||||
|
|
||||||
|
#### Bugfixes
|
||||||
|
- **tvdb_episode_cache**: Fehlende Spalten `overview` und `image_url` hinzugefuegt (Episoden-Beschreibungen funktionierten nicht)
|
||||||
|
- **Login-Form Flash**: Auto-Fill-Erkennung statt hartem Timeout (prueft 5x alle 200ms ob Browser Felder ausgefuellt hat)
|
||||||
|
- **Profil-Wechsel**: Zeigt jetzt alle User an (nicht nur die mit aktiver Session)
|
||||||
|
- **Debug-Prints entfernt**: Bereinigung aus server.py und tv_api.py
|
||||||
|
- **Route-Registrierung**: TV-API-Routen in `_setup_app()` verschoben (verhinderte 500-Fehler)
|
||||||
|
|
||||||
|
#### Neue API-Endpunkte
|
||||||
|
- `POST /api/library/generate-thumbnails` - Batch-Thumbnail-Generierung starten
|
||||||
|
- `GET /api/library/thumbnail-status` - Thumbnail-Fortschritt abfragen
|
||||||
|
|
||||||
|
#### Geaenderte Dateien (19 Dateien, +821/-122 Zeilen)
|
||||||
|
- `app/routes/library_api.py` - Batch-Thumbnails + aiomysql Import
|
||||||
|
- `app/routes/tv_api.py` - Gesehen-Status, Rating-Filter, Genre-Select
|
||||||
|
- `app/server.py` - Route-Registrierung Fix
|
||||||
|
- `app/services/auth.py` - Watch-Status DB-Methoden
|
||||||
|
- `app/services/library.py` - tvdb_episode_cache Spalten-Fix + Migration
|
||||||
|
- `app/static/css/style.css` - Redundanz-Zeilen-Style
|
||||||
|
- `app/static/js/library.js` - Redundanz-Erkennung, Batch-Thumbnails
|
||||||
|
- `app/static/tv/css/tv.css` - Player-Badges, Alphabet-Sidebar, Rating-Styles
|
||||||
|
- `app/static/tv/i18n/de.json` + `en.json` - Rating-Uebersetzungen
|
||||||
|
- `app/static/tv/js/player.js` - Overlay-Sections, Button-Updates
|
||||||
|
- `app/static/tv/js/tv.js` - Gesehen-Buttons, Alphabet-Filter
|
||||||
|
- `app/templates/library.html` - Thumbnails-Button
|
||||||
|
- `app/templates/tv/login.html` - Auto-Fill-Erkennung
|
||||||
|
- `app/templates/tv/movies.html` - Alphabet-Sidebar, data-letter
|
||||||
|
- `app/templates/tv/player.html` - Audio/CC/Quality-Buttons
|
||||||
|
- `app/templates/tv/profiles.html` - Alle User anzeigen
|
||||||
|
- `app/templates/tv/series.html` - Alphabet-Sidebar, data-letter
|
||||||
|
- `app/templates/tv/series_detail.html` - Gesehen-Buttons, Episoden-Beschreibungen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [4.0.0] - 2026-03-01
|
## [4.0.0] - 2026-03-01
|
||||||
|
|
||||||
### TV-App: Vollwertiger Streaming-Client
|
### TV-App: Vollwertiger Streaming-Client
|
||||||
|
|
|
||||||
BIN
docker-exports/videoconverter-4.0.1.tar
Normal file
BIN
docker-exports/videoconverter-4.0.1.tar
Normal file
Binary file not shown.
|
|
@ -1,6 +1,7 @@
|
||||||
"""REST API Endpoints fuer die Video-Bibliothek"""
|
"""REST API Endpoints fuer die Video-Bibliothek"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import aiomysql
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from app.config import Config
|
from app.config import Config
|
||||||
from app.services.library import LibraryService
|
from app.services.library import LibraryService
|
||||||
|
|
@ -1696,6 +1697,147 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
logging.error(f"Thumbnail-Fehler: {e}")
|
logging.error(f"Thumbnail-Fehler: {e}")
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
# === Batch-Thumbnail-Generierung ===
|
||||||
|
|
||||||
|
_thumbnail_task = None # Hintergrund-Task fuer Batch-Generierung
|
||||||
|
|
||||||
|
async def post_generate_thumbnails(request: web.Request) -> web.Response:
|
||||||
|
"""POST /api/library/generate-thumbnails
|
||||||
|
Generiert fehlende Thumbnails fuer alle Videos im Hintergrund.
|
||||||
|
Optional: ?series_id=123 fuer nur eine Serie."""
|
||||||
|
import os
|
||||||
|
import asyncio as _asyncio
|
||||||
|
nonlocal _thumbnail_task
|
||||||
|
|
||||||
|
# Laeuft bereits?
|
||||||
|
if _thumbnail_task and not _thumbnail_task.done():
|
||||||
|
return web.json_response({
|
||||||
|
"status": "running",
|
||||||
|
"message": "Thumbnail-Generierung laeuft bereits"
|
||||||
|
})
|
||||||
|
|
||||||
|
pool = await library_service._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Keine DB-Verbindung"}, status=500)
|
||||||
|
|
||||||
|
series_id = request.query.get("series_id")
|
||||||
|
|
||||||
|
async def _generate_batch():
|
||||||
|
"""Hintergrund-Task: Fehlende Thumbnails erzeugen."""
|
||||||
|
generated = 0
|
||||||
|
errors = 0
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
# Videos ohne Thumbnail finden
|
||||||
|
sql = """
|
||||||
|
SELECT v.id, v.file_path, v.duration_sec
|
||||||
|
FROM library_videos v
|
||||||
|
LEFT JOIN tv_episode_thumbnails t
|
||||||
|
ON v.id = t.video_id
|
||||||
|
WHERE t.video_id IS NULL
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
if series_id:
|
||||||
|
sql += " AND v.series_id = %s"
|
||||||
|
params.append(int(series_id))
|
||||||
|
sql += " ORDER BY v.id"
|
||||||
|
await cur.execute(sql, params)
|
||||||
|
videos = await cur.fetchall()
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail"
|
||||||
|
)
|
||||||
|
|
||||||
|
for video in videos:
|
||||||
|
vid = video["id"]
|
||||||
|
fp = video["file_path"]
|
||||||
|
dur = video.get("duration_sec") or 0
|
||||||
|
|
||||||
|
if not os.path.isfile(fp):
|
||||||
|
continue
|
||||||
|
|
||||||
|
seek = dur * 0.25 if dur > 10 else 5
|
||||||
|
vdir = os.path.dirname(fp)
|
||||||
|
tdir = os.path.join(vdir, ".metadata", "thumbnails")
|
||||||
|
os.makedirs(tdir, exist_ok=True)
|
||||||
|
tpath = os.path.join(tdir, f"{vid}.jpg")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-ss", str(int(seek)),
|
||||||
|
"-i", fp,
|
||||||
|
"-vframes", "1", "-q:v", "5",
|
||||||
|
"-vf", "scale=480:-1",
|
||||||
|
"-y", tpath,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = await _asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=_asyncio.subprocess.PIPE,
|
||||||
|
stderr=_asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
await proc.communicate()
|
||||||
|
|
||||||
|
if proc.returncode == 0 and os.path.isfile(tpath):
|
||||||
|
async with pool.acquire() as conn2:
|
||||||
|
async with conn2.cursor() as cur2:
|
||||||
|
await cur2.execute("""
|
||||||
|
INSERT INTO tv_episode_thumbnails
|
||||||
|
(video_id, thumbnail_path, source)
|
||||||
|
VALUES (%s, %s, 'ffmpeg')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
thumbnail_path = VALUES(thumbnail_path)
|
||||||
|
""", (vid, tpath))
|
||||||
|
generated += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Thumbnail-Fehler Video {vid}: {e}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
f"Thumbnail-Batch fertig: {generated} erzeugt, "
|
||||||
|
f"{errors} Fehler"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Thumbnail-Batch Fehler: {e}")
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
_thumbnail_task = asyncio.ensure_future(_generate_batch())
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": "started",
|
||||||
|
"message": "Thumbnail-Generierung gestartet"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def get_thumbnail_status(request: web.Request) -> web.Response:
|
||||||
|
"""GET /api/library/thumbnail-status
|
||||||
|
Zeigt Fortschritt der Thumbnail-Generierung."""
|
||||||
|
pool = await library_service._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Keine DB-Verbindung"}, status=500)
|
||||||
|
|
||||||
|
running = bool(_thumbnail_task and not _thumbnail_task.done())
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM tv_episode_thumbnails")
|
||||||
|
done = (await cur.fetchone())["cnt"]
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM library_videos")
|
||||||
|
total = (await cur.fetchone())["cnt"]
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"running": running,
|
||||||
|
"generated": done,
|
||||||
|
"total": total,
|
||||||
|
"missing": total - done,
|
||||||
|
})
|
||||||
|
|
||||||
# === Import: Item zuordnen / ueberspringen ===
|
# === Import: Item zuordnen / ueberspringen ===
|
||||||
|
|
||||||
async def post_reassign_import_item(
|
async def post_reassign_import_item(
|
||||||
|
|
@ -2202,6 +2344,13 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
"/api/library/videos/{video_id}/thumbnail", get_video_thumbnail
|
"/api/library/videos/{video_id}/thumbnail", get_video_thumbnail
|
||||||
)
|
)
|
||||||
|
# Batch-Thumbnails
|
||||||
|
app.router.add_post(
|
||||||
|
"/api/library/generate-thumbnails", post_generate_thumbnails
|
||||||
|
)
|
||||||
|
app.router.add_get(
|
||||||
|
"/api/library/thumbnail-status", get_thumbnail_status
|
||||||
|
)
|
||||||
# TVDB Auto-Match (Review-Modus)
|
# TVDB Auto-Match (Review-Modus)
|
||||||
app.router.add_post(
|
app.router.add_post(
|
||||||
"/api/library/tvdb-auto-match", post_tvdb_auto_match
|
"/api/library/tvdb-auto-match", post_tvdb_auto_match
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
)
|
)
|
||||||
src_name = src_map.get(source_filter, "")
|
src_name = src_map.get(source_filter, "")
|
||||||
if items:
|
if items:
|
||||||
folder_data.append({"name": src_name, "items": items})
|
folder_data.append({"name": src_name, "entries": items})
|
||||||
else:
|
else:
|
||||||
for src in sources:
|
for src in sources:
|
||||||
items = sorted(
|
items = sorted(
|
||||||
|
|
@ -326,7 +326,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
)
|
)
|
||||||
if items:
|
if items:
|
||||||
folder_data.append({
|
folder_data.append({
|
||||||
"name": src["name"], "items": items})
|
"name": src["name"], "entries": items})
|
||||||
# Serien ohne Quelle (Fallback)
|
# Serien ohne Quelle (Fallback)
|
||||||
src_ids = {src["id"] for src in sources}
|
src_ids = {src["id"] for src in sources}
|
||||||
orphans = sorted(
|
orphans = sorted(
|
||||||
|
|
@ -335,7 +335,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
key=lambda x: (x.get("folder_name") or "").lower()
|
key=lambda x: (x.get("folder_name") or "").lower()
|
||||||
)
|
)
|
||||||
if orphans:
|
if orphans:
|
||||||
folder_data.append({"name": "Sonstige", "items": orphans})
|
folder_data.append({"name": "Sonstige", "entries": orphans})
|
||||||
|
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/series.html", request, {
|
"tv/series.html", request, {
|
||||||
|
|
@ -554,7 +554,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
)
|
)
|
||||||
src_name = src_map.get(source_filter, "")
|
src_name = src_map.get(source_filter, "")
|
||||||
if items:
|
if items:
|
||||||
folder_data.append({"name": src_name, "items": items})
|
folder_data.append({"name": src_name, "entries": items})
|
||||||
else:
|
else:
|
||||||
for src in sources:
|
for src in sources:
|
||||||
items = sorted(
|
items = sorted(
|
||||||
|
|
@ -564,7 +564,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
)
|
)
|
||||||
if items:
|
if items:
|
||||||
folder_data.append({
|
folder_data.append({
|
||||||
"name": src["name"], "items": items})
|
"name": src["name"], "entries": items})
|
||||||
# Filme ohne Quelle (Fallback)
|
# Filme ohne Quelle (Fallback)
|
||||||
src_ids = {src["id"] for src in sources}
|
src_ids = {src["id"] for src in sources}
|
||||||
orphans = sorted(
|
orphans = sorted(
|
||||||
|
|
@ -573,7 +573,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
key=lambda x: (x.get("folder_name") or "").lower()
|
key=lambda x: (x.get("folder_name") or "").lower()
|
||||||
)
|
)
|
||||||
if orphans:
|
if orphans:
|
||||||
folder_data.append({"name": "Sonstige", "items": orphans})
|
folder_data.append({"name": "Sonstige", "entries": orphans})
|
||||||
|
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/movies.html", request, {
|
"tv/movies.html", request, {
|
||||||
|
|
@ -929,36 +929,56 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
# --- Profilauswahl (Multi-User Quick-Switch) ---
|
# --- Profilauswahl (Multi-User Quick-Switch) ---
|
||||||
|
|
||||||
async def get_profiles(request: web.Request) -> web.Response:
|
async def get_profiles(request: web.Request) -> web.Response:
|
||||||
"""GET /tv/profiles - Profilauswahl (wer schaut?)"""
|
"""GET /tv/profiles - Profilauswahl (wer schaut?)
|
||||||
|
Zeigt alle User an. Aktuelle Session wird hervorgehoben."""
|
||||||
client_id = request.cookies.get("vk_client_id")
|
client_id = request.cookies.get("vk_client_id")
|
||||||
profiles = []
|
|
||||||
if client_id:
|
|
||||||
profiles = await auth_service.get_client_profiles(client_id)
|
|
||||||
# Aktuelle Session herausfinden
|
|
||||||
current_session = request.cookies.get("vk_session")
|
current_session = request.cookies.get("vk_session")
|
||||||
|
current_user_id = None
|
||||||
|
if current_session:
|
||||||
|
user = await auth_service.validate_session(current_session)
|
||||||
|
if user:
|
||||||
|
current_user_id = user.get("id")
|
||||||
|
|
||||||
|
# Alle User laden (nicht nur die mit Sessions auf diesem Client)
|
||||||
|
all_users = await auth_service.get_all_users()
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/profiles.html", request, {
|
"tv/profiles.html", request, {
|
||||||
"profiles": profiles,
|
"profiles": all_users,
|
||||||
"current_session": current_session,
|
"current_user_id": current_user_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def post_switch_profile(request: web.Request) -> web.Response:
|
async def post_switch_profile(request: web.Request) -> web.Response:
|
||||||
"""POST /tv/switch-profile - Profil wechseln (Session-ID)"""
|
"""POST /tv/switch-profile - Auf anderen User wechseln.
|
||||||
|
Erstellt neue Session fuer den gewaehlten User."""
|
||||||
data = await request.post()
|
data = await request.post()
|
||||||
session_id = data.get("session_id", "")
|
user_id = data.get("user_id", "")
|
||||||
if not session_id:
|
if not user_id:
|
||||||
raise web.HTTPFound("/tv/profiles")
|
raise web.HTTPFound("/tv/profiles")
|
||||||
# Session validieren
|
|
||||||
user = await auth_service.validate_session(session_id)
|
# Client-ID ermitteln/erstellen
|
||||||
if not user:
|
client_id = request.cookies.get("vk_client_id")
|
||||||
|
client_id = await auth_service.get_or_create_client(client_id)
|
||||||
|
|
||||||
|
# Neue Session fuer den User erstellen
|
||||||
|
ua = request.headers.get("User-Agent", "")
|
||||||
|
session_id = await auth_service.create_session(
|
||||||
|
int(user_id), ua, client_id=client_id, persistent=True
|
||||||
|
)
|
||||||
|
if not session_id:
|
||||||
raise web.HTTPFound("/tv/login")
|
raise web.HTTPFound("/tv/login")
|
||||||
|
|
||||||
resp = web.HTTPFound("/tv/")
|
resp = web.HTTPFound("/tv/")
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
"vk_session", session_id,
|
"vk_session", session_id,
|
||||||
max_age=10 * 365 * 24 * 3600,
|
max_age=10 * 365 * 24 * 3600,
|
||||||
httponly=True, samesite="Lax", path="/",
|
httponly=True, samesite="Lax", path="/",
|
||||||
)
|
)
|
||||||
|
resp.set_cookie(
|
||||||
|
"vk_client_id", client_id,
|
||||||
|
max_age=10 * 365 * 24 * 3600,
|
||||||
|
httponly=True, samesite="Lax", path="/",
|
||||||
|
)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
# --- User-Einstellungen ---
|
# --- User-Einstellungen ---
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,18 @@ class VideoKonverterServer:
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def _no_cache_middleware(self, request: web.Request,
|
async def _no_cache_middleware(self, request: web.Request,
|
||||||
handler) -> web.Response:
|
handler) -> web.Response:
|
||||||
"""Verhindert Browser-Caching fuer API-Responses"""
|
"""Verhindert Browser-Caching fuer API-Responses + Error-Logging"""
|
||||||
|
try:
|
||||||
response = await handler(request)
|
response = await handler(request)
|
||||||
|
except web.HTTPException as he:
|
||||||
|
if he.status >= 500:
|
||||||
|
logging.error(f"HTTP {he.status} bei {request.method} {request.path}: {he.reason}",
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unbehandelte Ausnahme bei {request.method} {request.path}: {e}",
|
||||||
|
exc_info=True)
|
||||||
|
raise
|
||||||
if request.path.startswith("/api/"):
|
if request.path.startswith("/api/"):
|
||||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||||
response.headers["Pragma"] = "no-cache"
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
@ -96,8 +106,14 @@ class VideoKonverterServer:
|
||||||
# Seiten Routes
|
# Seiten Routes
|
||||||
setup_page_routes(self.app, self.config, self.queue_service)
|
setup_page_routes(self.app, self.config, self.queue_service)
|
||||||
|
|
||||||
# TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert)
|
# TV-App Routes (Auth-Service, DB-Pool wird in on_startup gesetzt)
|
||||||
self.auth_service = None
|
async def _lazy_pool():
|
||||||
|
return self.library_service._db_pool
|
||||||
|
self.auth_service = AuthService(_lazy_pool)
|
||||||
|
setup_tv_routes(
|
||||||
|
self.app, self.config,
|
||||||
|
self.auth_service, self.library_service,
|
||||||
|
)
|
||||||
|
|
||||||
# Statische Dateien
|
# Statische Dateien
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
|
@ -151,16 +167,9 @@ class VideoKonverterServer:
|
||||||
await self.tvdb_service.init_db()
|
await self.tvdb_service.init_db()
|
||||||
await self.importer_service.init_db()
|
await self.importer_service.init_db()
|
||||||
|
|
||||||
# TV-App Auth-Service initialisieren (braucht DB-Pool)
|
# TV-App Auth-Service: DB-Tabellen initialisieren (Pool kommt ueber lazy getter)
|
||||||
if self.library_service._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()
|
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")
|
host = self.config.server_config.get("host", "0.0.0.0")
|
||||||
port = self.config.server_config.get("port", 8080)
|
port = self.config.server_config.get("port", 8080)
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,20 @@ class AuthService:
|
||||||
""", (client_id,))
|
""", (client_id,))
|
||||||
return await cur.fetchall()
|
return await cur.fetchall()
|
||||||
|
|
||||||
|
async def get_all_users(self) -> list[dict]:
|
||||||
|
"""Alle User laden (fuer Profilauswahl)"""
|
||||||
|
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, avatar_color
|
||||||
|
FROM tv_users
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
return await cur.fetchall()
|
||||||
|
|
||||||
# --- User-Einstellungen ---
|
# --- User-Einstellungen ---
|
||||||
|
|
||||||
async def update_user_settings(self, user_id: int,
|
async def update_user_settings(self, user_id: int,
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,8 @@ class LibraryService:
|
||||||
episode_name VARCHAR(512),
|
episode_name VARCHAR(512),
|
||||||
aired DATE NULL,
|
aired DATE NULL,
|
||||||
runtime INT NULL,
|
runtime INT NULL,
|
||||||
|
overview TEXT NULL,
|
||||||
|
image_url VARCHAR(1024) NULL,
|
||||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_series (series_tvdb_id),
|
INDEX idx_series (series_tvdb_id),
|
||||||
UNIQUE INDEX idx_episode (
|
UNIQUE INDEX idx_episode (
|
||||||
|
|
@ -227,6 +229,18 @@ class LibraryService:
|
||||||
)
|
)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
""")
|
""")
|
||||||
|
# Spalten nachtraeglich hinzufuegen (bestehende DBs)
|
||||||
|
for col, coldef in [
|
||||||
|
("overview", "TEXT NULL"),
|
||||||
|
("image_url", "VARCHAR(1024) NULL"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
await cur.execute(
|
||||||
|
f"ALTER TABLE tvdb_episode_cache "
|
||||||
|
f"ADD COLUMN {col} {coldef}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Spalte existiert bereits
|
||||||
|
|
||||||
# movie_id Spalte in library_videos (falls noch nicht vorhanden)
|
# movie_id Spalte in library_videos (falls noch nicht vorhanden)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1081,6 +1081,8 @@ legend {
|
||||||
|
|
||||||
.row-missing { opacity: 0.6; }
|
.row-missing { opacity: 0.6; }
|
||||||
.row-missing td { color: #888; }
|
.row-missing td { color: #888; }
|
||||||
|
.row-redundant { background: rgba(255, 152, 0, 0.08); }
|
||||||
|
.row-redundant td { color: #b0a080; }
|
||||||
.text-warn { color: #ffb74d; }
|
.text-warn { color: #ffb74d; }
|
||||||
.text-muted { color: #888; font-size: 0.8rem; }
|
.text-muted { color: #888; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -638,6 +638,33 @@ function renderEpisodesTab(series) {
|
||||||
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
|
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
|
||||||
allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0));
|
allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0));
|
||||||
|
|
||||||
|
// Redundante Dateien erkennen: gleiche Episode-Nummer mehrfach vorhanden
|
||||||
|
// Die "beste" Datei behalten (kleinere Datei bei gleichem Codec, neueres Format bevorzugt)
|
||||||
|
const epGroups = {};
|
||||||
|
for (const ep of allEps) {
|
||||||
|
if (ep._type !== "local" || !ep.episode_number) continue;
|
||||||
|
const key = `${ep.season_number || 0}-${ep.episode_number}`;
|
||||||
|
if (!epGroups[key]) epGroups[key] = [];
|
||||||
|
epGroups[key].push(ep);
|
||||||
|
}
|
||||||
|
const redundantIds = new Set();
|
||||||
|
const codecRank = {av1: 4, hevc: 3, h265: 3, h264: 2, x264: 2, mpeg4: 1, mpeg2video: 0};
|
||||||
|
for (const key of Object.keys(epGroups)) {
|
||||||
|
const group = epGroups[key];
|
||||||
|
if (group.length <= 1) continue;
|
||||||
|
// Sortiere: neuerer Codec besser, bei gleichem Codec kleinere Datei besser
|
||||||
|
group.sort((a, b) => {
|
||||||
|
const ra = codecRank[(a.video_codec || "").toLowerCase()] || 0;
|
||||||
|
const rb = codecRank[(b.video_codec || "").toLowerCase()] || 0;
|
||||||
|
if (ra !== rb) return rb - ra;
|
||||||
|
return (a.file_size || 0) - (b.file_size || 0);
|
||||||
|
});
|
||||||
|
// Alle ausser dem ersten sind redundant
|
||||||
|
for (let i = 1; i < group.length; i++) {
|
||||||
|
redundantIds.add(group[i].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const ep of allEps) {
|
for (const ep of allEps) {
|
||||||
if (ep._type === "missing") {
|
if (ep._type === "missing") {
|
||||||
html += `<tr class="row-missing">
|
html += `<tr class="row-missing">
|
||||||
|
|
@ -647,6 +674,7 @@ function renderEpisodesTab(series) {
|
||||||
<td><span class="status-badge error">FEHLT</span></td>
|
<td><span class="status-badge error">FEHLT</span></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
} else {
|
} else {
|
||||||
|
const isRedundant = redundantIds.has(ep.id);
|
||||||
const audioInfo = (ep.audio_tracks || []).map(a => {
|
const audioInfo = (ep.audio_tracks || []).map(a => {
|
||||||
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
||||||
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
|
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
|
||||||
|
|
@ -654,9 +682,10 @@ function renderEpisodesTab(series) {
|
||||||
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
|
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
|
||||||
const epTitle = ep.episode_title || ep.file_name || "Episode";
|
const epTitle = ep.episode_title || ep.file_name || "Episode";
|
||||||
const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-";
|
const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-";
|
||||||
html += `<tr data-video-id="${ep.id}">
|
const redundantBadge = isRedundant ? ' <span class="status-badge warn" title="Duplikat - kann geloescht werden">REDUNDANT</span>' : '';
|
||||||
|
html += `<tr data-video-id="${ep.id}" class="${isRedundant ? 'row-redundant' : ''}">
|
||||||
<td>${ep.episode_number || "-"}</td>
|
<td>${ep.episode_number || "-"}</td>
|
||||||
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td>
|
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}${redundantBadge}</td>
|
||||||
<td>${res}</td>
|
<td>${res}</td>
|
||||||
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
|
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
|
||||||
<td><span class="tag">${fileExt}</span></td>
|
<td><span class="tag">${fileExt}</span></td>
|
||||||
|
|
@ -3158,3 +3187,45 @@ async function deleteVideo(videoId, title, context) {
|
||||||
})
|
})
|
||||||
.catch(e => showToast("Fehler: " + e, "error"));
|
.catch(e => showToast("Fehler: " + e, "error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Batch-Thumbnail-Generierung ===
|
||||||
|
|
||||||
|
async function generateThumbnails() {
|
||||||
|
// Status pruefen
|
||||||
|
const status = await fetch("/api/library/thumbnail-status").then(r => r.json());
|
||||||
|
if (status.missing === 0) {
|
||||||
|
showToast("Alle " + status.total + " Videos haben bereits Thumbnails", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await showConfirm(
|
||||||
|
status.missing + " von " + status.total + " Videos haben noch kein Thumbnail. Jetzt generieren?",
|
||||||
|
{title: "Thumbnails generieren", detail: "Die Generierung laeuft im Hintergrund per ffmpeg.", okText: "Starten", icon: "info"}
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
fetch("/api/library/generate-thumbnails", {method: "POST"})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === "running") {
|
||||||
|
showToast("Thumbnail-Generierung laeuft bereits", "info");
|
||||||
|
} else {
|
||||||
|
showToast("Thumbnail-Generierung gestartet", "success");
|
||||||
|
pollThumbnailStatus();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => showToast("Fehler: " + e, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollThumbnailStatus() {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetch("/api/library/thumbnail-status")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.running) {
|
||||||
|
clearInterval(interval);
|
||||||
|
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => clearInterval(interval));
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -290,9 +290,71 @@ a { color: var(--accent); text-decoration: none; }
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tv-episode-card:hover { background: var(--bg-hover); }
|
||||||
|
.tv-ep-link {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tv-ep-link:focus { outline: var(--focus-ring); outline-offset: -2px; border-radius: var(--radius); }
|
||||||
|
|
||||||
|
/* Gesehen-Button pro Episode */
|
||||||
|
.tv-ep-mark-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--text-dim);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
.tv-ep-mark-btn:hover, .tv-ep-mark-btn:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.tv-ep-mark-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tv-ep-seen { opacity: 0.6; }
|
||||||
|
.tv-ep-seen:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Staffel-Aktionen */
|
||||||
|
.tv-season-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.3rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
.tv-season-mark-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--text-dim);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tv-season-mark-btn:hover, .tv-season-mark-btn:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
.tv-episode-card:hover, .tv-episode-card:focus { background: var(--bg-hover); }
|
|
||||||
.tv-episode-card:focus { outline: var(--focus-ring); outline-offset: -2px; }
|
|
||||||
|
|
||||||
/* Thumbnail-Bereich */
|
/* Thumbnail-Bereich */
|
||||||
.tv-ep-thumb {
|
.tv-ep-thumb {
|
||||||
|
|
@ -919,6 +981,22 @@ a { color: var(--accent); text-decoration: none; }
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.player-btn:focus { outline: var(--focus-ring); }
|
.player-btn:focus { outline: var(--focus-ring); }
|
||||||
|
.player-btn svg { display: block; }
|
||||||
|
.player-btn-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.player-btn.active { color: var(--accent); }
|
||||||
|
.player-btn.active .player-btn-badge {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
.player-time { color: var(--text-muted); font-size: 0.85rem; }
|
.player-time { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
.player-spacer { flex: 1; }
|
.player-spacer { flex: 1; }
|
||||||
|
|
||||||
|
|
@ -1217,6 +1295,18 @@ a { color: var(--accent); text-decoration: none; }
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
/* SELECT im Editier-Modus: deutlich hervorgehoben */
|
||||||
|
select.select-editing {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
/* Auch Sort-/Filter-Selects im Content-Bereich */
|
||||||
|
.tv-sort-select.select-editing,
|
||||||
|
.tv-rating-filter.select-editing {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent);
|
||||||
|
}
|
||||||
.color-picker-grid {
|
.color-picker-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -1475,3 +1565,46 @@ a { color: var(--accent); text-decoration: none; }
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Alphabet-Seitenleiste === */
|
||||||
|
.tv-alpha-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
.tv-alpha-letter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 19px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.tv-alpha-letter:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
|
.tv-alpha-letter:focus { outline: var(--focus-ring); outline-offset: -1px; }
|
||||||
|
.tv-alpha-letter.active { color: #000; background: var(--accent); }
|
||||||
|
.tv-alpha-letter.dimmed { color: var(--border); pointer-events: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tv-alpha-sidebar { right: 2px; padding: 3px 1px; }
|
||||||
|
.tv-alpha-letter { width: 20px; height: 17px; font-size: 0.58rem; }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.tv-alpha-sidebar { right: 1px; padding: 2px 1px; }
|
||||||
|
.tv-alpha-letter { width: 16px; height: 14px; font-size: 0.5rem; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@
|
||||||
"sort_episodes": "Episoden-Anzahl",
|
"sort_episodes": "Episoden-Anzahl",
|
||||||
"sort_last_watched": "Zuletzt angesehen",
|
"sort_last_watched": "Zuletzt angesehen",
|
||||||
"sort_rating": "Bewertung",
|
"sort_rating": "Bewertung",
|
||||||
|
"all_genres": "Alle Genres",
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
"min_rating": "Min. Sterne"
|
"min_rating": "Min. Sterne"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@
|
||||||
"sort_episodes": "Episode Count",
|
"sort_episodes": "Episode Count",
|
||||||
"sort_last_watched": "Last Watched",
|
"sort_last_watched": "Last Watched",
|
||||||
"sort_rating": "Rating",
|
"sort_rating": "Rating",
|
||||||
|
"all_genres": "All Genres",
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
"min_rating": "Min. Stars"
|
"min_rating": "Min. Stars"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ function initPlayer(opts) {
|
||||||
loadVideoInfo().then(() => {
|
loadVideoInfo().then(() => {
|
||||||
// Stream starten
|
// Stream starten
|
||||||
setStreamUrl(opts.startPos || 0);
|
setStreamUrl(opts.startPos || 0);
|
||||||
|
updatePlayerButtons();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
|
|
@ -63,7 +64,15 @@ function initPlayer(opts) {
|
||||||
|
|
||||||
// Einstellungen-Button
|
// Einstellungen-Button
|
||||||
const btnSettings = document.getElementById("btn-settings");
|
const btnSettings = document.getElementById("btn-settings");
|
||||||
if (btnSettings) btnSettings.addEventListener("click", toggleOverlay);
|
if (btnSettings) btnSettings.addEventListener("click", () => openOverlaySection(null));
|
||||||
|
|
||||||
|
// Separate Buttons: Audio, Untertitel, Qualitaet
|
||||||
|
const btnAudio = document.getElementById("btn-audio");
|
||||||
|
if (btnAudio) btnAudio.addEventListener("click", () => openOverlaySection("audio"));
|
||||||
|
const btnSubs = document.getElementById("btn-subs");
|
||||||
|
if (btnSubs) btnSubs.addEventListener("click", () => openOverlaySection("subs"));
|
||||||
|
const btnQuality = document.getElementById("btn-quality");
|
||||||
|
if (btnQuality) btnQuality.addEventListener("click", () => openOverlaySection("quality"));
|
||||||
|
|
||||||
// Naechste-Episode-Button
|
// Naechste-Episode-Button
|
||||||
const btnNext = document.getElementById("btn-next");
|
const btnNext = document.getElementById("btn-next");
|
||||||
|
|
@ -279,6 +288,25 @@ function toggleOverlay() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openOverlaySection(section) {
|
||||||
|
const overlay = document.getElementById("player-overlay");
|
||||||
|
if (!overlay) return;
|
||||||
|
if (overlayOpen) {
|
||||||
|
// Bereits offen -> schliessen
|
||||||
|
overlayOpen = false;
|
||||||
|
overlay.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlayOpen = true;
|
||||||
|
overlay.style.display = "";
|
||||||
|
renderOverlay();
|
||||||
|
showControls();
|
||||||
|
if (section) {
|
||||||
|
var el = document.getElementById("overlay-" + section);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderOverlay() {
|
function renderOverlay() {
|
||||||
// Audio-Spuren
|
// Audio-Spuren
|
||||||
const audioEl = document.getElementById("overlay-audio");
|
const audioEl = document.getElementById("overlay-audio");
|
||||||
|
|
@ -343,12 +371,14 @@ function switchAudio(idx) {
|
||||||
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
||||||
setStreamUrl(currentTime);
|
setStreamUrl(currentTime);
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
|
updatePlayerButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchSub(idx) {
|
function switchSub(idx) {
|
||||||
currentSub = idx;
|
currentSub = idx;
|
||||||
updateSubtitleTrack();
|
updateSubtitleTrack();
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
|
updatePlayerButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSubtitleTrack() {
|
function updateSubtitleTrack() {
|
||||||
|
|
@ -364,6 +394,7 @@ function switchQuality(q) {
|
||||||
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
||||||
setStreamUrl(currentTime);
|
setStreamUrl(currentTime);
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
|
updatePlayerButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchSpeed(s) {
|
function switchSpeed(s) {
|
||||||
|
|
@ -470,6 +501,26 @@ function saveProgress(completed) {
|
||||||
|
|
||||||
window.addEventListener("beforeunload", () => saveProgress());
|
window.addEventListener("beforeunload", () => saveProgress());
|
||||||
|
|
||||||
|
// === Button-Status aktualisieren ===
|
||||||
|
|
||||||
|
function updatePlayerButtons() {
|
||||||
|
// CC-Button: aktiv wenn Untertitel an
|
||||||
|
var btnSubs = document.getElementById("btn-subs");
|
||||||
|
if (btnSubs) btnSubs.classList.toggle("active", currentSub >= 0);
|
||||||
|
// Quality-Badge: aktuellen Modus anzeigen
|
||||||
|
var badge = document.getElementById("quality-badge");
|
||||||
|
if (badge) {
|
||||||
|
var labels = { uhd: "4K", hd: "HD", sd: "SD", low: "LD" };
|
||||||
|
badge.textContent = labels[currentQuality] || "HD";
|
||||||
|
}
|
||||||
|
// Audio-Button: aktuelle Sprache anzeigen (Tooltip)
|
||||||
|
var btnAudio = document.getElementById("btn-audio");
|
||||||
|
if (btnAudio && videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) {
|
||||||
|
var lang = videoInfo.audio_tracks[currentAudio].lang;
|
||||||
|
btnAudio.title = langName(lang) || "Audio";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Hilfsfunktionen ===
|
// === Hilfsfunktionen ===
|
||||||
|
|
||||||
const LANG_NAMES = {
|
const LANG_NAMES = {
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,20 @@ class FocusManager {
|
||||||
this._currentFocus = null;
|
this._currentFocus = null;
|
||||||
// Merkt sich das letzte fokussierte Element im Content-Bereich
|
// Merkt sich das letzte fokussierte Element im Content-Bereich
|
||||||
this._lastContentFocus = null;
|
this._lastContentFocus = null;
|
||||||
|
// SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte
|
||||||
|
this._selectActive = false;
|
||||||
|
|
||||||
// Tastatur-Events abfangen
|
// Tastatur-Events abfangen
|
||||||
document.addEventListener("keydown", (e) => this._onKeyDown(e));
|
document.addEventListener("keydown", (e) => this._onKeyDown(e));
|
||||||
|
|
||||||
// Focus-Tracking: merken wo wir zuletzt waren
|
// Focus-Tracking: merken wo wir zuletzt waren
|
||||||
document.addEventListener("focusin", (e) => {
|
document.addEventListener("focusin", (e) => {
|
||||||
|
// SELECT-Editier-Modus beenden wenn Focus sich aendert
|
||||||
|
if (this._selectActive && e.target && e.target.tagName !== "SELECT") {
|
||||||
|
this._selectActive = false;
|
||||||
|
document.querySelectorAll(".select-editing").forEach(
|
||||||
|
el => el.classList.remove("select-editing"));
|
||||||
|
}
|
||||||
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
|
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
|
||||||
if (!e.target.closest("#tv-nav")) {
|
if (!e.target.closest("#tv-nav")) {
|
||||||
this._lastContentFocus = e.target;
|
this._lastContentFocus = e.target;
|
||||||
|
|
@ -84,10 +92,14 @@ class FocusManager {
|
||||||
if (direction === "ArrowLeft" || direction === "ArrowRight") return;
|
if (direction === "ArrowLeft" || direction === "ArrowRight") return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select-Elemente: Hoch/Runter dem Browser ueberlassen (Option wechseln)
|
// Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter durchlassen
|
||||||
if (active && active.tagName === "SELECT") {
|
if (active && active.tagName === "SELECT") {
|
||||||
|
if (this._selectActive) {
|
||||||
|
// Editier-Modus: Hoch/Runter aendert den Wert
|
||||||
if (direction === "ArrowUp" || direction === "ArrowDown") return;
|
if (direction === "ArrowUp" || direction === "ArrowDown") return;
|
||||||
}
|
}
|
||||||
|
// Sonst: normal weiternavigieren (Select wird uebersprungen)
|
||||||
|
}
|
||||||
|
|
||||||
const focusables = this._getFocusableElements();
|
const focusables = this._getFocusableElements();
|
||||||
if (!focusables.length) return;
|
if (!focusables.length) return;
|
||||||
|
|
@ -220,8 +232,20 @@ class FocusManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select: Enter oeffnet/schliesst das Dropdown nativ
|
// Select: Enter aktiviert/deaktiviert den Editier-Modus
|
||||||
if (active.tagName === "SELECT") {
|
if (active.tagName === "SELECT") {
|
||||||
|
if (this._selectActive) {
|
||||||
|
// Wert bestaetigen, Editier-Modus beenden
|
||||||
|
this._selectActive = false;
|
||||||
|
active.classList.remove("select-editing");
|
||||||
|
// onchange ausloesen falls sich Wert geaendert hat
|
||||||
|
active.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
// Editier-Modus starten
|
||||||
|
this._selectActive = true;
|
||||||
|
active.classList.add("select-editing");
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,9 +276,16 @@ class FocusManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Select-Feldern: Escape = Blur (zurueck zur Navigation)
|
// In Select-Feldern: Escape = Editier-Modus beenden oder Blur
|
||||||
if (active && active.tagName === "SELECT") {
|
if (active && active.tagName === "SELECT") {
|
||||||
|
if (this._selectActive) {
|
||||||
|
// Editier-Modus beenden (Wert nicht uebernehmen)
|
||||||
|
this._selectActive = false;
|
||||||
|
active.classList.remove("select-editing");
|
||||||
|
} else {
|
||||||
|
// Nicht im Editier-Modus -> Focus verlassen
|
||||||
active.blur();
|
active.blur();
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<button class="btn-secondary" onclick="openImportModal()">Importieren</button>
|
<button class="btn-secondary" onclick="openImportModal()">Importieren</button>
|
||||||
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
|
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
|
||||||
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
|
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
|
||||||
|
<button class="btn-secondary" onclick="generateThumbnails()">Thumbnails</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,27 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Kurz warten, dann Formular einblenden (Server hat ggf. schon Redirect gemacht)
|
// Pruefen ob Browser Felder vorausgefuellt hat -> automatisch absenden
|
||||||
setTimeout(function() {
|
var _autoAttempts = 0;
|
||||||
|
var _autoInterval = setInterval(function() {
|
||||||
|
_autoAttempts++;
|
||||||
|
var u = document.getElementById('username');
|
||||||
|
var p = document.getElementById('password');
|
||||||
|
if (u && p && u.value && p.value) {
|
||||||
|
clearInterval(_autoInterval);
|
||||||
|
document.querySelector('.login-form').submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_autoAttempts >= 5) {
|
||||||
|
clearInterval(_autoInterval);
|
||||||
|
// Kein Auto-Fill -> Formular anzeigen
|
||||||
document.getElementById('login-loader').style.display = 'none';
|
document.getElementById('login-loader').style.display = 'none';
|
||||||
var container = document.getElementById('login-container');
|
var container = document.getElementById('login-container');
|
||||||
container.style.display = '';
|
container.style.display = '';
|
||||||
container.style.animation = 'fadeIn 0.3s ease';
|
container.style.animation = 'fadeIn 0.3s ease';
|
||||||
var usernameInput = document.getElementById('username');
|
if (u) u.focus();
|
||||||
if (usernameInput) usernameInput.focus();
|
}
|
||||||
}, 300);
|
}, 200);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -48,18 +48,12 @@
|
||||||
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
|
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
|
||||||
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
||||||
{% if genres %}
|
{% if genres %}
|
||||||
<div class="tv-genre-chips">
|
<select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
|
||||||
<a href="/tv/movies?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
<option value="">{{ t('filter.all_genres') }}</option>
|
||||||
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
|
|
||||||
{{ t('filter.all') }}
|
|
||||||
</a>
|
|
||||||
{% for g in genres %}
|
{% for g in genres %}
|
||||||
<a href="/tv/movies?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
<option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
|
||||||
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
|
|
||||||
{{ g }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Rating-Filter -->
|
<!-- Rating-Filter -->
|
||||||
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
||||||
|
|
@ -82,7 +76,7 @@
|
||||||
<!-- === Grid-Ansicht === -->
|
<!-- === Grid-Ansicht === -->
|
||||||
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
||||||
{% for m in movies %}
|
{% for m in movies %}
|
||||||
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
|
||||||
{% if m.poster_url %}
|
{% if m.poster_url %}
|
||||||
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -102,7 +96,7 @@
|
||||||
<!-- === Liste (kompakt) === -->
|
<!-- === Liste (kompakt) === -->
|
||||||
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
||||||
{% for m in movies %}
|
{% for m in movies %}
|
||||||
<a href="/tv/movies/{{ m.id }}" class="tv-list-item" data-focusable>
|
<a href="/tv/movies/{{ m.id }}" class="tv-list-item" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
|
||||||
<div class="tv-list-poster">
|
<div class="tv-list-poster">
|
||||||
{% if m.poster_url %}
|
{% if m.poster_url %}
|
||||||
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
||||||
|
|
@ -119,7 +113,7 @@
|
||||||
<!-- === Detail-Liste === -->
|
<!-- === Detail-Liste === -->
|
||||||
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
||||||
{% for m in movies %}
|
{% for m in movies %}
|
||||||
<a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable>
|
<a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
|
||||||
<div class="tv-detail-thumb">
|
<div class="tv-detail-thumb">
|
||||||
{% if m.poster_url %}
|
{% if m.poster_url %}
|
||||||
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
||||||
|
|
@ -148,7 +142,7 @@
|
||||||
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
|
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-folder-list">
|
<div class="tv-folder-list">
|
||||||
{% for m in src.items %}
|
{% for m in src.entries %}
|
||||||
<a href="/tv/movies/{{ m.id }}" class="tv-folder-item" data-focusable>
|
<a href="/tv/movies/{{ m.id }}" class="tv-folder-item" data-focusable>
|
||||||
<span class="tv-folder-icon">📁</span>
|
<span class="tv-folder-icon">📁</span>
|
||||||
<span class="tv-folder-name">{{ m.folder_name }}</span>
|
<span class="tv-folder-name">{{ m.folder_name }}</span>
|
||||||
|
|
@ -163,6 +157,14 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alphabet-Seitenleiste -->
|
||||||
|
<nav class="tv-alpha-sidebar" id="alpha-sidebar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
||||||
|
{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}
|
||||||
|
<span class="tv-alpha-letter" data-letter="{{ letter }}" onclick="filterByLetter('{{ letter }}')" data-focusable>{{ letter }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
<span class="tv-alpha-letter" data-letter="#" onclick="filterByLetter('#')" data-focusable>#</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{% if not movies and view != 'folder' %}
|
{% if not movies and view != 'folder' %}
|
||||||
<div class="tv-empty">{{ t('movies.no_movies') }}</div>
|
<div class="tv-empty">{{ t('movies.no_movies') }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -180,9 +182,11 @@ function switchView(mode) {
|
||||||
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.dataset.view === mode);
|
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||||
});
|
});
|
||||||
// Filter-Leiste in Ordner-Ansicht verstecken
|
// Filter-Leiste und Alphabet in Ordner-Ansicht verstecken
|
||||||
const filterBar = document.getElementById('filter-bar');
|
const filterBar = document.getElementById('filter-bar');
|
||||||
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
|
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
|
||||||
|
var alphaSidebar = document.getElementById('alpha-sidebar');
|
||||||
|
if (alphaSidebar) alphaSidebar.style.display = mode === 'folder' ? 'none' : '';
|
||||||
fetch('/tv/settings', {
|
fetch('/tv/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
|
@ -197,6 +201,16 @@ function applySort(sort) {
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyGenre(genre) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
if (genre) {
|
||||||
|
url.searchParams.set('genre', genre);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('genre');
|
||||||
|
}
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function applyRating(rating) {
|
function applyRating(rating) {
|
||||||
const url = new URL(window.location);
|
const url = new URL(window.location);
|
||||||
if (rating) {
|
if (rating) {
|
||||||
|
|
@ -206,5 +220,35 @@ function applyRating(rating) {
|
||||||
}
|
}
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alphabet-Filter
|
||||||
|
var _currentLetter = null;
|
||||||
|
function filterByLetter(letter) {
|
||||||
|
_currentLetter = (_currentLetter === letter) ? null : letter;
|
||||||
|
['grid', 'list', 'detail'].forEach(function(v) {
|
||||||
|
var c = document.getElementById('view-' + v);
|
||||||
|
if (!c) return;
|
||||||
|
c.querySelectorAll('[data-letter]').forEach(function(item) {
|
||||||
|
if (!_currentLetter) { item.style.display = ''; return; }
|
||||||
|
var raw = item.dataset.letter;
|
||||||
|
var norm = /^[A-Z]$/.test(raw) ? raw : '#';
|
||||||
|
item.style.display = (norm === _currentLetter) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
|
||||||
|
el.classList.toggle('active', el.dataset.letter === _currentLetter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Buchstaben ohne Treffer abdunkeln
|
||||||
|
(function() {
|
||||||
|
var avail = {};
|
||||||
|
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
|
||||||
|
var raw = item.dataset.letter;
|
||||||
|
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
|
||||||
|
if (!avail[el.dataset.letter]) el.classList.add('dimmed');
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,15 @@
|
||||||
<button class="player-btn" id="btn-play" data-focusable>▶</button>
|
<button class="player-btn" id="btn-play" data-focusable>▶</button>
|
||||||
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||||
<span class="player-spacer"></span>
|
<span class="player-spacer"></span>
|
||||||
|
<button class="player-btn" id="btn-audio" data-focusable title="{{ t('player.audio') }}">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="player-btn" id="btn-subs" data-focusable title="{{ t('player.subtitles') }}">
|
||||||
|
<span class="player-btn-badge">CC</span>
|
||||||
|
</button>
|
||||||
|
<button class="player-btn" id="btn-quality" data-focusable title="{{ t('player.quality') }}">
|
||||||
|
<span class="player-btn-badge" id="quality-badge">HD</span>
|
||||||
|
</button>
|
||||||
<button class="player-btn" id="btn-settings" data-focusable title="{{ t('player.settings') }}">⚙</button>
|
<button class="player-btn" id="btn-settings" data-focusable title="{{ t('player.settings') }}">⚙</button>
|
||||||
{% if next_video %}
|
{% if next_video %}
|
||||||
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">⏭</button>
|
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">⏭</button>
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
<div class="profiles-grid">
|
<div class="profiles-grid">
|
||||||
{% for p in profiles %}
|
{% for p in profiles %}
|
||||||
<form method="post" action="/tv/switch-profile" class="profile-form">
|
<form method="post" action="/tv/switch-profile" class="profile-form">
|
||||||
<input type="hidden" name="session_id" value="{{ p.session_id }}">
|
<input type="hidden" name="user_id" value="{{ p.id }}">
|
||||||
<button type="submit" class="profile-card {% if p.session_id == current_session %}profile-active{% endif %}" data-focusable>
|
<button type="submit" class="profile-card {% if p.id == current_user_id %}profile-active{% endif %}" data-focusable>
|
||||||
<span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}">
|
<span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}">
|
||||||
{{ (p.display_name or p.username)[:1]|upper }}
|
{{ (p.display_name or p.username)[:1]|upper }}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -48,18 +48,12 @@
|
||||||
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
|
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
|
||||||
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
||||||
{% if genres %}
|
{% if genres %}
|
||||||
<div class="tv-genre-chips">
|
<select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
|
||||||
<a href="/tv/series?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
<option value="">{{ t('filter.all_genres') }}</option>
|
||||||
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
|
|
||||||
{{ t('filter.all') }}
|
|
||||||
</a>
|
|
||||||
{% for g in genres %}
|
{% for g in genres %}
|
||||||
<a href="/tv/series?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
<option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
|
||||||
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
|
|
||||||
{{ g }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Rating-Filter -->
|
<!-- Rating-Filter -->
|
||||||
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
||||||
|
|
@ -82,7 +76,7 @@
|
||||||
<!-- === Grid-Ansicht === -->
|
<!-- === Grid-Ansicht === -->
|
||||||
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
||||||
{% for s in series %}
|
{% for s in series %}
|
||||||
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
|
||||||
{% if s.poster_url %}
|
{% if s.poster_url %}
|
||||||
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -102,7 +96,7 @@
|
||||||
<!-- === Liste (kompakt) === -->
|
<!-- === Liste (kompakt) === -->
|
||||||
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
||||||
{% for s in series %}
|
{% for s in series %}
|
||||||
<a href="/tv/series/{{ s.id }}" class="tv-list-item" data-focusable>
|
<a href="/tv/series/{{ s.id }}" class="tv-list-item" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
|
||||||
<div class="tv-list-poster">
|
<div class="tv-list-poster">
|
||||||
{% if s.poster_url %}
|
{% if s.poster_url %}
|
||||||
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
||||||
|
|
@ -119,7 +113,7 @@
|
||||||
<!-- === Detail-Liste === -->
|
<!-- === Detail-Liste === -->
|
||||||
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
||||||
{% for s in series %}
|
{% for s in series %}
|
||||||
<a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable>
|
<a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
|
||||||
<div class="tv-detail-thumb">
|
<div class="tv-detail-thumb">
|
||||||
{% if s.poster_url %}
|
{% if s.poster_url %}
|
||||||
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
||||||
|
|
@ -149,7 +143,7 @@
|
||||||
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
|
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-folder-list">
|
<div class="tv-folder-list">
|
||||||
{% for s in src.items %}
|
{% for s in src.entries %}
|
||||||
<a href="/tv/series/{{ s.id }}" class="tv-folder-item" data-focusable>
|
<a href="/tv/series/{{ s.id }}" class="tv-folder-item" data-focusable>
|
||||||
<span class="tv-folder-icon">📁</span>
|
<span class="tv-folder-icon">📁</span>
|
||||||
<span class="tv-folder-name">{{ s.folder_name }}</span>
|
<span class="tv-folder-name">{{ s.folder_name }}</span>
|
||||||
|
|
@ -164,6 +158,14 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alphabet-Seitenleiste -->
|
||||||
|
<nav class="tv-alpha-sidebar" id="alpha-sidebar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
||||||
|
{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}
|
||||||
|
<span class="tv-alpha-letter" data-letter="{{ letter }}" onclick="filterByLetter('{{ letter }}')" data-focusable>{{ letter }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
<span class="tv-alpha-letter" data-letter="#" onclick="filterByLetter('#')" data-focusable>#</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{% if not series and view != 'folder' %}
|
{% if not series and view != 'folder' %}
|
||||||
<div class="tv-empty">{{ t('series.no_series') }}</div>
|
<div class="tv-empty">{{ t('series.no_series') }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -181,9 +183,11 @@ function switchView(mode) {
|
||||||
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.dataset.view === mode);
|
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||||
});
|
});
|
||||||
// Filter-Leiste in Ordner-Ansicht verstecken
|
// Filter-Leiste und Alphabet in Ordner-Ansicht verstecken
|
||||||
const filterBar = document.getElementById('filter-bar');
|
const filterBar = document.getElementById('filter-bar');
|
||||||
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
|
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
|
||||||
|
var alphaSidebar = document.getElementById('alpha-sidebar');
|
||||||
|
if (alphaSidebar) alphaSidebar.style.display = mode === 'folder' ? 'none' : '';
|
||||||
fetch('/tv/settings', {
|
fetch('/tv/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
|
@ -198,6 +202,16 @@ function applySort(sort) {
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyGenre(genre) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
if (genre) {
|
||||||
|
url.searchParams.set('genre', genre);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('genre');
|
||||||
|
}
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function applyRating(rating) {
|
function applyRating(rating) {
|
||||||
const url = new URL(window.location);
|
const url = new URL(window.location);
|
||||||
if (rating) {
|
if (rating) {
|
||||||
|
|
@ -207,5 +221,35 @@ function applyRating(rating) {
|
||||||
}
|
}
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alphabet-Filter
|
||||||
|
var _currentLetter = null;
|
||||||
|
function filterByLetter(letter) {
|
||||||
|
_currentLetter = (_currentLetter === letter) ? null : letter;
|
||||||
|
['grid', 'list', 'detail'].forEach(function(v) {
|
||||||
|
var c = document.getElementById('view-' + v);
|
||||||
|
if (!c) return;
|
||||||
|
c.querySelectorAll('[data-letter]').forEach(function(item) {
|
||||||
|
if (!_currentLetter) { item.style.display = ''; return; }
|
||||||
|
var raw = item.dataset.letter;
|
||||||
|
var norm = /^[A-Z]$/.test(raw) ? raw : '#';
|
||||||
|
item.style.display = (norm === _currentLetter) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
|
||||||
|
el.classList.toggle('active', el.dataset.letter === _currentLetter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Buchstaben ohne Treffer abdunkeln
|
||||||
|
(function() {
|
||||||
|
var avail = {};
|
||||||
|
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
|
||||||
|
var raw = item.dataset.letter;
|
||||||
|
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
|
||||||
|
if (!avail[el.dataset.letter]) el.classList.add('dimmed');
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,17 @@
|
||||||
<!-- Episoden pro Staffel -->
|
<!-- Episoden pro Staffel -->
|
||||||
{% for sn, episodes in seasons.items() %}
|
{% for sn, episodes in seasons.items() %}
|
||||||
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
||||||
|
<div class="tv-season-actions">
|
||||||
|
<button class="tv-season-mark-btn" data-focusable
|
||||||
|
onclick="markSeasonWatched({{ series.id }}, {{ sn }})">
|
||||||
|
✓ {{ t('status.mark_season') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="tv-episode-list">
|
<div class="tv-episode-list">
|
||||||
{% for ep in episodes %}
|
{% for ep in episodes %}
|
||||||
<a href="/tv/player?v={{ ep.id }}" class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %}" data-focusable>
|
<div class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= 95 %}tv-ep-seen{% endif %}"
|
||||||
|
data-video-id="{{ ep.id }}">
|
||||||
|
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-link" data-focusable>
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div class="tv-ep-thumb">
|
<div class="tv-ep-thumb">
|
||||||
{% if ep.ep_image_url %}
|
{% if ep.ep_image_url %}
|
||||||
|
|
@ -126,6 +134,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Gesehen-Button -->
|
||||||
|
<button class="tv-ep-mark-btn {% if ep.progress_pct >= 95 %}active{% endif %}"
|
||||||
|
data-focusable
|
||||||
|
title="{% if ep.progress_pct >= 95 %}{{ t('status.mark_unwatched') }}{% else %}{{ t('status.mark_watched') }}{% endif %}"
|
||||||
|
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -206,5 +222,82 @@ function setRating(value) {
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleWatched(videoId, btn) {
|
||||||
|
// Aktuellen Status pruefen und togglen
|
||||||
|
const card = btn.closest('.tv-episode-card');
|
||||||
|
const isSeen = card.classList.contains('tv-ep-seen');
|
||||||
|
const newPct = isSeen ? 0 : 100;
|
||||||
|
|
||||||
|
fetch('/tv/api/watch-progress', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ video_id: videoId, position: newPct, duration: 100 }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
if (isSeen) {
|
||||||
|
// Als ungesehen markieren
|
||||||
|
card.classList.remove('tv-ep-seen');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
const watchedEl = card.querySelector('.tv-ep-watched');
|
||||||
|
if (watchedEl) watchedEl.remove();
|
||||||
|
const progressEl = card.querySelector('.tv-ep-progress');
|
||||||
|
if (progressEl) progressEl.remove();
|
||||||
|
} else {
|
||||||
|
// Als gesehen markieren
|
||||||
|
card.classList.add('tv-ep-seen');
|
||||||
|
btn.classList.add('active');
|
||||||
|
// Haekchen-Symbol hinzufuegen
|
||||||
|
const thumb = card.querySelector('.tv-ep-thumb');
|
||||||
|
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
|
||||||
|
const check = document.createElement('div');
|
||||||
|
check.className = 'tv-ep-watched';
|
||||||
|
check.innerHTML = '✓';
|
||||||
|
thumb.appendChild(check);
|
||||||
|
}
|
||||||
|
// Fortschrittsbalken entfernen
|
||||||
|
const progressEl = card.querySelector('.tv-ep-progress');
|
||||||
|
if (progressEl) progressEl.remove();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSeasonWatched(seriesId, seasonNum) {
|
||||||
|
// Alle Episoden der Staffel als gesehen markieren
|
||||||
|
const season = document.getElementById('season-' + seasonNum);
|
||||||
|
if (!season) return;
|
||||||
|
const cards = season.querySelectorAll('.tv-episode-card:not(.tv-ep-seen)');
|
||||||
|
const ids = [];
|
||||||
|
cards.forEach(card => {
|
||||||
|
const vid = card.dataset.videoId;
|
||||||
|
if (vid) ids.push(parseInt(vid));
|
||||||
|
});
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
// Alle auf einmal senden
|
||||||
|
Promise.all(ids.map(id =>
|
||||||
|
fetch('/tv/api/watch-progress', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ video_id: id, position: 100, duration: 100 }),
|
||||||
|
})
|
||||||
|
)).then(() => {
|
||||||
|
// UI aktualisieren
|
||||||
|
season.querySelectorAll('.tv-episode-card').forEach(card => {
|
||||||
|
card.classList.add('tv-ep-seen');
|
||||||
|
const btn = card.querySelector('.tv-ep-mark-btn');
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
const thumb = card.querySelector('.tv-ep-thumb');
|
||||||
|
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
|
||||||
|
const check = document.createElement('div');
|
||||||
|
check.className = 'tv-ep-watched';
|
||||||
|
check.innerHTML = '✓';
|
||||||
|
thumb.appendChild(check);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue