diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 2f7e71b9..db15a907 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -969,19 +969,12 @@ color: #ef4444; } -/* ======================================== - END IMPORT PAGE - .importPageFileChip { - cursor: pointer; - } - - .importPageMatchRow { - cursor: pointer; - } -} - -/* Import Page — small screen */ @media (max-width: 768px) { + .importPageFileChip, + .importPageMatchRow { + cursor: pointer; + } + .importPageContainer { padding: 16px; } diff --git a/webui/static/init.js b/webui/static/init.js index 6b760b0c..10a047b4 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2420,9 +2420,6 @@ async function loadPageData(pageId) { loadApiKeys(); loadBlacklistCount(); break; - case 'import': - initializeImportPage(); - break; case 'hydrabase': // Check connection status and pre-fill saved credentials try { diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 043ea773..bd81ae64 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -2312,86 +2312,9 @@ opacity: 0.7; transition: opacity 0.1s ease; } - - /* Import Page - Touch fallback */ - .import-page-file-chip { - cursor: pointer; - } - - .import-page-match-row { - cursor: pointer; - } } -/* Import Page — small screen */ @media (max-width: 768px) { - .import-page-container { - padding: 16px; - } - - .import-page-title { - font-size: 22px; - } - - .import-page-album-grid { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 10px; - } - - .import-page-album-hero { - flex-direction: column; - text-align: center; - } - - .import-page-album-hero img { - width: 100px; - height: 100px; - } - - .import-page-match-row { - grid-template-columns: 28px 1fr; - gap: 8px; - } - - .import-page-match-file { - grid-column: 1 / -1; - padding-left: 28px; - } - - .import-page-match-unmatch { - grid-column: 1 / -1; - justify-self: end; - } - - .import-page-singles-header { - flex-direction: column; - align-items: flex-start; - } - - .import-page-singles-actions { - width: 100%; - flex-wrap: wrap; - } - - .import-page-single-item { - grid-template-columns: 28px 1fr; - } - - .import-page-single-actions { - grid-column: 1 / -1; - justify-self: end; - } - - .import-page-single-search-panel { - padding-left: 0; - } - - .import-page-staging-bar { - flex-direction: column; - gap: 4px; - align-items: flex-start; - } - /* Profile Picker - Mobile */ .profile-picker-grid { gap: 20px; diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 68772b35..9723c549 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1,1377 +1,3 @@ -// IMPORT PAGE (full page, replaces old modal) -// =================================================================== - -let importJobIdCounter = 0; - -const importPageState = { - stagingFiles: [], - selectedSingles: new Set(), - albumData: null, // response from /api/import/album/match - matchOverrides: {}, // { trackIndex: stagingFileIndex } — manual drag-drop overrides - singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } } - initialized: false, - activeTab: 'album', - tapSelectedChip: null, // for mobile tap-to-assign fallback - // Album lookup cache for click handlers. Populated by suggestions / - // search renderers; read by importPageSelectAlbum so the match POST - // can include `source` + `name` + `artist` (without these, backend - // can't look up cross-source IDs and falls back to a broken - // "Unknown Artist / album_id as title / 0 tracks" placeholder — - // github issue #524). - _albumLookup: {}, // { albumId: { id, name, artist, source } } -}; - -// --- Initialization --- - -function initializeImportPage() { - if (!importPageState.initialized) { - importPageState.initialized = true; - importPageRefreshStaging(); - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - } -} - -async function importPageRefreshStaging() { - // Clear finished jobs from the queue - importPageClearFinishedJobs(); - - try { - const resp = await fetch('/api/import/staging/files'); - const data = await resp.json(); - if (!data.success) { - document.getElementById('import-page-staging-path').textContent = `Import folder: error`; - return; - } - - importPageState.stagingFiles = data.files || []; - document.getElementById('import-page-staging-path').textContent = `Import: ${data.staging_path || 'Not configured'}`; - - const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0); - const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB` - : totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB` - : `${(totalSize / 1024).toFixed(0)} KB`; - document.getElementById('import-page-staging-stats').textContent = - `${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`; - - // Refresh the current tab view after data is loaded - if (importPageState.activeTab === 'singles') { - importPageRenderSinglesList(); - } else if (importPageState.activeTab === 'album') { - importPageLoadAutoGroups(); - } - // Always refresh suggestions and groups in background - importPageLoadSuggestions(); - } catch (err) { - console.error('Failed to refresh staging:', err); - } -} - -function importPageSwitchTab(tab) { - importPageState.activeTab = tab; - document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-tab-auto')?.classList.toggle('active', tab === 'auto'); - document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-singles-content')?.classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-auto-content')?.classList.toggle('active', tab === 'auto'); - - if (tab === 'singles' && importPageState.stagingFiles.length > 0) { - importPageRenderSinglesList(); - } - if (tab === 'auto') { - _autoImportLoadStatus(); - _autoImportLoadResults(); - _autoImportStartPolling(); - } else { - _autoImportStopPolling(); - } -} - -// ── Auto-Import Tab ── -let _autoImportPollInterval = null; -let _autoImportFilter = 'all'; -let _autoImportLastStatus = null; - -function _autoImportStartPolling() { - _autoImportStopPolling(); - _autoImportPollInterval = setInterval(async () => { - if (importPageState.activeTab === 'auto') { - await _autoImportLoadStatus(); - _autoImportLoadResults(); - } - }, 5000); -} - -function _autoImportStopPolling() { - if (_autoImportPollInterval) { clearInterval(_autoImportPollInterval); _autoImportPollInterval = null; } -} - -async function _autoImportToggle(enabled) { - // Optimistically update toggle state so it doesn't flicker - const toggle = document.getElementById('auto-import-enabled'); - if (toggle) toggle.checked = enabled; - const statusText = document.getElementById('auto-import-status-text'); - if (statusText) statusText.textContent = enabled ? 'Starting...' : 'Stopping...'; - - try { - const res = await fetch('/api/auto-import/toggle', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) - }); - const data = await res.json(); - if (data.success) { - showToast(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success'); - _autoImportLoadStatus(); - } else { - // Revert on failure - if (toggle) toggle.checked = !enabled; - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - if (toggle) toggle.checked = !enabled; - } -} - -async function _autoImportLoadStatus() { - try { - const res = await fetch('/api/auto-import/status'); - const data = await res.json(); - if (!data.success) return; - _autoImportLastStatus = data; - - const toggle = document.getElementById('auto-import-enabled'); - const statusText = document.getElementById('auto-import-status-text'); - const settingsRow = document.getElementById('auto-import-settings-row'); - const scanNowBtn = document.getElementById('auto-import-scan-now'); - const progressEl = document.getElementById('auto-import-progress'); - const progressText = document.getElementById('auto-import-progress-text'); - - if (toggle) toggle.checked = data.running; - if (settingsRow) settingsRow.style.display = data.running ? '' : 'none'; - if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none'; - - // Live scan + per-track processing progress. - // `active_imports` (added when the worker switched to a bounded - // executor pool) is the source of truth; multiple albums can be - // in flight at once. Render each one on its own line; fall back - // to the legacy single-line summary for older backend payloads. - if (progressEl) { - const active = Array.isArray(data.active_imports) ? data.active_imports : []; - if (active.length > 0) { - progressEl.style.display = ''; - if (progressText) { - const lines = active.map(a => { - const folder = a.folder_name || '...'; - const idx = a.track_index || 0; - const total = a.track_total || 0; - const trackName = a.track_name || ''; - if (a.status === 'processing' && total > 0) { - return `${folder} — track ${idx}/${total}: ${trackName}`; - } - if (a.status === 'matching') return `${folder} — matching tracks…`; - if (a.status === 'identifying') return `${folder} — identifying…`; - return `${folder} — queued`; - }); - progressText.textContent = lines.length === 1 - ? `Processing ${lines[0]}` - : `Processing ${lines.length} imports:\n${lines.join('\n')}`; - } - } else if (data.current_status === 'scanning') { - progressEl.style.display = ''; - if (progressText) { - const stats = data.stats || {}; - progressText.textContent = `Scanning… (${stats.scanned || 0} processed)`; - } - } else { - progressEl.style.display = 'none'; - } - } - - if (statusText) { - if (data.paused) statusText.textContent = 'Paused'; - else if (data.current_status === 'processing') statusText.textContent = 'Processing...'; - else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...'; - else if (data.running) { - // Show last scan time - let watchText = 'Watching'; - if (data.last_scan_time) { - try { - const lastScan = new Date(data.last_scan_time); - const diffS = Math.floor((Date.now() - lastScan) / 1000); - if (diffS < 60) watchText = `Watching (scanned ${diffS}s ago)`; - else if (diffS < 3600) watchText = `Watching (scanned ${Math.floor(diffS / 60)}m ago)`; - } catch (e) {} - } - statusText.textContent = watchText; - } else statusText.textContent = 'Disabled'; - const _runningClass = data.current_status === 'scanning' - ? 'scanning' - : data.current_status === 'processing' - ? 'processing' - : 'active'; - statusText.className = 'auto-import-status ' + (data.running ? _runningClass : 'disabled'); - } - } catch (e) {} -} - -async function _autoImportLoadResults() { - const container = document.getElementById('auto-import-results'); - if (!container) return; - try { - const res = await fetch('/api/auto-import/results?limit=100'); - const data = await res.json(); - if (!data.success || !data.results || data.results.length === 0) { - if (!container.querySelector('.auto-import-card')) { - container.innerHTML = `
-

