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.
This commit is contained in:
parent
0715c41745
commit
2f8bad23a8
7 changed files with 1562 additions and 78 deletions
238
DOCKER_PERMISSIONS.md
Normal file
238
DOCKER_PERMISSIONS.md
Normal file
|
|
@ -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
|
||||
15
Dockerfile
15
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"]
|
||||
57
entrypoint.sh
Normal file
57
entrypoint.sh
Normal file
|
|
@ -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 "$@"
|
||||
295
web_server.py
295
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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…
Reference in a new issue