#889 Phase 4: the Re-identify modal + apply backend

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.
This commit is contained in:
BoulderBadgeDad 2026-06-18 15:37:56 -07:00
parent 3e554f8274
commit f4c16ecc22
6 changed files with 665 additions and 0 deletions

View file

@ -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",
]

View file

@ -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"

View file

@ -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/<artist_id>/quality-analysis')
def get_artist_quality_analysis(artist_id):
"""Analyze track quality for an artist — returns tier classification for each track."""

View file

@ -7743,6 +7743,48 @@
</div>
</div>
<!-- Re-identify Track Modal (#889) -->
<div class="modal-overlay hidden" id="reid-modal-overlay" onclick="if(event.target===this)closeReidentifyModal()">
<div class="reid-modal" id="reid-modal">
<div class="reid-hero">
<div class="reid-hero-bg" id="reid-hero-bg"></div>
<div class="reid-hero-overlay"></div>
<span class="reid-close" onclick="closeReidentifyModal()">&times;</span>
<div class="reid-hero-content">
<div class="reid-hero-art" id="reid-hero-art"></div>
<div class="reid-hero-meta">
<div class="reid-hero-eyebrow">Re-identify track</div>
<div class="reid-hero-title" id="reid-hero-title">Track</div>
<div class="reid-hero-sub" id="reid-hero-sub"></div>
</div>
</div>
</div>
<div class="reid-tabs" id="reid-tabs"></div>
<div class="reid-search">
<svg class="reid-search-icon" viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"/></svg>
<input type="text" id="reid-search-input" class="reid-search-input"
placeholder="Search for the track…" onkeydown="if(event.key==='Enter')runReidentifySearch()" />
<button class="reid-search-btn" id="reid-search-btn" onclick="runReidentifySearch()">Search</button>
</div>
<div class="reid-results" id="reid-results"></div>
<div class="reid-footer">
<label class="reid-replace" title="When on, the original file is deleted after the track is re-filed under the new release.">
<input type="checkbox" id="reid-replace" checked />
<span class="reid-replace-box"></span>
<span class="reid-replace-text">Replace the original file <em>(recommended)</em></span>
</label>
<div class="reid-footer-actions">
<button class="btn btn--secondary" onclick="closeReidentifyModal()">Cancel</button>
<button class="btn btn--primary" id="reid-confirm-btn" disabled onclick="confirmReidentify()">Re-identify</button>
</div>
</div>
</div>
</div>
<!-- Watchlist Artist Config Modal -->
<div class="modal-overlay hidden" id="watchlist-artist-config-modal-overlay">
<div class="watchlist-artist-config-modal" id="watchlist-artist-config-modal">

View file

@ -9174,3 +9174,198 @@ async function openArtistExportModal(initialScope) {
refresh();
}
// ==================== Re-identify Track Modal (#889) ====================
// Lets an admin re-file an already-imported track under a different release
// (single / EP / album). Searches any configured metadata source (tabs, default
// active), and on confirm stages the file + writes a single-use hint the
// auto-import worker consumes (see core/imports/rematch_*.py).
const reidState = { trackId: null, source: null, sources: [], rows: [], selected: null };
function openReidentifyModal(trackId, title, artist, albumTitle, imageUrl) {
reidState.trackId = trackId;
reidState.source = null;
reidState.rows = [];
reidState.selected = null;
const overlay = document.getElementById('reid-modal-overlay');
if (!overlay) return;
// Hero
document.getElementById('reid-hero-title').textContent = title || 'Track';
const sub = document.getElementById('reid-hero-sub');
sub.textContent = (artist || '') + (albumTitle ? ` · currently in “${albumTitle}` : '');
const art = document.getElementById('reid-hero-art');
const bg = document.getElementById('reid-hero-bg');
if (imageUrl) {
art.style.backgroundImage = `url('${imageUrl}')`;
art.classList.remove('empty');
bg.style.backgroundImage = `url('${imageUrl}')`;
} else {
art.style.backgroundImage = '';
art.classList.add('empty');
bg.style.backgroundImage = '';
}
document.getElementById('reid-search-input').value = `${title || ''} ${artist || ''}`.trim();
document.getElementById('reid-replace').checked = true;
_reidUpdateConfirm();
_reidRenderState('idle');
overlay.classList.remove('hidden');
_reidLoadTabs();
}
function closeReidentifyModal() {
const overlay = document.getElementById('reid-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function _reidLoadTabs() {
const tabsEl = document.getElementById('reid-tabs');
tabsEl.innerHTML = '';
try {
const resp = await fetch('/api/reidentify/sources');
const data = await resp.json();
reidState.sources = (data && data.sources) || [];
} catch (_) {
reidState.sources = [];
}
if (!reidState.sources.length) {
tabsEl.innerHTML = '<span class="reid-tab active">No metadata sources available</span>';
_reidRenderState('empty', 'No configured metadata source to search.');
return;
}
const active = reidState.sources.find(s => s.active) || reidState.sources[0];
reidState.source = active.source;
reidState.sources.forEach(s => {
const tab = document.createElement('div');
tab.className = 'reid-tab' + (s.source === reidState.source ? ' active' : '');
tab.textContent = s.label || s.source;
tab.onclick = () => _reidSelectTab(s.source);
tabsEl.appendChild(tab);
});
runReidentifySearch(); // auto-search the active source on open
}
function _reidSelectTab(source) {
if (source === reidState.source) return;
reidState.source = source;
document.querySelectorAll('#reid-tabs .reid-tab').forEach(t => {
t.classList.toggle('active', t.textContent ===
(reidState.sources.find(s => s.source === source) || {}).label);
});
runReidentifySearch();
}
async function runReidentifySearch() {
const query = (document.getElementById('reid-search-input').value || '').trim();
if (!query || !reidState.source) return;
reidState.selected = null;
_reidUpdateConfirm();
_reidRenderState('loading');
try {
const url = `/api/reidentify/search?source=${encodeURIComponent(reidState.source)}&q=${encodeURIComponent(query)}`;
const resp = await fetch(url);
const data = await resp.json();
reidState.rows = (data && data.results) || [];
_reidRenderResults();
} catch (e) {
_reidRenderState('empty', 'Search failed. Try another source.');
}
}
function _reidRenderResults() {
const el = document.getElementById('reid-results');
if (!reidState.rows.length) {
_reidRenderState('empty', 'No releases found. Try refining the search or another source tab.');
return;
}
// ISRC-bearing rows first (provably the same recording), then the rest.
const ranked = reidState.rows
.map((r, i) => ({ r, i }))
.sort((a, b) => (b.r.isrc ? 1 : 0) - (a.r.isrc ? 1 : 0));
el.innerHTML = '';
ranked.forEach(({ r }, n) => {
const badge = (r.album_type || 'album').toLowerCase();
const bits = [];
if (r.year) bits.push(r.year);
if (r.total_tracks) bits.push(`${r.total_tracks} track${r.total_tracks === 1 ? '' : 's'}`);
const row = document.createElement('div');
row.className = 'reid-result';
row.style.animationDelay = `${Math.min(n * 0.03, 0.3)}s`;
row.onclick = () => _reidSelectResult(r, row);
row.innerHTML = `
<div class="reid-result-art" ${r.image_url ? `style="background-image:url('${encodeURI(r.image_url)}')"` : ''}>
${r.image_url ? '' : '<span>♪</span>'}
</div>
<div class="reid-result-info">
<div class="reid-result-title">${escapeHtml(r.track_title || '')}</div>
<div class="reid-result-release">${escapeHtml(r.album_name || 'Unknown release')}${r.artist_name ? ' · ' + escapeHtml(r.artist_name) : ''}</div>
</div>
<div class="reid-result-meta">
<span class="reid-badge ${badge}">${escapeHtml(badge)}</span>
${bits.length ? `<span class="reid-result-detail">${escapeHtml(bits.join(' · '))}</span>` : ''}
<span class="reid-result-check"></span>
</div>`;
el.appendChild(row);
});
}
function _reidSelectResult(r, rowEl) {
reidState.selected = r;
document.querySelectorAll('#reid-results .reid-result').forEach(x => x.classList.remove('selected'));
rowEl.classList.add('selected');
_reidUpdateConfirm();
}
function _reidUpdateConfirm() {
const btn = document.getElementById('reid-confirm-btn');
if (btn) btn.disabled = !reidState.selected;
}
function _reidRenderState(kind, msg) {
const el = document.getElementById('reid-results');
if (!el) return;
if (kind === 'loading') {
el.innerHTML = '<div class="reid-state"><div class="reid-spinner"></div><p>Searching…</p></div>'
+ '<div class="reid-skel"></div><div class="reid-skel"></div><div class="reid-skel"></div>';
} else if (kind === 'empty') {
el.innerHTML = `<div class="reid-state"><div class="reid-state-icon">🔍</div><p>${escapeHtml(msg || 'No results.')}</p></div>`;
} else { // idle
el.innerHTML = '<div class="reid-state"><div class="reid-state-icon">💿</div>'
+ '<p>Pick the release this track should be filed under — the same song may appear on a single, an EP, and an album.</p></div>';
}
}
async function confirmReidentify() {
if (!reidState.selected || !reidState.trackId) return;
const btn = document.getElementById('reid-confirm-btn');
const replace = document.getElementById('reid-replace').checked;
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Staging…';
try {
const resp = await fetch('/api/reidentify/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
library_track_id: reidState.trackId,
source: reidState.selected.source,
track_id: reidState.selected.track_id,
replace: replace,
}),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Re-identify failed');
showToast(`Re-filing under “${data.album_name || 'the chosen release'}” — it'll update after the next import pass.`, 'success');
closeReidentifyModal();
} catch (e) {
showToast(e.message || 'Re-identify failed', 'error');
btn.disabled = false;
btn.textContent = prev;
}
}

View file

@ -68354,3 +68354,190 @@ body.app-locked > *:not(#launch-pin-overlay):not(#login-overlay):not(script):not
}
.wlx-opt input { accent-color: #7aa2f7; }
.watchlist-export-btn .watchlist-all-icon { font-weight: 700; }
/* =========================================================================
Re-identify Track Modal (#889)
A focused, vibey chooser: "which release does this track belong to?"
========================================================================= */
#reid-modal-overlay { backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.reid-modal {
position: relative;
width: min(760px, 94vw);
max-height: 88vh;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 20px;
background: linear-gradient(180deg, #14161d 0%, #0e0f15 100%);
border: 1px solid rgba(var(--accent-rgb), 0.18);
box-shadow:
0 30px 90px rgba(0, 0, 0, 0.6),
0 0 80px rgba(var(--accent-rgb), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
animation: reidPop 0.34s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes reidPop {
from { opacity: 0; transform: translateY(18px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── Hero ── */
.reid-hero { position: relative; padding: 26px 28px; overflow: hidden; }
.reid-hero-bg {
position: absolute; inset: -30px;
background-size: cover; background-position: center;
filter: blur(46px) brightness(0.4) saturate(1.5);
transform: scale(1.25); z-index: 0; opacity: 0.9;
transition: background-image 0.4s ease;
}
.reid-hero-overlay {
position: absolute; inset: 0; z-index: 1;
background: linear-gradient(135deg, rgba(10,11,16,0.55) 0%, rgba(10,11,16,0.8) 100%);
}
.reid-hero-content { position: relative; z-index: 2; display: flex; gap: 18px; align-items: center; }
.reid-hero-art {
width: 76px; height: 76px; flex: 0 0 76px; border-radius: 12px;
background: rgba(255,255,255,0.05) center/cover no-repeat;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.08);
}
.reid-hero-art.empty { display: grid; place-items: center; }
.reid-hero-art.empty::after { content: '♪'; font-size: 30px; color: rgba(var(--accent-rgb), 0.7); }
.reid-hero-meta { min-width: 0; }
.reid-hero-eyebrow {
font: 600 11px/1 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 0.14em; text-transform: uppercase;
color: rgb(var(--accent-light-rgb)); margin-bottom: 7px;
}
.reid-hero-title {
font-size: 23px; font-weight: 800; color: #fff; line-height: 1.15;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.reid-hero-sub { margin-top: 4px; color: #aab; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reid-close {
position: absolute; top: 14px; right: 18px; z-index: 3;
font-size: 26px; line-height: 1; color: #aab; cursor: pointer;
width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%;
transition: all 0.18s ease;
}
.reid-close:hover { color: #fff; background: rgba(255,255,255,0.1); transform: rotate(90deg); }
/* ── Source tabs ── */
.reid-tabs {
display: flex; gap: 7px; padding: 14px 28px 0; flex-wrap: wrap;
}
.reid-tab {
padding: 7px 15px; border-radius: 999px; cursor: pointer;
font-size: 12.5px; font-weight: 600; color: #9aa3bd;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
transition: all 0.18s ease; white-space: nowrap;
}
.reid-tab:hover { color: #fff; background: rgba(255,255,255,0.08); }
.reid-tab.active {
color: #06210f; background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
border-color: transparent; box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.35);
}
/* ── Search ── */
.reid-search { display: flex; align-items: center; gap: 10px; padding: 16px 28px 12px; position: relative; }
.reid-search-icon { position: absolute; left: 42px; color: #6b7390; pointer-events: none; }
.reid-search-input {
flex: 1; padding: 12px 14px 12px 40px; border-radius: 12px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: #fff; font-size: 14px; transition: all 0.18s ease;
}
.reid-search-input:focus {
outline: none; border-color: rgba(var(--accent-rgb), 0.5);
background: rgba(255,255,255,0.06); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
}
.reid-search-btn {
padding: 11px 20px; border-radius: 12px; cursor: pointer; font-weight: 700; font-size: 13px;
color: #06210f; background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
border: none; transition: all 0.18s ease;
}
.reid-search-btn:hover { filter: brightness(1.08); transform: translateY(-1px); }
/* ── Results ── */
.reid-results { flex: 1; overflow-y: auto; padding: 4px 22px 10px; min-height: 220px; }
.reid-result {
display: flex; align-items: center; gap: 14px; padding: 12px 14px; margin: 6px 0;
border-radius: 14px; cursor: pointer;
background: rgba(255,255,255,0.025); border: 1.5px solid transparent;
transition: transform 0.16s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
animation: reidRowIn 0.3s ease backwards;
}
@keyframes reidRowIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.reid-result:hover { background: rgba(255,255,255,0.05); transform: translateY(-2px); }
.reid-result.selected {
border-color: rgba(var(--accent-rgb), 0.65);
background: rgba(var(--accent-rgb), 0.1);
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.18);
}
.reid-result-art {
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 9px;
background: rgba(255,255,255,0.06) center/cover no-repeat; border: 1px solid rgba(255,255,255,0.07);
display: grid; place-items: center;
}
.reid-result-art span { font-size: 20px; color: rgba(var(--accent-rgb), 0.6); }
.reid-result-info { min-width: 0; flex: 1; }
.reid-result-title { font-weight: 700; color: #fff; font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reid-result-release { color: #9aa3bd; font-size: 12.5px; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reid-result-meta { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
.reid-result-detail { color: #6b7390; font-size: 11.5px; font-family: 'JetBrains Mono', ui-monospace, monospace; text-align: right; }
.reid-result-check {
width: 22px; height: 22px; border-radius: 50%; flex: 0 0 22px;
border: 2px solid rgba(255,255,255,0.14); display: grid; place-items: center; transition: all 0.18s ease;
}
.reid-result.selected .reid-result-check {
border-color: rgb(var(--accent-rgb)); background: rgb(var(--accent-rgb));
}
.reid-result.selected .reid-result-check::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
/* Type badges */
.reid-badge {
font: 700 10px/1 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 0.08em; text-transform: uppercase;
padding: 4px 8px; border-radius: 6px; white-space: nowrap;
}
.reid-badge.album { color: #22ff6b; background: rgba(34,255,107,0.12); border: 1px solid rgba(34,255,107,0.25); }
.reid-badge.ep { color: #5aa9ff; background: rgba(90,169,255,0.12); border: 1px solid rgba(90,169,255,0.25); }
.reid-badge.single { color: #ffc24b; background: rgba(255,194,75,0.12); border: 1px solid rgba(255,194,75,0.25); }
.reid-badge.compilation { color: #c98bff; background: rgba(201,139,255,0.12); border: 1px solid rgba(201,139,255,0.25); }
/* Idle / loading / empty */
.reid-state { display: grid; place-items: center; gap: 14px; min-height: 200px; text-align: center; color: #6b7390; padding: 20px; }
.reid-state .reid-state-icon { font-size: 38px; opacity: 0.5; }
.reid-state p { font-size: 13.5px; max-width: 360px; }
.reid-spinner {
width: 38px; height: 38px; border-radius: 50%;
border: 3px solid rgba(var(--accent-rgb), 0.15); border-top-color: rgb(var(--accent-rgb));
animation: reidSpin 0.8s linear infinite;
}
@keyframes reidSpin { to { transform: rotate(360deg); } }
.reid-skel {
height: 76px; margin: 6px 0; border-radius: 14px;
background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.07) 37%, rgba(255,255,255,0.03) 63%);
background-size: 400% 100%; animation: reidShimmer 1.4s ease infinite;
}
@keyframes reidShimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
/* ── Footer ── */
.reid-footer {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 16px 28px; border-top: 1px solid rgba(255,255,255,0.06);
background: rgba(0,0,0,0.2); flex-wrap: wrap;
}
.reid-replace { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.reid-replace input { display: none; }
.reid-replace-box {
width: 20px; height: 20px; border-radius: 6px; flex: 0 0 20px;
border: 2px solid rgba(255,255,255,0.18); transition: all 0.18s ease; display: grid; place-items: center;
}
.reid-replace input:checked + .reid-replace-box {
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb))); border-color: transparent;
}
.reid-replace input:checked + .reid-replace-box::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
.reid-replace-text { color: #c2c9de; font-size: 13px; }
.reid-replace-text em { color: #7f879e; font-style: normal; font-size: 11.5px; }
.reid-footer-actions { display: flex; gap: 10px; }
#reid-confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.4); }