Merge pull request #446 from Nezreka/fix/bulk-discography-source-context

Fix bulk discography losing album source context (#399)
pull/447/head
BoulderBadgeDad 4 weeks ago committed by GitHub
commit 5646b54182
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -8853,33 +8853,58 @@ def get_album_tracks(album_id):
@app.route('/api/artist/<artist_id>/download-discography', methods=['POST'])
def download_discography(artist_id):
"""Add selected albums from an artist's discography to the wishlist."""
"""Add selected albums from an artist's discography to the wishlist.
Resolves each album through the same source-aware path that the
individual-album flow uses, so albums whose IDs come from a
fallback/provider-specific source (e.g. Deezer-formatted IDs surfaced
via Hydrabase) don't fail with "Album not found" when the primary
source can't look them up directly.
"""
try:
data = request.get_json()
if not data or 'album_ids' not in data:
return jsonify({"success": False, "error": "album_ids required"}), 400
if not data:
return jsonify({"success": False, "error": "request body required"}), 400
# Preferred payload: per-album metadata so each album can be resolved
# through its own source. Falls back to the legacy album_ids list,
# in which case every album is looked up under the artist-level source.
albums_payload = data.get('albums')
legacy_album_ids = data.get('album_ids')
if not albums_payload and not legacy_album_ids:
return jsonify({"success": False, "error": "albums or album_ids required"}), 400
album_ids = data['album_ids']
artist_name = data.get('artist_name', 'Unknown Artist')
artist_source = (data.get('source') or '').strip().lower() or None
if albums_payload:
album_entries = [
{
'id': str(a.get('id', '')),
'name': a.get('name') or a.get('title') or '',
'source': (a.get('source') or '').strip().lower() or artist_source,
'artist_name': a.get('artist_name') or artist_name,
}
for a in albums_payload if a.get('id')
]
else:
album_entries = [
{
'id': str(aid),
'name': '',
'source': artist_source,
'artist_name': artist_name,
}
for aid in legacy_album_ids if aid
]
if not album_entries:
return jsonify({"success": False, "error": "no valid albums in payload"}), 400
from database.music_database import MusicDatabase
from core.metadata.album_tracks import get_artist_album_tracks
db = MusicDatabase()
profile_id = get_current_profile_id()
active_server = config_manager.get_active_media_server()
# Resolve metadata client
client = None
if spotify_client and spotify_client.is_authenticated():
client = spotify_client
else:
fallback_src = _get_metadata_fallback_source()
if fallback_src == 'itunes':
client = _get_itunes_client()
elif fallback_src == 'deezer':
client = _get_deezer_client()
if not client:
return jsonify({"success": False, "error": "No metadata source available"}), 500
total_added = 0
total_skipped = 0
@ -8887,27 +8912,48 @@ def download_discography(artist_id):
def generate_ndjson():
nonlocal total_added, total_skipped
for album_id in album_ids:
for entry in album_entries:
album_id = entry['id']
hint_album_name = entry['name']
hint_artist = entry['artist_name']
source_override = entry['source']
try:
album_data = client.get_album(album_id)
if not album_data:
yield json.dumps({"album_id": album_id, "status": "error", "message": "Album not found"}) + '\n'
continue
result = get_artist_album_tracks(
album_id,
artist_name=hint_artist,
album_name=hint_album_name,
source_override=source_override,
)
album_name = album_data.get('name', 'Unknown')
album_images = album_data.get('images', [])
album_type = album_data.get('album_type', 'album')
release_date = album_data.get('release_date', '')
album_artists = album_data.get('artists', [])
if not result.get('success'):
message = result.get('error') or 'Album not found'
yield json.dumps({
"album_id": album_id,
"name": hint_album_name or album_id,
"status": "error",
"message": message,
}) + '\n'
continue
tracks = album_data.get('tracks', {}).get('items', [])
if not tracks:
tracks_data = client.get_album_tracks(album_id)
if tracks_data and 'items' in tracks_data:
tracks = tracks_data['items']
album = result.get('album', {}) or {}
tracks = result.get('tracks', []) or []
album_name = album.get('name') or hint_album_name or 'Unknown'
album_images = album.get('images') or (
[{'url': album['image_url']}] if album.get('image_url') else []
)
album_type = album.get('album_type', 'album')
release_date = album.get('release_date', '') or ''
album_artists = album.get('artists') or [{'name': hint_artist}]
resolved_album_id = result.get('resolved_album_id') or album.get('id') or album_id
resolved_source = result.get('source') or source_override or 'unknown'
if not tracks:
yield json.dumps({"album_id": album_id, "name": album_name, "status": "error", "message": "No tracks"}) + '\n'
yield json.dumps({
"album_id": album_id,
"name": album_name,
"status": "error",
"message": "No tracks",
}) + '\n'
continue
added = 0
@ -8915,24 +8961,23 @@ def download_discography(artist_id):
for track in tracks:
track_name = track.get('name', '')
track_artists = track.get('artists', [])
track_id = track.get('id', '')
if not track_name:
continue
track_artists = track.get('artists', []) or album_artists
track_id = track.get('id', '')
spotify_track_data = {
'id': track_id,
'name': track_name,
'artists': track_artists if isinstance(track_artists, list) else [{'name': str(track_artists)}],
'album': {
'id': str(album_id),
'id': str(resolved_album_id),
'name': album_name,
'artists': album_artists,
'images': album_images,
'album_type': album_type,
'release_date': release_date,
'total_tracks': len(tracks)
'total_tracks': len(tracks),
},
'duration_ms': track.get('duration_ms', 0),
'explicit': track.get('explicit', False),
@ -8941,7 +8986,8 @@ def download_discography(artist_id):
'uri': track.get('uri', ''),
'preview_url': track.get('preview_url'),
'external_urls': track.get('external_urls', {}),
'is_local': False
'is_local': False,
'_source': resolved_source,
}
try:
@ -8950,11 +8996,12 @@ def download_discography(artist_id):
failure_reason="Added via Download Discography",
source_type="discography",
source_info=json.dumps({
'artist_name': artist_name,
'artist_name': hint_artist,
'album_name': album_name,
'album_type': album_type
'album_type': album_type,
'source': resolved_source,
}),
profile_id=profile_id
profile_id=profile_id,
)
if was_added:
added += 1
@ -8965,17 +9012,37 @@ def download_discography(artist_id):
total_added += added
total_skipped += skipped
logger.warning(f"[Discography] {album_name}: {added} added, {skipped} skipped")
logger.warning(
f"[Discography] {album_name} ({resolved_source}): {added} added, {skipped} skipped"
)
yield json.dumps({
"album_id": album_id, "name": album_name, "status": "done",
"tracks_added": added, "tracks_skipped": skipped, "tracks_total": len(tracks)
"album_id": album_id,
"name": album_name,
"status": "done",
"tracks_added": added,
"tracks_skipped": skipped,
"tracks_total": len(tracks),
"source": resolved_source,
}) + '\n'
except Exception as album_err:
yield json.dumps({"album_id": album_id, "status": "error", "message": str(album_err)}) + '\n'
yield json.dumps({
"album_id": album_id,
"name": hint_album_name or album_id,
"status": "error",
"message": str(album_err),
}) + '\n'
logger.warning(f"[Discography] Complete for {artist_name}: {total_added} tracks added, {total_skipped} skipped across {len(album_ids)} albums")
yield json.dumps({"status": "complete", "total_added": total_added, "total_skipped": total_skipped, "total_albums": len(album_ids)}) + '\n'
logger.warning(
f"[Discography] Complete for {artist_name}: {total_added} tracks added, "
f"{total_skipped} skipped across {len(album_entries)} albums"
)
yield json.dumps({
"status": "complete",
"total_added": total_added,
"total_skipped": total_skipped,
"total_albums": len(album_entries),
}) + '\n'
return app.response_class(generate_ndjson(), mimetype='application/x-ndjson', headers={'X-Accel-Buffering': 'no'})

