fix: load fetchart and fetch artwork after retag - #32
Merged
Conversation
FetchArt (and every other bundled Beets plugin) failed to import as beetsplug.<name> once /app was on sys.path: this repo's own beetsplug/__init__.py made /app/beetsplug a regular package, which shadows -- rather than merges with -- the real beetsplug namespace package installed in site-packages, instead of extending it the way Beets' pluginpath mechanism expects. Confirmed via a real --no-cache Docker build: `python -c "import beetsplug.fetchart"` raised ModuleNotFoundError, and beetsplug.__path__ was ['/app/beetsplug'] only. Removing the initializer (beetsplug/ has no other need for one; discpath.py doesn't depend on it) makes it an implicit PEP 420 namespace package, and beetsplug.__path__ now correctly merges both locations. Also fixes the second half of the reported sequence: an as-is import retagged with MusicBrainz data never got fresh artwork, because FetchArt was invoked automatically during the as-is import (before the MusicBrainz identity existed to search against) and never again after mbsync/write/move/recording-ID repair. Added _fetch_artwork_after_retag(), called from _ai_import_folder after that whole sequence: verifies the persisted mb_albumid actually matches what was just imported, then reuses the existing single-album _repair_album_art() repair path (the same one behind the manual POST /api/albums/<aid>/fetch-art retry action) so idempotency and actual-file verification live in one place, not duplicated. Artwork failure is reported truthfully (metadata_imported/identity_verified/ artwork_status/artwork_retryable) without ever undoing a successful import; a genuine job cancellation still propagates instead of being swallowed as an artwork failure. Also: routes_setup.py's plugin diagnostics now report fetchart explicitly (previously silently absent from the per-integration list), and the IntakePanel surfaces an artwork-retryable folder in "Needs attention" with a one-click Retry artwork action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Corrections to PR #32 before final review: 1. Direct Docker before/after comparison proves the exact application subprocess path (_beet_run's mbsync/write/move/fetchart, same BEET_BIN resolution, PYTHONPATH=/config, cwd=/app, non-root user) was never actually broken by beetsplug/__init__.py in either state -- the namespace shadowing only affects in-process `import beetsplug.*`. The deleted-__init__.py fix remains correct and necessary (it's what makes the smoke test's own prescribed diagnostic, and any future in-process plugin use, reliable), but the original PR overclaimed what it explained. 2. Added _fetchart_integration_status()/_fetchart_namespace_probe() to routes_setup.py's setup diagnostics: distinct configured/installed/ importable_in_process/loadable_by_beet_cli/bundled_namespace_merged/ operational signals, never reporting operational from config.yaml text or find_spec() alone. 3. Added POST /api/ai-batch/reconcile-artwork: re-verifies an album's actual on-disk art via the existing _album_art_status() check before ever clearing a folder's artwork_retryable flag. The Intake UI now polls the retry job to a terminal state and calls this endpoint rather than trusting job creation as success. 4. metadata_imported no longer defaults to true when _ai_import_folder unexpectedly returns an empty result. 5. Added tests/test_post_retag_artwork_integration.py (full _ai_import_folder sequencing against a real beets Library) and tests/test_artwork_retry_reconciliation.py (the new endpoint). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ation to job identity
- Treat beet mbsync/write/move subprocess timeouts (rc=124) as a hard
failure instead of silently continuing to artwork/dedup/Plex-refresh with
unverified tag state.
- Raise instead of defaulting to {} when _ai_import_folder returns a falsy
result, so the batch worker's existing failure path (queued for review)
handles it instead of counting it as an "imported" folder.
- Require batch_job_id, folder_id, and artwork_job_id on
POST /api/ai-batch/reconcile-artwork (no more "latest batch" fallback);
validate the artwork job against the real job store (type, album_id,
terminal status) and preserve truthful cancelled/timed_out outcomes
instead of collapsing everything to fetched/failed. Real on-disk artwork
always wins over what the job claims.
- Bind /api/albums/<aid>/fetch-art jobs to structured metadata
(album_art_repair + album_id) and a terminal_outcome so reconciliation can
verify job identity server-side.
- Make the FetchArt namespace diagnostic portable: stop matching the
literal string "site-packages" and instead check the resolved module
isn't under this app's own bundled directory, cross-checked against
importlib.metadata.
Also fixes the actual GitHub Actions failure (unit-tests/security both
failing): tests/test_post_retag_artwork_integration.py and
tests/test_artwork_retry_reconciliation.py rebind app.py's process-wide
lib/LIB_PATH/_AI_BATCH_STATE_DIR singletons in setUp() (not just once at
import), and use atexit instead of unittest.addModuleCleanup for temp-dir
cleanup -- addModuleCleanup stores callbacks in a single process-wide list
that gets drained whenever ANY module's tests finish, so under
`unittest discover` on GitHub's Linux runner a different module's teardown
was deleting these tests' temp directories before their own tests ran,
producing "attempt to write a readonly database". Reproduced and verified
the fix under the exact CI toolchain (Linux, Python 3.13, same discover
command) via Docker, including with modules loaded in reversed order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Iranman
marked this pull request as ready for review
July 23, 2026 22:18
Iranman
added a commit
that referenced
this pull request
Jul 30, 2026
* fix: close non-app backend path flows and sanitize errors (SEC-002 wave 2) Independently traced and closed all 25 open CodeQL alerts in routes_submissions.py, routes_setup.py, routes_lidarr.py, and backend/transaction_engine.py (scope agreed with repo owner; app.py, index.html, and frontend files remain out of scope for a future wave). Genuine fixes: - routes_submissions.py _abs_resolved(): performed zero containment before Path.expanduser().resolve(); reachable from an authenticated GET query parameter (/api/submissions/target?path=...) with no other validation in the chain -- an arbitrary directory-read/audio-tag-parse primitive anywhere in the container filesystem. Fixed by requiring the resolved path fall under MUSIC_ROOT or DOWNLOADS_ROOT, reusing the existing _path_is_under()/_SUBMISSION_ALLOWED_ROOTS already present in the file (CodeQL #128). - routes_submissions.py: sanitized 3 genuinely broad `except Exception` handlers (submission_target, submission_reference_url's metadata fetch, validate_musicbrainz_release) that returned raw exception text; deliberate ValueError/KeyError paths with fixed safe messages are unchanged (CodeQL #323, #34, #36). - routes_setup.py: sanitized 6 broad-except response leaks (_write_env_file x2 call sites, AI/MusicBrainz/AcoustID/Plex connectivity tests) plus 2 unreported instances of the identical pattern found while reviewing the same functions (fpcalc check, dormant _check_path helper) (CodeQL #24, #25, #26, #27, #322). - routes_setup.py setup_test_ai(): "openai.com" in base_url matched any URL merely containing that substring; replaced with a real parsed- hostname check (CodeQL #102, py/incomplete-url-substring-sanitization). - routes_lidarr.py wanted_lidarr(): the true unbounded str(exc) source is app.py's _acq_fetch_lidarr_wanted() (out of scope this wave); fixed at this file's boundary instead, only echoing the known-safe "not configured" message (CodeQL #19). Proven false positives (14 total, dismissed on GitHub, each with an individual justification and a passing behavioral test -- see docs/TECHNICAL_DEBT.md SEC-002 for full per-alert detail): - #23, #28, #30, #31, #32, #33, #321: ValueError/KeyError raised only with fixed, deliberately-crafted, non-sensitive messages (env-var key names, numeric IDs the caller supplied, or OutboundPolicyError's own safe text), never a wrapped raw system exception. - #20, #21: routes_lidarr._http_error_message() already redacts every exception type except a RuntimeError carrying one of two fixed config-status strings from _lidarr_config_error(). - #129, #130, #131, #132, #133: TransactionStore._path() requires a txn_ prefix and rejects /, \, and NUL before any of its 5 flagged sink lines; it is the only path-construction site in the file. Also corrects docs/TECHNICAL_DEBT.md SEC-002's prior description of PR #52's #332/#334/#335 (previously described only as "fixed"; records that CodeQL's rescan still flagged them post-fix and they required a separate post-merge dismissal, with the reason each fix wasn't recognized by CodeQL's static model). * docs: update SEC-002 remediation history Corrects the PR #52 record (#332/#334/#335 required a post-merge dismissal, not just a code fix -- CodeQL's static model didn't recognize either mitigation) and records wave 2's full accounting: starting/in-scope counts, alert numbers, root-cause groups, fixes, dismissals, remaining 287-alert count (all in app.py/frontend), and the next recommended wave. * fix: check Lidarr config status directly instead of matching error text CodeQL flagged a new alert (#407) against the previous wanted_lidarr() fix: "not configured" in error.lower() distinguishes safe-to-echo messages from _acq_fetch_lidarr_wanted()'s own unbounded str(exc) by pattern-matching the exception TEXT, which isn't airtight -- a coincidentally-worded exception could slip through. Fixed by checking this file's own type-safe _lidarr_config_error() before ever calling _acq_fetch_lidarr_wanted(), removing the text-matching entirely.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second correction pass — new head:
de21ab1(previouslyf8ccfa4, base unchanged14c7f02). This pass fixes the six remaining blockers from the prior review: the GitHub-only CI failure (unit-tests/securityboth red onf8ccfa4), test isolation, timeout handling, empty-result accounting, artwork-retry truthfulness, and reconciliation identity. Still draft, still unmerged, still not merged intomain.The previous PR description's claim of "all six workflows green" at
f8ccfa4was wrong — GitHub's Linux runner failedunit-testsandsecurity(10 errors each) even though the same suite passed locally on Windows 5+ times. That claim is retracted below.GitHub-only CI failure: found and fixed
gh run view --log-failedon both failing workflows showed the identical failure, both failing during Python unittest discovery:10 errors,
Ran 1257 tests— all inAiImportFolderSequenceTests.setUp.Root cause, found by reproducing the exact CI command in a matching Linux/Python 3.13 container (not guessed from the traceback alone):
unittest.addModuleCleanupstores its callbacks in a single process-wide list (unittest.case._module_cleanups), not one scoped per module. Underunittest discover, every test module is imported (registering its cleanups) before any test runs; whichever module's tests finish first then drains and fires every other module's still-pending cleanups too — includingshutil.rmtree(<some other module's own temp root>)for modules whose tests hadn't run yet. This is confirmed with a minimal repro: importingtest_artwork_retry_reconciliationthentest_post_retag_artwork_integration, running only the first module's suite, and observing the second module's temp root already deleted (os.path.isdir(...)→False) before any of its own tests executed. Never reproduced on Windows locally because the specific module ordering happened not to trigger it there.Separately, a related but distinct gap: even with directory-deletion fixed,
app.py'sLIB_PATH/lib/_AI_BATCH_STATE_DIRare computed once at import time from env vars.app.pyis a process-wide singleton (sys.modules['app']); whichever of these two test modules imports it last wins those globals for both modules' tests, regardless of which module's tests actually run first. A one-time rebind at import time isn't enough — confirmed by deliberately loading the modules viapython -m unittest test_post_retag_artwork_integration test_artwork_retry_reconciliation ...(this module first, the other module importing second and last), which reproduced a new, different failure via this PR's own path-containment safety assertion.Fix, in both
tests/test_post_retag_artwork_integration.pyandtests/test_artwork_retry_reconciliation.py:unittest.addModuleCleanup(shutil.rmtree, ...)/addModuleCleanup(_env_patcher.stop)withatexit.register(...)— fires once at real process exit, immune to the shared-list-draining behavior above._bind_app_globals_to_this_test_module(APP, _TMP_ROOT), which explicitly rebindsapp.LIB_PATH,app.lib(a freshbeets.library.Library, closing the previous one), andapp._AI_BATCH_STATE_DIRto this module's own temp root. Called once at import and again in every test'ssetUp()— the setUp call is what makes this correct regardless of import/run order between sibling modules, not just regardless of order relative to unrelated modules._assert_path_owned_by_test()safety assertion before every raw SQL mutation in_clear_library(), verifyingAPP.LIB_PATHactually resolves under this module's own temp root — this is what caught the import-order gap above during validation, rather than letting it silently corrupt another module's temp directory.Verified, not assumed: ran
pip install -r requirements.txt && python -m unittest discover -s tests -p "test_*.py"(the literalunit-tests.yml/security.ymlcommands) insidepython:3.13-slimin Docker — 13 consecutive full-discovery runs, 1271 tests each, all green except one confirmed-unrelated pre-existing flake (see below). Also re-ran the focused reproduction with modules loaded in reversed/explicit order 5x, all green. This is the same failure mode the task explicitly warned about; it's now fixed and reproduced-fixed under the real CI toolchain, not just asserted from local runs.Pre-existing, unrelated flake found during this validation (not touched by this PR):
tests/test_ai_batch_retry_race.py::SimultaneousRecoverStartsExactlyOneWorkerTests::test_concurrent_recover_requests_start_exactly_one_workerfailed once in ~20 full-discovery runs (thread-timing race asserting exactly one winner among concurrent recover requests, occasionally saw two). That file was last modified in an unrelated, already-merged PR (#15) and has zero diff on this branch — flagging for separate follow-up, not fixed here (out of this PR's scope).The five other corrections
1. Subprocess timeouts (rc=124) no longer treated as success for mbsync/write/move.
_ai_import_folder's per-stage loop previously only checked for-9(cancelled); any other non-zero code including124(timeout) was logged and silently ignored, continuing on to recording-ID repair and artwork fetching with unverified tag state. Nowr2.returncode == 124raises immediately, same as cancellation. (Step 1, the initialimportcall, already raised correctly forrc >= 2, which includes 124 — no change needed there.) Four new tests cover mbsync/write/move/import timeout at each stage, each asserting_repair_album_artis never called.2. Empty
_ai_import_folder()results no longer counted as a successful import. The AI-batch worker previously didif not import_result: import_result = {}and then still ranimported += 1/status="imported". Every real return path from_ai_import_folderreturns a populated dict, so a falsy result is itself an internal failure; it nowraise RuntimeError(...), routing into the existingexceptbranch (queued for review,import_failed) instead of a false "imported" status.3. Artwork retry reconciliation now preserves cancelled/timed_out, not just fetched/failed.
_repair_album_artnow tracks whether the FetchArt subprocess itself timed out and threads that through to a distinguishable"fetchart timed out"error string when no fallback art is found either.POST /api/albums/<aid>/fetch-art's job now records a structuredterminal_outcome(success/cancelled/timed_out/failed) via the job'supdate_state(), and is tagged withmetadata={"type": "album_art_repair", "album_id": aid}.4. Reconciliation requires an exact batch/folder/job identity — no "latest batch" fallback.
POST /api/ai-batch/reconcile-artworknow requiresbatch_job_id,folder_id, andartwork_job_id(400batch_job_id_required/folder_id_required/artwork_job_id_requiredif any is missing/empty). The_ai_batch_latest_state()fallback is removed entirely. The artwork job is looked up from the real job store and validated server-side (404 artwork_job_not_found,400 artwork_job_mismatchif type/album_id don't match this folder,409 artwork_job_not_terminalif still running) rather than trusted from the client. Truth table: real on-disk art present →fetchedalways wins, even if the job claims failure/cancellation/timeout; otherwise the job'sterminal_outcomemaps tocancelled/timed_out/failed.IntakePanel.tsxandclient.tsupdated to send the retry job's own ID asartwork_job_id; the "Needs attention" chip now reads "Artwork retry cancelled" / "Artwork retry timed out" / "Artwork failed" instead of a single generic label.5. FetchArt namespace diagnostic no longer matches the literal string
"site-packages". That string is layout-specific (absent for editable installs, some distrodist-packageslayouts, etc.) — the GitHub Actions runner itself resolves beets to/home/runner/.local/lib/python3.12/site-packages/..., which happened to still contain the string, but relying on it was fragile._fetchart_namespace_probe()now checks that the resolved module path does not live under this app's own bundled code directory, cross-checked withimportlib.metadata.distribution("beets")actually resolving. Verified against the real, unmodified--no-cacheDocker image:/api/setup/statusstill reportsfetchart.operational: truewith the new logic (confirmed via a livecurlagainst the running container, not a UI claim).Files changed (this pass, on top of
f8ccfa4)app.py— mbsync/write/move timeout handling, empty-import-result raise,_repair_album_arttimeout tracking,/api/albums/<aid>/fetch-artjob metadata + terminal outcome,/api/ai-batch/reconcile-artworkidentity enforcement + truth tableroutes_setup.py—_fetchart_namespace_probe()portability rewritefrontend/src/api/client.ts—reconcileArtwork()now takesartworkJobIdfrontend/src/features/intake/IntakePanel.tsx— sendsartwork_job_id, distinct cancelled/timed-out/failed chip labelstests/test_post_retag_artwork_integration.py— isolation fix (atexit+ per-test rebind + containment assertion), 4 new/replaced timeout teststests/test_artwork_retry_reconciliation.py— isolation fix, rewritten for mandatoryartwork_job_id, 8 new tests (cancelled/timed_out/mismatch/not-terminal/required-field 400s/no-fallback)tests/test_ai_batch_import_reliability.py— 1 new static-assertion test confirming the empty-result raiseTests run
test_post_retag_artwork_integration,test_artwork_retry_reconciliation,test_routes_setup,test_ai_batch_import_reliability,test_post_retag_artwork_fetch) run individually and together, in both natural and reversed module-load order, 5x consecutively — clean (117 tests/run in the combined form).python -m unittest discover -s tests -p "test_*.py": 13 consecutive clean runs under the actual CI toolchain (Docker,python:3.13-slim,pip install -r requirements.txt, matchingunit-tests.yml/security.ymlexactly) — 1271 tests/run, plus additional clean runs on Windows locally. One unrelated pre-existing flake noted above, not introduced by this PR and not in scope.python -m py_compile app.py helpers_mb.py job_engine.py routes_jobs.py routes_lidarr.py routes_setup.py scripts/security_secret_scan.py scripts/validate_compose_security.py scripts/verify_security_config.py— clean.scripts/security_secret_scan.py— clean.scripts/validate_compose_security.py—ok: true(pre-existing unrelated warnings only).scripts/verify_security_config.py— clean (pre-existing unset-env-var list only, expected in a dev checkout).npm ci(0 vulnerabilities),npm run typecheck,npm run lint,npm run build— all clean.docker build --pull=false(no stale layers reused for the changed files), container started,/health/live→alive,/api/setup/status→fetchart.operational: trueverified via livecurl, clean shutdown, no errors in container logs.Live GitHub CI at
de21ab1All six required workflows green:
lint(frontend-lint),typecheck(frontend-typecheck),node-build(build),docker-build(compose-verification),unit-tests(python-tests ×2 matrix),security(security ×2 matrix). Verified viagh pr checks 32 --watchagainst the live run, not inferred from local results.Known limitations / incomplete
test_ai_batch_retry_race.pyconcurrency flake found during this validation (see above) is unfixed; it's unrelated to this PR's changes and is flagged for separate follow-up._ai_import_folder's "no album found" early-return path still reportsartwork_status: "skipped_no_album"without attempting FetchArt — unchanged from the prior pass.app.py; not addressed here per scope.