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 ---
|
# --- Logic from soulseek_client.py ---
|
||||||
patterns = [
|
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>.+)$',
|
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
|
# Pattern: Artist - Title
|
||||||
r'^(?P<artist>.+?)\s*[-–]\s*(?P<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:
|
for pattern in patterns:
|
||||||
|
|
@ -13207,6 +13208,65 @@ def _parse_filename_metadata(filename: str) -> dict:
|
||||||
return metadata
|
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)
|
# 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)
|
full_path = os.path.join(root, fname)
|
||||||
rel_path = os.path.relpath(full_path, staging_path)
|
rel_path = os.path.relpath(full_path, staging_path)
|
||||||
|
|
||||||
# Read tags first (most reliable)
|
meta = _read_staging_file_metadata(full_path, rel_path)
|
||||||
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')
|
|
||||||
|
|
||||||
files.append({
|
files.append({
|
||||||
'full_path': full_path,
|
'full_path': full_path,
|
||||||
'title': title or '',
|
'title': meta['title'] or '',
|
||||||
'artist': artist or '',
|
'artist': meta['albumartist'] or meta['artist'] or '',
|
||||||
'album': album or '',
|
'album': meta['album'] or '',
|
||||||
'extension': ext,
|
'extension': ext,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -41715,41 +41756,17 @@ def import_staging_files():
|
||||||
full_path = os.path.join(root, fname)
|
full_path = os.path.join(root, fname)
|
||||||
rel_path = os.path.relpath(full_path, staging_path)
|
rel_path = os.path.relpath(full_path, staging_path)
|
||||||
|
|
||||||
# Try reading tags
|
meta = _read_staging_file_metadata(full_path, rel_path)
|
||||||
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')
|
|
||||||
|
|
||||||
files.append({
|
files.append({
|
||||||
'filename': fname,
|
'filename': fname,
|
||||||
'rel_path': rel_path,
|
'rel_path': rel_path,
|
||||||
'full_path': full_path,
|
'full_path': full_path,
|
||||||
'title': title,
|
'title': meta['title'],
|
||||||
'artist': artist or 'Unknown Artist',
|
'artist': meta['albumartist'] or meta['artist'] or 'Unknown Artist',
|
||||||
'album': album,
|
'album': meta['album'],
|
||||||
'track_number': track_number,
|
'track_number': meta['track_number'],
|
||||||
|
'disc_number': meta['disc_number'],
|
||||||
'extension': ext
|
'extension': ext
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -41761,6 +41778,70 @@ def import_staging_files():
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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'])
|
@app.route('/api/import/staging/hints', methods=['GET'])
|
||||||
def import_staging_hints():
|
def import_staging_hints():
|
||||||
"""Extract album search hints from staging folder (tags + folder names). Fast — no Spotify calls."""
|
"""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_id = data.get('album_id')
|
||||||
album_name = data.get('album_name', '')
|
album_name = data.get('album_name', '')
|
||||||
album_artist = data.get('album_artist', '')
|
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:
|
if not album_id:
|
||||||
return jsonify({'success': False, 'error': 'Missing album_id'}), 400
|
return jsonify({'success': False, 'error': 'Missing album_id'}), 400
|
||||||
|
|
||||||
|
|
@ -41948,48 +42031,33 @@ def import_album_match():
|
||||||
continue
|
continue
|
||||||
full_path = os.path.join(root, fname)
|
full_path = os.path.join(root, fname)
|
||||||
|
|
||||||
title, artist, album_tag, track_number = None, None, None, None
|
meta = _read_staging_file_metadata(full_path, fname)
|
||||||
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')
|
|
||||||
|
|
||||||
staging_files.append({
|
staging_files.append({
|
||||||
'filename': fname,
|
'filename': fname,
|
||||||
'full_path': full_path,
|
'full_path': full_path,
|
||||||
'title': title,
|
'title': meta['title'],
|
||||||
'artist': artist,
|
'artist': meta['albumartist'] or meta['artist'],
|
||||||
'album': album_tag,
|
'album': meta['album'],
|
||||||
'track_number': track_number
|
'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
|
# Match each Spotify track to the best staging file
|
||||||
matches = []
|
matches = []
|
||||||
used_files = set()
|
used_files = set()
|
||||||
|
album_name_for_match = album_info.get('name', '')
|
||||||
|
|
||||||
for sp_track in spotify_tracks:
|
for sp_track in spotify_tracks:
|
||||||
sp_name = sp_track.get('name', '')
|
sp_name = sp_track.get('name', '')
|
||||||
sp_number = sp_track.get('track_number', 0)
|
sp_number = sp_track.get('track_number', 0)
|
||||||
sp_disc = sp_track.get('disc_number', 1)
|
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_match = None
|
||||||
best_score = 0.0
|
best_score = 0.0
|
||||||
|
|
@ -41999,19 +42067,40 @@ def import_album_match():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
score = 0.0
|
score = 0.0
|
||||||
# Title similarity (weight 0.5)
|
|
||||||
|
# Title similarity (weight 0.45)
|
||||||
title_sim = matching_engine.similarity_score(
|
title_sim = matching_engine.similarity_score(
|
||||||
matching_engine.normalize_string(sp_name),
|
matching_engine.normalize_string(sp_name),
|
||||||
matching_engine.normalize_string(sf['title'] or '')
|
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'] and sp_number:
|
||||||
if sf['track_number'] == sp_number:
|
if sf['track_number'] == sp_number:
|
||||||
score += 0.5
|
score += 0.30
|
||||||
elif abs(sf['track_number'] - sp_number) <= 1:
|
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:
|
if score > best_score and score >= 0.4:
|
||||||
best_score = score
|
best_score = score
|
||||||
|
|
|
||||||
|
|
@ -54484,6 +54484,7 @@ function initializeImportPage() {
|
||||||
if (!importPageState.initialized) {
|
if (!importPageState.initialized) {
|
||||||
importPageState.initialized = true;
|
importPageState.initialized = true;
|
||||||
importPageRefreshStaging();
|
importPageRefreshStaging();
|
||||||
|
importPageLoadAutoGroups();
|
||||||
importPageLoadSuggestions();
|
importPageLoadSuggestions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54492,7 +54493,8 @@ async function importPageRefreshStaging() {
|
||||||
// Clear finished jobs from the queue
|
// Clear finished jobs from the queue
|
||||||
importPageClearFinishedJobs();
|
importPageClearFinishedJobs();
|
||||||
|
|
||||||
// Re-fetch suggestions (server rebuilds cache after imports)
|
// Re-fetch groups and suggestions (server rebuilds cache after imports)
|
||||||
|
importPageLoadAutoGroups();
|
||||||
importPageLoadSuggestions();
|
importPageLoadSuggestions();
|
||||||
|
|
||||||
try {
|
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) ---
|
// --- Album Tab: Suggestions (server-side cache, just fetch and render) ---
|
||||||
|
|
||||||
async function importPageLoadSuggestions() {
|
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>';
|
matchList.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Matching files to tracklist...</div>';
|
||||||
|
|
||||||
try {
|
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', {
|
const resp = await fetch('/api/import/album/match', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({album_id: albumId})
|
body: JSON.stringify(matchBody)
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue