From f4c16ecc22b7392b5843292999976561bdd599d7 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 18 Jun 2026 15:37:56 -0700 Subject: [PATCH] #889 Phase 4: the Re-identify modal + apply backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The showpiece: a focused 'which release does this track belong to?' chooser. Source tabs (default active), pre-seeded search, the same song surfaced across single/EP/album with color-coded type badges, ISRC-ranked, replace-original toggle (on by default). Glassy panel, blurred hero art, shimmer/spinner states, hover-lift result cards — matched to the app's modal language. Backend: - core/imports/rematch_apply.py: pure staged_destination + build_reidentify_hint, injectable stage_file_for_reidentify (COPIES the file, never moves — original safe until re-import succeeds). 6 tests. - POST /api/reidentify/apply (admin-only): resolve_hint_fields → stage file → create_hint → nudge the worker. Replace deletes the old row only on success. Frontend: modal markup (index.html), full stylesheet (style.css), and the openReidentifyModal/search/select/confirm flow (library.js). Not yet reachable from a button — Phase 5 wires it. --- core/imports/rematch_apply.py | 92 +++++++++++++ tests/imports/test_rematch_apply.py | 71 ++++++++++ web_server.py | 78 +++++++++++ webui/index.html | 42 ++++++ webui/static/library.js | 195 ++++++++++++++++++++++++++++ webui/static/style.css | 187 ++++++++++++++++++++++++++ 6 files changed, 665 insertions(+) create mode 100644 core/imports/rematch_apply.py create mode 100644 tests/imports/test_rematch_apply.py diff --git a/core/imports/rematch_apply.py b/core/imports/rematch_apply.py new file mode 100644 index 00000000..33bcb8c6 --- /dev/null +++ b/core/imports/rematch_apply.py @@ -0,0 +1,92 @@ +"""#889 Phase 4/5: apply a re-identify — stage the library file + write the hint. + +When the user confirms a release in the Re-identify modal, we: + 1. COPY (never move) the track's library file into the auto-import staging folder, + so the original is untouched until the re-import succeeds, + 2. fingerprint the staged copy (rename-proof binding), and + 3. write a single-use hint carrying the chosen release's IDs (+ ``replace_track_id`` + when 'replace original' is ticked). + +The auto-import worker then picks the staged file up, finds the hint, and re-imports +it against the user-chosen release (Phase 2). The pieces here are split so the +naming + hint construction are pure/unit-tested and the actual copy is injectable. +""" + +from __future__ import annotations + +import os +import shutil +from typing import Any, Callable, Dict, Optional + +from core.imports.paths import sanitize_filename +from core.imports.rematch_hints import RematchHint, quick_file_signature + + +def staged_destination(staging_dir: str, real_path: str, library_track_id: Any) -> str: + """Where the staged copy lands: a single loose file in the staging ROOT (so the + worker treats it as a single-track candidate), named to keep the extension and + be unique + traceable to the track it re-identifies. The filename is cosmetic — + matching is driven by the hint, not the name.""" + base = os.path.basename(real_path) + stem, ext = os.path.splitext(base) + safe_stem = sanitize_filename(stem).strip() or "track" + name = f"{safe_stem} [reid-{library_track_id}]{ext}" + return os.path.join(staging_dir, name) + + +def stage_file_for_reidentify( + real_path: str, + staging_dir: str, + library_track_id: Any, + *, + copy_fn: Callable[[str, str], object] = shutil.copy2, + signature_fn: Callable[[str], Optional[str]] = quick_file_signature, +) -> Dict[str, Any]: + """Copy the library file into staging and fingerprint the copy. Returns + ``{staged_path, content_hash}``. Raises ``FileNotFoundError`` if the source is + gone (caller surfaces a clear error rather than writing a dangling hint).""" + if not real_path or not os.path.isfile(real_path): + raise FileNotFoundError(real_path or "(empty path)") + os.makedirs(staging_dir, exist_ok=True) + dest = staged_destination(staging_dir, real_path, library_track_id) + copy_fn(real_path, dest) + return {"staged_path": dest, "content_hash": signature_fn(dest)} + + +def build_reidentify_hint( + library_track_id: Any, + hint_fields: Dict[str, Any], + staged_path: str, + content_hash: Optional[str], + *, + replace: bool, +) -> RematchHint: + """Pure: assemble the RematchHint from the resolved release fields + staging + info. ``replace_track_id`` is the library row to delete on success, but only + when 'replace original' was ticked. ``exempt_dedup`` is always True — a + re-identify is explicit and must bypass dedup-skip.""" + return RematchHint( + staged_path=staged_path, + content_hash=content_hash, + source=hint_fields.get("source") or "", + isrc=hint_fields.get("isrc"), + track_id=hint_fields.get("track_id"), + album_id=hint_fields.get("album_id"), + artist_id=hint_fields.get("artist_id"), + track_title=hint_fields.get("track_title"), + album_name=hint_fields.get("album_name"), + artist_name=hint_fields.get("artist_name"), + album_type=hint_fields.get("album_type"), + track_number=hint_fields.get("track_number"), + disc_number=hint_fields.get("disc_number"), + replace_track_id=(int(library_track_id) if replace and str(library_track_id).isdigit() else + (library_track_id if replace else None)), + exempt_dedup=True, + ) + + +__all__ = [ + "staged_destination", + "stage_file_for_reidentify", + "build_reidentify_hint", +] diff --git a/tests/imports/test_rematch_apply.py b/tests/imports/test_rematch_apply.py new file mode 100644 index 00000000..20c3fd3e --- /dev/null +++ b/tests/imports/test_rematch_apply.py @@ -0,0 +1,71 @@ +"""#889 Phase 4/5: apply a re-identify — stage the file (copy, not move) + build +the hint. Locks down: the original is never touched, the staged name is unique + +keeps the extension, the hint carries the chosen release, and replace_track_id is +set ONLY when 'replace' is ticked. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from core.imports.rematch_apply import ( + build_reidentify_hint, + stage_file_for_reidentify, + staged_destination, +) + +_FIELDS = { + "source": "spotify", "track_id": "trk_1", "album_id": "alb_album1", + "artist_id": "art_1", "track_title": "Song", "album_name": "Album1", + "artist_name": "Artist", "album_type": "album", "track_number": 5, + "disc_number": 1, "isrc": "US1234567890", +} + + +def test_staged_destination_keeps_ext_and_is_traceable(): + dest = staged_destination("/staging", "/lib/EP1/05 - Song.flac", 42) + assert dest.endswith(".flac") + assert "[reid-42]" in dest # traceable to the track + unique per track + assert dest.startswith("/staging/") # loose file in staging root → single candidate + + +def test_stage_copies_not_moves(tmp_path: Path): + src = tmp_path / "lib" / "EP1" / "05 - Song.flac" + src.parent.mkdir(parents=True) + src.write_bytes(b"audio-bytes") + staging = tmp_path / "Staging" + + out = stage_file_for_reidentify(str(src), str(staging), 42, + signature_fn=lambda p: "sig123") + staged = Path(out["staged_path"]) + assert staged.is_file() and staged.read_bytes() == b"audio-bytes" + assert src.is_file() # ORIGINAL untouched (copy, never move) + assert out["content_hash"] == "sig123" + + +def test_stage_missing_source_raises(tmp_path: Path): + with pytest.raises(FileNotFoundError): + stage_file_for_reidentify(str(tmp_path / "gone.flac"), str(tmp_path / "S"), 1) + + +def test_build_hint_sets_replace_when_ticked(): + h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=True) + assert h.replace_track_id == 42 + assert h.album_id == "alb_album1" and h.source == "spotify" + assert h.track_number == 5 and h.isrc == "US1234567890" + assert h.exempt_dedup is True + assert h.staged_path == "/staging/x.flac" and h.content_hash == "sig" + + +def test_build_hint_no_replace_when_unticked(): + h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=False) + assert h.replace_track_id is None # keep original → no deletion + assert h.exempt_dedup is True # still bypasses dedup-skip (explicit action) + + +def test_build_hint_handles_non_numeric_track_id(): + # Jellyfin-style GUID track ids must still round-trip as replace target. + h = build_reidentify_hint("abc-guid", _FIELDS, "/s/x.flac", None, replace=True) + assert h.replace_track_id == "abc-guid" diff --git a/web_server.py b/web_server.py index d5234210..2cea211d 100644 --- a/web_server.py +++ b/web_server.py @@ -10085,6 +10085,84 @@ def reidentify_search(): return jsonify({"success": False, "error": str(e), "results": []}), 500 +@app.route('/api/reidentify/apply', methods=['POST']) +def reidentify_apply(): + """Apply a re-identify: stage the track's library file + write a single-use hint + so the auto-import worker re-files it under the chosen release (Phase 2). + + Body: ``{library_track_id, source, track_id, replace}``. Admin-only (mutates the + library). COPIES the file — the original is removed only after the re-import + succeeds, and only when ``replace`` is true.""" + try: + database = get_database() + pid = get_current_profile_id() + prof = database.get_profile(pid) if pid else None + if not prof or not prof.get('is_admin'): + return jsonify({"success": False, "error": "Admin only"}), 403 + + data = request.get_json(silent=True) or {} + library_track_id = data.get('library_track_id') + source = (data.get('source') or '').strip() + track_id = (data.get('track_id') or '').strip() + replace = bool(data.get('replace', True)) + if not library_track_id or not source or not track_id: + return jsonify({"success": False, "error": "library_track_id, source and track_id are required"}), 400 + + from core.imports.rematch_search import resolve_hint_fields + from core.imports.rematch_apply import stage_file_for_reidentify, build_reidentify_hint + from core.imports.rematch_hints import create_hint + + # 1) Resolve the picked release → the IDs the hint needs (album_id critically). + hint_fields = resolve_hint_fields(source, track_id) + if not hint_fields: + return jsonify({"success": False, "error": "Could not resolve the selected release (no album id)"}), 400 + + # 2) Locate the library file for this track. + conn = database._get_connection() + try: + cur = conn.cursor() + cur.execute("SELECT file_path FROM tracks WHERE id = ?", (str(library_track_id),)) + row = cur.fetchone() + finally: + conn.close() + if not row or not row['file_path']: + return jsonify({"success": False, "error": "Library track has no file on disk"}), 404 + real_path = _resolve_library_file_path(row['file_path']) or row['file_path'] + + # 3) Copy into staging + fingerprint the copy. + staging_dir = docker_resolve_path(config_manager.get('import.staging_path', './Staging')) + staged = stage_file_for_reidentify(real_path, staging_dir, library_track_id) + + # 4) Persist the single-use hint. + hint = build_reidentify_hint(library_track_id, hint_fields, + staged['staged_path'], staged['content_hash'], replace=replace) + conn = database._get_connection() + try: + cur = conn.cursor() + hint_id = create_hint(cur, hint) + conn.commit() + finally: + conn.close() + + # 5) Nudge the worker so it doesn't wait for the next timer tick. + try: + if auto_import_worker is not None: + auto_import_worker.trigger_scan() + except Exception as _e: + logger.debug("Re-identify: scan nudge failed (worker will catch it on its timer): %s", _e) + + logger.info("[Re-identify] staged track %s → %s '%s' (%s), replace=%s", + library_track_id, hint.album_type or 'release', hint.album_name or '?', + source, replace) + return jsonify({"success": True, "hint_id": hint_id, "staged_path": staged['staged_path'], + "album_name": hint.album_name, "album_type": hint.album_type}) + except FileNotFoundError as e: + return jsonify({"success": False, "error": f"Source file not found: {e}"}), 404 + except Exception as e: + logger.error(f"Re-identify apply error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/library/artist//quality-analysis') def get_artist_quality_analysis(artist_id): """Analyze track quality for an artist — returns tier classification for each track.""" diff --git a/webui/index.html b/webui/index.html index 127eb692..6db10878 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7743,6 +7743,48 @@ + + +