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:
parent
ee3500242e
commit
a7bef972e0
2 changed files with 281 additions and 91 deletions
267
web_server.py
267
web_server.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue