Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
]
Expand Down
4 changes: 4 additions & 0 deletions src/mediaforge/web/routes/browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/mediaforge/web/routes/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions src/mediaforge/web/static/gap_batch.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
})();
Loading
Loading