Add missing-track import from existing library files

Show actionable missing album tracks in the enhanced library from canonical metadata, with a practical Manage flow for Add to Library or I Have This.

Implement I Have This as a non-destructive copy/import path: copy the chosen existing file, run normal post-processing with the missing track context, insert the real library row, and inherit album identity tags from target siblings so Navidrome does not split albums.

Improve the modal with selectable search results, visible import progress, disabled controls during import, and missing-track row styling.
This commit is contained in:
Broque Thomas 2026-05-17 13:58:11 -07:00
parent d734f92e19
commit 3b62bcab0c
4 changed files with 1434 additions and 32 deletions

View file

@ -862,6 +862,9 @@ class MusicDatabase:
if 'server_source' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex'")
logger.info("Added server_source column to tracks table")
if 'disc_number' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1")
logger.info("Added disc_number column to tracks table")
# Create indexes for server_source columns for performance
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")

View file

@ -10779,6 +10779,463 @@ def library_clear_match():
return jsonify({"success": False, "error": str(e)}), 500
_IMPORT_ALBUM_IDENTITY_TAGS = {
'album',
'albumartist',
'album_artist',
'date',
'year',
'tracktotal',
'totaltracks',
'totaldiscs',
'musicbrainz_albumid',
'musicbrainz_albumartistid',
'musicbrainz_releasegroupid',
'barcode',
'catalognumber',
'originaldate',
'releasecountry',
'releasestatus',
'releasetype',
'media',
'script',
'copyright',
'spotify_album_id',
'deezer_album_id',
'tidal_album_id',
'qobuz_album_id',
'itunes_album_id',
'audiodb_album_id',
}
_IMPORT_ID3_STANDARD_TAGS = {
'album': 'TALB',
'albumartist': 'TPE2',
'album_artist': 'TPE2',
'date': 'TDRC',
'year': 'TDRC',
}
_IMPORT_ID3_TXXX_DESCS = {
'musicbrainz_albumid': 'MusicBrainz Album Id',
'musicbrainz_albumartistid': 'MusicBrainz Album Artist Id',
'musicbrainz_releasegroupid': 'MusicBrainz Release Group Id',
'barcode': 'BARCODE',
'catalognumber': 'CATALOGNUMBER',
'originaldate': 'ORIGINALDATE',
'releasecountry': 'RELEASECOUNTRY',
'releasestatus': 'RELEASESTATUS',
'releasetype': 'RELEASETYPE',
'media': 'MEDIA',
'script': 'SCRIPT',
'totaldiscs': 'TOTALDISCS',
'tracktotal': 'TOTALTRACKS',
'totaltracks': 'TOTALTRACKS',
'spotify_album_id': 'Spotify Album Id',
'deezer_album_id': 'Deezer Album Id',
'tidal_album_id': 'Tidal Album Id',
'qobuz_album_id': 'Qobuz Album Id',
'itunes_album_id': 'iTunes Album Id',
'audiodb_album_id': 'AudioDB Album Id',
}
_IMPORT_MP4_STANDARD_TAGS = {
'album': '\xa9alb',
'albumartist': 'aART',
'album_artist': 'aART',
'date': '\xa9day',
'year': '\xa9day',
}
def _first_tag_value(audio, key):
try:
values = audio.get(key)
if not values:
return None
value = values[0] if isinstance(values, (list, tuple)) else values
if isinstance(value, bytes):
value = value.decode('utf-8', errors='ignore')
return str(value).strip() or None
except Exception:
return None
def _read_album_identity_tags(file_path):
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TXXX
from mutagen.mp4 import MP4, MP4FreeForm
audio = MutagenFile(file_path)
if not audio:
return {}
tags = {}
if isinstance(getattr(audio, 'tags', None), ID3):
for tag_key, frame_id in _IMPORT_ID3_STANDARD_TAGS.items():
frames = audio.tags.getall(frame_id)
if frames and getattr(frames[0], 'text', None):
tags[tag_key] = str(frames[0].text[0]).strip()
desc_to_key = {desc: key for key, desc in _IMPORT_ID3_TXXX_DESCS.items()}
for frame in audio.tags.getall('TXXX'):
if isinstance(frame, TXXX) and frame.desc in desc_to_key and frame.text:
tags[desc_to_key[frame.desc]] = str(frame.text[0]).strip()
elif isinstance(audio, MP4):
for tag_key, mp4_key in _IMPORT_MP4_STANDARD_TAGS.items():
value = _first_tag_value(audio, mp4_key)
if value:
tags[tag_key] = value
for tag_key, desc in _IMPORT_ID3_TXXX_DESCS.items():
value = _first_tag_value(audio, f"----:com.apple.iTunes:{desc}")
if value:
tags[tag_key] = value
else:
for tag_key in _IMPORT_ALBUM_IDENTITY_TAGS:
value = _first_tag_value(audio, tag_key)
if value:
tags[tag_key] = value
return {k: v for k, v in tags.items() if v}
except Exception as exc:
logger.debug("Failed reading album identity tags from %s: %s", file_path, exc)
return {}
def _write_album_identity_tags(file_path, tags):
if not tags:
return False
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TALB, TDRC, TPE2, TXXX
from mutagen.mp4 import MP4, MP4FreeForm
audio = MutagenFile(file_path)
if not audio:
return False
tags = {k: str(v).strip() for k, v in tags.items() if k in _IMPORT_ALBUM_IDENTITY_TAGS and str(v).strip()}
if not tags:
return False
if isinstance(getattr(audio, 'tags', None), ID3):
standard_frames = {'TALB': TALB, 'TPE2': TPE2, 'TDRC': TDRC}
written_standard = set()
for tag_key, frame_id in _IMPORT_ID3_STANDARD_TAGS.items():
value = tags.get(tag_key)
if not value or frame_id in written_standard:
continue
audio.tags.delall(frame_id)
audio.tags.add(standard_frames[frame_id](encoding=3, text=[value]))
written_standard.add(frame_id)
for tag_key, desc in _IMPORT_ID3_TXXX_DESCS.items():
value = tags.get(tag_key)
if not value:
continue
for existing in list(audio.tags.getall('TXXX')):
if getattr(existing, 'desc', None) == desc:
audio.tags.remove(existing.HashKey)
audio.tags.add(TXXX(encoding=3, desc=desc, text=[value]))
elif isinstance(audio, MP4):
for tag_key, mp4_key in _IMPORT_MP4_STANDARD_TAGS.items():
if tags.get(tag_key):
audio[mp4_key] = [tags[tag_key]]
for tag_key, desc in _IMPORT_ID3_TXXX_DESCS.items():
if tags.get(tag_key):
audio[f"----:com.apple.iTunes:{desc}"] = [MP4FreeForm(tags[tag_key].encode('utf-8'))]
else:
for tag_key, value in tags.items():
audio[tag_key] = [value]
audio.save()
return True
except Exception as exc:
logger.warning("Failed writing album identity tags to %s: %s", file_path, exc)
return False
def _copy_album_identity_from_target_sibling(database, album_id, final_path, target_disc, target_track):
try:
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT file_path FROM tracks
WHERE album_id = ?
AND file_path IS NOT NULL
AND file_path != ''
AND NOT (COALESCE(disc_number, 1) = ? AND track_number = ?)
ORDER BY COALESCE(disc_number, 1), track_number
LIMIT 12
""", (album_id, target_disc, target_track))
sibling_rows = cursor.fetchall()
for row in sibling_rows:
sibling_path = _resolve_library_file_path(row['file_path'])
if not sibling_path or not os.path.exists(sibling_path):
continue
tags = _read_album_identity_tags(sibling_path)
if not tags:
continue
if _write_album_identity_tags(final_path, tags):
logger.info(
"Imported track inherited album identity tags from sibling: %s",
os.path.basename(sibling_path)
)
return True
except Exception as exc:
logger.warning("Failed to inherit album identity tags for imported track: %s", exc)
return False
@app.route('/api/library/album/<album_id>/import-existing-track', methods=['POST'])
@app.route('/api/library/album/<album_id>/missing-track/import-existing', methods=['POST'])
def library_import_existing_track_for_missing_slot(album_id):
"""Use an existing library file as source audio for a missing album slot.
The selected source file is copied to staging, then routed through the
normal post-processing pipeline with the target album/track metadata. The
original source file is never moved or deleted.
"""
try:
import shutil
from core.library_reorganize import _build_post_process_context
data = request.get_json() or {}
source_track_id = data.get('source_track_id') or data.get('linked_track_id')
expected = data.get('expected_track') or {}
if not source_track_id:
return jsonify({"success": False, "error": "source_track_id is required"}), 400
if not expected.get('track_number') or not (expected.get('title') or expected.get('name')):
return jsonify({"success": False, "error": "expected_track with title and track_number is required"}), 400
database = get_database()
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT al.*, ar.name AS artist_name, ar.id AS target_artist_id
FROM albums al
JOIN artists ar ON ar.id = al.artist_id
WHERE al.id = ?
""", (album_id,))
album_row = cursor.fetchone()
if not album_row:
return jsonify({"success": False, "error": "Album not found"}), 404
album_data = dict(album_row)
cursor.execute("SELECT * FROM tracks WHERE id = ?", (source_track_id,))
source_row = cursor.fetchone()
if not source_row:
return jsonify({"success": False, "error": "Selected library track not found"}), 404
source_track = dict(source_row)
if album_data.get('server_source') and source_track.get('server_source') and album_data['server_source'] != source_track['server_source']:
return jsonify({"success": False, "error": "Selected track belongs to a different library source"}), 400
source_path = _resolve_library_file_path(source_track.get('file_path'))
if not source_path:
return jsonify({"success": False, "error": _get_file_not_found_error(source_track.get('file_path'))}), 404
download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
staging_root = os.path.join(download_dir, 'ssync_existing_import')
os.makedirs(staging_root, exist_ok=True)
source_ext = os.path.splitext(source_path)[1] or '.audio'
staging_name = f"existing_{album_id}_{expected.get('disc_number') or 1}_{expected.get('track_number')}_{uuid.uuid4().hex[:8]}{source_ext}"
staging_path = os.path.join(staging_root, staging_name)
shutil.copy2(source_path, staging_path)
metadata_source = (expected.get('source') or data.get('source') or '').strip().lower() or 'library'
expected_title = expected.get('title') or expected.get('name') or 'Unknown Track'
expected_track_id = (
expected.get('track_id') or expected.get('id') or expected.get('source_track_id') or
expected.get('spotify_track_id') or expected.get('deezer_id') or
expected.get('itunes_track_id') or expected.get('musicbrainz_recording_id') or ''
)
album_source_id = (
data.get('album_source_id') or expected.get('album_id') or
album_data.get('spotify_album_id') or album_data.get('deezer_id') or
album_data.get('itunes_album_id') or album_data.get('musicbrainz_release_id') or
album_data.get('discogs_id') or album_data.get('tidal_id') or album_data.get('qobuz_id') or
str(album_id)
)
api_album = {
'id': album_source_id,
'name': album_data.get('title') or '',
'title': album_data.get('title') or '',
'release_date': f"{album_data.get('year')}-01-01" if album_data.get('year') else '',
'total_tracks': album_data.get('api_track_count') or album_data.get('track_count') or 0,
'image_url': album_data.get('thumb_url') or '',
'source': metadata_source,
}
api_track = {
'id': expected_track_id,
'track_id': expected_track_id,
'name': expected_title,
'title': expected_title,
'track_number': int(expected.get('track_number') or 1),
'disc_number': int(expected.get('disc_number') or 1),
'duration_ms': int(expected.get('duration') or expected.get('duration_ms') or 0),
'artists': expected.get('artists') or [album_data.get('artist_name') or ''],
'source': metadata_source,
'album_id': album_source_id,
'spotify_track_id': expected.get('spotify_track_id') or '',
'deezer_id': expected.get('deezer_id') or '',
'itunes_track_id': expected.get('itunes_track_id') or '',
'musicbrainz_recording_id': expected.get('musicbrainz_recording_id') or '',
}
context = _build_post_process_context(
api_album,
api_track,
album_data.get('artist_name') or '',
album_data.get('title') or '',
int(data.get('total_discs') or expected.get('total_discs') or 1),
)
context['source'] = metadata_source
context['source_service'] = 'existing_library'
context['source_filename'] = os.path.basename(source_path)
context['source_size'] = os.path.getsize(source_path) if os.path.exists(source_path) else 0
context['explicit_album_context'] = True
context['from_existing_library_track'] = True
context['batch_id'] = f"existing_import_{album_id}_{uuid.uuid4().hex[:8]}"
context['task_id'] = f"existing_import_{source_track_id}"
context_key = f"existing_import_{album_id}_{api_track['disc_number']}_{api_track['track_number']}_{uuid.uuid4().hex[:8]}"
_post_process_matched_download(context_key, context, staging_path)
final_path = context.get('_final_processed_path')
if not final_path or not os.path.exists(final_path):
return jsonify({"success": False, "error": "Post-processing did not produce a final file"}), 500
_copy_album_identity_from_target_sibling(
database,
album_id,
final_path,
api_track['disc_number'],
api_track['track_number'],
)
# Insert a real library track row for the target album so the album is
# complete immediately without waiting for a media-server rescan.
file_size = None
bitrate = source_track.get('bitrate') or 0
try:
file_size = os.path.getsize(final_path)
from mutagen import File as MutagenFile
audio = MutagenFile(final_path)
if audio and getattr(audio, 'info', None) and getattr(audio.info, 'bitrate', None):
bitrate = int(audio.info.bitrate / 1000)
except Exception as meta_err:
logger.debug("Existing-track import metadata read failed: %s", meta_err)
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(tracks)")
track_columns = {row[1] for row in cursor.fetchall()}
if 'disc_number' not in track_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1")
conn.commit()
track_columns.add('disc_number')
cursor.execute("SELECT id FROM tracks WHERE file_path = ? LIMIT 1", (final_path,))
existing_by_path = cursor.fetchone()
cursor.execute("""
SELECT id FROM tracks
WHERE album_id = ? AND COALESCE(disc_number, 1) = ? AND track_number = ?
LIMIT 1
""", (album_id, api_track['disc_number'], api_track['track_number']))
existing_target = cursor.fetchone()
if existing_by_path:
target_track_id = existing_by_path['id']
cursor.execute("""
UPDATE tracks
SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?,
duration = ?, file_path = ?, bitrate = ?, file_size = ?,
server_source = COALESCE(server_source, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (
album_id,
album_data.get('target_artist_id'),
expected_title,
api_track['track_number'],
api_track['disc_number'],
api_track['duration_ms'],
final_path,
bitrate,
file_size,
album_data.get('server_source') or source_track.get('server_source') or config_manager.get_active_media_server(),
target_track_id,
))
elif existing_target:
target_track_id = existing_target['id']
cursor.execute("""
UPDATE tracks
SET title = ?, duration = ?, file_path = ?, bitrate = ?, file_size = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (expected_title, api_track['duration_ms'], final_path, bitrate, file_size, target_track_id))
else:
cursor.execute("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) + 1 AS next_id FROM tracks")
target_track_id = cursor.fetchone()['next_id']
cursor.execute("""
INSERT INTO tracks (
id, album_id, artist_id, title, track_number, disc_number, duration,
file_path, bitrate, file_size, server_source, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", (
target_track_id,
album_id,
album_data.get('target_artist_id'),
expected_title,
api_track['track_number'],
api_track['disc_number'],
api_track['duration_ms'],
final_path,
bitrate,
file_size,
album_data.get('server_source') or source_track.get('server_source') or config_manager.get_active_media_server(),
))
track_source_col = _SERVICE_ID_COLUMNS.get(metadata_source, {}).get('track')
if track_source_col and expected_track_id:
try:
cursor.execute(f"UPDATE tracks SET {track_source_col} = ? WHERE id = ?", (expected_track_id, target_track_id))
except Exception as source_err:
logger.debug("Imported track source-id update failed: %s", source_err)
conn.commit()
try:
active_server = config_manager.get_active_media_server()
if active_server in ('jellyfin', 'navidrome'):
_sync_tracks_to_server([{
'id': target_track_id,
'title': expected_title,
'artist_name': album_data.get('artist_name'),
'album_title': album_data.get('title'),
'year': album_data.get('year'),
'server_source': album_data.get('server_source'),
}], active_server)
except Exception as sync_err:
logger.debug("Existing-track import server sync skipped/failed: %s", sync_err)
updated = database.get_artist_full_detail(album_data.get('target_artist_id'))
if updated.get('success'):
if updated.get('artist', {}).get('thumb_url'):
updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url'])
for album in updated.get('albums', []):
if album.get('thumb_url'):
album['thumb_url'] = fix_artist_image_url(album['thumb_url'])
return jsonify({
"success": True,
"message": "Imported existing track into album",
"track_id": target_track_id,
"final_path": final_path,
"updated_data": updated if updated.get('success') else None,
})
except Exception as e:
logger.error(f"Error importing existing track for album slot: {e}", exc_info=True)
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/track/<track_id>', methods=['DELETE'])
def library_delete_track(track_id):
"""Delete a track from the database, optionally deleting the file and blacklisting the source."""

View file

@ -3358,11 +3358,27 @@ function toggleAlbumExpand(albumId) {
inner.appendChild(renderAlbumMetaRow(album));
inner.appendChild(renderTrackTable(album));
inner.dataset.rendered = 'true';
ensureEnhancedAlbumCanonicalTracks(album).then(updated => {
if (!updated || !artistDetailPageState.expandedAlbums.has(albumId)) return;
rerenderEnhancedAlbumPanel(album.id);
});
}
}
}
}
function rerenderEnhancedAlbumPanel(albumId) {
const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`);
const inner = panel?.querySelector('.enhanced-tracks-panel-inner');
const album = findEnhancedAlbum(albumId);
if (!inner || !album) return;
inner.innerHTML = '';
inner.appendChild(renderExpandedAlbumHeader(album));
inner.appendChild(renderAlbumMetaRow(album));
inner.appendChild(renderTrackTable(album));
inner.dataset.rendered = 'true';
}
function findEnhancedAlbum(albumId) {
// Use cached map for O(1) lookups instead of O(n) array scan
if (artistDetailPageState._albumMap) {
@ -3408,8 +3424,21 @@ function renderExpandedAlbumHeader(album) {
const details = [];
if (album.year) details.push(String(album.year));
const trackCount = album.tracks ? album.tracks.length : 0;
details.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`);
const ownedTrackCount = album.tracks ? album.tracks.length : 0;
const visibleTrackRows = _getEnhancedAlbumTrackRows(album);
const expectedTrackCount = Math.max(
ownedTrackCount,
visibleTrackRows.length,
Number(album.api_track_count || album.track_count || 0)
);
const missingCount = visibleTrackRows.filter(t => t._missingExpected).length;
if (album._canonicalTracksLoading) details.push('checking tracklist');
if (expectedTrackCount > ownedTrackCount) {
details.push(`${ownedTrackCount}/${expectedTrackCount} tracks`);
} else {
details.push(`${ownedTrackCount} track${ownedTrackCount !== 1 ? 's' : ''}`);
}
if (missingCount > 0) details.push(`${missingCount} missing`);
let durMs = 0;
(album.tracks || []).forEach(t => { durMs += (t.duration || 0); });
if (durMs > 0) details.push(formatDurationMs(durMs));
@ -3644,20 +3673,167 @@ function renderAlbumMetaRow(album) {
return row;
}
function _trackSlotKey(track) {
const disc = Number(track.disc_number || track.expected_disc_number || 1);
const num = Number(track.track_number || track.expected_track_number || 0);
return `${disc}:${num}`;
}
function _normalizeExpectedMissingTrack(source, album) {
const title = source.title || source.name || `Track ${source.track_number || '?'}`;
const sourceTrackId = source.track_id || source.id || source.source_track_id || '';
const hasActionableContext = !!(
title &&
source.track_number &&
(sourceTrackId || source.spotify_track_id || source.deezer_id || source.itunes_track_id || source.musicbrainz_recording_id)
);
return {
id: `missing-${album.id}-${source.disc_number || 1}-${source.track_number || ''}`,
title,
track_number: source.track_number || source.position || '',
disc_number: source.disc_number || 1,
duration: source.duration || source.duration_ms || 0,
spotify_track_id: source.spotify_track_id || (source.source === 'spotify' ? sourceTrackId : ''),
deezer_id: source.deezer_id || (source.source === 'deezer' ? sourceTrackId : ''),
itunes_track_id: source.itunes_track_id || (source.source === 'itunes' ? sourceTrackId : ''),
musicbrainz_recording_id: source.musicbrainz_recording_id || (source.source === 'musicbrainz' ? sourceTrackId : ''),
source: source.source || source.metadata_source || '',
track_id: sourceTrackId,
album_id: source.album_id || source.source_album_id || '',
artists: source.artists || source.artist_names || [],
_hasActionableContext: hasActionableContext,
_missingExpected: true,
_sourceTrack: source,
};
}
function _getEnhancedAlbumCanonicalSource(album) {
const priority = [
['spotify', 'spotify_album_id'],
['deezer', 'deezer_id'],
['itunes', 'itunes_album_id'],
['musicbrainz', 'musicbrainz_release_id'],
['discogs', 'discogs_id'],
['tidal', 'tidal_id'],
['qobuz', 'qobuz_id'],
];
for (const [source, key] of priority) {
if (album[key]) return { source, id: album[key] };
}
return null;
}
async function ensureEnhancedAlbumCanonicalTracks(album) {
if (!album || album._canonicalTracksLoaded || album._canonicalTracksLoading) return false;
const canonicalSource = _getEnhancedAlbumCanonicalSource(album);
if (!canonicalSource) {
album._canonicalTracksLoaded = true;
return false;
}
album._canonicalTracksLoading = true;
try {
const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || '';
const params = new URLSearchParams({
name: album.title || '',
artist: artistName,
source: canonicalSource.source,
});
const response = await fetch(`/api/album/${encodeURIComponent(canonicalSource.id)}/tracks?${params}`);
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to load canonical tracklist');
}
const canonicalTracks = Array.isArray(data.tracks) ? data.tracks : [];
album.canonical_tracks = canonicalTracks.map((track, index) => ({
...track,
title: track.title || track.name || `Track ${track.track_number || index + 1}`,
name: track.name || track.title || `Track ${track.track_number || index + 1}`,
track_number: track.track_number || index + 1,
disc_number: track.disc_number || 1,
duration: track.duration || track.duration_ms || 0,
source: data.source || canonicalSource.source,
track_id: track.id || track.track_id || '',
id: track.id || track.track_id || `${canonicalSource.source}:${canonicalSource.id}:${track.disc_number || 1}:${track.track_number || index + 1}`,
}));
album.api_track_count = Math.max(Number(album.api_track_count || 0), album.canonical_tracks.length);
album.missing_tracks = _deriveEnhancedMissingTracks(album, album.canonical_tracks);
album._canonicalTracksLoaded = true;
return true;
} catch (error) {
album._canonicalTracksError = error.message;
album._canonicalTracksLoaded = true;
console.debug('Failed to load canonical album tracks:', album.title, error);
return false;
} finally {
album._canonicalTracksLoading = false;
}
}
function _deriveEnhancedMissingTracks(album, canonicalTracks) {
const occupiedSlots = new Set();
(album.tracks || []).forEach(track => {
const key = _trackSlotKey(track);
if (key !== '1:0') occupiedSlots.add(key);
});
return (canonicalTracks || [])
.map(track => ({
...track,
name: track.name || track.title,
duration_ms: track.duration_ms || track.duration || 0,
}))
.filter(track => {
const key = _trackSlotKey(track);
const normalized = _normalizeExpectedMissingTrack(track, album);
return key !== '1:0' && normalized._hasActionableContext && !occupiedSlots.has(key);
});
}
function _getEnhancedAlbumTrackRows(album) {
const ownedTracks = Array.isArray(album.tracks) ? album.tracks : [];
const rowsBySlot = new Map();
ownedTracks.forEach(track => {
const key = _trackSlotKey(track);
rowsBySlot.set(key === '1:0' ? `owned:${track.id}` : key, track);
});
const explicitMissing = Array.isArray(album.missing_tracks) ? album.missing_tracks : [];
explicitMissing.forEach(missing => {
const row = _normalizeExpectedMissingTrack(missing, album);
const key = _trackSlotKey(row);
if (row._hasActionableContext && !rowsBySlot.has(key)) rowsBySlot.set(key, row);
});
return Array.from(rowsBySlot.values()).sort((a, b) => {
const discDelta = Number(a.disc_number || 1) - Number(b.disc_number || 1);
if (discDelta !== 0) return discDelta;
const trackDelta = Number(a.track_number || 0) - Number(b.track_number || 0);
if (trackDelta !== 0) return trackDelta;
return String(a.title || '').localeCompare(String(b.title || ''));
});
}
function _buildTrackRow(track, album, admin) {
const tr = document.createElement('tr');
tr.dataset.trackId = track.id;
tr.dataset.albumId = album.id;
tr._enhancedTrack = track;
tr._enhancedAlbum = album;
if (track._missingExpected) tr.classList.add('enhanced-missing-track-row');
if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected');
// Checkbox (admin only)
if (admin) {
const cbTd = document.createElement('td');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'enhanced-track-checkbox';
cb.checked = artistDetailPageState.selectedTracks.has(String(track.id));
cbTd.appendChild(cb);
if (!track._missingExpected) {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'enhanced-track-checkbox';
cb.checked = artistDetailPageState.selectedTracks.has(String(track.id));
cbTd.appendChild(cb);
}
tr.appendChild(cbTd);
}
@ -3666,7 +3842,7 @@ function _buildTrackRow(track, album, admin) {
playTd.className = 'col-play';
const playBtn = document.createElement('button');
playBtn.className = 'enhanced-play-btn';
playBtn.innerHTML = '&#9654;';
playBtn.innerHTML = track._missingExpected ? '&mdash;' : '&#9654;';
playBtn.title = track.file_path ? 'Play track' : 'No file available';
if (!track.file_path) playBtn.disabled = true;
playTd.appendChild(playBtn);
@ -3688,6 +3864,13 @@ function _buildTrackRow(track, album, admin) {
const titleTd = document.createElement('td');
titleTd.className = 'col-title' + (admin ? ' editable' : '');
titleTd.textContent = track.title || 'Unknown';
if (track._missingExpected) {
titleTd.classList.remove('editable');
const status = document.createElement('span');
status.className = 'enhanced-missing-track-badge';
status.textContent = 'Missing';
titleTd.appendChild(status);
}
tr.appendChild(titleTd);
// Duration
@ -3699,12 +3882,16 @@ function _buildTrackRow(track, album, admin) {
// Format
const fmtTd = document.createElement('td');
fmtTd.className = 'col-format';
const format = extractFormat(track.file_path);
const fmtSpan = document.createElement('span');
const fmtClass = format === 'FLAC' ? 'flac' : (format === 'MP3' ? 'mp3' : 'other');
fmtSpan.className = `enhanced-format-badge ${fmtClass}`;
fmtSpan.textContent = format;
fmtTd.appendChild(fmtSpan);
if (track._missingExpected) {
fmtTd.textContent = '-';
} else {
const format = extractFormat(track.file_path);
const fmtSpan = document.createElement('span');
const fmtClass = format === 'FLAC' ? 'flac' : (format === 'MP3' ? 'mp3' : 'other');
fmtSpan.className = `enhanced-format-badge ${fmtClass}`;
fmtSpan.textContent = format;
fmtTd.appendChild(fmtSpan);
}
tr.appendChild(fmtTd);
// Bitrate
@ -3727,7 +3914,9 @@ function _buildTrackRow(track, album, admin) {
const pathTd = document.createElement('td');
pathTd.className = 'col-path';
const filePath = track.file_path || '-';
const fileName = filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-';
const fileName = track._missingExpected
? 'Missing from library'
: (filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-');
pathTd.textContent = fileName;
pathTd.title = filePath;
tr.appendChild(pathTd);
@ -3761,7 +3950,7 @@ function _buildTrackRow(track, album, admin) {
// Add to Queue button
const queueTd = document.createElement('td');
queueTd.className = 'col-queue';
if (track.file_path) {
if (!track._missingExpected && track.file_path) {
const queueBtn = document.createElement('button');
queueBtn.className = 'enhanced-queue-btn';
queueBtn.innerHTML = '&#43;';
@ -3774,7 +3963,7 @@ function _buildTrackRow(track, album, admin) {
// Write Tags button (admin only)
const tagTd = document.createElement('td');
tagTd.className = 'col-writetag';
if (track.file_path) {
if (track.file_path && !track._missingExpected) {
const tagBtn = document.createElement('button');
tagBtn.className = 'enhanced-write-tag-btn';
tagBtn.innerHTML = '&#9998;';
@ -3789,26 +3978,42 @@ function _buildTrackRow(track, album, admin) {
}
tr.appendChild(tagTd);
// Track actions cell — source info, redownload, delete (admin only)
// Track actions cell: source info, redownload, delete, or missing-track actions.
const actionsTd = document.createElement('td');
actionsTd.className = 'col-track-actions';
actionsTd.innerHTML = `
<div class="enhanced-track-actions-group">
<button class="enhanced-source-info-btn" title="View download source info"></button>
<button class="enhanced-redownload-btn" title="Redownload this track">&#8635;</button>
<button class="enhanced-delete-btn" title="Delete track from library">&#10005;</button>
</div>
`;
if (track._missingExpected) {
actionsTd.innerHTML = `
<div class="enhanced-track-actions-group visible">
<button class="enhanced-missing-manage-btn" data-action="manage-missing" title="Manage this missing album track">Manage</button>
</div>
`;
} else {
actionsTd.innerHTML = `
<div class="enhanced-track-actions-group">
<button class="enhanced-source-info-btn" title="View download source info"></button>
<button class="enhanced-redownload-btn" title="Redownload this track">&#8635;</button>
<button class="enhanced-delete-btn" title="Delete track from library">&#10005;</button>
</div>
`;
}
tr.appendChild(actionsTd);
} else {
// Report Issue button per track (non-admin)
const reportTd = document.createElement('td');
reportTd.className = 'col-report';
const reportBtn = document.createElement('button');
reportBtn.className = 'enhanced-track-report-btn';
reportBtn.innerHTML = '&#9873;';
reportBtn.title = 'Report issue with this track';
reportTd.appendChild(reportBtn);
if (track._missingExpected) {
const manageBtn = document.createElement('button');
manageBtn.className = 'enhanced-missing-manage-btn';
manageBtn.textContent = 'Manage';
manageBtn.dataset.action = 'manage-missing';
reportTd.appendChild(manageBtn);
} else {
const reportBtn = document.createElement('button');
reportBtn.className = 'enhanced-track-report-btn';
reportBtn.innerHTML = '&#9873;';
reportBtn.title = 'Report issue with this track';
reportTd.appendChild(reportBtn);
}
tr.appendChild(reportTd);
}
@ -3826,6 +4031,14 @@ function _buildTrackRow(track, album, admin) {
}
function _getTrackDataFromRow(tr) {
if (tr._enhancedTrack && tr._enhancedAlbum) {
return {
track: tr._enhancedTrack,
album: tr._enhancedAlbum,
trackId: tr._enhancedTrack.id,
albumId: tr._enhancedAlbum.id
};
}
const trackId = tr.dataset.trackId;
const albumId = tr.dataset.albumId;
const album = findEnhancedAlbum(albumId);
@ -3872,6 +4085,13 @@ function _attachTableDelegation(table, album) {
if (!info) return;
const { track, trackId } = info;
const manageAction = target.closest('.enhanced-missing-manage-btn');
if (manageAction && track._missingExpected) {
e.stopPropagation();
openMissingTrackManageModal(track, album);
return;
}
// Checkbox
if (target.classList.contains('enhanced-track-checkbox')) {
toggleTrackSelection(String(trackId));
@ -4065,7 +4285,7 @@ function _rebuildTbody(table, album) {
const admin = isEnhancedAdmin();
const oldTbody = table.querySelector('tbody');
const newTbody = document.createElement('tbody');
(album.tracks || []).forEach(track => {
_getEnhancedAlbumTrackRows(album).forEach(track => {
newTbody.appendChild(_buildTrackRow(track, album, admin));
});
if (oldTbody) table.replaceChild(newTbody, oldTbody);
@ -4074,13 +4294,13 @@ function _rebuildTbody(table, album) {
function renderTrackTable(album) {
const wrapper = document.createElement('div');
const tracks = album.tracks || [];
// Re-apply stored sort order if any
const activeSort = artistDetailPageState.enhancedTrackSort[album.id];
if (activeSort) {
sortEnhancedTracks(album, activeSort.field, activeSort.ascending);
}
const tracks = _getEnhancedAlbumTrackRows(album);
if (tracks.length === 0) {
wrapper.innerHTML = '<div class="enhanced-no-tracks">No tracks in database</div>';
@ -5785,6 +6005,397 @@ async function applyManualMatch(entityType, entityId, service, serviceId, artist
}
}
async function wishlistEnhancedMissingTrack(track, album, downloadNow = false) {
if (!track._hasActionableContext) {
showToast('This missing track needs metadata context before it can be wishlisted or downloaded.', 'error');
return;
}
const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || '';
const artist = {
id: artistDetailPageState.enhancedData?.artist?.id || artistDetailPageState.currentArtistId || '',
name: artistName,
image_url: artistDetailPageState.enhancedData?.artist?.thumb_url || getArtistImageFromPage() || '',
};
const albumData = {
id: album.id,
name: album.title || 'Unknown Album',
title: album.title || 'Unknown Album',
image_url: album.thumb_url || '',
release_date: album.year ? `${album.year}-01-01` : '',
album_type: album.record_type || 'album',
total_tracks: Number(album.api_track_count || album.track_count || album.tracks?.length || 1),
};
const wishlistTrack = {
id: track.spotify_track_id || track.deezer_id || track.itunes_track_id || track.musicbrainz_recording_id || track.id,
name: track.title || `Track ${track.track_number || ''}`,
title: track.title || `Track ${track.track_number || ''}`,
artists: [{ name: artistName }],
duration_ms: track.duration || 0,
track_number: track.track_number || 1,
disc_number: track.disc_number || 1,
album: albumData,
};
if (typeof openAddToWishlistModal !== 'function') {
showToast('Wishlist modal is not available on this page', 'error');
return;
}
await openAddToWishlistModal(albumData, artist, [wishlistTrack], albumData.album_type, { [wishlistTrack.name]: false });
if (downloadNow && typeof handleWishlistDownloadNow === 'function') {
setTimeout(() => handleWishlistDownloadNow(), 150);
}
}
function openMissingTrackManageModal(track, album) {
if (track._missingExpected && !track._hasActionableContext) {
showToast('This missing track needs metadata context before it can be managed.', 'error');
return;
}
const existing = document.getElementById('enhanced-missing-manage-overlay');
if (existing) existing.remove();
const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || '';
const overlay = document.createElement('div');
overlay.id = 'enhanced-missing-manage-overlay';
overlay.className = 'modal-overlay';
let isImporting = false;
overlay.onclick = (e) => { if (e.target === overlay && !isImporting) overlay.remove(); };
const modal = document.createElement('div');
modal.className = 'confirm-modal enhanced-missing-manage-modal';
modal.innerHTML = `
<div class="confirm-modal-header">
<h2>Manage Missing Track</h2>
<button class="confirm-modal-close" type="button">&times;</button>
</div>
<div class="confirm-modal-body enhanced-missing-manage-body">
<div class="enhanced-missing-manage-target">
<div class="enhanced-have-target-label">Missing album slot</div>
<div class="enhanced-have-target-title">#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}</div>
<div class="enhanced-have-target-meta">${escapeHtml(artistName)} &middot; ${escapeHtml(album.title || '')}</div>
</div>
<div class="enhanced-missing-manage-options">
<button class="enhanced-missing-option primary" data-action="library">
<span class="enhanced-missing-option-icon">+</span>
<span>
<span class="enhanced-missing-option-title">Add to Library</span>
<span class="enhanced-missing-option-desc">Open the normal library-add flow with this exact track context.</span>
</span>
</button>
<button class="enhanced-missing-option" data-action="have">
<span class="enhanced-missing-option-icon">OK</span>
<span>
<span class="enhanced-missing-option-title">I Have This</span>
<span class="enhanced-missing-option-desc">Copy an existing file and process it into this album slot. The original stays untouched.</span>
</span>
</button>
</div>
</div>
<div class="confirm-modal-actions">
<button class="modal-button modal-button--secondary" type="button" data-action="cancel">Cancel</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const close = () => overlay.remove();
modal.querySelector('.confirm-modal-close').onclick = close;
modal.querySelector('[data-action="cancel"]').onclick = close;
modal.querySelectorAll('.enhanced-missing-option').forEach(button => {
button.onclick = async () => {
const action = button.dataset.action;
close();
if (action === 'library') {
await wishlistEnhancedMissingTrack(track, album, false);
} else if (action === 'have') {
openHaveMissingTrackModal(track, album);
}
};
});
}
function openHaveMissingTrackModal(track, album) {
if (track._missingExpected && !track._hasActionableContext) {
showToast('This missing track needs metadata context before it can be imported.', 'error');
return;
}
const existing = document.getElementById('enhanced-have-track-overlay');
if (existing) existing.remove();
const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || '';
const overlay = document.createElement('div');
overlay.id = 'enhanced-have-track-overlay';
overlay.className = 'modal-overlay';
let isImporting = false;
overlay.onclick = (e) => { if (e.target === overlay && !isImporting) overlay.remove(); };
const modal = document.createElement('div');
modal.className = 'enhanced-manual-match-modal enhanced-have-track-modal';
modal.innerHTML = `
<div class="enhanced-bulk-modal-header">
<div>
<h3>I Have This Track</h3>
<div class="enhanced-have-subtitle">Use an existing file as the source audio. SoulSync will copy it into this album.</div>
</div>
<button class="enhanced-bulk-modal-close" type="button">&times;</button>
</div>
<div class="enhanced-have-target">
<div class="enhanced-have-target-label">Missing album slot</div>
<div class="enhanced-have-target-title">#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}</div>
<div class="enhanced-have-target-meta">${escapeHtml(artistName)} &middot; ${escapeHtml(album.title || '')}</div>
</div>
<div class="enhanced-match-search-row">
<input class="enhanced-match-search-input" id="enhanced-have-track-search" type="text" value="${escapeHtml(`${track.title || ''} ${artistName}`.trim())}" placeholder="Search your library...">
<button class="enhanced-enrich-btn" id="enhanced-have-track-search-btn" type="button">Search</button>
</div>
<div class="enhanced-match-results" id="enhanced-have-track-results">
<div class="enhanced-match-results-hint">Searching your library...</div>
</div>
<div class="enhanced-have-selected" id="enhanced-have-selected" hidden>
<span>Selected</span>
<strong></strong>
</div>
<div class="enhanced-have-note">The selected file stays in its current album/folder. SoulSync copies it, writes the missing track's tags, and places the copy in this album.</div>
<div class="enhanced-have-import-status" id="enhanced-have-import-status" hidden>
<div class="enhanced-have-import-status-top">
<span class="enhanced-have-import-spinner"></span>
<span class="enhanced-have-import-title">Preparing import...</span>
<span class="enhanced-have-import-time">0s</span>
</div>
<div class="enhanced-have-import-detail">Waiting to start.</div>
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" type="button" id="enhanced-have-cancel">Cancel</button>
<button class="enhanced-bulk-btn primary" type="button" id="enhanced-have-confirm" disabled>Import Track</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
let selectedTrackId = null;
let selectedTrackSummary = '';
let importTimer = null;
const searchResultsById = new Map();
const searchInput = modal.querySelector('#enhanced-have-track-search');
const resultsEl = modal.querySelector('#enhanced-have-track-results');
const selectedEl = modal.querySelector('#enhanced-have-selected');
const selectedText = selectedEl.querySelector('strong');
const confirmBtn = modal.querySelector('#enhanced-have-confirm');
const cancelBtn = modal.querySelector('#enhanced-have-cancel');
const closeBtn = modal.querySelector('.enhanced-bulk-modal-close');
const searchBtn = modal.querySelector('#enhanced-have-track-search-btn');
const statusEl = modal.querySelector('#enhanced-have-import-status');
const statusTitle = statusEl.querySelector('.enhanced-have-import-title');
const statusDetail = statusEl.querySelector('.enhanced-have-import-detail');
const statusTime = statusEl.querySelector('.enhanced-have-import-time');
const close = () => {
if (isImporting) return;
if (importTimer) clearInterval(importTimer);
overlay.remove();
};
closeBtn.onclick = close;
cancelBtn.onclick = close;
searchBtn.onclick = () => runSearch();
searchInput.onkeydown = (e) => { if (e.key === 'Enter' && !isImporting) runSearch(); };
function selectResultRow(row) {
if (isImporting || !row) return;
const trackId = row.dataset.trackId;
const result = searchResultsById.get(String(trackId));
if (!trackId || !result) return;
resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(r => {
r.classList.remove('selected');
r.setAttribute('aria-pressed', 'false');
});
row.classList.add('selected');
row.setAttribute('aria-pressed', 'true');
selectedTrackId = trackId;
selectedTrackSummary = `${result.title || 'Unknown'}${result.album_title ? ` from ${result.album_title}` : ''}`;
selectedText.textContent = selectedTrackSummary;
selectedEl.hidden = false;
confirmBtn.disabled = false;
}
resultsEl.addEventListener('click', (event) => {
const row = event.target.closest('.enhanced-have-result-row');
if (!row || !resultsEl.contains(row)) return;
event.preventDefault();
selectResultRow(row);
});
resultsEl.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const row = event.target.closest('.enhanced-have-result-row');
if (!row) return;
event.preventDefault();
selectResultRow(row);
});
function setImportStatus(title, detail, tone = 'working') {
statusEl.hidden = false;
statusEl.classList.toggle('error', tone === 'error');
statusEl.classList.toggle('success', tone === 'success');
statusTitle.textContent = title;
statusDetail.textContent = detail;
}
function startImportTimer() {
const start = Date.now();
const stages = [
{ after: 0, text: 'Copying selected file into staging.' },
{ after: 4, text: 'Verifying audio and writing the missing track tags.' },
{ after: 10, text: 'Post-processing can take a moment for FLAC files, lyrics, ReplayGain, and metadata.' },
{ after: 20, text: 'Still working. Waiting for the backend to finish and return the refreshed library row.' },
];
if (importTimer) clearInterval(importTimer);
importTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - start) / 1000);
statusTime.textContent = `${elapsed}s`;
const stage = [...stages].reverse().find(item => elapsed >= item.after);
if (stage) statusDetail.textContent = stage.text;
}, 250);
}
async function runSearch() {
const query = searchInput.value.trim();
if (!query) {
resultsEl.innerHTML = '<div class="enhanced-match-results-hint">Enter a title or artist to search.</div>';
return;
}
selectedTrackId = null;
selectedTrackSummary = '';
selectedEl.hidden = true;
confirmBtn.disabled = true;
resultsEl.innerHTML = '<div class="enhanced-loading">Searching...</div>';
searchResultsById.clear();
try {
const res = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=12`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Search failed');
const tracks = data.tracks || [];
if (tracks.length === 0) {
resultsEl.innerHTML = '<div class="enhanced-match-results-hint">No library tracks found. Try a different search.</div>';
return;
}
resultsEl.innerHTML = '';
tracks.forEach(result => {
if (!result.id) return;
searchResultsById.set(String(result.id), result);
const row = document.createElement('div');
row.className = 'enhanced-have-result-row';
row.dataset.trackId = String(result.id);
row.setAttribute('role', 'button');
row.setAttribute('tabindex', '0');
row.setAttribute('aria-pressed', 'false');
const fileName = result.file_path ? result.file_path.split(/[\\/]/).pop() : 'No file path';
row.innerHTML = `
<span class="enhanced-have-radio"></span>
<span class="enhanced-have-result-main">
<span class="enhanced-have-result-title">${escapeHtml(result.title || 'Unknown')}</span>
<span class="enhanced-have-result-meta">${escapeHtml(result.artist_name || '')}${result.album_title ? ` &middot; ${escapeHtml(result.album_title)}` : ''}</span>
<span class="enhanced-have-result-file">${escapeHtml(fileName)}</span>
</span>
<span class="enhanced-have-result-side">
${result.duration ? `<span>${formatDurationMs(result.duration)}</span>` : ''}
${result.bitrate ? `<span>${result.bitrate} kbps</span>` : ''}
</span>
`;
resultsEl.appendChild(row);
});
} catch (error) {
resultsEl.innerHTML = `<div class="enhanced-match-results-hint" style="color:#ff6b6b;">Error: ${escapeHtml(error.message)}</div>`;
}
}
confirmBtn.onclick = async () => {
if (!selectedTrackId) return;
isImporting = true;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Importing...';
cancelBtn.disabled = true;
closeBtn.disabled = true;
searchBtn.disabled = true;
searchInput.disabled = true;
resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(row => {
row.setAttribute('aria-disabled', 'true');
row.classList.add('disabled');
});
setImportStatus(
'Importing selected file',
selectedTrackSummary ? `Using ${selectedTrackSummary}.` : 'Using the selected library track.'
);
startImportTimer();
try {
const sourceTrack = track._sourceTrack || track;
const expectedTrack = {
title: track.title || sourceTrack.title || sourceTrack.name || '',
name: track.title || sourceTrack.title || sourceTrack.name || '',
track_number: track.track_number || sourceTrack.track_number,
disc_number: track.disc_number || sourceTrack.disc_number || 1,
duration: track.duration || sourceTrack.duration || sourceTrack.duration_ms || 0,
duration_ms: track.duration || sourceTrack.duration_ms || sourceTrack.duration || 0,
source: track.source || sourceTrack.source || '',
track_id: track.track_id || sourceTrack.track_id || sourceTrack.id || '',
id: track.track_id || sourceTrack.track_id || sourceTrack.id || '',
album_id: track.album_id || sourceTrack.album_id || '',
spotify_track_id: track.spotify_track_id || sourceTrack.spotify_track_id || '',
deezer_id: track.deezer_id || sourceTrack.deezer_id || '',
itunes_track_id: track.itunes_track_id || sourceTrack.itunes_track_id || '',
musicbrainz_recording_id: track.musicbrainz_recording_id || sourceTrack.musicbrainz_recording_id || '',
artists: track.artists || sourceTrack.artists || [artistName],
};
const discs = (album.canonical_tracks || album.tracks || []).map(t => Number(t.disc_number || 1));
const res = await fetch(`/api/library/album/${album.id}/import-existing-track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_track_id: selectedTrackId,
expected_track: expectedTrack,
album_source_id: album.spotify_album_id || album.deezer_id || album.itunes_album_id || album.musicbrainz_release_id || album.discogs_id || album.tidal_id || album.qobuz_id || '',
total_discs: Math.max(1, ...discs),
}),
});
setImportStatus('Finalizing import', 'Backend finished. Refreshing the enhanced library view...');
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to import track');
if (importTimer) clearInterval(importTimer);
statusTime.textContent = statusTime.textContent || 'done';
setImportStatus('Import complete', 'The copied file is now being shown in this album.', 'success');
showToast('Track imported. Original file was left untouched.', 'success');
if (data.updated_data && data.updated_data.success) {
artistDetailPageState.enhancedData = data.updated_data;
_rebuildAlbumMap();
renderEnhancedView();
} else if (artistDetailPageState.currentArtistId) {
await loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
setTimeout(() => overlay.remove(), 650);
} catch (error) {
if (importTimer) clearInterval(importTimer);
isImporting = false;
confirmBtn.disabled = false;
confirmBtn.textContent = 'Import Track';
cancelBtn.disabled = false;
closeBtn.disabled = false;
searchBtn.disabled = false;
searchInput.disabled = false;
resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(row => {
row.setAttribute('aria-disabled', 'false');
row.classList.remove('disabled');
});
setImportStatus('Import failed', error.message, 'error');
showToast(`Import failed: ${error.message}`, 'error');
}
};
searchInput.focus();
runSearch();
}
// ---- Enrichment ----
let _enrichmentInFlight = false;

View file

@ -45526,6 +45526,74 @@ textarea.enhanced-meta-field-input {
.enhanced-track-table tr.selected {
background: rgba(var(--accent-rgb), 0.06);
}
.enhanced-track-table tr.enhanced-missing-track-row {
background: rgba(255, 184, 77, 0.025);
}
.enhanced-track-table tr.enhanced-missing-track-row:hover {
background: rgba(255, 184, 77, 0.055);
}
.enhanced-missing-track-badge {
display: inline-flex;
align-items: center;
margin-left: 8px;
padding: 2px 6px;
border-radius: 7px;
font-size: 9px;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.4px;
vertical-align: middle;
}
.enhanced-missing-track-badge {
color: #ffb84d;
background: rgba(255, 184, 77, 0.11);
border: 1px solid rgba(255, 184, 77, 0.22);
}
.enhanced-missing-action-btn,
.enhanced-missing-manage-btn {
min-height: 24px;
padding: 4px 8px;
border-radius: 7px;
border: 1px solid rgba(255,255,255,0.09);
font-size: 10px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: all 0.18s ease;
white-space: nowrap;
}
.enhanced-missing-manage-btn {
min-width: 68px;
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.1);
border-color: rgba(var(--accent-rgb), 0.26);
}
.enhanced-missing-manage-btn:hover {
color: #fff;
background: rgba(var(--accent-rgb), 0.2);
border-color: rgba(var(--accent-rgb), 0.42);
transform: translateY(-1px);
}
.enhanced-missing-action-btn.primary {
color: #fff;
background: rgba(var(--accent-rgb), 0.9);
border-color: rgba(var(--accent-light-rgb), 0.55);
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.16);
}
.enhanced-missing-action-btn.primary:hover {
background: rgb(var(--accent-light-rgb));
transform: translateY(-1px);
}
.enhanced-missing-action-btn.secondary {
color: rgba(255,255,255,0.68);
background: rgba(255,255,255,0.055);
}
.enhanced-missing-action-btn.secondary:hover {
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.11);
border-color: rgba(var(--accent-rgb), 0.25);
}
/* Play button */
.enhanced-play-btn {
@ -46679,6 +46747,268 @@ textarea.enhanced-meta-field-input {
margin-top: 2px;
font-family: monospace;
}
.enhanced-have-track-modal {
width: 640px;
}
.enhanced-have-subtitle {
margin-top: 4px;
font-size: 12px;
color: rgba(255,255,255,0.45);
font-weight: 400;
}
.enhanced-have-target {
margin: 16px 20px 0;
padding: 14px 16px;
border: 1px solid rgba(var(--accent-rgb), 0.18);
border-radius: 10px;
background: rgba(var(--accent-rgb), 0.055);
}
.enhanced-have-target-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.7px;
text-transform: uppercase;
color: rgb(var(--accent-light-rgb));
margin-bottom: 5px;
}
.enhanced-have-target-title {
color: #fff;
font-size: 15px;
font-weight: 700;
}
.enhanced-have-target-meta {
margin-top: 3px;
color: rgba(255,255,255,0.48);
font-size: 12px;
}
.enhanced-have-note {
margin: 0 20px 16px;
padding: 10px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.52);
background: rgba(255,255,255,0.035);
border: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
line-height: 1.45;
}
.enhanced-have-import-status {
margin: 0 20px 16px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: rgba(var(--accent-rgb), 0.07);
}
.enhanced-have-import-status[hidden] {
display: none;
}
.enhanced-have-import-status-top {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.enhanced-have-import-spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(var(--accent-rgb), 0.24);
border-top-color: rgb(var(--accent-light-rgb));
flex-shrink: 0;
animation: enhanced-have-spin 0.8s linear infinite;
}
.enhanced-have-import-title {
color: rgba(255,255,255,0.92);
font-size: 13px;
font-weight: 700;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.enhanced-have-import-time {
color: rgba(255,255,255,0.44);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.enhanced-have-import-detail {
margin-top: 6px;
padding-left: 23px;
color: rgba(255,255,255,0.54);
font-size: 12px;
line-height: 1.4;
}
.enhanced-have-import-status.success {
border-color: rgba(76, 217, 100, 0.3);
background: rgba(76, 217, 100, 0.08);
}
.enhanced-have-import-status.success .enhanced-have-import-spinner {
animation: none;
border-color: rgba(76, 217, 100, 0.35);
background: rgba(76, 217, 100, 0.8);
}
.enhanced-have-import-status.error {
border-color: rgba(255, 107, 107, 0.32);
background: rgba(255, 107, 107, 0.08);
}
.enhanced-have-import-status.error .enhanced-have-import-spinner {
animation: none;
border-color: rgba(255, 107, 107, 0.38);
background: rgba(255, 107, 107, 0.85);
}
@keyframes enhanced-have-spin {
to { transform: rotate(360deg); }
}
.enhanced-have-result-row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px;
margin-bottom: 8px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.05);
background: rgba(255,255,255,0.025);
color: inherit;
text-align: left;
cursor: pointer;
transition: all 0.16s ease;
}
.enhanced-have-result-row:hover,
.enhanced-have-result-row.selected {
background: rgba(var(--accent-rgb), 0.07);
border-color: rgba(var(--accent-rgb), 0.28);
}
.enhanced-have-result-row.disabled {
opacity: 0.55;
cursor: wait;
pointer-events: none;
}
.enhanced-have-radio {
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.28);
flex-shrink: 0;
}
.enhanced-have-result-row.selected .enhanced-have-radio {
border-color: rgb(var(--accent-light-rgb));
box-shadow: inset 0 0 0 4px #151515;
background: rgb(var(--accent-light-rgb));
}
.enhanced-have-result-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.enhanced-have-result-title {
color: rgba(255,255,255,0.92);
font-size: 13px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.enhanced-have-result-meta,
.enhanced-have-result-file {
color: rgba(255,255,255,0.42);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.enhanced-have-result-file {
color: rgba(255,255,255,0.28);
font-family: 'SF Mono', 'Consolas', monospace;
}
.enhanced-have-result-side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
color: rgba(255,255,255,0.45);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.enhanced-missing-manage-modal {
width: 520px;
}
.enhanced-missing-manage-modal .confirm-modal-header h2 {
color: #fff;
font-size: 18px;
font-weight: 700;
margin: 0;
}
.enhanced-missing-manage-body {
padding: 20px 24px;
}
.enhanced-missing-manage-target {
padding: 14px 16px;
border: 1px solid rgba(var(--accent-rgb), 0.18);
border-radius: 10px;
background: rgba(var(--accent-rgb), 0.055);
}
.enhanced-missing-manage-options {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.enhanced-missing-option {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 13px 14px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(255,255,255,0.035);
color: inherit;
text-align: left;
cursor: pointer;
transition: all 0.16s ease;
}
.enhanced-missing-option:hover,
.enhanced-missing-option.primary:hover {
background: rgba(var(--accent-rgb), 0.1);
border-color: rgba(var(--accent-rgb), 0.28);
transform: translateY(-1px);
}
.enhanced-missing-option.primary {
background: rgba(var(--accent-rgb), 0.065);
border-color: rgba(var(--accent-rgb), 0.2);
}
.enhanced-missing-option-icon {
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--accent-rgb), 0.12);
color: rgb(var(--accent-light-rgb));
border: 1px solid rgba(var(--accent-rgb), 0.2);
font-size: 14px;
font-weight: 800;
}
.enhanced-missing-option-title,
.enhanced-missing-option-desc {
display: block;
}
.enhanced-missing-option-title {
color: rgba(255,255,255,0.9);
font-size: 13px;
font-weight: 700;
}
.enhanced-missing-option-desc {
margin-top: 2px;
color: rgba(255,255,255,0.46);
font-size: 11px;
line-height: 1.35;
}
/* Track-level match status */
.enhanced-track-match-cell {
@ -56853,6 +57183,7 @@ tr.tag-diff-same {
transition: opacity 0.15s;
}
tr:hover .enhanced-track-actions-group { opacity: 1; }
.enhanced-track-actions-group.visible { opacity: 1; }
.enhanced-source-info-btn,
.enhanced-redownload-btn {