Files
Ensemble/lib/services/metadata_service.dart
ChrisandClaude Opus 4.5 ffbb41580b Phase 2: UI/Widget fixes and memory management
MetadataService Cache Eviction (2.4):
- Create LruCache<K,V> utility class with O(1) LRU operations
- Create NullableLruCache for caches storing null (failed lookups)
- Replace unbounded static maps with bounded LRU caches:
  - Description cache: 100 entries
  - Artist/album image caches: 200 entries each
  - Author image cache: 100 entries
  - Lyrics cache: 500 entries with manual eviction

Async Cancellation on Player Switch (2.2):
- Add staleness detection to _updatePlayerState()
- Capture player ID at operation start
- Check for player changes after each await
- Abort stale operations to prevent UI inconsistency

Scroll Controller Cleanup (2.3):
- Verified existing cleanup is correct
- Only tracks scroll controller has listener, properly removed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:17:53 +00:00

835 lines
30 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
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 (100 entries)
static final NullableLruCache<String, String> _authorImageCache = NullableLruCache(maxSize: 100);
/// 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;
}
/// Fetches author image URL from multiple sources with fuzzy name matching
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
/// Tries name variations if exact match fails
static Future<String?> getAuthorImageUrl(String authorName) async {
// Check cache first
final cacheKey = 'authorImage:$authorName';
if (_authorImageCache.containsKey(cacheKey)) {
return _authorImageCache[cacheKey];
}
// Handle comma-separated author names (e.g., "Neil Gaiman, Dirk Maggs")
// Try to get image for the first/primary author
if (authorName.contains(',')) {
final authors = authorName.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
// Skip if this looks like "Lastname, Firstname" format or has suffixes
// - Second part is initials (J.R.R., J.K., etc.)
// - Second part is a suffix (Jr., Sr., III, PhD, etc.)
final suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'phd', 'ph.d.', 'md', 'm.d.'];
if (authors.length == 2) {
final secondPart = authors[1].toLowerCase();
// Check if second part is a suffix or looks like initials
if (suffixes.contains(secondPart) ||
RegExp(r'^[a-z]\.?\s*[a-z]?\.?\s*[a-z]?\.?$').hasMatch(secondPart)) {
// This is "Lastname, First" or "Name, Jr." - don't split
} else if (authors[0].split(' ').length > 1 && authors[1].split(' ').length > 1) {
// Both parts have multiple words - likely "Author1, Author2"
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null here - fall through to try the full name
}
} else if (authors.length > 2) {
// Multiple commas = definitely multiple authors
for (final author in authors) {
final result = await getAuthorImageUrl(author);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// Don't cache null - fall through to try full name
}
}
// Generate name variations for fuzzy matching
final variations = _generateNameVariations(authorName);
// Try each source with all variations before moving to next source
// This prioritizes source quality over name variation
// 1. Try Audnexus (best for audiobook authors)
for (final name in variations) {
final result = await _tryAudnexus(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 2. Try Open Library
for (final name in variations) {
final result = await _tryOpenLibrary(name);
if (result != null) {
_authorImageCache[cacheKey] = result;
return result;
}
}
// 3. Try Wikidata (structured data, good for finding exact person)
for (final name in variations.take(2)) {
final wikidataResult = await _tryWikidata(name);
if (wikidataResult != null) {
_authorImageCache[cacheKey] = wikidataResult;
return wikidataResult;
}
}
// 4. Try Wikipedia (search-based, good fallback)
for (final name in variations.take(3)) {
final wikiResult = await _tryWikipedia(name);
if (wikiResult != null) {
_authorImageCache[cacheKey] = wikiResult;
return wikiResult;
}
}
// Cache the null result to avoid repeated failed lookups
_authorImageCache[cacheKey] = null;
return null;
}
/// 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();
}
}