Merge pull request #316 from kettui/fix/reduce-ui-stalls
Reduce UI stalling during enhanced search, add caching & small concurrency optimizations
This commit is contained in:
commit
f636014b9a
2 changed files with 238 additions and 118 deletions
278
web_server.py
278
web_server.py
|
|
@ -2764,6 +2764,63 @@ def add_cache_headers(response, cache_duration=300):
|
|||
response.headers['Pragma'] = 'cache'
|
||||
return response
|
||||
|
||||
|
||||
_enhanced_search_cache = collections.OrderedDict()
|
||||
_enhanced_search_cache_lock = threading.Lock()
|
||||
_ENHANCED_SEARCH_CACHE_TTL = 600
|
||||
_ENHANCED_SEARCH_CACHE_MAX_ENTRIES = 100
|
||||
|
||||
|
||||
def _get_enhanced_search_cache_key(query):
|
||||
"""Build a cache key that follows the current metadata/search configuration."""
|
||||
normalized_query = (query or '').strip().lower()
|
||||
|
||||
try:
|
||||
active_server = config_manager.get_active_media_server()
|
||||
except Exception:
|
||||
active_server = 'unknown'
|
||||
|
||||
try:
|
||||
fallback_source = _get_metadata_fallback_source()
|
||||
except Exception:
|
||||
fallback_source = 'unknown'
|
||||
|
||||
try:
|
||||
hydrabase_active = _is_hydrabase_active()
|
||||
except Exception:
|
||||
hydrabase_active = False
|
||||
|
||||
return (normalized_query, active_server, fallback_source, hydrabase_active)
|
||||
|
||||
|
||||
def _get_cached_enhanced_search_response(cache_key):
|
||||
"""Return a cached enhanced-search response if it is still fresh."""
|
||||
now = time.time()
|
||||
with _enhanced_search_cache_lock:
|
||||
entry = _enhanced_search_cache.get(cache_key)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
if now - entry['timestamp'] < _ENHANCED_SEARCH_CACHE_TTL:
|
||||
_enhanced_search_cache.move_to_end(cache_key)
|
||||
return entry['data']
|
||||
|
||||
_enhanced_search_cache.pop(cache_key, None)
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached_enhanced_search_response(cache_key, response_data):
|
||||
"""Store an enhanced-search response in the short-lived in-memory cache."""
|
||||
with _enhanced_search_cache_lock:
|
||||
_enhanced_search_cache[cache_key] = {
|
||||
'timestamp': time.time(),
|
||||
'data': response_data,
|
||||
}
|
||||
_enhanced_search_cache.move_to_end(cache_key)
|
||||
|
||||
while len(_enhanced_search_cache) > _ENHANCED_SEARCH_CACHE_MAX_ENTRIES:
|
||||
_enhanced_search_cache.popitem(last=False)
|
||||
|
||||
# --- Background Download Monitoring (GUI Parity) ---
|
||||
class WebUIDownloadMonitor:
|
||||
"""
|
||||
|
|
@ -8514,6 +8571,7 @@ def enhanced_search():
|
|||
"""
|
||||
data = request.get_json()
|
||||
query = data.get('query', '').strip()
|
||||
cache_key = _get_enhanced_search_cache_key(query)
|
||||
|
||||
empty_source = {"artists": [], "albums": [], "tracks": [], "available": False}
|
||||
|
||||
|
|
@ -8528,6 +8586,11 @@ def enhanced_search():
|
|||
"metadata_source": "spotify"
|
||||
})
|
||||
|
||||
cached_response = _get_cached_enhanced_search_response(cache_key)
|
||||
if cached_response is not None:
|
||||
logger.info(f"Enhanced search cache hit for: '{query}'")
|
||||
return jsonify(cached_response)
|
||||
|
||||
logger.info(f"Enhanced search initiated for: '{query}'")
|
||||
|
||||
try:
|
||||
|
|
@ -8547,6 +8610,23 @@ def enhanced_search():
|
|||
"image_url": image_url
|
||||
})
|
||||
|
||||
# Very short queries are usually broad enough that remote metadata searches
|
||||
# just add latency without improving the result quality much. Keep them local.
|
||||
if len(query) < 3:
|
||||
fb_source = _get_metadata_fallback_source()
|
||||
response_data = {
|
||||
"db_artists": db_artists,
|
||||
"spotify_artists": [],
|
||||
"spotify_albums": [],
|
||||
"spotify_tracks": [],
|
||||
"metadata_source": fb_source,
|
||||
"primary_source": fb_source,
|
||||
"alternate_sources": [],
|
||||
"sources": {},
|
||||
}
|
||||
_set_cached_enhanced_search_response(cache_key, response_data)
|
||||
return jsonify(response_data)
|
||||
|
||||
# ── Determine primary source and search it synchronously ──
|
||||
primary_source = "spotify"
|
||||
primary_results = empty_source
|
||||
|
|
@ -8576,7 +8656,7 @@ def enhanced_search():
|
|||
# Search using the user's configured primary metadata source
|
||||
fb_source = _get_metadata_fallback_source()
|
||||
try:
|
||||
primary_results = _enhanced_search_source(query, _get_metadata_fallback_client())
|
||||
primary_results = _enhanced_search_source(query, _get_metadata_fallback_client(), fb_source)
|
||||
primary_source = fb_source
|
||||
except Exception as e:
|
||||
logger.debug(f"Primary source ({fb_source}) search failed: {e}")
|
||||
|
|
@ -8585,7 +8665,7 @@ def enhanced_search():
|
|||
if primary_results is empty_source and fb_source != 'spotify':
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
try:
|
||||
primary_results = _enhanced_search_source(query, spotify_client)
|
||||
primary_results = _enhanced_search_source(query, spotify_client, "spotify")
|
||||
primary_source = "spotify"
|
||||
except Exception as e:
|
||||
logger.debug(f"Spotify fallback search failed: {e}")
|
||||
|
|
@ -8615,7 +8695,7 @@ def enhanced_search():
|
|||
f"{len(primary_results['tracks'])} tracks | "
|
||||
f"Alt sources available: {alternate_sources}")
|
||||
|
||||
return jsonify({
|
||||
response_data = {
|
||||
# Backward compat — same shape as before
|
||||
"db_artists": db_artists,
|
||||
"spotify_artists": primary_results["artists"],
|
||||
|
|
@ -8625,66 +8705,95 @@ def enhanced_search():
|
|||
# New multi-source data
|
||||
"primary_source": primary_source,
|
||||
"alternate_sources": alternate_sources,
|
||||
})
|
||||
}
|
||||
_set_cached_enhanced_search_response(cache_key, response_data)
|
||||
return jsonify(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Enhanced search error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
def _enhanced_search_source(query, client):
|
||||
def _search_metadata_source_kind(client, query, kind, source_name=None):
|
||||
"""Search one result type from a metadata source and normalize it."""
|
||||
source_label = source_name or type(client).__name__
|
||||
|
||||
if kind == "artists":
|
||||
artists = []
|
||||
try:
|
||||
artist_objs = client.search_artists(query, limit=10)
|
||||
for artist in artist_objs:
|
||||
artists.append({
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url,
|
||||
"external_urls": artist.external_urls or {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Artist search failed for {source_label}: {e}")
|
||||
return artists
|
||||
|
||||
if kind == "albums":
|
||||
albums = []
|
||||
try:
|
||||
album_objs = client.search_albums(query, limit=10)
|
||||
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 {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Album search failed for {source_label}: {e}", exc_info=True)
|
||||
return albums
|
||||
|
||||
if kind == "tracks":
|
||||
tracks = []
|
||||
try:
|
||||
track_objs = client.search_tracks(query, limit=10)
|
||||
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 {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Track search failed for {source_label}: {e}", exc_info=True)
|
||||
return tracks
|
||||
|
||||
raise ValueError(f"Unknown metadata search kind: {kind}")
|
||||
|
||||
|
||||
def _enhanced_search_source(query, client, source_name=None):
|
||||
"""Search a single metadata source and return normalized results dict."""
|
||||
artists = []
|
||||
albums = []
|
||||
tracks = []
|
||||
results = {"artists": [], "albums": [], "tracks": []}
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = {
|
||||
executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists",
|
||||
executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums",
|
||||
executor.submit(_search_metadata_source_kind, client, query, "tracks", source_name): "tracks",
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
kind = futures[future]
|
||||
try:
|
||||
results[kind] = future.result()
|
||||
except Exception as e:
|
||||
logger.warning(f"{kind.title()} search failed for {source_name or type(client).__name__}: {e}", exc_info=True)
|
||||
results[kind] = []
|
||||
|
||||
try:
|
||||
artist_objs = client.search_artists(query, limit=10)
|
||||
for artist in artist_objs:
|
||||
artists.append({
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url,
|
||||
"external_urls": artist.external_urls or {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Artist search failed for {type(client).__name__}: {e}")
|
||||
|
||||
try:
|
||||
album_objs = client.search_albums(query, limit=10)
|
||||
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 {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Album search failed for {type(client).__name__}: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
track_objs = client.search_tracks(query, limit=10)
|
||||
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 {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Track search failed for {type(client).__name__}: {e}", exc_info=True)
|
||||
|
||||
return {"artists": artists, "albums": albums, "tracks": tracks, "available": True}
|
||||
return {"artists": results["artists"], "albums": results["albums"], "tracks": results["tracks"], "available": True}
|
||||
|
||||
|
||||
@app.route('/api/enhanced-search/source/<source_name>', methods=['POST'])
|
||||
|
|
@ -8766,51 +8875,20 @@ def enhanced_search_source(source_name):
|
|||
|
||||
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"
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = {
|
||||
executor.submit(_search_metadata_source_kind, client, query, "artists", source_name): "artists",
|
||||
executor.submit(_search_metadata_source_kind, client, query, "albums", source_name): "albums",
|
||||
executor.submit(_search_metadata_source_kind, client, query, "tracks", source_name): "tracks",
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
kind = futures[future]
|
||||
try:
|
||||
payload = future.result()
|
||||
except Exception as e:
|
||||
logger.warning(f"{kind.title()} search failed for {source_name}: {e}", exc_info=True)
|
||||
payload = []
|
||||
yield json.dumps({"type": kind, "data": payload}) + "\n"
|
||||
|
||||
yield json.dumps({"type": "done"}) + "\n"
|
||||
|
||||
|
|
|
|||
|
|
@ -8672,6 +8672,7 @@ function initializeSearchModeToggle() {
|
|||
|
||||
async function performEnhancedSearch(query) {
|
||||
console.log('Enhanced search:', query);
|
||||
const searchId = Date.now() + Math.random();
|
||||
|
||||
// Show loading state with correct source name
|
||||
showDropdown();
|
||||
|
|
@ -8694,14 +8695,7 @@ function initializeSearchModeToggle() {
|
|||
_altSourceController = new AbortController();
|
||||
|
||||
// Initialize multi-source state early so alternate fetches can write to it
|
||||
_enhancedSearchData = { db_artists: [], primary_source: null, sources: {} };
|
||||
|
||||
// Fire ALL source fetches immediately in parallel with the primary endpoint.
|
||||
// Don't guess which is primary — the main endpoint response will tell us.
|
||||
// If an alternate duplicates the primary, it just overwrites with same data.
|
||||
for (const srcName of ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos', 'musicbrainz']) {
|
||||
_fetchAlternateSource(srcName, query);
|
||||
}
|
||||
_enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query };
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/enhanced-search', {
|
||||
|
|
@ -8717,7 +8711,7 @@ function initializeSearchModeToggle() {
|
|||
console.log('Enhanced results:', data);
|
||||
|
||||
// Store multi-source state
|
||||
const primarySource = data.primary_source || data.metadata_source || 'spotify';
|
||||
const primarySource = data.primary_source || data.metadata_source || 'deezer';
|
||||
_activeSearchSource = primarySource;
|
||||
_enhancedSearchData = _enhancedSearchData || {};
|
||||
_enhancedSearchData.db_artists = data.db_artists;
|
||||
|
|
@ -8747,6 +8741,10 @@ function initializeSearchModeToggle() {
|
|||
resultsContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Alternate sources now start after the primary response has landed.
|
||||
// This avoids speculative fan-out for short or aborted searches.
|
||||
_queueAlternateSourceFetches(data.alternate_sources || [], query, searchId);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Enhanced search error:', error);
|
||||
|
|
@ -8961,8 +8959,26 @@ function initializeSearchModeToggle() {
|
|||
}
|
||||
}
|
||||
|
||||
async function _fetchAlternateSource(sourceName, query) {
|
||||
function _queueAlternateSourceFetches(alternateSources, query, searchId) {
|
||||
if (!Array.isArray(alternateSources) || alternateSources.length === 0) return;
|
||||
|
||||
// Fetch metadata sources first, then YouTube last so it does not compete
|
||||
// with the primary artist/album/track results for early attention.
|
||||
const orderedSources = ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'youtube_videos']
|
||||
.filter(src => alternateSources.includes(src) && src !== _activeSearchSource);
|
||||
|
||||
orderedSources.forEach((src, index) => {
|
||||
setTimeout(() => {
|
||||
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
||||
_fetchAlternateSource(src, query, searchId);
|
||||
}, index * 150);
|
||||
});
|
||||
}
|
||||
|
||||
async function _fetchAlternateSource(sourceName, query, searchId) {
|
||||
try {
|
||||
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
||||
|
||||
const response = await fetch(`/api/enhanced-search/source/${sourceName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -8970,9 +8986,9 @@ function initializeSearchModeToggle() {
|
|||
signal: _altSourceController?.signal,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
||||
|
||||
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
|
||||
if (!_enhancedSearchData) return;
|
||||
if (!_enhancedSearchData.sources[sourceName]) {
|
||||
const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
|
||||
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
|
||||
|
|
@ -8993,6 +9009,7 @@ function initializeSearchModeToggle() {
|
|||
const line = buffer.slice(0, newlineIdx).trim();
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
if (!line) continue;
|
||||
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(line);
|
||||
|
|
@ -9016,7 +9033,7 @@ function initializeSearchModeToggle() {
|
|||
}
|
||||
|
||||
// Final render
|
||||
if (_enhancedSearchData.primary_source) {
|
||||
if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) {
|
||||
renderSourceTabs(_enhancedSearchData);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -9033,16 +9050,25 @@ function initializeSearchModeToggle() {
|
|||
const sources = data.sources || {};
|
||||
const primary = data.primary_source || 'spotify';
|
||||
|
||||
// Build tab list: primary first, then alternates sorted alphabetically
|
||||
// Build tab list: primary first, then alternates sorted alphabetically.
|
||||
// Hide completed zero-result sources so the bar stays focused.
|
||||
const sourceNames = Object.keys(sources).filter(s => sources[s].available);
|
||||
if (sourceNames.length <= 1) {
|
||||
const visibleSources = sourceNames.filter(name => {
|
||||
const src = sources[name] || {};
|
||||
const count = name === 'youtube_videos'
|
||||
? (src.videos?.length || 0)
|
||||
: (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
|
||||
const isLoading = !!(src._loading && src._loading.size > 0);
|
||||
return isLoading || count > 0 || name === _activeSearchSource;
|
||||
});
|
||||
if (visibleSources.length <= 1) {
|
||||
tabBar.classList.add('hidden');
|
||||
tabBar.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Primary tab first, then others
|
||||
const ordered = [primary, ...sourceNames.filter(s => s !== primary).sort()];
|
||||
const ordered = [primary, ...visibleSources.filter(s => s !== primary).sort()];
|
||||
|
||||
tabBar.innerHTML = ordered.map(name => {
|
||||
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
|
||||
|
|
@ -18169,10 +18195,26 @@ function _gsRenderTabs() {
|
|||
const el = document.getElementById('gsearch-tabs');
|
||||
if (!el) return;
|
||||
const sources = Object.keys(_gsState.sources);
|
||||
if (sources.length < 2) { el.style.display = 'none'; return; }
|
||||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos' };
|
||||
const labels = {
|
||||
spotify: 'Spotify',
|
||||
itunes: 'Apple Music',
|
||||
deezer: 'Deezer',
|
||||
discogs: 'Discogs',
|
||||
hydrabase: 'Hydrabase',
|
||||
youtube_videos: 'Music Videos',
|
||||
musicbrainz: 'MusicBrainz',
|
||||
};
|
||||
const visibleSources = sources.filter(s => {
|
||||
const d = _gsState.sources[s] || {};
|
||||
const count = s === 'youtube_videos'
|
||||
? (d.videos?.length || 0)
|
||||
: (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
|
||||
const isLoading = !!(d._loading && d._loading.size > 0);
|
||||
return isLoading || count > 0 || s === _gsState.activeSource;
|
||||
});
|
||||
if (visibleSources.length < 2) { el.style.display = 'none'; return; }
|
||||
el.style.display = 'flex';
|
||||
el.innerHTML = sources.map(s => {
|
||||
el.innerHTML = visibleSources.map(s => {
|
||||
const d = _gsState.sources[s];
|
||||
const c = s === 'youtube_videos'
|
||||
? (d.videos?.length || 0)
|
||||
|
|
|
|||
Loading…
Reference in a new issue