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>
906 lines
32 KiB
Dart
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();
|
|
}
|
|
}
|