From 95b2506ba3165a077e85b4c1c1a3b8615c52db44 Mon Sep 17 00:00:00 2001 From: chodeus Date: Fri, 7 Aug 2026 14:02:58 +0800 Subject: [PATCH 1/2] fix(cl2k): heal each asset in the Drive folder that receives its type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poster_self_heal paired every locally-saved row with a single Drive id — the first upload claiming "poster". Now that art types route to their own folders, a generated logo/background/squareart was renamed against the POSTER Drive, where the file does not exist. rclone moveto exits non-zero, apply_proposal raises before the local rename, and the row never heals — silently, on every run. Resolve the twin from the Drive claiming the row's own image_type. A type no Drive claims still falls back to the poster Drive, then to the first Drive at all, preserving the uploads-off behaviour. Extracted as drive_twins() so the mapping is testable; the new tests fail against the previous single-id behaviour. --- backend/modules/poster_self_heal.py | 54 ++++++++++++------- tests/test_poster_self_heal_resolver.py | 71 +++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 20 deletions(-) diff --git a/backend/modules/poster_self_heal.py b/backend/modules/poster_self_heal.py index 1719fc3c..3d93b41a 100644 --- a/backend/modules/poster_self_heal.py +++ b/backend/modules/poster_self_heal.py @@ -43,6 +43,35 @@ def _is_under(path: str, base_dir: str) -> bool: return p == base or p.startswith(base + os.sep) +def drive_twins(cl2k) -> tuple: + """``(drive_ids, twin_of)`` for the configured ``gdrive_uploads``. + + ``twin_of(image_type)`` gives the Drive folder a locally-saved row of that + type should be renamed in. Art types route to their own folders, so a logo + must heal against the logo Drive — renaming it in the poster Drive raises + (the file isn't there) and the row then never heals at all. First claimer + wins, matching ``_drive_targets``' config-order preference; a type no Drive + claims falls back to the poster Drive, else the first Drive at all (the + pre-redesign behaviour of healing the linked folder even with uploads off). + """ + drive_ids: list = [] + by_type: dict = {} + for drive in getattr(cl2k, "gdrive_uploads", None) or []: + fid = (getattr(drive, "folder_id", "") or "").strip() + if not fid: + continue + if fid not in drive_ids: + drive_ids.append(fid) + for image_type in getattr(drive, "types", None) or []: + by_type.setdefault(image_type, fid) + fallback = by_type.get("poster") or (drive_ids[0] if drive_ids else None) + + def twin_of(image_type): + return by_type.get(image_type or "poster", fallback) + + return drive_ids, twin_of + + class PosterSelfHeal(ChubModule): """Detect stale ids / changed titles / missing ids on CL2K posters.""" @@ -61,24 +90,7 @@ def run(self) -> None: p = (getattr(folder, "path", "") or "").strip() if p and p not in local_dirs: local_dirs.append(p) - drive_ids: list = [] - poster_twin_id = None - for drive in getattr(cl2k, "gdrive_uploads", None) or []: - fid = (getattr(drive, "folder_id", "") or "").strip() - if not fid: - continue - if fid not in drive_ids: - drive_ids.append(fid) - # Where a locally-saved poster's Drive twin lives: the first Drive - # that receives posters (fall back to the first Drive at all, which - # matches the pre-redesign behaviour of healing the linked folder - # even with uploads off). - if poster_twin_id is None and "poster" in ( - getattr(drive, "types", None) or [] - ): - poster_twin_id = fid - if poster_twin_id is None and drive_ids: - poster_twin_id = drive_ids[0] + drive_ids, twin_of = drive_twins(cl2k) if not local_dirs and not drive_ids: self.logger.error( "CL2K maker has no local folders or Drive uploads configured — " @@ -137,8 +149,10 @@ def run(self) -> None: seen_names.add(base) drive_posters.append((parsed, fid)) - # Local posters heal their Drive twin (if any) in poster_twin_id. - posters = [(p, poster_twin_id) for p in local_posters] + drive_posters + # Local rows heal the Drive twin that receives their own image_type. + posters = [ + (p, twin_of(p.get("image_type"))) for p in local_posters + ] + drive_posters media_index = index_media(db.media.get_all()) reviews = poster_heal_review_for(db) diff --git a/tests/test_poster_self_heal_resolver.py b/tests/test_poster_self_heal_resolver.py index f9525e85..8e622f42 100644 --- a/tests/test_poster_self_heal_resolver.py +++ b/tests/test_poster_self_heal_resolver.py @@ -332,6 +332,77 @@ def test_is_under_empty_inputs_are_false(): assert not _is_under(None, "/base") +# --- a local row heals in the Drive that receives ITS image_type --- + +from backend.modules.poster_self_heal import drive_twins # noqa: E402 + + +class _Drive: + def __init__(self, folder_id, types): + self.folder_id = folder_id + self.types = types + + +class _Cl2k: + def __init__(self, gdrive_uploads): + self.gdrive_uploads = gdrive_uploads + + +_ROUTED = _Cl2k( + [ + _Drive("POSTERS", ["poster"]), + _Drive("BACKGROUNDS", ["background"]), + _Drive("LOGOS", ["logo"]), + _Drive("SQUAREART", ["squareart"]), + ] +) + + +def test_twin_follows_the_drive_claiming_that_image_type(): + drive_ids, twin_of = drive_twins(_ROUTED) + assert drive_ids == ["POSTERS", "BACKGROUNDS", "LOGOS", "SQUAREART"] + # The regression: art types must NOT all resolve to the poster Drive, or the + # rename targets a folder the file isn't in, raises, and never heals. + assert twin_of("poster") == "POSTERS" + assert twin_of("background") == "BACKGROUNDS" + assert twin_of("logo") == "LOGOS" + assert twin_of("squareart") == "SQUAREART" + + +def test_twin_falls_back_to_poster_drive_for_unclaimed_type(): + _, twin_of = drive_twins(_ROUTED) + # No Drive claims "banner"; a missing image_type means poster. + assert twin_of("banner") == "POSTERS" + assert twin_of(None) == "POSTERS" + + +def test_twin_falls_back_to_first_drive_when_nothing_claims_posters(): + # Uploads-off / asset-only setup: keep healing the linked folder. + _, twin_of = drive_twins(_Cl2k([_Drive("ONLY", [])])) + assert twin_of("poster") == "ONLY" + assert twin_of("logo") == "ONLY" + + +def test_twin_is_none_and_ids_empty_without_drives(): + drive_ids, twin_of = drive_twins(_Cl2k([])) + assert drive_ids == [] + assert twin_of("logo") is None + + +def test_twin_skips_blank_folder_ids_and_keeps_first_claimer(): + drive_ids, twin_of = drive_twins( + _Cl2k( + [ + _Drive(" ", ["logo"]), # blank id contributes nothing + _Drive("LOGOS_A", ["logo"]), + _Drive("LOGOS_B", ["logo"]), # later claimer must not win + ] + ) + ) + assert drive_ids == ["LOGOS_A", "LOGOS_B"] + assert twin_of("logo") == "LOGOS_A" + + # --- season formatting is not drift (Season 1 == Season 01, Specials, year) --- From 995423bfa562219d44ad3a78a21b2e81c63cbfce Mon Sep 17 00:00:00 2001 From: chodeus Date: Fri, 7 Aug 2026 14:07:50 +0800 Subject: [PATCH 2/2] docs(cl2k): trim the drive_twins docstring to its routing contract The rationale and failure mode belong in the PR body, not the source. --- backend/modules/poster_self_heal.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/backend/modules/poster_self_heal.py b/backend/modules/poster_self_heal.py index 3d93b41a..7fdbef5c 100644 --- a/backend/modules/poster_self_heal.py +++ b/backend/modules/poster_self_heal.py @@ -44,16 +44,9 @@ def _is_under(path: str, base_dir: str) -> bool: def drive_twins(cl2k) -> tuple: - """``(drive_ids, twin_of)`` for the configured ``gdrive_uploads``. - - ``twin_of(image_type)`` gives the Drive folder a locally-saved row of that - type should be renamed in. Art types route to their own folders, so a logo - must heal against the logo Drive — renaming it in the poster Drive raises - (the file isn't there) and the row then never heals at all. First claimer - wins, matching ``_drive_targets``' config-order preference; a type no Drive - claims falls back to the poster Drive, else the first Drive at all (the - pre-redesign behaviour of healing the linked folder even with uploads off). - """ + """``(drive_ids, twin_of)``; ``twin_of(image_type)`` resolves to the first + ``gdrive_uploads`` entry claiming that type, else the poster Drive, else the + first Drive.""" drive_ids: list = [] by_type: dict = {} for drive in getattr(cl2k, "gdrive_uploads", None) or []: