From 2f8bad23a8e49fcfd34747918732af2f543db2cd Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 3 Oct 2025 10:48:25 -0700 Subject: [PATCH] Add manual track matching and Docker permission docs Implements manual track matching (discovery fix modal) for YouTube, Tidal, and Beatport platforms, allowing users to search and select Spotify tracks for unmatched results. Adds backend endpoints and frontend logic for updating matches, improves conversion of discovery results for sync/download, and updates Dockerfile/entrypoint for dynamic PUID/PGID/UMASK support. Includes a new DOCKER_PERMISSIONS.md guide. --- DOCKER_PERMISSIONS.md | 238 ++++++++++++++ Dockerfile | 15 +- entrypoint.sh | 57 ++++ web_server.py | 295 ++++++++++++++++- webui/index.html | 3 +- webui/static/script.js | 707 +++++++++++++++++++++++++++++++++++++---- webui/static/style.css | 325 ++++++++++++++++++- 7 files changed, 1562 insertions(+), 78 deletions(-) create mode 100644 DOCKER_PERMISSIONS.md create mode 100644 entrypoint.sh diff --git a/DOCKER_PERMISSIONS.md b/DOCKER_PERMISSIONS.md new file mode 100644 index 0000000..83bebb7 --- /dev/null +++ b/DOCKER_PERMISSIONS.md @@ -0,0 +1,238 @@ +# Docker Permissions Guide + +## Understanding PUID/PGID/UMASK + +SoulSync supports dynamic user/group ID configuration to ensure files are created with the correct ownership, especially important when sharing files with other containers like Lidarr, Sonarr, or Plex. + +### What are PUID and PGID? + +- **PUID** (Process User ID): The numeric user ID the container runs as +- **PGID** (Process Group ID): The numeric group ID the container runs as +- **UMASK**: Controls default permissions for newly created files/directories + +## Quick Start + +### Finding Your IDs + +On your host system, run: +```bash +id your_username +``` + +Example output: +``` +uid=1000(myuser) gid=1000(myuser) groups=1000(myuser),999(docker) +``` + +Your PUID is `1000` and PGID is `1000`. + +### Matching Lidarr/Sonarr/Plex + +If you're using SoulSync with Lidarr, check Lidarr's docker-compose to see what PUID/PGID it uses: + +```yaml +# Your Lidarr container +services: + lidarr: + environment: + - PUID=99 + - PGID=100 +``` + +Then set SoulSync to match: + +```yaml +# Your SoulSync container +services: + soulsync: + environment: + - PUID=99 # Match Lidarr + - PGID=100 # Match Lidarr + - UMASK=002 # Allows group write permissions +``` + +## Common Scenarios + +### Scenario 1: Sharing with Lidarr (Unraid) + +**Problem**: Lidarr can't import files downloaded by SoulSync + +**Solution**: Match the PUID/PGID +```yaml +services: + soulsync: + environment: + - PUID=99 # Common Unraid user + - PGID=100 # Common Unraid group + - UMASK=002 +``` + +### Scenario 2: Single User System + +**Problem**: Default 1000:1000 works but want to match your user + +**Solution**: Use your user's IDs (run `id` command) +```yaml +services: + soulsync: + environment: + - PUID=1000 # Your user ID + - PGID=1000 # Your group ID + - UMASK=022 +``` + +### Scenario 3: Multiple Containers Sharing Files + +**Problem**: Multiple containers (Plex, Lidarr, SoulSync) need access to same files + +**Solution**: All containers should use same PUID/PGID +```yaml +services: + plex: + environment: + - PUID=1000 + - PGID=1000 + + lidarr: + environment: + - PUID=1000 + - PGID=1000 + + soulsync: + environment: + - PUID=1000 + - PGID=1000 + - UMASK=002 # Allows all containers to write +``` + +## UMASK Values Explained + +UMASK controls default permissions: + +- **022**: Files: `644` (rw-r--r--), Directories: `755` (rwxr-xr-x) + - Owner: read/write + - Group: read only + - Others: read only + - **Use when**: Only you need to modify files + +- **002**: Files: `664` (rw-rw-r--), Directories: `775` (rwxrwxr-x) + - Owner: read/write + - Group: read/write + - Others: read only + - **Use when**: Multiple containers share the same group and need write access + +- **000**: Files: `666` (rw-rw-rw-), Directories: `777` (rwxrwxrwx) + - Everyone: full access + - **Use when**: Not recommended (security risk) + +## Troubleshooting + +### Permission Denied Errors + +**Symptom**: +``` +Permission denied: Access to the path '/music/Artist/Album/track.flac' is denied. +``` + +**Diagnosis**: +1. Check file ownership: + ```bash + ls -la /path/to/music/Artist/Album/ + ``` + +2. Check what user Lidarr runs as: + ```bash + docker exec lidarr id + ``` + +3. Check what user SoulSync runs as: + ```bash + docker exec soulsync-webui id + ``` + +**Fix**: Ensure both containers use the same PUID/PGID + +### Files Created with Wrong Owner + +**Symptom**: Files show as `1000:1000` even though you set `PUID=99 PGID=100` + +**Cause**: Container needs to be rebuilt after changing PUID/PGID + +**Fix**: +```bash +docker-compose down +docker-compose up -d +``` + +### Existing Files Have Wrong Permissions + +**Symptom**: Old files created before changing PUID/PGID have wrong ownership + +**Fix**: Manually fix ownership: +```bash +# Find your download directory +docker exec soulsync-webui ls -la /app/downloads + +# Fix ownership (run on host, not in container) +sudo chown -R 99:100 /path/to/downloads +``` + +## Advanced: Custom Entrypoint + +The container uses `/entrypoint.sh` to handle PUID/PGID. When the container starts: + +1. Reads `PUID`, `PGID`, `UMASK` environment variables +2. Changes the internal `soulsync` user to match those IDs +3. Fixes permissions on app directories +4. Starts Python app as that user + +You can verify this by checking container logs: +```bash +docker logs soulsync-webui +``` + +Look for: +``` +🐳 SoulSync Container Starting... +📝 User Configuration: + PUID: 99 + PGID: 100 + UMASK: 002 +🔧 Adjusting user permissions... + Changing group ID from 1000 to 100 + Changing user ID from 1000 to 99 +🚀 Starting SoulSync Web Server... +``` + +## Example docker-compose.yml + +```yaml +version: '3.8' + +services: + soulsync: + image: boulderbadgedad/soulsync:latest + container_name: soulsync-webui + environment: + # Match these to your Lidarr/Plex/other containers + - PUID=99 + - PGID=100 + - UMASK=002 + - TZ=America/New_York + ports: + - "8008:8008" + volumes: + - ./config:/app/config + - ./logs:/app/logs + - ./downloads:/app/downloads + - /mnt/user/Music:/music:rw # Shared music library + restart: unless-stopped +``` + +## Need Help? + +If you're still experiencing permission issues: +1. Check container logs: `docker logs soulsync-webui` +2. Verify PUID/PGID: `docker exec soulsync-webui id` +3. Check file permissions: `docker exec soulsync-webui ls -la /app/downloads` +4. Open an issue on GitHub with the output of these commands diff --git a/Dockerfile b/Dockerfile index 62a4b95..fe236f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y \ libffi-dev \ libssl-dev \ curl \ + gosu \ && rm -rf /var/lib/apt/lists/* # Create non-root user for security @@ -37,8 +38,12 @@ RUN cp /app/config/config.example.json /app/config/config.json && \ # Create volume mount points VOLUME ["/app/config", "/app/database", "/app/logs", "/app/downloads", "/app/Transfer"] -# Switch to non-root user -USER soulsync +# Copy and set up entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Note: Don't switch to soulsync user yet - entrypoint needs root to change UIDs +# The entrypoint script will switch to soulsync after setting up permissions # Expose port EXPOSE 8008 @@ -51,6 +56,10 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ ENV PYTHONPATH=/app ENV FLASK_APP=web_server.py ENV FLASK_ENV=production +ENV PUID=1000 +ENV PGID=1000 +ENV UMASK=022 -# Run the web server +# Set entrypoint and default command +ENTRYPOINT ["/entrypoint.sh"] CMD ["python", "web_server.py"] \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..fe8faf2 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# SoulSync Docker Entrypoint Script +# Handles PUID/PGID/UMASK configuration for proper file permissions + +set -e + +# Default values +PUID=${PUID:-1000} +PGID=${PGID:-1000} +UMASK=${UMASK:-022} + +echo "🐳 SoulSync Container Starting..." +echo "📝 User Configuration:" +echo " PUID: $PUID" +echo " PGID: $PGID" +echo " UMASK: $UMASK" + +# Get current soulsync user/group IDs +CURRENT_UID=$(id -u soulsync) +CURRENT_GID=$(id -g soulsync) + +# Only modify user/group if they differ from requested values +if [ "$CURRENT_UID" != "$PUID" ] || [ "$CURRENT_GID" != "$PGID" ]; then + echo "🔧 Adjusting user permissions..." + + # Modify group ID if needed + if [ "$CURRENT_GID" != "$PGID" ]; then + echo " Changing group ID from $CURRENT_GID to $PGID" + groupmod -o -g "$PGID" soulsync + fi + + # Modify user ID if needed + if [ "$CURRENT_UID" != "$PUID" ]; then + echo " Changing user ID from $CURRENT_UID to $PUID" + usermod -o -u "$PUID" soulsync + fi + + # Fix ownership of app directories + echo "🔒 Fixing permissions on app directories..." + chown -R soulsync:soulsync /app/config /app/database /app/logs /app/downloads /app/Transfer 2>/dev/null || true +else + echo "✅ User/Group IDs already correct" +fi + +# Set umask for file creation permissions +echo "🎭 Setting UMASK to $UMASK" +umask "$UMASK" + +# Display final user info +echo "👤 Running as:" +echo " User: $(id -u soulsync):$(id -g soulsync) ($(id -un soulsync):$(id -gn soulsync))" +echo " UMASK: $(umask)" +echo "" +echo "🚀 Starting SoulSync Web Server..." + +# Execute the main command as the soulsync user +exec gosu soulsync "$@" diff --git a/web_server.py b/web_server.py index 0632b15..d3e4046 100644 --- a/web_server.py +++ b/web_server.py @@ -10165,8 +10165,40 @@ def get_playlist_tracks(playlist_id): return jsonify({"error": str(e)}), 500 +@app.route('/api/spotify/search_tracks', methods=['GET']) +def search_spotify_tracks(): + """Search for tracks on Spotify - used by discovery fix modal""" + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"error": "Spotify not authenticated."}), 401 + + try: + query = request.args.get('query', '').strip() + limit = int(request.args.get('limit', 20)) + + if not query: + return jsonify({"error": "Query parameter is required"}), 400 + + # Search using spotify_client + tracks = spotify_client.search_tracks(query, limit=limit) + + # Convert tracks to dict format + tracks_dict = [{ + 'id': t.id, + 'name': t.name, + 'artists': t.artists, + 'album': t.album, + 'duration_ms': t.duration_ms + } for t in tracks] + + return jsonify({'tracks': tracks_dict}) + + except Exception as e: + print(f"❌ Error searching Spotify tracks: {e}") + return jsonify({"error": str(e)}), 500 + + # =================================================================== -# TIDAL PLAYLIST API ENDPOINTS +# TIDAL PLAYLIST API ENDPOINTS # =================================================================== @app.route('/api/tidal/playlists', methods=['GET']) @@ -10368,11 +10400,78 @@ def get_tidal_discovery_status(playlist_id): } return jsonify(response) - + except Exception as e: print(f"❌ Error getting Tidal discovery status: {e}") return jsonify({"error": str(e)}), 500 + +@app.route('/api/tidal/discovery/update_match', methods=['POST']) +def update_tidal_discovery_match(): + """Update a Tidal discovery result with manually selected Spotify track""" + try: + data = request.get_json() + identifier = data.get('identifier') # playlist_id + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return jsonify({'error': 'Missing required fields'}), 400 + + # Get the state + state = tidal_discovery_states.get(identifier) + + if not state: + return jsonify({'error': 'Discovery state not found'}), 404 + + if track_index >= len(state['discovery_results']): + return jsonify({'error': 'Invalid track index'}), 400 + + # Update the result + result = state['discovery_results'][track_index] + old_status = result.get('status') + + # Update with user-selected track + result['status'] = '✅ Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = ', '.join(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else spotify_track['artists'] + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] + + # Format duration (Tidal doesn't show duration in table, but store it anyway) + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' + + # IMPORTANT: Also set spotify_data for sync/download compatibility + result['spotify_data'] = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': spotify_track['artists'], + 'album': spotify_track['album'] + } + + result['manual_match'] = True # Flag for tracking + + # Update match count if status changed from not found/error + if old_status != 'found' and old_status != '✅ Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + + print(f"✅ Manual match updated: tidal - {identifier} - track {track_index}") + print(f" → {result['spotify_artist']} - {result['spotify_track']}") + + return jsonify({'success': True, 'result': result}) + + except Exception as e: + print(f"❌ Error updating Tidal discovery match: {e}") + return jsonify({'error': str(e)}), 500 + + @app.route('/api/tidal/playlists/states', methods=['GET']) def get_tidal_playlist_states(): """Get all stored Tidal playlist discovery states for frontend hydration (similar to YouTube playlists)""" @@ -10727,21 +10826,32 @@ def _search_spotify_for_tidal_track(tidal_track): def convert_tidal_results_to_spotify_tracks(discovery_results): """Convert Tidal discovery results to Spotify tracks format for sync""" spotify_tracks = [] - + for result in discovery_results: + # Support both data formats: spotify_data (manual fixes) and individual fields (automatic discovery) if result.get('spotify_data'): spotify_data = result['spotify_data'] - + # Create track object matching the expected format track = { 'id': spotify_data['id'], 'name': spotify_data['name'], 'artists': spotify_data['artists'], 'album': spotify_data['album'], - 'duration_ms': spotify_data['duration_ms'] + 'duration_ms': spotify_data.get('duration_ms', 0) } spotify_tracks.append(track) - + elif result.get('spotify_track') and result.get('status_class') == 'found': + # Build from individual fields (automatic discovery format) + track = { + 'id': result.get('spotify_id', 'unknown'), + 'name': result.get('spotify_track', 'Unknown Track'), + 'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'], + 'album': result.get('spotify_album', 'Unknown Album'), + 'duration_ms': 0 + } + spotify_tracks.append(track) + print(f"🔄 Converted {len(spotify_tracks)} Tidal matches to Spotify tracks for sync") return spotify_tracks @@ -10833,11 +10943,13 @@ def get_tidal_sync_status(playlist_id): state['phase'] = 'sync_complete' state['sync_progress'] = sync_state.get('progress', {}) # Add activity for sync completion - playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist') + playlist = state.get('playlist') + playlist_name = playlist.name if playlist and hasattr(playlist, 'name') else 'Unknown Playlist' add_activity_item("🔄", "Sync Complete", f"Tidal playlist '{playlist_name}' synced successfully", "Now") elif sync_state.get('status') == 'error': state['phase'] = 'discovered' # Revert on error - playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist') + playlist = state.get('playlist') + playlist_name = playlist.name if playlist and hasattr(playlist, 'name') else 'Unknown Playlist' add_activity_item("❌", "Sync Failed", f"Tidal playlist '{playlist_name}' sync failed", "Now") return jsonify(response) @@ -10999,12 +11111,78 @@ def get_youtube_discovery_status(url_hash): } return jsonify(response) - + except Exception as e: print(f"❌ Error getting YouTube discovery status: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/youtube/discovery/update_match', methods=['POST']) +def update_youtube_discovery_match(): + """Update a YouTube discovery result with manually selected Spotify track""" + try: + data = request.get_json() + identifier = data.get('identifier') # url_hash + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return jsonify({'error': 'Missing required fields'}), 400 + + # Get the state + state = youtube_playlist_states.get(identifier) + + if not state: + return jsonify({'error': 'Discovery state not found'}), 404 + + if track_index >= len(state['discovery_results']): + return jsonify({'error': 'Invalid track index'}), 400 + + # Update the result + result = state['discovery_results'][track_index] + old_status = result.get('status') + + # Update with user-selected track + result['status'] = '✅ Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = ', '.join(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else spotify_track['artists'] + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] + + # Format duration + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' + + # IMPORTANT: Also set spotify_data for sync/download compatibility + result['spotify_data'] = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': spotify_track['artists'], + 'album': spotify_track['album'] + } + + result['manual_match'] = True # Flag for tracking + + # Update match count if status changed from not found/error + if old_status != 'found' and old_status != '✅ Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + + print(f"✅ Manual match updated: youtube - {identifier} - track {track_index}") + print(f" → {result['spotify_artist']} - {result['spotify_track']}") + + return jsonify({'success': True, 'result': result}) + + except Exception as e: + print(f"❌ Error updating YouTube discovery match: {e}") + return jsonify({'error': str(e)}), 500 + + def _run_youtube_discovery_worker(url_hash): """Background worker for YouTube Spotify discovery process""" try: @@ -11518,21 +11696,32 @@ def update_youtube_playlist_phase(url_hash): def convert_youtube_results_to_spotify_tracks(discovery_results): """Convert YouTube discovery results to Spotify tracks format for sync""" spotify_tracks = [] - + for result in discovery_results: + # Support both data formats: spotify_data (manual fixes) and individual fields (automatic discovery) if result.get('spotify_data'): spotify_data = result['spotify_data'] - + # Create track object matching the expected format track = { 'id': spotify_data['id'], 'name': spotify_data['name'], 'artists': spotify_data['artists'], 'album': spotify_data['album'], - 'duration_ms': spotify_data['duration_ms'] + 'duration_ms': spotify_data.get('duration_ms', 0) } spotify_tracks.append(track) - + elif result.get('spotify_track') and result.get('status_class') == 'found': + # Build from individual fields (automatic discovery format) + track = { + 'id': result.get('spotify_id', 'unknown'), + 'name': result.get('spotify_track', 'Unknown Track'), + 'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'], + 'album': result.get('spotify_album', 'Unknown Album'), + 'duration_ms': 0 + } + spotify_tracks.append(track) + print(f"🔄 Converted {len(spotify_tracks)} YouTube matches to Spotify tracks for sync") return spotify_tracks @@ -11729,7 +11918,8 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json): with sync_lock: sync_states[playlist_id] = { "status": "finished", - "result": result.__dict__ # Convert dataclass to dict + "progress": result.__dict__, # Store result as progress for status endpoint compatibility + "result": result.__dict__ # Keep result for backward compatibility } print(f"🏁 Sync finished for {playlist_id} - state updated") @@ -14079,6 +14269,73 @@ def get_beatport_discovery_status(url_hash): logger.error(f"❌ Error getting Beatport discovery status: {e}") return jsonify({"error": str(e)}), 500 + +@app.route('/api/beatport/discovery/update_match', methods=['POST']) +def update_beatport_discovery_match(): + """Update a Beatport discovery result with manually selected Spotify track""" + try: + data = request.get_json() + identifier = data.get('identifier') # url_hash + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return jsonify({'error': 'Missing required fields'}), 400 + + # Get the state + state = beatport_chart_states.get(identifier) + + if not state: + return jsonify({'error': 'Discovery state not found'}), 404 + + if track_index >= len(state['discovery_results']): + return jsonify({'error': 'Invalid track index'}), 400 + + # Update the result + result = state['discovery_results'][track_index] + old_status = result.get('status') + + # Update with user-selected track + result['status'] = '✅ Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = ', '.join(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else spotify_track['artists'] + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] + + # Format duration (Beatport doesn't show duration in table, but store it anyway) + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' + + # IMPORTANT: Also set spotify_data for sync/download compatibility + result['spotify_data'] = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': spotify_track['artists'], + 'album': spotify_track['album'] + } + + result['manual_match'] = True # Flag for tracking + + # Update match count if status changed from not found/error + if old_status != 'found' and old_status != '✅ Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + + logger.info(f"✅ Manual match updated: beatport - {identifier} - track {track_index}") + logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") + + return jsonify({'success': True, 'result': result}) + + except Exception as e: + logger.error(f"❌ Error updating Beatport discovery match: {e}") + return jsonify({'error': str(e)}), 500 + + def clean_beatport_text(text): """Clean Beatport track/artist text for proper spacing""" if not text: @@ -14599,6 +14856,7 @@ def convert_beatport_results_to_spotify_tracks(discovery_results): spotify_tracks = [] for result in discovery_results: + # Support both data formats: spotify_data (manual fixes) and individual fields (automatic discovery) if result.get('spotify_data'): spotify_data = result['spotify_data'] @@ -14616,6 +14874,15 @@ def convert_beatport_results_to_spotify_tracks(discovery_results): 'album': spotify_data['album'], 'source': 'beatport' }) + elif result.get('spotify_track') and result.get('status_class') == 'found': + # Build from individual fields (automatic discovery format) + spotify_tracks.append({ + 'id': result.get('spotify_id', 'unknown'), + 'name': result.get('spotify_track', 'Unknown Track'), + 'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'], + 'album': result.get('spotify_album', 'Unknown Album'), + 'source': 'beatport' + }) return spotify_tracks diff --git a/webui/index.html b/webui/index.html index 04c6322..6ce35d5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1869,7 +1869,8 @@ - + + + + + `; @@ -13425,10 +13955,14 @@ function getInitialProgressText(phase, isTidal = false, isBeatport = false) { function generateTableRowsFromState(state, urlHash) { const isTidal = state.is_tidal_playlist; const isBeatport = state.is_beatport_playlist; - - if (state.discoveryResults && state.discoveryResults.length > 0) { + const platform = isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube'); + + // Support both camelCase and snake_case + const discoveryResults = state.discoveryResults || state.discovery_results; + + if (discoveryResults && discoveryResults.length > 0) { // Generate rows from existing discovery results - return state.discoveryResults.map((result, index) => ` + return discoveryResults.map((result, index) => ` ${result.yt_track} ${result.yt_artist} @@ -13436,7 +13970,7 @@ function generateTableRowsFromState(state, urlHash) { ${result.spotify_track || '-'} ${result.spotify_artist || '-'} ${result.spotify_album || '-'} - ${(isTidal || isBeatport) ? '' : `${result.duration}`} + ${generateDiscoveryActionButton(result, urlHash, platform)} `).join(''); } else { @@ -13454,7 +13988,7 @@ function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatp - - - - ${(isTidal || isBeatport) ? '' : `${formatDuration(track.duration_ms)}`} + - `).join(''); } @@ -13466,6 +14000,44 @@ function formatDuration(durationMs) { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } +/** + * Generate action button for discovery table row + */ +function generateDiscoveryActionButton(result, identifier, platform) { + // Show fix button for not_found, error, or any non-found status + const isNotFound = result.status === 'not_found' || + result.status_class === 'not-found' || + result.status === '❌ Not Found' || + result.status === 'Not Found'; + + const isError = result.status === 'error' || + result.status_class === 'error' || + result.status === '❌ Error'; + + const isFound = result.status === 'found' || + result.status_class === 'found' || + result.status === '✅ Found'; + + if (isNotFound || isError) { + return ``; + } + + // For found matches, show optional re-match button + if (isFound) { + return ``; + } + + return '-'; +} + function updateYouTubeDiscoveryModal(urlHash, status) { const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`); const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`); @@ -13489,18 +14061,26 @@ function updateYouTubeDiscoveryModal(urlHash, status) { status.results.forEach(result => { const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`); if (!row) return; - + const statusCell = row.querySelector('.discovery-status'); const spotifyTrackCell = row.querySelector('.spotify-track'); const spotifyArtistCell = row.querySelector('.spotify-artist'); const spotifyAlbumCell = row.querySelector('.spotify-album'); - + const actionsCell = row.querySelector('.discovery-actions'); + statusCell.textContent = result.status; statusCell.className = `discovery-status ${result.status_class}`; - + spotifyTrackCell.textContent = result.spotify_track || '-'; spotifyArtistCell.textContent = result.spotify_artist || '-'; spotifyAlbumCell.textContent = result.spotify_album || '-'; + + // Update actions cell with appropriate button + if (actionsCell) { + const state = youtubePlaylistStates[urlHash]; + const platform = state?.is_tidal_playlist ? 'tidal' : (state?.is_beatport_playlist ? 'beatport' : 'youtube'); + actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform); + } }); // Update action buttons if discovery is complete (progress = 100%) @@ -13860,17 +14440,32 @@ function updateYouTubeModalButtons(urlHash, phase) { async function startYouTubeDownloadMissing(urlHash) { try { console.log('🔍 Starting download missing tracks for YouTube playlist:', urlHash); - + const state = youtubePlaylistStates[urlHash]; - if (!state || !state.discoveryResults) { + // Support both camelCase and snake_case + const discoveryResults = state?.discoveryResults || state?.discovery_results; + + if (!state || !discoveryResults) { showToast('No discovery results available for download', 'error'); return; } - + // Convert YouTube results to a format compatible with the download modal - const spotifyTracks = state.discoveryResults - .filter(result => result.spotify_data) - .map(result => result.spotify_data); + const spotifyTracks = discoveryResults + .filter(result => result.spotify_data || (result.spotify_track && result.status_class === 'found')) + .map(result => { + if (result.spotify_data) { + return result.spotify_data; + } else { + // Build from individual fields (automatic discovery format) + return { + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: result.spotify_album || 'Unknown Album' + }; + } + }); if (spotifyTracks.length === 0) { showToast('No Spotify matches found for download', 'error'); @@ -14362,7 +14957,7 @@ function displayArtistsResults(query, results) { */ function createArtistCardHTML(artist) { const imageUrl = artist.image_url || ''; - const genres = artist.genres && artist.genres.length > 0 ? + const genres = artist.genres && artist.genres.length > 0 ? artist.genres.slice(0, 3).join(', ') : 'Various genres'; const popularity = artist.popularity || 0; diff --git a/webui/static/style.css b/webui/static/style.css index 993a13e..ed52423 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -8527,10 +8527,6 @@ body { background: #1ed760; } -.hidden { - display: none !important; -} - /* Download Complete Styling - Ready for Review State */ .playlist-card.download-complete { /* Add subtle green left border to indicate completion */ @@ -15093,3 +15089,324 @@ body { padding: 15px 0; } } + + +/* ============================================================================ + DISCOVERY FIX MODAL STYLING + ============================================================================ */ + +/* Fix modal overlay - nested inside discovery modal */ +.discovery-fix-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; /* Above parent modal content */ + transition: opacity 0.2s ease; +} + +.discovery-fix-modal-overlay.hidden { + display: none; +} + +.discovery-fix-modal { + background: linear-gradient(135deg, rgba(20, 20, 20, 0.98) 0%, rgba(12, 12, 12, 0.99) 100%); + backdrop-filter: blur(20px); + border-radius: 16px; + padding: 0; + width: 700px; + max-width: 90vw; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), + 0 0 1px rgba(255, 255, 255, 0.1) inset; + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; +} + +.discovery-fix-modal-header { + background: linear-gradient(135deg, rgba(29, 185, 84, 0.15) 0%, rgba(29, 185, 84, 0.05) 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding: 20px 24px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.discovery-fix-modal-header h2 { + font-size: 20px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + margin: 0; +} + +.discovery-fix-modal-content { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +.source-track-info { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; +} + +.source-track-info h3 { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.source-track-display { + display: flex; + flex-direction: column; + gap: 8px; +} + +.source-field { + display: flex; + align-items: baseline; + gap: 12px; +} + +.source-field label { + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.5); + min-width: 60px; +} + +.source-field span { + font-size: 14px; + color: rgba(255, 255, 255, 0.9); + font-weight: 500; +} + +.search-inputs-section { + margin-bottom: 24px; +} + +.search-inputs-section h3 { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.search-input-group { + display: flex; + gap: 12px; + align-items: stretch; +} + +.fix-modal-input { + flex: 1; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px 14px; + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-family: inherit; + transition: all 0.2s ease; +} + +.fix-modal-input:focus { + outline: none; + background: rgba(255, 255, 255, 0.08); + border-color: rgba(29, 185, 84, 0.5); + box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.1); +} + +.fix-modal-input::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.search-btn { + background: linear-gradient(135deg, rgba(29, 185, 84, 0.9) 0%, rgba(29, 185, 84, 0.7) 100%); + border: 1px solid rgba(29, 185, 84, 0.3); + border-radius: 8px; + padding: 10px 20px; + color: white; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.search-btn:hover { + background: linear-gradient(135deg, rgba(29, 185, 84, 1) 0%, rgba(29, 185, 84, 0.8) 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(29, 185, 84, 0.3); +} + +.search-btn:active { + transform: translateY(0); +} + +.search-results-section h3 { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.fix-modal-results { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 400px; + overflow-y: auto; +} + +.fix-result-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 14px 16px; + cursor: pointer; + transition: all 0.2s ease; +} + +.fix-result-card:hover { + background: rgba(29, 185, 84, 0.1); + border-color: rgba(29, 185, 84, 0.3); + transform: translateX(4px); +} + +.fix-result-card-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.fix-result-title { + font-size: 15px; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); +} + +.fix-result-artist { + font-size: 13px; + color: rgba(255, 255, 255, 0.7); +} + +.fix-result-album { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.fix-result-duration { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} + +.discovery-fix-modal-footer { + background: rgba(255, 255, 255, 0.02); + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding: 16px 24px; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.modal-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; + font-family: inherit; +} + +.modal-btn.secondary { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.7); +} + +.modal-btn.secondary:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +/* Action buttons in discovery table */ +.discovery-actions { + text-align: center; +} + +.fix-match-btn, +.rematch-btn { + background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(29, 185, 84, 0.1) 100%); + border: 1px solid rgba(29, 185, 84, 0.3); + border-radius: 6px; + padding: 6px 12px; + color: rgba(29, 185, 84, 0.95); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.fix-match-btn:hover, +.rematch-btn:hover { + background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(29, 185, 84, 0.2) 100%); + border-color: rgba(29, 185, 84, 0.5); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2); +} + +.fix-match-btn:active, +.rematch-btn:active { + transform: translateY(0); +} + +.rematch-btn { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%); + border-color: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.7); + padding: 4px 10px; + font-size: 16px; +} + +.rematch-btn:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.08) 100%); + border-color: rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.9); +} + +/* Loading and error states */ +.fix-modal-results .loading, +.fix-modal-results .error-message, +.fix-modal-results .no-results { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; +} + +.fix-modal-results .error-message { + color: rgba(239, 68, 68, 0.9); +}