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.
|
||||
|
||||
## [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
|
||||
|
||||
### 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"""
|
||||
import asyncio
|
||||
import logging
|
||||
import aiomysql
|
||||
from aiohttp import web
|
||||
from app.config import Config
|
||||
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}")
|
||||
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 ===
|
||||
|
||||
async def post_reassign_import_item(
|
||||
|
|
@ -2202,6 +2344,13 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
app.router.add_get(
|
||||
"/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)
|
||||
app.router.add_post(
|
||||
"/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, "")
|
||||
if items:
|
||||
folder_data.append({"name": src_name, "items": items})
|
||||
folder_data.append({"name": src_name, "entries": items})
|
||||
else:
|
||||
for src in sources:
|
||||
items = sorted(
|
||||
|
|
@ -326,7 +326,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
)
|
||||
if items:
|
||||
folder_data.append({
|
||||
"name": src["name"], "items": items})
|
||||
"name": src["name"], "entries": items})
|
||||
# Serien ohne Quelle (Fallback)
|
||||
src_ids = {src["id"] for src in sources}
|
||||
orphans = sorted(
|
||||
|
|
@ -335,7 +335,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
key=lambda x: (x.get("folder_name") or "").lower()
|
||||
)
|
||||
if orphans:
|
||||
folder_data.append({"name": "Sonstige", "items": orphans})
|
||||
folder_data.append({"name": "Sonstige", "entries": orphans})
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/series.html", request, {
|
||||
|
|
@ -554,7 +554,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
)
|
||||
src_name = src_map.get(source_filter, "")
|
||||
if items:
|
||||
folder_data.append({"name": src_name, "items": items})
|
||||
folder_data.append({"name": src_name, "entries": items})
|
||||
else:
|
||||
for src in sources:
|
||||
items = sorted(
|
||||
|
|
@ -564,7 +564,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
)
|
||||
if items:
|
||||
folder_data.append({
|
||||
"name": src["name"], "items": items})
|
||||
"name": src["name"], "entries": items})
|
||||
# Filme ohne Quelle (Fallback)
|
||||
src_ids = {src["id"] for src in sources}
|
||||
orphans = sorted(
|
||||
|
|
@ -573,7 +573,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
key=lambda x: (x.get("folder_name") or "").lower()
|
||||
)
|
||||
if orphans:
|
||||
folder_data.append({"name": "Sonstige", "items": orphans})
|
||||
folder_data.append({"name": "Sonstige", "entries": orphans})
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/movies.html", request, {
|
||||
|
|
@ -929,36 +929,56 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
# --- Profilauswahl (Multi-User Quick-Switch) ---
|
||||
|
||||
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")
|
||||
profiles = []
|
||||
if client_id:
|
||||
profiles = await auth_service.get_client_profiles(client_id)
|
||||
# Aktuelle Session herausfinden
|
||||
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(
|
||||
"tv/profiles.html", request, {
|
||||
"profiles": profiles,
|
||||
"current_session": current_session,
|
||||
"profiles": all_users,
|
||||
"current_user_id": current_user_id,
|
||||
}
|
||||
)
|
||||
|
||||
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()
|
||||
session_id = data.get("session_id", "")
|
||||
if not session_id:
|
||||
user_id = data.get("user_id", "")
|
||||
if not user_id:
|
||||
raise web.HTTPFound("/tv/profiles")
|
||||
# Session validieren
|
||||
user = await auth_service.validate_session(session_id)
|
||||
if not user:
|
||||
|
||||
# Client-ID ermitteln/erstellen
|
||||
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")
|
||||
|
||||
resp = web.HTTPFound("/tv/")
|
||||
resp.set_cookie(
|
||||
"vk_session", session_id,
|
||||
max_age=10 * 365 * 24 * 3600,
|
||||
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
|
||||
|
||||
# --- User-Einstellungen ---
|
||||
|
|
|
|||
|
|
@ -53,8 +53,18 @@ class VideoKonverterServer:
|
|||
@web.middleware
|
||||
async def _no_cache_middleware(self, request: web.Request,
|
||||
handler) -> web.Response:
|
||||
"""Verhindert Browser-Caching fuer API-Responses"""
|
||||
"""Verhindert Browser-Caching fuer API-Responses + Error-Logging"""
|
||||
try:
|
||||
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/"):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
|
@ -96,8 +106,14 @@ 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
|
||||
# TV-App Routes (Auth-Service, DB-Pool wird in on_startup gesetzt)
|
||||
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
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
|
|
@ -151,16 +167,9 @@ class VideoKonverterServer:
|
|||
await self.tvdb_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:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -596,6 +596,20 @@ class AuthService:
|
|||
""", (client_id,))
|
||||
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 ---
|
||||
|
||||
async def update_user_settings(self, user_id: int,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,8 @@ class LibraryService:
|
|||
episode_name VARCHAR(512),
|
||||
aired DATE NULL,
|
||||
runtime INT NULL,
|
||||
overview TEXT NULL,
|
||||
image_url VARCHAR(1024) NULL,
|
||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_series (series_tvdb_id),
|
||||
UNIQUE INDEX idx_episode (
|
||||
|
|
@ -227,6 +229,18 @@ class LibraryService:
|
|||
)
|
||||
) 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)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1081,6 +1081,8 @@ legend {
|
|||
|
||||
.row-missing { opacity: 0.6; }
|
||||
.row-missing td { color: #888; }
|
||||
.row-redundant { background: rgba(255, 152, 0, 0.08); }
|
||||
.row-redundant td { color: #b0a080; }
|
||||
.text-warn { color: #ffb74d; }
|
||||
.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"});
|
||||
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) {
|
||||
if (ep._type === "missing") {
|
||||
html += `<tr class="row-missing">
|
||||
|
|
@ -647,6 +674,7 @@ function renderEpisodesTab(series) {
|
|||
<td><span class="status-badge error">FEHLT</span></td>
|
||||
</tr>`;
|
||||
} else {
|
||||
const isRedundant = redundantIds.has(ep.id);
|
||||
const audioInfo = (ep.audio_tracks || []).map(a => {
|
||||
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
||||
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 epTitle = ep.episode_title || ep.file_name || "Episode";
|
||||
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 title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td>
|
||||
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}${redundantBadge}</td>
|
||||
<td>${res}</td>
|
||||
<td><span class="tag codec">${ep.video_codec || "-"}</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"));
|
||||
}
|
||||
|
||||
// === 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;
|
||||
color: var(--text);
|
||||
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 */
|
||||
.tv-ep-thumb {
|
||||
|
|
@ -919,6 +981,22 @@ a { color: var(--accent); text-decoration: none; }
|
|||
border-radius: var(--radius);
|
||||
}
|
||||
.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-spacer { flex: 1; }
|
||||
|
||||
|
|
@ -1217,6 +1295,18 @@ a { color: var(--accent); text-decoration: none; }
|
|||
border-color: var(--accent);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1475,3 +1565,46 @@ a { color: var(--accent); text-decoration: 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_last_watched": "Zuletzt angesehen",
|
||||
"sort_rating": "Bewertung",
|
||||
"all_genres": "Alle Genres",
|
||||
"genres": "Genres",
|
||||
"min_rating": "Min. Sterne"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"sort_episodes": "Episode Count",
|
||||
"sort_last_watched": "Last Watched",
|
||||
"sort_rating": "Rating",
|
||||
"all_genres": "All Genres",
|
||||
"genres": "Genres",
|
||||
"min_rating": "Min. Stars"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ function initPlayer(opts) {
|
|||
loadVideoInfo().then(() => {
|
||||
// Stream starten
|
||||
setStreamUrl(opts.startPos || 0);
|
||||
updatePlayerButtons();
|
||||
});
|
||||
|
||||
// Events
|
||||
|
|
@ -63,7 +64,15 @@ function initPlayer(opts) {
|
|||
|
||||
// Einstellungen-Button
|
||||
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
|
||||
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() {
|
||||
// Audio-Spuren
|
||||
const audioEl = document.getElementById("overlay-audio");
|
||||
|
|
@ -343,12 +371,14 @@ function switchAudio(idx) {
|
|||
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
||||
setStreamUrl(currentTime);
|
||||
renderOverlay();
|
||||
updatePlayerButtons();
|
||||
}
|
||||
|
||||
function switchSub(idx) {
|
||||
currentSub = idx;
|
||||
updateSubtitleTrack();
|
||||
renderOverlay();
|
||||
updatePlayerButtons();
|
||||
}
|
||||
|
||||
function updateSubtitleTrack() {
|
||||
|
|
@ -364,6 +394,7 @@ function switchQuality(q) {
|
|||
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
||||
setStreamUrl(currentTime);
|
||||
renderOverlay();
|
||||
updatePlayerButtons();
|
||||
}
|
||||
|
||||
function switchSpeed(s) {
|
||||
|
|
@ -470,6 +501,26 @@ function saveProgress(completed) {
|
|||
|
||||
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 ===
|
||||
|
||||
const LANG_NAMES = {
|
||||
|
|
|
|||
|
|
@ -12,12 +12,20 @@ class FocusManager {
|
|||
this._currentFocus = null;
|
||||
// Merkt sich das letzte fokussierte Element im Content-Bereich
|
||||
this._lastContentFocus = null;
|
||||
// SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte
|
||||
this._selectActive = false;
|
||||
|
||||
// Tastatur-Events abfangen
|
||||
document.addEventListener("keydown", (e) => this._onKeyDown(e));
|
||||
|
||||
// Focus-Tracking: merken wo wir zuletzt waren
|
||||
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.closest("#tv-nav")) {
|
||||
this._lastContentFocus = e.target;
|
||||
|
|
@ -84,10 +92,14 @@ class FocusManager {
|
|||
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 (this._selectActive) {
|
||||
// Editier-Modus: Hoch/Runter aendert den Wert
|
||||
if (direction === "ArrowUp" || direction === "ArrowDown") return;
|
||||
}
|
||||
// Sonst: normal weiternavigieren (Select wird uebersprungen)
|
||||
}
|
||||
|
||||
const focusables = this._getFocusableElements();
|
||||
if (!focusables.length) return;
|
||||
|
|
@ -220,8 +232,20 @@ class FocusManager {
|
|||
return;
|
||||
}
|
||||
|
||||
// Select: Enter oeffnet/schliesst das Dropdown nativ
|
||||
// Select: Enter aktiviert/deaktiviert den Editier-Modus
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -252,9 +276,16 @@ class FocusManager {
|
|||
return;
|
||||
}
|
||||
|
||||
// In Select-Feldern: Escape = Blur (zurueck zur Navigation)
|
||||
// In Select-Feldern: Escape = Editier-Modus beenden oder Blur
|
||||
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();
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
<button class="btn-secondary" onclick="openImportModal()">Importieren</button>
|
||||
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
|
||||
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
|
||||
<button class="btn-secondary" onclick="generateThumbnails()">Thumbnails</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -49,15 +49,27 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// Kurz warten, dann Formular einblenden (Server hat ggf. schon Redirect gemacht)
|
||||
setTimeout(function() {
|
||||
// Pruefen ob Browser Felder vorausgefuellt hat -> automatisch absenden
|
||||
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';
|
||||
var container = document.getElementById('login-container');
|
||||
container.style.display = '';
|
||||
container.style.animation = 'fadeIn 0.3s ease';
|
||||
var usernameInput = document.getElementById('username');
|
||||
if (usernameInput) usernameInput.focus();
|
||||
}, 300);
|
||||
if (u) u.focus();
|
||||
}
|
||||
}, 200);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -48,18 +48,12 @@
|
|||
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
|
||||
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
||||
{% if genres %}
|
||||
<div class="tv-genre-chips">
|
||||
<a href="/tv/movies?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
|
||||
{{ t('filter.all') }}
|
||||
</a>
|
||||
<select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
|
||||
<option value="">{{ t('filter.all_genres') }}</option>
|
||||
{% for g in genres %}
|
||||
<a href="/tv/movies?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
|
||||
{{ g }}
|
||||
</a>
|
||||
<option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</select>
|
||||
{% endif %}
|
||||
<!-- Rating-Filter -->
|
||||
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
||||
|
|
@ -82,7 +76,7 @@
|
|||
<!-- === Grid-Ansicht === -->
|
||||
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
||||
{% 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 %}
|
||||
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
||||
{% else %}
|
||||
|
|
@ -102,7 +96,7 @@
|
|||
<!-- === Liste (kompakt) === -->
|
||||
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
||||
{% 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">
|
||||
{% if m.poster_url %}
|
||||
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
||||
|
|
@ -119,7 +113,7 @@
|
|||
<!-- === Detail-Liste === -->
|
||||
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
||||
{% 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">
|
||||
{% if m.poster_url %}
|
||||
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
||||
|
|
@ -148,7 +142,7 @@
|
|||
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
|
||||
{% endif %}
|
||||
<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>
|
||||
<span class="tv-folder-icon">📁</span>
|
||||
<span class="tv-folder-name">{{ m.folder_name }}</span>
|
||||
|
|
@ -163,6 +157,14 @@
|
|||
{% endfor %}
|
||||
</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' %}
|
||||
<div class="tv-empty">{{ t('movies.no_movies') }}</div>
|
||||
{% endif %}
|
||||
|
|
@ -180,9 +182,11 @@ function switchView(mode) {
|
|||
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
||||
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');
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
|
||||
|
|
@ -197,6 +201,16 @@ function applySort(sort) {
|
|||
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) {
|
||||
const url = new URL(window.location);
|
||||
if (rating) {
|
||||
|
|
@ -206,5 +220,35 @@ function applyRating(rating) {
|
|||
}
|
||||
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>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@
|
|||
<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-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>
|
||||
{% if next_video %}
|
||||
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">⏭</button>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
<div class="profiles-grid">
|
||||
{% for p in profiles %}
|
||||
<form method="post" action="/tv/switch-profile" class="profile-form">
|
||||
<input type="hidden" name="session_id" value="{{ p.session_id }}">
|
||||
<button type="submit" class="profile-card {% if p.session_id == current_session %}profile-active{% endif %}" data-focusable>
|
||||
<input type="hidden" name="user_id" value="{{ p.id }}">
|
||||
<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' }}">
|
||||
{{ (p.display_name or p.username)[:1]|upper }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -48,18 +48,12 @@
|
|||
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
|
||||
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
|
||||
{% if genres %}
|
||||
<div class="tv-genre-chips">
|
||||
<a href="/tv/series?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
|
||||
{{ t('filter.all') }}
|
||||
</a>
|
||||
<select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
|
||||
<option value="">{{ t('filter.all_genres') }}</option>
|
||||
{% for g in genres %}
|
||||
<a href="/tv/series?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
|
||||
{{ g }}
|
||||
</a>
|
||||
<option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</select>
|
||||
{% endif %}
|
||||
<!-- Rating-Filter -->
|
||||
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
||||
|
|
@ -82,7 +76,7 @@
|
|||
<!-- === Grid-Ansicht === -->
|
||||
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
||||
{% 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 %}
|
||||
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
||||
{% else %}
|
||||
|
|
@ -102,7 +96,7 @@
|
|||
<!-- === Liste (kompakt) === -->
|
||||
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
||||
{% 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">
|
||||
{% if s.poster_url %}
|
||||
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
||||
|
|
@ -119,7 +113,7 @@
|
|||
<!-- === Detail-Liste === -->
|
||||
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
||||
{% 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">
|
||||
{% if s.poster_url %}
|
||||
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
||||
|
|
@ -149,7 +143,7 @@
|
|||
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
|
||||
{% endif %}
|
||||
<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>
|
||||
<span class="tv-folder-icon">📁</span>
|
||||
<span class="tv-folder-name">{{ s.folder_name }}</span>
|
||||
|
|
@ -164,6 +158,14 @@
|
|||
{% endfor %}
|
||||
</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' %}
|
||||
<div class="tv-empty">{{ t('series.no_series') }}</div>
|
||||
{% endif %}
|
||||
|
|
@ -181,9 +183,11 @@ function switchView(mode) {
|
|||
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
||||
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');
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
|
||||
|
|
@ -198,6 +202,16 @@ function applySort(sort) {
|
|||
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) {
|
||||
const url = new URL(window.location);
|
||||
if (rating) {
|
||||
|
|
@ -207,5 +221,35 @@ function applyRating(rating) {
|
|||
}
|
||||
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>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -82,9 +82,17 @@
|
|||
<!-- Episoden pro Staffel -->
|
||||
{% 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-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">
|
||||
{% 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 -->
|
||||
<div class="tv-ep-thumb">
|
||||
{% if ep.ep_image_url %}
|
||||
|
|
@ -126,6 +134,14 @@
|
|||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -206,5 +222,82 @@ function setRating(value) {
|
|||
})
|
||||
.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>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue