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. Alle relevanten Aenderungen am VideoKonverter-Projekt.
## [4.0.1] - 2026-03-01
### TV-App: UX-Verbesserungen & Bugfixes
#### Neue Features
- **Alphabet-Seitenleiste**: Vertikale A-Z Sidebar auf Serien-/Filme-Seite zum Filtern nach Anfangsbuchstabe
- Buchstaben ohne Treffer automatisch abgedunkelt
- Wird in Ordner-Ansicht versteckt
- Responsive fuer Handy/Tablet
- **Genre-Select statt Chips**: Genre-Filter als Dropdown-Element (uebersichtlicher bei vielen Genres)
- **Player-Buttons**: Separate Symbole fuer Audio (Lautsprecher-SVG), Untertitel (CC-Badge), Qualitaet (HD-Badge)
- CC-Button leuchtet wenn Untertitel aktiv, Quality-Badge zeigt aktuellen Modus (4K/HD/SD/LD)
- Klick oeffnet Overlay direkt bei der entsprechenden Sektion
- **Gesehen-Markierung**: Buttons fuer "Episode gesehen" und "Staffel gesehen" in Serien-Detail
- **Batch-Thumbnails**: Neuer Button "Thumbnails" in der Bibliothek generiert alle fehlenden Episoden-Thumbnails im Hintergrund per ffmpeg
- **Redundanz-Markierung**: Duplikate in der Episoden-Tabelle werden jetzt orange markiert mit "REDUNDANT"-Badge
- Ranking: Neuerer Codec > kleinere Datei
- **Rating-Sortierung**: Serien/Filme nach Bewertung sortierbar + Min-Rating-Filter
#### Bugfixes
- **tvdb_episode_cache**: Fehlende Spalten `overview` und `image_url` hinzugefuegt (Episoden-Beschreibungen funktionierten nicht)
- **Login-Form Flash**: Auto-Fill-Erkennung statt hartem Timeout (prueft 5x alle 200ms ob Browser Felder ausgefuellt hat)
- **Profil-Wechsel**: Zeigt jetzt alle User an (nicht nur die mit aktiver Session)
- **Debug-Prints entfernt**: Bereinigung aus server.py und tv_api.py
- **Route-Registrierung**: TV-API-Routen in `_setup_app()` verschoben (verhinderte 500-Fehler)
#### Neue API-Endpunkte
- `POST /api/library/generate-thumbnails` - Batch-Thumbnail-Generierung starten
- `GET /api/library/thumbnail-status` - Thumbnail-Fortschritt abfragen
#### Geaenderte Dateien (19 Dateien, +821/-122 Zeilen)
- `app/routes/library_api.py` - Batch-Thumbnails + aiomysql Import
- `app/routes/tv_api.py` - Gesehen-Status, Rating-Filter, Genre-Select
- `app/server.py` - Route-Registrierung Fix
- `app/services/auth.py` - Watch-Status DB-Methoden
- `app/services/library.py` - tvdb_episode_cache Spalten-Fix + Migration
- `app/static/css/style.css` - Redundanz-Zeilen-Style
- `app/static/js/library.js` - Redundanz-Erkennung, Batch-Thumbnails
- `app/static/tv/css/tv.css` - Player-Badges, Alphabet-Sidebar, Rating-Styles
- `app/static/tv/i18n/de.json` + `en.json` - Rating-Uebersetzungen
- `app/static/tv/js/player.js` - Overlay-Sections, Button-Updates
- `app/static/tv/js/tv.js` - Gesehen-Buttons, Alphabet-Filter
- `app/templates/library.html` - Thumbnails-Button
- `app/templates/tv/login.html` - Auto-Fill-Erkennung
- `app/templates/tv/movies.html` - Alphabet-Sidebar, data-letter
- `app/templates/tv/player.html` - Audio/CC/Quality-Buttons
- `app/templates/tv/profiles.html` - Alle User anzeigen
- `app/templates/tv/series.html` - Alphabet-Sidebar, data-letter
- `app/templates/tv/series_detail.html` - Gesehen-Buttons, Episoden-Beschreibungen
---
## [4.0.0] - 2026-03-01 ## [4.0.0] - 2026-03-01
### TV-App: Vollwertiger Streaming-Client ### TV-App: Vollwertiger Streaming-Client

Binary file not shown.

View file

