Merge pull request #446 from Nezreka/fix/bulk-discography-source-context
Fix bulk discography losing album source context (#399)
This commit is contained in:
commit
5646b54182
3 changed files with 150 additions and 57 deletions
167
web_server.py
167
web_server.py
|
|
@ -8853,33 +8853,58 @@ def get_album_tracks(album_id):
|
||||||
|
|
||||||
@app.route('/api/artist/<artist_id>/download-discography', methods=['POST'])
|
@app.route('/api/artist/<artist_id>/download-discography', methods=['POST'])
|
||||||
def download_discography(artist_id):
|
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:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'album_ids' not in data:
|
if not data:
|
||||||
return jsonify({"success": False, "error": "album_ids required"}), 400
|
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_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 database.music_database import MusicDatabase
|
||||||
|
from core.metadata.album_tracks import get_artist_album_tracks
|
||||||
db = MusicDatabase()
|
db = MusicDatabase()
|
||||||
profile_id = get_current_profile_id()
|
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_added = 0
|
||||||
total_skipped = 0
|
total_skipped = 0
|
||||||
|
|
@ -8887,27 +8912,48 @@ def download_discography(artist_id):
|
||||||
def generate_ndjson():
|
def generate_ndjson():
|
||||||
nonlocal total_added, total_skipped
|
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:
|
try:
|
||||||
album_data = client.get_album(album_id)
|
result = get_artist_album_tracks(
|
||||||
if not album_data:
|
album_id,
|
||||||
yield json.dumps({"album_id": album_id, "status": "error", "message": "Album not found"}) + '\n'
|
artist_name=hint_artist,
|
||||||
|
album_name=hint_album_name,
|
||||||
|
source_override=source_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
continue
|
||||||
|
|
||||||
album_name = album_data.get('name', 'Unknown')
|
album = result.get('album', {}) or {}
|
||||||
album_images = album_data.get('images', [])
|
tracks = result.get('tracks', []) or []
|
||||||
album_type = album_data.get('album_type', 'album')
|
album_name = album.get('name') or hint_album_name or 'Unknown'
|
||||||
release_date = album_data.get('release_date', '')
|
album_images = album.get('images') or (
|
||||||
album_artists = album_data.get('artists', [])
|
[{'url': album['image_url']}] if album.get('image_url') else []
|
||||||
|
)
|
||||||
tracks = album_data.get('tracks', {}).get('items', [])
|
album_type = album.get('album_type', 'album')
|
||||||
if not tracks:
|
release_date = album.get('release_date', '') or ''
|
||||||
tracks_data = client.get_album_tracks(album_id)
|
album_artists = album.get('artists') or [{'name': hint_artist}]
|
||||||
if tracks_data and 'items' in tracks_data:
|
resolved_album_id = result.get('resolved_album_id') or album.get('id') or album_id
|
||||||
tracks = tracks_data['items']
|
resolved_source = result.get('source') or source_override or 'unknown'
|
||||||
|
|
||||||
if not tracks:
|
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
|
continue
|
||||||
|
|
||||||
added = 0
|
added = 0
|
||||||
|
|
@ -8915,24 +8961,23 @@ def download_discography(artist_id):
|
||||||
|
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
track_name = track.get('name', '')
|
track_name = track.get('name', '')
|
||||||
track_artists = track.get('artists', [])
|
|
||||||
track_id = track.get('id', '')
|
|
||||||
|
|
||||||
if not track_name:
|
if not track_name:
|
||||||
continue
|
continue
|
||||||
|
track_artists = track.get('artists', []) or album_artists
|
||||||
|
track_id = track.get('id', '')
|
||||||
|
|
||||||
spotify_track_data = {
|
spotify_track_data = {
|
||||||
'id': track_id,
|
'id': track_id,
|
||||||
'name': track_name,
|
'name': track_name,
|
||||||
'artists': track_artists if isinstance(track_artists, list) else [{'name': str(track_artists)}],
|
'artists': track_artists if isinstance(track_artists, list) else [{'name': str(track_artists)}],
|
||||||
'album': {
|
'album': {
|
||||||
'id': str(album_id),
|
'id': str(resolved_album_id),
|
||||||
'name': album_name,
|
'name': album_name,
|
||||||
'artists': album_artists,
|
'artists': album_artists,
|
||||||
'images': album_images,
|
'images': album_images,
|
||||||
'album_type': album_type,
|
'album_type': album_type,
|
||||||
'release_date': release_date,
|
'release_date': release_date,
|
||||||
'total_tracks': len(tracks)
|
'total_tracks': len(tracks),
|
||||||
},
|
},
|
||||||
'duration_ms': track.get('duration_ms', 0),
|
'duration_ms': track.get('duration_ms', 0),
|
||||||
'explicit': track.get('explicit', False),
|
'explicit': track.get('explicit', False),
|
||||||
|
|
@ -8941,7 +8986,8 @@ def download_discography(artist_id):
|
||||||
'uri': track.get('uri', ''),
|
'uri': track.get('uri', ''),
|
||||||
'preview_url': track.get('preview_url'),
|
'preview_url': track.get('preview_url'),
|
||||||
'external_urls': track.get('external_urls', {}),
|
'external_urls': track.get('external_urls', {}),
|
||||||
'is_local': False
|
'is_local': False,
|
||||||
|
'_source': resolved_source,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -8950,11 +8996,12 @@ def download_discography(artist_id):
|
||||||
failure_reason="Added via Download Discography",
|
failure_reason="Added via Download Discography",
|
||||||
source_type="discography",
|
source_type="discography",
|
||||||
source_info=json.dumps({
|
source_info=json.dumps({
|
||||||
'artist_name': artist_name,
|
'artist_name': hint_artist,
|
||||||
'album_name': album_name,
|
'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:
|
if was_added:
|
||||||
added += 1
|
added += 1
|
||||||
|
|
@ -8965,17 +9012,37 @@ def download_discography(artist_id):
|
||||||
|
|
||||||
total_added += added
|
total_added += added
|
||||||
total_skipped += skipped
|
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({
|
yield json.dumps({
|
||||||
"album_id": album_id, "name": album_name, "status": "done",
|
"album_id": album_id,
|
||||||
"tracks_added": added, "tracks_skipped": skipped, "tracks_total": len(tracks)
|
"name": album_name,
|
||||||
|
"status": "done",
|
||||||
|
"tracks_added": added,
|
||||||
|
"tracks_skipped": skipped,
|
||||||
|
"tracks_total": len(tracks),
|
||||||
|
"source": resolved_source,
|
||||||
}) + '\n'
|
}) + '\n'
|
||||||
|
|
||||||
except Exception as album_err:
|
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")
|
logger.warning(
|
||||||
yield json.dumps({"status": "complete", "total_added": total_added, "total_skipped": total_skipped, "total_albums": len(album_ids)}) + '\n'
|
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'})
|
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 statusClass = isOwned ? 'owned' : isPartial ? 'partial' : '';
|
||||||
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
|
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
|
||||||
|
|
||||||
|
const albumName = release.name || release.title || '';
|
||||||
return `
|
return `
|
||||||
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s">
|
<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">
|
<div class="discog-card-art">
|
||||||
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
|
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
|
||||||
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
|
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="discog-card-info">
|
<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 class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="discog-card-check"></div>
|
<div class="discog-card-check"></div>
|
||||||
|
|
@ -2319,6 +2320,7 @@ async function startDiscographyDownload() {
|
||||||
if (cb.closest('.discog-card').style.display !== 'none') {
|
if (cb.closest('.discog-card').style.display !== 'none') {
|
||||||
albumEntries.push({
|
albumEntries.push({
|
||||||
id: cb.dataset.albumId,
|
id: cb.dataset.albumId,
|
||||||
|
name: cb.dataset.albumName || '',
|
||||||
tracks: parseInt(cb.dataset.tracks) || 0
|
tracks: parseInt(cb.dataset.tracks) || 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2326,9 +2328,8 @@ async function startDiscographyDownload() {
|
||||||
// Sort by track count descending — process Deluxe/expanded editions first
|
// Sort by track count descending — process Deluxe/expanded editions first
|
||||||
// so their tracks get added before standard editions (which then get deduped)
|
// so their tracks get added before standard editions (which then get deduped)
|
||||||
albumEntries.sort((a, b) => b.tracks - a.tracks);
|
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
|
// Switch to progress view
|
||||||
const grid = document.getElementById('discog-grid');
|
const grid = document.getElementById('discog-grid');
|
||||||
|
|
@ -2379,11 +2380,26 @@ async function startDiscographyDownload() {
|
||||||
// Mark all items as active
|
// Mark all items as active
|
||||||
document.querySelectorAll('.discog-progress-item').forEach(item => item.classList.add('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 {
|
try {
|
||||||
const response = await fetch(`/api/artist/${artist.id}/download-discography`, {
|
const response = await fetch(`/api/artist/${artist.id}/download-discography`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const reader = response.body.getReader();
|
||||||
|
|
|
||||||
|
|
@ -807,13 +807,23 @@ async function _explorerWishlistSubmit(artistSections) {
|
||||||
for (const [artistId, data] of Object.entries(byArtist)) {
|
for (const [artistId, data] of Object.entries(byArtist)) {
|
||||||
// Sort by track count descending (deluxe editions first) BEFORE extracting IDs
|
// Sort by track count descending (deluxe editions first) BEFORE extracting IDs
|
||||||
data.albums.sort((a, b) => b.tracks - a.tracks);
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/artist/${artistId}/download-discography`, {
|
const response = await fetch(`/api/artist/${artistId}/download-discography`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const reader = response.body.getReader();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue