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:
Eduard Wisch 2026-03-01 09:22:04 +01:00
parent 61ca20bf8b
commit c7151e8bd1
21 changed files with 873 additions and 122 deletions

View file

@ -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

Binary file not shown.

View file

@ -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

View file

@ -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 ---

View file

@ -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"""
response = await handler(request)
"""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)

View file

@ -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,

View file

@ -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:

View file

@ -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; }

View file

@ -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);
}

View file

@ -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; }
}

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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 = {

View file

@ -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,9 +92,13 @@ 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 (direction === "ArrowUp" || direction === "ArrowDown") return;
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();
@ -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") {
active.blur();
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;
}

View file

@ -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>

View file

@ -49,15 +49,27 @@
</div>
<script>
// Kurz warten, dann Formular einblenden (Server hat ggf. schon Redirect gemacht)
setTimeout(function() {
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);
// 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';
if (u) u.focus();
}
}, 200);
</script>
</body>
</html>

View file

@ -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">&#128193;</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 %}

View file

@ -27,6 +27,15 @@
<button class="player-btn" id="btn-play" data-focusable>&#9654;</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') }}">&#9881;</button>
{% if next_video %}
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">&#9197;</button>

View file

@ -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>

View file

@ -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">&#128193;</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 %}

View file

@ -82,50 +82,66 @@
<!-- 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 }})">
&#10003; {{ 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>
<!-- Thumbnail -->
<div class="tv-ep-thumb">
{% if ep.ep_image_url %}
<img src="{{ ep.ep_image_url }}" alt="" loading="lazy">
{% else %}
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
{% endif %}
{% if ep.progress_pct > 0 and ep.progress_pct < 95 %}
<div class="tv-ep-progress">
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
<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 %}
<img src="{{ ep.ep_image_url }}" alt="" loading="lazy">
{% else %}
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
{% endif %}
{% if ep.progress_pct > 0 and ep.progress_pct < 95 %}
<div class="tv-ep-progress">
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
</div>
{% endif %}
{% if ep.progress_pct >= 95 %}
<div class="tv-ep-watched">&#10003;</div>
{% endif %}
<div class="tv-ep-duration">
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
</div>
</div>
{% endif %}
{% if ep.progress_pct >= 95 %}
<div class="tv-ep-watched">&#10003;</div>
{% endif %}
<div class="tv-ep-duration">
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
<!-- Info -->
<div class="tv-ep-info">
<div class="tv-ep-header">
<span class="tv-ep-num">
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}
</span>
<span class="tv-ep-title">
{{ ep.episode_title or ep.file_name }}
</span>
</div>
{% if ep.ep_overview %}
<p class="tv-ep-desc">{{ ep.ep_overview }}</p>
{% endif %}
<div class="tv-ep-meta">
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span> {% endif %}
{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %}
&middot; {{ ep.container|upper }}
{% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %}
{% if ep.file_size %} &middot; {{ (ep.file_size / 1048576)|round|int }} MB{% endif %}
</div>
</div>
</div>
<!-- Info -->
<div class="tv-ep-info">
<div class="tv-ep-header">
<span class="tv-ep-num">
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}
</span>
<span class="tv-ep-title">
{{ ep.episode_title or ep.file_name }}
</span>
</div>
{% if ep.ep_overview %}
<p class="tv-ep-desc">{{ ep.ep_overview }}</p>
{% endif %}
<div class="tv-ep-meta">
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span> {% endif %}
{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %}
&middot; {{ ep.container|upper }}
{% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %}
{% if ep.file_size %} &middot; {{ (ep.file_size / 1048576)|round|int }} MB{% endif %}
</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)">
&#10003;
</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 = '&#10003;';
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 = '&#10003;';
thumb.appendChild(check);
}
});
}).catch(() => {});
}
</script>
{% endblock %}