diff --git a/core/auto_import_worker.py b/core/auto_import_worker.py index b870e2c3..4f33d3f8 100644 --- a/core/auto_import_worker.py +++ b/core/auto_import_worker.py @@ -210,10 +210,12 @@ class AutoImportWorker: """One full scan of the staging folder.""" staging = self._resolve_staging_path() if not staging or not os.path.isdir(staging): + logger.warning(f"[Auto-Import] Staging path not found or invalid: {self.staging_path}") return # Find folder candidates candidates = self._enumerate_folders(staging) + logger.info(f"[Auto-Import] Scan cycle: {len(candidates)} candidates in {staging}") if not candidates: return @@ -316,68 +318,73 @@ class AutoImportWorker: return None def _enumerate_folders(self, staging: str) -> List[FolderCandidate]: - """Find album folder and single file candidates in staging directory.""" + """Find album folder and single file candidates in staging directory (recursive).""" candidates = [] + self._scan_directory(staging, candidates) + return candidates + + def _scan_directory(self, directory: str, candidates: List[FolderCandidate]): + """Recursively scan a directory for album folders and loose audio files.""" try: - entries = sorted(os.listdir(staging)) + entries = sorted(os.listdir(directory)) except OSError: - return candidates + return - for entry in entries: - full_path = os.path.join(staging, entry) + # Collect loose audio files at this level + loose_files = [] + subdirs = [] - # Loose audio file in staging root → single track candidate + for entry in entries: + full_path = os.path.join(directory, entry) if os.path.isfile(full_path) and os.path.splitext(entry)[1].lower() in AUDIO_EXTENSIONS: - folder_hash = _compute_folder_hash([full_path]) - candidates.append(FolderCandidate( - path=full_path, name=entry, audio_files=[full_path], - folder_hash=folder_hash, is_single=True - )) - continue + loose_files.append(full_path) + elif os.path.isdir(full_path): + subdirs.append((entry, full_path)) - if not os.path.isdir(full_path): - continue - - audio_files = [] + if loose_files: + # This directory has audio files — treat it as an album folder candidate + audio_files = loose_files disc_structure = {} - # Check for disc subfolders + # Check if any subdirs are disc folders has_disc_folders = False - for sub in os.listdir(full_path): - sub_path = os.path.join(full_path, sub) - disc_match = DISC_FOLDER_RE.match(sub) - if disc_match and os.path.isdir(sub_path): + for sub_name, sub_path in subdirs: + disc_match = DISC_FOLDER_RE.match(sub_name) + if disc_match: has_disc_folders = True disc_num = int(disc_match.group(1)) disc_files = [os.path.join(sub_path, f) for f in sorted(os.listdir(sub_path)) - if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS] + if os.path.isfile(os.path.join(sub_path, f)) + and os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS] if disc_files: disc_structure[disc_num] = disc_files audio_files.extend(disc_files) - # Also collect top-level audio files - top_files = [os.path.join(full_path, f) for f in sorted(os.listdir(full_path)) - if os.path.isfile(os.path.join(full_path, f)) - and os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS] - - if not has_disc_folders: - audio_files = top_files - else: - # Add any stray top-level files to disc 0 - if top_files: - disc_structure[0] = top_files - audio_files.extend(top_files) - - if not audio_files: - continue + if has_disc_folders: + disc_structure[0] = loose_files # Top-level files are disc 0 + # Determine if this is a single or album + is_single = len(audio_files) == 1 and not has_disc_folders + folder_name = os.path.basename(directory) folder_hash = _compute_folder_hash(audio_files) - candidates.append(FolderCandidate( - path=full_path, name=entry, audio_files=audio_files, - disc_structure=disc_structure, folder_hash=folder_hash - )) - return candidates + if is_single: + candidates.append(FolderCandidate( + path=audio_files[0], name=os.path.basename(audio_files[0]), + audio_files=audio_files, folder_hash=folder_hash, is_single=True + )) + else: + candidates.append(FolderCandidate( + path=directory, name=folder_name, audio_files=audio_files, + disc_structure=disc_structure, folder_hash=folder_hash + )) + else: + # No audio files here — recurse into subdirectories + for sub_name, sub_path in subdirs: + # Skip disc folders at this level (they'll be handled by the parent album) + if DISC_FOLDER_RE.match(sub_name): + continue + self._scan_directory(sub_path, candidates) def _is_folder_stable(self, candidate: FolderCandidate) -> bool: """Check if folder contents have stopped changing.""" diff --git a/webui/static/script.js b/webui/static/script.js index 3c4ecbd2..cbc4dce7 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -66528,54 +66528,112 @@ async function _autoImportLoadResults() { return; } - container.innerHTML = data.results.map(r => { + container.innerHTML = data.results.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': '⚠ Review', - 'needs_identification': '✗ Unidentified', 'failed': '✗ Failed', - 'scanning': '⌛ Scanning', 'matched': '✓ Matched', - 'rejected': '🚫 Rejected', 'approved': '✅ Approved', + 'completed': 'Imported', 'pending_review': 'Needs Review', + 'needs_identification': 'Unidentified', 'failed': 'Failed', + 'scanning': 'Scanning...', 'matched': 'Matched', + 'rejected': 'Dismissed', 'approved': 'Approved', + }; + const statusIcons = { + 'completed': '\u2713', 'pending_review': '\u26A0', + 'needs_identification': '\u2717', 'failed': '\u2717', + 'scanning': '\u231B', 'matched': '\u2713', + 'rejected': '\u2715', 'approved': '\u2713', }; 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' : 'neutral'; - let matchInfo = ''; + // 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; - matchInfo = `
${md.matched_count || 0}/${md.total_tracks || '?'} tracks matched
`; + matchCount = md.matched_count || 0; + totalTracks = md.total_tracks || 0; + if (md.matches) { + trackDetails = md.matches.map(m => ({ + name: m.track?.name || 'Unknown', + file: m.file ? m.file.split(/[/\\]/).pop() : '?', + confidence: Math.round((m.confidence || 0) * 100), + })); + } + } catch (e) {} + } + + const matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`; + 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 = `
- - + +
`; } - return `
-
- ${r.image_url ? `` : `
💿
`} -
-
-
${escapeHtml(r.album_name || r.folder_name)}
-
${escapeHtml(r.artist_name || 'Unknown Artist')}
-
${escapeHtml(r.folder_name)} · ${r.total_files} files
- ${matchInfo} - ${r.error_message ? `
${escapeHtml(r.error_message)}
` : ''} -
-
-
-
+ // Expanded track list (hidden by default) + let trackListHtml = ''; + if (trackDetails.length > 0) { + trackListHtml = `
+
+ TrackMatched FileConf +
+ ${trackDetails.map(t => { + const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low'; + 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}
-
${confPct}%
-
${statusLabel}
- ${actions}
+
${escapeHtml(r.folder_name)}
+ ${trackListHtml}
`; }).join(''); @@ -66594,6 +66652,14 @@ async function _autoImportSaveSettings() { } catch (e) { showToast('Error', 'error'); } } +function _autoImportToggleDetail(idx) { + const trackList = document.getElementById(`auto-import-tracks-${idx}`); + if (trackList) { + trackList.classList.toggle('expanded'); + } +} +window._autoImportToggleDetail = _autoImportToggleDetail; + async function _autoImportApprove(id) { try { const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' }); diff --git a/webui/static/style.css b/webui/static/style.css index 061ca136..1c44924f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58229,13 +58229,18 @@ body.reduce-effects *::after { /* Result cards */ .auto-import-card { display: flex; - gap: 14px; + 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; } @@ -58308,6 +58313,90 @@ body.reduce-effects *::after { font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5); } +.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;