WebApp changes
This commit is contained in:
@@ -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
@@ -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 []
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user