Add a library management interface to the artist detail page with inline metadata editing, per-service manual matching, bulk operations, and full track/album management.
This commit is contained in:
parent
7b933ff97a
commit
7c50f350c0
6 changed files with 3978 additions and 19 deletions
|
|
@ -6068,6 +6068,197 @@ class MusicDatabase:
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ==================== Enhanced Library Management Methods ====================
|
||||||
|
|
||||||
|
# Field whitelists for safe updates
|
||||||
|
ARTIST_EDITABLE_FIELDS = {'name', 'genres', 'summary', 'style', 'mood', 'label'}
|
||||||
|
ALBUM_EDITABLE_FIELDS = {'title', 'year', 'genres', 'style', 'mood', 'label', 'explicit', 'record_type', 'track_count'}
|
||||||
|
TRACK_EDITABLE_FIELDS = {'title', 'track_number', 'bpm', 'explicit', 'style', 'mood'}
|
||||||
|
|
||||||
|
def get_artist_full_detail(self, artist_id) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get complete artist information with ALL columns, all albums with ALL columns,
|
||||||
|
and all tracks per album with ALL columns. For the enhanced library management view.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get artist with all columns
|
||||||
|
cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,))
|
||||||
|
artist_row = cursor.fetchone()
|
||||||
|
if not artist_row:
|
||||||
|
return {'success': False, 'error': f'Artist with ID {artist_id} not found'}
|
||||||
|
|
||||||
|
artist_name = artist_row['name']
|
||||||
|
server_source = artist_row['server_source']
|
||||||
|
|
||||||
|
# Parse artist data
|
||||||
|
artist_data = dict(artist_row)
|
||||||
|
# Parse genres JSON
|
||||||
|
if artist_data.get('genres'):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(artist_data['genres'])
|
||||||
|
artist_data['genres'] = parsed if isinstance(parsed, list) else [str(parsed)]
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
artist_data['genres'] = [g.strip() for g in artist_data['genres'].split(',') if g.strip()]
|
||||||
|
else:
|
||||||
|
artist_data['genres'] = []
|
||||||
|
|
||||||
|
# Get all album IDs for this artist (including same-name artists on same server)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM artists
|
||||||
|
WHERE name = ? AND server_source = ?
|
||||||
|
""", (artist_name, server_source))
|
||||||
|
artist_ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Get all albums with all columns
|
||||||
|
placeholders = ','.join('?' * len(artist_ids))
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT * FROM albums
|
||||||
|
WHERE artist_id IN ({placeholders})
|
||||||
|
ORDER BY year DESC, title
|
||||||
|
""", artist_ids)
|
||||||
|
album_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
albums = []
|
||||||
|
for album_row in album_rows:
|
||||||
|
album_data = dict(album_row)
|
||||||
|
# Parse album genres
|
||||||
|
if album_data.get('genres'):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(album_data['genres'])
|
||||||
|
album_data['genres'] = parsed if isinstance(parsed, list) else [str(parsed)]
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
album_data['genres'] = [g.strip() for g in album_data['genres'].split(',') if g.strip()]
|
||||||
|
else:
|
||||||
|
album_data['genres'] = []
|
||||||
|
|
||||||
|
# Get all tracks for this album with all columns
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM tracks
|
||||||
|
WHERE album_id = ?
|
||||||
|
ORDER BY track_number, title
|
||||||
|
""", (album_data['id'],))
|
||||||
|
track_rows = cursor.fetchall()
|
||||||
|
album_data['tracks'] = [dict(tr) for tr in track_rows]
|
||||||
|
|
||||||
|
# Determine record type from data if not set
|
||||||
|
if not album_data.get('record_type'):
|
||||||
|
track_count = len(album_data['tracks']) or album_data.get('track_count') or 0
|
||||||
|
title_lower = (album_data.get('title') or '').lower()
|
||||||
|
if any(ind in title_lower for ind in ['single', ' - single', '(single)']) and track_count <= 3:
|
||||||
|
album_data['record_type'] = 'single'
|
||||||
|
elif any(ind in title_lower for ind in ['ep', ' - ep', '(ep)', 'extended play']) or (4 <= track_count <= 7):
|
||||||
|
album_data['record_type'] = 'ep'
|
||||||
|
else:
|
||||||
|
album_data['record_type'] = 'album'
|
||||||
|
|
||||||
|
albums.append(album_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'artist': artist_data,
|
||||||
|
'albums': albums
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting artist full detail for ID {artist_id}: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def update_artist_fields(self, artist_id, updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Update artist metadata fields. Only whitelisted fields are accepted."""
|
||||||
|
valid_updates = {k: v for k, v in updates.items() if k in self.ARTIST_EDITABLE_FIELDS}
|
||||||
|
if not valid_updates:
|
||||||
|
return {'success': False, 'error': 'No valid fields to update'}
|
||||||
|
|
||||||
|
# Serialize genres to JSON if present
|
||||||
|
if 'genres' in valid_updates:
|
||||||
|
if isinstance(valid_updates['genres'], list):
|
||||||
|
valid_updates['genres'] = json.dumps(valid_updates['genres'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
|
||||||
|
values = list(valid_updates.values()) + [artist_id]
|
||||||
|
cursor.execute(f"UPDATE artists SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values)
|
||||||
|
conn.commit()
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return {'success': False, 'error': f'Artist {artist_id} not found'}
|
||||||
|
return {'success': True, 'updated_fields': list(valid_updates.keys())}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating artist {artist_id}: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def update_album_fields(self, album_id, updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Update album metadata fields. Only whitelisted fields are accepted."""
|
||||||
|
valid_updates = {k: v for k, v in updates.items() if k in self.ALBUM_EDITABLE_FIELDS}
|
||||||
|
if not valid_updates:
|
||||||
|
return {'success': False, 'error': 'No valid fields to update'}
|
||||||
|
|
||||||
|
if 'genres' in valid_updates:
|
||||||
|
if isinstance(valid_updates['genres'], list):
|
||||||
|
valid_updates['genres'] = json.dumps(valid_updates['genres'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
|
||||||
|
values = list(valid_updates.values()) + [album_id]
|
||||||
|
cursor.execute(f"UPDATE albums SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values)
|
||||||
|
conn.commit()
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return {'success': False, 'error': f'Album {album_id} not found'}
|
||||||
|
return {'success': True, 'updated_fields': list(valid_updates.keys())}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating album {album_id}: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def update_track_fields(self, track_id, updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Update track metadata fields. Only whitelisted fields are accepted."""
|
||||||
|
valid_updates = {k: v for k, v in updates.items() if k in self.TRACK_EDITABLE_FIELDS}
|
||||||
|
if not valid_updates:
|
||||||
|
return {'success': False, 'error': 'No valid fields to update'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
|
||||||
|
values = list(valid_updates.values()) + [track_id]
|
||||||
|
cursor.execute(f"UPDATE tracks SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values)
|
||||||
|
conn.commit()
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return {'success': False, 'error': f'Track {track_id} not found'}
|
||||||
|
return {'success': True, 'updated_fields': list(valid_updates.keys())}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating track {track_id}: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def batch_update_tracks(self, track_ids: List[str], updates: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Batch update multiple tracks with the same field values."""
|
||||||
|
valid_updates = {k: v for k, v in updates.items() if k in self.TRACK_EDITABLE_FIELDS}
|
||||||
|
if not valid_updates:
|
||||||
|
return {'success': False, 'error': 'No valid fields to update'}
|
||||||
|
if not track_ids:
|
||||||
|
return {'success': False, 'error': 'No track IDs provided'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
set_clause = ', '.join(f'{k} = ?' for k in valid_updates)
|
||||||
|
placeholders = ','.join('?' * len(track_ids))
|
||||||
|
values = list(valid_updates.values()) + list(track_ids)
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE tracks SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})",
|
||||||
|
values
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {'success': True, 'updated_count': cursor.rowcount, 'updated_fields': list(valid_updates.keys())}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error batch updating tracks: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
# ==================== Discovery Match Cache Methods ====================
|
# ==================== Discovery Match Cache Methods ====================
|
||||||
|
|
||||||
def get_discovery_cache_match(self, normalized_title: str, normalized_artist: str, provider: str) -> Optional[Dict]:
|
def get_discovery_cache_match(self, normalized_title: str, normalized_artist: str, provider: str) -> Optional[Dict]:
|
||||||
|
|
|
||||||
614
web_server.py
614
web_server.py
|
|
@ -8728,6 +8728,578 @@ def library_check_tracks():
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Enhanced Library Management Endpoints ====================
|
||||||
|
|
||||||
|
@app.route('/api/library/artist/<artist_id>/enhanced')
|
||||||
|
def get_artist_enhanced_detail(artist_id):
|
||||||
|
"""Get full artist detail with all albums and tracks for enhanced library view."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
result = database.get_artist_full_detail(artist_id)
|
||||||
|
if not result.get('success'):
|
||||||
|
return jsonify(result), 404
|
||||||
|
|
||||||
|
# Fix image URLs for artist and all albums (resolve Plex/Jellyfin relative paths)
|
||||||
|
if result.get('artist', {}).get('thumb_url'):
|
||||||
|
result['artist']['thumb_url'] = fix_artist_image_url(result['artist']['thumb_url'])
|
||||||
|
for album in result.get('albums', []):
|
||||||
|
if album.get('thumb_url'):
|
||||||
|
album['thumb_url'] = fix_artist_image_url(album['thumb_url'])
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/library/artist/<artist_id>', methods=['PUT'])
|
||||||
|
def update_library_artist(artist_id):
|
||||||
|
"""Update artist metadata fields."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||||
|
result = database.update_artist_fields(artist_id, data)
|
||||||
|
if not result.get('success'):
|
||||||
|
return jsonify(result), 400
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/library/album/<album_id>', methods=['PUT'])
|
||||||
|
def update_library_album(album_id):
|
||||||
|
"""Update album metadata fields."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||||
|
result = database.update_album_fields(album_id, data)
|
||||||
|
if not result.get('success'):
|
||||||
|
return jsonify(result), 400
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/library/track/<track_id>', methods=['PUT'])
|
||||||
|
def update_library_track(track_id):
|
||||||
|
"""Update track metadata fields."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||||
|
result = database.update_track_fields(track_id, data)
|
||||||
|
if not result.get('success'):
|
||||||
|
return jsonify(result), 400
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/library/tracks/batch', methods=['PUT'])
|
||||||
|
def batch_update_library_tracks():
|
||||||
|
"""Batch update multiple tracks with the same field values."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||||
|
track_ids = data.get('track_ids', [])
|
||||||
|
updates = data.get('updates', {})
|
||||||
|
if not track_ids or not updates:
|
||||||
|
return jsonify({"success": False, "error": "track_ids and updates are required"}), 400
|
||||||
|
result = database.batch_update_tracks(track_ids, updates)
|
||||||
|
if not result.get('success'):
|
||||||
|
return jsonify(result), 400
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/library/play', methods=['POST'])
|
||||||
|
def library_play_track():
|
||||||
|
"""Start playing a track directly from the user's library (no download needed)."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or not data.get('file_path'):
|
||||||
|
return jsonify({"success": False, "error": "file_path is required"}), 400
|
||||||
|
|
||||||
|
file_path = data['file_path']
|
||||||
|
|
||||||
|
# Resolve server-side paths (e.g. /mnt/musicBackup/...) to local transfer path
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||||
|
# Try stripping common server-side prefixes and remapping to transfer dir
|
||||||
|
# The DB stores Plex/media server absolute paths; strip the root to get relative path
|
||||||
|
path_parts = file_path.replace('\\', '/').split('/')
|
||||||
|
# Try progressively shorter prefixes: /mnt/musicBackup/artist/... -> artist/...
|
||||||
|
resolved = None
|
||||||
|
for i in range(1, len(path_parts)):
|
||||||
|
relative = '/'.join(path_parts[i:])
|
||||||
|
candidate = os.path.join(transfer_dir, relative.replace('/', os.sep))
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
resolved = candidate
|
||||||
|
break
|
||||||
|
if resolved:
|
||||||
|
file_path = resolved
|
||||||
|
else:
|
||||||
|
return jsonify({"success": False, "error": "File not found on disk"}), 404
|
||||||
|
|
||||||
|
print(f"📚 Library play request: {os.path.basename(file_path)}")
|
||||||
|
|
||||||
|
# Set stream state to ready with the library file path directly
|
||||||
|
with stream_lock:
|
||||||
|
stream_state.update({
|
||||||
|
"status": "ready",
|
||||||
|
"progress": 100,
|
||||||
|
"track_info": {
|
||||||
|
"title": data.get('title', os.path.basename(file_path)),
|
||||||
|
"artist": data.get('artist', 'Unknown Artist'),
|
||||||
|
"album": data.get('album', 'Unknown Album'),
|
||||||
|
},
|
||||||
|
"file_path": file_path,
|
||||||
|
"error_message": None,
|
||||||
|
"is_library": True
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "Library track ready for playback"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error playing library track: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes')}
|
||||||
|
|
||||||
|
@app.route('/api/library/enrich', methods=['POST'])
|
||||||
|
def library_enrich_entity():
|
||||||
|
"""Trigger enrichment of a specific entity from a single service.
|
||||||
|
Body: { entity_type: 'artist'|'album'|'track', entity_id: str, service: str,
|
||||||
|
name: str, artist_name: str? }
|
||||||
|
service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||||
|
|
||||||
|
entity_type = data.get('entity_type') # artist, album, track
|
||||||
|
entity_id = data.get('entity_id')
|
||||||
|
service = data.get('service')
|
||||||
|
name = data.get('name', '')
|
||||||
|
artist_name = data.get('artist_name', '')
|
||||||
|
|
||||||
|
if not entity_type or not entity_id or not service:
|
||||||
|
return jsonify({"success": False, "error": "entity_type, entity_id, and service are required"}), 400
|
||||||
|
|
||||||
|
if entity_type not in ('artist', 'album', 'track'):
|
||||||
|
return jsonify({"success": False, "error": "entity_type must be artist, album, or track"}), 400
|
||||||
|
|
||||||
|
valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes')
|
||||||
|
if service not in valid_services:
|
||||||
|
return jsonify({"success": False, "error": f"service must be one of: {', '.join(valid_services)}"}), 400
|
||||||
|
|
||||||
|
# Per-service lock to avoid thread-safety issues with shared worker clients
|
||||||
|
lock = _enrichment_locks.get(service)
|
||||||
|
if not lock:
|
||||||
|
return jsonify({"success": False, "error": f"Unknown service: {service}"}), 400
|
||||||
|
|
||||||
|
acquired = lock.acquire(blocking=False)
|
||||||
|
if not acquired:
|
||||||
|
return jsonify({"success": False, "error": f"{service} enrichment already in progress. Please wait."}), 429
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = {}
|
||||||
|
try:
|
||||||
|
result = _run_single_enrichment(service, entity_type, entity_id, name, artist_name)
|
||||||
|
results[service] = result
|
||||||
|
except Exception as e:
|
||||||
|
results[service] = {"success": False, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
# Re-fetch updated data to return fresh state
|
||||||
|
database = get_database()
|
||||||
|
updated = database.get_artist_full_detail(
|
||||||
|
data.get('artist_id', entity_id) if entity_type == 'artist' else
|
||||||
|
_get_artist_id_for_entity(database, entity_type, entity_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix image URLs in updated data
|
||||||
|
if updated.get('success'):
|
||||||
|
if updated.get('artist', {}).get('thumb_url'):
|
||||||
|
updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url'])
|
||||||
|
for album in updated.get('albums', []):
|
||||||
|
if album.get('thumb_url'):
|
||||||
|
album['thumb_url'] = fix_artist_image_url(album['thumb_url'])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"results": results,
|
||||||
|
"updated_data": updated if updated.get('success') else None
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error enriching entity: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _get_artist_id_for_entity(database, entity_type, entity_id):
|
||||||
|
"""Look up the artist_id for an album or track."""
|
||||||
|
try:
|
||||||
|
with database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
if entity_type == 'album':
|
||||||
|
cursor.execute("SELECT artist_id FROM albums WHERE id = ?", (entity_id,))
|
||||||
|
elif entity_type == 'track':
|
||||||
|
cursor.execute("SELECT artist_id FROM tracks WHERE id = ?", (entity_id,))
|
||||||
|
else:
|
||||||
|
return entity_id
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row['artist_id'] if row else entity_id
|
||||||
|
except Exception:
|
||||||
|
return entity_id
|
||||||
|
|
||||||
|
|
||||||
|
def _run_single_enrichment(service, entity_type, entity_id, name, artist_name):
|
||||||
|
"""Run a single enrichment service on a single entity."""
|
||||||
|
if service == 'audiodb':
|
||||||
|
if not audiodb_worker:
|
||||||
|
return {"success": False, "error": "AudioDB worker not initialized"}
|
||||||
|
item = {'type': entity_type, 'id': entity_id, 'name': name}
|
||||||
|
if entity_type in ('album', 'track'):
|
||||||
|
item['artist'] = artist_name
|
||||||
|
audiodb_worker._process_item(item)
|
||||||
|
return {"success": True, "message": f"AudioDB lookup complete for {entity_type}"}
|
||||||
|
|
||||||
|
elif service == 'deezer':
|
||||||
|
if not deezer_worker:
|
||||||
|
return {"success": False, "error": "Deezer worker not initialized"}
|
||||||
|
item = {'type': entity_type, 'id': entity_id, 'name': name, 'artist': artist_name}
|
||||||
|
if entity_type == 'artist':
|
||||||
|
deezer_worker._process_artist(entity_id, name)
|
||||||
|
elif entity_type == 'album':
|
||||||
|
deezer_worker._process_album(entity_id, name, artist_name, item)
|
||||||
|
elif entity_type == 'track':
|
||||||
|
deezer_worker._process_track(entity_id, name, artist_name, item)
|
||||||
|
return {"success": True, "message": f"Deezer lookup complete for {entity_type}"}
|
||||||
|
|
||||||
|
elif service == 'musicbrainz':
|
||||||
|
if not mb_worker:
|
||||||
|
return {"success": False, "error": "MusicBrainz worker not initialized"}
|
||||||
|
item = {'type': entity_type, 'id': entity_id, 'name': name}
|
||||||
|
if entity_type in ('album', 'track'):
|
||||||
|
item['artist'] = artist_name
|
||||||
|
mb_worker._process_item(item)
|
||||||
|
return {"success": True, "message": f"MusicBrainz lookup complete for {entity_type}"}
|
||||||
|
|
||||||
|
elif service == 'spotify':
|
||||||
|
if not spotify_enrichment_worker:
|
||||||
|
return {"success": False, "error": "Spotify worker not initialized"}
|
||||||
|
if entity_type == 'artist':
|
||||||
|
spotify_enrichment_worker._process_artist({'type': 'artist', 'id': entity_id, 'name': name})
|
||||||
|
elif entity_type == 'album':
|
||||||
|
spotify_enrichment_worker._process_album_individual({'type': 'album_individual', 'id': entity_id, 'name': name, 'artist': artist_name})
|
||||||
|
elif entity_type == 'track':
|
||||||
|
spotify_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name})
|
||||||
|
return {"success": True, "message": f"Spotify lookup complete for {entity_type}"}
|
||||||
|
|
||||||
|
elif service == 'itunes':
|
||||||
|
if not itunes_enrichment_worker:
|
||||||
|
return {"success": False, "error": "iTunes worker not initialized"}
|
||||||
|
if entity_type == 'artist':
|
||||||
|
itunes_enrichment_worker._process_artist({'type': 'artist', 'id': entity_id, 'name': name})
|
||||||
|
elif entity_type == 'album':
|
||||||
|
itunes_enrichment_worker._process_album_individual({'type': 'album_individual', 'id': entity_id, 'name': name, 'artist': artist_name})
|
||||||
|
elif entity_type == 'track':
|
||||||
|
itunes_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name})
|
||||||
|
return {"success": True, "message": f"iTunes lookup complete for {entity_type}"}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": f"Unknown service: {service}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/library/search-service', methods=['POST'])
|
||||||
|
def library_search_service():
|
||||||
|
"""Search a specific service for matching entities (for manual matching).
|
||||||
|
Body: { service: str, entity_type: str, query: str }
|
||||||
|
Returns normalized list of results.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "Invalid or missing JSON body"}), 400
|
||||||
|
service = data.get('service', '')
|
||||||
|
entity_type = data.get('entity_type', '')
|
||||||
|
query = data.get('query', '').strip()
|
||||||
|
|
||||||
|
if not service or not entity_type or not query:
|
||||||
|
return jsonify({"success": False, "error": "service, entity_type, and query are required"}), 400
|
||||||
|
|
||||||
|
results = _search_service(service, entity_type, query)
|
||||||
|
return jsonify({"success": True, "results": results})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error searching service: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _search_service(service, entity_type, query):
|
||||||
|
"""Search a service and return normalized results."""
|
||||||
|
import requests as req_lib
|
||||||
|
|
||||||
|
if service == 'spotify':
|
||||||
|
if not spotify_enrichment_worker or not spotify_enrichment_worker.client:
|
||||||
|
raise ValueError("Spotify worker not initialized")
|
||||||
|
client = spotify_enrichment_worker.client
|
||||||
|
if entity_type == 'artist':
|
||||||
|
items = client.search_artists(query, limit=8)
|
||||||
|
return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items]
|
||||||
|
elif entity_type == 'album':
|
||||||
|
items = client.search_albums(query, limit=8)
|
||||||
|
return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items]
|
||||||
|
elif entity_type == 'track':
|
||||||
|
items = client.search_tracks(query, limit=8)
|
||||||
|
return [{'id': t.id, 'name': t.name, 'image': t.image_url, 'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items]
|
||||||
|
|
||||||
|
elif service == 'itunes':
|
||||||
|
if not itunes_enrichment_worker or not itunes_enrichment_worker.client:
|
||||||
|
raise ValueError("iTunes worker not initialized")
|
||||||
|
client = itunes_enrichment_worker.client
|
||||||
|
if entity_type == 'artist':
|
||||||
|
items = client.search_artists(query, limit=8)
|
||||||
|
return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items]
|
||||||
|
elif entity_type == 'album':
|
||||||
|
items = client.search_albums(query, limit=8)
|
||||||
|
return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items]
|
||||||
|
elif entity_type == 'track':
|
||||||
|
items = client.search_tracks(query, limit=8)
|
||||||
|
return [{'id': t.id, 'name': t.name, 'image': t.image_url, 'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items]
|
||||||
|
|
||||||
|
elif service == 'musicbrainz':
|
||||||
|
if not mb_worker or not mb_worker.mb_service:
|
||||||
|
raise ValueError("MusicBrainz worker not initialized")
|
||||||
|
mb_client = mb_worker.mb_service.mb_client
|
||||||
|
if entity_type == 'artist':
|
||||||
|
items = mb_client.search_artist(query, limit=8)
|
||||||
|
return [{'id': a['id'], 'name': a.get('name', ''), 'image': None,
|
||||||
|
'extra': f"Score: {a.get('score', '')} · {a.get('disambiguation', '') or a.get('country', '')}"} for a in items]
|
||||||
|
elif entity_type == 'album':
|
||||||
|
items = mb_client.search_release(query, limit=8)
|
||||||
|
results = []
|
||||||
|
for r in items:
|
||||||
|
artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict))
|
||||||
|
# Cover Art Archive provides album art by release MBID
|
||||||
|
cover_url = f"https://coverartarchive.org/release/{r['id']}/front-250" if r.get('id') else None
|
||||||
|
results.append({'id': r['id'], 'name': r.get('title', ''), 'image': cover_url,
|
||||||
|
'extra': f"{artists} · {r.get('date', '')} · Score: {r.get('score', '')}"})
|
||||||
|
return results
|
||||||
|
elif entity_type == 'track':
|
||||||
|
items = mb_client.search_recording(query, limit=8)
|
||||||
|
results = []
|
||||||
|
for r in items:
|
||||||
|
artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict))
|
||||||
|
results.append({'id': r['id'], 'name': r.get('title', ''), 'image': None,
|
||||||
|
'extra': f"{artists} · Score: {r.get('score', '')}"})
|
||||||
|
return results
|
||||||
|
|
||||||
|
elif service == 'deezer':
|
||||||
|
# Deezer client only returns single results, so hit the API directly for multiple
|
||||||
|
type_map = {'artist': 'artist', 'album': 'album', 'track': 'track'}
|
||||||
|
deezer_type = type_map.get(entity_type, 'track')
|
||||||
|
try:
|
||||||
|
resp = req_lib.get(f'https://api.deezer.com/search/{deezer_type}', params={'q': query, 'limit': 8}, timeout=10)
|
||||||
|
data = resp.json().get('data', [])
|
||||||
|
except Exception:
|
||||||
|
data = []
|
||||||
|
results = []
|
||||||
|
for item in data:
|
||||||
|
if entity_type == 'artist':
|
||||||
|
results.append({'id': str(item.get('id', '')), 'name': item.get('name', ''),
|
||||||
|
'image': item.get('picture_medium'), 'extra': f"{item.get('nb_fan', 0)} fans"})
|
||||||
|
elif entity_type == 'album':
|
||||||
|
artist_name = item.get('artist', {}).get('name', '') if isinstance(item.get('artist'), dict) else ''
|
||||||
|
results.append({'id': str(item.get('id', '')), 'name': item.get('title', ''),
|
||||||
|
'image': item.get('cover_medium'), 'extra': artist_name})
|
||||||
|
elif entity_type == 'track':
|
||||||
|
artist_name = item.get('artist', {}).get('name', '') if isinstance(item.get('artist'), dict) else ''
|
||||||
|
album_name = item.get('album', {}).get('title', '') if isinstance(item.get('album'), dict) else ''
|
||||||
|
results.append({'id': str(item.get('id', '')), 'name': item.get('title', ''),
|
||||||
|
'image': item.get('album', {}).get('cover_medium') if isinstance(item.get('album'), dict) else None,
|
||||||
|
'extra': f"{artist_name} · {album_name}"})
|
||||||
|
return results
|
||||||
|
|
||||||
|
elif service == 'audiodb':
|
||||||
|
if not audiodb_worker or not audiodb_worker.client:
|
||||||
|
raise ValueError("AudioDB worker not initialized")
|
||||||
|
client = audiodb_worker.client
|
||||||
|
result = None
|
||||||
|
if entity_type == 'artist':
|
||||||
|
result = client.search_artist(query)
|
||||||
|
elif entity_type == 'album':
|
||||||
|
# AudioDB album search needs artist + album, try query as-is
|
||||||
|
parts = query.split(' - ', 1) if ' - ' in query else [query, '']
|
||||||
|
result = client.search_album(parts[0], parts[1] if len(parts) > 1 else query)
|
||||||
|
elif entity_type == 'track':
|
||||||
|
parts = query.split(' - ', 1) if ' - ' in query else [query, '']
|
||||||
|
result = client.search_track(parts[0], parts[1] if len(parts) > 1 else query)
|
||||||
|
if result:
|
||||||
|
if entity_type == 'artist':
|
||||||
|
return [{'id': str(result.get('idArtist', '')), 'name': result.get('strArtist', ''),
|
||||||
|
'image': result.get('strArtistThumb'), 'extra': result.get('strGenre', '')}]
|
||||||
|
elif entity_type == 'album':
|
||||||
|
return [{'id': str(result.get('idAlbum', '')), 'name': result.get('strAlbum', ''),
|
||||||
|
'image': result.get('strAlbumThumb'), 'extra': f"{result.get('strArtist', '')} · {result.get('intYearReleased', '')}"}]
|
||||||
|
elif entity_type == 'track':
|
||||||
|
return [{'id': str(result.get('idTrack', '')), 'name': result.get('strTrack', ''),
|
||||||
|
'image': None, 'extra': f"{result.get('strArtist', '')} · {result.get('strAlbum', '')}"}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# Column name mappings for manual matching
|
||||||
|
_SERVICE_ID_COLUMNS = {
|
||||||
|
'spotify': {'artist': 'spotify_artist_id', 'album': 'spotify_album_id', 'track': 'spotify_track_id'},
|
||||||
|
'musicbrainz': {'artist': 'musicbrainz_id', 'album': 'musicbrainz_release_id', 'track': 'musicbrainz_recording_id'},
|
||||||
|
'deezer': {'artist': 'deezer_id', 'album': 'deezer_id', 'track': 'deezer_id'},
|
||||||
|
'audiodb': {'artist': 'audiodb_id', 'album': 'audiodb_id', 'track': 'audiodb_id'},
|
||||||
|
'itunes': {'artist': 'itunes_artist_id', 'album': 'itunes_album_id', 'track': 'itunes_track_id'},
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.route('/api/library/manual-match', methods=['PUT'])
|
||||||
|
def library_manual_match():
|
||||||
|
"""Manually set a service ID for an entity.
|
||||||
|
Body: { entity_type: str, entity_id: str, service: str, service_id: str, artist_id: str }
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
entity_type = data.get('entity_type')
|
||||||
|
entity_id = data.get('entity_id')
|
||||||
|
service = data.get('service')
|
||||||
|
service_id = data.get('service_id')
|
||||||
|
|
||||||
|
if not all([entity_type, entity_id, service, service_id]):
|
||||||
|
return jsonify({"success": False, "error": "entity_type, entity_id, service, and service_id are required"}), 400
|
||||||
|
|
||||||
|
id_col = _SERVICE_ID_COLUMNS.get(service, {}).get(entity_type)
|
||||||
|
if not id_col:
|
||||||
|
return jsonify({"success": False, "error": f"Invalid service/entity_type combination"}), 400
|
||||||
|
|
||||||
|
status_col = f"{service}_match_status"
|
||||||
|
attempted_col = f"{service}_last_attempted"
|
||||||
|
table = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}[entity_type]
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
with database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(f"""
|
||||||
|
UPDATE {table}
|
||||||
|
SET {id_col} = ?, {status_col} = 'matched', {attempted_col} = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""", (service_id, entity_id))
|
||||||
|
conn.commit()
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return jsonify({"success": False, "error": "Entity not found"}), 404
|
||||||
|
|
||||||
|
# Re-fetch fresh data
|
||||||
|
artist_id = data.get('artist_id', entity_id)
|
||||||
|
if entity_type != 'artist':
|
||||||
|
artist_id = _get_artist_id_for_entity(database, entity_type, entity_id)
|
||||||
|
|
||||||
|
updated = database.get_artist_full_detail(artist_id)
|
||||||
|
if updated.get('success'):
|
||||||
|
if updated.get('artist', {}).get('thumb_url'):
|
||||||
|
updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url'])
|
||||||
|
for album in updated.get('albums', []):
|
||||||
|
if album.get('thumb_url'):
|
||||||
|
album['thumb_url'] = fix_artist_image_url(album['thumb_url'])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Manually matched {entity_type} to {service} ID: {service_id}",
|
||||||
|
"updated_data": updated if updated.get('success') else None
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error manual matching: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/library/track/<int:track_id>', methods=['DELETE'])
|
||||||
|
def library_delete_track(track_id):
|
||||||
|
"""Delete a single track record from the database (does NOT delete the file on disk)."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
with database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
||||||
|
conn.commit()
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return jsonify({"success": False, "error": "Track not found"}), 404
|
||||||
|
return jsonify({"success": True, "deleted_count": cursor.rowcount})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error deleting track {track_id}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/library/album/<int:album_id>', methods=['DELETE'])
|
||||||
|
def library_delete_album(album_id):
|
||||||
|
"""Delete an album and all its tracks from the database (does NOT delete files on disk)."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
with database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# Delete all tracks belonging to this album first
|
||||||
|
cursor.execute("DELETE FROM tracks WHERE album_id = ?", (album_id,))
|
||||||
|
tracks_deleted = cursor.rowcount
|
||||||
|
# Delete the album itself
|
||||||
|
cursor.execute("DELETE FROM albums WHERE id = ?", (album_id,))
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
conn.rollback()
|
||||||
|
return jsonify({"success": False, "error": "Album not found"}), 404
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"success": True, "deleted_count": 1, "tracks_deleted": tracks_deleted})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error deleting album {album_id}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/library/tracks/delete-batch', methods=['POST'])
|
||||||
|
def library_delete_tracks_batch():
|
||||||
|
"""Delete multiple track records from the database (does NOT delete files on disk).
|
||||||
|
Body: { track_ids: [int] }
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
track_ids = data.get('track_ids', [])
|
||||||
|
if not track_ids or not isinstance(track_ids, list):
|
||||||
|
return jsonify({"success": False, "error": "track_ids array is required"}), 400
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
with database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
placeholders = ','.join('?' for _ in track_ids)
|
||||||
|
cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", track_ids)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"success": True, "deleted_count": cursor.rowcount})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error batch deleting tracks: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== End Enhanced Library Management ====================
|
||||||
|
|
||||||
@app.route('/api/stream/start', methods=['POST'])
|
@app.route('/api/stream/start', methods=['POST'])
|
||||||
def stream_start():
|
def stream_start():
|
||||||
"""Start streaming a track in the background"""
|
"""Start streaming a track in the background"""
|
||||||
|
|
@ -8751,7 +9323,8 @@ def stream_start():
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"track_info": None,
|
"track_info": None,
|
||||||
"file_path": None,
|
"file_path": None,
|
||||||
"error_message": None
|
"error_message": None,
|
||||||
|
"is_library": False
|
||||||
})
|
})
|
||||||
|
|
||||||
# Start new background streaming task
|
# Start new background streaming task
|
||||||
|
|
@ -8886,23 +9459,29 @@ def stream_audio():
|
||||||
def stream_stop():
|
def stream_stop():
|
||||||
"""Stop streaming and clean up"""
|
"""Stop streaming and clean up"""
|
||||||
global stream_background_task
|
global stream_background_task
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Cancel background task
|
# Cancel background task
|
||||||
if stream_background_task and not stream_background_task.done():
|
if stream_background_task and not stream_background_task.done():
|
||||||
stream_background_task.cancel()
|
stream_background_task.cancel()
|
||||||
|
|
||||||
# Clear Stream folder
|
# Only clear Stream folder if NOT playing a library file
|
||||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
with stream_lock:
|
||||||
stream_folder = os.path.join(project_root, 'Stream')
|
is_library = stream_state.get("is_library", False)
|
||||||
|
|
||||||
if os.path.exists(stream_folder):
|
if not is_library:
|
||||||
for filename in os.listdir(stream_folder):
|
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||||
file_path = os.path.join(stream_folder, filename)
|
stream_folder = os.path.join(project_root, 'Stream')
|
||||||
if os.path.isfile(file_path):
|
|
||||||
os.remove(file_path)
|
if os.path.exists(stream_folder):
|
||||||
print(f"🗑️ Removed stream file: {filename}")
|
for filename in os.listdir(stream_folder):
|
||||||
|
file_path = os.path.join(stream_folder, filename)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
print(f"🗑️ Removed stream file: {filename}")
|
||||||
|
else:
|
||||||
|
print(f"📚 Library playback stopped - skipping file deletion")
|
||||||
|
|
||||||
# Reset stream state
|
# Reset stream state
|
||||||
with stream_lock:
|
with stream_lock:
|
||||||
stream_state.update({
|
stream_state.update({
|
||||||
|
|
@ -8910,11 +9489,12 @@ def stream_stop():
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"track_info": None,
|
"track_info": None,
|
||||||
"file_path": None,
|
"file_path": None,
|
||||||
"error_message": None
|
"error_message": None,
|
||||||
|
"is_library": False
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({"success": True, "message": "Stream stopped"})
|
return jsonify({"success": True, "message": "Stream stopped"})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error stopping stream: {e}")
|
print(f"❌ Error stopping stream: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
|
||||||
|
|
@ -2248,6 +2248,12 @@
|
||||||
<button class="discography-filter-btn" data-filter="ownership" data-value="owned">Owned</button>
|
<button class="discography-filter-btn" data-filter="ownership" data-value="owned">Owned</button>
|
||||||
<button class="discography-filter-btn" data-filter="ownership" data-value="missing">Missing</button>
|
<button class="discography-filter-btn" data-filter="ownership" data-value="missing">Missing</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-divider"></div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="filter-label">View</span>
|
||||||
|
<button class="enhanced-view-toggle-btn active" data-view="standard" onclick="toggleEnhancedView(false)">Standard</button>
|
||||||
|
<button class="enhanced-view-toggle-btn" data-view="enhanced" onclick="toggleEnhancedView(true)">Enhanced</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Discography Sections -->
|
<!-- Discography Sections -->
|
||||||
|
|
@ -2294,10 +2300,44 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced Library Management View -->
|
||||||
|
<div class="enhanced-view-container hidden" id="enhanced-view-container">
|
||||||
|
<!-- Populated dynamically by renderEnhancedView() -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced Bulk Edit Modal -->
|
||||||
|
<div class="modal-overlay hidden" id="enhanced-bulk-edit-overlay">
|
||||||
|
<div class="enhanced-bulk-modal">
|
||||||
|
<div class="enhanced-bulk-modal-header">
|
||||||
|
<h3 id="enhanced-bulk-modal-title">Batch Edit Tracks</h3>
|
||||||
|
<button class="enhanced-bulk-modal-close" onclick="closeBulkEditModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="enhanced-bulk-modal-body" id="enhanced-bulk-modal-body">
|
||||||
|
<!-- Populated dynamically -->
|
||||||
|
</div>
|
||||||
|
<div class="enhanced-bulk-modal-footer">
|
||||||
|
<button class="enhanced-bulk-btn secondary" onclick="closeBulkEditModal()">Cancel</button>
|
||||||
|
<button class="enhanced-bulk-btn primary" onclick="executeBulkEdit()">Apply Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced Bulk Actions Bar -->
|
||||||
|
<div class="enhanced-bulk-bar" id="enhanced-bulk-bar">
|
||||||
|
<div class="enhanced-bulk-bar-info">
|
||||||
|
<span class="enhanced-bulk-bar-count" id="enhanced-bulk-count">0</span>
|
||||||
|
<span class="enhanced-bulk-bar-label">tracks selected</span>
|
||||||
|
</div>
|
||||||
|
<div class="enhanced-bulk-bar-actions">
|
||||||
|
<button class="enhanced-bulk-btn secondary" onclick="showBulkEditModal()">Edit Selected</button>
|
||||||
|
<button class="enhanced-bulk-btn clear" onclick="clearTrackSelection()">Clear Selection</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Discover Page -->
|
<!-- Discover Page -->
|
||||||
<div class="page" id="discover-page">
|
<div class="page" id="discover-page">
|
||||||
<div class="discover-container">
|
<div class="discover-container">
|
||||||
|
|
|
||||||
|
|
@ -2172,4 +2172,46 @@
|
||||||
width: calc(100vw - 32px);
|
width: calc(100vw - 32px);
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced Library Management - Mobile */
|
||||||
|
.enhanced-artist-meta-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.enhanced-meta-field.wide {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
.enhanced-album-row {
|
||||||
|
padding: 10px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.enhanced-album-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.enhanced-album-type-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.enhanced-album-track-count {
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
.enhanced-track-table .col-path,
|
||||||
|
.enhanced-track-table .col-bpm,
|
||||||
|
.enhanced-track-table .col-format,
|
||||||
|
.enhanced-track-table .col-match,
|
||||||
|
.enhanced-track-table .col-disc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.enhanced-manual-match-modal {
|
||||||
|
width: 95vw;
|
||||||
|
}
|
||||||
|
.enhanced-album-meta-row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.enhanced-bulk-bar {
|
||||||
|
left: 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.enhanced-bulk-modal {
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue