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 ' ' +
escHtml(sl) + " ";
@@ -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(t("Lücken füllen", "Fill gaps")) + " " +
'' + escHtml(item.location) + " " +
'' + chips + "
";
@@ -828,7 +865,9 @@ function _incompleteTableHtml(info) {
'' +
escHtml(t("Auswahl leeren", "Clear selection")) + " " +
'' +
- escHtml(t("Auswahl ignorieren", "Ignore selected")) + " ";
+ escHtml(t("Auswahl ignorieren", "Ignore selected")) + "" +
+ '' +
+ escHtml(t("Auswahl herunterladen", "Download 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 @@
+
+
+
×
+
+
+
+
+ {{ _('Source') }}
+
+ {{ _('Language') }}
+
+ {{ _('Provider') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
×
+
+
+
+ {{ _('Language') }}
+
+ {{ _('Provider') }}
+
+
+
+
+
+
+
+
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=
+ 'Open ' + 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"] },