WebApp changes

This commit is contained in:
2026-05-26 21:03:37 +02:00
parent 4f4660883f
commit 79d64d7ed5
5 changed files with 353 additions and 116 deletions
+4 -4
View File
@@ -74,15 +74,15 @@ def _env_bool(name: str, default: bool) -> bool:
def main() -> int: def main() -> int:
suwayomi_path = _env_str("SUWAYOMI_PATH", "/mnt/suwayomi") suwayomi_path = _env_str("SUWAYOMI_PATH", r"M:\config\downloads\mangas")
kavita_path = _env_str("KAVITA_PATH", "/mnt/kavita") kavita_path = _env_str("KAVITA_PATH", "/mnt/kavita")
kavita_url = _env_str("KAVITA_URL", required=True) kavita_url = _env_str("KAVITA_URL", "http://kavita:5000")
kavita_api_key = _env_str("KAVITA_API_KEY", required=True) kavita_api_key = _env_str("KAVITA_API_KEY", "")
language = _env_str("LANGUAGE", "en") or "en" language = _env_str("LANGUAGE", "en") or "en"
settle_seconds = _env_int("SETTLE_SECONDS", 600) settle_seconds = _env_int("SETTLE_SECONDS", 600)
request_timeout = _env_int("REQUEST_TIMEOUT", 30) request_timeout = _env_int("REQUEST_TIMEOUT", 30)
delete_source = _env_bool("DELETE_SOURCE", True) delete_source = _env_bool("DELETE_SOURCE", True)
match_path = _env_str("MATCH_PATH", "/config/matches.json") match_path = _env_str("MATCH_PATH", "matches.json")
web_host = _env_str("WEB_HOST", "0.0.0.0") or "0.0.0.0" web_host = _env_str("WEB_HOST", "0.0.0.0") or "0.0.0.0"
web_port = _env_int("WEB_PORT", 8080) web_port = _env_int("WEB_PORT", 8080)
+12 -1
View File
@@ -49,6 +49,7 @@ from MangaBakaWorksResolver import MangaBakaWorksResolver
from MALResolver import MALResolver from MALResolver import MALResolver
from AniListResolver import AniListResolver from AniListResolver import AniListResolver
from MatchesCache import MatchesCache from MatchesCache import MatchesCache
from MangaBakaRateLimit import apply_to_session as _apply_mangabaka_rate_limit
try: try:
from PIL import Image from PIL import Image
@@ -62,6 +63,12 @@ except ImportError:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"}
# Series types accepted by the MangaBaka search endpoint. Light/web novels
# are filtered out because this pipeline only handles image-based manga.
# Passed to `requests` as a list so each value becomes its own `&type=...`
# query parameter (MangaBaka's API expects repeated keys, not a CSV list).
_SEARCH_TYPES = ["manga", "manhwa", "manhua"]
_AGE_RATING_MAP = { _AGE_RATING_MAP = {
"safe": "Everyone", "safe": "Everyone",
"suggestive": "Teen", "suggestive": "Teen",
@@ -184,6 +191,9 @@ class ComicInfoBuilder:
self.request_timeout = request_timeout self.request_timeout = request_timeout
self._session = session or requests.Session() self._session = session or requests.Session()
self._session.headers.setdefault("User-Agent", "ComicInfoBuilder/1.0") self._session.headers.setdefault("User-Agent", "ComicInfoBuilder/1.0")
# Throttle every call to api.mangabaka.dev (idempotent — safe even
# when the session was already prepared by a parent class).
_apply_mangabaka_rate_limit(self._session)
self._volume_resolver = (volume_resolver self._volume_resolver = (volume_resolver
or MangaDexVolumeResolver( or MangaDexVolumeResolver(
@@ -378,7 +388,8 @@ class ComicInfoBuilder:
url = f"{self.api_base_url}/series/search" url = f"{self.api_base_url}/series/search"
resp = self._session.get( resp = self._session.get(
url, params={"q": title, "page": 1, "limit": 1}, url, params={"q": title, "type": _SEARCH_TYPES,
"page": 1, "limit": 1},
timeout=self.request_timeout) timeout=self.request_timeout)
resp.raise_for_status() resp.raise_for_status()
data = resp.json().get("data") or [] data = resp.json().get("data") or []
+92
View File
@@ -0,0 +1,92 @@
"""
mangabaka_rate_limit.py
=======================
Process-wide rate limiter for the MangaBaka API.
Apply via:
from MangaBakaRateLimit import apply_to_session
apply_to_session(session)
This mounts a custom ``requests.adapters.HTTPAdapter`` on the given
``requests.Session`` for the ``api.mangabaka.dev`` host. Every request
going through that adapter is:
* throttled so that no two requests are dispatched within
``_MIN_INTERVAL`` seconds of one another, and
* retried on HTTP 429, honouring the ``Retry-After`` header when
present, otherwise exponential backoff capped at ``_MAX_BACKOFF``.
Throttle state is module-global, so even if several sessions exist in
the same process they share one budget — important because they all hit
the same upstream IP-based limit.
"""
from __future__ import annotations
import threading
import time
from requests.adapters import HTTPAdapter
# Tune these if MangaBaka tightens or loosens limits.
_MIN_INTERVAL = 1.1 # seconds between consecutive requests
_MAX_RETRIES = 6 # retries on 429 before giving up
_MAX_BACKOFF = 60.0 # cap on per-attempt backoff sleep
# --- shared throttle state --------------------------------------------------
_state_lock = threading.Lock()
_last_request_time = 0.0
def _wait_for_slot() -> None:
"""Block until the next request slot is available, then reserve it."""
global _last_request_time
while True:
with _state_lock:
now = time.monotonic()
wait = _MIN_INTERVAL - (now - _last_request_time)
if wait <= 0:
_last_request_time = now
return
time.sleep(wait)
class _MangaBakaRateLimitAdapter(HTTPAdapter):
def send(self, request, **kwargs):
response = None
for attempt in range(_MAX_RETRIES + 1):
_wait_for_slot()
response = super().send(request, **kwargs)
if response.status_code != 429:
return response
retry_after = response.headers.get("Retry-After")
try:
wait = (float(retry_after) if retry_after
else min(_MAX_BACKOFF, 2.0 * (2 ** attempt)))
except ValueError:
wait = min(_MAX_BACKOFF, 2.0 * (2 ** attempt))
print(f"[MangaBaka] 429 — backing off {wait:.1f}s "
f"(attempt {attempt + 1}/{_MAX_RETRIES})",
flush=True)
response.close()
time.sleep(wait)
# Retries exhausted — let the caller deal with the last 429.
return response
def apply_to_session(session) -> None:
"""
Mount the rate-limit adapter on ``session`` so every MangaBaka call
is automatically throttled. Safe to call multiple times (later mounts
just replace the earlier adapter for the same prefix).
"""
adapter = _MangaBakaRateLimitAdapter()
session.mount("https://api.mangabaka.dev/", adapter)
session.mount("http://api.mangabaka.dev/", adapter)
+203 -99
View File
@@ -9,25 +9,27 @@ Routes
------ ------
GET / HTML table view (one row per cached match) GET / HTML table view (one row per cached match)
GET /api/matches JSON dump of the full cache GET /api/matches JSON dump of the full cache
POST /api/matches Upsert / rename an entry POST /api/matches Update an entry's mangabakaId
body: {originalTitle?, title, mangabakaId, body: {title, mangabakaId}
mangabakaName, imageUrl, firstMatchTime?} Server resolves the id against MangaBaka and
POST /api/matches/delete Remove an entry body: {title} refreshes the mangabakaName + imageUrl fields.
POST /api/build Trigger a full re-scan via SuwayomiMover.build_matches_only POST /api/matches/delete Remove an entry body: {title}
(only available if a mover is wired in) POST /api/build Trigger a full re-scan via
SuwayomiMover.build_matches_only
The Title cell is rendered as a link to MangaBaka's search page, restricted The Title cell is rendered as a link to MangaBaka's search page restricted
to the manga / manhwa / manhua types. to the manga / manhwa / manhua types. Only mangabakaId is editable; title
(folder name) and mangabakaName (info only) are read-only.
""" """
from __future__ import annotations from __future__ import annotations
import threading import threading
from urllib.parse import quote_plus
from flask import Flask, jsonify, request, Response from flask import Flask, jsonify, request, Response
from MatchesCache import MatchesCache from MatchesCache import MatchesCache
from ComicInfoBuilder import _pick_cover_url
_INDEX_HTML = """<!doctype html> _INDEX_HTML = """<!doctype html>
@@ -43,35 +45,41 @@ _INDEX_HTML = """<!doctype html>
button { padding: .35rem .7rem; cursor: pointer; background:#2a2a2a; color:#eee; border:1px solid #555; } button { padding: .35rem .7rem; cursor: pointer; background:#2a2a2a; color:#eee; border:1px solid #555; }
button.primary { background:#2563eb; border-color:#2563eb; color:white; } button.primary { background:#2563eb; border-color:#2563eb; color:white; }
button.danger { background:#7f1d1d; border-color:#7f1d1d; color:white; } button.danger { background:#7f1d1d; border-color:#7f1d1d; color:white; }
button:disabled { opacity:.5; cursor:default; }
table { border-collapse: collapse; width: 100%; } table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #333; padding: .4rem .6rem; vertical-align: top; } th, td { border: 1px solid #333; padding: .4rem .6rem; vertical-align: top; }
th { background: #1d1d1d; text-align: left; position: sticky; top: 0; } th { background: #1d1d1d; text-align: left; position: sticky; top: 0; }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { background:#252525; }
th .arrow { display:inline-block; width:.8em; color:#9ca3af; }
tr:nth-child(even) td { background: #161616; } tr:nth-child(even) td { background: #161616; }
td.image img { max-width: 90px; max-height: 130px; display:block; } td.image img { max-width: 90px; max-height: 130px; display:block; }
td input { width: 100%; padding: .25rem; background:#222; color:#eee; border:1px solid #444; } td.id input { width: 14rem; padding: .25rem; background:#222; color:#eee; border:1px solid #444; font-family: monospace; }
td.title a { color: #60a5fa; text-decoration: none; } td.title a { color: #60a5fa; text-decoration: none; }
td.title a:hover { text-decoration: underline; } td.title a:hover { text-decoration: underline; }
td.actions { white-space: nowrap; } td.actions { white-space: nowrap; }
.status { margin-left: .5rem; color:#9ca3af; font-size: .9rem; } .status { margin-left: .5rem; color:#9ca3af; font-size: .9rem; }
.dirty td { background: #1f2937 !important; } .dirty td { background: #1f2937 !important; }
.count { color:#9ca3af; font-size:.9rem; margin-left:.5rem; }
</style> </style>
</head> </head>
<body> <body>
<h1>MangaBaka matches</h1> <h1>MangaBaka matches <span id="count" class="count"></span></h1>
<div class="bar"> <div class="bar">
<input id="filter" type="search" placeholder="Filter by title…"> <input id="filter" type="search" placeholder="Filter by title…">
<button id="reload">Reload</button> <button id="reload">Reload</button>
<button id="build" class="primary">Build all (rescan)</button> <button id="batchSave" class="primary">Save dirty (0)</button>
<button id="build">Build all (rescan)</button>
<span class="status" id="status"></span> <span class="status" id="status"></span>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Title</th> <th class="sortable" data-col="title">Title <span class="arrow" id="arrow-title"></span></th>
<th>mangabakaId</th> <th>mangabakaId</th>
<th>mangabakaName</th> <th>mangabakaName</th>
<th>firstMatchTime</th> <th class="sortable" data-col="firstMatchTime">firstMatchTime <span class="arrow" id="arrow-firstMatchTime"></span></th>
<th>Image</th> <th>Image</th>
<th></th> <th></th>
</tr> </tr>
@@ -81,6 +89,8 @@ _INDEX_HTML = """<!doctype html>
<script> <script>
const TYPES = "&type=manhwa&type=manhua&type=manga"; const TYPES = "&type=manhwa&type=manhua&type=manga";
let matchesData = {};
let currentSort = { col: "title", asc: true };
function fmtTime(unix) { function fmtTime(unix) {
if (!unix) return ""; if (!unix) return "";
@@ -94,10 +104,18 @@ function searchUrl(title) {
function setStatus(msg) { document.getElementById("status").textContent = msg; } function setStatus(msg) { document.getElementById("status").textContent = msg; }
function updateDirtyCount() {
const n = document.querySelectorAll("#rows tr.dirty").length;
const btn = document.getElementById("batchSave");
btn.textContent = "Save dirty (" + n + ")";
btn.disabled = n === 0;
}
function makeRow(title, e) { function makeRow(title, e) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.dataset.originalTitle = title; tr.dataset.title = title;
// Title — link only, not editable
const titleTd = document.createElement("td"); const titleTd = document.createElement("td");
titleTd.className = "title"; titleTd.className = "title";
const titleLink = document.createElement("a"); const titleLink = document.createElement("a");
@@ -105,114 +123,172 @@ function makeRow(title, e) {
titleLink.target = "_blank"; titleLink.target = "_blank";
titleLink.rel = "noopener"; titleLink.rel = "noopener";
titleLink.textContent = title; titleLink.textContent = title;
const titleInput = document.createElement("input"); titleTd.appendChild(titleLink);
titleInput.value = title;
titleInput.style.marginTop = ".25rem";
titleInput.addEventListener("input", () => {
titleLink.textContent = titleInput.value;
titleLink.href = searchUrl(titleInput.value);
tr.classList.add("dirty");
});
titleTd.append(titleLink, titleInput);
tr.appendChild(titleTd); tr.appendChild(titleTd);
function field(value) { // mangabakaId — editable
const td = document.createElement("td"); const idTd = document.createElement("td");
const inp = document.createElement("input"); idTd.className = "id";
inp.value = value || ""; const idInp = document.createElement("input");
inp.addEventListener("input", () => tr.classList.add("dirty")); idInp.value = e.mangabakaId || "";
td.appendChild(inp); idInp.dataset.original = e.mangabakaId || "";
return [td, inp]; idInp.addEventListener("input", () => {
} if (idInp.value !== idInp.dataset.original) tr.classList.add("dirty");
else tr.classList.remove("dirty");
const [idTd, idInp] = field(e.mangabakaId); updateDirtyCount();
const [nameTd, nameInp] = field(e.mangabakaName); });
const [urlTd, urlInp] = field(e.imageUrl); idTd.appendChild(idInp);
tr.appendChild(idTd); tr.appendChild(idTd);
// mangabakaName — plain text (info only)
const nameTd = document.createElement("td");
nameTd.className = "name";
nameTd.textContent = e.mangabakaName || "";
tr.appendChild(nameTd); tr.appendChild(nameTd);
// firstMatchTime — plain text
const timeTd = document.createElement("td"); const timeTd = document.createElement("td");
timeTd.textContent = fmtTime(e.firstMatchTime); timeTd.textContent = fmtTime(e.firstMatchTime);
tr.appendChild(timeTd); tr.appendChild(timeTd);
// Image
const imgTd = document.createElement("td"); const imgTd = document.createElement("td");
imgTd.className = "image"; imgTd.className = "image";
const img = document.createElement("img"); const img = document.createElement("img");
img.src = e.imageUrl || ""; img.src = e.imageUrl || "";
img.alt = ""; img.alt = "";
img.loading = "lazy"; img.loading = "lazy";
urlInp.addEventListener("input", () => { img.src = urlInp.value; }); imgTd.appendChild(img);
imgTd.append(img, urlInp);
tr.appendChild(imgTd); tr.appendChild(imgTd);
// Actions
const actTd = document.createElement("td"); const actTd = document.createElement("td");
actTd.className = "actions"; actTd.className = "actions";
const save = document.createElement("button"); const save = document.createElement("button");
save.textContent = "Save"; save.textContent = "Save";
save.className = "primary"; save.className = "primary";
save.addEventListener("click", async () => { save.addEventListener("click", () => saveRow(tr));
save.disabled = true;
setStatus("Saving " + titleInput.value + "");
const body = {
originalTitle: tr.dataset.originalTitle,
title: titleInput.value,
mangabakaId: idInp.value,
mangabakaName: nameInp.value,
imageUrl: urlInp.value,
};
try {
const r = await fetch("/api/matches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
tr.dataset.originalTitle = titleInput.value;
tr.classList.remove("dirty");
setStatus("Saved " + titleInput.value);
} catch (err) {
setStatus("Save failed: " + err.message);
} finally {
save.disabled = false;
}
});
const del = document.createElement("button"); const del = document.createElement("button");
del.textContent = "Delete"; del.textContent = "Delete";
del.className = "danger"; del.className = "danger";
del.style.marginLeft = ".25rem"; del.style.marginLeft = ".25rem";
del.addEventListener("click", async () => { del.addEventListener("click", () => deleteRow(tr));
if (!confirm("Delete " + tr.dataset.originalTitle + "?")) return;
setStatus("Deleting " + tr.dataset.originalTitle + "");
try {
const r = await fetch("/api/matches/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: tr.dataset.originalTitle }),
});
if (!r.ok) throw new Error(await r.text());
tr.remove();
setStatus("Deleted");
} catch (err) {
setStatus("Delete failed: " + err.message);
}
});
actTd.append(save, del); actTd.append(save, del);
tr.appendChild(actTd); tr.appendChild(actTd);
tr._idInp = idInp;
tr._nameTd = nameTd;
tr._img = img;
return tr; return tr;
} }
async function saveRow(tr) {
const title = tr.dataset.title;
const newId = tr._idInp.value.trim();
setStatus("Saving " + title + "");
try {
const r = await fetch("/api/matches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title, mangabakaId: newId }),
});
if (!r.ok) throw new Error(await r.text());
const data = await r.json();
const entry = data.entry || {};
matchesData[title] = entry;
tr._idInp.value = entry.mangabakaId || "";
tr._idInp.dataset.original = entry.mangabakaId || "";
tr._nameTd.textContent = entry.mangabakaName || "";
tr._img.src = entry.imageUrl || "";
tr.classList.remove("dirty");
updateDirtyCount();
setStatus("Saved " + title);
return true;
} catch (err) {
setStatus("Save failed (" + title + "): " + err.message);
return false;
}
}
async function deleteRow(tr) {
const title = tr.dataset.title;
if (!confirm("Delete " + title + "?")) return;
setStatus("Deleting " + title + "");
try {
const r = await fetch("/api/matches/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title }),
});
if (!r.ok) throw new Error(await r.text());
delete matchesData[title];
tr.remove();
updateDirtyCount();
document.getElementById("count").textContent =
"(" + Object.keys(matchesData).length + " entries)";
setStatus("Deleted");
} catch (err) {
setStatus("Delete failed: " + err.message);
}
}
async function batchSave() {
const dirty = Array.from(document.querySelectorAll("#rows tr.dirty"));
if (dirty.length === 0) return;
if (!confirm("Save " + dirty.length + " changed row(s)?")) return;
setStatus("Batch saving " + dirty.length + " rows…");
let ok = 0, fail = 0;
for (const tr of dirty) {
const success = await saveRow(tr);
if (success) ok++; else fail++;
}
setStatus("Batch: " + ok + " ok, " + fail + " failed");
}
function sortedTitles() {
const titles = Object.keys(matchesData);
const dir = currentSort.asc ? 1 : -1;
if (currentSort.col === "title") {
return titles.sort((a, b) => a.localeCompare(b) * dir);
}
if (currentSort.col === "firstMatchTime") {
return titles.sort((a, b) => {
const av = matchesData[a].firstMatchTime || 0;
const bv = matchesData[b].firstMatchTime || 0;
return (av - bv) * dir;
});
}
return titles;
}
function updateSortArrows() {
for (const a of document.querySelectorAll("th .arrow")) a.textContent = "";
const id = "arrow-" + currentSort.col;
const el = document.getElementById(id);
if (el) el.textContent = currentSort.asc ? "" : "";
}
function render() {
const tbody = document.getElementById("rows");
tbody.innerHTML = "";
for (const t of sortedTitles()) {
tbody.appendChild(makeRow(t, matchesData[t]));
}
updateSortArrows();
applyFilter();
updateDirtyCount();
document.getElementById("count").textContent =
"(" + Object.keys(matchesData).length + " entries)";
}
async function load() { async function load() {
setStatus("Loading…"); setStatus("Loading…");
const tbody = document.getElementById("rows");
tbody.innerHTML = "";
try { try {
const r = await fetch("/api/matches"); const r = await fetch("/api/matches");
const data = await r.json(); const data = await r.json();
const matches = data.matches || {}; matchesData = data.matches || {};
const titles = Object.keys(matches).sort((a,b)=>a.localeCompare(b)); render();
for (const t of titles) tbody.appendChild(makeRow(t, matches[t])); setStatus(Object.keys(matchesData).length + " entries");
setStatus(titles.length + " entries");
applyFilter();
} catch (err) { } catch (err) {
setStatus("Load failed: " + err.message); setStatus("Load failed: " + err.message);
} }
@@ -221,13 +297,14 @@ async function load() {
function applyFilter() { function applyFilter() {
const q = document.getElementById("filter").value.toLowerCase(); const q = document.getElementById("filter").value.toLowerCase();
for (const tr of document.querySelectorAll("#rows tr")) { for (const tr of document.querySelectorAll("#rows tr")) {
const t = tr.dataset.originalTitle.toLowerCase(); const t = tr.dataset.title.toLowerCase();
tr.style.display = t.includes(q) ? "" : "none"; tr.style.display = t.includes(q) ? "" : "none";
} }
} }
document.getElementById("filter").addEventListener("input", applyFilter); document.getElementById("filter").addEventListener("input", applyFilter);
document.getElementById("reload").addEventListener("click", load); document.getElementById("reload").addEventListener("click", load);
document.getElementById("batchSave").addEventListener("click", batchSave);
document.getElementById("build").addEventListener("click", async () => { document.getElementById("build").addEventListener("click", async () => {
if (!confirm("Run full scan? This may take several minutes.")) return; if (!confirm("Run full scan? This may take several minutes.")) return;
setStatus("Building… (running on the server)"); setStatus("Building… (running on the server)");
@@ -240,6 +317,14 @@ document.getElementById("build").addEventListener("click", async () => {
setStatus("Build failed: " + err.message); setStatus("Build failed: " + err.message);
} }
}); });
for (const th of document.querySelectorAll("th.sortable")) {
th.addEventListener("click", () => {
const col = th.dataset.col;
if (currentSort.col === col) currentSort.asc = !currentSort.asc;
else { currentSort.col = col; currentSort.asc = true; }
render();
});
}
load(); load();
</script> </script>
@@ -250,9 +335,10 @@ load();
class MatchesWebApp: class MatchesWebApp:
""" """
Flask app exposing the MatchesCache. `mover` is optional — if provided, Flask app exposing the MatchesCache. `mover` is required when you want
POST /api/build triggers SuwayomiMover.build_matches_only() on a worker POST /api/matches to resolve a new mangabakaId against MangaBaka (it
thread. uses the mover's rate-limited session) and when POST /api/build should
work.
""" """
def __init__(self, cache: MatchesCache, *, def __init__(self, cache: MatchesCache, *,
@@ -296,7 +382,7 @@ class MatchesWebApp:
return self._thread return self._thread
def wait(self) -> None: def wait(self) -> None:
"""Blocks until the Flask thread exits (or returns immediately if not started).""" """Blocks until the Flask thread exits."""
if self._thread is not None: if self._thread is not None:
self._thread.join() self._thread.join()
@@ -321,15 +407,33 @@ class MatchesWebApp:
title = (body.get("title") or "").strip() title = (body.get("title") or "").strip()
if not title: if not title:
return Response("title is required", status=400) return Response("title is required", status=400)
original = (body.get("originalTitle") or "").strip() or title
if original != title: new_id_raw = body.get("mangabakaId")
cache.rename(original, title) new_id = str(new_id_raw).strip() if new_id_raw is not None else ""
if not new_id:
return Response("mangabakaId is required", status=400)
# Resolve the id against MangaBaka so mangabakaName + imageUrl
# always reflect what the id actually points to.
new_name: "str | None" = None
new_image: "str | None" = None
if self._mover is not None:
try:
series = self._mover.fetch_series(new_id)
except Exception as exc:
return Response(f"resolve failed: {exc}", status=502)
if not series:
return Response(
f"MangaBaka has no series with id {new_id}",
status=404)
new_name = series.get("title") or ""
new_image = _pick_cover_url(series.get("cover")) or ""
entry = cache.upsert( entry = cache.upsert(
title, title,
mangabaka_id=body.get("mangabakaId"), mangabaka_id=new_id,
mangabaka_name=body.get("mangabakaName"), mangabaka_name=new_name,
image_url=body.get("imageUrl"), image_url=new_image,
first_match_time=body.get("firstMatchTime"),
) )
return jsonify({"title": title, "entry": entry}) return jsonify({"title": title, "entry": entry})
+42 -12
View File
@@ -51,13 +51,14 @@ from pathlib import Path
import requests import requests
from ComicInfoBuilder import ComicInfoBuilder, _pick_cover_url from ComicInfoBuilder import ComicInfoBuilder, _pick_cover_url, _SEARCH_TYPES
from MangadexVolumeResolver import MangaDexVolumeResolver from MangadexVolumeResolver import MangaDexVolumeResolver
from MangaBakaWorksResolver import MangaBakaWorksResolver from MangaBakaWorksResolver import MangaBakaWorksResolver
from MALResolver import MALResolver from MALResolver import MALResolver
from AniListResolver import AniListResolver from AniListResolver import AniListResolver
from KavitaPersonUpdater import KavitaPersonUpdater from KavitaPersonUpdater import KavitaPersonUpdater
from MatchesCache import MatchesCache from MatchesCache import MatchesCache
from MangaBakaRateLimit import apply_to_session as _apply_mangabaka_rate_limit
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"}
@@ -303,6 +304,8 @@ class SuwayomiMover:
# to maximise cache hits and minimise API round-trips. # to maximise cache hits and minimise API round-trips.
session = requests.Session() session = requests.Session()
session.headers.setdefault("User-Agent", "SuwayomiMover/1.0") session.headers.setdefault("User-Agent", "SuwayomiMover/1.0")
# Throttle every call to api.mangabaka.dev (>=1s gap + retry on 429).
_apply_mangabaka_rate_limit(session)
self._session = session self._session = session
self._mal = MALResolver(request_timeout=request_timeout) self._mal = MALResolver(request_timeout=request_timeout)
@@ -362,6 +365,18 @@ class SuwayomiMover:
raise FileNotFoundError( raise FileNotFoundError(
f"No Suwayomi directory found for '{manga_title}' under {self._src}") f"No Suwayomi directory found for '{manga_title}' under {self._src}")
def fetch_series(self, series_id) -> "dict | None":
"""
Fetches a MangaBaka series by id via the shared (rate-limited) session.
Returns the inner `data` dict, or None if not found / empty.
"""
if series_id is None or str(series_id).strip() == "":
return None
url = f"{self._api_base_url}/series/{series_id}"
resp = self._session.get(url, timeout=self._timeout)
resp.raise_for_status()
return resp.json().get("data")
def build_matches_only(self) -> dict: def build_matches_only(self) -> dict:
""" """
Walks every series under the Suwayomi root and resolves each one Walks every series under the Suwayomi root and resolves each one
@@ -410,7 +425,8 @@ class SuwayomiMover:
try: try:
resp = self._session.get( resp = self._session.get(
search_url, search_url,
params={"q": builder_title, "page": 1, "limit": 1}, params={"q": builder_title, "type": _SEARCH_TYPES,
"page": 1, "limit": 1},
timeout=self._timeout) timeout=self._timeout)
resp.raise_for_status() resp.raise_for_status()
data = resp.json().get("data") or [] data = resp.json().get("data") or []
@@ -555,27 +571,41 @@ class SuwayomiMover:
# Usage example # Usage example
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
SUWAYOMI_PATH = r"\\192.168.2.2\root\Temp\managdl\mangas" # Local (no-Docker) smoke test. Adjust paths to your environment.
SUWAYOMI_PATH = r"M:\config\downloads\mangas"
KAVITA_PATH = r"\\192.168.2.2\root\ServerData\Kavita\test" KAVITA_PATH = r"\\192.168.2.2\root\ServerData\Kavita\test"
KAVITA_URL = "http://192.168.2.2:5000" KAVITA_URL = "http://192.168.2.2:5000"
KAVITA_KEY = "Sq4a3hcV171dn3gzCl0K4eN7hZNk4sOA" KAVITA_KEY = "Sq4a3hcV171dn3gzCl0K4eN7hZNk4sOA"
# matches.json lives next to this script during local testing.
MATCHES_PATH = Path(__file__).resolve().parent.parent / "matches.json"
matches_cache = MatchesCache(MATCHES_PATH)
mover = SuwayomiMover( mover = SuwayomiMover(
SUWAYOMI_PATH, SUWAYOMI_PATH,
KAVITA_PATH, KAVITA_PATH,
kavita_base_url=KAVITA_URL, kavita_base_url=KAVITA_URL,
kavita_api_key=KAVITA_KEY, kavita_api_key=KAVITA_KEY,
delete_source=False delete_source=False,
matches_cache=matches_cache,
) )
# Process a single series # ---- Option A: build matches.json only (no moves / no Kavita sync) ----
result = mover.process_series("Yofukashi no Uta") data = mover.build_matches_only()
ok = sum(1 for c in result["chapters"] if c["ok"]) matches = data.get("matches", {})
failed = sum(1 for c in result["chapters"] if not c["ok"]) print(f"\n[matches] {len(matches)} entries total — file: {MATCHES_PATH}")
print(f"\nDone: {ok} ok, {failed} failed") for title, entry in list(matches.items())[:10]:
for c in result["chapters"]: print(f" {title!r:50s} id={entry.get('mangabakaId')} "
if not c["ok"]: f"name={entry.get('mangabakaName')!r}")
print(f" Chapter {c['chapter']}: {c['error']}")
# ---- Option B: full pipeline for one series (uses the cache too) ----
# result = mover.process_series("Yofukashi no Uta")
# ok = sum(1 for c in result["chapters"] if c["ok"])
# failed = sum(1 for c in result["chapters"] if not c["ok"])
# print(f"\nDone: {ok} ok, {failed} failed")
# for c in result["chapters"]:
# if not c["ok"]:
# print(f" Chapter {c['chapter']}: {c['error']}")
# Or process everything at once: # Or process everything at once:
# results = mover.process_all() # results = mover.process_all()