@ -2251,15 +2251,16 @@ function _renderDiscogCard(release, index, completionData) {
const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : '';
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
const albumName = release.name || release.title || '';
return `
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s">
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
<div class="discog-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
</div>
<div class="discog-card-info">
<div class="discog-card-title">${_esc(release.name)}</div>
<div class="discog-card-title">${_esc(albumName)}</div>
<div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
</div>
<div class="discog-card-check"></div>
@ -2319,6 +2320,7 @@ async function startDiscographyDownload() {
if (cb.closest('.discog-card').style.display !== 'none') {
albumEntries.push({
id: cb.dataset.albumId,
name: cb.dataset.albumName || '',
tracks: parseInt(cb.dataset.tracks) || 0
});
}
@ -2326,9 +2328,8 @@ async function startDiscographyDownload() {
// Sort by track count descending — process Deluxe/expanded editions first
// so their tracks get added before standard editions (which then get deduped)
albumEntries.sort((a, b) => b.tracks - a.tracks);
const albumIds = albumEntries.map(e => e.id);
if (albumIds.length === 0) return;
if (albumEntries.length === 0) return;
// Switch to progress view
const grid = document.getElementById('discog-grid');
@ -2379,11 +2380,26 @@ async function startDiscographyDownload() {
// Mark all items as active
document.querySelectorAll('.discog-progress-item').forEach(item => item.classList.add('active'));
// Per-album metadata so the backend can resolve each album through its
// own source — fixes albums whose IDs come from a fallback/provider-specific
// source (e.g. Deezer-formatted IDs surfaced via Hydrabase).
const sourceForBatch = (artist.source || artistsPageState.sourceOverride || '').toString().toLowerCase() || null;
const albumsPayload = albumEntries.map(e => ({
id: e.id,
name: e.name,
artist_name: artist.name,
source: sourceForBatch,
}));
try {
const response = await fetch(`/api/artist/${artist.id}/download-discography`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_ids: albumIds, artist_name: artist.name })
body: JSON.stringify({
albums: albumsPayload,
artist_name: artist.name,
source: sourceForBatch,
})
});
const reader = response.body.getReader();

@ -807,13 +807,23 @@ async function _explorerWishlistSubmit(artistSections) {
for (const [artistId, data] of Object.entries(byArtist)) {
// Sort by track count descending (deluxe editions first) BEFORE extracting IDs
data.albums.sort((a, b) => b.tracks - a.tracks);
const albumIds = data.albums.map(a => a.id);
// Per-album metadata so the backend can resolve each album through its
// own source even when the explorer doesn't carry per-album source info.
const albumsPayload = data.albums.map(a => ({
id: a.id,
name: a.title || '',
artist_name: data.name,
source: null,
}));
try {
const response = await fetch(`/api/artist/${artistId}/download-discography`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_ids: albumIds, artist_name: data.name })
body: JSON.stringify({
albums: albumsPayload,
artist_name: data.name,
})
});
const reader = response.body.getReader();

Loading…
Cancel
Save