Recursive staging scan, singles support, and improved import UI

Auto-import now scans the staging folder recursively — any folder
structure depth works (Artist/Album/tracks, Album/tracks, etc.).
Loose audio files are treated as singles with tag/filename/AcoustID
identification.

Import results UI redesigned:
- Click cards to expand per-track match details with confidence scores
- Shows identification method badge (Tags, Folder Name, AcoustID)
- Per-track grid: track name, matched filename, confidence percentage
- Time ago labels, folder path, better status badges
- Approve/Dismiss buttons use event.stopPropagation for clean UX
pull/315/head
Broque Thomas 1 month ago
parent d66adb3c6e
commit d2c6979ce4

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

@ -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 = `<div class="auto-import-match-info">${md.matched_count || 0}/${md.total_tracks || '?'} tracks matched</div>`;
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 = `<div class="auto-import-actions">
<button class="watchlist-action-btn watchlist-action-primary" onclick="_autoImportApprove(${r.id})">Approve</button>
<button class="watchlist-action-btn watchlist-action-secondary" onclick="_autoImportReject(${r.id})">Dismiss</button>
<button class="watchlist-action-btn watchlist-action-primary" onclick="event.stopPropagation(); _autoImportApprove(${r.id})">Approve & Import</button>
<button class="watchlist-action-btn watchlist-action-secondary" onclick="event.stopPropagation(); _autoImportReject(${r.id})">Dismiss</button>
</div>`;
}
return `<div class="auto-import-card auto-import-${statusClass}">
<div class="auto-import-card-left">
${r.image_url ? `<img class="auto-import-card-art" src="${r.image_url}" alt="">` : `<div class="auto-import-card-art-fallback">&#128191;</div>`}
</div>
<div class="auto-import-card-center">
<div class="auto-import-card-album">${escapeHtml(r.album_name || r.folder_name)}</div>
<div class="auto-import-card-artist">${escapeHtml(r.artist_name || 'Unknown Artist')}</div>
<div class="auto-import-card-folder">${escapeHtml(r.folder_name)} &middot; ${r.total_files} files</div>
${matchInfo}
${r.error_message ? `<div class="auto-import-card-error">${escapeHtml(r.error_message)}</div>` : ''}
</div>
<div class="auto-import-card-right">
<div class="auto-import-confidence-bar">
<div class="auto-import-confidence-fill auto-import-conf-${confClass}" style="width:${confPct}%"></div>
// Expanded track list (hidden by default)
let trackListHtml = '';
if (trackDetails.length > 0) {
trackListHtml = `<div class="auto-import-track-list" id="auto-import-tracks-${idx}">
<div class="auto-import-track-list-header">
<span>Track</span><span>Matched File</span><span>Conf</span>
</div>
${trackDetails.map(t => {
const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low';
return `<div class="auto-import-track-row">
<span class="auto-import-track-name">${escapeHtml(t.name)}</span>
<span class="auto-import-track-file">${escapeHtml(t.file)}</span>
<span class="auto-import-track-conf auto-import-conf-${tConfClass}">${t.confidence}%</span>
</div>`;
}).join('')}
</div>`;
}
return `<div class="auto-import-card auto-import-${statusClass}" onclick="_autoImportToggleDetail(${idx})" style="cursor:pointer">
<div class="auto-import-card-top">
<div class="auto-import-card-left">
${r.image_url ? `<img class="auto-import-card-art" src="${r.image_url}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="auto-import-card-art-fallback" style="display:none">\uD83D\uDCBF</div>` : `<div class="auto-import-card-art-fallback">\uD83D\uDCBF</div>`}
</div>
<div class="auto-import-card-center">
<div class="auto-import-card-album">${escapeHtml(r.album_name || r.folder_name)}</div>
<div class="auto-import-card-artist">${escapeHtml(r.artist_name || 'Unknown Artist')}</div>
<div class="auto-import-card-meta">
<span>${matchSummary}</span>
${methodLabel ? `<span class="auto-import-method-badge">${methodLabel}</span>` : ''}
${timeAgo ? `<span>${timeAgo}</span>` : ''}
</div>
${r.error_message ? `<div class="auto-import-card-error">${escapeHtml(r.error_message)}</div>` : ''}
</div>
<div class="auto-import-card-right">
<div class="auto-import-status-badge auto-import-badge-${statusClass}">${statusIcon} ${statusLabel}</div>
<div class="auto-import-confidence-bar">
<div class="auto-import-confidence-fill auto-import-conf-${confClass}" style="width:${confPct}%"></div>
</div>
<div class="auto-import-confidence-text">${confPct}% confidence</div>
${actions}
</div>
<div class="auto-import-confidence-text">${confPct}%</div>
<div class="auto-import-status-badge auto-import-badge-${statusClass}">${statusLabel}</div>
${actions}
</div>
<div class="auto-import-card-folder-path">${escapeHtml(r.folder_name)}</div>
${trackListHtml}
</div>`;
}).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' });

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

Loading…
Cancel
Save