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); +}