diff --git a/web_server.py b/web_server.py index b6b22048..d7bdcc80 100644 --- a/web_server.py +++ b/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\d{1,2})\s*[-\.]\s*(?P.+?)\s*[-–]\s*(?P.+)$', + # 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 diff --git a/webui/static/script.js b/webui/static/script.js index 67cb12da..c7bcc64c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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) {