@ -1,6 +1,7 @@
"""REST API Endpoints fuer die Video-Bibliothek""" """REST API Endpoints fuer die Video-Bibliothek"""
import asyncio import asyncio
import logging import logging
import aiomysql
from aiohttp import web from aiohttp import web
from app.config import Config from app.config import Config
from app.services.library import LibraryService from app.services.library import LibraryService
@ -1696,6 +1697,147 @@ def setup_library_routes(app: web.Application, config: Config,
logging.error(f"Thumbnail-Fehler: {e}") logging.error(f"Thumbnail-Fehler: {e}")
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
# === Batch-Thumbnail-Generierung ===
_thumbnail_task = None # Hintergrund-Task fuer Batch-Generierung
async def post_generate_thumbnails(request: web.Request) -> web.Response:
"""POST /api/library/generate-thumbnails
Generiert fehlende Thumbnails fuer alle Videos im Hintergrund.
Optional: ?series_id=123 fuer nur eine Serie."""
import os
import asyncio as _asyncio
nonlocal _thumbnail_task
# Laeuft bereits?
if _thumbnail_task and not _thumbnail_task.done():
return web.json_response({
"status": "running",
"message": "Thumbnail-Generierung laeuft bereits"
})
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
series_id = request.query.get("series_id")
async def _generate_batch():
"""Hintergrund-Task: Fehlende Thumbnails erzeugen."""
generated = 0
errors = 0
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# Videos ohne Thumbnail finden
sql = """
SELECT v.id, v.file_path, v.duration_sec
FROM library_videos v
LEFT JOIN tv_episode_thumbnails t
ON v.id = t.video_id
WHERE t.video_id IS NULL
"""
params = []
if series_id:
sql += " AND v.series_id = %s"
params.append(int(series_id))
sql += " ORDER BY v.id"
await cur.execute(sql, params)
videos = await cur.fetchall()
logging.info(
f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail"
)
for video in videos:
vid = video["id"]
fp = video["file_path"]
dur = video.get("duration_sec") or 0
if not os.path.isfile(fp):
continue
seek = dur * 0.25 if dur > 10 else 5
vdir = os.path.dirname(fp)
tdir = os.path.join(vdir, ".metadata", "thumbnails")
os.makedirs(tdir, exist_ok=True)
tpath = os.path.join(tdir, f"{vid}.jpg")
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", str(int(seek)),
"-i", fp,
"-vframes", "1", "-q:v", "5",
"-vf", "scale=480:-1",
"-y", tpath,
]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
await proc.communicate()
if proc.returncode == 0 and os.path.isfile(tpath):
async with pool.acquire() as conn2:
async with conn2.cursor() as cur2:
await cur2.execute("""
INSERT INTO tv_episode_thumbnails
(video_id, thumbnail_path, source)
VALUES (%s, %s, 'ffmpeg')
ON DUPLICATE KEY UPDATE
thumbnail_path = VALUES(thumbnail_path)
""", (vid, tpath))
generated += 1
else:
errors += 1
except Exception as e:
logging.warning(f"Thumbnail-Fehler Video {vid}: {e}")
errors += 1
logging.info(
f"Thumbnail-Batch fertig: {generated} erzeugt, "
f"{errors} Fehler"
)
except Exception as e:
logging.error(f"Thumbnail-Batch Fehler: {e}")
import asyncio
_thumbnail_task = asyncio.ensure_future(_generate_batch())
return web.json_response({
"status": "started",
"message": "Thumbnail-Generierung gestartet"
})
async def get_thumbnail_status(request: web.Request) -> web.Response:
"""GET /api/library/thumbnail-status
Zeigt Fortschritt der Thumbnail-Generierung."""
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
running = bool(_thumbnail_task and not _thumbnail_task.done())
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT COUNT(*) AS cnt FROM tv_episode_thumbnails")
done = (await cur.fetchone())["cnt"]
await cur.execute(
"SELECT COUNT(*) AS cnt FROM library_videos")
total = (await cur.fetchone())["cnt"]
return web.json_response({
"running": running,
"generated": done,
"total": total,
"missing": total - done,
})
# === Import: Item zuordnen / ueberspringen === # === Import: Item zuordnen / ueberspringen ===
async def post_reassign_import_item( async def post_reassign_import_item(
@ -2202,6 +2344,13 @@ def setup_library_routes(app: web.Application, config: Config,
app.router.add_get( app.router.add_get(
"/api/library/videos/{video_id}/thumbnail", get_video_thumbnail "/api/library/videos/{video_id}/thumbnail", get_video_thumbnail
) )
# Batch-Thumbnails
app.router.add_post(
"/api/library/generate-thumbnails", post_generate_thumbnails
)
app.router.add_get(
"/api/library/thumbnail-status", get_thumbnail_status
)
# TVDB Auto-Match (Review-Modus) # TVDB Auto-Match (Review-Modus)
app.router.add_post( app.router.add_post(
"/api/library/tvdb-auto-match", post_tvdb_auto_match "/api/library/tvdb-auto-match", post_tvdb_auto_match

View file

@ -316,7 +316,7 @@ def setup_tv_routes(app: web.Application, config: Config,
) )
src_name = src_map.get(source_filter, "") src_name = src_map.get(source_filter, "")
if items: if items:
folder_data.append({"name": src_name, "items": items}) folder_data.append({"name": src_name, "entries": items})
else: else:
for src in sources: for src in sources:
items = sorted( items = sorted(
@ -326,7 +326,7 @@ def setup_tv_routes(app: web.Application, config: Config,
) )
if items: if items:
folder_data.append({ folder_data.append({
"name": src["name"], "items": items}) "name": src["name"], "entries": items})
# Serien ohne Quelle (Fallback) # Serien ohne Quelle (Fallback)
src_ids = {src["id"] for src in sources} src_ids = {src["id"] for src in sources}
orphans = sorted( orphans = sorted(
@ -335,7 +335,7 @@ def setup_tv_routes(app: web.Application, config: Config,
key=lambda x: (x.get("folder_name") or "").lower() key=lambda x: (x.get("folder_name") or "").lower()
) )
if orphans: if orphans:
folder_data.append({"name": "Sonstige", "items": orphans}) folder_data.append({"name": "Sonstige", "entries": orphans})
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/series.html", request, { "tv/series.html", request, {
@ -554,7 +554,7 @@ def setup_tv_routes(app: web.Application, config: Config,
) )
src_name = src_map.get(source_filter, "") src_name = src_map.get(source_filter, "")
if items: if items:
folder_data.append({"name": src_name, "items": items}) folder_data.append({"name": src_name, "entries": items})
else: else:
for src in sources: for src in sources:
items = sorted( items = sorted(
@ -564,7 +564,7 @@ def setup_tv_routes(app: web.Application, config: Config,
) )
if items: if items:
folder_data.append({ folder_data.append({
"name": src["name"], "items": items}) "name": src["name"], "entries": items})
# Filme ohne Quelle (Fallback) # Filme ohne Quelle (Fallback)
src_ids = {src["id"] for src in sources} src_ids = {src["id"] for src in sources}
orphans = sorted( orphans = sorted(
@ -573,7 +573,7 @@ def setup_tv_routes(app: web.Application, config: Config,
key=lambda x: (x.get("folder_name") or "").lower() key=lambda x: (x.get("folder_name") or "").lower()
) )
if orphans: if orphans:
folder_data.append({"name": "Sonstige", "items": orphans}) folder_data.append({"name": "Sonstige", "entries": orphans})
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/movies.html", request, { "tv/movies.html", request, {
@ -929,36 +929,56 @@ def setup_tv_routes(app: web.Application, config: Config,
# --- Profilauswahl (Multi-User Quick-Switch) --- # --- Profilauswahl (Multi-User Quick-Switch) ---
async def get_profiles(request: web.Request) -> web.Response: async def get_profiles(request: web.Request) -> web.Response:
"""GET /tv/profiles - Profilauswahl (wer schaut?)""" """GET /tv/profiles - Profilauswahl (wer schaut?)
Zeigt alle User an. Aktuelle Session wird hervorgehoben."""
client_id = request.cookies.get("vk_client_id") client_id = request.cookies.get("vk_client_id")
profiles = []
if client_id:
profiles = await auth_service.get_client_profiles(client_id)
# Aktuelle Session herausfinden
current_session = request.cookies.get("vk_session") current_session = request.cookies.get("vk_session")
current_user_id = None
if current_session:
user = await auth_service.validate_session(current_session)
if user:
current_user_id = user.get("id")
# Alle User laden (nicht nur die mit Sessions auf diesem Client)
all_users = await auth_service.get_all_users()
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/profiles.html", request, { "tv/profiles.html", request, {
"profiles": profiles, "profiles": all_users,
"current_session": current_session, "current_user_id": current_user_id,
} }
) )
async def post_switch_profile(request: web.Request) -> web.Response: async def post_switch_profile(request: web.Request) -> web.Response:
"""POST /tv/switch-profile - Profil wechseln (Session-ID)""" """POST /tv/switch-profile - Auf anderen User wechseln.
Erstellt neue Session fuer den gewaehlten User."""
data = await request.post() data = await request.post()
session_id = data.get("session_id", "") user_id = data.get("user_id", "")
if not session_id: if not user_id:
raise web.HTTPFound("/tv/profiles") raise web.HTTPFound("/tv/profiles")
# Session validieren
user = await auth_service.validate_session(session_id) # Client-ID ermitteln/erstellen
if not user: client_id = request.cookies.get("vk_client_id")
client_id = await auth_service.get_or_create_client(client_id)
# Neue Session fuer den User erstellen
ua = request.headers.get("User-Agent", "")
session_id = await auth_service.create_session(
int(user_id), ua, client_id=client_id, persistent=True
)
if not session_id:
raise web.HTTPFound("/tv/login") raise web.HTTPFound("/tv/login")
resp = web.HTTPFound("/tv/") resp = web.HTTPFound("/tv/")
resp.set_cookie( resp.set_cookie(
"vk_session", session_id, "vk_session", session_id,
max_age=10 * 365 * 24 * 3600, max_age=10 * 365 * 24 * 3600,
httponly=True, samesite="Lax", path="/", httponly=True, samesite="Lax", path="/",
) )
resp.set_cookie(
"vk_client_id", client_id,
max_age=10 * 365 * 24 * 3600,
httponly=True, samesite="Lax", path="/",
)
return resp return resp
# --- User-Einstellungen --- # --- User-Einstellungen ---

View file

@ -53,8 +53,18 @@ class VideoKonverterServer:
@web.middleware @web.middleware
async def _no_cache_middleware(self, request: web.Request, async def _no_cache_middleware(self, request: web.Request,
handler) -> web.Response: handler) -> web.Response:
"""Verhindert Browser-Caching fuer API-Responses""" """Verhindert Browser-Caching fuer API-Responses + Error-Logging"""
response = await handler(request) 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/"): if request.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
@ -96,8 +106,14 @@ class VideoKonverterServer:
# Seiten Routes # Seiten Routes
setup_page_routes(self.app, self.config, self.queue_service) setup_page_routes(self.app, self.config, self.queue_service)
# TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert) # TV-App Routes (Auth-Service, DB-Pool wird in on_startup gesetzt)
self.auth_service = None async def _lazy_pool():
return self.library_service._db_pool
self.auth_service = AuthService(_lazy_pool)
setup_tv_routes(
self.app, self.config,
self.auth_service, self.library_service,
)
# Statische Dateien # Statische Dateien
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
@ -151,16 +167,9 @@ class VideoKonverterServer:
await self.tvdb_service.init_db() await self.tvdb_service.init_db()
await self.importer_service.init_db() await self.importer_service.init_db()
# TV-App Auth-Service initialisieren (braucht DB-Pool) # TV-App Auth-Service: DB-Tabellen initialisieren (Pool kommt ueber lazy getter)
if self.library_service._db_pool: if self.library_service._db_pool:
async def _get_pool():
return self.library_service._db_pool
self.auth_service = AuthService(_get_pool)
await self.auth_service.init_db() await self.auth_service.init_db()
setup_tv_routes(
self.app, self.config,
self.auth_service, self.library_service,
)
host = self.config.server_config.get("host", "0.0.0.0") host = self.config.server_config.get("host", "0.0.0.0")
port = self.config.server_config.get("port", 8080) port = self.config.server_config.get("port", 8080)

View file

@ -596,6 +596,20 @@ class AuthService:
""", (client_id,)) """, (client_id,))
return await cur.fetchall() return await cur.fetchall()
async def get_all_users(self) -> list[dict]:
"""Alle User laden (fuer Profilauswahl)"""
pool = await self._get_pool()
if not pool:
return []
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT id, username, display_name, avatar_color
FROM tv_users
ORDER BY id
""")
return await cur.fetchall()
# --- User-Einstellungen --- # --- User-Einstellungen ---
async def update_user_settings(self, user_id: int, async def update_user_settings(self, user_id: int,

View file

@ -220,6 +220,8 @@ class LibraryService:
episode_name VARCHAR(512), episode_name VARCHAR(512),
aired DATE NULL, aired DATE NULL,
runtime INT NULL, runtime INT NULL,
overview TEXT NULL,
image_url VARCHAR(1024) NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_series (series_tvdb_id), INDEX idx_series (series_tvdb_id),
UNIQUE INDEX idx_episode ( UNIQUE INDEX idx_episode (
@ -227,6 +229,18 @@ class LibraryService:
) )
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""") """)
# Spalten nachtraeglich hinzufuegen (bestehende DBs)
for col, coldef in [
("overview", "TEXT NULL"),
("image_url", "VARCHAR(1024) NULL"),
]:
try:
await cur.execute(
f"ALTER TABLE tvdb_episode_cache "
f"ADD COLUMN {col} {coldef}"
)
except Exception:
pass # Spalte existiert bereits
# movie_id Spalte in library_videos (falls noch nicht vorhanden) # movie_id Spalte in library_videos (falls noch nicht vorhanden)
try: try:

View file

@ -1081,6 +1081,8 @@ legend {
.row-missing { opacity: 0.6; } .row-missing { opacity: 0.6; }
.row-missing td { color: #888; } .row-missing td { color: #888; }
.row-redundant { background: rgba(255, 152, 0, 0.08); }
.row-redundant td { color: #b0a080; }
.text-warn { color: #ffb74d; } .text-warn { color: #ffb74d; }
.text-muted { color: #888; font-size: 0.8rem; } .text-muted { color: #888; font-size: 0.8rem; }

View file

@ -638,6 +638,33 @@ function renderEpisodesTab(series) {
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"}); for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0)); allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0));
// Redundante Dateien erkennen: gleiche Episode-Nummer mehrfach vorhanden
// Die "beste" Datei behalten (kleinere Datei bei gleichem Codec, neueres Format bevorzugt)
const epGroups = {};
for (const ep of allEps) {
if (ep._type !== "local" || !ep.episode_number) continue;
const key = `${ep.season_number || 0}-${ep.episode_number}`;
if (!epGroups[key]) epGroups[key] = [];
epGroups[key].push(ep);
}
const redundantIds = new Set();
const codecRank = {av1: 4, hevc: 3, h265: 3, h264: 2, x264: 2, mpeg4: 1, mpeg2video: 0};
for (const key of Object.keys(epGroups)) {
const group = epGroups[key];
if (group.length <= 1) continue;
// Sortiere: neuerer Codec besser, bei gleichem Codec kleinere Datei besser
group.sort((a, b) => {
const ra = codecRank[(a.video_codec || "").toLowerCase()] || 0;
const rb = codecRank[(b.video_codec || "").toLowerCase()] || 0;
if (ra !== rb) return rb - ra;
return (a.file_size || 0) - (b.file_size || 0);
});
// Alle ausser dem ersten sind redundant
for (let i = 1; i < group.length; i++) {
redundantIds.add(group[i].id);
}
}
for (const ep of allEps) { for (const ep of allEps) {
if (ep._type === "missing") { if (ep._type === "missing") {
html += `<tr class="row-missing"> html += `<tr class="row-missing">
@ -647,6 +674,7 @@ function renderEpisodesTab(series) {
<td><span class="status-badge error">FEHLT</span></td> <td><span class="status-badge error">FEHLT</span></td>
</tr>`; </tr>`;
} else { } else {
const isRedundant = redundantIds.has(ep.id);
const audioInfo = (ep.audio_tracks || []).map(a => { const audioInfo = (ep.audio_tracks || []).map(a => {
const lang = (a.lang || "?").toUpperCase().substring(0, 3); const lang = (a.lang || "?").toUpperCase().substring(0, 3);
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`; return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
@ -654,9 +682,10 @@ function renderEpisodesTab(series) {
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-"; const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
const epTitle = ep.episode_title || ep.file_name || "Episode"; const epTitle = ep.episode_title || ep.file_name || "Episode";
const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-"; const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-";
html += `<tr data-video-id="${ep.id}"> const redundantBadge = isRedundant ? ' <span class="status-badge warn" title="Duplikat - kann geloescht werden">REDUNDANT</span>' : '';
html += `<tr data-video-id="${ep.id}" class="${isRedundant ? 'row-redundant' : ''}">
<td>${ep.episode_number || "-"}</td> <td>${ep.episode_number || "-"}</td>
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td> <td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}${redundantBadge}</td>
<td>${res}</td> <td>${res}</td>
<td><span class="tag codec">${ep.video_codec || "-"}</span></td> <td><span class="tag codec">${ep.video_codec || "-"}</span></td>
<td><span class="tag">${fileExt}</span></td> <td><span class="tag">${fileExt}</span></td>
@ -3158,3 +3187,45 @@ async function deleteVideo(videoId, title, context) {
}) })
.catch(e => showToast("Fehler: " + e, "error")); .catch(e => showToast("Fehler: " + e, "error"));
} }
// === Batch-Thumbnail-Generierung ===
async function generateThumbnails() {
// Status pruefen
const status = await fetch("/api/library/thumbnail-status").then(r => r.json());
if (status.missing === 0) {
showToast("Alle " + status.total + " Videos haben bereits Thumbnails", "info");
return;
}
if (!await showConfirm(
status.missing + " von " + status.total + " Videos haben noch kein Thumbnail. Jetzt generieren?",
{title: "Thumbnails generieren", detail: "Die Generierung laeuft im Hintergrund per ffmpeg.", okText: "Starten", icon: "info"}
)) return;
fetch("/api/library/generate-thumbnails", {method: "POST"})
.then(r => r.json())
.then(data => {
if (data.status === "running") {
showToast("Thumbnail-Generierung laeuft bereits", "info");
} else {
showToast("Thumbnail-Generierung gestartet", "success");
pollThumbnailStatus();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
function pollThumbnailStatus() {
const interval = setInterval(() => {
fetch("/api/library/thumbnail-status")
.then(r => r.json())
.then(data => {
if (!data.running) {
clearInterval(interval);
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
}
})
.catch(() => clearInterval(interval));
}, 3000);
}

View file

@ -290,9 +290,71 @@ a { color: var(--accent); text-decoration: none; }
transition: background 0.2s; transition: background 0.2s;
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
align-items: center;
position: relative;
}
.tv-episode-card:hover { background: var(--bg-hover); }
.tv-ep-link {
display: flex;
gap: 1rem;
color: var(--text);
text-decoration: none;
flex: 1;
min-width: 0;
}
.tv-ep-link:focus { outline: var(--focus-ring); outline-offset: -2px; border-radius: var(--radius); }
/* Gesehen-Button pro Episode */
.tv-ep-mark-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--text-dim);
background: transparent;
color: var(--text-dim);
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
margin-right: 0.4rem;
}
.tv-ep-mark-btn:hover, .tv-ep-mark-btn:focus {
border-color: var(--accent);
color: var(--accent);
outline: none;
}
.tv-ep-mark-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.tv-ep-seen { opacity: 0.6; }
.tv-ep-seen:hover { opacity: 1; }
/* Staffel-Aktionen */
.tv-season-actions {
display: flex;
justify-content: flex-end;
padding: 0.3rem 0 0.6rem;
}
.tv-season-mark-btn {
background: transparent;
border: 1px solid var(--text-dim);
color: var(--text-dim);
padding: 0.3rem 0.8rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.tv-season-mark-btn:hover, .tv-season-mark-btn:focus {
border-color: var(--accent);
color: var(--accent);
outline: none;
} }
.tv-episode-card:hover, .tv-episode-card:focus { background: var(--bg-hover); }
.tv-episode-card:focus { outline: var(--focus-ring); outline-offset: -2px; }
/* Thumbnail-Bereich */ /* Thumbnail-Bereich */
.tv-ep-thumb { .tv-ep-thumb {
@ -919,6 +981,22 @@ a { color: var(--accent); text-decoration: none; }
border-radius: var(--radius); border-radius: var(--radius);
} }
.player-btn:focus { outline: var(--focus-ring); } .player-btn:focus { outline: var(--focus-ring); }
.player-btn svg { display: block; }
.player-btn-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
padding: 1px 4px;
border: 1px solid currentColor;
border-radius: 3px;
line-height: 1.2;
}
.player-btn.active { color: var(--accent); }
.player-btn.active .player-btn-badge {
border-color: var(--accent);
background: var(--accent);
color: #000;
}
.player-time { color: var(--text-muted); font-size: 0.85rem; } .player-time { color: var(--text-muted); font-size: 0.85rem; }
.player-spacer { flex: 1; } .player-spacer { flex: 1; }
@ -1217,6 +1295,18 @@ a { color: var(--accent); text-decoration: none; }
border-color: var(--accent); border-color: var(--accent);
outline: none; outline: none;
} }
/* SELECT im Editier-Modus: deutlich hervorgehoben */
select.select-editing {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
background: var(--bg-hover);
}
/* Auch Sort-/Filter-Selects im Content-Bereich */
.tv-sort-select.select-editing,
.tv-rating-filter.select-editing {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
}
.color-picker-grid { .color-picker-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1475,3 +1565,46 @@ a { color: var(--accent); text-decoration: none; }
display: none; display: none;
} }
} }
/* === Alphabet-Seitenleiste === */
.tv-alpha-sidebar {
position: fixed;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
z-index: 50;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 4px 2px;
}
.tv-alpha-letter {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 19px;
font-size: 0.65rem;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
font-weight: 600;
user-select: none;
}
.tv-alpha-letter:hover { color: var(--text); background: var(--bg-hover); }
.tv-alpha-letter:focus { outline: var(--focus-ring); outline-offset: -1px; }
.tv-alpha-letter.active { color: #000; background: var(--accent); }
.tv-alpha-letter.dimmed { color: var(--border); pointer-events: none; }
@media (max-width: 768px) {
.tv-alpha-sidebar { right: 2px; padding: 3px 1px; }
.tv-alpha-letter { width: 20px; height: 17px; font-size: 0.58rem; }
}
@media (max-width: 480px) {
.tv-alpha-sidebar { right: 1px; padding: 2px 1px; }
.tv-alpha-letter { width: 16px; height: 14px; font-size: 0.5rem; }
}

View file

@ -169,6 +169,7 @@
"sort_episodes": "Episoden-Anzahl", "sort_episodes": "Episoden-Anzahl",
"sort_last_watched": "Zuletzt angesehen", "sort_last_watched": "Zuletzt angesehen",
"sort_rating": "Bewertung", "sort_rating": "Bewertung",
"all_genres": "Alle Genres",
"genres": "Genres", "genres": "Genres",
"min_rating": "Min. Sterne" "min_rating": "Min. Sterne"
}, },

View file

@ -169,6 +169,7 @@
"sort_episodes": "Episode Count", "sort_episodes": "Episode Count",
"sort_last_watched": "Last Watched", "sort_last_watched": "Last Watched",
"sort_rating": "Rating", "sort_rating": "Rating",
"all_genres": "All Genres",
"genres": "Genres", "genres": "Genres",
"min_rating": "Min. Stars" "min_rating": "Min. Stars"
}, },

View file

@ -42,6 +42,7 @@ function initPlayer(opts) {
loadVideoInfo().then(() => { loadVideoInfo().then(() => {
// Stream starten // Stream starten
setStreamUrl(opts.startPos || 0); setStreamUrl(opts.startPos || 0);
updatePlayerButtons();
}); });
// Events // Events
@ -63,7 +64,15 @@ function initPlayer(opts) {
// Einstellungen-Button // Einstellungen-Button
const btnSettings = document.getElementById("btn-settings"); const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.addEventListener("click", toggleOverlay); if (btnSettings) btnSettings.addEventListener("click", () => openOverlaySection(null));
// Separate Buttons: Audio, Untertitel, Qualitaet
const btnAudio = document.getElementById("btn-audio");
if (btnAudio) btnAudio.addEventListener("click", () => openOverlaySection("audio"));
const btnSubs = document.getElementById("btn-subs");
if (btnSubs) btnSubs.addEventListener("click", () => openOverlaySection("subs"));
const btnQuality = document.getElementById("btn-quality");
if (btnQuality) btnQuality.addEventListener("click", () => openOverlaySection("quality"));
// Naechste-Episode-Button // Naechste-Episode-Button
const btnNext = document.getElementById("btn-next"); const btnNext = document.getElementById("btn-next");
@ -279,6 +288,25 @@ function toggleOverlay() {
} }
} }
function openOverlaySection(section) {
const overlay = document.getElementById("player-overlay");
if (!overlay) return;
if (overlayOpen) {
// Bereits offen -> schliessen
overlayOpen = false;
overlay.style.display = "none";
return;
}
overlayOpen = true;
overlay.style.display = "";
renderOverlay();
showControls();
if (section) {
var el = document.getElementById("overlay-" + section);
if (el) el.scrollIntoView({ behavior: "smooth" });
}
}
function renderOverlay() { function renderOverlay() {
// Audio-Spuren // Audio-Spuren
const audioEl = document.getElementById("overlay-audio"); const audioEl = document.getElementById("overlay-audio");
@ -343,12 +371,14 @@ function switchAudio(idx) {
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0); const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
setStreamUrl(currentTime); setStreamUrl(currentTime);
renderOverlay(); renderOverlay();
updatePlayerButtons();
} }
function switchSub(idx) { function switchSub(idx) {
currentSub = idx; currentSub = idx;
updateSubtitleTrack(); updateSubtitleTrack();
renderOverlay(); renderOverlay();
updatePlayerButtons();
} }
function updateSubtitleTrack() { function updateSubtitleTrack() {
@ -364,6 +394,7 @@ function switchQuality(q) {
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0); const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
setStreamUrl(currentTime); setStreamUrl(currentTime);
renderOverlay(); renderOverlay();
updatePlayerButtons();
} }
function switchSpeed(s) { function switchSpeed(s) {
@ -470,6 +501,26 @@ function saveProgress(completed) {
window.addEventListener("beforeunload", () => saveProgress()); window.addEventListener("beforeunload", () => saveProgress());
// === Button-Status aktualisieren ===
function updatePlayerButtons() {
// CC-Button: aktiv wenn Untertitel an
var btnSubs = document.getElementById("btn-subs");
if (btnSubs) btnSubs.classList.toggle("active", currentSub >= 0);
// Quality-Badge: aktuellen Modus anzeigen
var badge = document.getElementById("quality-badge");
if (badge) {
var labels = { uhd: "4K", hd: "HD", sd: "SD", low: "LD" };
badge.textContent = labels[currentQuality] || "HD";
}
// Audio-Button: aktuelle Sprache anzeigen (Tooltip)
var btnAudio = document.getElementById("btn-audio");
if (btnAudio && videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) {
var lang = videoInfo.audio_tracks[currentAudio].lang;
btnAudio.title = langName(lang) || "Audio";
}
}
// === Hilfsfunktionen === // === Hilfsfunktionen ===
const LANG_NAMES = { const LANG_NAMES = {

View file

@ -12,12 +12,20 @@ class FocusManager {
this._currentFocus = null; this._currentFocus = null;
// Merkt sich das letzte fokussierte Element im Content-Bereich // Merkt sich das letzte fokussierte Element im Content-Bereich
this._lastContentFocus = null; this._lastContentFocus = null;
// SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte
this._selectActive = false;
// Tastatur-Events abfangen // Tastatur-Events abfangen
document.addEventListener("keydown", (e) => this._onKeyDown(e)); document.addEventListener("keydown", (e) => this._onKeyDown(e));
// Focus-Tracking: merken wo wir zuletzt waren // Focus-Tracking: merken wo wir zuletzt waren
document.addEventListener("focusin", (e) => { document.addEventListener("focusin", (e) => {
// SELECT-Editier-Modus beenden wenn Focus sich aendert
if (this._selectActive && e.target && e.target.tagName !== "SELECT") {
this._selectActive = false;
document.querySelectorAll(".select-editing").forEach(
el => el.classList.remove("select-editing"));
}
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
if (!e.target.closest("#tv-nav")) { if (!e.target.closest("#tv-nav")) {
this._lastContentFocus = e.target; this._lastContentFocus = e.target;
@ -84,9 +92,13 @@ class FocusManager {
if (direction === "ArrowLeft" || direction === "ArrowRight") return; if (direction === "ArrowLeft" || direction === "ArrowRight") return;
} }
// Select-Elemente: Hoch/Runter dem Browser ueberlassen (Option wechseln) // Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter durchlassen
if (active && active.tagName === "SELECT") { if (active && active.tagName === "SELECT") {
if (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(); const focusables = this._getFocusableElements();
@ -220,8 +232,20 @@ class FocusManager {
return; return;
} }
// Select: Enter oeffnet/schliesst das Dropdown nativ // Select: Enter aktiviert/deaktiviert den Editier-Modus
if (active.tagName === "SELECT") { if (active.tagName === "SELECT") {
if (this._selectActive) {
// Wert bestaetigen, Editier-Modus beenden
this._selectActive = false;
active.classList.remove("select-editing");
// onchange ausloesen falls sich Wert geaendert hat
active.dispatchEvent(new Event("change", { bubbles: true }));
} else {
// Editier-Modus starten
this._selectActive = true;
active.classList.add("select-editing");
}
e.preventDefault();
return; return;
} }
@ -252,9 +276,16 @@ class FocusManager {
return; return;
} }
// In Select-Feldern: Escape = Blur (zurueck zur Navigation) // In Select-Feldern: Escape = Editier-Modus beenden oder Blur
if (active && active.tagName === "SELECT") { if (active && active.tagName === "SELECT") {
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(); e.preventDefault();
return; return;
} }

View file

@ -13,6 +13,7 @@
<button class="btn-secondary" onclick="openImportModal()">Importieren</button> <button class="btn-secondary" onclick="openImportModal()">Importieren</button>
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button> <button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button> <button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
<button class="btn-secondary" onclick="generateThumbnails()">Thumbnails</button>
</div> </div>
</div> </div>

View file

@ -49,15 +49,27 @@
</div> </div>
<script> <script>
// Kurz warten, dann Formular einblenden (Server hat ggf. schon Redirect gemacht) // Pruefen ob Browser Felder vorausgefuellt hat -> automatisch absenden
setTimeout(function() { var _autoAttempts = 0;
document.getElementById('login-loader').style.display = 'none'; var _autoInterval = setInterval(function() {
var container = document.getElementById('login-container'); _autoAttempts++;
container.style.display = ''; var u = document.getElementById('username');
container.style.animation = 'fadeIn 0.3s ease'; var p = document.getElementById('password');
var usernameInput = document.getElementById('username'); if (u && p && u.value && p.value) {
if (usernameInput) usernameInput.focus(); clearInterval(_autoInterval);
}, 300); 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> </script>
</body> </body>
</html> </html>

View file

@ -48,18 +48,12 @@
<!-- Filter-Leiste (nicht in Ordner-Ansicht) --> <!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}> <div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% if genres %} {% if genres %}
<div class="tv-genre-chips"> <select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
<a href="/tv/movies?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}" <option value="">{{ t('filter.all_genres') }}</option>
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for g in genres %} {% for g in genres %}
<a href="/tv/movies?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}" <option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
{{ g }}
</a>
{% endfor %} {% endfor %}
</div> </select>
{% endif %} {% endif %}
<!-- Rating-Filter --> <!-- Rating-Filter -->
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)"> <select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
@ -82,7 +76,7 @@
<!-- === Grid-Ansicht === --> <!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}> <div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for m in movies %} {% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable> <a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
{% if m.poster_url %} {% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy"> <img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %} {% else %}
@ -102,7 +96,7 @@
<!-- === Liste (kompakt) === --> <!-- === Liste (kompakt) === -->
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}> <div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
{% for m in movies %} {% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-list-item" data-focusable> <a href="/tv/movies/{{ m.id }}" class="tv-list-item" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
<div class="tv-list-poster"> <div class="tv-list-poster">
{% if m.poster_url %} {% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" loading="lazy"> <img src="{{ m.poster_url }}" alt="" loading="lazy">
@ -119,7 +113,7 @@
<!-- === Detail-Liste === --> <!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}> <div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for m in movies %} {% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable> <a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
<div class="tv-detail-thumb"> <div class="tv-detail-thumb">
{% if m.poster_url %} {% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" loading="lazy"> <img src="{{ m.poster_url }}" alt="" loading="lazy">
@ -148,7 +142,7 @@
<h3 class="tv-folder-source-title">{{ src.name }}</h3> <h3 class="tv-folder-source-title">{{ src.name }}</h3>
{% endif %} {% endif %}
<div class="tv-folder-list"> <div class="tv-folder-list">
{% for m in src.items %} {% for m in src.entries %}
<a href="/tv/movies/{{ m.id }}" class="tv-folder-item" data-focusable> <a href="/tv/movies/{{ m.id }}" class="tv-folder-item" data-focusable>
<span class="tv-folder-icon">&#128193;</span> <span class="tv-folder-icon">&#128193;</span>
<span class="tv-folder-name">{{ m.folder_name }}</span> <span class="tv-folder-name">{{ m.folder_name }}</span>
@ -163,6 +157,14 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Alphabet-Seitenleiste -->
<nav class="tv-alpha-sidebar" id="alpha-sidebar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}
<span class="tv-alpha-letter" data-letter="{{ letter }}" onclick="filterByLetter('{{ letter }}')" data-focusable>{{ letter }}</span>
{% endfor %}
<span class="tv-alpha-letter" data-letter="#" onclick="filterByLetter('#')" data-focusable>#</span>
</nav>
{% if not movies and view != 'folder' %} {% if not movies and view != 'folder' %}
<div class="tv-empty">{{ t('movies.no_movies') }}</div> <div class="tv-empty">{{ t('movies.no_movies') }}</div>
{% endif %} {% endif %}
@ -180,9 +182,11 @@ function switchView(mode) {
document.querySelectorAll('.tv-view-btn').forEach(btn => { document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode); btn.classList.toggle('active', btn.dataset.view === mode);
}); });
// Filter-Leiste in Ordner-Ansicht verstecken // Filter-Leiste und Alphabet in Ordner-Ansicht verstecken
const filterBar = document.getElementById('filter-bar'); const filterBar = document.getElementById('filter-bar');
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : ''; if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
var alphaSidebar = document.getElementById('alpha-sidebar');
if (alphaSidebar) alphaSidebar.style.display = mode === 'folder' ? 'none' : '';
fetch('/tv/settings', { fetch('/tv/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', headers: { 'Content-Type': 'application/x-www-form-urlencoded',
@ -197,6 +201,16 @@ function applySort(sort) {
window.location.href = url.toString(); window.location.href = url.toString();
} }
function applyGenre(genre) {
const url = new URL(window.location);
if (genre) {
url.searchParams.set('genre', genre);
} else {
url.searchParams.delete('genre');
}
window.location.href = url.toString();
}
function applyRating(rating) { function applyRating(rating) {
const url = new URL(window.location); const url = new URL(window.location);
if (rating) { if (rating) {
@ -206,5 +220,35 @@ function applyRating(rating) {
} }
window.location.href = url.toString(); window.location.href = url.toString();
} }
// Alphabet-Filter
var _currentLetter = null;
function filterByLetter(letter) {
_currentLetter = (_currentLetter === letter) ? null : letter;
['grid', 'list', 'detail'].forEach(function(v) {
var c = document.getElementById('view-' + v);
if (!c) return;
c.querySelectorAll('[data-letter]').forEach(function(item) {
if (!_currentLetter) { item.style.display = ''; return; }
var raw = item.dataset.letter;
var norm = /^[A-Z]$/.test(raw) ? raw : '#';
item.style.display = (norm === _currentLetter) ? '' : 'none';
});
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
el.classList.toggle('active', el.dataset.letter === _currentLetter);
});
}
// Buchstaben ohne Treffer abdunkeln
(function() {
var avail = {};
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
var raw = item.dataset.letter;
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
if (!avail[el.dataset.letter]) el.classList.add('dimmed');
});
})();
</script> </script>
{% endblock %} {% endblock %}

View file

@ -27,6 +27,15 @@
<button class="player-btn" id="btn-play" data-focusable>&#9654;</button> <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-time" id="player-time">0:00 / 0:00</span>
<span class="player-spacer"></span> <span class="player-spacer"></span>
<button class="player-btn" id="btn-audio" data-focusable title="{{ t('player.audio') }}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/></svg>
</button>
<button class="player-btn" id="btn-subs" data-focusable title="{{ t('player.subtitles') }}">
<span class="player-btn-badge">CC</span>
</button>
<button class="player-btn" id="btn-quality" data-focusable title="{{ t('player.quality') }}">
<span class="player-btn-badge" id="quality-badge">HD</span>
</button>
<button class="player-btn" id="btn-settings" data-focusable title="{{ t('player.settings') }}">&#9881;</button> <button class="player-btn" id="btn-settings" data-focusable title="{{ t('player.settings') }}">&#9881;</button>
{% if next_video %} {% if next_video %}
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">&#9197;</button> <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"> <div class="profiles-grid">
{% for p in profiles %} {% for p in profiles %}
<form method="post" action="/tv/switch-profile" class="profile-form"> <form method="post" action="/tv/switch-profile" class="profile-form">
<input type="hidden" name="session_id" value="{{ p.session_id }}"> <input type="hidden" name="user_id" value="{{ p.id }}">
<button type="submit" class="profile-card {% if p.session_id == current_session %}profile-active{% endif %}" data-focusable> <button type="submit" class="profile-card {% if p.id == current_user_id %}profile-active{% endif %}" data-focusable>
<span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}"> <span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}">
{{ (p.display_name or p.username)[:1]|upper }} {{ (p.display_name or p.username)[:1]|upper }}
</span> </span>

View file

@ -48,18 +48,12 @@
<!-- Filter-Leiste (nicht in Ordner-Ansicht) --> <!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}> <div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% if genres %} {% if genres %}
<div class="tv-genre-chips"> <select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
<a href="/tv/series?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}" <option value="">{{ t('filter.all_genres') }}</option>
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for g in genres %} {% for g in genres %}
<a href="/tv/series?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}" <option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
{{ g }}
</a>
{% endfor %} {% endfor %}
</div> </select>
{% endif %} {% endif %}
<!-- Rating-Filter --> <!-- Rating-Filter -->
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)"> <select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
@ -82,7 +76,7 @@
<!-- === Grid-Ansicht === --> <!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}> <div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for s in series %} {% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable> <a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
{% if s.poster_url %} {% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy"> <img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %} {% else %}
@ -102,7 +96,7 @@
<!-- === Liste (kompakt) === --> <!-- === Liste (kompakt) === -->
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}> <div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
{% for s in series %} {% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-list-item" data-focusable> <a href="/tv/series/{{ s.id }}" class="tv-list-item" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
<div class="tv-list-poster"> <div class="tv-list-poster">
{% if s.poster_url %} {% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" loading="lazy"> <img src="{{ s.poster_url }}" alt="" loading="lazy">
@ -119,7 +113,7 @@
<!-- === Detail-Liste === --> <!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}> <div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for s in series %} {% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable> <a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
<div class="tv-detail-thumb"> <div class="tv-detail-thumb">
{% if s.poster_url %} {% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" loading="lazy"> <img src="{{ s.poster_url }}" alt="" loading="lazy">
@ -149,7 +143,7 @@
<h3 class="tv-folder-source-title">{{ src.name }}</h3> <h3 class="tv-folder-source-title">{{ src.name }}</h3>
{% endif %} {% endif %}
<div class="tv-folder-list"> <div class="tv-folder-list">
{% for s in src.items %} {% for s in src.entries %}
<a href="/tv/series/{{ s.id }}" class="tv-folder-item" data-focusable> <a href="/tv/series/{{ s.id }}" class="tv-folder-item" data-focusable>
<span class="tv-folder-icon">&#128193;</span> <span class="tv-folder-icon">&#128193;</span>
<span class="tv-folder-name">{{ s.folder_name }}</span> <span class="tv-folder-name">{{ s.folder_name }}</span>
@ -164,6 +158,14 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Alphabet-Seitenleiste -->
<nav class="tv-alpha-sidebar" id="alpha-sidebar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}
<span class="tv-alpha-letter" data-letter="{{ letter }}" onclick="filterByLetter('{{ letter }}')" data-focusable>{{ letter }}</span>
{% endfor %}
<span class="tv-alpha-letter" data-letter="#" onclick="filterByLetter('#')" data-focusable>#</span>
</nav>
{% if not series and view != 'folder' %} {% if not series and view != 'folder' %}
<div class="tv-empty">{{ t('series.no_series') }}</div> <div class="tv-empty">{{ t('series.no_series') }}</div>
{% endif %} {% endif %}
@ -181,9 +183,11 @@ function switchView(mode) {
document.querySelectorAll('.tv-view-btn').forEach(btn => { document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode); btn.classList.toggle('active', btn.dataset.view === mode);
}); });
// Filter-Leiste in Ordner-Ansicht verstecken // Filter-Leiste und Alphabet in Ordner-Ansicht verstecken
const filterBar = document.getElementById('filter-bar'); const filterBar = document.getElementById('filter-bar');
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : ''; if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
var alphaSidebar = document.getElementById('alpha-sidebar');
if (alphaSidebar) alphaSidebar.style.display = mode === 'folder' ? 'none' : '';
fetch('/tv/settings', { fetch('/tv/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', headers: { 'Content-Type': 'application/x-www-form-urlencoded',
@ -198,6 +202,16 @@ function applySort(sort) {
window.location.href = url.toString(); window.location.href = url.toString();
} }
function applyGenre(genre) {
const url = new URL(window.location);
if (genre) {
url.searchParams.set('genre', genre);
} else {
url.searchParams.delete('genre');
}
window.location.href = url.toString();
}
function applyRating(rating) { function applyRating(rating) {
const url = new URL(window.location); const url = new URL(window.location);
if (rating) { if (rating) {
@ -207,5 +221,35 @@ function applyRating(rating) {
} }
window.location.href = url.toString(); window.location.href = url.toString();
} }
// Alphabet-Filter
var _currentLetter = null;
function filterByLetter(letter) {
_currentLetter = (_currentLetter === letter) ? null : letter;
['grid', 'list', 'detail'].forEach(function(v) {
var c = document.getElementById('view-' + v);
if (!c) return;
c.querySelectorAll('[data-letter]').forEach(function(item) {
if (!_currentLetter) { item.style.display = ''; return; }
var raw = item.dataset.letter;
var norm = /^[A-Z]$/.test(raw) ? raw : '#';
item.style.display = (norm === _currentLetter) ? '' : 'none';
});
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
el.classList.toggle('active', el.dataset.letter === _currentLetter);
});
}
// Buchstaben ohne Treffer abdunkeln
(function() {
var avail = {};
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
var raw = item.dataset.letter;
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
if (!avail[el.dataset.letter]) el.classList.add('dimmed');
});
})();
</script> </script>
{% endblock %} {% endblock %}

View file

@ -82,50 +82,66 @@
<!-- Episoden pro Staffel --> <!-- Episoden pro Staffel -->
{% for sn, episodes in seasons.items() %} {% for sn, episodes in seasons.items() %}
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}> <div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
<div class="tv-season-actions">
<button class="tv-season-mark-btn" data-focusable
onclick="markSeasonWatched({{ series.id }}, {{ sn }})">
&#10003; {{ t('status.mark_season') }}
</button>
</div>
<div class="tv-episode-list"> <div class="tv-episode-list">
{% for ep in episodes %} {% for ep in episodes %}
<a href="/tv/player?v={{ ep.id }}" class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %}" data-focusable> <div class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= 95 %}tv-ep-seen{% endif %}"
<!-- Thumbnail --> data-video-id="{{ ep.id }}">
<div class="tv-ep-thumb"> <a href="/tv/player?v={{ ep.id }}" class="tv-ep-link" data-focusable>
{% if ep.ep_image_url %} <!-- Thumbnail -->
<img src="{{ ep.ep_image_url }}" alt="" loading="lazy"> <div class="tv-ep-thumb">
{% else %} {% if ep.ep_image_url %}
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy"> <img src="{{ ep.ep_image_url }}" alt="" loading="lazy">
{% endif %} {% else %}
{% if ep.progress_pct > 0 and ep.progress_pct < 95 %} <img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
<div class="tv-ep-progress"> {% endif %}
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div> {% 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> </div>
{% endif %} <!-- Info -->
{% if ep.progress_pct >= 95 %} <div class="tv-ep-info">
<div class="tv-ep-watched">&#10003;</div> <div class="tv-ep-header">
{% endif %} <span class="tv-ep-num">
<div class="tv-ep-duration"> {% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% 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>
</div> </a>
<!-- Info --> <!-- Gesehen-Button -->
<div class="tv-ep-info"> <button class="tv-ep-mark-btn {% if ep.progress_pct >= 95 %}active{% endif %}"
<div class="tv-ep-header"> data-focusable
<span class="tv-ep-num"> title="{% if ep.progress_pct >= 95 %}{{ t('status.mark_unwatched') }}{% else %}{{ t('status.mark_watched') }}{% endif %}"
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %} onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
</span> &#10003;
<span class="tv-ep-title"> </button>
{{ ep.episode_title or ep.file_name }} </div>
</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>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -206,5 +222,82 @@ function setRating(value) {
}) })
.catch(() => {}); .catch(() => {});
} }
function toggleWatched(videoId, btn) {
// Aktuellen Status pruefen und togglen
const card = btn.closest('.tv-episode-card');
const isSeen = card.classList.contains('tv-ep-seen');
const newPct = isSeen ? 0 : 100;
fetch('/tv/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: videoId, position: newPct, duration: 100 }),
})
.then(r => r.json())
.then(() => {
if (isSeen) {
// Als ungesehen markieren
card.classList.remove('tv-ep-seen');
btn.classList.remove('active');
const watchedEl = card.querySelector('.tv-ep-watched');
if (watchedEl) watchedEl.remove();
const progressEl = card.querySelector('.tv-ep-progress');
if (progressEl) progressEl.remove();
} else {
// Als gesehen markieren
card.classList.add('tv-ep-seen');
btn.classList.add('active');
// Haekchen-Symbol hinzufuegen
const thumb = card.querySelector('.tv-ep-thumb');
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
const check = document.createElement('div');
check.className = 'tv-ep-watched';
check.innerHTML = '&#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> </script>
{% endblock %} {% endblock %}