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.
pull/49/head
Broque Thomas 5 months ago
parent 0715c41745
commit 2f8bad23a8

@ -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

@ -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"]

@ -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 "$@"

@ -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

@ -1869,7 +1869,8 @@
</div>
</div>
</div>
<!-- Version Info Modal -->
<div class="version-modal-overlay hidden" id="version-modal-overlay" onclick="closeVersionModal()">
<div class="version-modal" onclick="event.stopPropagation()">

@ -6698,7 +6698,418 @@ const additionalStyles = `
// Inject additional styles
document.head.insertAdjacentHTML('beforeend', additionalStyles);
// ============================================================================
// DISCOVERY FIX MODAL - Manual Track Matching
// ============================================================================
// Global state for discovery fix
let currentDiscoveryFix = {
platform: null, // 'youtube', 'tidal', 'beatport'
identifier: null, // url_hash or playlist_id
trackIndex: null,
sourceTrack: null,
sourceArtist: null
};
// Store event handler reference to allow proper removal
let discoveryFixEnterHandler = null;
/**
* Open discovery fix modal for a specific track
*/
function openDiscoveryFixModal(platform, identifier, trackIndex) {
console.log(`🔧 Opening fix modal: ${platform} - ${identifier} - track ${trackIndex}`);
// Get the discovery state
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
let state, result;
if (platform === 'youtube') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'tidal') {
state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure
} else if (platform === 'beatport') {
state = youtubePlaylistStates[identifier]; // Beatport uses YouTube state infrastructure
}
// Support both camelCase and snake_case for discovery results
const results = state?.discoveryResults || state?.discovery_results;
result = results?.[trackIndex];
if (!result) {
console.error('❌ Track data not found');
console.error(' Platform:', platform);
console.error(' Identifier:', identifier);
console.error(' State:', state);
console.error(' Discovery results (camelCase):', state?.discoveryResults?.length);
console.error(' Discovery results (snake_case):', state?.discovery_results?.length);
showToast('Track data not found', 'error');
return;
}
console.log('✅ Found result:', result);
// Store context
currentDiscoveryFix = {
platform,
identifier,
trackIndex,
sourceTrack: result.yt_track || result.tidal_track?.name || result.beatport_track?.title,
sourceArtist: result.yt_artist || result.tidal_track?.artist || result.beatport_track?.artist
};
// Find the fix modal within the active discovery modal
const discoveryModal = document.getElementById(`youtube-discovery-modal-${identifier}`);
if (!discoveryModal) {
console.error('❌ Discovery modal not found:', identifier);
showToast('Discovery modal not found', 'error');
return;
}
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (!fixModalOverlay) {
console.error('❌ Fix modal not found within discovery modal');
showToast('Fix modal not found', 'error');
return;
}
console.log('🔍 Source track:', currentDiscoveryFix.sourceTrack);
console.log('🔍 Source artist:', currentDiscoveryFix.sourceArtist);
console.log('🔍 Fix modal overlay found:', fixModalOverlay);
// Populate modal - use document.getElementById since IDs are unique globally
const sourceTrackEl = document.getElementById('fix-modal-source-track');
const sourceArtistEl = document.getElementById('fix-modal-source-artist');
const trackInput = document.getElementById('fix-modal-track-input');
const artistInput = document.getElementById('fix-modal-artist-input');
console.log('🔍 Elements found:', {
sourceTrackEl,
sourceArtistEl,
trackInput,
artistInput
});
if (!sourceTrackEl || !sourceArtistEl || !trackInput || !artistInput) {
console.error('❌ Fix modal elements not found in DOM');
showToast('Fix modal not properly initialized', 'error');
return;
}
sourceTrackEl.textContent = currentDiscoveryFix.sourceTrack;
sourceArtistEl.textContent = currentDiscoveryFix.sourceArtist;
trackInput.value = currentDiscoveryFix.sourceTrack;
artistInput.value = currentDiscoveryFix.sourceArtist;
console.log('✅ Populated modal with:', {
track: trackInput.value,
artist: artistInput.value
});
// Remove old enter key handler if exists
if (discoveryFixEnterHandler) {
trackInput.removeEventListener('keypress', discoveryFixEnterHandler);
artistInput.removeEventListener('keypress', discoveryFixEnterHandler);
}
// Add new enter key handler
discoveryFixEnterHandler = function(e) {
if (e.key === 'Enter') searchDiscoveryFix();
};
trackInput.addEventListener('keypress', discoveryFixEnterHandler);
artistInput.addEventListener('keypress', discoveryFixEnterHandler);
// Show modal BEFORE auto-search so elements are visible
fixModalOverlay.classList.remove('hidden');
console.log('✅ Fix modal opened, starting auto-search...');
// Auto-search with initial values (after a tiny delay to ensure modal is rendered)
setTimeout(() => searchDiscoveryFix(), 100);
}
/**
* Close discovery fix modal
*/
function closeDiscoveryFixModal() {
if (!currentDiscoveryFix.identifier) {
console.warn('No active fix modal to close');
return;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`);
if (discoveryModal) {
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (fixModalOverlay) {
fixModalOverlay.classList.add('hidden');
}
}
currentDiscoveryFix = { platform: null, identifier: null, trackIndex: null, sourceTrack: null, sourceArtist: null };
}
/**
* Search for tracks in Spotify
*/
async function searchDiscoveryFix() {
if (!currentDiscoveryFix.identifier) {
console.error('No active fix modal context');
return;
}
const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`);
if (!discoveryModal) {
console.error('Discovery modal not found');
return;
}
const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay');
if (!fixModalOverlay) {
console.error('Fix modal not found');
return;
}
const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input').value.trim();
const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input').value.trim();
if (!trackInput && !artistInput) {
showToast('Enter track name or artist', 'error');
return;
}
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
resultsContainer.innerHTML = '<div class="loading">🔍 Searching Spotify...</div>';
try {
// Build search query
const query = `${artistInput} ${trackInput}`.trim();
// Call Spotify search API
const response = await fetch(`/api/spotify/search_tracks?query=${encodeURIComponent(query)}&limit=20`);
const data = await response.json();
if (data.error) {
resultsContainer.innerHTML = `<div class="error-message">❌ ${data.error}</div>`;
return;
}
if (!data.tracks || data.tracks.length === 0) {
resultsContainer.innerHTML = '<div class="no-results">No matches found. Try different search terms.</div>';
return;
}
// Render results
renderDiscoveryFixResults(data.tracks, fixModalOverlay);
} catch (error) {
console.error('Search error:', error);
resultsContainer.innerHTML = '<div class="error-message">❌ Search failed. Try again.</div>';
}
}
/**
* Render search results as clickable cards
*/
function renderDiscoveryFixResults(tracks, fixModalOverlay) {
const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results');
resultsContainer.innerHTML = '';
tracks.forEach(track => {
const card = document.createElement('div');
card.className = 'fix-result-card';
card.onclick = () => selectDiscoveryFixTrack(track);
card.innerHTML = `
<div class="fix-result-card-content">
<div class="fix-result-title">${escapeHtml(track.name || 'Unknown Track')}</div>
<div class="fix-result-artist">${escapeHtml((track.artists || ['Unknown Artist']).join(', '))}</div>
<div class="fix-result-album">${escapeHtml(track.album || 'Unknown Album')}</div>
<div class="fix-result-duration">${formatDuration(track.duration_ms || 0)}</div>
</div>
`;
resultsContainer.appendChild(card);
});
}
/**
* User selected a track - update discovery state
*/
async function selectDiscoveryFixTrack(track) {
console.log('✅ User selected track:', track);
const { platform, identifier, trackIndex } = currentDiscoveryFix;
console.log('📡 Updating backend match:', { platform, identifier, trackIndex, track });
// Update backend
try {
// Get the correct backend identifier based on platform
let backendIdentifier = identifier;
if (platform === 'tidal') {
// For Tidal, backend expects the actual playlist_id, not url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.tidal_playlist_id || identifier;
} else if (platform === 'beatport') {
// For Beatport, backend expects url_hash (same as identifier)
backendIdentifier = identifier;
}
const requestBody = {
identifier: backendIdentifier,
track_index: trackIndex,
spotify_track: {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album,
duration_ms: track.duration_ms
}
};
console.log('📡 Request body:', requestBody);
console.log('📡 Backend identifier:', backendIdentifier);
const response = await fetch(`/api/${platform}/discovery/update_match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
console.log('📡 Response status:', response.status);
const data = await response.json();
console.log('📡 Response data:', data);
if (data.error) {
showToast(`Failed to update: ${data.error}`, 'error');
console.error('❌ Backend update failed:', data.error);
return;
}
showToast('Match updated successfully!', 'success');
console.log('✅ Backend update successful');
// Update frontend state
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
let state;
if (platform === 'youtube') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'tidal') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'beatport') {
state = youtubePlaylistStates[identifier];
}
// Support both camelCase and snake_case
const results = state?.discoveryResults || state?.discovery_results;
if (state && results && results[trackIndex]) {
const result = results[trackIndex];
const wasNotFound = result.status !== 'found' && result.status_class !== 'found';
// Update result
result.status = '✅ Found';
result.status_class = 'found';
result.spotify_track = track.name;
result.spotify_artist = Array.isArray(track.artists) ? track.artists.join(', ') : track.artists;
result.spotify_album = track.album;
result.spotify_id = track.id;
result.duration = formatDuration(track.duration_ms);
result.manual_match = true;
// IMPORTANT: Also set spotify_data for download/sync compatibility
result.spotify_data = {
id: track.id,
name: track.name,
artists: track.artists,
album: track.album,
duration_ms: track.duration_ms
};
// Increment match count if this was previously not_found or error
if (wasNotFound) {
state.spotifyMatches = (state.spotifyMatches || 0) + 1;
// Update progress bar and text
const spotify_total = state.spotify_total || state.playlist?.tracks?.length || 0;
const progress = spotify_total > 0 ? Math.round((state.spotifyMatches / spotify_total) * 100) : 0;
const progressBar = document.getElementById(`youtube-discovery-progress-${identifier}`);
const progressText = document.getElementById(`youtube-discovery-progress-text-${identifier}`);
if (progressBar) {
progressBar.style.width = `${progress}%`;
}
if (progressText) {
progressText.textContent = `${state.spotifyMatches} / ${spotify_total} tracks matched (${progress}%)`;
}
console.log(`✅ Updated progress: ${state.spotifyMatches}/${spotify_total} (${progress}%)`);
}
// Update UI - refresh the table row
updateDiscoveryModalSingleRow(platform, identifier, trackIndex);
}
// Close modal
closeDiscoveryFixModal();
} catch (error) {
console.error('Error updating match:', error);
showToast('Failed to update match', 'error');
}
}
/**
* Update a single row in the discovery modal table
*/
function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) {
// Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results
const state = youtubePlaylistStates[identifier];
// Support both camelCase and snake_case
const results = state?.discoveryResults || state?.discovery_results;
if (!state || !results || !results[trackIndex]) {
console.warn(`Cannot update row: state or result not found`);
return;
}
const result = results[trackIndex];
const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`);
if (!row) {
console.warn(`Cannot update row: row element not found for ${identifier}-${trackIndex}`);
return;
}
// Update cells
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');
if (statusCell) {
statusCell.textContent = result.status;
statusCell.className = `discovery-status ${result.status_class}`;
}
if (spotifyTrackCell) spotifyTrackCell.textContent = result.spotify_track || '-';
if (spotifyArtistCell) spotifyArtistCell.textContent = result.spotify_artist || '-';
if (spotifyAlbumCell) spotifyAlbumCell.textContent = result.spotify_album || '-';
// Update action button
if (actionsCell) {
actionsCell.innerHTML = generateDiscoveryActionButton(result, identifier, platform);
}
console.log(`✅ Updated row ${trackIndex} in discovery modal`);
}
// Make functions available globally for onclick handlers
window.openDiscoveryFixModal = openDiscoveryFixModal;
window.closeDiscoveryFixModal = closeDiscoveryFixModal;
window.searchDiscoveryFix = searchDiscoveryFix;
window.openMatchingModal = openMatchingModal;
window.closeMatchingModal = closeMatchingModal;
window.selectArtist = selectArtist;
@ -8880,18 +9291,27 @@ async function openTidalDiscoveryModal(playlistId, playlistData) {
let actualMatches = 0;
if (isAlreadyDiscovered && tidalCardState.discovery_results) {
transformedResults = tidalCardState.discovery_results.map((result, index) => {
const isFound = result.status === 'found';
// Check multiple status formats
const isFound = result.status === 'found' ||
result.status === '✅ Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
if (isFound) actualMatches++;
return {
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? '✅ Found' : '❌ Not Found',
status_class: isFound ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? result.spotify_data.album : (result.spotify_album || '-'),
spotify_data: result.spotify_data, // Pass through spotify_data
spotify_id: result.spotify_id, // Pass through spotify_id
manual_match: result.manual_match // Pass through manual match flag
};
});
console.log(`🎵 Tidal modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`);
@ -8991,16 +9411,28 @@ function startTidalDiscoveryPolling(fakeUrlHash, playlistId) {
progress: status.progress,
spotify_matches: status.spotify_matches,
spotify_total: status.spotify_total,
results: status.results.map((result, index) => ({
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: result.status === 'found' ? '✅ Found' : '❌ Not Found',
status_class: result.status === 'found' ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
spotify_artist: result.spotify_data ? result.spotify_data.artists.join(', ') : '-',
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
}))
results: status.results.map((result, index) => {
const isFound = result.status === 'found' ||
result.status === '✅ Found' ||
result.status_class === 'found' ||
result.spotify_data ||
result.spotify_track;
return {
index: index,
yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown',
yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown',
status: isFound ? '✅ Found' : '❌ Not Found',
status_class: isFound ? 'found' : 'not-found',
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
(Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? result.spotify_data.album : (result.spotify_album || '-'),
spotify_data: result.spotify_data, // Pass through
spotify_id: result.spotify_id, // Pass through
manual_match: result.manual_match // Pass through
};
})
};
// Update fake YouTube state with Tidal discovery results
@ -9499,27 +9931,34 @@ function updateTidalModalButtons(urlHash, phase) {
async function startTidalDownloadMissing(urlHash) {
try {
console.log('🔍 Starting download missing tracks for Tidal playlist:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.is_tidal_playlist) {
console.error('❌ Invalid Tidal playlist state for download');
return;
}
// Get the actual Tidal playlist ID
const tidalPlaylistId = state.tidal_playlist_id;
const tidalState = tidalPlaylistStates[tidalPlaylistId];
if (!tidalState || !tidalState.discovery_results) {
// Tidal reuses youtubePlaylistStates infrastructure, so get results from there
const discoveryResults = state.discoveryResults || state.discovery_results;
if (!discoveryResults) {
showToast('No discovery results available for download', 'error');
return;
}
// Convert Tidal discovery results to Spotify tracks format (same as YouTube)
const spotifyTracks = [];
for (const result of tidalState.discovery_results) {
for (const result of discoveryResults) {
if (result.spotify_data) {
spotifyTracks.push(result.spotify_data);
} else if (result.spotify_track && result.status_class === 'found') {
// Build from individual fields (automatic discovery format)
spotifyTracks.push({
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'
});
}
}
@ -9527,13 +9966,12 @@ async function startTidalDownloadMissing(urlHash) {
showToast('No Spotify matches found for download', 'error');
return;
}
// Create a virtual playlist for the download system
const virtualPlaylistId = `tidal_${tidalPlaylistId}`;
const playlistName = `[Tidal] ${tidalState.playlist.name}`;
const virtualPlaylistId = `tidal_${state.tidal_playlist_id}`;
const playlistName = `[Tidal] ${state.playlist.name}`;
// Store reference for card navigation (same as YouTube)
tidalState.convertedSpotifyPlaylistId = virtualPlaylistId;
state.convertedSpotifyPlaylistId = virtualPlaylistId;
// Close the discovery modal if it's open (same as YouTube)
@ -10704,12 +11142,15 @@ function startBeatportDiscoveryPolling(urlHash) {
index: result.index !== undefined ? result.index : index,
yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown',
yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown',
status: result.status === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'),
status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : '-',
status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'),
status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')),
spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'),
spotify_artist: result.spotify_data && result.spotify_data.artists ?
result.spotify_data.artists.map(a => a.name || a).join(', ') : '-',
spotify_album: result.spotify_data ? result.spotify_data.album : '-'
result.spotify_data.artists.map(a => a.name || a).join(', ') : (result.spotify_artist || '-'),
spotify_album: result.spotify_data ? result.spotify_data.album : (result.spotify_album || '-'),
spotify_data: result.spotify_data, // Pass through
spotify_id: result.spotify_id, // Pass through
manual_match: result.manual_match // Pass through
}))
};
@ -11065,6 +11506,8 @@ async function handleBeatportCardClick(chartHash) {
// Also restore discovery results if available
if (fullState.discovery_results) {
youtubePlaylistStates[chartHash].discovery_results = fullState.discovery_results;
console.log(`🔄 [Hydration] Restored ${fullState.discovery_results.length} discovery results`);
console.log(`🔄 [Hydration] First result:`, fullState.discovery_results[0]);
}
// Restore discovery progress state
@ -11073,6 +11516,7 @@ async function handleBeatportCardClick(chartHash) {
}
if (fullState.spotify_matches !== undefined) {
youtubePlaylistStates[chartHash].spotify_matches = fullState.spotify_matches;
console.log(`🔄 [Hydration] Restored spotify_matches: ${fullState.spotify_matches}`);
}
if (fullState.spotify_total !== undefined) {
youtubePlaylistStates[chartHash].spotify_total = fullState.spotify_total;
@ -11496,7 +11940,10 @@ async function startBeatportDownloadMissing(urlHash) {
console.log('🔍 Starting download missing tracks for Beatport chart:', urlHash);
const state = youtubePlaylistStates[urlHash];
if (!state || !state.discovery_results) {
// 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;
}
@ -11508,10 +11955,33 @@ async function startBeatportDownloadMissing(urlHash) {
}
// Convert Beatport discovery results to Spotify tracks format (like Tidal does)
const spotifyTracks = state.discovery_results
.filter(result => result.spotify_data)
console.log(`🔍 Total discovery results: ${discoveryResults.length}`);
console.log(`🔍 First result (full object):`, JSON.stringify(discoveryResults[0], null, 2));
console.log(`🔍 Second result (full object):`, JSON.stringify(discoveryResults[1], null, 2));
console.log(`🔍 Results with spotify_data:`, discoveryResults.filter(r => r.spotify_data).length);
console.log(`🔍 Results with spotify_id:`, discoveryResults.filter(r => r.spotify_id).length);
const spotifyTracks = discoveryResults
.filter(result => {
// Accept if has spotify_data OR if has spotify_track (from automatic discovery)
return result.spotify_data || (result.spotify_track && result.status_class === 'found');
})
.map(result => {
const track = result.spotify_data;
// Use spotify_data if available, otherwise build from individual fields
let track;
if (result.spotify_data) {
track = result.spotify_data;
} else {
// Build from individual fields (automatic discovery format)
track = {
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',
duration_ms: 0
};
}
// Ensure artists is an array of strings
if (track.artists && Array.isArray(track.artists)) {
track.artists = track.artists.map(artist =>
@ -13148,12 +13618,13 @@ function openYouTubeDiscoveryModal(urlHash) {
// Check if modal already exists
let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) {
// Modal exists, just show it
modal.classList.remove('hidden');
console.log('🔄 Showing existing modal with preserved state');
console.log('🔄 Current discovery results count:', state.discoveryResults?.length || state.discovery_results?.length || 0);
// Resume polling if discovery or sync is in progress
if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) {
console.log('🔄 Resuming discovery polling...');
@ -13208,7 +13679,7 @@ function openYouTubeDiscoveryModal(urlHash) {
<th>Spotify Track</th>
<th>Spotify Artist</th>
<th>Album</th>
${(isTidal || isBeatport) ? '' : '<th>Duration</th>'}
<th>Actions</th>
</tr>
</thead>
<tbody id="youtube-discovery-table-${urlHash}">
@ -13226,6 +13697,65 @@ function openYouTubeDiscoveryModal(urlHash) {
<button class="modal-btn modal-btn-secondary" onclick="closeYouTubeDiscoveryModal('${urlHash}')">🏠 Close</button>
</div>
</div>
<!-- Discovery Fix Modal (nested inside) -->
<div class="discovery-fix-modal-overlay hidden" id="discovery-fix-modal-overlay">
<div class="discovery-fix-modal">
<div class="discovery-fix-modal-header">
<h2>Fix Track Match</h2>
<button class="modal-close-btn" onclick="closeDiscoveryFixModal()"></button>
</div>
<div class="discovery-fix-modal-content">
<!-- Source track info (read-only) -->
<div class="source-track-info">
<h3>Source Track</h3>
<div class="source-track-display">
<div class="source-field">
<label>Track:</label>
<span id="fix-modal-source-track">-</span>
</div>
<div class="source-field">
<label>Artist:</label>
<span id="fix-modal-source-artist">-</span>
</div>
</div>
</div>
<!-- Search inputs (editable) -->
<div class="search-inputs-section">
<h3>Search for Match</h3>
<div class="search-input-group">
<input type="text"
id="fix-modal-track-input"
placeholder="Track name"
class="fix-modal-input">
<input type="text"
id="fix-modal-artist-input"
placeholder="Artist name"
class="fix-modal-input">
<button class="search-btn" onclick="searchDiscoveryFix()">
🔍 Search
</button>
</div>
</div>
<!-- Search results -->
<div class="search-results-section">
<h3>Results</h3>
<div id="fix-modal-results" class="fix-modal-results">
<!-- Auto-populated on modal open, updated on search -->
</div>
</div>
</div>
<div class="discovery-fix-modal-footer">
<button class="modal-btn secondary" onclick="closeDiscoveryFixModal()">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
`;
@ -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) => `
<tr id="discovery-row-${urlHash}-${result.index}">
<td class="yt-track">${result.yt_track}</td>
<td class="yt-artist">${result.yt_artist}</td>
@ -13436,7 +13970,7 @@ function generateTableRowsFromState(state, urlHash) {
<td class="spotify-track">${result.spotify_track || '-'}</td>
<td class="spotify-artist">${result.spotify_artist || '-'}</td>
<td class="spotify-album">${result.spotify_album || '-'}</td>
${(isTidal || isBeatport) ? '' : `<td class="duration">${result.duration}</td>`}
<td class="discovery-actions">${generateDiscoveryActionButton(result, urlHash, platform)}</td>
</tr>
`).join('');
} else {
@ -13454,7 +13988,7 @@ function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatp
<td class="spotify-track">-</td>
<td class="spotify-artist">-</td>
<td class="spotify-album">-</td>
${(isTidal || isBeatport) ? '' : `<td class="duration">${formatDuration(track.duration_ms)}</td>`}
<td class="discovery-actions">-</td>
</tr>
`).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 `<button class="fix-match-btn"
onclick="openDiscoveryFixModal('${platform}', '${identifier}', ${result.index})"
title="Manually search for this track">
🔧 Fix
</button>`;
}
// For found matches, show optional re-match button
if (isFound) {
return `<button class="rematch-btn"
onclick="openDiscoveryFixModal('${platform}', '${identifier}', ${result.index})"
title="Change this match">
</button>`;
}
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;

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

Loading…
Cancel
Save