#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:
parent
3e554f8274
commit
f4c16ecc22
6 changed files with 665 additions and 0 deletions
92
core/imports/rematch_apply.py
Normal file
92
core/imports/rematch_apply.py
Normal 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",
|
||||
]
|
||||
71
tests/imports/test_rematch_apply.py
Normal file
71
tests/imports/test_rematch_apply.py
Normal 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"
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()">×</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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue