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:
parent
fa2c6e2a1c
commit
d674b999e5
7 changed files with 1840 additions and 1 deletions
|
|
@ -219,6 +219,9 @@ class ConfigManager:
|
|||
},
|
||||
"settings": {
|
||||
"audio_quality": "flac"
|
||||
},
|
||||
"import": {
|
||||
"staging_path": "./Staging"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
633
web_server.py
633
web_server.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()">×</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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue