Stream enhanced search source results as NDJSON for progressive rendering
- Source endpoint now streams artists/albums/tracks as separate NDJSON lines as each search type completes — iTunes users see artists in ~3s instead of waiting 9+ seconds for all 3 rate-limited calls to finish - Enhanced search _fetchAlternateSource reads stream with ReadableStream reader, merges each chunk into source data, re-renders tabs immediately - Global search uses same streaming pattern via _gsFetchSourceStream - No data loss: streamed data merges incrementally, primary response preserves already-received alternate source data
This commit is contained in:
parent
4786dc21ec
commit
1bb59f3c24
2 changed files with 141 additions and 21 deletions
|
|
@ -7349,8 +7349,9 @@ def _enhanced_search_source(query, client):
|
|||
def enhanced_search_source(source_name):
|
||||
"""Fetch search results from a specific alternate metadata source.
|
||||
|
||||
Called asynchronously by the frontend after the primary search returns.
|
||||
Each source tab fires its own request so slow sources don't block fast ones.
|
||||
Streams NDJSON — one line per search type (artists, albums, tracks) as each completes.
|
||||
This prevents slow sources (iTunes with 3s rate limit) from blocking the UI.
|
||||
Falls back to single JSON response if streaming not supported.
|
||||
"""
|
||||
if source_name not in ('spotify', 'itunes', 'deezer', 'hydrabase'):
|
||||
return jsonify({"error": f"Unknown source: {source_name}"}), 400
|
||||
|
|
@ -7378,8 +7379,57 @@ def enhanced_search_source(source_name):
|
|||
else:
|
||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||
|
||||
result = _enhanced_search_source(query, client)
|
||||
return jsonify(result)
|
||||
def generate():
|
||||
# Stream each search type as it completes
|
||||
try:
|
||||
artist_objs = client.search_artists(query, limit=10)
|
||||
artists = []
|
||||
for artist in artist_objs:
|
||||
artists.append({
|
||||
"id": artist.id, "name": artist.name,
|
||||
"image_url": artist.image_url,
|
||||
"external_urls": artist.external_urls or {},
|
||||
})
|
||||
yield json.dumps({"type": "artists", "data": artists}) + "\n"
|
||||
except Exception as e:
|
||||
logger.debug(f"Artist search failed for {source_name}: {e}")
|
||||
yield json.dumps({"type": "artists", "data": []}) + "\n"
|
||||
|
||||
try:
|
||||
album_objs = client.search_albums(query, limit=10)
|
||||
albums = []
|
||||
for album in album_objs:
|
||||
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
|
||||
albums.append({
|
||||
"id": album.id, "name": album.name, "artist": artist_name,
|
||||
"image_url": album.image_url, "release_date": album.release_date,
|
||||
"total_tracks": album.total_tracks, "album_type": album.album_type,
|
||||
"external_urls": album.external_urls or {},
|
||||
})
|
||||
yield json.dumps({"type": "albums", "data": albums}) + "\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Album search failed for {source_name}: {e}")
|
||||
yield json.dumps({"type": "albums", "data": []}) + "\n"
|
||||
|
||||
try:
|
||||
track_objs = client.search_tracks(query, limit=10)
|
||||
tracks = []
|
||||
for track in track_objs:
|
||||
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
|
||||
tracks.append({
|
||||
"id": track.id, "name": track.name, "artist": artist_name,
|
||||
"album": track.album, "duration_ms": track.duration_ms,
|
||||
"image_url": track.image_url, "release_date": track.release_date,
|
||||
"external_urls": track.external_urls or {},
|
||||
})
|
||||
yield json.dumps({"type": "tracks", "data": tracks}) + "\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Track search failed for {source_name}: {e}")
|
||||
yield json.dumps({"type": "tracks", "data": []}) + "\n"
|
||||
|
||||
yield json.dumps({"type": "done"}) + "\n"
|
||||
|
||||
return app.response_class(generate(), mimetype='application/x-ndjson')
|
||||
except Exception as e:
|
||||
logger.error(f"Enhanced search source ({source_name}) error: {e}")
|
||||
return jsonify({"artists": [], "albums": [], "tracks": [], "available": False})
|
||||
|
|
|
|||
|
|
@ -8295,17 +8295,50 @@ function initializeSearchModeToggle() {
|
|||
signal: _altSourceController?.signal,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (!result.available) return;
|
||||
|
||||
// Store in multi-source state
|
||||
if (_enhancedSearchData) {
|
||||
_enhancedSearchData.sources[sourceName] = result;
|
||||
// Re-render tabs if primary has loaded (primary_source is set)
|
||||
if (_enhancedSearchData.primary_source) {
|
||||
renderSourceTabs(_enhancedSearchData);
|
||||
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
|
||||
if (!_enhancedSearchData) return;
|
||||
if (!_enhancedSearchData.sources[sourceName]) {
|
||||
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], available: true };
|
||||
}
|
||||
const sourceData = _enhancedSearchData.sources[sourceName];
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIdx;
|
||||
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx).trim();
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
if (!line) continue;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(line);
|
||||
if (chunk.type === 'artists') sourceData.artists = chunk.data;
|
||||
else if (chunk.type === 'albums') sourceData.albums = chunk.data;
|
||||
else if (chunk.type === 'tracks') sourceData.tracks = chunk.data;
|
||||
else if (chunk.type === 'done') break;
|
||||
|
||||
// Re-render tabs after each chunk
|
||||
if (_enhancedSearchData.primary_source) {
|
||||
renderSourceTabs(_enhancedSearchData);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.debug(`NDJSON parse error for ${sourceName}:`, parseErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final render
|
||||
if (_enhancedSearchData.primary_source) {
|
||||
renderSourceTabs(_enhancedSearchData);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.debug(`Alternate source ${sourceName} failed:`, e);
|
||||
|
|
@ -16973,24 +17006,61 @@ async function _gsPerformSearch(query) {
|
|||
// Async library ownership check — adds badges + swaps play buttons for library tracks
|
||||
setTimeout(() => _gsLibraryCheck(), 200);
|
||||
|
||||
// Fetch alternate sources
|
||||
// Fetch alternate sources — stream NDJSON so slow sources render incrementally
|
||||
const alts = data.alternate_sources || [];
|
||||
for (const src of alts) {
|
||||
if (src === _gsState.activeSource) continue;
|
||||
fetch(`/api/enhanced-search/source/${src}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: _gsState.altAbortCtrl.signal,
|
||||
}).then(r => r.json()).then(altData => {
|
||||
if (altData.available) { _gsState.sources[src] = altData; _gsRenderTabs(); }
|
||||
}).catch(() => {});
|
||||
_gsFetchSourceStream(src, query);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') results.innerHTML = '<div class="gsearch-empty">Search failed</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function _gsFetchSourceStream(src, query) {
|
||||
try {
|
||||
const res = await fetch(`/api/enhanced-search/source/${src}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: _gsState.altAbortCtrl.signal,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
|
||||
if (!_gsState.sources[src]) {
|
||||
_gsState.sources[src] = { artists: [], albums: [], tracks: [], available: true };
|
||||
}
|
||||
const sourceData = _gsState.sources[src];
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line);
|
||||
if (chunk.type === 'artists') sourceData.artists = chunk.data;
|
||||
else if (chunk.type === 'albums') sourceData.albums = chunk.data;
|
||||
else if (chunk.type === 'tracks') sourceData.tracks = chunk.data;
|
||||
_gsRenderTabs();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
_gsRenderTabs();
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
function _gsRender(data) {
|
||||
const results = document.getElementById('gsearch-results');
|
||||
if (!results) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue