duplicate entry fix
Build and Deploy / build (push) Successful in 34s
Build and Deploy / deploy (push) Successful in 36s

This commit is contained in:
2026-07-05 09:06:51 +02:00
parent be2c227e1d
commit 33d7bd9590
2 changed files with 73 additions and 21 deletions
+36 -8
View File
@@ -196,11 +196,20 @@ class ComicInfoBuilder:
mal_resolver: "MALResolver | None" = None, mal_resolver: "MALResolver | None" = None,
al_resolver: "AniListResolver | None" = None, al_resolver: "AniListResolver | None" = None,
matches_cache: "MatchesCache | None" = None, matches_cache: "MatchesCache | None" = None,
cover_cache: "CoverCache | None" = None): cover_cache: "CoverCache | None" = None,
match_title: "str | None" = None):
if not manga_title or not str(manga_title).strip(): if not manga_title or not str(manga_title).strip():
raise ValueError("manga_title must not be empty.") raise ValueError("manga_title must not be empty.")
self._manga_title = str(manga_title).strip() self._manga_title = str(manga_title).strip()
# matches.json identity key. The mover passes the *folder name*
# here so the cache key stays stable across runs — manga_title (the
# ComicInfo <Series> title) is only used as the search query and can
# differ from the folder (Windows-illegal chars are substituted in
# folder names, e.g. "Foo" vs [Foo]).
self._match_title = (str(match_title).strip()
if match_title and str(match_title).strip()
else None)
self._chapter = chapter self._chapter = chapter
self.api_base_url = api_base_url.rstrip("/") self.api_base_url = api_base_url.rstrip("/")
@@ -258,6 +267,11 @@ class ComicInfoBuilder:
def manga_title(self) -> str: def manga_title(self) -> str:
return self._manga_title return self._manga_title
@property
def match_title(self) -> str:
"""matches.json key; falls back to manga_title when not set."""
return self._match_title or self._manga_title
@manga_title.setter @manga_title.setter
def manga_title(self, value): def manga_title(self, value):
value = str(value).strip() value = str(value).strip()
@@ -412,19 +426,33 @@ class ComicInfoBuilder:
Resolves `title` to a MangaBaka series. Resolves `title` to a MangaBaka series.
Lookup order: Lookup order:
1. matches.json cache (if attached) — uses the stored series ID 1. matches.json cache (if attached) — keyed by `match_title` (the
to fetch the full series, skipping the search step entirely. stable folder name); uses the stored series ID to fetch the
2. Fresh MangaBaka search — top hit. The match is persisted to full series, skipping the search step entirely.
matches.json before being returned so it survives a crash. 2. Fresh MangaBaka search with `title` (the cleaned ComicInfo
series title — better search hits than folder names with
substituted characters). A genuinely new match is persisted
under `match_title` so it survives a crash.
""" """
cache_key = self.match_title
if self._matches_cache is not None: if self._matches_cache is not None:
cached = self._matches_cache.get(title) cached = self._matches_cache.get(cache_key)
if cached is None and cache_key != title:
# Migration: older versions keyed entries by the (cleaned)
# ComicInfo series title, which drifts from the folder name
# when it contains Windows-illegal characters. Move such an
# entry onto the stable folder-name key.
if self._matches_cache.get(title):
self._matches_cache.rename(title, cache_key)
cached = self._matches_cache.get(cache_key)
print(f"[ComicInfoBuilder] migrated match key "
f"{title!r} -> {cache_key!r}", flush=True)
if cached and cached.get("mangabakaId"): if cached and cached.get("mangabakaId"):
try: try:
return self._fetch_series_by_id(cached["mangabakaId"]) return self._fetch_series_by_id(cached["mangabakaId"])
except Exception as exc: except Exception as exc:
print(f"[ComicInfoBuilder] cached id " print(f"[ComicInfoBuilder] cached id "
f"{cached['mangabakaId']} for {title!r} failed " f"{cached['mangabakaId']} for {cache_key!r} failed "
f"({exc}); falling back to fresh search", f"({exc}); falling back to fresh search",
flush=True) flush=True)
@@ -443,7 +471,7 @@ class ComicInfoBuilder:
# for uncached titles, or when a cached id failed to resolve; in # for uncached titles, or when a cached id failed to resolve; in
# the latter case the manual entry must be preserved.) # the latter case the manual entry must be preserved.)
self._matches_cache.add_if_absent( self._matches_cache.add_if_absent(
title, cache_key,
mangabaka_id=series.get("id"), mangabaka_id=series.get("id"),
mangabaka_name=series.get("title") or "", mangabaka_name=series.get("title") or "",
image_url=_pick_thumbnail_url(series.get("cover")), image_url=_pick_thumbnail_url(series.get("cover")),
+37 -13
View File
@@ -445,7 +445,13 @@ class SuwayomiMover:
if not manga_dir.is_dir(): if not manga_dir.is_dir():
continue continue
raw_series = manga_dir.name # The FOLDER NAME is the stable matches.json key — it is
# what exists on disk and never drifts between runs. The
# ComicInfo <Series> title is only the search query: it has
# the real characters (e.g. quotes) that folder names can't
# carry, so it produces better MangaBaka hits.
folder_title = manga_dir.name
raw_series = folder_title
for chapter_dir in sorted(manga_dir.iterdir(), for chapter_dir in sorted(manga_dir.iterdir(),
key=lambda p: _chapter_sort_key(p.name)): key=lambda p: _chapter_sort_key(p.name)):
if chapter_dir.is_dir(): if chapter_dir.is_dir():
@@ -454,36 +460,50 @@ class SuwayomiMover:
raw_series = fields["Series"] raw_series = fields["Series"]
break break
builder_title = _clean_suwayomi_title(raw_series) search_title = _clean_suwayomi_title(raw_series)
if self._matches_cache.get(builder_title): if self._matches_cache.get(folder_title):
print(f"[matches] {builder_title} — cached") print(f"[matches] {folder_title} — cached")
continue continue
print(f"[matches] {builder_title} — searching") # Migration: older versions keyed entries by the cleaned
# ComicInfo/folder title. Move such an entry onto the
# stable folder-name key instead of creating a duplicate.
for legacy_key in {search_title,
_clean_suwayomi_title(folder_title)}:
if (legacy_key != folder_title
and self._matches_cache.get(legacy_key)):
self._matches_cache.rename(legacy_key, folder_title)
print(f"[matches] {folder_title} — migrated key "
f"from {legacy_key!r}")
break
if self._matches_cache.get(folder_title):
continue
print(f"[matches] {folder_title} — searching "
f"(query: {search_title!r})")
try: try:
resp = self._session.get( resp = self._session.get(
search_url, search_url,
params={"q": builder_title, "type": _SEARCH_TYPES, params={"q": search_title, "type": _SEARCH_TYPES,
"page": 1, "limit": 1}, "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 []
if not data: if not data:
print(f" [warn] no MangaBaka match for {builder_title!r}") print(f" [warn] no MangaBaka match for {search_title!r}")
continue continue
series = data[0] series = data[0]
# add_if_absent (never overwrite) — the get() above # add_if_absent (never overwrite) — a match run can only
# already skips cached titles; this is a safety net so a # ever append, never replace manual corrections.
# match run can only ever append, never replace.
self._matches_cache.add_if_absent( self._matches_cache.add_if_absent(
builder_title, folder_title,
mangabaka_id=series.get("id"), mangabaka_id=series.get("id"),
mangabaka_name=series.get("title") or "", mangabaka_name=series.get("title") or "",
image_url=_pick_thumbnail_url(series.get("cover")), image_url=_pick_thumbnail_url(series.get("cover")),
) )
except Exception as exc: except Exception as exc:
print(f" [warn] search failed for {builder_title!r}: {exc}") print(f" [warn] search failed for {search_title!r}: {exc}")
return self._matches_cache.all() return self._matches_cache.all()
@@ -524,7 +544,10 @@ class SuwayomiMover:
raw_series = xml_series raw_series = xml_series
builder_title = _clean_suwayomi_title(raw_series) builder_title = _clean_suwayomi_title(raw_series)
# One builder per series — metadata fetched once, reused for all chapters. # One builder per series — metadata fetched once, reused for all
# chapters. match_title = the folder name: the stable matches.json
# key (the XML series title is only the search query and can drift
# from the folder when it contains Windows-illegal characters).
builder = ComicInfoBuilder( builder = ComicInfoBuilder(
builder_title, chapter=1, builder_title, chapter=1,
api_base_url=self._api_base_url, api_base_url=self._api_base_url,
@@ -537,6 +560,7 @@ class SuwayomiMover:
al_resolver=self._al, al_resolver=self._al,
matches_cache=self._matches_cache, matches_cache=self._matches_cache,
cover_cache=self._cover_cache, cover_cache=self._cover_cache,
match_title=manga_title,
) )
# Fetch MangaBaka metadata now to get the canonical title and MAL ID. # Fetch MangaBaka metadata now to get the canonical title and MAL ID.