No imports yet. Drop album folders or single tracks into your import folder.

-
`; - } - // Hide stats and filters - const statsEl = document.getElementById('auto-import-stats'); - const filtersEl = document.getElementById('auto-import-filters'); - if (statsEl) statsEl.style.display = 'none'; - if (filtersEl) filtersEl.style.display = 'none'; - return; - } - - // Compute stats - const allResults = data.results; - const importedCount = allResults.filter(r => r.status === 'completed' || r.status === 'approved').length; - const reviewCount = allResults.filter(r => r.status === 'pending_review').length; - const failedCount = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification').length; - - // Update stats - const statsEl = document.getElementById('auto-import-stats'); - if (statsEl) { - statsEl.style.display = ''; - document.getElementById('auto-import-stat-imported').textContent = `${importedCount} imported`; - document.getElementById('auto-import-stat-review').textContent = `${reviewCount} review`; - document.getElementById('auto-import-stat-failed').textContent = `${failedCount} failed`; - } - - // Show filters - const filtersEl = document.getElementById('auto-import-filters'); - if (filtersEl) { - filtersEl.style.display = ''; - // Show batch action buttons when applicable - const approveAllBtn = document.getElementById('auto-import-approve-all'); - const clearBtn = document.getElementById('auto-import-clear-completed'); - if (approveAllBtn) approveAllBtn.style.display = reviewCount > 0 ? '' : 'none'; - if (clearBtn) clearBtn.style.display = (importedCount + failedCount) > 0 ? '' : 'none'; - } - - // Apply filter - let filtered = allResults; - if (_autoImportFilter === 'pending') filtered = allResults.filter(r => r.status === 'pending_review'); - else if (_autoImportFilter === 'imported') filtered = allResults.filter(r => r.status === 'completed' || r.status === 'approved'); - else if (_autoImportFilter === 'failed') filtered = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification'); - - if (filtered.length === 0) { - const filterName = _autoImportFilter === 'pending' ? 'pending review' : _autoImportFilter; - container.innerHTML = `

No ${filterName} items.

