feat: Import Music from local staging folder

Added an Import feature that lets users process local audio files through the existing post-processing pipeline (metadata enrichment, cover art, lyrics, library organization). Files are placed in a configurable Staging folder and imported via two modes: Album mode (search/match files to a Spotify tracklist) and Singles mode (select individual files for processing). Includes auto-suggested albums based on staging folder contents and real-time per-track progress tracking.
This commit is contained in:
Broque Thomas 2026-02-06 23:29:05 -08:00
parent fa2c6e2a1c
commit d674b999e5
7 changed files with 1840 additions and 1 deletions

View file

@ -219,6 +219,9 @@ class ConfigManager:
},
"settings": {
"audio_quality": "flac"
},
"import": {
"staging_path": "./Staging"
}
}

View file

@ -29,6 +29,7 @@ services:
- ./config:/app/config
- ./logs:/app/logs
- ./downloads:/app/downloads
- ./Staging:/app/Staging
# Use named volume for database persistence (separate from host database)
# NOTE: Changed from /app/database to /app/data to avoid overwriting Python package
- soulsync_database:/app/data

View file

@ -2362,7 +2362,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz', 'acoustid']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz', 'acoustid', 'import']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -24920,6 +24920,637 @@ def musicbrainz_resume():
# ================================================================================================
# ================================================================================================
# IMPORT / STAGING SYSTEM
# ================================================================================================
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif', '.ape'}
def _get_staging_path():
"""Get the resolved staging folder path."""
raw = config_manager.get('import.staging_path', './Staging')
return docker_resolve_path(raw)
@app.route('/api/import/staging/files', methods=['GET'])
def import_staging_files():
"""Scan the staging folder and return audio files with tag metadata."""
try:
staging_path = _get_staging_path()
os.makedirs(staging_path, exist_ok=True)
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)
# 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')
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,
'extension': ext
})
# Sort by filename
files.sort(key=lambda f: f['filename'].lower())
return jsonify({'success': True, 'files': files, 'staging_path': staging_path})
except Exception as e:
logger.error(f"Error scanning staging files: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/staging/suggestions', methods=['GET'])
def import_staging_suggestions():
"""Suggest albums based on staging folder contents (tags + folder names)."""
try:
staging_path = _get_staging_path()
if not os.path.isdir(staging_path):
return jsonify({'success': True, 'suggestions': []})
# Collect hints from tags and folder structure
tag_albums = {} # (album, artist) -> file count
folder_hints = {} # subfolder name -> file count
for root, _dirs, filenames in os.walk(staging_path):
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
if not audio_files:
continue
# Folder-based hint: use immediate subfolder name relative to staging
rel_dir = os.path.relpath(root, staging_path)
if rel_dir != '.':
# Use the top-level subfolder as the hint
top_folder = rel_dir.split(os.sep)[0]
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
# Tag-based hints
for fname in audio_files:
full_path = os.path.join(root, fname)
try:
from mutagen import File as MutagenFile
tags = MutagenFile(full_path, easy=True)
if tags:
album = (tags.get('album') or [None])[0]
artist = (tags.get('artist') or (tags.get('albumartist') or [None]))[0]
if album:
key = (album.strip(), (artist or '').strip())
tag_albums[key] = tag_albums.get(key, 0) + 1
except Exception:
pass
# Build search queries, prioritizing tag-based hints (more specific)
queries = []
seen_queries_lower = set()
# Tag-based: sort by file count descending
for (album, artist), count in sorted(tag_albums.items(), key=lambda x: -x[1]):
q = f"{album} {artist}".strip() if artist else album
if q.lower() not in seen_queries_lower:
seen_queries_lower.add(q.lower())
queries.append(q)
# Folder-based: parse "Artist - Album" pattern or use as-is
for folder, count in sorted(folder_hints.items(), key=lambda x: -x[1]):
# Try to parse "Artist - Album" folder name
q = folder.replace('_', ' ')
if q.lower() not in seen_queries_lower:
seen_queries_lower.add(q.lower())
queries.append(q)
# Cap at 5 queries to keep it fast
queries = queries[:5]
if not queries:
return jsonify({'success': True, 'suggestions': []})
# Search Spotify for each hint, take top 1-2 results per query
suggestions = []
seen_ids = set()
for q in queries:
try:
albums = spotify_client.search_albums(q, limit=2)
for a in albums:
if a.id not in seen_ids:
seen_ids.add(a.id)
suggestions.append({
'id': a.id,
'name': a.name,
'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist',
'release_date': a.release_date or '',
'total_tracks': a.total_tracks,
'image_url': a.image_url,
'album_type': a.album_type or 'album',
'hint_query': q
})
except Exception as search_err:
logger.warning(f"Suggestion search failed for '{q}': {search_err}")
return jsonify({'success': True, 'suggestions': suggestions[:8]})
except Exception as e:
logger.error(f"Error getting staging suggestions: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/search/albums', methods=['GET'])
def import_search_albums():
"""Search for albums via Spotify for import matching."""
try:
query = request.args.get('q', '').strip()
if not query:
return jsonify({'success': False, 'error': 'Missing query parameter'}), 400
limit = min(int(request.args.get('limit', 12)), 50)
albums = spotify_client.search_albums(query, limit=limit)
results = []
for a in albums:
results.append({
'id': a.id,
'name': a.name,
'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist',
'release_date': a.release_date or '',
'total_tracks': a.total_tracks,
'image_url': a.image_url,
'album_type': a.album_type or 'album'
})
return jsonify({'success': True, 'albums': results})
except Exception as e:
logger.error(f"Error searching albums for import: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/album/match', methods=['POST'])
def import_album_match():
"""Match staging files to an album's tracklist."""
try:
data = request.get_json()
album_id = data.get('album_id')
if not album_id:
return jsonify({'success': False, 'error': 'Missing album_id'}), 400
# Get album info and tracklist from Spotify
album_data = spotify_client.get_album(album_id)
if not album_data:
return jsonify({'success': False, 'error': 'Album not found'}), 404
tracks_data = spotify_client.get_album_tracks(album_id)
if not tracks_data or 'items' not in tracks_data:
return jsonify({'success': False, 'error': 'Could not get album tracks'}), 500
spotify_tracks = tracks_data['items']
# Build album summary
album_artists = [a['name'] for a in album_data.get('artists', [])]
album_info = {
'id': album_id,
'name': album_data.get('name', 'Unknown Album'),
'artist': ', '.join(album_artists),
'artists': album_artists,
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('total_tracks', len(spotify_tracks)),
'image_url': (album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None),
'genres': album_data.get('genres', [])
}
# Get artist info for context building later
if album_data.get('artists'):
primary_artist = album_data['artists'][0]
album_info['artist_id'] = primary_artist.get('id', '')
# Scan staging files
staging_path = _get_staging_path()
staging_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)
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')
staging_files.append({
'filename': fname,
'full_path': full_path,
'title': title,
'artist': artist,
'album': album_tag,
'track_number': track_number
})
# Match each Spotify track to the best staging file
matches = []
used_files = set()
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)
best_match = None
best_score = 0.0
for i, sf in enumerate(staging_files):
if i in used_files:
continue
score = 0.0
# Title similarity (weight 0.5)
title_sim = matching_engine.similarity_score(
matching_engine.normalize_string(sp_name),
matching_engine.normalize_string(sf['title'] or '')
)
score += title_sim * 0.5
# Track number match (weight 0.5)
if sf['track_number'] and sp_number:
if sf['track_number'] == sp_number:
score += 0.5
elif abs(sf['track_number'] - sp_number) <= 1:
score += 0.2
if score > best_score and score >= 0.4:
best_score = score
best_match = i
if best_match is not None:
used_files.add(best_match)
matches.append({
'spotify_track': {
'name': sp_name,
'track_number': sp_number,
'disc_number': sp_disc,
'duration_ms': sp_track.get('duration_ms', 0),
'id': sp_track.get('id', ''),
'artists': [a['name'] for a in sp_track.get('artists', [])],
'uri': sp_track.get('uri', '')
},
'staging_file': staging_files[best_match],
'confidence': round(best_score, 2)
})
else:
matches.append({
'spotify_track': {
'name': sp_name,
'track_number': sp_number,
'disc_number': sp_disc,
'duration_ms': sp_track.get('duration_ms', 0),
'id': sp_track.get('id', ''),
'artists': [a['name'] for a in sp_track.get('artists', [])],
'uri': sp_track.get('uri', '')
},
'staging_file': None,
'confidence': 0
})
# Unmatched staging files
unmatched_files = [sf for i, sf in enumerate(staging_files) if i not in used_files]
return jsonify({
'success': True,
'album': album_info,
'matches': matches,
'unmatched_files': unmatched_files
})
except Exception as e:
logger.error(f"Error matching album for import: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/album/process', methods=['POST'])
def import_album_process():
"""Process matched album files through the post-processing pipeline."""
try:
data = request.get_json()
album = data.get('album', {})
matches = data.get('matches', [])
if not album or not matches:
return jsonify({'success': False, 'error': 'Missing album or matches data'}), 400
processed = 0
errors = []
album_name = album.get('name', 'Unknown Album')
artist_name = album.get('artist', 'Unknown Artist')
artist_id = album.get('artist_id', '')
album_id = album.get('id', '')
# Get artist genres from Spotify if possible
artist_genres = album.get('genres', [])
if not artist_genres and artist_id:
try:
sp_artist = spotify_client.sp.artist(artist_id) if hasattr(spotify_client, 'sp') and spotify_client.sp else None
if sp_artist:
artist_genres = sp_artist.get('genres', [])
except Exception:
pass
for match in matches:
staging_file = match.get('staging_file')
spotify_track = match.get('spotify_track')
if not staging_file or not spotify_track:
continue
file_path = staging_file.get('full_path', '')
if not os.path.isfile(file_path):
errors.append(f"File not found: {staging_file.get('filename', '?')}")
continue
track_name = spotify_track.get('name', 'Unknown Track')
track_number = spotify_track.get('track_number', 1)
disc_number = spotify_track.get('disc_number', 1)
track_artists = spotify_track.get('artists', [artist_name])
context_key = f"import_album_{album_id}_{track_number}_{uuid.uuid4().hex[:8]}"
context = {
'spotify_artist': {
'name': artist_name,
'id': artist_id,
'genres': artist_genres
},
'spotify_album': {
'id': album_id,
'name': album_name,
'release_date': album.get('release_date', ''),
'total_tracks': album.get('total_tracks', len(matches)),
'image_url': album.get('image_url', '')
},
'track_info': {
'name': track_name,
'id': spotify_track.get('id', ''),
'track_number': track_number,
'disc_number': disc_number,
'duration_ms': spotify_track.get('duration_ms', 0),
'artists': [{'name': a} if isinstance(a, str) else a for a in track_artists],
'uri': spotify_track.get('uri', '')
},
'original_search_result': {
'title': track_name,
'artist': artist_name,
'album': album_name,
'track_number': track_number,
'disc_number': disc_number,
'spotify_clean_title': track_name,
'spotify_clean_album': album_name,
'artists': [{'name': a} if isinstance(a, str) else a for a in track_artists]
},
'is_album_download': True,
'has_clean_spotify_data': True,
'has_full_spotify_metadata': True
}
try:
_post_process_matched_download(context_key, context, file_path)
processed += 1
logger.info(f"Import processed: {track_number}. {track_name} from {album_name}")
except Exception as proc_err:
err_msg = f"{track_name}: {str(proc_err)}"
errors.append(err_msg)
logger.error(f"Import processing error: {err_msg}")
# Trigger library scan
if web_scan_manager and processed > 0:
threading.Thread(
target=lambda: web_scan_manager.request_scan("Import album processed"),
daemon=True
).start()
add_activity_item("📥", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now")
return jsonify({
'success': True,
'processed': processed,
'total': len(matches),
'errors': errors
})
except Exception as e:
logger.error(f"Error processing album import: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/import/singles/process', methods=['POST'])
def import_singles_process():
"""Process individual staging files as singles through the post-processing pipeline."""
try:
data = request.get_json()
files = data.get('files', [])
if not files:
return jsonify({'success': False, 'error': 'No files provided'}), 400
processed = 0
errors = []
for file_info in files:
file_path = file_info.get('full_path', '')
if not os.path.isfile(file_path):
errors.append(f"File not found: {file_info.get('filename', '?')}")
continue
title = file_info.get('title', '')
artist = file_info.get('artist', '')
# Fallback to filename parsing if no metadata
if not title:
parsed = _parse_filename_metadata(file_info.get('filename', ''))
title = parsed.get('title', os.path.splitext(file_info.get('filename', 'Unknown'))[0])
if not artist:
artist = parsed.get('artist', '')
# Search Spotify for rich metadata
spotify_track_data = None
spotify_artist_data = None
spotify_album_data = None
if title:
try:
search_q = f"{title} {artist}" if artist else title
tracks = spotify_client.search_tracks(search_q, limit=1)
if tracks:
t = tracks[0]
spotify_track_data = {
'name': t.name,
'id': t.id,
'track_number': t.track_number if hasattr(t, 'track_number') else 1,
'disc_number': 1,
'duration_ms': t.duration_ms if hasattr(t, 'duration_ms') else 0,
'artists': [{'name': a} for a in (t.artists if hasattr(t, 'artists') else [artist])],
'uri': f"spotify:track:{t.id}"
}
# Get album info from the track's album
if hasattr(t, 'album_id') and t.album_id:
sp_album = spotify_client.get_album(t.album_id)
if sp_album:
spotify_album_data = {
'id': t.album_id,
'name': sp_album.get('name', ''),
'release_date': sp_album.get('release_date', ''),
'total_tracks': sp_album.get('total_tracks', 1),
'image_url': (sp_album.get('images', [{}])[0].get('url') if sp_album.get('images') else '')
}
# Get artist genres
sp_artists = sp_album.get('artists', [])
if sp_artists:
spotify_artist_data = {
'name': sp_artists[0].get('name', artist),
'id': sp_artists[0].get('id', ''),
'genres': []
}
try:
sp_a = spotify_client.sp.artist(sp_artists[0]['id']) if hasattr(spotify_client, 'sp') and spotify_client.sp else None
if sp_a:
spotify_artist_data['genres'] = sp_a.get('genres', [])
except Exception:
pass
# Fallback artist data from track
if not spotify_artist_data:
track_artists = t.artists if hasattr(t, 'artists') else [artist]
spotify_artist_data = {
'name': track_artists[0] if track_artists else artist,
'id': '',
'genres': []
}
# Fallback album data
if not spotify_album_data:
spotify_album_data = {
'id': '',
'name': t.album if hasattr(t, 'album') else '',
'release_date': '',
'total_tracks': 1,
'image_url': t.image_url if hasattr(t, 'image_url') else ''
}
except Exception as sp_err:
logger.warning(f"Spotify lookup failed for '{title}': {sp_err}")
# Build context — use Spotify data if found, else use file metadata
if not spotify_artist_data:
spotify_artist_data = {'name': artist or 'Unknown Artist', 'id': '', 'genres': []}
if not spotify_album_data:
spotify_album_data = {'id': '', 'name': '', 'release_date': '', 'total_tracks': 1, 'image_url': ''}
if not spotify_track_data:
spotify_track_data = {
'name': title, 'id': '', 'track_number': 1, 'disc_number': 1,
'duration_ms': 0, 'artists': [{'name': artist or 'Unknown Artist'}], 'uri': ''
}
final_title = spotify_track_data.get('name', title)
final_artist = spotify_artist_data.get('name', artist)
final_album = spotify_album_data.get('name', '')
context_key = f"import_single_{uuid.uuid4().hex[:8]}"
context = {
'spotify_artist': spotify_artist_data,
'spotify_album': spotify_album_data,
'track_info': spotify_track_data,
'original_search_result': {
'title': final_title,
'artist': final_artist,
'album': final_album,
'track_number': spotify_track_data.get('track_number', 1),
'disc_number': 1,
'spotify_clean_title': final_title,
'spotify_clean_album': final_album,
'artists': spotify_track_data.get('artists', [{'name': final_artist}])
},
'is_album_download': False,
'has_clean_spotify_data': bool(spotify_track_data.get('id')),
'has_full_spotify_metadata': bool(spotify_track_data.get('id'))
}
try:
_post_process_matched_download(context_key, context, file_path)
processed += 1
logger.info(f"Import single processed: {final_title} by {final_artist}")
except Exception as proc_err:
err_msg = f"{title}: {str(proc_err)}"
errors.append(err_msg)
logger.error(f"Import single processing error: {err_msg}")
# Trigger library scan
if web_scan_manager and processed > 0:
threading.Thread(
target=lambda: web_scan_manager.request_scan("Import singles processed"),
daemon=True
).start()
add_activity_item("📥", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now")
return jsonify({
'success': True,
'processed': processed,
'total': len(files),
'errors': errors
})
except Exception as e:
logger.error(f"Error processing singles import: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# ================================================================================================
# END IMPORT / STAGING SYSTEM
# ================================================================================================
if __name__ == '__main__':
# Initialize logging for web server
from utils.logging_config import setup_logging

View file

@ -195,6 +195,7 @@
</div>
</div>
</div>
<button class="header-button import-button" id="import-button" onclick="openImportModal()">📥 Import</button>
<button class="header-button watchlist-button" id="watchlist-button">👁️ Watchlist
(0)</button>
<button class="header-button wishlist-button" id="wishlist-button">🎵 Wishlist (0)</button>
@ -2797,6 +2798,14 @@
</div>
</div>
<div class="form-group">
<label>Import Staging Dir:</label>
<div class="path-input-group">
<input type="text" id="staging-path" placeholder="./Staging">
<button class="browse-button" onclick="browsePath('staging')">Browse</button>
</div>
</div>
<div class="form-group">
<label>Download Source:</label>
<select id="download-source-mode" class="form-select"
@ -3270,6 +3279,96 @@
</div>
</div>
<!-- Import Modal -->
<div class="modal-overlay hidden" id="import-modal-overlay" onclick="if(event.target===this)closeImportModal()">
<div class="import-modal" id="import-modal">
<div class="import-modal-header">
<h2 class="import-modal-title">Import Music</h2>
<div class="import-tab-bar">
<button class="import-tab-btn active" id="import-tab-album-btn" onclick="switchImportTab('album')">Album</button>
<button class="import-tab-btn" id="import-tab-singles-btn" onclick="switchImportTab('singles')">Singles</button>
</div>
<span class="import-modal-close" onclick="closeImportModal()">&times;</span>
</div>
<div class="import-modal-body">
<!-- Album Tab -->
<div class="import-tab-content active" id="import-tab-album">
<!-- Search state -->
<div id="import-album-search-section">
<div class="import-suggestions-section" id="import-suggestions-section">
<div class="import-suggestions-label">Suggested from your staging folder</div>
<div class="import-album-grid" id="import-suggestions-grid"></div>
</div>
<div class="import-search-bar">
<input type="text" id="import-album-search-input" placeholder="Search for an album..." onkeydown="if(event.key==='Enter')searchImportAlbum()">
<button class="import-search-btn" onclick="searchImportAlbum()">Search</button>
</div>
<div class="import-album-grid" id="import-album-results"></div>
</div>
<!-- Match state (hidden initially) -->
<div id="import-album-match-section" class="hidden">
<div class="import-album-hero" id="import-album-hero"></div>
<div class="import-match-header">
<h3>Track Matching</h3>
<button class="import-back-btn" onclick="resetImportAlbumSearch()">← Back to Search</button>
</div>
<div class="import-match-list" id="import-match-list"></div>
<div class="import-match-footer">
<div class="import-match-stats" id="import-match-stats"></div>
<button class="import-process-btn" id="import-album-process-btn" onclick="processImportAlbum()">Process Album</button>
</div>
</div>
<!-- Progress state -->
<div id="import-album-progress-section" class="hidden">
<div class="import-progress-container">
<div class="import-progress-text" id="import-album-progress-text">Processing...</div>
<div class="import-progress-bar" id="import-album-progress-bar">
<div class="import-progress-fill" id="import-album-progress-fill"></div>
</div>
<div class="import-progress-result" id="import-album-progress-result"></div>
<div class="import-progress-actions hidden" id="import-album-progress-actions">
<button class="import-secondary-btn" onclick="resetImportAlbumSearch()">Import Another</button>
<button class="import-process-btn" onclick="closeImportModal()">Done</button>
</div>
</div>
</div>
</div>
<!-- Singles Tab -->
<div class="import-tab-content" id="import-tab-singles">
<div id="import-singles-controls">
<div class="import-singles-header">
<div class="import-singles-info">
<span id="import-staging-path-display"></span>
</div>
<div class="import-singles-actions">
<button class="import-action-btn" onclick="loadStagingFiles()">Refresh</button>
<button class="import-action-btn" onclick="selectAllStagingFiles()">Select All</button>
<button class="import-process-btn" id="import-singles-process-btn" onclick="processImportSingles()" disabled>Process Selected (0)</button>
</div>
</div>
</div>
<div class="import-singles-list" id="import-singles-list">
<div class="import-singles-empty">Click "Refresh" to scan staging folder for audio files</div>
</div>
<!-- Singles Progress -->
<div id="import-singles-progress-section" class="hidden">
<div class="import-progress-container">
<div class="import-progress-text" id="import-singles-progress-text">Processing...</div>
<div class="import-progress-bar" id="import-singles-progress-bar">
<div class="import-progress-fill" id="import-singles-progress-fill"></div>
</div>
<div class="import-progress-result" id="import-singles-progress-result"></div>
<div class="import-progress-actions hidden" id="import-singles-progress-actions">
<button class="import-secondary-btn" onclick="resetImportSingles()">Import More</button>
<button class="import-process-btn" onclick="closeImportModal()">Done</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add to Wishlist Modal -->
<div class="modal-overlay hidden" id="add-to-wishlist-modal-overlay">
<div class="add-to-wishlist-modal" id="add-to-wishlist-modal">

View file

@ -1835,4 +1835,36 @@
opacity: 0.7;
transition: opacity 0.1s ease;
}
/* Import Modal - Mobile */
.import-modal {
width: 100vw;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
height: 100vh;
}
.import-album-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.import-singles-header {
flex-direction: column;
align-items: flex-start;
}
.import-singles-actions {
width: 100%;
flex-wrap: wrap;
}
.import-match-item {
flex-wrap: wrap;
}
.import-match-file {
text-align: left;
flex-basis: 100%;
}
.import-album-hero {
flex-direction: column;
text-align: center;
}
}

View file

@ -1733,6 +1733,7 @@ async function loadSettingsData() {
// Populate Download settings (right column)
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
document.getElementById('staging-path').value = settings.import?.staging_path || './Staging';
// Populate Download Source settings
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
@ -2154,6 +2155,9 @@ async function saveSettings() {
},
playlist_sync: {
create_backup: document.getElementById('create-backup').checked
},
import: {
staging_path: document.getElementById('staging-path').value || './Staging'
}
};
@ -34475,3 +34479,407 @@ if (document.readyState === 'loading') {
console.log('✅ MusicBrainz UI initialized');
}
}
// ===================================================================
// IMPORT / STAGING SYSTEM
// ===================================================================
let currentImportAlbumData = null;
let currentImportStagingFiles = [];
let selectedStagingFiles = new Set();
function openImportModal() {
document.getElementById('import-modal-overlay').classList.remove('hidden');
// Always refresh suggestions when opening — staging path or contents may have changed
loadImportSuggestions();
}
function closeImportModal() {
document.getElementById('import-modal-overlay').classList.add('hidden');
}
function switchImportTab(tab) {
// Toggle tab buttons
document.getElementById('import-tab-album-btn').classList.toggle('active', tab === 'album');
document.getElementById('import-tab-singles-btn').classList.toggle('active', tab === 'singles');
// Toggle tab content
document.getElementById('import-tab-album').classList.toggle('active', tab === 'album');
document.getElementById('import-tab-singles').classList.toggle('active', tab === 'singles');
// Auto-load staging files when switching to singles tab
if (tab === 'singles' && currentImportStagingFiles.length === 0) {
loadStagingFiles();
}
}
// --- Album Tab ---
async function loadImportSuggestions() {
const section = document.getElementById('import-suggestions-section');
const grid = document.getElementById('import-suggestions-grid');
grid.innerHTML = '';
section.classList.add('hidden');
try {
const resp = await fetch('/api/import/staging/suggestions');
const data = await resp.json();
if (!data.success || !data.suggestions || data.suggestions.length === 0) return;
section.classList.remove('hidden');
grid.innerHTML = data.suggestions.map(a => `
<div class="import-album-card" onclick="selectImportAlbum('${a.id}')">
<img src="${a.image_url || '/static/placeholder.png'}" alt="${a.name}" onerror="this.src='/static/placeholder.png'">
<div class="import-album-card-info">
<div class="import-album-card-title" title="${a.name}">${a.name}</div>
<div class="import-album-card-artist" title="${a.artist}">${a.artist}</div>
<div class="import-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0,4) : ''}</div>
</div>
</div>
`).join('');
} catch (err) {
// Silently fail - suggestions are optional
console.warn('Failed to load import suggestions:', err);
}
}
async function searchImportAlbum() {
const query = document.getElementById('import-album-search-input').value.trim();
if (!query) return;
// Hide suggestions once user searches manually
document.getElementById('import-suggestions-section').classList.add('hidden');
const grid = document.getElementById('import-album-results');
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.length) {
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found</div>';
return;
}
grid.innerHTML = data.albums.map(a => `
<div class="import-album-card" onclick="selectImportAlbum('${a.id}')">
<img src="${a.image_url || '/static/placeholder.png'}" alt="${a.name}" onerror="this.src='/static/placeholder.png'">
<div class="import-album-card-info">
<div class="import-album-card-title" title="${a.name}">${a.name}</div>
<div class="import-album-card-artist" title="${a.artist}">${a.artist}</div>
<div class="import-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0,4) : ''}</div>
</div>
</div>
`).join('');
} catch (err) {
grid.innerHTML = `<div style="color:#ef4444;text-align:center;padding:20px;">Error: ${err.message}</div>`;
}
}
async function selectImportAlbum(albumId) {
// Show match section, hide search
document.getElementById('import-album-search-section').classList.add('hidden');
document.getElementById('import-album-match-section').classList.remove('hidden');
document.getElementById('import-album-progress-section').classList.add('hidden');
const matchList = document.getElementById('import-match-list');
matchList.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">Matching files to tracklist...</div>';
try {
const resp = await fetch('/api/import/album/match', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({album_id: albumId})
});
const data = await resp.json();
if (!data.success) {
matchList.innerHTML = `<div style="color:#ef4444;padding:20px;">Error: ${data.error}</div>`;
return;
}
currentImportAlbumData = data;
// Render hero
const album = data.album;
document.getElementById('import-album-hero').innerHTML = `
<img src="${album.image_url || '/static/placeholder.png'}" alt="${album.name}" onerror="this.src='/static/placeholder.png'">
<div class="import-album-hero-info">
<h3>${album.name}</h3>
<p>${album.artist} · ${album.total_tracks} tracks · ${album.release_date ? album.release_date.substring(0,4) : ''}</p>
</div>
`;
// Render match list
const matchedCount = data.matches.filter(m => m.staging_file).length;
matchList.innerHTML = data.matches.map(m => {
const hasFile = m.staging_file !== null;
const confClass = m.confidence >= 0.7 ? 'high' : m.confidence >= 0.5 ? 'medium' : 'low';
return `
<div class="import-match-item ${hasFile ? 'matched' : 'unmatched'}">
<span class="import-match-number">${m.spotify_track.track_number}</span>
<span class="import-match-track">${m.spotify_track.name}</span>
<span class="import-match-arrow">${hasFile ? '←' : '✗'}</span>
<span class="import-match-file">${hasFile ? m.staging_file.filename : 'No match'}</span>
${hasFile ? `<span class="import-match-confidence ${confClass}">${Math.round(m.confidence * 100)}%</span>` : ''}
</div>
`;
}).join('');
// Stats
document.getElementById('import-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`;
const processBtn = document.getElementById('import-album-process-btn');
processBtn.disabled = matchedCount === 0;
processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`;
} catch (err) {
matchList.innerHTML = `<div style="color:#ef4444;padding:20px;">Error: ${err.message}</div>`;
}
}
function resetImportAlbumSearch() {
currentImportAlbumData = null;
document.getElementById('import-album-search-section').classList.remove('hidden');
document.getElementById('import-album-match-section').classList.add('hidden');
document.getElementById('import-album-progress-section').classList.add('hidden');
// Reset progress bar state
const progressFill = document.getElementById('import-album-progress-fill');
const progressBar = document.getElementById('import-album-progress-bar');
progressFill.style.width = '0%';
progressFill.classList.remove('processing');
progressBar.classList.remove('processing');
document.getElementById('import-album-progress-actions').classList.add('hidden');
// Re-show suggestions if they were loaded
const sugGrid = document.getElementById('import-suggestions-grid');
if (sugGrid && sugGrid.children.length > 0) {
document.getElementById('import-suggestions-section').classList.remove('hidden');
}
// Refresh suggestions since files may have changed
loadImportSuggestions();
// Clear search results
document.getElementById('import-album-results').innerHTML = '';
document.getElementById('import-album-search-input').value = '';
}
async function processImportAlbum() {
if (!currentImportAlbumData) return;
const matched = currentImportAlbumData.matches.filter(m => m.staging_file);
if (matched.length === 0) return;
// Show progress
document.getElementById('import-album-match-section').classList.add('hidden');
document.getElementById('import-album-progress-section').classList.remove('hidden');
const progressText = document.getElementById('import-album-progress-text');
const progressBar = document.getElementById('import-album-progress-bar');
const progressFill = document.getElementById('import-album-progress-fill');
const progressResult = document.getElementById('import-album-progress-result');
const progressActions = document.getElementById('import-album-progress-actions');
const total = matched.length;
let processed = 0;
let errors = [];
progressFill.style.width = '0%';
progressFill.classList.remove('processing');
progressBar.classList.remove('processing');
progressResult.textContent = '';
progressActions.classList.add('hidden');
// Process one track at a time for real progress
for (let i = 0; i < total; i++) {
const trackName = matched[i].spotify_track?.name || `Track ${i + 1}`;
progressText.textContent = `Processing ${i + 1}/${total}: ${trackName}`;
progressFill.style.width = `${Math.round((i / total) * 100)}%`;
try {
const resp = await fetch('/api/import/album/process', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
album: currentImportAlbumData.album,
matches: [matched[i]]
})
});
const data = await resp.json();
if (data.success) {
processed += data.processed;
}
if (data.errors && data.errors.length > 0) {
errors.push(...data.errors);
}
} catch (err) {
errors.push(`${trackName}: ${err.message}`);
}
// Update bar after each track
progressFill.style.width = `${Math.round(((i + 1) / total) * 100)}%`;
}
// Done
progressFill.style.width = '100%';
if (processed === total) {
progressText.textContent = 'Import Complete';
} else {
progressText.textContent = `Import Complete (${processed}/${total})`;
}
progressResult.innerHTML = `<span style="color:#1ed760">${processed}/${total} tracks processed successfully</span>`;
if (errors.length > 0) {
progressResult.innerHTML += `<br><span style="color:#ef4444;font-size:12px">${errors.length} error(s): ${errors.join(', ')}</span>`;
}
progressActions.classList.remove('hidden');
}
// --- Singles Tab ---
async function loadStagingFiles() {
const list = document.getElementById('import-singles-list');
list.innerHTML = '<div class="import-singles-empty">Scanning staging folder...</div>';
selectedStagingFiles.clear();
updateSinglesProcessButton();
try {
const resp = await fetch('/api/import/staging/files');
const data = await resp.json();
if (!data.success) {
list.innerHTML = `<div class="import-singles-empty" style="color:#ef4444">Error: ${data.error}</div>`;
return;
}
// Show staging path
const pathDisplay = document.getElementById('import-staging-path-display');
if (pathDisplay) pathDisplay.textContent = `Staging: ${data.staging_path}`;
currentImportStagingFiles = data.files;
if (data.files.length === 0) {
list.innerHTML = '<div class="import-singles-empty">No audio files found in staging folder</div>';
return;
}
list.innerHTML = data.files.map((f, i) => `
<div class="import-single-item ${selectedStagingFiles.has(i) ? 'selected' : ''}" onclick="toggleStagingFile(${i})">
<input type="checkbox" ${selectedStagingFiles.has(i) ? 'checked' : ''} onclick="event.stopPropagation(); toggleStagingFile(${i})">
<div class="import-single-info">
<div class="import-single-title">${f.title || f.filename}</div>
<div class="import-single-artist">${f.artist || 'Unknown Artist'}${f.album ? ' · ' + f.album : ''}</div>
</div>
<span class="import-single-ext">${f.extension}</span>
</div>
`).join('');
} catch (err) {
list.innerHTML = `<div class="import-singles-empty" style="color:#ef4444">Error: ${err.message}</div>`;
}
}
function toggleStagingFile(idx) {
if (selectedStagingFiles.has(idx)) {
selectedStagingFiles.delete(idx);
} else {
selectedStagingFiles.add(idx);
}
// Update UI
const items = document.querySelectorAll('#import-singles-list .import-single-item');
items.forEach((item, i) => {
item.classList.toggle('selected', selectedStagingFiles.has(i));
const cb = item.querySelector('input[type="checkbox"]');
if (cb) cb.checked = selectedStagingFiles.has(i);
});
updateSinglesProcessButton();
}
function selectAllStagingFiles() {
if (selectedStagingFiles.size === currentImportStagingFiles.length) {
// Deselect all
selectedStagingFiles.clear();
} else {
// Select all
currentImportStagingFiles.forEach((_, i) => selectedStagingFiles.add(i));
}
// Update UI
const items = document.querySelectorAll('#import-singles-list .import-single-item');
items.forEach((item, i) => {
item.classList.toggle('selected', selectedStagingFiles.has(i));
const cb = item.querySelector('input[type="checkbox"]');
if (cb) cb.checked = selectedStagingFiles.has(i);
});
updateSinglesProcessButton();
}
function updateSinglesProcessButton() {
const btn = document.getElementById('import-singles-process-btn');
const count = selectedStagingFiles.size;
btn.textContent = `Process Selected (${count})`;
btn.disabled = count === 0;
}
async function processImportSingles() {
if (selectedStagingFiles.size === 0) return;
const filesToProcess = Array.from(selectedStagingFiles).map(i => currentImportStagingFiles[i]);
// Show progress
document.getElementById('import-singles-progress-section').classList.remove('hidden');
const progressText = document.getElementById('import-singles-progress-text');
const progressBar = document.getElementById('import-singles-progress-bar');
const progressFill = document.getElementById('import-singles-progress-fill');
const progressResult = document.getElementById('import-singles-progress-result');
const progressActions = document.getElementById('import-singles-progress-actions');
const total = filesToProcess.length;
let processed = 0;
let errors = [];
progressFill.style.width = '0%';
progressFill.classList.remove('processing');
progressBar.classList.remove('processing');
progressResult.textContent = '';
progressActions.classList.add('hidden');
// Process one file at a time for real progress
for (let i = 0; i < total; i++) {
const fileName = filesToProcess[i].title || filesToProcess[i].filename || `File ${i + 1}`;
progressText.textContent = `Processing ${i + 1}/${total}: ${fileName}`;
progressFill.style.width = `${Math.round((i / total) * 100)}%`;
try {
const resp = await fetch('/api/import/singles/process', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({files: [filesToProcess[i]]})
});
const data = await resp.json();
if (data.success) {
processed += data.processed;
}
if (data.errors && data.errors.length > 0) {
errors.push(...data.errors);
}
} catch (err) {
errors.push(`${fileName}: ${err.message}`);
}
progressFill.style.width = `${Math.round(((i + 1) / total) * 100)}%`;
}
// Done
progressFill.style.width = '100%';
selectedStagingFiles.clear();
updateSinglesProcessButton();
if (processed === total) {
progressText.textContent = 'Import Complete';
} else {
progressText.textContent = `Import Complete (${processed}/${total})`;
}
progressResult.innerHTML = `<span style="color:#1ed760">${processed}/${total} tracks processed successfully</span>`;
if (errors.length > 0) {
progressResult.innerHTML += `<br><span style="color:#ef4444;font-size:12px">${errors.length} error(s): ${errors.join(', ')}</span>`;
}
progressActions.classList.remove('hidden');
// Pre-refresh the file list in the background so it's ready when user clicks "Import More"
loadStagingFiles();
// Also refresh album suggestions since staging contents changed
loadImportSuggestions();
}
function resetImportSingles() {
document.getElementById('import-singles-progress-section').classList.add('hidden');
loadStagingFiles();
}

View file

@ -21785,3 +21785,668 @@ body {
background: #d466a9;
opacity: 1 !important;
}
/* ============================= */
/* IMPORT MODAL */
/* ============================= */
.import-button {
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%) !important;
color: #fff !important;
}
.import-button:hover {
background: linear-gradient(135deg, #a855f7 0%, #c084fc 100%) !important;
}
/* Modal container — matches wishlist/playlist modal vibe */
.import-modal {
width: 900px;
max-width: 90vw;
max-height: 85vh;
border-radius: 20px;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.95) 0%,
rgba(12, 12, 12, 0.98) 100%);
backdrop-filter: blur(20px) saturate(1.2);
border: 1px solid rgba(255, 255, 255, 0.12);
border-top: 1px solid rgba(255, 255, 255, 0.18);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.6),
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 40px rgba(124, 58, 237, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
/* Header */
.import-modal-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 28px;
background: linear-gradient(135deg,
rgba(45, 45, 45, 0.8) 0%,
rgba(26, 26, 26, 0.9) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.import-modal-title {
font-size: 20px;
font-weight: 700;
color: #fff;
margin: 0;
white-space: nowrap;
}
.import-modal-close {
margin-left: auto;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
color: #b3b3b3;
font-size: 22px;
cursor: pointer;
transition: all 0.3s ease;
line-height: 1;
}
.import-modal-close:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Tabs */
.import-tab-bar {
display: flex;
gap: 4px;
background: rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.import-tab-btn {
padding: 7px 20px;
border: none;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.import-tab-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.import-tab-btn.active {
background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);
color: #000;
box-shadow: 0 2px 8px rgba(29, 185, 84, 0.3);
}
/* Tab content */
.import-tab-content {
display: none;
flex-direction: column;
height: 100%;
}
.import-tab-content.active {
display: flex;
}
/* Body */
.import-modal-body {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
min-height: 0;
background: #121212;
}
/* Scrollbar — green tint matching other modals */
.import-modal-body::-webkit-scrollbar { width: 8px; }
.import-modal-body::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 4px; }
.import-modal-body::-webkit-scrollbar-thumb { background: rgba(29,185,84,0.3); border-radius: 4px; }
.import-modal-body::-webkit-scrollbar-thumb:hover { background: rgba(29,185,84,0.5); }
.import-match-list::-webkit-scrollbar { width: 6px; }
.import-match-list::-webkit-scrollbar-track { background: rgba(255,255,255,0.03); border-radius: 3px; }
.import-match-list::-webkit-scrollbar-thumb { background: rgba(29,185,84,0.25); border-radius: 3px; }
.import-singles-list::-webkit-scrollbar { width: 6px; }
.import-singles-list::-webkit-scrollbar-track { background: rgba(255,255,255,0.03); border-radius: 3px; }
.import-singles-list::-webkit-scrollbar-thumb { background: rgba(29,185,84,0.25); border-radius: 3px; }
/* Suggestions */
.import-suggestions-section {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.import-suggestions-section:empty,
.import-suggestions-section.hidden {
display: none;
}
.import-suggestions-label {
font-size: 11px;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
/* Search bar */
.import-search-bar {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.import-search-bar input {
flex: 1;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: #fff;
font-size: 14px;
outline: none;
transition: all 0.3s ease;
}
.import-search-bar input:focus {
border-color: rgba(29, 185, 84, 0.5);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.1);
}
.import-search-bar input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.import-search-btn {
padding: 12px 24px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);
color: #000;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(29, 185, 84, 0.3);
}
.import-search-btn:hover {
background: linear-gradient(135deg, #1ed760 0%, #22e167 100%);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3);
}
/* Album grid */
.import-album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 14px;
}
.import-album-card {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.import-album-card:hover {
border-color: rgba(29, 185, 84, 0.4);
background: linear-gradient(135deg,
rgba(29, 185, 84, 0.08) 0%,
rgba(29, 185, 84, 0.03) 100%);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 20px rgba(29, 185, 84, 0.08);
}
.import-album-card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.import-album-card-info {
padding: 10px 12px;
}
.import-album-card-title {
font-size: 13px;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.import-album-card-artist {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 3px;
}
.import-album-card-meta {
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
margin-top: 4px;
}
/* Album hero — blurred background style */
.import-album-hero {
display: flex;
gap: 20px;
align-items: center;
padding: 20px;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.06) 0%,
rgba(255, 255, 255, 0.02) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
margin-bottom: 20px;
position: relative;
overflow: hidden;
}
.import-album-hero img {
width: 90px;
height: 90px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
position: relative;
z-index: 1;
}
.import-album-hero-info {
position: relative;
z-index: 1;
}
.import-album-hero-info h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.import-album-hero-info p {
margin: 6px 0 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
}
/* Match header */
.import-match-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.import-match-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #fff;
}
.import-back-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
padding: 8px 16px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.import-back-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
}
/* Match list */
.import-match-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 380px;
overflow-y: auto;
padding-right: 4px;
}
.import-match-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
border-left: 3px solid #333;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.import-match-item:hover {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.03) 100%);
transform: translateX(4px);
}
.import-match-item.matched {
border-left-color: #1db954;
}
.import-match-item.matched:hover {
border-color: rgba(29, 185, 84, 0.2);
border-left-color: #1db954;
}
.import-match-item.unmatched {
border-left-color: #ef4444;
opacity: 0.5;
}
.import-match-number {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.3);
min-width: 24px;
text-align: center;
}
.import-match-track {
flex: 1;
font-size: 13px;
font-weight: 500;
color: #fff;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.import-match-arrow {
color: rgba(255, 255, 255, 0.2);
font-size: 14px;
flex-shrink: 0;
}
.import-match-file {
flex: 1;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
}
.import-match-confidence {
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 10px;
flex-shrink: 0;
}
.import-match-confidence.high {
background: rgba(29, 185, 84, 0.15);
color: #1ed760;
border: 1px solid rgba(29, 185, 84, 0.2);
}
.import-match-confidence.medium {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
border: 1px solid rgba(255, 193, 7, 0.2);
}
.import-match-confidence.low {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* Match footer */
.import-match-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
padding: 20px 0 0;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.import-match-stats {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
}
/* Buttons — matching wishlist/watchlist pattern */
.import-process-btn {
padding: 12px 28px;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);
color: #fff;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(29, 185, 84, 0.3);
min-width: 120px;
}
.import-process-btn:hover {
background: linear-gradient(135deg, #1ed760 0%, #22e167 100%);
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(29, 185, 84, 0.4);
}
.import-process-btn:active {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(29, 185, 84, 0.3);
}
.import-process-btn:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.3);
cursor: not-allowed;
transform: none;
box-shadow: none;
border-color: transparent;
}
.import-secondary-btn {
padding: 12px 24px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.15);
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 120px;
}
.import-secondary-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
border-color: rgba(255, 255, 255, 0.25);
}
/* Singles */
.import-singles-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-wrap: wrap;
}
.import-singles-info {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
.import-singles-actions {
display: flex;
gap: 8px;
align-items: center;
}
.import-action-btn {
padding: 8px 18px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.import-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
border-color: rgba(255, 255, 255, 0.2);
}
.import-singles-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 450px;
overflow-y: auto;
padding-right: 4px;
}
.import-singles-empty {
text-align: center;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
padding: 60px 20px;
}
.import-single-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.import-single-item:hover {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.03) 100%);
border-color: rgba(255, 255, 255, 0.1);
transform: translateX(4px);
}
.import-single-item.selected {
background: linear-gradient(135deg,
rgba(29, 185, 84, 0.1) 0%,
rgba(29, 185, 84, 0.04) 100%);
border-color: rgba(29, 185, 84, 0.3);
}
.import-single-item input[type="checkbox"] {
accent-color: #1db954;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
}
.import-single-info {
flex: 1;
min-width: 0;
}
.import-single-title {
font-size: 14px;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.import-single-artist {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
margin-top: 3px;
}
.import-single-ext {
font-size: 10px;
color: rgba(255, 255, 255, 0.25);
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.5px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
flex-shrink: 0;
}
/* Progress */
.import-progress-container {
padding: 50px 28px;
text-align: center;
}
.import-progress-text {
font-size: 16px;
font-weight: 600;
color: #fff;
margin-bottom: 20px;
}
.import-progress-bar {
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
margin-bottom: 20px;
position: relative;
}
.import-progress-fill {
height: 100%;
background: linear-gradient(90deg, #1db954 0%, #1ed760 100%);
border-radius: 3px;
width: 0%;
transition: width 0.3s ease;
}
/* Animated pulse while processing */
.import-progress-fill.processing {
animation: import-progress-pulse 1.8s ease-in-out infinite;
}
@keyframes import-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Striped animation while processing */
.import-progress-bar.processing .import-progress-fill {
background: repeating-linear-gradient(
-45deg,
#1db954 0px,
#1db954 10px,
#1ed760 10px,
#1ed760 20px
);
background-size: 28px 28px;
animation: import-progress-stripes 0.8s linear infinite;
}
@keyframes import-progress-stripes {
0% { background-position: 0 0; }
100% { background-position: 28px 0; }
}
.import-progress-result {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
}
.import-progress-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
}