From 3d4af05790b121aa4671e5fcc2204b6f02e9238a Mon Sep 17 00:00:00 2001 From: Marshmello0w <127906581+Marshmello0w@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:25:54 +0100 Subject: [PATCH] feat: download selected missing episodes from the library gap list --- pyproject.toml | 6 +- src/mediaforge/web/routes/browse.py | 4 + src/mediaforge/web/routes/stats.py | 1 + src/mediaforge/web/static/gap_batch.js | 164 ++++++++ src/mediaforge/web/static/gap_downloads.js | 198 +++++++++ src/mediaforge/web/static/home_feed.js | 8 +- src/mediaforge/web/static/home_gaps.css | 39 ++ src/mediaforge/web/static/home_gaps.js | 207 ++++++++++ src/mediaforge/web/static/home_panels.js | 11 +- src/mediaforge/web/static/stats.js | 91 ++++- .../web/templates/_gap_fill_modal.html | 42 ++ src/mediaforge/web/templates/index.html | 7 +- src/mediaforge/web/templates/monitoring.html | 6 + src/mediaforge/web/templates/stats.html | 6 + tests/test_gap_downloads_node.cjs | 61 +++ tests/test_home_gaps.py | 45 +++ tests/test_home_gaps_browser.py | 381 ++++++++++++++++++ tests/test_stats_gap_link_browser.py | 218 ++++++++++ tools/local-test.ps1 | 109 +++++ uv.lock | 39 +- 20 files changed, 1610 insertions(+), 33 deletions(-) create mode 100644 src/mediaforge/web/static/gap_batch.js create mode 100644 src/mediaforge/web/static/gap_downloads.js create mode 100644 src/mediaforge/web/static/home_gaps.css create mode 100644 src/mediaforge/web/static/home_gaps.js create mode 100644 src/mediaforge/web/templates/_gap_fill_modal.html create mode 100644 tests/test_gap_downloads_node.cjs create mode 100644 tests/test_home_gaps.py create mode 100644 tests/test_home_gaps_browser.py create mode 100644 tests/test_stats_gap_link_browser.py create mode 100644 tools/local-test.ps1 diff --git a/pyproject.toml b/pyproject.toml index d45c293..45eea09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "flask-wtf>=1.2,<2", "flask-limiter>=4.1,<5", "authlib>=1.6,<2", - "joserfc>=1.7,<2", + "joserfc>=1.7.5,<2", "requests", "beautifulsoup4", "waitress>=3.0,<4", @@ -160,17 +160,21 @@ test = [ # # aiohttp < 3.14.3 CVE-2026-69244 OOB heap read in the C response parser # aiohttp < 3.14.4 CVE-2026-111064, CVE-2026-111066, GHSA-2g87-pp6c-29x5 (DoS) +# authlib < 1.9.1 CVE-2026-96760, CVE-2026-114615, CVE-2026-114616 (auth bypass) # brotli < 1.2.1 GHSA-9v8h-qqgm-4m35 out-of-bounds write in the decoder # certifi < 2026.10.19 GHSA-6q48-6c7w-9gw2 bundles compromised emSign root CAs # cryptography < 50.0.0 CVE-2026-69247 Bleichenbacher oracle in PKCS#7 decrypt +# dnspython < 2.9.0 CVE-2026-114605 (DNS parser CPU exhaustion); 2.9 needs Python >= 3.11 # pillow < 13.0.0 CVE-2026-96778 (JPEG encoder code execution) and 27 more; # 13.x needs Python >= 3.11, so 3.10 stays on 12.x # urllib3 < 2.8.0 CVE-2026-97687 (proxy TLS override), CVE-2026-97689 (DoS) constraint-dependencies = [ "aiohttp>=3.14.4", + "authlib>=1.9.1", "brotli>=1.2.1", "certifi>=2026.10.19", "cryptography>=50.0.0", + "dnspython>=2.9.0; python_version >= '3.11'", "pillow>=13.0.0; python_version >= '3.11'", "urllib3>=2.8.0", ] diff --git a/src/mediaforge/web/routes/browse.py b/src/mediaforge/web/routes/browse.py index fd7da87..dae5cc0 100644 --- a/src/mediaforge/web/routes/browse.py +++ b/src/mediaforge/web/routes/browse.py @@ -749,9 +749,11 @@ def _feed_gap_rows(lib_titles, limit=12): # get_media_ignores() keys by the lower-cased folder name (see its # docstring); looking up the raw folder silently ignored every ignore. entry = ignores.get(folder.lower()) or ignores.get(folder) + ignored_slots = [] if entry: slots = entry.get("slots") if isinstance(entry, dict) else entry slots = set(slots or []) + ignored_slots = sorted(slots) if "__all__" in slots: continue missing = [m for m in missing if m not in slots] @@ -761,6 +763,8 @@ def _feed_gap_rows(lib_titles, limit=12): "title": folder, "folder": folder, "missing": missing[:12], + "missing_slots": missing, + "ignored_slots": ignored_slots, "missing_count": len(missing), # Both are shown: "3 missing" is the headline, the slot list is # what makes it actionable. diff --git a/src/mediaforge/web/routes/stats.py b/src/mediaforge/web/routes/stats.py index d7569ca..0a41aa8 100644 --- a/src/mediaforge/web/routes/stats.py +++ b/src/mediaforge/web/routes/stats.py @@ -600,6 +600,7 @@ def _compute_media_stats(cache=None): "title": agg["title"], "location": agg["location"], "missing": missing, + "ignored_slots": sorted((ig or {}).get("slots") or []), }) else: complete += 1 diff --git a/src/mediaforge/web/static/gap_batch.js b/src/mediaforge/web/static/gap_batch.js new file mode 100644 index 0000000..d6a71d5 --- /dev/null +++ b/src/mediaforge/web/static/gap_batch.js @@ -0,0 +1,164 @@ +(function () { + const overlay = document.getElementById("gapBatchOverlay"); + if (!overlay) return; + const api = window.MFGapDownloads; + const dialog = overlay.querySelector('[role="dialog"]'); + const language = document.getElementById("gapBatchLanguage"); + const provider = document.getElementById("gapBatchProvider"); + const status = document.getElementById("gapBatchStatus"); + const list = document.getElementById("gapBatchEntries"); + const submit = document.getElementById("gapBatchSubmit"); + const close = document.getElementById("gapBatchClose"); + let current = null, previousFocus, previousOverflow; + function text(de, en) { return window.t(de, en); } + function options(select, values, preferred) { + select.replaceChildren(); + values.forEach(function (value) { select.add(new Option(value, value)); }); + if (values.includes(preferred)) select.value = preferred; + select.disabled = !values.length; + } + function selected(ctx) { return ctx.entries.filter(function (entry) { return entry.included; }); } + function render(ctx) { + list.replaceChildren(); + ctx.entries.forEach(function (entry) { + const li = document.createElement("li"), label = document.createElement("label"); + const box = document.createElement("input"), note = document.createElement("small"); + box.type = "checkbox"; box.className = "chb-main"; + box.checked = entry.included; box.disabled = !!ctx.submitting || !!entry.done || !!ctx.finished; + box.addEventListener("change", function () { entry.included = box.checked; refresh(ctx); }); + label.append(box, document.createTextNode(entry.item.title)); + note.textContent = entry.note || entry.error || text("Wird geprüft …", "Checking …"); + li.append(label, note); list.appendChild(li); + }); + } + window.openGapFillBatch = async function (items, onQueued) { + if (!items.length || (current && current.submitting)) return; + if (current) current.cancel.abort(); + else { previousFocus = document.activeElement; previousOverflow = document.body.style.overflow; } + const ctx = { cancel: new AbortController(), entries: items.map(function (item) { + return { item: item, included: true }; + }), onQueued: onQueued, seq: 0, loading: true }; + current = ctx; + document.getElementById("gapBatchTitle").textContent = text("Ausgewählte Lücken herunterladen", "Download selected gaps"); + submit.textContent = text("Auswahl zur Warteschlange", "Queue selected"); + submit.disabled = true; + options(language, []); options(provider, []); + overlay.style.display = "block"; document.body.style.overflow = "hidden"; dialog.focus(); + status.textContent = text("Prüfe Serien und fehlende Episoden …", "Checking series and missing episodes …"); + render(ctx); + try { + const catalogue = await api.sources(ctx); + for (let i = 0; i < ctx.entries.length; i += 3) { + await Promise.all(ctx.entries.slice(i, i + 3).map(async function (entry) { + try { + Object.assign(entry, await api.prepare(entry.item, ctx, catalogue)); + if (entry.unavailable.length) throw new Error(text("Ausgewählte Folgen nicht verfügbar: ", + "Selected episodes unavailable: ") + entry.unavailable.join(", ")); + entry.note = entry.episodes.length + " " + text("fehlende Folgen gefunden", "missing episodes found"); + if (!entry.episodes.some(function (ep) { return (ep.languages || []).length; })) { + entry.initialProviders = await api.providers(entry.episodes[0], ctx); + } + } catch (error) { entry.error = error.message; } + })); + if (current !== ctx) return; + render(ctx); + } + const langs = [...new Set(ctx.entries.flatMap(function (entry) { + return (entry.episodes || []).flatMap(function (ep) { return ep.languages || []; }) + .concat(Object.keys(entry.initialProviders || {})); + }))]; + options(language, langs, "German Dub"); + ctx.loading = false; + await refresh(ctx); + } catch (error) { + if (current === ctx && !ctx.cancel.signal.aborted) status.textContent = error.message; + } + }; + async function refresh(ctx) { + if (current !== ctx || ctx.loading || ctx.submitting || ctx.finished) return; + const seq = ++ctx.seq, chosenLanguage = language.value; + const entries = selected(ctx); + submit.disabled = true; + options(provider, []); + status.textContent = text("Prüfe gemeinsame Downloadquelle …", "Checking for a shared download provider …"); + let shared = null, blocked = 0; + for (let i = 0; i < entries.length; i += 3) { + await Promise.all(entries.slice(i, i + 3).map(async function (entry) { + entry.available = api.available(entry.episodes || [], chosenLanguage); + entry.hosters = []; + if (entry.error) { entry.note = entry.error; return; } + if (!entry.available.length || entry.available.length !== entry.episodes.length) { + entry.note = entry.available.length + " / " + entry.episodes.length + " " + + text("Folgen in dieser Sprache verfügbar. Wähle eine andere Sprache oder nimm diese Serie aus der Auswahl.", + "episodes available in this language. Choose another language or deselect this series."); + return; + } + try { + const map = await api.providers(entry.available[0], ctx); + if (current !== ctx || ctx.seq !== seq) return; + entry.hosters = map[chosenLanguage] || []; + entry.note = entry.available.length + " " + text("Folgen bereit", "episodes ready"); + } catch (error) { if (current === ctx && ctx.seq === seq) entry.note = error.message; } + })); + if (current !== ctx || ctx.seq !== seq) return; + } + entries.forEach(function (entry) { + if (!entry.hosters.length) blocked++; + shared = shared === null ? entry.hosters.slice() : shared.filter(function (h) { return entry.hosters.includes(h); }); + }); + options(provider, shared || [], "VOE"); + submit.disabled = !entries.length || blocked > 0 || !provider.value; + status.textContent = !entries.length ? text("Nichts ausgewählt.", "Nothing selected.") + : blocked ? text("Nicht alle ausgewählten Serien sind mit dieser Sprache verfügbar. Prüfe die Hinweise in der Liste.", + "Not all selected series are available in this language. Check the notes below.") + : !provider.value ? text("Kein gemeinsamer Anbieter verfügbar. Ändere die Auswahl oder lade die Serien einzeln.", + "No shared provider available. Change the selection or download the series individually.") + : entries.length + " " + text("Serien bereit. Die ausgewählten Folgen werden gemeinsam eingereiht.", + "series ready. The selected episodes will be queued together."); + render(ctx); + } + language.addEventListener("change", function () { if (current) refresh(current); }); + submit.addEventListener("click", async function () { + const ctx = current; + if (!ctx || ctx.submitting || submit.disabled) return; + const entries = selected(ctx), chosenLanguage = language.value, hoster = provider.value; + ctx.submitting = true; + submit.disabled = language.disabled = provider.disabled = close.disabled = true; + render(ctx); + let success = 0; + for (const entry of entries) { + status.textContent = text("Füge zur Warteschlange hinzu: ", "Adding to queue: ") + entry.item.title; + try { + await api.enqueue(entry, entry.available, chosenLanguage, hoster, ctx); + entry.done = true; entry.included = false; success++; + entry.note = entry.available.length + " " + text("Folgen zur Warteschlange hinzugefügt", "episodes added to queue"); + if (ctx.onQueued) ctx.onQueued(entry.item); + } catch (error) { entry.note = text("Fehlgeschlagen: ", "Failed: ") + error.message; } + render(ctx); + } + ctx.submitting = false; ctx.finished = true; close.disabled = false; + status.textContent = success + " / " + entries.length + " " + text("Serien zur Warteschlange hinzugefügt.", "series added to queue."); + submit.textContent = text("Abgeschlossen", "Finished"); + render(ctx); + }); + function dismiss() { + if (!current || current.submitting) return; + current.cancel.abort(); current = null; overlay.style.display = "none"; + document.body.style.overflow = previousOverflow; + if (previousFocus && previousFocus.isConnected) previousFocus.focus(); + } + close.addEventListener("click", dismiss); + overlay.addEventListener("click", function (event) { if (event.target === overlay) dismiss(); }); + overlay.addEventListener("keydown", function (event) { + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); dismiss(); } + if (event.key !== "Tab") return; + const controls = [...dialog.querySelectorAll("button, select, input")].filter(function (el) { return !el.disabled; }); + const first = controls[0], last = controls[controls.length - 1]; + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { + event.preventDefault(); last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === dialog)) { + event.preventDefault(); first.focus(); + } + }); +})(); diff --git a/src/mediaforge/web/static/gap_downloads.js b/src/mediaforge/web/static/gap_downloads.js new file mode 100644 index 0000000..f6928e5 --- /dev/null +++ b/src/mediaforge/web/static/gap_downloads.js @@ -0,0 +1,198 @@ +(function () { + function text(de, en) { return window.t(de, en); } + const cache = new Map(); + const cacheLifetime = 5 * 60 * 1000; + let active = 0; + const waiting = []; + async function acquire(signal) { + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + if (active < 3) { active++; return; } + await new Promise(function (resolve, reject) { + const entry = { resolve: resolve, reject: reject }; + entry.abort = function () { + const index = waiting.indexOf(entry); + if (index !== -1) waiting.splice(index, 1); + reject(new DOMException("Aborted", "AbortError")); + }; + signal.addEventListener("abort", entry.abort, { once: true }); + entry.signal = signal; + waiting.push(entry); + }); + } + function release() { + const next = waiting.shift(); + if (next) { + next.signal.removeEventListener("abort", next.abort); + next.resolve(); + } else active--; + } + async function request(url, ctx, body) { + const cacheable = url === "/api/search" || /^\/api\/(series|seasons|episodes|providers)\?/.test(url); + const key = url + (body ? JSON.stringify(body) : ""); + const cached = cacheable && cache.get(key); + if (ctx.cancel.signal.aborted) throw new DOMException("Aborted", "AbortError"); + if (cached && cached.expires > Date.now()) return cached.data; + if (cached) cache.delete(key); + await acquire(ctx.cancel.signal); + const controller = new AbortController(); + const abort = function () { controller.abort(); }; + ctx.cancel.signal.addEventListener("abort", abort, { once: true }); + if (ctx.cancel.signal.aborted) controller.abort(); + const timeout = setTimeout(abort, 30000); + try { + const response = await fetch(url, { + method: body ? "POST" : "GET", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + if (!response.ok) { + let message = "HTTP " + response.status; + if ((response.headers.get("content-type") || "").includes("application/json")) { + try { const data = await response.json(); if (data.error) message = data.error; } catch (_) {} + } + throw new Error(message); + } + const data = await response.json(); + if (data.error) throw new Error(data.error); + if (cacheable) { + if (cache.size >= 500) cache.delete(cache.keys().next().value); + cache.set(key, { data: data, expires: Date.now() + cacheLifetime }); + } + return data; + } finally { + clearTimeout(timeout); + ctx.cancel.signal.removeEventListener("abort", abort); + release(); + } + } + function normalTitle(value) { + return String(value || "").trim().replace(/\s+/g, " ").toLocaleLowerCase(); + } + function searchTitle(value) { + let result = String(value || "").trim(); + const suffix = /\s*(?:[\[{](?:imdb|tmdb|tvdb)(?:id)?[-:=\s]+(?:tt)?\d+[\]}]|\(\d{4}(?:\s*[-–—]\s*(?:\d{4})?)?\))\s*$/i; + while (suffix.test(result)) result = result.replace(suffix, "").trim(); + return result; + } + function imdbId(value) { + const match = String(value || "").match(/\btt\d+\b/i); + return match ? match[0].toLowerCase() : ""; + } + async function sources(ctx) { + const [catalogue, settings] = await Promise.all([ + request("/api/search/sources", ctx), request("/api/settings/client", ctx), + ]); + const enabled = ((settings || {}).sources || {}).enabled || {}; + const order = catalogue.order || []; + return (catalogue.sources || []).filter(function (source) { + const flag = enabled[source.id]; + const on = flag === undefined ? !!source.enabled : source.adult ? flag === "1" : flag !== "0"; + return source.searchable !== false && on; + }).sort(function (a, b) { + const ia = order.indexOf(a.id), ib = order.indexOf(b.id); + return (ia < 0 ? order.length : ia) - (ib < 0 ? order.length : ib); + }); + } + async function find(item, ctx, catalogue) { + const keyword = searchTitle(item.title); + const results = await Promise.allSettled(catalogue.map(async function (source) { + const data = await request("/api/search", ctx, { keyword: keyword, site: source.id }); + return (data.results || []).filter(function (hit) { return hit.url && !hit.is_movie; }) + .map(function (hit) { return { ...hit, sourceLabel: source.label }; }); + })); + if (ctx.cancel.signal.aborted) throw new DOMException("Aborted", "AbortError"); + const hits = results.flatMap(function (r) { return r.status === "fulfilled" ? r.value : []; }); + function titleMatches(hit) { + const key = normalTitle(hit.title); + return key === normalTitle(item.title) || key === normalTitle(keyword); + } + hits.sort(function (a, b) { return Number(titleMatches(b)) - Number(titleMatches(a)); }); + const folderId = item.title.match(/[\[{]imdb(?:id)?[-:=\s]+(tt\d+)[\]}]/i); + const expectedId = imdbId(item.imdb_id) || imdbId(folderId && folderId[1]); + let match = expectedId ? -1 : hits.findIndex(titleMatches); + const conflictingIds = new Set(); + // Check identities in small batches; provider titles can use another language. + for (let i = 0; expectedId && match === -1 && i < hits.length; i += 3) { + if (ctx.cancel.signal.aborted) throw new DOMException("Aborted", "AbortError"); + const matches = await Promise.all(hits.slice(i, i + 3).map(async function (hit) { + try { + const data = await request("/api/series?url=" + encodeURIComponent(hit.url), ctx); + const foundId = imdbId(data.imdb_id); + if (foundId && foundId !== expectedId) conflictingIds.add(hit); + return !data.is_movie && foundId === expectedId; + } catch (error) { return false; } + })); + const index = matches.indexOf(true); + if (index !== -1) match = i + index; + } + if (ctx.cancel.signal.aborted) throw new DOMException("Aborted", "AbortError"); + if (match === -1) { + const titleHits = hits.map(function (hit, index) { return titleMatches(hit) && !conflictingIds.has(hit) ? index : -1; }).filter(function (index) { return index !== -1; }); + if (titleHits.length === 1) match = titleHits[0]; + } + return { hits: hits, match: match, failed: results.some(function (r) { return r.status === "rejected"; }) }; + } + async function episodes(item, hit, ctx) { + const missing = item.missing_slots || item.missing || []; + const ignored = item.ignored_slots || []; + const data = await request("/api/seasons?url=" + encodeURIComponent(hit.url), ctx); + const seasons = (data.seasons || []).filter(function (season) { + return !season.are_movies && missing.some(function (slot) { + return slot === "S" + season.season_number || slot.startsWith("S" + season.season_number + "E"); + }); + }); + const out = []; + for (let i = 0; i < seasons.length; i += 3) { + const batch = await Promise.all(seasons.slice(i, i + 3).map(async function (season) { + const result = await request("/api/episodes?url=" + encodeURIComponent(season.url), ctx); + const seasonSlot = "S" + season.season_number; + return (result.episodes || []).filter(function (ep) { + const slot = seasonSlot + "E" + ep.episode_number; + return ep.url && !ep.downloaded && !ignored.includes(slot) && !ignored.includes(seasonSlot) && + (missing.includes(slot) || missing.includes(seasonSlot)); + }).map(function (ep) { return { ...ep, slot: seasonSlot + "E" + ep.episode_number }; }); + })); + out.push(...batch.flat()); + } + return out; + } + function available(episodes, language) { + return episodes.filter(function (ep) { + return !ep.languages || !ep.languages.length || ep.languages.includes(language); + }); + } + async function providers(episode, ctx) { + const data = await request("/api/providers?url=" + encodeURIComponent(episode.url), ctx); + return data.providers || {}; + } + async function prepare(item, ctx, catalogue) { + // Try sources in priority order. Batch downloads need one match, not a + // complete list of every provider that carries the series. + let result = { match: -1 }; + for (const source of catalogue) { + result = await find(item, ctx, [source]); + if (result.match !== -1) break; + } + if (result.match === -1) throw new Error(text("Serie nicht eindeutig zugeordnet.", "Series could not be matched unambiguously.")); + const hit = result.hits[result.match]; + const found = await episodes(item, hit, ctx); + if (!found.length) throw new Error(text("Keine nachladbaren Lücken gefunden.", "No downloadable gaps found.")); + const unavailable = (item.missing_slots || item.missing || []).filter(function (slot) { + return /^S\d+E\d+$/.test(slot) && !found.some(function (ep) { return ep.slot === slot; }); + }); + return { item: item, hit: hit, episodes: found, unavailable: unavailable }; + } + async function enqueue(entry, selected, language, provider, ctx) { + const result = await request("/api/download", ctx, { + series_url: entry.hit.url, title: entry.hit.title, + episodes: selected.map(function (ep) { return ep.url; }), language: language, provider: provider, + }); + for (const key of cache.keys()) { + if (/^\/api\/(seasons|episodes|providers)\?/.test(key)) cache.delete(key); + } + return result; + } + window.MFGapDownloads = { request: request, sources: sources, find: find, episodes: episodes, + available: available, providers: providers, prepare: prepare, enqueue: enqueue }; +})(); diff --git a/src/mediaforge/web/static/home_feed.js b/src/mediaforge/web/static/home_feed.js index 6592e03..ef117f3 100644 --- a/src/mediaforge/web/static/home_feed.js +++ b/src/mediaforge/web/static/home_feed.js @@ -863,12 +863,8 @@ }).join(""); grid.querySelectorAll("[data-gap]").forEach(function (btn) { btn.addEventListener("click", function () { - // Hand the title to the ordinary search: it is the one place that - // knows which of the enabled sources actually has this series, and - // duplicating that decision here would be a second, worse answer. - const input = document.getElementById("searchInput"); - if (input) input.value = btn.dataset.gap; - if (typeof doSearch === "function") doSearch(); + const item = gaps.find(function (gap) { return gap.title === btn.dataset.gap; }); + if (item) window.openGapFill(item); }); }); // "Not interested in this one." Writes the same media_ignored entry the diff --git a/src/mediaforge/web/static/home_gaps.css b/src/mediaforge/web/static/home_gaps.css new file mode 100644 index 0000000..836ad41 --- /dev/null +++ b/src/mediaforge/web/static/home_gaps.css @@ -0,0 +1,39 @@ +.gap-fill-modal { + max-height: calc(100dvh - 48px); + overflow-y: auto; + overflow-wrap: anywhere; +} +.gap-fill-fields { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; + margin: 20px 0; +} +.gap-fill-fields select { + width: 100%; + min-width: 0; + padding: 8px; + color: var(--text-primary); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} +.gap-fill-episodes { + max-height: 35dvh; + overflow-y: auto; + padding: 0; + list-style: none; + line-height: 1.8; +} +.gap-fill-episodes label, .gap-fill-select-all, .gap-batch-entries label { + display: flex; + align-items: baseline; + gap: 10px; + padding: 6px 0; +} +.gap-fill-select-all { margin-top: 16px; } +.gap-batch-entries { list-style: none; padding: 0; margin: 16px 0; } +.gap-batch-entries li { padding: 10px 0; border-bottom: 1px solid var(--border); } +.gap-batch-entries small { display: block; color: var(--text-secondary); padding-left: 26px; } +.gap-fill-modal .dl-modal-actions { flex-wrap: wrap; } diff --git a/src/mediaforge/web/static/home_gaps.js b/src/mediaforge/web/static/home_gaps.js new file mode 100644 index 0000000..4fe2c22 --- /dev/null +++ b/src/mediaforge/web/static/home_gaps.js @@ -0,0 +1,207 @@ +(function () { + const overlay = document.getElementById("gapFillOverlay"); + if (!overlay) return; + const api = window.MFGapDownloads; + const dialog = overlay.querySelector('[role="dialog"]'); + const title = document.getElementById("gapFillTitle"); + const slots = document.getElementById("gapFillSlots"); + const status = document.getElementById("gapFillStatus"); + const source = document.getElementById("gapFillSource"); + const language = document.getElementById("gapFillLanguage"); + const provider = document.getElementById("gapFillProvider"); + const list = document.getElementById("gapFillEpisodes"); + const selectAll = document.getElementById("gapFillSelectAll"); + const submit = document.getElementById("gapFillSubmit"); + const submitAll = document.getElementById("gapFillAllSubmit"); + const close = document.getElementById("gapFillClose"); + let current = null; + let previousFocus; + let previousOverflow; + function text(de, en) { return window.t(de, en); } + function message(value) { status.textContent = value; } + function options(select, values, preferred) { + select.replaceChildren(); + values.forEach(function (value) { select.add(new Option(value, value)); }); + if (values.includes(preferred)) select.value = preferred; + select.disabled = !values.length; + } + function reset() { + options(language, []); + options(provider, []); + list.replaceChildren(); + selectAll.checked = selectAll.indeterminate = false; + selectAll.disabled = submit.disabled = submitAll.disabled = true; + submit.textContent = text("Auswahl zur Warteschlange", "Queue selected"); + submitAll.textContent = text("Alle fehlenden zur Warteschlange", "Queue all missing"); + document.getElementById("gapFillSelectAllLabel").textContent = text("Alle fehlenden auswählen", "Select all missing"); + } + function report(error, ctx) { + if (current === ctx && !ctx.cancel.signal.aborted) { + message(text("Anfrage fehlgeschlagen: ", "Request failed: ") + error.message); + } + } + function available(ctx) { return api.available(ctx.episodes, language.value); } + function selected(ctx) { return available(ctx).filter(function (ep) { return ctx.selected.has(ep.url); }); } + function updateSelection(ctx) { + const episodes = available(ctx), count = selected(ctx).length; + selectAll.checked = episodes.length > 0 && count === episodes.length; + selectAll.indeterminate = count > 0 && count < episodes.length; + selectAll.disabled = ctx.submitting || !episodes.length; + submit.disabled = ctx.submitting || !ctx.ready || !count; + submitAll.disabled = ctx.submitting || !ctx.ready || !episodes.length; + if (ctx.ready && !ctx.submitting) message(count + " / " + episodes.length + " " + + text("verfügbare fehlende Episoden ausgewählt.", "available missing episodes selected.")); + } + function renderEpisodes(ctx) { + list.replaceChildren(); + available(ctx).forEach(function (ep) { + const li = document.createElement("li"), label = document.createElement("label"); + const box = document.createElement("input"); + box.type = "checkbox"; + box.className = "chb-main"; + box.checked = ctx.selected.has(ep.url); + box.disabled = !!ctx.submitting; + box.addEventListener("change", function () { + if (box.checked) ctx.selected.add(ep.url); else ctx.selected.delete(ep.url); + updateSelection(ctx); + }); + label.append(box, document.createTextNode(ep.slot + + (ep.title_de || ep.title_en ? " — " + (ep.title_de || ep.title_en) : ""))); + li.appendChild(label); + list.appendChild(li); + }); + updateSelection(ctx); + } + window.openGapFill = async function (item) { + if (current && current.submitting) return; + if (current) current.cancel.abort(); + else { previousFocus = document.activeElement; previousOverflow = document.body.style.overflow; } + const ctx = { item: item, cancel: new AbortController(), episodes: [], selected: new Set(), queued: new Set() }; + current = ctx; + reset(); + options(source, []); + title.textContent = text("Lücken füllen: ", "Fill gaps: ") + item.title; + slots.textContent = (item.missing_slots || item.missing || []).join(", "); + message(text("Suche und ordne die Serie zu …", "Searching and matching the series …")); + overlay.style.display = "block"; + document.body.style.overflow = "hidden"; + dialog.focus(); + try { + const catalogue = await api.sources(ctx); + const result = await api.find(item, ctx, catalogue); + if (current !== ctx) return; + ctx.hits = result.hits; + source.add(new Option(text("Serie auswählen", "Choose series"), "")); + ctx.hits.forEach(function (hit, i) { source.add(new Option(hit.sourceLabel + " — " + hit.title, String(i))); }); + source.disabled = !ctx.hits.length; + message(ctx.hits.length + ? text("Wähle die passende Serie und Quelle.", "Choose the matching series and source.") + : result.failed ? text("Die Suche ist teilweise fehlgeschlagen.", "Some searches failed.") + : text("Keine Serie in den aktivierten Quellen gefunden.", "No series found in the enabled sources.")); + if (result.match !== -1) { source.value = String(result.match); await loadEpisodes(ctx); } + } catch (error) { report(error, ctx); } + }; + async function loadEpisodes(ctx) { + if (ctx.lookup) ctx.lookup.cancel.abort(); + const lookup = { cancel: new AbortController() }; + ctx.lookup = lookup; + ctx.cancel.signal.addEventListener("abort", function () { lookup.cancel.abort(); }, { once: true }); + reset(); + ctx.episodes = []; + ctx.ready = false; + ctx.hit = source.value === "" ? null : ctx.hits[Number(source.value)]; + if (!ctx.hit) { message(text("Wähle eine Serie.", "Choose a series.")); return; } + message(text("Prüfe fehlende Episoden …", "Checking missing episodes …")); + try { + const episodes = await api.episodes(ctx.item, ctx.hit, lookup); + if (current !== ctx || ctx.lookup !== lookup) return; + ctx.episodes = episodes.filter(function (ep) { return !ctx.queued.has(ep.slot); }); + ctx.selected = new Set(ctx.episodes.map(function (ep) { return ep.url; })); + if (!ctx.episodes.length) { message(text("Keine nachladbaren Lücken gefunden.", "No downloadable gaps found.")); return; } + const langs = [...new Set(ctx.episodes.flatMap(function (ep) { return ep.languages || []; }))]; + if (!langs.length) langs.push(...Object.keys(await api.providers(ctx.episodes[0], lookup))); + if (current !== ctx || ctx.lookup !== lookup) return; + const existing = document.getElementById("languageSelect"); + options(language, langs, (existing && existing.value) || "German Dub"); + await loadProviders(ctx); + } catch (error) { if (ctx.lookup === lookup && !lookup.cancel.signal.aborted) report(error, ctx); } + } + async function loadProviders(ctx) { + const lookup = ctx.lookup, chosenLanguage = language.value; + const seq = ctx.providerSeq = (ctx.providerSeq || 0) + 1; + ctx.ready = false; + submit.textContent = text("Auswahl zur Warteschlange", "Queue selected"); + options(provider, []); + renderEpisodes(ctx); + const episodes = available(ctx); + if (!chosenLanguage || !episodes.length) { message(text("Keine Episoden in dieser Sprache verfügbar.", "No episodes available in this language.")); return; } + message(text("Prüfe Downloadquellen …", "Checking download providers …")); + try { + const providers = await api.providers(episodes[0], lookup); + if (current !== ctx || ctx.lookup !== lookup || ctx.providerSeq !== seq) return; + const existing = document.getElementById("providerSelect"); + options(provider, providers[chosenLanguage] || [], (existing && existing.value) || "VOE"); + ctx.ready = !!provider.value; + updateSelection(ctx); + if (!ctx.ready) message(text("Keine Downloadquelle für diese Sprache verfügbar.", "No download provider available for this language.")); + } catch (error) { if (ctx.lookup === lookup && ctx.providerSeq === seq) report(error, ctx); } + } + source.addEventListener("change", function () { if (current) loadEpisodes(current); }); + language.addEventListener("change", function () { if (current) loadProviders(current); }); + selectAll.addEventListener("change", function () { + if (!current || current.submitting) return; + available(current).forEach(function (ep) { + if (selectAll.checked) current.selected.add(ep.url); else current.selected.delete(ep.url); + }); + renderEpisodes(current); + }); + async function enqueue(all) { + const ctx = current; + if (!ctx || ctx.submitting || (all ? submitAll.disabled : submit.disabled)) return; + const episodes = all ? available(ctx) : selected(ctx); + ctx.submitting = true; + source.disabled = language.disabled = provider.disabled = close.disabled = true; + renderEpisodes(ctx); + message(text("Füge Episoden zur Warteschlange hinzu …", "Adding episodes to the queue …")); + try { + await api.enqueue(ctx, episodes, language.value, provider.value, ctx); + episodes.forEach(function (ep) { ctx.queued.add(ep.slot); ctx.selected.delete(ep.url); }); + ctx.episodes = ctx.episodes.filter(function (ep) { return !ctx.queued.has(ep.slot); }); + ctx.submitting = false; + if (ctx.episodes.length) { + source.disabled = language.disabled = provider.disabled = false; + await loadProviders(ctx); + } else { + ctx.ready = false; + renderEpisodes(ctx); + submit.textContent = submitAll.textContent = text("Hinzugefügt", "Added"); + } + if (!ctx.ready) submit.textContent = text("Hinzugefügt", "Added"); + message(text("Zur Download-Warteschlange hinzugefügt.", "Added to download queue.")); + } catch (error) { ctx.ready = false; report(error, ctx); } + finally { ctx.submitting = false; close.disabled = false; } + } + submit.addEventListener("click", function () { enqueue(false); }); + submitAll.addEventListener("click", function () { enqueue(true); }); + function dismiss() { + if (!current || current.submitting) return; + current.cancel.abort(); current = null; + overlay.style.display = "none"; + document.body.style.overflow = previousOverflow; + if (previousFocus && previousFocus.isConnected) previousFocus.focus(); + } + close.addEventListener("click", dismiss); + overlay.addEventListener("click", function (event) { if (event.target === overlay) dismiss(); }); + overlay.addEventListener("keydown", function (event) { + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); dismiss(); } + if (event.key !== "Tab") return; + const controls = [...dialog.querySelectorAll("button, select, input")].filter(function (el) { return !el.disabled; }); + const first = controls[0], last = controls[controls.length - 1]; + if (!first) { event.preventDefault(); return; } + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { + event.preventDefault(); last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === dialog)) { + event.preventDefault(); first.focus(); + } + }); +})(); diff --git a/src/mediaforge/web/static/home_panels.js b/src/mediaforge/web/static/home_panels.js index 4bb2f03..afd1279 100644 --- a/src/mediaforge/web/static/home_panels.js +++ b/src/mediaforge/web/static/home_panels.js @@ -649,7 +649,7 @@ head(HT("dash_gaps"), HT("dash_gap_count").replace("{}", String(total)), OWN_ICONS.gaps), '' + paged.pagerHtml, - '' + esc(HT("dash_gaps_all")) + ' ›'); + '' + esc(HT("dash_gaps_all")) + ' ›'); } /** Same hashing home_feed.js and the Library page use for placeholder art, @@ -971,11 +971,10 @@ } ev.preventDefault(); if (el.hasAttribute("data-gap-search")) { - // Hand the title to the ordinary search: it is the one place that - // knows which of the enabled sources actually has this series. - const input = document.getElementById("searchInput"); - if (input) input.value = el.getAttribute("data-gap-search"); - if (typeof window.doSearch === "function") window.doSearch(); + const item = (feedPersonal.gaps || []).find(function (gap) { + return gap.title === el.getAttribute("data-gap-search"); + }); + if (item) window.openGapFill(item); return; } ignoreGap(el); diff --git a/src/mediaforge/web/static/stats.js b/src/mediaforge/web/static/stats.js index 52f2cb0..666a09f 100644 --- a/src/mediaforge/web/static/stats.js +++ b/src/mediaforge/web/static/stats.js @@ -684,6 +684,7 @@ function _renderSpeedTable() { * keyed by folder and survives paging, filtering and tab switches. */ var _ignSel = { series: {}, slots: {}, titles: {} }; +var _gapRows = Object.create(null); function _ignHasSelection() { return Object.keys(_ignSel.series).length > 0 || @@ -715,12 +716,14 @@ function statsIgnToggleSeries(box) { if (!row) return; var folder = row.getAttribute("data-folder"); if (box.checked) { - _ignSel.series[folder] = true; + _ignSel.series[folder] = ((_gapRows[folder] || {}).missing || []).slice(); + delete _ignSel.slots[folder]; // Remember the title with the selection: the full series list no longer // lives in the browser, so it cannot be looked up at submit time. _ignSel.titles[folder] = row.getAttribute("data-title") || folder; } else { delete _ignSel.series[folder]; + delete _ignSel.slots[folder]; } _updateIgnActions(); } @@ -732,6 +735,10 @@ function statsIgnToggleSlot(box) { var slot = box.getAttribute("data-slot"); var checked = box.checked; _ignSel.titles[folder] = row.getAttribute("data-title") || folder; + if (_ignSel.series[folder]) { + _ignSel.slots[folder] = _ignSel.series[folder].slice(); + delete _ignSel.series[folder]; + } var list = _ignSel.slots[folder] || (_ignSel.slots[folder] = []); var at = list.indexOf(slot); if (checked && at === -1) list.push(slot); @@ -747,14 +754,40 @@ function statsIgnClearSelection() { /** Keep the action bar's counter/disabled state in sync without a re-render. */ function _updateIgnActions() { var btn = document.getElementById("ignApplyBtn"); + var downloadBtn = document.getElementById("gapDownloadSelectedBtn"); var info = document.getElementById("ignSelInfo"); var n = _ignSelectionCount(); if (btn) btn.disabled = !n; + if (downloadBtn) downloadBtn.disabled = !n; if (info) { info.textContent = n ? t(n + " ausgewählt", n + " selected") : t("Nichts ausgewählt", "Nothing selected"); } + document.querySelectorAll("#incompleteBody tr[data-folder]").forEach(function (row) { + var folder = row.dataset.folder; + var whole = !!_ignSel.series[folder]; + var picked = _ignSel.slots[folder] || []; + var slots = row.querySelectorAll(".ign-slot"); + var count = 0; + slots.forEach(function (box) { + box.checked = whole || picked.indexOf(box.dataset.slot) !== -1; + if (box.checked) count++; + }); + var series = row.querySelector(".ign-series"); + if (series) { + series.checked = whole || (slots.length > 0 && count === slots.length); + series.indeterminate = count > 0 && count < slots.length; + } + }); + var seriesBoxes = Array.from(document.querySelectorAll("#incompleteBody .ign-series")); + var pageSelection = document.querySelector("#incompleteTableHost thead input"); + if (pageSelection) { + pageSelection.checked = seriesBoxes.length > 0 && seriesBoxes.every(function (box) { return box.checked; }); + pageSelection.indeterminate = !pageSelection.checked && seriesBoxes.some(function (box) { + return box.checked || box.indeterminate; + }); + } } function toggleAllIncomplete(cb) { @@ -763,10 +796,11 @@ function toggleAllIncomplete(cb) { document.querySelectorAll("#incompleteBody tr[data-folder]").forEach(function (tr) { var folder = tr.dataset.folder; if (cb.checked) { - _ignSel.series[folder] = true; + _ignSel.series[folder] = ((_gapRows[folder] || {}).missing || []).slice(); _ignSel.titles[folder] = tr.dataset.title || folder; } else { delete _ignSel.series[folder]; + delete _ignSel.slots[folder]; } var box = tr.querySelector(".ign-series"); if (box) box.checked = cb.checked; @@ -801,10 +835,11 @@ function _incompleteTableHtml(info) { ''; info.items.forEach(function (item) { var miss = item.missing || []; + _gapRows[item.folder] = item; var seriesChecked = !!_ignSel.series[item.folder]; var picked = _ignSel.slots[item.folder] || []; var chips = miss.map(function (sl) { - var on = picked.indexOf(sl) !== -1 ? " checked" : ""; + var on = seriesChecked || picked.indexOf(sl) !== -1 ? " checked" : ""; return '"; @@ -812,11 +847,13 @@ function _incompleteTableHtml(info) { mh += '' + '' + - escHtml(t("Ganze Serie ignorieren", "Ignore whole series")) + "" + + escHtml(t("Alle Lücken dieser Serie auswählen", "Select all gaps of this series")) + '">' + + escHtml(t("Serie auswählen", "Select series")) + "" + '' + escHtml(item.title) + "" + - '' + miss.length + " " + escHtml(t("fehlend", "missing")) + "" + + '' + miss.length + " " + escHtml(t("fehlend", "missing")) + "" + + '" + '' + escHtml(item.location) + "" + '
' + chips + "
"; @@ -828,7 +865,9 @@ function _incompleteTableHtml(info) { '" + '"; + escHtml(t("Auswahl ignorieren", "Ignore selected")) + "" + + '"; return mh; } @@ -1148,17 +1187,40 @@ function _renderDuplicatesTable(info) { // Ignore / restore actions // --------------------------------------------------------------- +function statsOpenGapFill(button) { + var row = _ignRowOf(button); + var item = row && _gapRows[row.dataset.folder]; + if (item) window.openGapFill(item); +} + +function statsDownloadSelectedGaps() { + var folders = new Set(Object.keys(_ignSel.series).concat(Object.keys(_ignSel.slots))); + var items = []; + folders.forEach(function (folder) { + var row = _gapRows[folder]; + var slots = _ignSel.series[folder] || _ignSel.slots[folder]; + if (row && slots && slots.length) items.push(Object.assign({}, row, { missing_slots: slots.slice() })); + }); + window.openGapFillBatch(items, function (item) { + delete _ignSel.series[item.folder]; + delete _ignSel.slots[item.folder]; + delete _ignSel.titles[item.folder]; + _updateIgnActions(); + }); +} + function mediaIgnoreSelected() { // Read from _ignSel, not the DOM: with pagination the checkboxes of every // other page do not exist, so a DOM scan would drop those selections. var items = {}; Object.keys(_ignSel.series).forEach(function (folder) { - items[folder] = { folder: folder, title: _ignSel.titles[folder] || folder, all: true }; + var slots = _ignSel.series[folder]; + if (slots.length) items[folder] = { folder: folder, title: _ignSel.titles[folder] || folder, slots: slots.slice() }; }); Object.keys(_ignSel.slots).forEach(function (folder) { var slots = _ignSel.slots[folder] || []; if (!slots.length) return; - if (items[folder] && items[folder].all) return; // whole series covers it + if (items[folder]) return; items[folder] = { folder: folder, title: _ignSel.titles[folder] || folder, slots: slots.slice() }; }); @@ -1631,3 +1693,14 @@ function renderStats(data, container) { // (mfLazyPanel, monitoring.html); the standalone page loads at once. if (window.mfLazyPanel) window.mfLazyPanel("stats", function () { loadStats(); }); else loadStats(); + +function openLinkedIncompleteModal() { + if (window.location.hash !== "#incomplete") return; + // The gap list has its own endpoint and also works with media stats disabled. + _modalView.incomplete.q = ""; + switchIncompleteView("incomplete"); + openIncompleteModal(); +} + +window.addEventListener("hashchange", openLinkedIncompleteModal); +openLinkedIncompleteModal(); diff --git a/src/mediaforge/web/templates/_gap_fill_modal.html b/src/mediaforge/web/templates/_gap_fill_modal.html new file mode 100644 index 0000000..9e4997f --- /dev/null +++ b/src/mediaforge/web/templates/_gap_fill_modal.html @@ -0,0 +1,42 @@ + + diff --git a/src/mediaforge/web/templates/index.html b/src/mediaforge/web/templates/index.html index d1a1c91..9876227 100644 --- a/src/mediaforge/web/templates/index.html +++ b/src/mediaforge/web/templates/index.html @@ -3,6 +3,7 @@ {% block styles %} + @@ -1237,11 +1238,15 @@

{{ _('Leave kids mode') }}

{% endif %} {% include "shared_modals.html" %} +{% include "_gap_fill_modal.html" %} {# Same "Could be for you" Details button as above -- markup only needed in the new_home layout. #} {% if new_home %}{% include "mf_detail_modal.html" %}{% endif %} {% endblock %} {% block scripts %} + + + {% if new_home %} @@ -1309,4 +1314,4 @@

{{ _('Leave kids mode') }}

})(); {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/mediaforge/web/templates/monitoring.html b/src/mediaforge/web/templates/monitoring.html index 17bd38c..c94b7f2 100644 --- a/src/mediaforge/web/templates/monitoring.html +++ b/src/mediaforge/web/templates/monitoring.html @@ -5,6 +5,8 @@ + + {# Module panels below use the shared settings-card markup, which the @@ -112,6 +114,10 @@

{{ t.label }}

{% endblock %} {% block scripts %} +{% include "_gap_fill_modal.html" %} + + + {# MFCharts must load before stats.js, which draws the Statistics panel. #} + + + {% endblock %} diff --git a/tests/test_gap_downloads_node.cjs b/tests/test_gap_downloads_node.cjs new file mode 100644 index 0000000..e4892c7 --- /dev/null +++ b/tests/test_gap_downloads_node.cjs @@ -0,0 +1,61 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const code = fs.readFileSync('src/mediaforge/web/static/gap_downloads.js', 'utf8'); +function setup(fetch) { + const window = {t: (_, en) => en}; + const context = {window, fetch, AbortController, DOMException, setTimeout, clearTimeout}; + vm.runInNewContext(code, context); + return window.MFGapDownloads; +} +function ctx() { return {cancel: new AbortController()}; } +function response(data, status = 200, type = 'application/json') { + return {ok: status < 400, status, headers: {get: () => type}, json: async () => data}; +} +test('proxy errors report HTTP status without parsing HTML', async () => { + const api = setup(async () => ({ok: false, status: 502, headers: {get: () => 'text/html'}, json: () => { throw Error('invalid JSON'); }})); + await assert.rejects(api.request('/api/series?url=x', ctx()), /HTTP 502/); +}); +test('successful searches are cached and failures are not', async () => { + let calls = 0; + const api = setup(async () => { calls++; return response({}, calls === 1 ? 503 : 200); }); + await assert.rejects(api.request('/api/search', ctx(), {keyword:'Example'})); + await api.request('/api/search', ctx(), {keyword:'Example'}); + await api.request('/api/search', ctx(), {keyword:'Example'}); + assert.equal(calls, 2); +}); +test('missing IMDb information falls back to an exact title', async () => { + const api = setup(async (url) => response(url === '/api/search' ? {results:[{title:'Example',url:'https://x'}]} : {imdb_id:''})); + const found = await api.find({title:'Example [imdbid-tt123]'}, ctx(), [{id:'x',label:'X'}]); + assert.equal(found.match, 0); +}); +test('requests are limited and waiting requests can be cancelled', async () => { + let active = 0, max = 0; + const api = setup(async () => { active++; max = Math.max(active, max); await new Promise(r => setTimeout(r, 10)); active--; return response({}); }); + await Promise.all(Array.from({length: 12}, (_, i) => api.request('/api/series?url=' + i, ctx()))); + assert.equal(max, 3); + const cancelled = ctx(); cancelled.cancel.abort(); + await assert.rejects(api.request('/api/series?url=x', cancelled), {name: 'AbortError'}); +}); +test('series selection sends missing slots instead of a permanent ignore', () => { + const stats = fs.readFileSync('src/mediaforge/web/static/stats.js', 'utf8'); + const start = stats.indexOf('function mediaIgnoreSelected()'); + const end = stats.indexOf('/** Restore one slot', start); + let payload; + const context = {_ignSel:{series:{folder:['S1E2','S2']},slots:{},titles:{folder:'Example'}}, _gapRows:{folder:{missing:['S1E2','S2']}}, fetch: (_, options) => { payload = JSON.parse(options.body); return {then: () => ({then: () => ({catch: () => {}})})}; }, Object}; + vm.runInNewContext(stats.slice(start, end) + '\nmediaIgnoreSelected();', context); + assert.deepEqual(JSON.parse(JSON.stringify(payload.items)), [{folder:'folder',title:'Example',slots:['S1E2','S2']}]); +}); +test('batch stops searching after a source matches', async () => { + const calls = []; + const api = setup(async (url, options) => { + calls.push([url, options && options.body]); + if (url === '/api/search') return response({results:[{title:'Example',url:'https://x'}]}); + if (url.startsWith('/api/seasons')) return response({seasons:[{season_number:1,url:'https://x/s1'}]}); + if (url.startsWith('/api/episodes')) return response({episodes:[{episode_number:2,url:'https://x/e2'}]}); + return response({}); + }); + await api.prepare({title:'Example',missing:['S1E2']}, ctx(), [{id:'one',label:'One'},{id:'two',label:'Two'}]); + assert.equal(calls.filter(call => call[0] === '/api/search').length, 1); +}); diff --git a/tests/test_home_gaps.py b/tests/test_home_gaps.py new file mode 100644 index 0000000..b8ed542 --- /dev/null +++ b/tests/test_home_gaps.py @@ -0,0 +1,45 @@ +"""Gap payloads used by the home page's download dialog.""" + +from mediaforge.web.routes.browse import _feed_gap_rows + + +def test_gap_dialog_receives_more_than_twelve_slots(monkeypatch): + monkeypatch.setattr("mediaforge.web.db.get_media_ignores", lambda: {}) + rows = _feed_gap_rows([{ + "folder": "Example", "seasons": {"1": [{"episode": 20}]}, + }]) + assert len(rows[0]["missing"]) == 12 + assert rows[0]["missing_slots"] == [f"S1E{n}" for n in range(1, 20)] + assert rows[0]["missing_count"] == 19 + + +def test_ignored_episode_in_missing_season_is_sent_to_dialog(monkeypatch): + monkeypatch.setattr("mediaforge.web.db.get_media_ignores", lambda: { + "example": {"slots": ["S1E2"]}, + }) + rows = _feed_gap_rows([{ + "folder": "Example", "seasons": {"2": [{"episode": 1}]}, + }]) + assert rows[0]["missing_slots"] == ["S1"] + assert rows[0]["ignored_slots"] == ["S1E2"] + + +def test_ignored_series_is_not_offered_for_gap_filling(monkeypatch): + monkeypatch.setattr("mediaforge.web.db.get_media_ignores", lambda: { + "example": {"slots": ["__all__"]}, + }) + assert not _feed_gap_rows([{ + "folder": "Example", "seasons": {"1": [{"episode": 3}]}, + }]) + + +def test_statistics_preserve_ignored_slots_for_missing_season_download(monkeypatch): + from mediaforge.web.routes import stats + + monkeypatch.setattr(stats, "_active_library_cache", lambda cache: cache) + monkeypatch.setattr(stats, "get_media_ignores", lambda: {"example": {"slots": ["S1E2"]}}) + result = stats._compute_media_stats({"test": {"data": {"titles": [{ + "folder": "Example", "seasons": {"2": [{"episode": 1, "path": "test.mkv"}]}, + }]}}}) + assert result["incomplete"][0]["missing"] == ["S1"] + assert result["incomplete"][0]["ignored_slots"] == ["S1E2"] diff --git a/tests/test_home_gaps_browser.py b/tests/test_home_gaps_browser.py new file mode 100644 index 0000000..adb3dea --- /dev/null +++ b/tests/test_home_gaps_browser.py @@ -0,0 +1,381 @@ +"""Opt-in browser checks: MEDIAFORGE_BROWSER_TESTS=1 pytest tests/test_home_gaps_browser.py. + +Uses Patchright's Chromium, or MEDIAFORGE_TEST_BROWSER for a local executable. +All provider and queue requests are intercepted; no downloads are started. +""" + +import json +import os +import re +from pathlib import Path + +import pytest +from jinja2 import Environment + +WEB = Path(__file__).resolve().parents[1] / "src/mediaforge/web" +pytestmark = pytest.mark.skipif( + os.environ.get("MEDIAFORGE_BROWSER_TESTS") != "1", + reason="opt-in browser tests", +) + + +@pytest.fixture() +def gap_page(): + from patchright.sync_api import sync_playwright + + downloads = [] + responses = { + "/api/search/sources": {"sources": [{"id": "test", "label": "Test", "enabled": True}]}, + "/api/settings/client": {}, + "/api/search": {"results": [{"title": "Example", "url": "https://source.test/example"}]}, + "/api/series": {"title": "Example", "imdb_id": "tt41298100"}, + "/api/seasons": {"seasons": [ + {"season_number": 1, "url": "https://source.test/s1"}, + {"season_number": 2, "url": "https://source.test/s2"}, + ]}, + "/api/episodes": {"episodes": [ + {"episode_number": 1, "url": "https://source.test/e1", "downloaded": True, + "languages": ["German Dub"]}, + {"episode_number": 2, "url": "https://source.test/e2", + "languages": ["German Dub", "English Sub"]}, + {"episode_number": 3, "url": "https://source.test/e3", + "languages": ["English Sub"]}, + {"episode_number": 4, "url": "https://source.test/e4", + "languages": ["German Dub"]}, + ]}, + "/api/providers": {"providers": {"German Dub": ["VOE"], "English Sub": ["VOE"]}}, + "/api/download": {"queue_id": 42}, + } + template = (WEB / "templates/_gap_fill_modal.html").read_text(encoding="utf-8") + html = Environment().from_string(template).render(_=lambda s: s) + css = "\n".join((WEB / "static" / name).read_text(encoding="utf-8") for name in ( + "variables.css", "base.css", "forms.css", "buttons.css", + "modals.css", "shared_modals.css", "home_gaps.css", + )) + with sync_playwright() as p: + executable = os.environ.get("MEDIAFORGE_TEST_BROWSER") + browser = p.chromium.launch(headless=True, executable_path=executable, timeout=15000) + page = browser.new_page(viewport={"width": 390, "height": 844}) + + def respond(route): + path = route.request.url.split("mediaforge.test", 1)[-1].split("?", 1)[0] + if path == "/": + route.fulfill(content_type="text/html", body= + '' + html) + return + if path not in responses: + route.fulfill(status=404, body="") + return + if path == "/api/download": + downloads.append(route.request.post_data_json) + body = responses[path] + if callable(body): + body = body(route.request) + route.fulfill(status=403 if body.get("error") else 200, + content_type="application/json", body=json.dumps(body)) + + page.route("**/*", respond) + page.goto("http://mediaforge.test/") + page.add_script_tag(content=""" + window.t = (de, en) => de; + window.loadSearchSources = async () => [{id: 'test', label: 'Test', enabled: true}]; + window.loadGeneralSettings = async () => ({}); + window._sourceIsOn = s => s.enabled; + """) + for name in ("gap_downloads.js", "home_gaps.js", "gap_batch.js"): + page.add_script_tag(content=(WEB / "static" / name).read_text(encoding="utf-8")) + yield page, responses, downloads + browser.close() + + +def open_gap(page, **overrides): + item = {"title": "Example", "missing_slots": ["S1E1", "S1E2", "S1E3"]} + item.update(overrides) + page.evaluate("item => window.openGapFill(item)", item, isolated_context=False) + + +def test_click_queues_only_missing_episodes_in_selected_language(gap_page): + page, _, downloads = gap_page + open_gap(page) + assert page.locator("#gapFillEpisodes li").all_text_contents() == ["S1E2"] + page.locator("#gapFillSubmit").click() + page.wait_for_function("document.querySelector('#gapFillSubmit').textContent === 'Hinzugefügt'") + assert downloads == [{ + "series_url": "https://source.test/example", "title": "Example", + "episodes": ["https://source.test/e2"], "language": "German Dub", "provider": "VOE", + }] + assert page.locator("#gapFillSubmit").is_disabled() + + +def test_whole_season_respects_ignored_and_existing_episodes(gap_page): + page, _, _ = gap_page + open_gap(page, missing_slots=["S1"], ignored_slots=["S1E2"]) + assert page.locator("#gapFillEpisodes li").all_text_contents() == ["S1E4"] + + +def test_language_change_updates_episodes_before_queuing(gap_page): + page, _, downloads = gap_page + open_gap(page) + page.locator("#gapFillLanguage").select_option("English Sub") + page.wait_for_function("!document.querySelector('#gapFillSubmit').disabled") + assert page.locator("#gapFillEpisodes li").all_text_contents() == ["S1E2", "S1E3"] + page.locator("#gapFillSubmit").click() + page.wait_for_function("document.querySelector('#gapFillSubmit').textContent === 'Hinzugefügt'") + assert downloads[0]["episodes"] == ["https://source.test/e2", "https://source.test/e3"] + + +def test_unrelated_search_result_requires_explicit_choice(gap_page): + page, responses, _ = gap_page + responses["/api/search"]["results"][0]["title"] = "Example sequel" + open_gap(page) + assert page.locator("#gapFillSource").input_value() == "" + assert page.locator("#gapFillSubmit").is_disabled() + page.locator("#gapFillSource").select_option("0") + page.wait_for_function("!document.querySelector('#gapFillSubmit').disabled") + + +def test_different_release_year_is_not_automatically_selected(gap_page): + page, responses, _ = gap_page + responses["/api/search"]["results"][0]["title"] = "Example (1990)" + open_gap(page, title="Example (2020)") + assert page.locator("#gapFillSource").input_value() == "" + assert page.locator("#gapFillSubmit").is_disabled() + + +@pytest.mark.parametrize("folder, expected", [ + ("Reborn as a Space Mercenary I Woke Up Piloting the Strongest Starship! " + "(2026-2026) [imdbid-tt41298100]", + "Reborn as a Space Mercenary I Woke Up Piloting the Strongest Starship!"), + ("Example (2026) [imdbid-tt41298100]", "Example"), + ("Example (2020-2026) [tmdbid-1234] [tvdbid-5678]", "Example"), + ("Example (2020–2026) {imdb-tt41298100}", "Example"), + ("Example (2020-) [tmdb-1234]", "Example"), + ("Example [imdbid-tt41298100] (2026)", "Example"), + ("[Oshi no Ko] (2023-2026) [imdbid-tt21030032]", "[Oshi no Ko]"), + ("Example (The Animation) [imdbid-tt41298100]", "Example (The Animation)"), +]) +def test_search_strips_library_metadata_and_preserves_display_title(gap_page, folder, expected): + page, responses, _ = gap_page + searches = [] + page.on("request", lambda req: searches.append(req.post_data_json) + if req.url.endswith("/api/search") else None) + responses["/api/search"]["results"][0]["title"] = expected + match = re.search(r"tt\d+", folder) + if match: + responses["/api/series"]["imdb_id"] = match[0] + open_gap(page, title=folder) + assert searches == [{"keyword": expected, "site": "test"}] + assert page.locator("#gapFillTitle").inner_text() == "Lücken füllen: " + folder + assert page.locator("#gapFillSubmit").is_enabled() + + +def test_alternative_provider_title_opens_matching_series_directly(gap_page): + page, responses, _ = gap_page + responses["/api/search"]["results"][0]["title"] = "Mezametara Saikyou" + open_gap(page, title="Reborn as a Space Mercenary (2026-2026) [imdbid-tt41298100]") + assert page.locator("#gapFillSource").input_value() == "0" + assert page.locator("#gapFillEpisodes li").all_text_contents() == ["S1E2"] + assert page.locator("#gapFillSubmit").is_enabled() + + +def test_imdb_mismatch_is_not_overridden_by_equal_title(gap_page): + page, responses, _ = gap_page + responses["/api/series"]["imdb_id"] = "tt99999999" + open_gap(page, title="Example (2026) [imdbid-tt41298100]") + assert page.locator("#gapFillSource").input_value() == "" + assert page.locator("#gapFillSubmit").is_disabled() + + +def test_verified_series_is_selected_instead_of_first_search_result(gap_page): + page, responses, _ = gap_page + responses["/api/search"]["results"] = [ + {"title": "Example", "url": "https://source.test/wrong"}, + {"title": "Alternative title", "url": "https://source.test/correct"}, + ] + responses["/api/series"] = lambda req: { + "imdb_id": "tt41298100" if "correct" in req.url else "tt99999999", + } + open_gap(page, title="Example (2026) [imdbid-tt41298100]") + assert page.locator("#gapFillSource").input_value() == "1" + assert page.locator("#gapFillSubmit").is_enabled() + + +def test_failed_episode_lookup_cannot_queue_partial_data(gap_page): + page, responses, downloads = gap_page + responses["/api/episodes"] = {"error": "Source unavailable"} + open_gap(page) + assert "Source unavailable" in page.locator("#gapFillStatus").inner_text() + assert page.locator("#gapFillSubmit").is_disabled() + assert not downloads + + +def test_queue_refusal_stays_visible_and_does_not_claim_success(gap_page): + page, responses, downloads = gap_page + responses["/api/download"] = {"error": "not permitted"} + open_gap(page) + page.locator("#gapFillSubmit").click() + page.wait_for_function("document.querySelector('#gapFillStatus').textContent.includes('not permitted')") + assert len(downloads) == 1 + assert page.locator("#gapFillSubmit").is_disabled() + assert page.locator("#gapFillClose").is_enabled() + + +def test_escape_restores_focus_and_scroll(gap_page): + page, _, _ = gap_page + page.locator("#opener").focus() + open_gap(page) + assert page.evaluate("document.body.style.overflow") == "hidden" + page.keyboard.press("Escape") + assert page.locator("#gapFillOverlay").is_hidden() + assert page.evaluate("document.activeElement.id") == "opener" + assert page.evaluate("document.body.style.overflow") == "" + + +def test_individual_episode_selection_queues_only_checked_episode(gap_page): + page, _, downloads = gap_page + open_gap(page) + page.locator("#gapFillLanguage").select_option("English Sub") + page.locator("#gapFillSubmit:enabled").wait_for() + page.locator("#gapFillEpisodes input").nth(1).uncheck() + assert page.locator("#gapFillSelectAll").evaluate("el => el.indeterminate") + page.locator("#gapFillSubmit").click() + page.wait_for_function("document.querySelectorAll('#gapFillEpisodes li').length === 1") + assert downloads[0]["episodes"] == ["https://source.test/e2"] + page.locator("#gapFillEpisodes input").check() + page.locator("#gapFillSubmit").click() + page.wait_for_function("document.querySelector('#gapFillSubmit').textContent === 'Hinzugefügt'") + assert downloads[1]["episodes"] == ["https://source.test/e3"] + + +def test_all_missing_button_works_with_empty_individual_selection(gap_page): + page, _, downloads = gap_page + open_gap(page) + page.locator("#gapFillLanguage").select_option("English Sub") + page.locator("#gapFillSubmit:enabled").wait_for() + page.locator("#gapFillSelectAll").uncheck() + assert page.locator("#gapFillSubmit").is_disabled() + assert page.locator("#gapFillAllSubmit").is_enabled() + page.locator("#gapFillAllSubmit").click() + page.wait_for_function("document.querySelector('#gapFillSubmit').textContent === 'Hinzugefügt'") + assert downloads[0]["episodes"] == ["https://source.test/e2", "https://source.test/e3"] + + +def batch_searches(responses): + responses["/api/search"] = lambda req: {"results": [{ + "title": req.post_data_json["keyword"], + "url": "https://source.test/" + req.post_data_json["keyword"], + }]} + + +def test_batch_queues_selected_slots_for_multiple_series(gap_page): + page, responses, downloads = gap_page + batch_searches(responses) + page.evaluate("""() => window.openGapFillBatch([ + {title:'First', folder:'first', missing_slots:['S1E2']}, + {title:'Second', folder:'second', missing_slots:['S1E4']} + ])""", isolated_context=False) + page.locator("#gapBatchSubmit:enabled").wait_for() + page.locator("#gapBatchSubmit").click() + page.wait_for_function("document.querySelector('#gapBatchSubmit').textContent === 'Abgeschlossen'") + assert [(d["title"], d["episodes"]) for d in downloads] == [ + ("First", ["https://source.test/e2"]), ("Second", ["https://source.test/e4"]), + ] + assert all(d["language"] == "German Dub" and d["provider"] == "VOE" for d in downloads) + + +def test_batch_language_must_cover_all_selected_episodes(gap_page): + page, responses, downloads = gap_page + batch_searches(responses) + page.evaluate("""() => window.openGapFillBatch([ + {title:'First', folder:'first', missing_slots:['S1E2','S1E3']} + ])""", isolated_context=False) + assert page.locator("#gapBatchSubmit").is_disabled() + page.locator("#gapBatchLanguage").select_option("English Sub") + page.locator("#gapBatchSubmit:enabled").wait_for() + page.locator("#gapBatchSubmit").click() + page.wait_for_function("document.querySelector('#gapBatchSubmit').textContent === 'Abgeschlossen'") + assert downloads[0]["episodes"] == ["https://source.test/e2", "https://source.test/e3"] + + +def test_batch_reports_partial_failure_and_only_clears_successful_series(gap_page): + page, responses, downloads = gap_page + batch_searches(responses) + responses["/api/download"] = lambda req: {"error": "not permitted"} if req.post_data_json["title"] == "Second" else {"queue_id": 42} + page.evaluate("""() => { + window.queuedFolders = []; + return window.openGapFillBatch([ + {title:'First', folder:'first', missing_slots:['S1E2']}, + {title:'Second', folder:'second', missing_slots:['S1E4']} + ], item => window.queuedFolders.push(item.folder)); + }""", isolated_context=False) + page.locator("#gapBatchSubmit:enabled").wait_for() + page.locator("#gapBatchSubmit").click() + page.wait_for_function("document.querySelector('#gapBatchSubmit').textContent === 'Abgeschlossen'") + assert page.evaluate("window.queuedFolders", isolated_context=False) == ["first"] + assert "not permitted" in page.locator("#gapBatchEntries").inner_text() + assert "1 / 2" in page.locator("#gapBatchStatus").inner_text() + assert len(downloads) == 2 + + +def test_batch_does_not_silently_skip_an_unavailable_selected_slot(gap_page): + page, responses, downloads = gap_page + batch_searches(responses) + page.evaluate("""() => window.openGapFillBatch([ + {title:'First', folder:'first', missing_slots:['S1E2','S1E9']} + ])""", isolated_context=False) + assert page.locator("#gapBatchSubmit").is_disabled() + assert "S1E9" in page.locator("#gapBatchEntries").inner_text() + assert not downloads + + +def test_batch_requires_a_shared_provider(gap_page): + page, responses, downloads = gap_page + batch_searches(responses) + responses["/api/providers"] = lambda req: {"providers": { + "German Dub": ["VOE"] if "e2" in req.url else ["Filemoon"], + }} + page.evaluate("""() => window.openGapFillBatch([ + {title:'First', folder:'first', missing_slots:['S1E2']}, + {title:'Second', folder:'second', missing_slots:['S1E4']} + ])""", isolated_context=False) + assert page.locator("#gapBatchSubmit").is_disabled() + assert "Kein gemeinsamer Anbieter" in page.locator("#gapBatchStatus").inner_text() + assert not downloads + + +def test_unmatched_series_can_be_removed_from_batch_without_guessing(gap_page): + page, responses, downloads = gap_page + responses["/api/search"] = lambda req: {"results": [{ + "title": "First" if req.post_data_json["keyword"] == "First" else "Unrelated", + "url": "https://source.test/example", + }]} + page.evaluate("""() => window.openGapFillBatch([ + {title:'First', folder:'first', missing_slots:['S1E2']}, + {title:'Second', folder:'second', missing_slots:['S1E4']} + ])""", isolated_context=False) + assert page.locator("#gapBatchSubmit").is_disabled() + page.locator("#gapBatchEntries input").nth(1).uncheck() + page.locator("#gapBatchSubmit:enabled").wait_for() + page.locator("#gapBatchSubmit").click() + page.wait_for_function("document.querySelector('#gapBatchSubmit').textContent === 'Abgeschlossen'") + assert len(downloads) == 1 + assert downloads[0]["title"] == "First" + + +@pytest.mark.parametrize("width", [390, 1280]) +def test_dialog_fits_viewport_and_keeps_keyboard_focus(gap_page, width): + page, _, _ = gap_page + page.set_viewport_size({"width": width, "height": 844}) + open_gap(page) + box = page.locator("#gapFillOverlay .gap-fill-modal").bounding_box() + assert box["x"] >= 0 + assert box["x"] + box["width"] <= width + assert box["y"] >= 0 + assert box["y"] + box["height"] <= 844 + page.locator("#gapFillSubmit").focus() + page.keyboard.press("Tab") + assert page.evaluate("document.activeElement.id") == "gapFillClose" + output = os.environ.get("MEDIAFORGE_SCREENSHOT_DIR") + if output: + page.screenshot(path=str(Path(output) / f"gap-fill-{width}.png")) diff --git a/tests/test_stats_gap_link_browser.py b/tests/test_stats_gap_link_browser.py new file mode 100644 index 0000000..ff4e373 --- /dev/null +++ b/tests/test_stats_gap_link_browser.py @@ -0,0 +1,218 @@ +"""Opt-in browser tests for the dashboard's complete gap list.""" + +import json +import os +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import pytest +from jinja2 import Environment + +WEB = Path(__file__).resolve().parents[1] / "src/mediaforge/web" +pytestmark = pytest.mark.skipif( + os.environ.get("MEDIAFORGE_BROWSER_TESTS") != "1", reason="opt-in browser tests", +) + + +@pytest.fixture() +def stats_page(): + from patchright.sync_api import sync_playwright + + body = Environment().from_string( + (WEB / "templates/_stats_body.html").read_text(encoding="utf-8") + + (WEB / "templates/_gap_fill_modal.html").read_text(encoding="utf-8"), + ).render(_=lambda s: s) + css = "\n".join((WEB / "static" / name).read_text(encoding="utf-8") for name in ( + "variables.css", "base.css", "forms.css", "buttons.css", "cards.css", + "modals.css", "stats.css", "shared_modals.css", "home_gaps.css", + )) + # Omit media statistics deliberately: the direct link must not require them. + payload = {"general": {}, "queue": {}, "sync": {}} + items = [{"folder": f"series-{i}", "title": f"Series {i:02}", "location": "Test", + "missing": [f"S1E{n}" for n in range(1, 28)]} for i in range(25)] + requests = [] + state = {"downloads": [], "ignored": [], "failures": set()} + with sync_playwright() as p: + browser = p.chromium.launch( + headless=True, executable_path=os.environ.get("MEDIAFORGE_TEST_BROWSER"), + timeout=15000, + ) + page = browser.new_page() + + def respond(route): + url = urlparse(route.request.url) + if url.path == "/stats": + route.fulfill(content_type="text/html", body= + '' + body + + '' + + '' + + '' + + '' + + '' + + '') + elif url.path.startswith("/static/"): + route.fulfill(content_type="application/javascript", body= + (WEB / "static" / url.path.rsplit("/", 1)[-1]).read_text(encoding="utf-8")) + elif url.path == "/api/stats": + route.fulfill(content_type="application/json", body=json.dumps(payload)) + elif url.path == "/api/media/incomplete": + query = parse_qs(url.query) + requests.append(query) + number = int(query.get("page", ["1"])[0]) + size = int(query.get("per_page", ["20"])[0]) + route.fulfill(content_type="application/json", body=json.dumps({ + "items": items[(number - 1) * size:number * size], + "page": number, "per_page": size, "total": len(items), "total_pages": 2, + "summary": {"incomplete": 25, "ignored": 0, "complete": 0, "missing_slots": 675}, + })) + elif url.path == "/api/search/sources": + route.fulfill(json={"sources": [{"id": "test", "label": "Test", "enabled": True}]}) + elif url.path == "/api/settings/client": + route.fulfill(json={}) + elif url.path == "/api/search": + title = route.request.post_data_json["keyword"] + route.fulfill(json={"results": [{"title": title, "url": "https://source.test/" + title}]}) + elif url.path == "/api/seasons": + route.fulfill(json={"seasons": [{"season_number": 1, "url": "https://source.test/s1"}]}) + elif url.path == "/api/episodes": + route.fulfill(json={"episodes": [{ + "episode_number": n, "url": f"https://source.test/e{n}", + "languages": ["German Dub", "English Sub"], + } for n in range(1, 28)]}) + elif url.path == "/api/providers": + route.fulfill(json={"providers": {"German Dub": ["VOE"], "English Sub": ["Filemoon"]}}) + elif url.path == "/api/download": + data = route.request.post_data_json + state["downloads"].append(data) + route.fulfill(status=403 if data["title"] in state["failures"] else 200, + json={"error": "not permitted"} if data["title"] in state["failures"] else {"queue_id": 42}) + elif url.path == "/api/media/ignore": + state["ignored"].append(route.request.post_data_json) + route.fulfill(json={"ok": True}) + else: + route.fulfill(status=404, body="") + + page.route("**/*", respond) + yield page, requests, state + browser.close() + + +def test_direct_link_opens_all_gap_pages_and_all_episode_slots(stats_page): + page, requests, _ = stats_page + page.goto("http://mediaforge.test/stats#incomplete") + page.locator("#incompleteBody tr").first.wait_for() + assert page.locator("#incompleteModal").is_visible() + assert page.locator("#incompleteBody tr").count() == 20 + assert page.locator("#incompleteBody tr").first.locator(".ignore-slot-chip").count() == 27 + page.locator('#incompleteTableHost .mf-pagination-page[data-page="2"]').click() + page.wait_for_function("document.querySelectorAll('#incompleteBody tr').length === 5") + assert "Series 24" in page.locator("#incompleteBody").inner_text() + assert [r["page"] for r in requests] == [["1"], ["2"]] + + +def test_plain_statistics_page_keeps_gap_modal_closed(stats_page): + page, requests, _ = stats_page + page.goto("http://mediaforge.test/stats") + page.locator("#statsContent .stats-hint").first.wait_for() + assert page.locator("#incompleteModal").is_hidden() + assert not requests + + +def test_hash_link_resets_previous_filters_and_ignored_tab(stats_page): + page, requests, _ = stats_page + page.goto("http://mediaforge.test/stats") + page.locator("#statsContent .stats-hint").first.wait_for() + page.evaluate("""() => { + window._incompleteView = 'ignored'; + _modalView.incomplete.q = 'old search'; + _modalView.incomplete.page = 2; + location.hash = 'incomplete'; + }""", isolated_context=False) + page.locator("#incompleteBody tr").first.wait_for() + assert page.locator("#incompleteModal").is_visible() + assert requests[0]["page"] == ["1"] + assert requests[0]["view"] == ["incomplete"] + assert "q" not in requests[0] + + +def select_gaps_on_two_pages(page): + page.goto("http://mediaforge.test/stats#incomplete") + page.locator('#incompleteBody tr[data-folder="series-0"] .ign-slot[data-slot="S1E2"]').check() + page.locator('#incompleteTableHost .mf-pagination-page[data-page="2"]').click() + page.locator('#incompleteBody tr[data-folder="series-20"] .ign-slot[data-slot="S1E4"]').check() + page.locator("#gapDownloadSelectedBtn").click() + page.locator("#gapBatchSubmit:enabled").wait_for() + + +def test_gap_selection_across_pages_queues_exact_selected_slots(stats_page): + page, _, state = stats_page + select_gaps_on_two_pages(page) + assert page.locator("#gapBatchEntries li").count() == 2 + page.locator("#gapBatchSubmit").click() + page.wait_for_function("document.querySelector('#gapBatchSubmit').textContent === 'Abgeschlossen'") + assert [(d["title"], d["episodes"]) for d in state["downloads"]] == [ + ("Series 00", ["https://source.test/e2"]), ("Series 20", ["https://source.test/e4"]), + ] + assert not state["ignored"] + assert page.evaluate("_ignSelectionCount()", isolated_context=False) == 0 + + +def test_failed_series_remains_selected_for_retry(stats_page): + page, _, state = stats_page + state["failures"].add("Series 20") + select_gaps_on_two_pages(page) + page.locator("#gapBatchSubmit").click() + page.wait_for_function("document.querySelector('#gapBatchSubmit').textContent === 'Abgeschlossen'") + assert page.evaluate("_ignSelectionCount()", isolated_context=False) == 1 + assert page.evaluate("_ignSel.slots['series-20']", isolated_context=False) == ["S1E4"] + assert "not permitted" in page.locator("#gapBatchEntries").inner_text() + + +def test_gap_list_opens_individual_series_with_episode_checkboxes(stats_page): + page, _, state = stats_page + page.goto("http://mediaforge.test/stats#incomplete") + page.locator('#incompleteBody tr[data-folder="series-0"] button').click() + page.locator("#gapFillSubmit:enabled").wait_for() + page.locator("#gapFillSelectAll").uncheck() + page.locator("#gapFillEpisodes input").nth(1).check() + page.locator("#gapFillEpisodes input").nth(3).check() + page.locator("#gapFillSubmit").click() + page.wait_for_function("document.querySelector('#gapFillStatus').textContent.includes('hinzugefügt')") + assert state["downloads"][0]["episodes"] == ["https://source.test/e2", "https://source.test/e4"] + + +def test_ignore_action_still_works_independently_of_downloads(stats_page): + page, _, state = stats_page + page.goto("http://mediaforge.test/stats#incomplete") + page.locator('#incompleteBody tr[data-folder="series-0"] .ign-slot[data-slot="S1E2"]').check() + page.locator("#ignApplyBtn").click() + page.wait_for_function("document.querySelector('#incompleteModal').style.display !== 'none'") + assert state["ignored"] == [{"items": [{"folder": "series-0", "title": "Series 00", "slots": ["S1E2"]}]}] + assert not state["downloads"] + + +def test_whole_series_can_be_reduced_to_individual_missing_episodes(stats_page): + page, _, state = stats_page + page.goto("http://mediaforge.test/stats#incomplete") + row = page.locator('#incompleteBody tr[data-folder="series-0"]') + row.locator(".ign-series").check() + assert row.locator(".ign-slot:checked").count() == 27 + row.locator('.ign-slot[data-slot="S1E3"]').uncheck() + assert row.locator(".ign-series").evaluate("el => el.indeterminate") + page.locator("#gapDownloadSelectedBtn").click() + page.locator("#gapBatchSubmit:enabled").wait_for() + page.locator("#gapBatchSubmit").click() + page.wait_for_function("document.querySelector('#gapBatchSubmit').textContent === 'Abgeschlossen'") + assert state["downloads"][0]["episodes"] == [ + f"https://source.test/e{n}" for n in range(1, 28) if n != 3 + ] + + +def test_closing_series_window_keeps_parent_gap_list_open(stats_page): + page, _, _ = stats_page + page.goto("http://mediaforge.test/stats#incomplete") + page.locator('#incompleteBody tr[data-folder="series-0"] button').click() + page.locator("#gapFillSubmit:enabled").wait_for() + page.keyboard.press("Escape") + assert page.locator("#gapFillOverlay").is_hidden() + assert page.locator("#incompleteModal").is_visible() diff --git a/tools/local-test.ps1 b/tools/local-test.ps1 new file mode 100644 index 0000000..d6de350 --- /dev/null +++ b/tools/local-test.ps1 @@ -0,0 +1,109 @@ +param( + [ValidateSet('Start', 'Stop', 'Remove')] + [string]$Action = 'Start', + [string]$EnvironmentRoot = $PSScriptRoot, + [ValidateRange(1024, 65535)] + [int]$Port = 18080 +) + +$ErrorActionPreference = 'Stop' +$testRoot = (Resolve-Path -LiteralPath $EnvironmentRoot).Path +$marker = Join-Path $testRoot '.mediaforge-test' +if (-not (Test-Path -LiteralPath $marker) -or + (Get-Content -LiteralPath $marker -Raw).Trim() -ne 'MediaForge disposable test environment') { + throw 'Dieser Ordner ist keine gekennzeichnete MediaForge-Testumgebung.' +} +if ($testRoot -eq [IO.Path]::GetPathRoot($testRoot)) { + throw 'Ein Laufwerksstamm darf keine Testumgebung sein.' +} +$python = Join-Path $testRoot 'venv\Scripts\python.exe' +$pidFile = Join-Path $testRoot 'process.json' +$appProcess = $null +if (Test-Path -LiteralPath $pidFile) { + $savedProcess = Get-Content -LiteralPath $pidFile -Raw | ConvertFrom-Json + $candidate = Get-Process -Id $savedProcess.Id -ErrorAction SilentlyContinue + if ($candidate) { + if ($candidate.Path -ne $python -or + $candidate.StartTime.ToUniversalTime().Ticks.ToString() -ne $savedProcess.StartTicks) { + throw 'Die gespeicherte Prozess-ID gehoert nicht mehr zur Testinstanz.' + } + $appProcess = $candidate + } +} + +if ($Action -eq 'Start') { + if ($appProcess) { + Write-Output "Testinstanz laeuft bereits: http://127.0.0.1:$Port" + return + } + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, $Port) + try { $listener.Start() } finally { $listener.Stop() } + $variables = @{ + PYTHONPATH = (Join-Path $testRoot 'source\src') + PYTHONDONTWRITEBYTECODE = '1' + PYTHONUTF8 = '1' + MEDIAFORGE_CONFIG_DIR = (Join-Path $testRoot 'data') + MEDIAFORGE_DOWNLOAD_PATH = (Join-Path $testRoot 'downloads') + MEDIAFORGE_NO_DOMAIN_LOOKUP = '1' + MEDIAFORGE_DEBUG_MODE = '0' + MEDIAFORGE_WEB_HOST = '127.0.0.1' + MEDIAFORGE_WEB_PORT = "$Port" + MEDIAFORGE_WEB_THREADS = '8' + APPDATA = (Join-Path $testRoot 'appdata') + LOCALAPPDATA = (Join-Path $testRoot 'localappdata') + PLAYWRIGHT_BROWSERS_PATH = (Join-Path $testRoot 'browsers') + TEMP = (Join-Path $testRoot 'temp') + TMP = (Join-Path $testRoot 'temp') + } + $previous = @{} + try { + foreach ($name in $variables.Keys) { + $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $variables[$name], 'Process') + } + $command = '"from mediaforge.web.app import start_web_ui; start_web_ui(host=''127.0.0.1'', port=' + $Port + ', open_browser=False)"' + $appProcess = Start-Process -FilePath $python -ArgumentList @('-c', $command) ` + -WorkingDirectory $testRoot -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput (Join-Path $testRoot 'stdout.log') ` + -RedirectStandardError (Join-Path $testRoot 'stderr.log') + @{ + Id = $appProcess.Id + StartTicks = $appProcess.StartTime.ToUniversalTime().Ticks.ToString() + } | ConvertTo-Json | Set-Content -LiteralPath $pidFile -Encoding UTF8 + } finally { + foreach ($name in $previous.Keys) { + [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') + } + } + $deadline = [DateTime]::UtcNow.AddSeconds(45) + while ([DateTime]::UtcNow -lt $deadline) { + $appProcess.Refresh() + if ($appProcess.HasExited) { + throw 'Start fehlgeschlagen. Details stehen in stderr.log im Testordner.' + } + try { + $response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/healthz" ` + -UseBasicParsing -TimeoutSec 2 + if ($response.StatusCode -eq 200) { + Write-Output "Testinstanz erreichbar: http://127.0.0.1:$Port" + return + } + } catch { } + Start-Sleep -Milliseconds 500 + } + Stop-Process -Id $appProcess.Id + throw 'Die Testinstanz wurde nicht rechtzeitig erreichbar. Sie wurde wieder beendet.' +} + +if ($appProcess) { + Stop-Process -Id $appProcess.Id + if (-not $appProcess.WaitForExit(10000)) { throw 'Die Testinstanz wurde nicht beendet.' } +} +if (Test-Path -LiteralPath $pidFile) { Remove-Item -LiteralPath $pidFile } +if ($Action -eq 'Remove') { + Set-Location -LiteralPath (Split-Path -Parent $testRoot) + Remove-Item -LiteralPath $testRoot -Recurse -Force + Write-Output 'Die Testumgebung wurde entfernt.' +} else { + Write-Output 'Die Testinstanz wurde beendet.' +} diff --git a/uv.lock b/uv.lock index 48a6b54..f8bf922 100644 --- a/uv.lock +++ b/uv.lock @@ -10,9 +10,11 @@ resolution-markers = [ [manifest] constraints = [ { name = "aiohttp", specifier = ">=3.14.4" }, + { name = "authlib", specifier = ">=1.9.1" }, { name = "brotli", specifier = ">=1.2.1" }, { name = "certifi", specifier = ">=2026.10.19" }, { name = "cryptography", specifier = ">=50.0.0" }, + { name = "dnspython", marker = "python_full_version >= '3.11'", specifier = ">=2.9.0" }, { name = "pillow", marker = "python_full_version >= '3.11'", specifier = ">=13.0.0" }, { name = "urllib3", specifier = ">=2.8.0" }, ] @@ -238,15 +240,15 @@ wheels = [ [[package]] name = "authlib" -version = "1.7.2" +version = "1.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/dc/087c08741a98d234c9b98beef8a28d3c309f814d14b6156422c5815bf6d6/authlib-1.9.1.tar.gz", hash = "sha256:5c7d9848f47cac340f060f76ae0bc09b59f5c8e89cc1bb032d4370a0c55b5974", size = 182700, upload-time = "2026-10-14T14:17:52.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/39/44/a46089266de32a0d809023b6bf31b80a1f26a46954446a7ce695a565cbea/authlib-1.9.1-py2.py3-none-any.whl", hash = "sha256:8b9be8b1e5174dcbf5520e984f1a22dc2edae5b6e40a61d4cedbf86b97e9e69b", size = 266042, upload-time = "2026-10-14T14:17:50.695Z" }, ] [[package]] @@ -839,17 +841,33 @@ wheels = [ name = "dnspython" version = "2.8.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "dnspython" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/91/21/7fcd6780f61e9756aafaea760560509bb4111375daa2e23c1be7a89f33f1/dnspython-2.9.1.tar.gz", hash = "sha256:9a6d30e55f8dc63e6052597666112bff0586b95a2474a9deda03afbbf820d37a", size = 455302, upload-time = "2026-10-29T18:05:12.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/55/f90405bbbbd3dd8f788e92cec2d39964f5c09783f0570fc35f3bb3381485/dnspython-2.9.1-py3-none-any.whl", hash = "sha256:7c81caf8de1ed57b8cfc7e0ad4f528a0b8ceace473c7eb946df195dc9def751f", size = 360364, upload-time = "2026-10-29T18:05:10.206Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1359,14 +1377,14 @@ wheels = [ [[package]] name = "joserfc" -version = "1.7.3" +version = "1.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/c6/b1cac0280f8efc57626ea8804866b37099f23cae11b1485a42b213245e31/joserfc-1.7.3.tar.gz", hash = "sha256:116955c2587139dba20621fd0bd7fc9255fa960c9fe7f43c43ebef2e801dcfcf", size = 233821, upload-time = "2026-07-08T12:41:42.66Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/15/48ff246732232d35ee234228def22b52455fcc55577f06146602399a29b0/joserfc-1.7.7.tar.gz", hash = "sha256:471d0fd12a5a0d1c0942de280f39efe6f597878315033270b504d4d006d25510", size = 237446, upload-time = "2026-10-19T00:30:29.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/f5/650b59d1b74f5befb7a7a7e7d7c92a26b94256df3541e2b4914152cd177a/joserfc-1.7.3-py3-none-any.whl", hash = "sha256:7c39f3f2c943dbc03122747fa8ebbd8e156e54904cf25651b452f4d2634a6075", size = 70982, upload-time = "2026-07-08T12:41:41.521Z" }, + { url = "https://files.pythonhosted.org/packages/df/9f/934db284aa22f37af59708e382903860d4b81329c137b1b0ed2745daeb65/joserfc-1.7.7-py3-none-any.whl", hash = "sha256:06979cfbab78bc802f14c0358f32969826cc26720e13cb38926ffdc86bc0cb2c", size = 71758, upload-time = "2026-10-19T00:30:28.498Z" }, ] [[package]] @@ -1513,7 +1531,7 @@ wheels = [ [[package]] name = "mediaforge" -version = "1.6.0" +version = "1.7.0" source = { editable = "." } dependencies = [ { name = "authlib" }, @@ -1521,7 +1539,8 @@ dependencies = [ { name = "bleach" }, { name = "cryptography" }, { name = "curl-cffi" }, - { name = "dnspython" }, + { name = "dnspython", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "dnspython", version = "2.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "fake-useragent" }, { name = "ffmpeg-python" }, { name = "flask" }, @@ -1567,7 +1586,7 @@ requires-dist = [ { name = "flask-babel" }, { name = "flask-limiter", specifier = ">=4.1,<5" }, { name = "flask-wtf", specifier = ">=1.2,<2" }, - { name = "joserfc", specifier = ">=1.7,<2" }, + { name = "joserfc", specifier = ">=1.7.5,<2" }, { name = "markdown" }, { name = "mobi" }, { name = "niquests", extras = ["brotli"] },