Files
Ensemble/lib/services/metadata_service.dart
ChrisandClaude Opus 4.6 51c94e40a3 Improve author image loading and fix series cover rebuilds
Author images:
- Fetch from all 4 sources in parallel (was sequential) — ~5s vs 30s worst case
- Validate all image URLs with HEAD request before accepting
- Deduplicate concurrent requests for same author via Completer map
- Persist URLs to SharedPreferences for instant cold start
- Increase LRU cache from 100 to 500 entries

Series cover mosaic:
- Cache built cover widget to prevent rebuilds from unrelated setState
- Match hero widget tree structure (RepaintBoundary > AspectRatio > ClipRRect)
- Keep initialCovers stable — don't overwrite from loaded books
- Add ValueKey and memCacheWidth/Height to cover CachedNetworkImages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:32:00 +00:00

906 lines
32 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import '../utils/lru_cache.dart';
class MetadataService {
static final _logger = DebugLogger();
// LRU caches to avoid repeated API calls and prevent unbounded memory growth
// Using NullableLruCache for caches that store null values (failed lookups)
/// Cache for artist/album descriptions (100 entries)
static final LruCache<String, String> _descriptionCache = LruCache(maxSize: 100);
/// Cache for artist images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _artistImageCache = NullableLruCache(maxSize: 200);
/// Fetches artist biography/description with fallback chain:
/// 1. Music Assistant metadata (passed in)
/// 2. Last.fm API (if key configured)
/// 3. TheAudioDB API (if key configured)
static Future<String?> getArtistDescription(
String artistName,
Map<String, dynamic>? musicAssistantMetadata,
) async {
// Try Music Assistant metadata first
if (musicAssistantMetadata != null) {
final maDescription = musicAssistantMetadata['description'] ??
musicAssistantMetadata['biography'] ??
musicAssistantMetadata['wiki'] ??
musicAssistantMetadata['bio'] ??
musicAssistantMetadata['summary'];
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
return maDescription;
}
}
// Check cache
final cacheKey = 'artist:$artistName';
if (_descriptionCache.containsKey(cacheKey)) {
return _descriptionCache[cacheKey];
}
// Try Last.fm API
final lastFmKey = await SettingsService.getLastFmApiKey();
if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
if (lastFmDesc != null) {
_descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc;
}
}
// Try TheAudioDB API
final audioDbKey = await SettingsService.getTheAudioDbApiKey();
if (audioDbKey != null && audioDbKey.isNotEmpty) {
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
if (audioDbDesc != null) {
_descriptionCache[cacheKey] = audioDbDesc;
return audioDbDesc;
}
}
return null;
}
/// Fetches album description with fallback chain:
/// 1. Music Assistant metadata (passed in)
/// 2. Last.fm API (if key configured)
static Future<String?> getAlbumDescription(
String artistName,
String albumName,
Map<String, dynamic>? musicAssistantMetadata,
) async {
// Try Music Assistant metadata first
if (musicAssistantMetadata != null) {
final maDescription = musicAssistantMetadata['description'] ??
musicAssistantMetadata['wiki'] ??
musicAssistantMetadata['biography'] ??
musicAssistantMetadata['summary'];
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
return maDescription;
}
}
// Check cache
final cacheKey = 'album:$artistName:$albumName';
if (_descriptionCache.containsKey(cacheKey)) {
return _descriptionCache[cacheKey];
}
// Try Last.fm API (TheAudioDB doesn't have good album info)
final lastFmKey = await SettingsService.getLastFmApiKey();
if (lastFmKey != null && lastFmKey.isNotEmpty) {
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
if (lastFmDesc != null) {
_descriptionCache[cacheKey] = lastFmDesc;
return lastFmDesc;
}
}
return null;
}
static Future<String?> _fetchFromLastFm(
String artistName,
String? albumName,
String apiKey,
) async {
try {
final String method;
final Map<String, String> params = {
'api_key': apiKey,
'format': 'json',
};
if (albumName != null) {
// Album info
method = 'album.getinfo';
params['artist'] = artistName;
params['album'] = albumName;
} else {
// Artist info
method = 'artist.getinfo';
params['artist'] = artistName;
}
params['method'] = method;
final uri = Uri.https('ws.audioscrobbler.com', '/2.0/', params);
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (albumName != null) {
// Parse album response
final album = data['album'];
if (album != null) {
final wiki = album['wiki'];
if (wiki != null) {
// Prefer summary, fall back to content
return _cleanLastFmText(wiki['summary'] ?? wiki['content']);
}
}
} else {
// Parse artist response
final artist = data['artist'];
if (artist != null) {
final bio = artist['bio'];
if (bio != null) {
// Prefer summary, fall back to content
return _cleanLastFmText(bio['summary'] ?? bio['content']);
}
}
}
}
} catch (e) {
_logger.warning('Last.fm API error: $e', context: 'Metadata');
}
return null;
}
static Future<String?> _fetchFromTheAudioDb(
String artistName,
String apiKey,
) async {
try {
final uri = Uri.https(
'theaudiodb.com',
'/api/v1/json/$apiKey/search.php',
{'s': artistName},
);
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final artists = data['artists'];
if (artists != null && artists.isNotEmpty) {
final artist = artists[0];
// Try multiple language fields
return artist['strBiographyEN'] ??
artist['strBiographyDE'] ??
artist['strBiographyFR'] ??
artist['strBiographyIT'] ??
artist['strBiographyES'];
}
}
} catch (e) {
_logger.warning('TheAudioDB API error: $e', context: 'Metadata');
}
return null;
}
/// Removes Last.fm HTML tags and links
static String? _cleanLastFmText(String? text) {
if (text == null) return null;
// Remove <a href...> tags
text = text.replaceAll(RegExp(r'<a[^>]*>'), '');
text = text.replaceAll('</a>', '');
// Remove "Read more on Last.fm" footer
text = text.replaceAll(RegExp(r'\s*<a[^>]*>.*?</a>.*$'), '');
// Clean up any remaining HTML
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
return text.trim();
}
/// Fetches artist image URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
static Future<String?> getArtistImageUrl(String artistName) async {
// Check cache first
final cacheKey = 'artistImage:$artistName';
if (_artistImageCache.containsKey(cacheKey)) {
return _artistImageCache[cacheKey];
}
// Use Deezer API (free, no key, excellent coverage)
try {
final uri = Uri.https(
'api.deezer.com',
'/search/artist',
{
'q': artistName,
'limit': '1',
},
);
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final artists = data['data'] as List?;
if (artists != null && artists.isNotEmpty) {
final artist = artists[0];
// Use picture_xl for high quality, fall back to picture_medium
final imageUrl = artist['picture_xl'] ?? artist['picture_medium'] ?? artist['picture'];
_artistImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
} catch (e) {
_logger.warning('Deezer artist image error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_artistImageCache[cacheKey] = null;
return null;
}
/// Cache for album images - includes null entries for failed lookups (200 entries)
static final NullableLruCache<String, String> _albumImageCache = NullableLruCache(maxSize: 200);
/// Cache for author images (audiobooks) - includes null entries for failed lookups (500 entries)
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 500);
/// In-flight author image requests for deduplication
static final Map<String, Completer<String?>> _authorImageInFlight = {};
/// SharedPreferences key for persisted author images
static const _authorImagePrefsKey = 'author_image_urls';
/// Whether persisted author images have been loaded into memory cache
static bool _authorImageCacheWarmed = false;
/// Fetches album cover URL from Deezer (free, no API key required)
/// Returns the image URL if found, null otherwise
static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async {
// Build cache key
final cacheKey = 'albumImage:${artistName ?? ""}:$albumName';
if (_albumImageCache.containsKey(cacheKey)) {
return _albumImageCache[cacheKey];
}
// Use Deezer API (free, no key, excellent coverage)
try {
// Search with both album and artist if available for better matching
final query = artistName != null && artistName.isNotEmpty
? '$artistName $albumName'
: albumName;
final uri = Uri.https(
'api.deezer.com',
'/search/album',
{
'q': query,
'limit': '5', // Get a few results to find best match
},
);
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final albums = data['data'] as List?;
if (albums != null && albums.isNotEmpty) {
// Try to find exact match first
Map<String, dynamic>? bestMatch;
for (final album in albums) {
final deezerAlbumName = (album['title'] as String?)?.toLowerCase();
final deezerArtistName = (album['artist']?['name'] as String?)?.toLowerCase();
if (deezerAlbumName == albumName.toLowerCase()) {
// Album name matches
if (artistName == null || artistName.isEmpty ||
deezerArtistName == artistName.toLowerCase()) {
// And artist matches (or we don't have artist to match)
bestMatch = album;
break;
}
// Album matches but artist doesn't - keep as candidate
bestMatch ??= album;
}
}
// Fall back to first result if no good match
final album = bestMatch ?? albums[0] as Map<String, dynamic>;
// Use cover_xl for high quality, fall back to cover_medium
final imageUrl = album['cover_xl'] ?? album['cover_big'] ??
album['cover_medium'] ?? album['cover'];
_albumImageCache[cacheKey] = imageUrl;
return imageUrl;
}
}
} catch (e) {
_logger.warning('Deezer album image error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_albumImageCache[cacheKey] = null;
return null;
}
/// Generate name variations for fuzzy matching
/// Returns a list of name variations to try, in priority order
static List<String> _generateNameVariations(String authorName) {
final variations = <String>[];
final original = authorName.trim();
// Always try original first
variations.add(original);
// Handle "Lastname, Firstname" format
if (original.contains(',')) {
final parts = original.split(',').map((p) => p.trim()).toList();
if (parts.length == 2) {
variations.add('${parts[1]} ${parts[0]}'); // "Firstname Lastname"
}
}
// Normalize and expand initials for better matching
// Handles messy formats like "J. R.R Tolkien", "J.R.R. Tolkien", etc.
String normalized = original;
// First, normalize spacing around periods in initials: "R.R" -> "R. R", "J. R" -> "J. R"
// Match single letter followed by period and optional space, then another letter
normalized = normalized.replaceAllMapped(
RegExp(r'([A-Z])\.(\s?)([A-Z])'),
(m) => '${m.group(1)}. ${m.group(3)}',
);
// Also handle single letters without periods followed by period-letter: "R.R" in "J. R.R"
// This catches the case where we have "R.R" as a unit
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])\.([A-Z])'),
(m) => '${m.group(1)}${m.group(2)}. ${m.group(3)}.',
);
// Add periods to standalone single uppercase letters (initials without periods)
// "J. R. R Tolkien" -> "J. R. R. Tolkien"
normalized = normalized.replaceAllMapped(
RegExp(r'(\s)([A-Z])(\s)'),
(m) => '${m.group(1)}${m.group(2)}.${m.group(3)}',
);
if (normalized != original && !variations.contains(normalized)) {
variations.add(normalized);
}
// Remove common titles
final titles = ['Dr.', 'Dr', 'Mr.', 'Mr', 'Mrs.', 'Mrs', 'Ms.', 'Ms',
'Prof.', 'Prof', 'Professor', 'Sir', 'Dame', 'Lord', 'Lady'];
String withoutTitle = original;
for (final title in titles) {
if (withoutTitle.toLowerCase().startsWith(title.toLowerCase() + ' ')) {
withoutTitle = withoutTitle.substring(title.length).trim();
if (withoutTitle != original) {
variations.add(withoutTitle);
}
break;
}
}
// Remove suffixes
final suffixes = [', Jr.', ', Jr', ' Jr.', ' Jr', ', Sr.', ', Sr', ' Sr.', ' Sr',
', III', ' III', ', II', ' II', ', IV', ' IV', ', PhD', ', Ph.D.'];
String withoutSuffix = withoutTitle;
for (final suffix in suffixes) {
if (withoutSuffix.toLowerCase().endsWith(suffix.toLowerCase())) {
withoutSuffix = withoutSuffix.substring(0, withoutSuffix.length - suffix.length).trim();
if (withoutSuffix != withoutTitle && !variations.contains(withoutSuffix)) {
variations.add(withoutSuffix);
}
break;
}
}
// Split into parts for further variations (use normalized name for better splitting)
final nameToSplit = normalized != original ? normalized : withoutSuffix;
final parts = nameToSplit.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.length >= 2) {
// Try first + last only (skip middle names/initials)
final firstLast = '${parts.first} ${parts.last}';
if (!variations.contains(firstLast)) {
variations.add(firstLast);
}
// Check if name starts with initials (single letter or letter with period)
bool isInitial(String s) => s.length == 1 || (s.length == 2 && s.endsWith('.'));
// If first part looks like an initial, try just the last name
if (isInitial(parts.first)) {
if (!variations.contains(parts.last)) {
variations.add(parts.last);
}
}
// Handle multiple initials like "J. K. Rowling" or "J. R. R. Tolkien"
// Find where initials end and actual name begins
int initialsEnd = 0;
for (int i = 0; i < parts.length - 1; i++) {
if (isInitial(parts[i])) {
initialsEnd = i + 1;
} else {
break;
}
}
if (initialsEnd > 0 && initialsEnd < parts.length) {
// Try the non-initial part of the name
final restOfName = parts.sublist(initialsEnd).join(' ');
if (restOfName.isNotEmpty && !variations.contains(restOfName)) {
variations.add(restOfName);
}
// Also try initials without periods + last name (e.g., "JRR Tolkien", "JK Rowling")
final initialsNoPeriods = parts.sublist(0, initialsEnd)
.map((s) => s.replaceAll('.', ''))
.join('');
final compactInitials = '$initialsNoPeriods ${parts.last}';
if (!variations.contains(compactInitials)) {
variations.add(compactInitials);
}
}
}
// Remove duplicates while preserving order
return variations.toSet().toList();
}
/// Try to fetch author image from Audnexus
/// Audnexus requires a two-step process:
/// 1. Search by name to get the ASIN
/// 2. Fetch full author details by ASIN to get the image
static Future<String?> _tryAudnexus(String name) async {
try {
// Step 1: Search for author to get ASIN
final searchUri = Uri.https('api.audnex.us', '/authors', {'name': name});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
if (searchData is List && searchData.isNotEmpty) {
// Find the best match - prefer exact name matches
String? bestAsin;
final nameLower = name.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
for (final result in searchData) {
final resultName = (result['name'] as String?)?.toLowerCase().replaceAll(RegExp(r'[.\s]+'), '');
final asin = result['asin'] as String?;
if (asin != null) {
if (resultName == nameLower) {
bestAsin = asin;
break; // Exact match found
}
bestAsin ??= asin; // Keep first result as fallback
}
}
if (bestAsin != null) {
// Step 2: Fetch full author details by ASIN
final detailsUri = Uri.https('api.audnex.us', '/authors/$bestAsin');
final detailsResponse = await http.get(detailsUri).timeout(const Duration(seconds: 5));
if (detailsResponse.statusCode == 200) {
final detailsData = json.decode(detailsResponse.body);
final imageUrl = detailsData['image'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
return imageUrl;
}
}
}
}
}
} catch (e) {
_logger.warning('Audnexus author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Try to fetch author image from Open Library
static Future<String?> _tryOpenLibrary(String name) async {
try {
final uri = Uri.https('openlibrary.org', '/search/authors.json', {
'q': name,
'limit': '1',
});
final response = await http.get(uri).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final authors = data['docs'] as List?;
if (authors != null && authors.isNotEmpty) {
final authorKey = authors[0]['key'] as String?;
if (authorKey != null) {
final olid = authorKey.replaceAll('/authors/', '');
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
// Verify image exists - Open Library returns 302 redirects for valid images
try {
final check = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
// Accept 200 (direct), 301/302 (redirect to archive.org), or 307/308 (temporary redirect)
if (check.statusCode == 200 || check.statusCode == 301 ||
check.statusCode == 302 || check.statusCode == 307 || check.statusCode == 308) {
return imageUrl;
}
} catch (_) {}
}
}
}
} catch (e) {
_logger.warning('Open Library author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Try to fetch author image from Wikidata (structured Wikipedia data)
static Future<String?> _tryWikidata(String name) async {
try {
// Search for entity by name
final searchUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbsearchentities',
'search': name,
'language': 'en',
'type': 'item',
'limit': '3',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final results = searchData['search'] as List?;
// Try each result to find one with an image
for (final result in results ?? []) {
final entityId = result['id'] as String?;
if (entityId == null) continue;
// Get the image property (P18)
final claimsUri = Uri.https('www.wikidata.org', '/w/api.php', {
'action': 'wbgetclaims',
'entity': entityId,
'property': 'P18', // P18 is the "image" property
'format': 'json',
});
final claimsResponse = await http.get(claimsUri).timeout(const Duration(seconds: 5));
if (claimsResponse.statusCode == 200) {
final claimsData = json.decode(claimsResponse.body);
final claims = claimsData['claims'] as Map<String, dynamic>?;
final imageProperty = claims?['P18'] as List?;
if (imageProperty != null && imageProperty.isNotEmpty) {
final imageName = imageProperty[0]['mainsnak']?['datavalue']?['value'] as String?;
if (imageName != null && imageName.isNotEmpty) {
// Convert filename to Wikimedia Commons URL
final encodedName = Uri.encodeComponent(imageName.replaceAll(' ', '_'));
return 'https://commons.wikimedia.org/wiki/Special:FilePath/$encodedName?width=500';
}
}
}
}
}
} catch (e) {
_logger.warning('Wikidata author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Try to fetch author image from Wikipedia
static Future<String?> _tryWikipedia(String name) async {
try {
// Search for author - "writer author" suffix helps find the right page
final searchUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'list': 'search',
'srsearch': '$name writer author',
'srlimit': '1',
'format': 'json',
});
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
if (searchResponse.statusCode == 200) {
final searchData = json.decode(searchResponse.body);
final results = searchData['query']?['search'] as List?;
if (results != null && results.isNotEmpty) {
final pageTitle = results[0]['title'] as String?;
if (pageTitle != null) {
// Get page image
final imageUri = Uri.https('en.wikipedia.org', '/w/api.php', {
'action': 'query',
'titles': pageTitle,
'prop': 'pageimages',
'pithumbsize': '500',
'format': 'json',
});
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
if (imageResponse.statusCode == 200) {
final imageData = json.decode(imageResponse.body);
final pages = imageData['query']?['pages'] as Map<String, dynamic>?;
if (pages != null && pages.isNotEmpty) {
final page = pages.values.first as Map<String, dynamic>?;
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
final imageUrl = thumbnail?['source'] as String?;
if (imageUrl != null && imageUrl.isNotEmpty) {
return imageUrl;
}
}
}
}
}
}
} catch (e) {
_logger.warning('Wikipedia author image error for "$name": $e', context: 'Metadata');
}
return null;
}
/// Load persisted author image URLs into memory cache on first access
static Future<void> _warmAuthorImageCache() async {
if (_authorImageCacheWarmed) return;
_authorImageCacheWarmed = true;
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_authorImagePrefsKey);
if (jsonStr != null) {
final map = (json.decode(jsonStr) as Map<String, dynamic>).cast<String, String>();
for (final entry in map.entries) {
_authorImageCache[entry.key] = entry.value;
}
_logger.log('📸 Loaded ${map.length} persisted author images');
}
} catch (e) {
_logger.warning('Failed to load persisted author images: $e', context: 'Metadata');
}
}
/// Persist an author image URL to SharedPreferences
static Future<void> _persistAuthorImage(String cacheKey, String url) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_authorImagePrefsKey);
final map = jsonStr != null
? (json.decode(jsonStr) as Map<String, dynamic>).cast<String, String>()
: <String, String>{};
map[cacheKey] = url;
// Cap at 500 entries to prevent unbounded growth
if (map.length > 500) {
final keys = map.keys.toList();
for (var i = 0; i < map.length - 500; i++) {
map.remove(keys[i]);
}
}
await prefs.setString(_authorImagePrefsKey, json.encode(map));
} catch (e) {
_logger.warning('Failed to persist author image: $e', context: 'Metadata');
}
}
/// Validate that an image URL actually returns an image
static Future<bool> _validateImageUrl(String url) async {
try {
final response = await http.head(Uri.parse(url)).timeout(const Duration(seconds: 3));
return response.statusCode == 200 || response.statusCode == 301 ||
response.statusCode == 302 || response.statusCode == 307 || response.statusCode == 308;
} catch (_) {
return false;
}
}
/// Try a single source with all name variations, return first validated result
static Future<String?> _trySourceWithVariations(
Future<String?> Function(String name) source,
List<String> variations, {
bool alreadyValidated = false,
}) async {
for (final name in variations) {
final url = await source(name);
if (url != null) {
if (alreadyValidated || await _validateImageUrl(url)) {
return url;
}
}
}
return null;
}
/// Fetches author image URL from multiple sources in parallel
/// Sources: 1. Audnexus, 2. Open Library, 3. Wikidata, 4. Wikipedia
/// All sources run in parallel; first validated result wins
static Future<String?> getAuthorImageUrl(String authorName) async {
// Warm cache from persistence on first access
await _warmAuthorImageCache();
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Deduplicate concurrent requests for the same author
if (_authorImageInFlight.containsKey(cacheKey)) {
return _authorImageInFlight[cacheKey]!.future;
}
final completer = Completer<String?>();
_authorImageInFlight[cacheKey] = completer;
try {
final result = await _fetchAuthorImage(authorName, cacheKey);
completer.complete(result);
return result;
} catch (e) {
completer.complete(null);
return null;
} finally {
_authorImageInFlight.remove(cacheKey);
}
}
/// Internal fetch logic (called once per author, deduplicated)
static Future<String?> _fetchAuthorImage(String authorName, String cacheKey) async {
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
if (authorName.contains(',')) {
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
if (authors.length == 2) {
final secondPart = authors[1].toLowerCase();
if (suffixes.contains(secondPart) ||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
// "Lastname, First" or "Name, Jr." - don't split
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
}
} else if (authors.length > 2) {
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
}
}
// Generate name variations for fuzzy matching
final variations = _generateNameVariations(authorName);
// Run all sources in parallel - first validated result wins
final results = await Future.wait([
_trySourceWithVariations(_tryAudnexus, variations),
_trySourceWithVariations(_tryOpenLibrary, variations, alreadyValidated: true),
_trySourceWithVariations(_tryWikidata, variations.take(2).toList()),
_trySourceWithVariations(_tryWikipedia, variations.take(3).toList()),
]);
// Pick first non-null result (priority: Audnexus > OpenLibrary > Wikidata > Wikipedia)
final result = results[0] ?? results[1] ?? results[2] ?? results[3];
_authorImageCache[cacheKey] = result;
if (result != null) {
_persistAuthorImage(cacheKey, result);
}
return result;
}
/// Cache for lyrics - uses bounded Map with manual size limiting (500 entries max)
/// Stores (plainLyrics, syncedLyrics) tuples or null for failed lookups
static final Map<String, (String?, String?)?> _lyricsCache = {};
static const int _lyricsCacheMaxSize = 500;
/// Fetches lyrics from LRCLIB (free, no API key required)
/// Returns a tuple of (plainLyrics, syncedLyrics) or null if not found
/// LRCLIB provides both synced (LRC format) and plain lyrics
static Future<(String?, String?)?> getTrackLyrics({
required String trackName,
required String artistName,
String? albumName,
int? durationSeconds,
}) async {
// Build cache key
final cacheKey = 'lyrics:$artistName:$trackName';
if (_lyricsCache.containsKey(cacheKey)) {
return _lyricsCache[cacheKey];
}
// Try LRCLIB API
try {
final params = <String, String>{
'track_name': trackName,
'artist_name': artistName,
};
if (albumName != null && albumName.isNotEmpty) {
params['album_name'] = albumName;
}
if (durationSeconds != null && durationSeconds > 0) {
params['duration'] = durationSeconds.toString();
}
final uri = Uri.https('lrclib.net', '/api/get', params);
_logger.log('🎤 Fetching lyrics from LRCLIB: $trackName by $artistName');
final response = await http.get(
uri,
headers: {
'User-Agent': 'Ensemble Music Player (https://github.com/ensemble-app)',
},
).timeout(const Duration(seconds: 5));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final plainLyrics = data['plainLyrics'] as String?;
final syncedLyrics = data['syncedLyrics'] as String?;
if ((plainLyrics != null && plainLyrics.isNotEmpty) ||
(syncedLyrics != null && syncedLyrics.isNotEmpty)) {
_logger.log('🎤 LRCLIB found lyrics: plain=${plainLyrics?.length ?? 0} chars, synced=${syncedLyrics?.length ?? 0} chars');
final result = (plainLyrics, syncedLyrics);
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = result;
return result;
}
} else if (response.statusCode == 404) {
_logger.log('🎤 LRCLIB: No lyrics found for $trackName');
} else {
_logger.log('🎤 LRCLIB error: ${response.statusCode}');
}
} catch (e) {
_logger.warning('LRCLIB API error: $e', context: 'Metadata');
}
// Cache the null result to avoid repeated failed lookups
_evictLyricsCacheIfNeeded();
_lyricsCache[cacheKey] = null;
return null;
}
/// Evict oldest entries from lyrics cache if over max size
static void _evictLyricsCacheIfNeeded() {
while (_lyricsCache.length >= _lyricsCacheMaxSize) {
_lyricsCache.remove(_lyricsCache.keys.first);
}
}
/// Clears all metadata caches
static void clearCache() {
_descriptionCache.clear();
_artistImageCache.clear();
_albumImageCache.clear();
_authorImageCache.clear();
_lyricsCache.clear();
}
}