Smarter staging import: tag-first matching and auto-grouping

1. Fix filename parser pattern order — "01 - Title" now matched
   before "Artist - Title", preventing track numbers being treated
   as artist names (e.g., "08" no longer becomes the artist)

2. Tag priority over filename parsing — shared _read_staging_file_metadata()
   helper reads title, artist, albumartist, album, track_number, disc_number
   from Mutagen tags. Only falls back to filename parsing when BOTH title
   AND artist tags are empty. Applied to all 3 staging scan sites.

3. Improved match scoring — rebalanced from title(0.5)+tracknum(0.5) to
   title(0.45)+artist(0.15)+tracknum(0.30)+album_bonus(0.10). Files
   whose album tag matches the selected album get boosted.

4. Auto-group detection — new /api/import/staging/groups endpoint groups
   staging files by album+artist tags. Frontend shows "Auto-Detected
   Albums" section with one-click search. Match endpoint accepts
   optional file_paths filter to scope matching to a specific group.
This commit is contained in:
Broque Thomas 2026-03-20 22:34:20 -07:00
parent ee3500242e
commit a7bef972e0
2 changed files with 281 additions and 91 deletions

View file

@ -13156,12 +13156,13 @@ def _parse_filename_metadata(filename: str) -> dict:
# --- Logic from soulseek_client.py ---
patterns = [
# Pattern: 01 - Artist - Title
# Pattern: 01 - Artist - Title (three-part with track number)
r'^(?P<track_number>\d{1,2})\s*[-\.]\s*(?P<artist>.+?)\s*[-]\s*(?P<title>.+)$',
# Pattern: 01 - Title (track number + title — must come before Artist - Title
# to prevent "08 - Kilburn Market Dub" matching as artist="08")
r'^(?P<track_number>\d{1,2})\s*[-\.]\s*(?P<title>.+)$',
# Pattern: Artist - Title
r'^(?P<artist>.+?)\s*[-]\s*(?P<title>.+)$',
# Pattern: 01 - Title
r'^(?P<track_number>\d{1,2})\s*[-\.]\s*(?P<title>.+)$',
]
for pattern in patterns:
@ -13207,6 +13208,65 @@ def _parse_filename_metadata(filename: str) -> dict:
return metadata
def _read_staging_file_metadata(full_path: str, filename: str) -> dict:
"""Read metadata from a staging file — tags first, filename parsing as fallback.
Returns dict with: title, artist, albumartist, album, track_number, disc_number.
Only falls back to filename parsing when BOTH title AND artist tags are empty.
"""
meta = {
'title': None, 'artist': None, 'albumartist': None,
'album': None, 'track_number': None, 'disc_number': None,
}
# Phase 1: Read embedded tags (most reliable)
try:
from mutagen import File as MutagenFile
tags = MutagenFile(full_path, easy=True)
if tags:
def _first(tag_list):
if isinstance(tag_list, list) and tag_list:
val = str(tag_list[0]).strip()
return val if val else None
return None
meta['title'] = _first(tags.get('title'))
meta['artist'] = _first(tags.get('artist'))
meta['albumartist'] = _first(tags.get('albumartist'))
meta['album'] = _first(tags.get('album'))
tn = _first(tags.get('tracknumber'))
if tn:
try:
meta['track_number'] = int(tn.split('/')[0])
except (ValueError, IndexError):
pass
dn = _first(tags.get('discnumber'))
if dn:
try:
meta['disc_number'] = int(dn.split('/')[0])
except (ValueError, IndexError):
pass
except Exception:
pass
# Phase 2: Only fall back to filename parsing when tags are genuinely empty
if not meta['title'] and not meta['artist']:
parsed = _parse_filename_metadata(filename)
meta['title'] = parsed.get('title') or os.path.splitext(os.path.basename(filename))[0]
meta['artist'] = parsed.get('artist')
if not meta['track_number']:
meta['track_number'] = parsed.get('track_number')
if not meta['album']:
meta['album'] = parsed.get('album')
elif not meta['title']:
# Has artist tag but no title — use filename for title only
meta['title'] = os.path.splitext(os.path.basename(filename))[0]
return meta
# ===================================================================
# NEW POST-PROCESSING HELPERS (Ported from downloads.py)
# ===================================================================
@ -23814,32 +23874,13 @@ def _get_staging_file_cache(batch_id):
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
# Read tags first (most reliable)
title, artist, album = None, None, None
try:
from mutagen import File as MutagenFile
tags = MutagenFile(full_path, easy=True)
if tags:
title = (tags.get('title') or [None])[0]
artist = (tags.get('artist') or [None])[0]
album = (tags.get('album') or [None])[0]
except Exception:
pass
# Fallback to filename parsing
if not title:
parsed = _parse_filename_metadata(rel_path)
title = parsed.get('title') or os.path.splitext(fname)[0]
if not artist:
artist = parsed.get('artist')
if not album:
album = parsed.get('album')
meta = _read_staging_file_metadata(full_path, rel_path)
files.append({
'full_path': full_path,
'title': title or '',
'artist': artist or '',
'album': album or '',
'title': meta['title'] or '',
'artist': meta['albumartist'] or meta['artist'] or '',
'album': meta['album'] or '',
'extension': ext,
})
@ -41715,41 +41756,17 @@ def import_staging_files():
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
# Try reading tags
title, artist, album, track_number = None, None, None, None
try:
from mutagen import File as MutagenFile
tags = MutagenFile(full_path, easy=True)
if tags:
title = (tags.get('title') or [None])[0]
artist = (tags.get('artist') or [None])[0]
album = (tags.get('album') or [None])[0]
tn = (tags.get('tracknumber') or [None])[0]
if tn:
try:
track_number = int(str(tn).split('/')[0])
except ValueError:
pass
except Exception:
pass
# Fallback to filename parsing
if not title:
parsed = _parse_filename_metadata(fname)
title = parsed.get('title') or os.path.splitext(fname)[0]
if not artist:
artist = parsed.get('artist')
if not track_number:
track_number = parsed.get('track_number')
meta = _read_staging_file_metadata(full_path, rel_path)
files.append({
'filename': fname,
'rel_path': rel_path,
'full_path': full_path,
'title': title,
'artist': artist or 'Unknown Artist',
'album': album,
'track_number': track_number,
'title': meta['title'],
'artist': meta['albumartist'] or meta['artist'] or 'Unknown Artist',
'album': meta['album'],
'track_number': meta['track_number'],
'disc_number': meta['disc_number'],
'extension': ext
})
@ -41761,6 +41778,70 @@ def import_staging_files():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/staging/groups', methods=['GET'])
def import_staging_groups():
"""Auto-detect album groups from staging files based on their tags.
Groups files by (album_tag, artist) where both are non-empty and at least 2 files share
the same album+artist combo. Returns groups sorted by file count descending.
"""
try:
staging_path = _get_staging_path()
if not os.path.isdir(staging_path):
return jsonify({'success': True, 'groups': []})
# Scan files and group by album+artist tags
album_groups = {} # (album_lower, artist_lower) -> {album, artist, files: []}
for root, _dirs, filenames in os.walk(staging_path):
for fname in filenames:
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
meta = _read_staging_file_metadata(full_path, rel_path)
album = meta['album']
artist = meta['albumartist'] or meta['artist']
if not album or not artist:
continue
key = (album.lower().strip(), artist.lower().strip())
if key not in album_groups:
album_groups[key] = {
'album': album.strip(),
'artist': artist.strip(),
'files': []
}
album_groups[key]['files'].append({
'filename': fname,
'full_path': full_path,
'title': meta['title'],
'track_number': meta['track_number'],
})
# Only return groups with 2+ files
groups = []
for group in album_groups.values():
if len(group['files']) >= 2:
group['files'].sort(key=lambda f: f.get('track_number') or 999)
groups.append({
'album': group['album'],
'artist': group['artist'],
'file_count': len(group['files']),
'files': group['files'],
'file_paths': [f['full_path'] for f in group['files']],
})
# Sort by file count descending
groups.sort(key=lambda g: g['file_count'], reverse=True)
return jsonify({'success': True, 'groups': groups})
except Exception as e:
logger.error(f"Error building staging groups: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/staging/hints', methods=['GET'])
def import_staging_hints():
"""Extract album search hints from staging folder (tags + folder names). Fast — no Spotify calls."""
@ -41870,6 +41951,8 @@ def import_album_match():
album_id = data.get('album_id')
album_name = data.get('album_name', '')
album_artist = data.get('album_artist', '')
# Optional: only match specific files (from auto-group selection)
filter_file_paths = set(data.get('file_paths', []))
if not album_id:
return jsonify({'success': False, 'error': 'Missing album_id'}), 400
@ -41948,48 +42031,33 @@ def import_album_match():
continue
full_path = os.path.join(root, fname)
title, artist, album_tag, track_number = None, None, None, None
try:
from mutagen import File as MutagenFile
tags = MutagenFile(full_path, easy=True)
if tags:
title = (tags.get('title') or [None])[0]
artist = (tags.get('artist') or [None])[0]
album_tag = (tags.get('album') or [None])[0]
tn = (tags.get('tracknumber') or [None])[0]
if tn:
try:
track_number = int(str(tn).split('/')[0])
except ValueError:
pass
except Exception:
pass
if not title:
parsed = _parse_filename_metadata(fname)
title = parsed.get('title') or os.path.splitext(fname)[0]
if not artist:
artist = parsed.get('artist')
if not track_number:
track_number = parsed.get('track_number')
meta = _read_staging_file_metadata(full_path, fname)
staging_files.append({
'filename': fname,
'full_path': full_path,
'title': title,
'artist': artist,
'album': album_tag,
'track_number': track_number
'title': meta['title'],
'artist': meta['albumartist'] or meta['artist'],
'album': meta['album'],
'track_number': meta['track_number'],
'disc_number': meta['disc_number'],
})
# Filter to specific files if requested (auto-group selection)
if filter_file_paths:
staging_files = [sf for sf in staging_files if sf['full_path'] in filter_file_paths]
# Match each Spotify track to the best staging file
matches = []
used_files = set()
album_name_for_match = album_info.get('name', '')
for sp_track in spotify_tracks:
sp_name = sp_track.get('name', '')
sp_number = sp_track.get('track_number', 0)
sp_disc = sp_track.get('disc_number', 1)
sp_artists = sp_track.get('artists', [])
sp_artist_name = sp_artists[0]['name'] if sp_artists and isinstance(sp_artists[0], dict) else (sp_artists[0] if sp_artists else '')
best_match = None
best_score = 0.0
@ -41999,19 +42067,40 @@ def import_album_match():
continue
score = 0.0
# Title similarity (weight 0.5)
# Title similarity (weight 0.45)
title_sim = matching_engine.similarity_score(
matching_engine.normalize_string(sp_name),
matching_engine.normalize_string(sf['title'] or '')
)
score += title_sim * 0.5
score += title_sim * 0.45
# Track number match (weight 0.5)
# Artist similarity (weight 0.15)
sf_artist = sf.get('artist') or ''
if sf_artist and sp_artist_name:
artist_sim = matching_engine.similarity_score(
matching_engine.normalize_string(sp_artist_name),
matching_engine.normalize_string(sf_artist)
)
score += artist_sim * 0.15
else:
score += 0.075 # neutral when no artist data
# Track number match (weight 0.30)
if sf['track_number'] and sp_number:
if sf['track_number'] == sp_number:
score += 0.5
score += 0.30
elif abs(sf['track_number'] - sp_number) <= 1:
score += 0.2
score += 0.12
# Album tag bonus (weight 0.10) — reward files whose album tag matches
sf_album = sf.get('album') or ''
if sf_album and album_name_for_match:
album_sim = matching_engine.similarity_score(
matching_engine.normalize_string(sf_album),
matching_engine.normalize_string(album_name_for_match)
)
score += album_sim * 0.10
if score > best_score and score >= 0.4:
best_score = score

View file

@ -54484,6 +54484,7 @@ function initializeImportPage() {
if (!importPageState.initialized) {
importPageState.initialized = true;
importPageRefreshStaging();
importPageLoadAutoGroups();
importPageLoadSuggestions();
}
}
@ -54492,7 +54493,8 @@ async function importPageRefreshStaging() {
// Clear finished jobs from the queue
importPageClearFinishedJobs();
// Re-fetch suggestions (server rebuilds cache after imports)
// Re-fetch groups and suggestions (server rebuilds cache after imports)
importPageLoadAutoGroups();
importPageLoadSuggestions();
try {
@ -54534,6 +54536,99 @@ function importPageSwitchTab(tab) {
}
}
// --- 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 = `
<div style="font-size:0.82em;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;color:rgba(255,255,255,0.5);margin-bottom:10px;">
Auto-Detected Albums
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">
${data.groups.map((g, idx) => `
<div class="import-page-album-card" style="cursor:pointer;display:flex;align-items:center;gap:12px;padding:12px;border-radius:10px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);transition:all 0.2s;"
onmouseenter="this.style.borderColor='rgba(255,255,255,0.12)';this.style.background='rgba(255,255,255,0.05)'"
onmouseleave="this.style.borderColor='rgba(255,255,255,0.06)';this.style.background='rgba(255,255,255,0.03)'"
onclick="importPageMatchAutoGroup(${idx})">
<div style="width:48px;height:48px;border-radius:8px;background:rgba(var(--accent-rgb,29,185,84),0.15);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1.2em;">
${g.file_count}
</div>
<div style="min-width:0;">
<div style="font-size:0.92em;font-weight:500;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${_escAttr(g.album)}">${_esc(g.album)}</div>
<div style="font-size:0.8em;color:rgba(255,255,255,0.5);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${_escAttr(g.artist)}">${_esc(g.artist)} · ${g.file_count} tracks</div>
</div>
</div>
`).join('')}
</div>
`;
// 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 = '<div style="color:#888;text-align:center;padding:20px;">Searching...</div>';
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
grid.innerHTML = data.albums.map(a => _renderSuggestionCard(a)).join('');
} else {
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found — try searching manually</div>';
}
} catch (err) {
console.error('Auto-group search failed:', err);
if (grid) grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Search failed</div>';
}
}
// --- Album Tab: Suggestions (server-side cache, just fetch and render) ---
async function importPageLoadSuggestions() {
@ -54617,10 +54712,16 @@ async function importPageSelectAlbum(albumId) {
matchList.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Matching files to tracklist...</div>';
try {
// Include file_paths filter if matching from an auto-group
const matchBody = { album_id: albumId };
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({album_id: albumId})
body: JSON.stringify(matchBody)
});
const data = await resp.json();
if (!data.success) {