WebApp changes
This commit is contained in:
@@ -74,15 +74,15 @@ def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
|
||||
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_url = _env_str("KAVITA_URL", required=True)
|
||||
kavita_api_key = _env_str("KAVITA_API_KEY", required=True)
|
||||
kavita_url = _env_str("KAVITA_URL", "http://kavita:5000")
|
||||
kavita_api_key = _env_str("KAVITA_API_KEY", "")
|
||||
language = _env_str("LANGUAGE", "en") or "en"
|
||||
settle_seconds = _env_int("SETTLE_SECONDS", 600)
|
||||
request_timeout = _env_int("REQUEST_TIMEOUT", 30)
|
||||
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_port = _env_int("WEB_PORT", 8080)
|
||||
|
||||
|
||||
+12
-1
@@ -49,6 +49,7 @@ from MangaBakaWorksResolver import MangaBakaWorksResolver
|
||||
from MALResolver import MALResolver
|
||||
from AniListResolver import AniListResolver
|
||||
from MatchesCache import MatchesCache
|
||||
from MangaBakaRateLimit import apply_to_session as _apply_mangabaka_rate_limit
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
@@ -62,6 +63,12 @@ except ImportError:
|
||||
# --------------------------------------------------------------------------
|
||||
_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 = {
|
||||
"safe": "Everyone",
|
||||
"suggestive": "Teen",
|
||||
@@ -184,6 +191,9 @@ class ComicInfoBuilder:
|
||||
self.request_timeout = request_timeout
|
||||
self._session = session or requests.Session()
|
||||
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
|
||||
or MangaDexVolumeResolver(
|
||||
@@ -378,7 +388,8 @@ class ComicInfoBuilder:
|
||||
|
||||
url = f"{self.api_base_url}/series/search"
|
||||
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)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data") or []
|
||||
|
||||
@@ -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)
|
||||
+193
-89
@@ -9,25 +9,27 @@ Routes
|
||||
------
|
||||
GET / HTML table view (one row per cached match)
|
||||
GET /api/matches JSON dump of the full cache
|
||||
POST /api/matches Upsert / rename an entry
|
||||
body: {originalTitle?, title, mangabakaId,
|
||||
mangabakaName, imageUrl, firstMatchTime?}
|
||||
POST /api/matches Update an entry's mangabakaId
|
||||
body: {title, mangabakaId}
|
||||
Server resolves the id against MangaBaka and
|
||||
refreshes the mangabakaName + imageUrl fields.
|
||||
POST /api/matches/delete Remove an entry body: {title}
|
||||
POST /api/build Trigger a full re-scan via SuwayomiMover.build_matches_only
|
||||
(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
|
||||
to the manga / manhwa / manhua types.
|
||||
The Title cell is rendered as a link to MangaBaka's search page restricted
|
||||
to the manga / manhwa / manhua types. Only mangabakaId is editable; title
|
||||
(folder name) and mangabakaName (info only) are read-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from flask import Flask, jsonify, request, Response
|
||||
|
||||
from MatchesCache import MatchesCache
|
||||
from ComicInfoBuilder import _pick_cover_url
|
||||
|
||||
|
||||
_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.primary { background:#2563eb; border-color:#2563eb; color:white; }
|
||||
button.danger { background:#7f1d1d; border-color:#7f1d1d; color:white; }
|
||||
button:disabled { opacity:.5; cursor:default; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #333; padding: .4rem .6rem; vertical-align: top; }
|
||||
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; }
|
||||
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:hover { text-decoration: underline; }
|
||||
td.actions { white-space: nowrap; }
|
||||
.status { margin-left: .5rem; color:#9ca3af; font-size: .9rem; }
|
||||
.dirty td { background: #1f2937 !important; }
|
||||
.count { color:#9ca3af; font-size:.9rem; margin-left:.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MangaBaka matches</h1>
|
||||
<h1>MangaBaka matches <span id="count" class="count"></span></h1>
|
||||
<div class="bar">
|
||||
<input id="filter" type="search" placeholder="Filter by title…">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th class="sortable" data-col="title">Title <span class="arrow" id="arrow-title"></span></th>
|
||||
<th>mangabakaId</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></th>
|
||||
</tr>
|
||||
@@ -81,6 +89,8 @@ _INDEX_HTML = """<!doctype html>
|
||||
|
||||
<script>
|
||||
const TYPES = "&type=manhwa&type=manhua&type=manga";
|
||||
let matchesData = {};
|
||||
let currentSort = { col: "title", asc: true };
|
||||
|
||||
function fmtTime(unix) {
|
||||
if (!unix) return "";
|
||||
@@ -94,10 +104,18 @@ function searchUrl(title) {
|
||||
|
||||
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) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.originalTitle = title;
|
||||
tr.dataset.title = title;
|
||||
|
||||
// Title — link only, not editable
|
||||
const titleTd = document.createElement("td");
|
||||
titleTd.className = "title";
|
||||
const titleLink = document.createElement("a");
|
||||
@@ -105,114 +123,172 @@ function makeRow(title, e) {
|
||||
titleLink.target = "_blank";
|
||||
titleLink.rel = "noopener";
|
||||
titleLink.textContent = title;
|
||||
const titleInput = document.createElement("input");
|
||||
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);
|
||||
titleTd.appendChild(titleLink);
|
||||
tr.appendChild(titleTd);
|
||||
|
||||
function field(value) {
|
||||
const td = document.createElement("td");
|
||||
const inp = document.createElement("input");
|
||||
inp.value = value || "";
|
||||
inp.addEventListener("input", () => tr.classList.add("dirty"));
|
||||
td.appendChild(inp);
|
||||
return [td, inp];
|
||||
}
|
||||
|
||||
const [idTd, idInp] = field(e.mangabakaId);
|
||||
const [nameTd, nameInp] = field(e.mangabakaName);
|
||||
const [urlTd, urlInp] = field(e.imageUrl);
|
||||
// mangabakaId — editable
|
||||
const idTd = document.createElement("td");
|
||||
idTd.className = "id";
|
||||
const idInp = document.createElement("input");
|
||||
idInp.value = e.mangabakaId || "";
|
||||
idInp.dataset.original = e.mangabakaId || "";
|
||||
idInp.addEventListener("input", () => {
|
||||
if (idInp.value !== idInp.dataset.original) tr.classList.add("dirty");
|
||||
else tr.classList.remove("dirty");
|
||||
updateDirtyCount();
|
||||
});
|
||||
idTd.appendChild(idInp);
|
||||
tr.appendChild(idTd);
|
||||
|
||||
// mangabakaName — plain text (info only)
|
||||
const nameTd = document.createElement("td");
|
||||
nameTd.className = "name";
|
||||
nameTd.textContent = e.mangabakaName || "";
|
||||
tr.appendChild(nameTd);
|
||||
|
||||
// firstMatchTime — plain text
|
||||
const timeTd = document.createElement("td");
|
||||
timeTd.textContent = fmtTime(e.firstMatchTime);
|
||||
tr.appendChild(timeTd);
|
||||
|
||||
// Image
|
||||
const imgTd = document.createElement("td");
|
||||
imgTd.className = "image";
|
||||
const img = document.createElement("img");
|
||||
img.src = e.imageUrl || "";
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
urlInp.addEventListener("input", () => { img.src = urlInp.value; });
|
||||
imgTd.append(img, urlInp);
|
||||
imgTd.appendChild(img);
|
||||
tr.appendChild(imgTd);
|
||||
|
||||
// Actions
|
||||
const actTd = document.createElement("td");
|
||||
actTd.className = "actions";
|
||||
const save = document.createElement("button");
|
||||
save.textContent = "Save";
|
||||
save.className = "primary";
|
||||
save.addEventListener("click", async () => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
save.addEventListener("click", () => saveRow(tr));
|
||||
const del = document.createElement("button");
|
||||
del.textContent = "Delete";
|
||||
del.className = "danger";
|
||||
del.style.marginLeft = ".25rem";
|
||||
del.addEventListener("click", async () => {
|
||||
if (!confirm("Delete " + tr.dataset.originalTitle + "?")) return;
|
||||
setStatus("Deleting " + tr.dataset.originalTitle + "…");
|
||||
del.addEventListener("click", () => deleteRow(tr));
|
||||
actTd.append(save, del);
|
||||
tr.appendChild(actTd);
|
||||
|
||||
tr._idInp = idInp;
|
||||
tr._nameTd = nameTd;
|
||||
tr._img = img;
|
||||
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: tr.dataset.originalTitle }),
|
||||
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;
|
||||
});
|
||||
actTd.append(save, del);
|
||||
tr.appendChild(actTd);
|
||||
return tr;
|
||||
}
|
||||
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() {
|
||||
setStatus("Loading…");
|
||||
const tbody = document.getElementById("rows");
|
||||
tbody.innerHTML = "";
|
||||
try {
|
||||
const r = await fetch("/api/matches");
|
||||
const data = await r.json();
|
||||
const matches = data.matches || {};
|
||||
const titles = Object.keys(matches).sort((a,b)=>a.localeCompare(b));
|
||||
for (const t of titles) tbody.appendChild(makeRow(t, matches[t]));
|
||||
setStatus(titles.length + " entries");
|
||||
applyFilter();
|
||||
matchesData = data.matches || {};
|
||||
render();
|
||||
setStatus(Object.keys(matchesData).length + " entries");
|
||||
} catch (err) {
|
||||
setStatus("Load failed: " + err.message);
|
||||
}
|
||||
@@ -221,13 +297,14 @@ async function load() {
|
||||
function applyFilter() {
|
||||
const q = document.getElementById("filter").value.toLowerCase();
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("filter").addEventListener("input", applyFilter);
|
||||
document.getElementById("reload").addEventListener("click", load);
|
||||
document.getElementById("batchSave").addEventListener("click", batchSave);
|
||||
document.getElementById("build").addEventListener("click", async () => {
|
||||
if (!confirm("Run full scan? This may take several minutes.")) return;
|
||||
setStatus("Building… (running on the server)");
|
||||
@@ -240,6 +317,14 @@ document.getElementById("build").addEventListener("click", async () => {
|
||||
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();
|
||||
</script>
|
||||
@@ -250,9 +335,10 @@ load();
|
||||
|
||||
class MatchesWebApp:
|
||||
"""
|
||||
Flask app exposing the MatchesCache. `mover` is optional — if provided,
|
||||
POST /api/build triggers SuwayomiMover.build_matches_only() on a worker
|
||||
thread.
|
||||
Flask app exposing the MatchesCache. `mover` is required when you want
|
||||
POST /api/matches to resolve a new mangabakaId against MangaBaka (it
|
||||
uses the mover's rate-limited session) and when POST /api/build should
|
||||
work.
|
||||
"""
|
||||
|
||||
def __init__(self, cache: MatchesCache, *,
|
||||
@@ -296,7 +382,7 @@ class MatchesWebApp:
|
||||
return self._thread
|
||||
|
||||
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:
|
||||
self._thread.join()
|
||||
|
||||
@@ -321,15 +407,33 @@ class MatchesWebApp:
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return Response("title is required", status=400)
|
||||
original = (body.get("originalTitle") or "").strip() or title
|
||||
if original != title:
|
||||
cache.rename(original, title)
|
||||
|
||||
new_id_raw = body.get("mangabakaId")
|
||||
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(
|
||||
title,
|
||||
mangabaka_id=body.get("mangabakaId"),
|
||||
mangabaka_name=body.get("mangabakaName"),
|
||||
image_url=body.get("imageUrl"),
|
||||
first_match_time=body.get("firstMatchTime"),
|
||||
mangabaka_id=new_id,
|
||||
mangabaka_name=new_name,
|
||||
image_url=new_image,
|
||||
)
|
||||
return jsonify({"title": title, "entry": entry})
|
||||
|
||||
|
||||
+42
-12
@@ -51,13 +51,14 @@ from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from ComicInfoBuilder import ComicInfoBuilder, _pick_cover_url
|
||||
from ComicInfoBuilder import ComicInfoBuilder, _pick_cover_url, _SEARCH_TYPES
|
||||
from MangadexVolumeResolver import MangaDexVolumeResolver
|
||||
from MangaBakaWorksResolver import MangaBakaWorksResolver
|
||||
from MALResolver import MALResolver
|
||||
from AniListResolver import AniListResolver
|
||||
from KavitaPersonUpdater import KavitaPersonUpdater
|
||||
from MatchesCache import MatchesCache
|
||||
from MangaBakaRateLimit import apply_to_session as _apply_mangabaka_rate_limit
|
||||
|
||||
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"}
|
||||
@@ -303,6 +304,8 @@ class SuwayomiMover:
|
||||
# to maximise cache hits and minimise API round-trips.
|
||||
session = requests.Session()
|
||||
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._mal = MALResolver(request_timeout=request_timeout)
|
||||
@@ -362,6 +365,18 @@ class SuwayomiMover:
|
||||
raise FileNotFoundError(
|
||||
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:
|
||||
"""
|
||||
Walks every series under the Suwayomi root and resolves each one
|
||||
@@ -410,7 +425,8 @@ class SuwayomiMover:
|
||||
try:
|
||||
resp = self._session.get(
|
||||
search_url,
|
||||
params={"q": builder_title, "page": 1, "limit": 1},
|
||||
params={"q": builder_title, "type": _SEARCH_TYPES,
|
||||
"page": 1, "limit": 1},
|
||||
timeout=self._timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data") or []
|
||||
@@ -555,27 +571,41 @@ class SuwayomiMover:
|
||||
# Usage example
|
||||
# --------------------------------------------------------------------------
|
||||
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_URL = "http://192.168.2.2:5000"
|
||||
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(
|
||||
SUWAYOMI_PATH,
|
||||
KAVITA_PATH,
|
||||
kavita_base_url=KAVITA_URL,
|
||||
kavita_api_key=KAVITA_KEY,
|
||||
delete_source=False
|
||||
delete_source=False,
|
||||
matches_cache=matches_cache,
|
||||
)
|
||||
|
||||
# Process a single series
|
||||
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']}")
|
||||
# ---- Option A: build matches.json only (no moves / no Kavita sync) ----
|
||||
data = mover.build_matches_only()
|
||||
matches = data.get("matches", {})
|
||||
print(f"\n[matches] {len(matches)} entries total — file: {MATCHES_PATH}")
|
||||
for title, entry in list(matches.items())[:10]:
|
||||
print(f" {title!r:50s} id={entry.get('mangabakaId')} "
|
||||
f"name={entry.get('mangabakaName')!r}")
|
||||
|
||||
# ---- 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:
|
||||
# results = mover.process_all()
|
||||
|
||||
Reference in New Issue
Block a user