soulsync/core/repair_jobs/relocate.py
BoulderBadgeDad ee4d514d60 CI: silence S110 on three intentional best-effort swallows (unblocks dev build)
The dev-nightly build runs `ruff check .` before "Build and push to GHCR" in the
same job, so the three S110 (try/except/pass) errors introduced since the last
green build (ce6ce4d) failed the lint step and SKIPPED the image push entirely —
every dev-nightly since #704 went red, so the dev image was never rebuilt and none
of the recent fixes (incl. the #852 WebSocket login-bypass fix) ever shipped to
the image users pull.

All three are deliberate best-effort swallows; annotate them with the repo's
existing `# noqa: S110 — <reason>` convention rather than adding dead logging:
- relocate.py: tag write is best-effort (re-import re-derives tags)
- acoustid_scanner.py: verification-status tag is optional context
- web_server.py: audio-duration probe falls through to 0

ruff check . + compileall now clean; pytest already passed in CI at ce6ce4d.
2026-06-11 16:00:07 -07:00

68 lines
2.6 KiB
Python

"""Relocate an AcoustID-mismatched file into Staging for clean re-import (#704).
The existing 'retag' fix corrects a mismatched file's tags + DB record but leaves
the file in the WRONG artist/album folder on disk — so the library shows the right
title while the file sits under the previous track's artist/album. AcoustID only
yields a title + artist (not a reliable album), so an *in-place* move has no
trustworthy target.
Instead: retag the file, move it into the staging folder, and drop the stale
``tracks`` row. The auto-import worker (which watches staging) then re-identifies
the file with full metadata and files it in the correct artist/album/track path —
reusing the battle-tested import pipeline rather than guessing a destination here.
Side effects are injected so the orchestration is a pure, unit-testable seam.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Dict, Optional
def staging_destination(staging_dir: str, filename: str,
exists: Callable[[str], bool]) -> str:
"""A non-colliding path for ``filename`` inside ``staging_dir``.
If the name is already taken, suffix it ``' (1)'``, ``' (2)'``, … before the
extension — never overwrite an unrelated file already waiting in staging.
"""
base, ext = os.path.splitext(filename)
dest = os.path.join(staging_dir, filename)
n = 1
while exists(dest):
dest = os.path.join(staging_dir, f"{base} ({n}){ext}")
n += 1
return dest
def relocate_mismatch_to_staging(
resolved_path: str,
staging_dir: str,
tag_updates: Optional[Dict[str, Any]],
*,
write_tags: Callable[[str, Dict[str, Any]], Any],
move_file: Callable[[str, str], Any],
drop_db_row: Callable[[], Any],
exists: Callable[[str], bool],
) -> str:
"""Retag (best-effort) → move into staging → drop the stale DB row.
Returns the staging destination path. Order matters: the DB row is dropped
only AFTER a successful move, so a failed move (which raises) leaves the
library entry intact rather than orphaning it.
"""
if tag_updates:
try:
write_tags(resolved_path, tag_updates)
except Exception: # noqa: S110 — tags are best-effort; re-import re-derives them
# The relocation itself is the point, so don't abort over a tag write.
pass
dest = staging_destination(staging_dir, os.path.basename(resolved_path), exists)
move_file(resolved_path, dest) # may raise → row NOT dropped (intentional)
drop_db_row()
return dest
__all__ = ["staging_destination", "relocate_mismatch_to_staging"]