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:
Broque Thomas 2026-03-08 23:37:02 -07:00
parent 7b933ff97a
commit 7c50f350c0
6 changed files with 3978 additions and 19 deletions

View file

@ -6068,6 +6068,197 @@ class MusicDatabase:
'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 ====================
def get_discovery_cache_match(self, normalized_title: str, normalized_artist: str, provider: str) -> Optional[Dict]:

View file

@ -8728,6 +8728,578 @@ def library_check_tracks():
traceback.print_exc()
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'])
def stream_start():
"""Start streaming a track in the background"""
@ -8751,7 +9323,8 @@ def stream_start():
"progress": 0,
"track_info": None,
"file_path": None,
"error_message": None
"error_message": None,
"is_library": False
})
# Start new background streaming task
@ -8886,23 +9459,29 @@ def stream_audio():
def stream_stop():
"""Stop streaming and clean up"""
global stream_background_task
try:
# Cancel background task
if stream_background_task and not stream_background_task.done():
stream_background_task.cancel()
# Clear Stream folder
project_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = os.path.join(project_root, 'Stream')
if os.path.exists(stream_folder):
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}")
# Only clear Stream folder if NOT playing a library file
with stream_lock:
is_library = stream_state.get("is_library", False)
if not is_library:
project_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = os.path.join(project_root, 'Stream')
if os.path.exists(stream_folder):
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
with stream_lock:
stream_state.update({
@ -8910,11 +9489,12 @@ def stream_stop():
"progress": 0,
"track_info": None,
"file_path": None,
"error_message": None
"error_message": None,
"is_library": False
})
return jsonify({"success": True, "message": "Stream stopped"})
except Exception as e:
print(f"❌ Error stopping stream: {e}")
return jsonify({"success": False, "error": str(e)}), 500

View file

@ -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="missing">Missing</button>
</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>
<!-- Discography Sections -->
@ -2294,10 +2300,44 @@
</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>
<!-- 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()">&times;</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 -->
<div class="page" id="discover-page">
<div class="discover-container">

View file

@ -2172,4 +2172,46 @@
width: calc(100vw - 32px);
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