`; - return; - } - - container.innerHTML = filtered.map((r, idx) => { - const confPct = Math.round((r.confidence || 0) * 100); - const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; - const statusLabels = { - 'completed': 'Imported', 'pending_review': 'Needs Review', - 'needs_identification': 'Unidentified', 'failed': 'Failed', - 'scanning': 'Scanning...', 'matched': 'Matched', - 'rejected': 'Dismissed', 'approved': 'Approved', - 'processing': 'Processing', - }; - const statusIcons = { - 'completed': '\u2713', 'pending_review': '\u26A0', - 'needs_identification': '\u2717', 'failed': '\u2717', - 'scanning': '\u231B', 'matched': '\u2713', - 'rejected': '\u2715', 'approved': '\u2713', - 'processing': '\u29D7', - }; - const statusLabel = statusLabels[r.status] || r.status; - const statusIcon = statusIcons[r.status] || ''; - const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' : - r.status === 'failed' || r.status === 'needs_identification' ? 'failed' : - r.status === 'processing' ? 'processing' : 'neutral'; - - // Live per-track progress for the row currently being processed. - // Match by folder_hash through the `active_imports` array - // — the worker now runs multiple imports in parallel via a - // bounded executor pool, so `current_folder` alone can't - // identify a row's live state. - const liveStatus = _autoImportLastStatus; - const liveActive = (liveStatus && Array.isArray(liveStatus.active_imports)) - ? liveStatus.active_imports.find(a => a.folder_hash === r.folder_hash) - : null; - const isLiveProcessing = r.status === 'processing' - && liveActive && liveActive.status === 'processing'; - const liveTrackIdx = isLiveProcessing ? (liveActive.track_index || 0) : 0; - const liveTrackTotal = isLiveProcessing ? (liveActive.track_total || 0) : 0; - const liveTrackName = isLiveProcessing ? (liveActive.track_name || '') : ''; - - // Parse match data for track details - let matchCount = 0, totalTracks = 0, trackDetails = []; - if (r.match_data) { - try { - const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data; - matchCount = md.matched_count || 0; - totalTracks = md.total_tracks || 0; - if (md.matches) { - trackDetails = md.matches.map(m => ({ - name: m.track_name || m.track?.name || 'Unknown', - file: m.file ? m.file.split(/[/\\]/).pop() : '?', - confidence: Math.round((m.confidence || 0) * 100), - })); - } - } catch (e) {} - } - - let matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`; - if (isLiveProcessing && liveTrackTotal > 0) { - matchSummary = `track ${liveTrackIdx}/${liveTrackTotal}: ${liveTrackName}`; - } - const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' }; - const methodLabel = methodLabels[r.identification_method] || r.identification_method || ''; - - // Time ago - let timeAgo = ''; - if (r.created_at) { - try { - const d = new Date(r.created_at); - const diffM = Math.floor((Date.now() - d) / 60000); - if (diffM < 1) timeAgo = 'just now'; - else if (diffM < 60) timeAgo = `${diffM}m ago`; - else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`; - else timeAgo = `${Math.floor(diffM / 1440)}d ago`; - } catch (e) {} - } - - let actions = ''; - if (r.status === 'pending_review') { - actions = `
- - -
`; - } - - // Expanded track list (hidden by default) - let trackListHtml = ''; - if (trackDetails.length > 0) { - trackListHtml = `
-
- TrackMatched FileConf -
- ${trackDetails.map((t, tIdx) => { - const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low'; - // 1-based liveTrackIdx — current row glows, prior rows dim as "done". - let rowState = ''; - if (isLiveProcessing && liveTrackIdx > 0) { - if (tIdx + 1 === liveTrackIdx) rowState = ' auto-import-track-row-active'; - else if (tIdx + 1 < liveTrackIdx) rowState = ' auto-import-track-row-done'; - } - return `
- ${escapeHtml(t.name)} - ${escapeHtml(t.file)} - ${t.confidence}% -
`; - }).join('')} -
`; - } - - return `
-
-
- ${r.image_url ? `` : `
\uD83D\uDCBF
`} -
-
-
${escapeHtml(r.album_name || r.folder_name)}
-
${escapeHtml(r.artist_name || 'Unknown Artist')}
-
- ${matchSummary} - ${methodLabel ? `${methodLabel}` : ''} - ${timeAgo ? `${timeAgo}` : ''} -
- ${r.error_message ? `
${escapeHtml(r.error_message)}
` : ''} -
-
-
${statusIcon} ${statusLabel}
-
-
-
-
${confPct}% confidence
- ${actions} -
-
-
${escapeHtml(r.folder_name)}
- ${trackListHtml} -
`; - }).join(''); - - } catch (e) {} -} - -async function _autoImportSaveSettings() { - const confidence = (document.getElementById('auto-import-confidence')?.value || 90) / 100; - const interval = parseInt(document.getElementById('auto-import-interval')?.value || 60); - try { - await fetch('/api/auto-import/settings', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ confidence_threshold: confidence, scan_interval: interval }) - }); - showToast('Settings saved', 'success'); - } catch (e) { showToast('Error', 'error'); } -} - -function _autoImportSetFilter(filter) { - _autoImportFilter = filter; - document.querySelectorAll('#auto-import-filters .adl-pill').forEach(p => - p.classList.toggle('active', p.dataset.filter === filter)); - _autoImportLoadResults(); -} - -async function _autoImportScanNow() { - try { - const res = await fetch('/api/auto-import/scan-now', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast('Scan triggered', 'success'); - _autoImportLoadStatus(); - } else { - showToast(data.error || 'Failed to trigger scan', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportApproveAll() { - const confirmed = await showConfirmDialog({ - title: 'Approve All', - message: 'Approve and import all pending review items?', - confirmText: 'Approve All', - }); - if (!confirmed) return; - try { - const res = await fetch('/api/auto-import/approve-all', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Approved ${data.count || 0} items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportClearCompleted() { - try { - const res = await fetch('/api/auto-import/clear-completed', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Cleared ${data.count || 0} imported items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -function _autoImportToggleDetail(idx) { - const trackList = document.getElementById(`auto-import-tracks-${idx}`); - if (trackList) { - trackList.classList.toggle('expanded'); - } -} -window._autoImportToggleDetail = _autoImportToggleDetail; -window._autoImportSetFilter = _autoImportSetFilter; -window._autoImportScanNow = _autoImportScanNow; -window._autoImportApproveAll = _autoImportApproveAll; -window._autoImportClearCompleted = _autoImportClearCompleted; - -async function _autoImportApprove(id) { - try { - const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Approved', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -async function _autoImportReject(id) { - try { - const res = await fetch(`/api/auto-import/reject/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Dismissed', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -// --- Album Tab: Auto-Detected Groups (from file tags) --- - -async function importPageLoadAutoGroups() { - const grid = document.getElementById('import-page-suggestions-grid'); - if (!grid) return; - - try { - const resp = await fetch('/api/import/staging/groups'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.groups || data.groups.length === 0) return; - - // Build auto-groups section above suggestions - let groupsContainer = document.getElementById('import-page-auto-groups'); - if (!groupsContainer) { - groupsContainer = document.createElement('div'); - groupsContainer.id = 'import-page-auto-groups'; - groupsContainer.style.marginBottom = '16px'; - const suggestionsSection = document.getElementById('import-page-suggestions'); - if (suggestionsSection) { - suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection); - } else { - grid.parentNode.insertBefore(groupsContainer, grid); - } - } - - groupsContainer.innerHTML = ` -
- Auto-Detected Albums -
-
- ${data.groups.map((g, idx) => ` -
-
- ${g.file_count} -
-
-
${_esc(g.album)}
-
${_esc(g.artist)} · ${g.file_count} tracks
-
-
- `).join('')} -
- `; - - // Store groups for click handler - importPageState._autoGroups = data.groups; - } catch (err) { - console.warn('Failed to load auto-groups:', err); - } -} - -async function importPageMatchAutoGroup(groupIdx) { - const group = importPageState._autoGroups?.[groupIdx]; - if (!group) return; - - // Search for the album by name + artist - const query = `${group.artist} ${group.album}`; - const searchInput = document.getElementById('import-page-album-search-input'); - if (searchInput) searchInput.value = query; - - // Hide suggestions/groups, show search results - const suggestionsEl = document.getElementById('import-page-suggestions'); - const groupsEl = document.getElementById('import-page-auto-groups'); - if (suggestionsEl) suggestionsEl.style.display = 'none'; - if (groupsEl) groupsEl.style.display = 'none'; - - const grid = document.getElementById('import-page-album-results'); - if (grid) grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - - if (data.success && data.albums && data.albums.length > 0) { - // Store file_paths filter so match only includes this group's files - importPageState._autoGroupFilePaths = group.file_paths; - - // Render results — user picks the right album - const banner = _renderImportFallbackBanner(data.albums, data.primary_source); - grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - } else { - grid.innerHTML = '
No albums found — try searching manually
'; - } - } catch (err) { - console.error('Auto-group search failed:', err); - if (grid) grid.innerHTML = '
Search failed
'; - } -} - -// --- Album Tab: Suggestions (server-side cache, just fetch and render) --- - -async function importPageLoadSuggestions() { - const section = document.getElementById('import-page-suggestions'); - const grid = document.getElementById('import-page-suggestions-grid'); - if (!section || !grid) return; - - try { - const resp = await fetch('/api/import/staging/suggestions'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.suggestions || data.suggestions.length === 0) { - if (!data.ready) { - // Server is still building cache — show placeholder, retry shortly - section.style.display = ''; - grid.innerHTML = '
Loading suggestions...
'; - setTimeout(() => importPageLoadSuggestions(), 3000); - } else { - section.style.display = 'none'; - grid.innerHTML = ''; - } - return; - } - - section.style.display = ''; - const banner = _renderImportFallbackBanner(data.suggestions, data.primary_source); - grid.innerHTML = banner + data.suggestions.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - } catch (err) { - // Network error or server not ready — fail silently - console.warn('Failed to load import suggestions:', err); - } -} - -function _renderSuggestionCard(a, primarySource) { - // Cache the album lookup so importPageSelectAlbum can pull source + - // name + artist on click (the onclick can only carry the ID string - // — see github issue #524 root cause). - importPageState._albumLookup[a.id] = { - id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '', - }; - // Surface the served source when it differs from the user's configured - // primary — the search route silently falls through to the next source - // in METADATA_SOURCE_PRIORITY when the primary returns nothing - // (intentional design, see core/auto_import_worker.py:1316). Without - // this badge the user has no idea their MusicBrainz / Discogs choice - // got bypassed (github issue #681). - const sourceBadge = (a.source && primarySource && a.source !== primarySource) - ? `
via ${_esc((SOURCE_LABELS[a.source] || {}).text || a.source)}
` - : ''; - const metaParts = [ - `${a.total_tracks || 0} tracks`, - a.release_date ? a.release_date.substring(0, 4) : '', - a.format || '', - a.country || '', - a.disambiguation || '', - ].filter(Boolean); - const details = [a.status || '', a.label || ''].filter(Boolean); - const detailsLine = details.length - ? `
${_esc(details.join(' · '))}
` - : ''; - return `
- ${_escAttr(a.name)} -
${_esc(a.name)}
-
${_esc(a.artist)}
-
${_esc(metaParts.join(' · '))}
- ${detailsLine} - ${sourceBadge} -
`; -} - -function _renderImportFallbackBanner(albums, primarySource) { - if (!primarySource || !albums || !albums.length) return ''; - const allFallback = albums.every(a => a.source && a.source !== primarySource); - if (!allFallback) return ''; - const servedSource = albums[0].source; - const primaryLabel = (SOURCE_LABELS[primarySource] || {}).text || primarySource; - const servedLabel = (SOURCE_LABELS[servedSource] || {}).text || servedSource; - // Neutral wording — covers both live-search fallback (primary returned 0) - // and cache-stale suggestions (primary changed since cache was built). - return `
Showing ${_esc(servedLabel)} results — not from your primary source (${_esc(primaryLabel)}).
`; -} - -// --- Album Tab: Search --- - -async function importPageSearchAlbum() { - const query = document.getElementById('import-page-album-search-input').value.trim(); - if (!query) return; - - document.getElementById('import-page-suggestions').style.display = 'none'; - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = 'none'; - const grid = document.getElementById('import-page-album-results'); - grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - if (!data.success || !data.albums.length) { - grid.innerHTML = '
No albums found
'; - return; - } - const banner = _renderImportFallbackBanner(data.albums, data.primary_source); - grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - document.getElementById('import-page-album-clear-btn').classList.remove('hidden'); - } catch (err) { - grid.innerHTML = `
Error: ${err.message}
`; - } -} - -// --- Album Tab: Select Album & Match --- - -async function importPageSelectAlbum(albumId) { - document.getElementById('import-page-album-search-section').classList.add('hidden'); - document.getElementById('import-page-album-match-section').classList.remove('hidden'); - - const matchList = document.getElementById('import-page-match-list'); - matchList.innerHTML = '
Matching files to tracklist...
'; - - try { - // Include file_paths filter if matching from an auto-group. - // CRITICAL: include source + album_name + album_artist from the - // search/suggestion result. Without `source`, the backend can't - // route the lookup to the metadata source the album_id came - // from — for example a Deezer album_id needs Deezer's get_album - // call. Cross-source lookup fails silently, returns the - // failure-fallback dict with album_id-as-name + Unknown Artist - // + 0 tracks, then the import flow writes that broken metadata - // to the library DB (github issue #524). - const cached = importPageState._albumLookup[albumId] || {}; - const matchBody = { - album_id: albumId, - source: cached.source || '', - album_name: cached.name || '', - album_artist: cached.artist || '', - }; - if (importPageState._autoGroupFilePaths) { - matchBody.file_paths = importPageState._autoGroupFilePaths; - importPageState._autoGroupFilePaths = null; // clear after use - } - const resp = await fetch('/api/import/album/match', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(matchBody) - }); - const data = await resp.json(); - if (!data.success) { - matchList.innerHTML = `
Error: ${data.error}
`; - return; - } - - importPageState.albumData = data; - importPageState.matchOverrides = {}; - - // Render hero - const album = data.album; - const heroMetaParts = [ - `${album.total_tracks || 0} tracks`, - album.release_date ? album.release_date.substring(0, 4) : '', - album.format || '', - album.country || '', - album.disambiguation || '', - ].filter(Boolean); - document.getElementById('import-page-album-hero').innerHTML = ` - ${_escAttr(album.name)} -
-
${_esc(album.name)}
-
${_esc(album.artist)}
-
${_esc(heroMetaParts.join(' · '))}
-
- `; - - importPageRenderMatchList(); - } catch (err) { - matchList.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageRenderMatchList() { - const data = importPageState.albumData; - if (!data) return; - - const matchList = document.getElementById('import-page-match-list'); - const overrides = importPageState.matchOverrides; - - // Build effective matches: auto-match overridden by manual overrides - // Also track which staging files are used (auto or override) - const usedStagingFiles = new Set(); - - // First pass: collect overridden indices - Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx)); - - // Build rows - let matchedCount = 0; - const rows = data.matches.map((m, idx) => { - const trackInfo = _importPageGetTrackDisplayInfo(m, idx); - let file = null; - let confidence = m.confidence; - let isOverride = false; - - if (overrides.hasOwnProperty(idx)) { - const sfIdx = overrides[idx]; - if (sfIdx === -1) { - // Forcibly unmatched — no file - file = null; - } else { - // Manual override - file = importPageState.stagingFiles[sfIdx] || null; - confidence = 1.0; - isOverride = true; - usedStagingFiles.add(sfIdx); - } - } else if (m.staging_file) { - file = m.staging_file; - // Check if this file was reassigned to another track via override - const autoFileName = m.staging_file.filename; - const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => { - const sf = importPageState.stagingFiles[sfIdx]; - return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx; - }); - if (!reassigned) { - usedStagingFiles.add(-1); // placeholder — auto-matched file - } else { - file = null; // file was reassigned elsewhere - } - } - - if (file) matchedCount++; - const confPercent = Math.round(confidence * 100); - const confClass = confidence >= 0.7 ? '' : 'low'; - - return ` -
- ${trackInfo.displayTrackNumber} - ${_esc(trackInfo.name)} - - ${file - ? `${_esc(file.filename)} - ${confPercent}%` - : `Drop a file here`} - - ${file ? `` : ''} -
- `; - }); - - matchList.innerHTML = rows.join(''); - - // Unmatched file pool - const unmatchedFiles = []; - importPageState.stagingFiles.forEach((f, i) => { - // Check if used by override - if (Object.values(overrides).includes(i)) return; - // Check if used by auto-match (not overridden away) - const autoUsed = data.matches.some((m, mIdx) => { - if (overrides.hasOwnProperty(mIdx)) return false; - return m.staging_file && m.staging_file.filename === f.filename; - }); - if (autoUsed) return; - unmatchedFiles.push({ file: f, index: i }); - }); - - const poolChips = document.getElementById('import-page-pool-chips'); - document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length; - - if (unmatchedFiles.length === 0) { - poolChips.innerHTML = 'All files matched'; - } else { - poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => ` - - ${_esc(file.filename)} - - `).join(''); - } - - // Stats & button - document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`; - const processBtn = document.getElementById('import-page-album-process-btn'); - processBtn.disabled = matchedCount === 0; - processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`; -} - -function _importPageGetTrackDisplayInfo(item, index) { - const track = item?.track || item?.spotify_track || {}; - const rawTrackNumber = track.track_number ?? track.trackNumber ?? null; - const trackNumber = rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === '' - ? null - : String(rawTrackNumber).split('/')[0].trim(); - - return { - track, - name: track.name || track.title || `Track ${index + 1}`, - trackNumber, - displayTrackNumber: trackNumber || String(index + 1), - }; -} - -// --- Album Tab: Drag and Drop --- - -function importPageStartDrag(event, stagingFileIndex) { - event.dataTransfer.setData('text/plain', stagingFileIndex.toString()); - event.dataTransfer.effectAllowed = 'move'; -} - -function importPageHandleDragOver(event) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - event.currentTarget.classList.add('drag-over'); - // Remove drag-over from others - document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => { - if (el !== event.currentTarget) el.classList.remove('drag-over'); - }); -} - -function importPageHandleDrop(event, trackIndex) { - event.preventDefault(); - event.currentTarget.classList.remove('drag-over'); - const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain')); - if (isNaN(stagingFileIndex)) return; - - // Remove this staging file from any other track it was assigned to - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// Mobile tap-to-assign fallback -function importPageTapSelectChip(stagingFileIndex) { - if (importPageState.tapSelectedChip === stagingFileIndex) { - importPageState.tapSelectedChip = null; - } else { - importPageState.tapSelectedChip = stagingFileIndex; - } - importPageRenderMatchList(); -} - -function importPageTapAssign(trackIndex) { - if (importPageState.tapSelectedChip === null) return; - const stagingFileIndex = importPageState.tapSelectedChip; - - // Remove from any other track - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -function importPageUnmatchTrack(trackIndex) { - delete importPageState.matchOverrides[trackIndex]; - // Also remove auto-match by setting override to -1 special value? No — just delete override and let auto-match stay. - // Actually, to truly unmatch: we need to suppress the auto-match too. - // We'll use a sentinel: override = -1 means "forcibly unmatched" - const m = importPageState.albumData?.matches[trackIndex]; - if (m && m.staging_file) { - importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match - } - importPageRenderMatchList(); -} - -function importPageAutoRematch() { - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// --- Album Tab: Process --- - -function importPageProcessAlbum() { - const data = importPageState.albumData; - if (!data) return; - - // Build effective matches with overrides applied - const overrides = importPageState.matchOverrides; - const effectiveMatches = []; - data.matches.forEach((m, idx) => { - if (overrides.hasOwnProperty(idx)) { - if (overrides[idx] === -1) return; // forcibly unmatched — skip - const sf = importPageState.stagingFiles[overrides[idx]]; - effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 }); - } else if (m.staging_file !== null) { - effectiveMatches.push(m); - } - }); - - if (effectiveMatches.length === 0) return; - - // Add to queue and reset search immediately so user can queue more - const album = data.album; - _importQueueAdd({ - type: 'album', - label: album.name, - sublabel: `${album.artist} · ${effectiveMatches.length} tracks`, - imageUrl: album.image_url, - items: effectiveMatches, - albumData: album, - }); - - importPageResetAlbumSearch(); -} - -function importPageResetAlbumSearch() { - importPageState.albumData = null; - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageState._autoGroupFilePaths = null; - - document.getElementById('import-page-album-search-section').classList.remove('hidden'); - document.getElementById('import-page-album-match-section').classList.add('hidden'); - - // Clear search - document.getElementById('import-page-album-results').innerHTML = ''; - document.getElementById('import-page-album-search-input').value = ''; - document.getElementById('import-page-album-clear-btn').classList.add('hidden'); - - // Re-show auto-groups - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = ''; - - // Refresh suggestions & staging - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - importPageRefreshStaging(); -} - -// --- Singles Tab --- - -function importPageRenderSinglesList() { - const list = document.getElementById('import-page-singles-list'); - const files = importPageState.stagingFiles; - - if (files.length === 0) { - list.innerHTML = '
No audio files found in import folder
'; - return; - } - - list.innerHTML = files.map((f, i) => { - const isSelected = importPageState.selectedSingles.has(i); - const manualMatch = importPageState.singlesManualMatches[i]; - const searchOpen = document.querySelector(`[data-singles-search="${i}"]`); - - let html = ` -
-
-
-
${_esc(f.filename)}
-
- ${f.title ? `${_esc(f.title)}` : ''} - ${f.artist ? `${_esc(f.artist)}` : ''} - ${f.extension ? `${f.extension}` : ''} -
- ${manualMatch ? ` -
- ✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)} - change -
- ` : ''} -
-
- -
-
- `; - return html; - }).join(''); - - importPageUpdateSinglesProcessButton(); -} - -function importPageToggleSingle(idx) { - if (importPageState.selectedSingles.has(idx)) { - importPageState.selectedSingles.delete(idx); - } else { - importPageState.selectedSingles.add(idx); - } - // Update checkbox UI without full re-render - const item = document.querySelector(`[data-single-idx="${idx}"]`); - if (item) { - const cb = item.querySelector('.import-page-single-checkbox'); - if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx)); - } - importPageUpdateSinglesProcessButton(); -} - -function importPageSelectAllSingles() { - const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length; - if (allSelected) { - importPageState.selectedSingles.clear(); - } else { - importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i)); - } - document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All'; - // Update all checkboxes - document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => { - cb.classList.toggle('checked', importPageState.selectedSingles.has(i)); - }); - importPageUpdateSinglesProcessButton(); -} - -function importPageUpdateSinglesProcessButton() { - const btn = document.getElementById('import-page-singles-process-btn'); - const count = importPageState.selectedSingles.size; - btn.textContent = `Process Selected (${count})`; - btn.disabled = count === 0; -} - -function importPageOpenSingleSearch(fileIdx) { - const item = document.querySelector(`[data-single-idx="${fileIdx}"]`); - if (!item) return; - - // Remove any existing search panel - const existing = item.querySelector('.import-page-single-search-panel'); - if (existing) { - existing.remove(); - return; - } - - // Close other open panels - document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove()); - - const f = importPageState.stagingFiles[fileIdx]; - const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, ''); - - const panel = document.createElement('div'); - panel.className = 'import-page-single-search-panel'; - panel.innerHTML = ` - -
- `; - item.appendChild(panel); - - // Auto-search - const input = panel.querySelector('input'); - input.focus(); - if (defaultQuery) { - importPageSearchSingleTrack(fileIdx, defaultQuery); - } -} - -async function importPageSearchSingleTrack(fileIdx, query) { - if (!query || !query.trim()) return; - - const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`); - if (!resultsDiv) return; - resultsDiv.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`); - const data = await resp.json(); - if (!data.success || !data.tracks.length) { - resultsDiv.innerHTML = '
No results found
'; - return; - } - // Store results in a temp cache so we can reference by index - window._importSingleSearchResults = window._importSingleSearchResults || {}; - window._importSingleSearchResults[fileIdx] = data.tracks; - - resultsDiv.innerHTML = data.tracks.map((t, tIdx) => { - const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - return ` -
- ${t.image_url ? `` : ''} -
-
${_esc(t.name)} - ${_esc(t.artist)}
-
${_esc(t.album)}${dur ? ' · ' + dur : ''}
-
- -
- `; - }).join(''); - } catch (err) { - resultsDiv.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageSelectSingleMatch(fileIdx, trackIdx) { - const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx]; - if (!trackData) return; - importPageState.singlesManualMatches[fileIdx] = trackData; - - // Auto-select this file - importPageState.selectedSingles.add(fileIdx); - - // Close search panel and re-render this item - importPageRenderSinglesList(); -} - -// --- Singles Tab: Process --- - -function importPageProcessSingles() { - if (importPageState.selectedSingles.size === 0) return; - - const filesToProcess = Array.from(importPageState.selectedSingles).map(i => { - const f = importPageState.stagingFiles[i]; - const manualMatch = importPageState.singlesManualMatches[i]; - if (manualMatch) { - return { ...f, manual_match: manualMatch }; - } - return f; - }); - - // Add to queue and reset immediately - _importQueueAdd({ - type: 'singles', - label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`, - sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''), - imageUrl: null, - items: filesToProcess, - }); - - importPageState.selectedSingles.clear(); - importPageState.singlesManualMatches = {}; - importPageUpdateSinglesProcessButton(); - importPageRefreshStaging(); -} - -// --- Processing Queue --- - -const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors } - -function _importQueueAdd(job) { - const id = ++importJobIdCounter; - const entry = { - id, - type: job.type, - label: job.label, - sublabel: job.sublabel, - imageUrl: job.imageUrl, - status: 'running', // running | done | error - processed: 0, - total: job.items.length, - errors: [], - }; - _importQueue.push(entry); - _importQueueRender(); - - // Fire and forget — runs in background - _importQueueRunJob(entry, job); -} - -async function _importQueueRunJob(entry, job) { - for (let i = 0; i < job.items.length; i++) { - const itemName = job.type === 'album' - ? _importPageGetTrackDisplayInfo(job.items[i], i).name - : (job.items[i].title || job.items[i].filename || `File ${i + 1}`); - - // Update status with current track info - entry.sublabel = `Processing ${i + 1}/${job.items.length}: ${itemName}`; - _importQueueRender(); - - try { - let resp; - if (job.type === 'album') { - resp = await fetch('/api/import/album/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - album: job.albumData, - matches: [job.items[i]] - }) - }); - } else { - resp = await fetch('/api/import/singles/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files: [job.items[i]] }) - }); - } - - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - if (data.success) entry.processed += (data.processed || 0); - if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors); - } catch (err) { - entry.errors.push(`${itemName}: ${err.message}`); - } - - _importQueueRender(); - } - - entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done'; - _importQueueRender(); - - // Refresh staging and suggestions since files moved - importPageRefreshStaging(); - importPageLoadSuggestions(); -} - -function _importQueueRender() { - const container = document.getElementById('import-page-queue'); - const list = document.getElementById('import-page-queue-list'); - const clearBtn = document.getElementById('import-page-queue-clear'); - if (!container || !list) return; - - if (_importQueue.length === 0) { - container.classList.add('hidden'); - return; - } - - container.classList.remove('hidden'); - - // Show clear button only if there are finished jobs - const hasFinished = _importQueue.some(j => j.status !== 'running'); - clearBtn.style.display = hasFinished ? '' : 'none'; - - list.innerHTML = _importQueue.map(j => { - const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0; - const fillClass = j.status === 'error' ? 'error' : ''; - let statusText, statusClass; - if (j.status === 'running') { - statusText = `${j.processed}/${j.total}`; - statusClass = ''; - } else if (j.status === 'done') { - statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done'; - statusClass = j.errors.length > 0 ? 'error' : 'done'; - } else { - statusText = 'Failed'; - statusClass = 'error'; - } - - return ` -
- ${j.imageUrl - ? `` - : `
`} -
-
${_esc(j.label)}
-
${_esc(j.sublabel)}
-
-
-
-
-
-
${statusText}
-
-
- `; - }).join(''); -} - -function importPageClearFinishedJobs() { - for (let i = _importQueue.length - 1; i >= 0; i--) { - if (_importQueue[i].status !== 'running') { - _importQueue.splice(i, 1); - } - } - _importQueueRender(); -} - // ── Import File Tab ────────────────────────────────────────────────── let _importFileState = { @@ -6056,14 +4682,15 @@ async function playArtistRadio() { const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; // Clear existing queue and disable radio before starting fresh - npSetRadioMode(false, { toast: false }); + npRadioMode = false; clearQueue(); if (audioPlayer && !audioPlayer.paused) { audioPlayer.pause(); } - // Play the track first so currentTrack is populated before radio seeds the queue. - await playLibraryTrack({ + // Play the track first, then enable radio mode after a short delay + // so currentTrack is set and the radio queue fill triggers + playLibraryTrack({ id: random.track.id, title: random.track.title, file_path: random.track.file_path, @@ -6072,9 +4699,14 @@ async function playArtistRadio() { album_id: random.album.id, }, random.album.title || '', artistName); - npSetRadioMode(true, { toast: false, fetchIfNeeded: true }); + // Enable radio mode after track starts loading + setTimeout(() => { + npRadioMode = true; + const radioBtn = document.querySelector('.np-radio-btn'); + if (radioBtn) radioBtn.classList.add('active'); + }, 1000); - showToast(`Playing ${artistName} radio - similar tracks will auto-queue`, 'success'); + showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); } catch (e) { showToast(`Failed to start artist radio: ${e.message}`, 'error'); } diff --git a/webui/static/style.css b/webui/static/style.css index ea65ca7f..7706efc0 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -10376,8 +10376,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(167, 139, 250, 0.25); } -.library-history-badge.download.source-staging, -.library-history-badge.download.source-auto-import { +.library-history-badge.download.source-staging { color: rgba(255, 255, 255, 0.55); background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.12); @@ -40072,956 +40071,6 @@ div.artist-hero-badge { opacity: 1; } -/* ======================================== - IMPORT PAGE (full page, replaces modal) - ======================================== */ - -/* ============================================================================ - IMPORT PAGE - ============================================================================ */ - -.import-page-container { - max-width: 1200px; - margin: 0 auto; - padding: 24px 32px; -} - -.import-page-header { - margin-bottom: 20px; -} - -.import-page-title-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.import-page-title { - font-size: 28px; - font-weight: 700; - margin: 0; - letter-spacing: -0.3px; - font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; - display: flex; - align-items: center; - gap: 14px; -} - -.import-page-title > span { - background: linear-gradient(90deg, #ffffff 0%, rgb(var(--accent-light-rgb)) 50%, rgb(var(--accent-rgb)) 100%); - background-size: 200% 100%; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - animation: page-title-shimmer 6s ease-in-out infinite; -} - -.import-page-refresh-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #ccc; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-refresh-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -.import-page-staging-bar { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - font-size: 13px; - color: rgba(255, 255, 255, 0.5); -} - -.import-staging-path { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.import-staging-stats { - color: rgb(var(--accent-light-rgb)); - font-weight: 500; - white-space: nowrap; -} - -/* Tab Bar */ -.import-page-tab-bar { - display: flex; - gap: 4px; - margin-bottom: 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - padding-bottom: 0; -} - -.import-page-tab { - padding: 10px 24px; - background: none; - border: none; - border-bottom: 2px solid transparent; - color: rgba(255, 255, 255, 0.5); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - margin-bottom: -1px; -} - -.import-page-tab:hover { - color: rgba(255, 255, 255, 0.8); -} - -.import-page-tab.active { - color: rgb(var(--accent-light-rgb)); - border-bottom-color: rgb(var(--accent-light-rgb)); -} - -.import-page-tab-content { - display: none; -} - -.import-page-tab-content.active { - display: block; -} - -/* Section labels */ -.import-page-section-label { - font-size: 13px; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 500; -} - -/* Search bar */ -.import-page-search-bar { - display: flex; - gap: 8px; - margin-bottom: 20px; - position: relative; -} - -.import-page-search-input { - flex: 1; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 10px; - color: #fff; - font-size: 14px; - outline: none; - transition: border-color 0.2s; -} - -.import-page-search-input:focus { - border-color: rgba(var(--accent-light-rgb), 0.5); -} - -.import-page-search-btn { - padding: 10px 20px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 10px; - color: #000; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: background 0.2s; - white-space: nowrap; -} - -.import-page-search-btn:hover { - background: #1fdf64; -} - -.import-page-clear-btn { - position: absolute; - right: 100px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: rgba(255, 255, 255, 0.4); - font-size: 16px; - cursor: pointer; - padding: 4px 8px; -} - -.import-page-clear-btn:hover { - color: #fff; -} - -/* Album grid */ -.import-page-album-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 16px; - margin-bottom: 24px; -} - -.import-page-album-card { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 12px; - cursor: pointer; - transition: all 0.2s; - text-align: center; -} - -.import-page-album-card:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(var(--accent-light-rgb), 0.3); - transform: translateY(-2px); -} - -.import-page-album-card img { - width: 100%; - aspect-ratio: 1; - object-fit: cover; - border-radius: 8px; - margin-bottom: 8px; -} - -.import-page-album-card-title { - font-size: 13px; - font-weight: 600; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 2px; -} - -.import-page-album-card-artist { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-meta { - font-size: 10px; - color: rgba(255, 255, 255, 0.3); - margin-top: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-detail { - font-size: 10px; - color: rgba(255, 255, 255, 0.36); - margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-source { - font-size: 10px; - color: rgba(255, 200, 100, 0.75); - margin-top: 2px; - font-style: italic; -} - -.import-page-fallback-banner { - grid-column: 1 / -1; - padding: 10px 14px; - margin-bottom: 12px; - background: rgba(255, 200, 100, 0.08); - border: 1px solid rgba(255, 200, 100, 0.25); - border-radius: 8px; - color: rgba(255, 220, 170, 0.9); - font-size: 12px; - line-height: 1.4; -} - -/* Album hero (selected album) */ -.import-page-album-hero { - display: flex; - gap: 20px; - padding: 20px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - margin-bottom: 20px; - align-items: center; -} - -.import-page-album-hero img { - width: 120px; - height: 120px; - border-radius: 10px; - object-fit: cover; - flex-shrink: 0; -} - -.import-page-album-hero-info { - flex: 1; - min-width: 0; -} - -.import-page-album-hero-title { - font-size: 22px; - font-weight: 700; - color: #fff; - margin-bottom: 4px; -} - -.import-page-album-hero-artist { - font-size: 15px; - color: rgba(255, 255, 255, 0.6); - margin-bottom: 4px; -} - -.import-page-album-hero-meta { - font-size: 12px; - color: rgba(255, 255, 255, 0.35); -} - -/* Match header */ -.import-page-match-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.import-page-match-header h3 { - font-size: 16px; - font-weight: 600; - color: #fff; - margin: 0; -} - -.import-page-match-actions { - display: flex; - gap: 8px; -} - -/* Match list */ -.import-page-match-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 16px; -} - -.import-page-match-row { - display: grid; - grid-template-columns: 36px 1fr 1fr 36px; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - min-height: 44px; - transition: all 0.2s; -} - -.import-page-match-row.drag-over { - background: rgba(var(--accent-light-rgb), 0.08); - border-color: rgba(var(--accent-light-rgb), 0.4); - box-shadow: 0 0 12px rgba(var(--accent-light-rgb), 0.15); -} - -.import-page-match-row.matched { - border-color: rgba(var(--accent-light-rgb), 0.2); -} - -.import-page-match-num { - font-size: 13px; - color: rgba(255, 255, 255, 0.3); - text-align: center; - font-weight: 500; -} - -.import-page-match-track { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-match-file { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - display: flex; - align-items: center; - gap: 6px; - overflow: hidden; -} - -.import-page-match-file.has-file { - color: rgb(var(--accent-light-rgb)); -} - -.import-page-match-file-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.import-page-match-confidence { - font-size: 10px; - padding: 2px 6px; - border-radius: 4px; - background: rgba(var(--accent-light-rgb), 0.15); - color: rgb(var(--accent-light-rgb)); - font-weight: 500; - white-space: nowrap; -} - -.import-page-match-confidence.low { - background: rgba(255, 165, 0, 0.15); - color: #ffa500; -} - -.import-page-match-drop-zone { - font-size: 11px; - color: rgba(255, 255, 255, 0.25); - font-style: italic; -} - -.import-page-match-unmatch { - background: none; - border: none; - color: rgba(255, 255, 255, 0.3); - font-size: 14px; - cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.import-page-match-unmatch:hover { - color: #ff4444; - background: rgba(255, 68, 68, 0.1); -} - -/* Unmatched file pool */ -.import-page-unmatched-pool { - padding: 16px; - background: rgba(255, 255, 255, 0.02); - border: 1px dashed rgba(255, 255, 255, 0.1); - border-radius: 12px; - margin-bottom: 16px; -} - -.import-page-pool-label { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 10px; - font-weight: 500; -} - -.import-page-pool-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.import-page-file-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 20px; - font-size: 12px; - color: #ccc; - cursor: grab; - transition: all 0.2s; - user-select: none; -} - -.import-page-file-chip:active { - cursor: grabbing; -} - -.import-page-file-chip:hover { - background: rgba(255, 255, 255, 0.14); - border-color: rgba(var(--accent-light-rgb), 0.3); - color: #fff; -} - -.import-page-file-chip.selected { - background: rgba(var(--accent-light-rgb), 0.15); - border-color: rgba(var(--accent-light-rgb), 0.4); - color: rgb(var(--accent-light-rgb)); -} - -.import-page-pool-empty { - font-size: 12px; - color: rgba(255, 255, 255, 0.2); - font-style: italic; -} - -/* Match footer */ -.import-page-match-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 16px; - border-top: 1px solid rgba(255, 255, 255, 0.06); -} - -.import-page-match-stats { - font-size: 13px; - color: rgba(255, 255, 255, 0.5); -} - -/* Buttons */ -.import-page-process-btn { - padding: 10px 24px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 10px; - color: #000; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-process-btn:hover { - background: #1fdf64; -} - -.import-page-process-btn:disabled { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.3); - cursor: not-allowed; -} - -.import-page-secondary-btn { - padding: 8px 16px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #ccc; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-secondary-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -.import-page-back-btn { - padding: 8px 16px; - background: none; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: rgba(255, 255, 255, 0.5); - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-back-btn:hover { - color: #fff; - border-color: rgba(255, 255, 255, 0.2); -} - -/* Singles */ -.import-page-singles-header { - display: flex; - align-items: center; - justify-content: flex-end; - margin-bottom: 16px; -} - -.import-page-singles-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.import-page-singles-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.import-page-single-item { - display: grid; - grid-template-columns: 32px 1fr auto; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - transition: all 0.2s; -} - -.import-page-single-item.matched { - border-color: rgba(var(--accent-light-rgb), 0.2); -} - -.import-page-single-checkbox { - width: 18px; - height: 18px; - border: 2px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - flex-shrink: 0; -} - -.import-page-single-checkbox.checked { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.import-page-single-checkbox.checked::after { - content: '✓'; - color: #000; - font-size: 12px; - font-weight: 700; -} - -.import-page-single-info { - min-width: 0; -} - -.import-page-single-filename { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-single-meta { - font-size: 11px; - color: rgba(255, 255, 255, 0.35); - margin-top: 2px; - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.import-page-single-matched-info { - font-size: 12px; - color: rgb(var(--accent-light-rgb)); - margin-top: 4px; - display: flex; - align-items: center; - gap: 6px; -} - -.import-page-single-matched-change { - color: rgba(255, 255, 255, 0.4); - cursor: pointer; - font-size: 11px; - text-decoration: underline; -} - -.import-page-single-matched-change:hover { - color: #fff; -} - -.import-page-single-actions { - display: flex; - gap: 6px; - align-items: center; - flex-shrink: 0; -} - -.import-page-identify-btn { - padding: 6px 12px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - color: #ccc; - font-size: 12px; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 4px; -} - -.import-page-identify-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -/* Inline search panel for singles */ -.import-page-single-search-panel { - grid-column: 1 / -1; - padding: 12px 0 4px 44px; -} - -.import-page-single-search-bar { - display: flex; - gap: 8px; - margin-bottom: 10px; -} - -.import-page-single-search-input { - flex: 1; - padding: 8px 12px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #fff; - font-size: 13px; - outline: none; -} - -.import-page-single-search-input:focus { - border-color: rgba(var(--accent-light-rgb), 0.4); -} - -.import-page-single-search-go { - padding: 8px 14px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 8px; - color: #000; - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -.import-page-single-search-results { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 200px; - overflow-y: auto; -} - -.import-page-single-result-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - cursor: pointer; - transition: all 0.15s; -} - -.import-page-single-result-item:hover { - background: rgba(var(--accent-light-rgb), 0.08); - border-color: rgba(var(--accent-light-rgb), 0.25); -} - -.import-page-single-result-img { - width: 36px; - height: 36px; - border-radius: 4px; - object-fit: cover; - flex-shrink: 0; -} - -.import-page-single-result-info { - flex: 1; - min-width: 0; -} - -.import-page-single-result-name { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-single-result-detail { - font-size: 11px; - color: rgba(255, 255, 255, 0.4); -} - -.import-page-single-result-select { - padding: 4px 12px; - background: rgba(var(--accent-light-rgb), 0.15); - border: 1px solid rgba(var(--accent-light-rgb), 0.3); - border-radius: 6px; - color: rgb(var(--accent-light-rgb)); - font-size: 11px; - font-weight: 600; - cursor: pointer; - flex-shrink: 0; -} - -.import-page-single-result-select:hover { - background: rgba(var(--accent-light-rgb), 0.25); -} - -/* Empty state */ -.import-page-empty-state { - text-align: center; - padding: 60px 20px; - color: rgba(255, 255, 255, 0.3); - font-size: 14px; -} - -/* Suggestions */ -.import-page-suggestions { - margin-bottom: 24px; -} - -/* Processing Queue */ -.import-page-queue { - margin-bottom: 20px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - background: rgba(255, 255, 255, 0.02); - overflow: hidden; -} - -.import-page-queue-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); -} - -.import-page-queue-title { - font-size: 13px; - font-weight: 600; - color: rgba(255, 255, 255, 0.6); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.import-page-queue-clear { - background: none; - border: none; - color: rgba(255, 255, 255, 0.3); - font-size: 12px; - cursor: pointer; - padding: 2px 8px; -} - -.import-page-queue-clear:hover { - color: #fff; -} - -.import-page-queue-list { - display: flex; - flex-direction: column; -} - -.import-page-queue-item { - display: grid; - grid-template-columns: 44px 1fr auto; - gap: 12px; - align-items: center; - padding: 10px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); -} - -.import-page-queue-item:last-child { - border-bottom: none; -} - -.import-page-queue-art { - width: 44px; - height: 44px; - border-radius: 6px; - object-fit: cover; -} - -.import-page-queue-info { - min-width: 0; -} - -.import-page-queue-name { - font-size: 13px; - font-weight: 600; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-queue-detail { - font-size: 11px; - color: rgba(255, 255, 255, 0.4); - margin-top: 2px; -} - -.import-page-queue-progress { - width: 120px; - flex-shrink: 0; -} - -.import-page-queue-bar { - height: 4px; - background: rgba(255, 255, 255, 0.08); - border-radius: 2px; - overflow: hidden; - margin-bottom: 4px; -} - -.import-page-queue-fill { - height: 100%; - background: rgb(var(--accent-light-rgb)); - border-radius: 2px; - width: 0%; - transition: width 0.3s ease; -} - -.import-page-queue-fill.error { - background: #ef4444; -} - -.import-page-queue-status { - font-size: 10px; - color: rgba(255, 255, 255, 0.4); - text-align: right; -} - -.import-page-queue-status.done { - color: rgb(var(--accent-light-rgb)); -} - -.import-page-queue-status.error { - color: #ef4444; -} - -/* ======================================== - END IMPORT PAGE - ======================================== */ - /* ======================================== FAILED DOWNLOAD ERROR TOOLTIP ======================================== */ @@ -45706,10 +44755,6 @@ textarea.enhanced-meta-field-input { .discog-download-wrap { margin: 12px 0 4px; } -.artist-hero-actions .discog-download-wrap { margin: 0; } - -.artist-hero-actions .discog-btn-compact { margin-top: 0; } - .discog-download-btn { position: relative; display: flex; @@ -45757,112 +44802,6 @@ textarea.enhanced-meta-field-input { animation: discogShimmer 3s ease-in-out infinite; } -.artist-hero-actions { - gap: 10px; - align-items: center; -} - -.artist-hero-actions .library-artist-radio-btn, -.artist-hero-actions .library-artist-watchlist-btn, -.artist-hero-actions .discog-download-btn, -.artist-hero-actions .library-artist-enhance-btn { - --artist-action-rgb: var(--accent-rgb); - min-height: 38px; - width: auto; - max-width: none; - padding: 8px 14px 8px 12px; - gap: 8px; - border-radius: 999px; - color: rgba(255, 255, 255, 0.82); - background: - linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.13), rgba(var(--artist-action-rgb), 0.03)), - rgba(255, 255, 255, 0.035); - border: 1px solid rgba(var(--artist-action-rgb), 0.26); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 8px 22px rgba(0, 0, 0, 0.22); - font-size: 12px; - font-weight: 650; - letter-spacing: 0; - line-height: 1; - backdrop-filter: blur(14px) saturate(1.25); - transform: none; -} - -.artist-hero-actions .library-artist-radio-btn { --artist-action-rgb: 79, 195, 247; } -.artist-hero-actions .library-artist-watchlist-btn { --artist-action-rgb: 255, 193, 7; } -.artist-hero-actions .discog-download-btn { --artist-action-rgb: var(--accent-rgb); } -.artist-hero-actions .library-artist-enhance-btn { --artist-action-rgb: 180, 132, 255; } - -.artist-hero-actions .library-artist-radio-btn::before, -.artist-hero-actions .library-artist-watchlist-btn::before, -.artist-hero-actions .library-artist-enhance-btn::before, -.artist-hero-actions .discog-download-btn::before { - content: ''; - width: 7px; - height: 7px; - border-radius: 999px; - position: relative; - inset: auto; - flex: 0 0 auto; - opacity: 1; - background: rgb(var(--artist-action-rgb)); - box-shadow: 0 0 14px rgba(var(--artist-action-rgb), 0.85); - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.artist-hero-actions .library-artist-radio-btn:hover, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled), -.artist-hero-actions .discog-download-btn:hover, -.artist-hero-actions .library-artist-enhance-btn:hover { - color: #ffffff; - border-color: rgba(var(--artist-action-rgb), 0.5); - background: - linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.22), rgba(var(--artist-action-rgb), 0.06)), - rgba(255, 255, 255, 0.055); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.12), - 0 12px 28px rgba(0, 0, 0, 0.28), - 0 0 0 1px rgba(var(--artist-action-rgb), 0.12); - transform: translateY(-1px); -} - -.artist-hero-actions .library-artist-radio-btn:hover::before, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled)::before, -.artist-hero-actions .discog-download-btn:hover::before, -.artist-hero-actions .library-artist-enhance-btn:hover::before { - transform: scale(1.25); - box-shadow: 0 0 18px rgba(var(--artist-action-rgb), 1); -} - -.artist-hero-actions .library-artist-watchlist-btn.watching { - color: #ffd761; - border-color: rgba(255, 193, 7, 0.52); - background: - linear-gradient(135deg, rgba(255, 193, 7, 0.24), rgba(255, 193, 7, 0.06)), - rgba(255, 255, 255, 0.04); -} - -.artist-hero-actions .library-artist-radio-btn .radio-icon, -.artist-hero-actions .library-artist-watchlist-btn .watchlist-icon, -.artist-hero-actions .discog-download-btn .discog-btn-icon, -.artist-hero-actions .library-artist-enhance-btn .enhance-icon { - font-size: 13px; - position: relative; - z-index: 1; - transform: none; -} - -.artist-hero-actions .library-artist-radio-btn:hover .radio-icon, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled) .watchlist-icon, -.artist-hero-actions .library-artist-enhance-btn:hover .enhance-icon { - transform: none; -} - -.artist-hero-actions .discog-btn-shimmer { - display: none; -} - @keyframes discogShimmer { 0%, 100% { left: -100%; } 50% { left: 150%; } @@ -47281,56 +46220,26 @@ textarea.enhanced-meta-field-input { /* Radio mode button */ .np-radio-btn { - --np-radio-rgb: 79, 195, 247; - position: relative; - overflow: hidden; - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.1), rgba(var(--np-radio-rgb), 0.025)), - rgba(255, 255, 255, 0.035); - border: 1px solid rgba(var(--np-radio-rgb), 0.22); - color: rgba(255, 255, 255, 0.62); + background: none; + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.35); font-size: 11px; - font-weight: 650; - line-height: 1; - padding: 7px 10px; - border-radius: 999px; + padding: 3px 8px; + border-radius: 6px; cursor: pointer; display: flex; align-items: center; - gap: 6px; - min-height: 30px; - transition: all 0.18s ease; + gap: 4px; + transition: all 0.15s ease; } .np-radio-btn:hover { - color: rgba(255, 255, 255, 0.9); - border-color: rgba(var(--np-radio-rgb), 0.42); - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.16), rgba(var(--np-radio-rgb), 0.04)), - rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.2); } .np-radio-btn.active { - color: #ffffff; - border-color: rgba(var(--np-radio-rgb), 0.55); - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.28), rgba(var(--np-radio-rgb), 0.07)), - rgba(255, 255, 255, 0.06); - box-shadow: 0 0 0 1px rgba(var(--np-radio-rgb), 0.08), 0 0 18px rgba(var(--np-radio-rgb), 0.12); -} -.np-radio-label { - position: relative; - z-index: 1; -} -.np-radio-pulse { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.24); - box-shadow: none; - transition: all 0.18s ease; -} -.np-radio-btn.active .np-radio-pulse { - background: rgb(var(--np-radio-rgb)); - box-shadow: 0 0 12px rgba(var(--np-radio-rgb), 0.95); + color: rgb(var(--accent-rgb)); + border-color: rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.08); } .np-queue-header-actions { display: flex; @@ -50453,18 +49362,15 @@ tr.tag-diff-same { transition: transform 0.2s ease; } -.repair-master-toggle input, -.auto-import-toggle-label input { +.repair-master-toggle input { display: none; } -.repair-master-toggle input:checked + .repair-toggle-slider, -.auto-import-toggle-label input:checked + .repair-toggle-slider { +.repair-master-toggle input:checked + .repair-toggle-slider { background: var(--accent-color, #6366f1); } -.repair-master-toggle input:checked + .repair-toggle-slider::after, -.auto-import-toggle-label input:checked + .repair-toggle-slider::after { +.repair-master-toggle input:checked + .repair-toggle-slider::after { transform: translateX(20px); } @@ -59601,388 +58507,6 @@ body.reduce-effects *::after { .wl-orb-group.expanded { max-width: 100%; } } -/* ═══════════════════════════════════════════════════════════════════ - AUTO-IMPORT TAB - ═══════════════════════════════════════════════════════════════════ */ - -.auto-import-controls { - padding: 12px 0 16px; - border-bottom: 1px solid rgba(255,255,255,0.05); - margin-bottom: 16px; -} - -.auto-import-toggle-row { - display: flex; - align-items: center; - gap: 12px; -} - -.auto-import-toggle-label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 13px; - font-weight: 600; - color: rgba(255,255,255,0.7); -} - -.auto-import-toggle-label input { display: none; } - -.auto-import-status { - font-size: 12px; - font-weight: 500; - padding: 2px 10px; - border-radius: 6px; -} -.auto-import-status.active { color: #4ade80; background: rgba(74,222,128,0.1); } -.auto-import-status.scanning { color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb),0.1); } -.auto-import-status.disabled { color: rgba(255,255,255,0.3); } - -.auto-import-settings-row { - display: flex; - align-items: center; - gap: 16px; - margin-top: 10px; - flex-wrap: wrap; - font-size: 12px; - color: rgba(255,255,255,0.5); -} - -.auto-import-settings-row label { display: flex; align-items: center; gap: 6px; } -.auto-import-settings-row input[type="range"] { width: 100px; } -.auto-import-settings-row select { - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.1); - color: #fff; - border-radius: 6px; - padding: 3px 8px; - font-size: 12px; -} - -.auto-import-empty { - text-align: center; - padding: 40px 20px; - color: rgba(255,255,255,0.3); - font-size: 13px; -} - -/* Result cards */ -.auto-import-card { - display: flex; - flex-direction: column; - padding: 14px 16px; - background: rgba(255,255,255,0.02); - border: 1px solid rgba(255,255,255,0.06); - border-radius: 12px; - margin-bottom: 8px; - transition: all 0.2s; -} - -.auto-import-card-top { - display: flex; - gap: 14px; - align-items: center; -} - -.auto-import-card:hover { - background: rgba(255,255,255,0.04); - border-color: rgba(255,255,255,0.1); -} - -.auto-import-completed { border-left: 3px solid #4ade80; } -.auto-import-review { border-left: 3px solid #fbbf24; } -.auto-import-failed { border-left: 3px solid #f87171; } -.auto-import-processing { border-left: 3px solid #60a5fa; } - -.auto-import-card-art { - width: 56px; height: 56px; - border-radius: 8px; - object-fit: cover; - flex-shrink: 0; -} - -.auto-import-card-art-fallback { - width: 56px; height: 56px; - border-radius: 8px; - background: rgba(255,255,255,0.05); - display: flex; align-items: center; justify-content: center; - font-size: 22px; opacity: 0.3; flex-shrink: 0; -} - -.auto-import-card-center { flex: 1; min-width: 0; } - -.auto-import-card-album { - font-size: 14px; font-weight: 600; color: #fff; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-artist { - font-size: 12px; color: rgba(255,255,255,0.45); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-folder { - font-size: 10px; color: rgba(255,255,255,0.25); margin-top: 2px; -} - -.auto-import-match-info { - font-size: 10px; color: rgba(255,255,255,0.35); margin-top: 2px; -} - -.auto-import-card-error { - font-size: 10px; color: #f87171; margin-top: 2px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-right { - display: flex; flex-direction: column; align-items: flex-end; gap: 4px; - flex-shrink: 0; min-width: 80px; -} - -.auto-import-confidence-bar { - width: 60px; height: 4px; - background: rgba(255,255,255,0.06); - border-radius: 2px; overflow: hidden; -} - -.auto-import-confidence-fill { height: 100%; border-radius: 2px; } -.auto-import-conf-high { background: #4ade80; } -.auto-import-conf-medium { background: #fbbf24; } -.auto-import-conf-low { background: #f87171; } - -.auto-import-confidence-text { - font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5); -} - -/* Scan Now button */ -.auto-import-scan-now-btn { - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.1); - color: rgba(255,255,255,0.6); - font-size: 11px; - padding: 4px 12px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - gap: 5px; - transition: all 0.15s; - margin-left: auto; -} - -.auto-import-scan-now-btn:hover { - background: rgba(255,255,255,0.1); - color: rgba(255,255,255,0.9); -} - -/* Live progress */ -.auto-import-progress { - margin-top: 8px; - padding: 8px 12px; - background: rgba(var(--accent-rgb), 0.04); - border: 1px solid rgba(var(--accent-rgb), 0.1); - border-radius: 8px; -} - -.auto-import-progress-text { - font-size: 11px; - color: rgba(255,255,255,0.6); - margin-bottom: 4px; -} - -.auto-import-progress-bar { - height: 3px; - background: rgba(255,255,255,0.06); - border-radius: 2px; - overflow: hidden; -} - -.auto-import-progress-fill { - height: 100%; - background: rgba(var(--accent-rgb), 0.6); - border-radius: 2px; - width: 100%; - animation: adlPulse 1.5s ease-in-out infinite; -} - -/* Stats summary */ -.auto-import-stats { - display: flex; - gap: 16px; - padding: 8px 0; - margin-bottom: 4px; -} - -.auto-import-stat { - font-size: 12px; - color: rgba(255,255,255,0.5); - font-weight: 500; -} - -.auto-import-stat-review { color: #fbbf24; } -.auto-import-stat-failed { color: #f87171; } - -/* Filter pills */ -.auto-import-filters { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 0; - margin-bottom: 8px; -} - -/* Batch action buttons */ -.auto-import-batch-btn { - font-size: 11px; - padding: 4px 12px; - border-radius: 6px; - border: 1px solid rgba(var(--accent-rgb), 0.3); - background: rgba(var(--accent-rgb), 0.08); - color: rgba(var(--accent-rgb), 1); - cursor: pointer; - transition: all 0.15s; -} - -.auto-import-batch-btn:hover { - background: rgba(var(--accent-rgb), 0.15); -} - -.auto-import-clear-btn { - border-color: rgba(255,255,255,0.1); - background: rgba(255,255,255,0.04); - color: rgba(255,255,255,0.5); -} - -.auto-import-clear-btn:hover { - background: rgba(255,255,255,0.08); - color: rgba(255,255,255,0.8); -} - -.auto-import-card-meta { - display: flex; gap: 8px; align-items: center; - font-size: 10px; color: rgba(255,255,255,0.3); margin-top: 3px; -} - -.auto-import-method-badge { - background: rgba(255,255,255,0.06); - padding: 1px 6px; - border-radius: 4px; - font-size: 9px; - color: rgba(255,255,255,0.45); - text-transform: uppercase; - letter-spacing: 0.3px; -} - -.auto-import-card-folder-path { - font-size: 9px; - color: rgba(255,255,255,0.15); - margin-top: 6px; - padding-top: 6px; - border-top: 1px solid rgba(255,255,255,0.03); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Expandable track list */ -.auto-import-track-list { - display: none; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255,255,255,0.04); -} - -.auto-import-track-list.expanded { - display: block; -} - -.auto-import-track-list-header { - display: grid; - grid-template-columns: 1fr 1fr 50px; - gap: 8px; - font-size: 9px; - font-weight: 600; - color: rgba(255,255,255,0.3); - text-transform: uppercase; - letter-spacing: 0.5px; - padding-bottom: 4px; - margin-bottom: 4px; - border-bottom: 1px solid rgba(255,255,255,0.03); -} - -.auto-import-track-row { - display: grid; - grid-template-columns: 1fr 1fr 50px; - gap: 8px; - padding: 3px 0; - font-size: 11px; -} - -.auto-import-track-name { - color: rgba(255,255,255,0.7); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.auto-import-track-file { - color: rgba(255,255,255,0.3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.auto-import-track-conf { - text-align: right; - font-weight: 600; - font-size: 10px; -} - -.auto-import-track-conf.auto-import-conf-high { color: #4ade80; } -.auto-import-track-conf.auto-import-conf-medium { color: #fbbf24; } -.auto-import-track-conf.auto-import-conf-low { color: #f87171; } - -.auto-import-status-badge { - font-size: 9px; font-weight: 600; padding: 2px 8px; - border-radius: 6px; white-space: nowrap; -} -.auto-import-badge-completed { background: rgba(74,222,128,0.1); color: #4ade80; } -.auto-import-badge-review { background: rgba(251,191,36,0.1); color: #fbbf24; } -.auto-import-badge-failed { background: rgba(248,113,113,0.1); color: #f87171; } -.auto-import-badge-neutral { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4); } -.auto-import-badge-processing { - background: rgba(96,165,250,0.12); - color: #60a5fa; - animation: auto-import-badge-pulse 1.6s ease-in-out infinite; -} -@keyframes auto-import-badge-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } -} - -.auto-import-track-row-active { - background: rgba(96,165,250,0.08); - border-left: 2px solid #60a5fa; - padding-left: 6px; - margin-left: -8px; - border-radius: 3px; -} -.auto-import-track-row-active .auto-import-track-name { color: #60a5fa; font-weight: 600; } -.auto-import-track-row-done .auto-import-track-name, -.auto-import-track-row-done .auto-import-track-file { opacity: 0.4; } - -.auto-import-actions { - display: flex; gap: 4px; margin-top: 4px; -} - -.auto-import-actions button { font-size: 10px; padding: 3px 10px; } - -@media (max-width: 768px) { - .auto-import-card { flex-direction: column; align-items: flex-start; } - .auto-import-card-right { flex-direction: row; width: 100%; justify-content: space-between; } -} - /* ── Legacy (hidden) ── */ #wishlist-page-categories { display: none; margin-bottom: 24px;