From 33d7bd95907ef3748be448033eb98c881703ebf1 Mon Sep 17 00:00:00 2001 From: JohannesBOT Date: Sun, 5 Jul 2026 09:06:51 +0200 Subject: [PATCH] duplicate entry fix --- src/manga/ComicInfoBuilder.py | 44 ++++++++++++++++++++++++------ src/manga/SuwayomiMover.py | 50 ++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/manga/ComicInfoBuilder.py b/src/manga/ComicInfoBuilder.py index af9b794..4e17ad2 100644 --- a/src/manga/ComicInfoBuilder.py +++ b/src/manga/ComicInfoBuilder.py @@ -196,11 +196,20 @@ class ComicInfoBuilder: mal_resolver: "MALResolver | None" = None, al_resolver: "AniListResolver | 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(): raise ValueError("manga_title must not be empty.") 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 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.api_base_url = api_base_url.rstrip("/") @@ -258,6 +267,11 @@ class ComicInfoBuilder: def manga_title(self) -> str: 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 def manga_title(self, value): value = str(value).strip() @@ -412,19 +426,33 @@ class ComicInfoBuilder: Resolves `title` to a MangaBaka series. Lookup order: - 1. matches.json cache (if attached) — uses the stored series ID - to fetch the full series, skipping the search step entirely. - 2. Fresh MangaBaka search — top hit. The match is persisted to - matches.json before being returned so it survives a crash. + 1. matches.json cache (if attached) — keyed by `match_title` (the + stable folder name); uses the stored series ID to fetch the + full series, skipping the search step entirely. + 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: - 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"): try: return self._fetch_series_by_id(cached["mangabakaId"]) except Exception as exc: 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", flush=True) @@ -443,7 +471,7 @@ class ComicInfoBuilder: # for uncached titles, or when a cached id failed to resolve; in # the latter case the manual entry must be preserved.) self._matches_cache.add_if_absent( - title, + cache_key, mangabaka_id=series.get("id"), mangabaka_name=series.get("title") or "", image_url=_pick_thumbnail_url(series.get("cover")), diff --git a/src/manga/SuwayomiMover.py b/src/manga/SuwayomiMover.py index df92cc9..2568336 100644 --- a/src/manga/SuwayomiMover.py +++ b/src/manga/SuwayomiMover.py @@ -445,7 +445,13 @@ class SuwayomiMover: if not manga_dir.is_dir(): 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 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(), key=lambda p: _chapter_sort_key(p.name)): if chapter_dir.is_dir(): @@ -454,36 +460,50 @@ class SuwayomiMover: raw_series = fields["Series"] break - builder_title = _clean_suwayomi_title(raw_series) + search_title = _clean_suwayomi_title(raw_series) - if self._matches_cache.get(builder_title): - print(f"[matches] {builder_title} — cached") + if self._matches_cache.get(folder_title): + print(f"[matches] {folder_title} — cached") 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: resp = self._session.get( search_url, - params={"q": builder_title, "type": _SEARCH_TYPES, + params={"q": search_title, "type": _SEARCH_TYPES, "page": 1, "limit": 1}, timeout=self._timeout) resp.raise_for_status() data = resp.json().get("data") or [] if not data: - print(f" [warn] no MangaBaka match for {builder_title!r}") + print(f" [warn] no MangaBaka match for {search_title!r}") continue series = data[0] - # add_if_absent (never overwrite) — the get() above - # already skips cached titles; this is a safety net so a - # match run can only ever append, never replace. + # add_if_absent (never overwrite) — a match run can only + # ever append, never replace manual corrections. self._matches_cache.add_if_absent( - builder_title, + folder_title, mangabaka_id=series.get("id"), mangabaka_name=series.get("title") or "", image_url=_pick_thumbnail_url(series.get("cover")), ) 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() @@ -524,7 +544,10 @@ class SuwayomiMover: raw_series = xml_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_title, chapter=1, api_base_url=self._api_base_url, @@ -537,6 +560,7 @@ class SuwayomiMover: al_resolver=self._al, matches_cache=self._matches_cache, cover_cache=self._cover_cache, + match_title=manga_title, ) # Fetch MangaBaka metadata now to get the canonical title and MAL ID.