docker.videokonverter/video-konverter/app/templates/tv/series_detail.html
data c7151e8bd1 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>
2026-03-01 09:22:04 +01:00

303 lines
13 KiB
HTML

{% extends "tv/base.html" %}
{% block title %}{{ series.title or series.folder_name }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<!-- Serien-Header -->
<div class="tv-detail-header">
{% if series.poster_url %}
<img src="{{ series.poster_url }}" alt="" class="tv-detail-poster">
{% endif %}
<div class="tv-detail-info">
<h1 class="tv-page-title">{{ series.title or series.folder_name }}</h1>
{% if series.genres %}
<p class="tv-detail-genres">{{ series.genres }}</p>
{% endif %}
{% if series.overview %}
<p class="tv-detail-overview">{{ series.overview }}</p>
{% endif %}
<!-- Bewertungen -->
<div class="tv-rating-section">
<!-- Eigene Bewertung (klickbare Sterne) -->
<div class="tv-rating-user">
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
<div class="tv-stars-input" id="user-stars"
data-series-id="{{ series.id }}" data-rating="{{ user_rating }}">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
data-value="{{ i }}" data-focusable
onclick="setRating({{ i }})">&#9733;</span>
{% endfor %}
{% if user_rating > 0 %}
<span class="tv-rating-remove" onclick="setRating(0)"
data-focusable title="{{ t('rating.remove') }}">&#10005;</span>
{% endif %}
</div>
</div>
<!-- Durchschnitt -->
{% if avg_rating.count > 0 %}
<div class="tv-rating-avg">
<span class="tv-stars-display">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">&#9733;</span>
{% endfor %}
</span>
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
</div>
{% endif %}
<!-- TVDB-Score -->
{% if tvdb_score %}
<div class="tv-rating-external">
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
</div>
{% endif %}
</div>
<div class="tv-detail-actions">
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
id="btn-watchlist"
data-focusable
data-series-id="{{ series.id }}"
onclick="toggleWatchlist(this)">
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('series.watchlist') }}</span>
</button>
</div>
</div>
</div>
<!-- Staffel-Tabs -->
{% if seasons %}
<div class="tv-tabs" id="season-tabs">
{% for sn in seasons.keys() %}
<button class="tv-tab {% if loop.first %}active{% endif %}"
data-focusable
onclick="showSeason({{ sn }})">
{% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
</button>
{% endfor %}
</div>
<!-- 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 %}
<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>
<!-- 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>
<!-- 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>
{% endfor %}
{% else %}
<div class="tv-empty">{{ t('series.no_episodes') }}</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
function showSeason(sn) {
// Alle Staffeln verstecken
document.querySelectorAll('.tv-season').forEach(el => el.style.display = 'none');
// Alle Tabs deaktivieren
document.querySelectorAll('.tv-tab').forEach(el => el.classList.remove('active'));
// Gewaehlte Staffel anzeigen
const season = document.getElementById('season-' + sn);
if (season) season.style.display = '';
// Tab aktivieren
event.target.classList.add('active');
}
function toggleWatchlist(btn) {
const seriesId = btn.dataset.seriesId;
fetch('/tv/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_id: parseInt(seriesId) }),
})
.then(r => r.json())
.then(data => {
if (data.in_watchlist) {
btn.classList.add('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9829;';
} else {
btn.classList.remove('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9825;';
}
})
.catch(() => {});
}
function setRating(value) {
const container = document.getElementById('user-stars');
const seriesId = container.dataset.seriesId;
fetch('/tv/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_id: parseInt(seriesId), rating: value }),
})
.then(r => r.json())
.then(data => {
// Sterne aktualisieren
container.dataset.rating = data.user_rating;
container.querySelectorAll('.tv-star').forEach(star => {
const v = parseInt(star.dataset.value);
star.classList.toggle('active', v <= data.user_rating);
});
// Entfernen-Button anzeigen/verstecken
let removeBtn = container.querySelector('.tv-rating-remove');
if (data.user_rating > 0 && !removeBtn) {
removeBtn = document.createElement('span');
removeBtn.className = 'tv-rating-remove';
removeBtn.setAttribute('data-focusable', '');
removeBtn.innerHTML = '&#10005;';
removeBtn.onclick = () => setRating(0);
container.appendChild(removeBtn);
} else if (data.user_rating === 0 && removeBtn) {
removeBtn.remove();
}
// Durchschnitt aktualisieren (Seite neu laden fuer Einfachheit)
if (data.avg_rating !== undefined) {
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
}
})
.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 %}