fix: improve image loading for cards with empty or invalid image fields

- Add fallback image search when DB image field is empty
- Add detailed logging for image resolution diagnostics
- Allow image loading even when image field is not set
- Try to find images by cardId when normal resolution fails
- Opportunistic DB migration when resolving legacy image references
This commit is contained in:
Dmitry 2025-12-19 02:45:48 +03:00
parent a50fb4110a
commit b45153975a
2 changed files with 112 additions and 58 deletions

View file

@ -403,11 +403,15 @@ class PacksApiV2 {
String cardId,
) async {
try {
print('getCardImage called: packId=$packId, cardId=$cardId');
if (cardId.isEmpty) {
print('Invalid card ID: empty');
return _badRequest('Invalid card ID');
}
if (packId.isEmpty) {
print('Invalid pack ID: empty');
return _badRequest('Invalid pack ID');
}
@ -415,79 +419,56 @@ class PacksApiV2 {
// We allow access to images for enabled packs even without auth
// to support image previews in public listings
final pack = await _db.packDao.getPackById(packId);
print('Pack lookup result: pack=$pack, enabled=${pack?.enabled}');
if (pack == null || !pack.enabled) {
print('Pack not found or not enabled');
return _notFound('Pack not found or not enabled');
}
// Get card and verify it belongs to the pack
final card = await _db.packDao.getCardById(cardId);
if (card == null || card.image.isEmpty) {
return _notFound('Card or image not found');
print('Card lookup result: card=$card, image=${card?.image}');
if (card == null) {
print('Card not found');
return _notFound('Card not found');
}
// Verify card belongs to this pack
final packCards = await _db.packDao.getPackCards(packId);
print('Pack cards count: ${packCards.length}');
final belongsToPack = packCards.any((c) => c.id == cardId);
print('Card belongs to pack: $belongsToPack');
if (!belongsToPack) {
print('Card does not belong to this pack');
return _notFound('Card does not belong to this pack');
}
final imageValue = card.image.trim();
if (imageValue.isEmpty) {
return _notFound('Image not found');
}
print('Image value after trim: "$imageValue"');
// Remote image: redirect (DB stores a path/URL)
if (CardImageStorage.isRemoteUrl(imageValue)) {
return Response.found(imageValue);
}
// Local image file name (preferred), or legacy values (API URL / UUID / base64)
// Try to resolve image (now handles empty imageValue with fallback)
final resolved = await CardImageStorage.tryResolveLocalFile(
cardId: cardId,
imageValue: imageValue,
isBack: false,
);
if (resolved != null) {
// Opportunistic migration: if DB accidentally contains an API URL/UUID,
// rewrite to the real file name once we successfully resolve it.
if (resolved.fileName != card.image) {
await _db.packDao.updateCard(card.copyWith(image: resolved.fileName));
}
return Response.ok(
resolved.bytes,
headers: {
'Content-Type': resolved.contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
},
);
}
// Base64 fallback: persist to file and migrate DB.
final stored = await CardImageStorage.persistFromBase64(
cardId: cardId,
imageValue: imageValue,
preferredFileName: null,
isBack: false,
);
if (stored == null) {
if (resolved == null) {
print('Could not resolve image file for card $cardId');
return _notFound('Image not found');
}
await _db.packDao.updateCard(card.copyWith(image: stored.fileName));
final file = File('${PackManagerUtils.assetsDirectory.path}/cards/${stored.fileName}');
if (!file.existsSync()) {
return _notFound('Image not found');
// Opportunistic migration: if DB accidentally contains an API URL/UUID,
// rewrite to the real file name once we successfully resolve it.
if (resolved.fileName != card.image) {
print('Migrating image field from "${card.image}" to "${resolved.fileName}"');
await _db.packDao.updateCard(card.copyWith(image: resolved.fileName));
}
final bytes = await file.readAsBytes();
return Response.ok(
bytes,
resolved.bytes,
headers: {
'Content-Type': stored.contentType,
'Content-Type': resolved.contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
},
);

View file

@ -217,40 +217,65 @@ class CardImageStorage {
Directory? assetsDirectory,
}) async {
final assetsDir = _assetsDirectory(assetsDirectory);
print('tryResolveLocalFile: cardId=$cardId, imageValue="$imageValue", isBack=$isBack, assetsDir=${assetsDir.path}');
final v = imageValue.trim();
if (v.isEmpty) return null;
// If imageValue is not empty, try normal resolution first
if (v.isNotEmpty) {
final sanitized = sanitizeCardsFileName(v);
print('Sanitized filename: "$sanitized"');
if (sanitized != null) {
final file = File('${assetsDir.path}/cards/$sanitized');
print('Checking file: ${file.path}, exists: ${file.existsSync()}');
if (file.existsSync()) {
final bytes = await file.readAsBytes();
final contentType =
_detectContentType(bytes) ?? _contentTypeFromFileName(sanitized);
print('File found and loaded successfully');
return ResolvedCardImage(
fileName: sanitized,
bytes: bytes,
contentType: contentType,
);
} else {
print('File does not exist');
}
}
// Self-referential API URL (common admin UI round-trip bug).
if (isApiImageUrl(v)) {
return _tryCardIdFallback(
assetsDir: assetsDir,
cardId: cardId,
isBack: isBack,
);
// Self-referential API URL (common admin UI round-trip bug).
if (isApiImageUrl(v)) {
print('Image value is API URL, trying cardId fallback');
final result = await _tryCardIdFallback(
assetsDir: assetsDir,
cardId: cardId,
isBack: isBack,
);
if (result != null) return result;
}
// UUID without extension.
if (_uuidRegex.hasMatch(v)) {
print('Image value is UUID, trying baseName fallback');
final result = await _tryFileNameFallbackByBaseName(
assetsDir: assetsDir,
baseName: v,
isBack: isBack,
);
if (result != null) return result;
}
}
// UUID without extension.
if (_uuidRegex.hasMatch(v)) {
return _tryFileNameFallbackByBaseName(
assetsDir: assetsDir,
baseName: v,
isBack: isBack,
);
// Fallback: try to find image by cardId even if imageValue is empty
print('Trying fallback search for card $cardId');
final fallbackResult = await _tryFallbackCardImageSearch(
assetsDir: assetsDir,
cardId: cardId,
isBack: isBack,
);
if (fallbackResult != null) {
print('Fallback search found file: ${fallbackResult.fileName}');
return fallbackResult;
}
return null;
@ -306,6 +331,54 @@ class CardImageStorage {
return null;
}
/// Fallback search for card images when the image field is empty
/// Try to find image by cardId when DB field is not set
static Future<ResolvedCardImage?> _tryFallbackCardImageSearch({
required Directory assetsDir,
required String cardId,
required bool isBack,
}) async {
print('Trying fallback search by cardId: $cardId');
// Try to find file by cardId (UUID) as base name
final result = await _tryFileNameFallbackByBaseName(
assetsDir: assetsDir,
baseName: cardId,
isBack: isBack,
);
if (result != null) return result;
// If cardId is UUID, try to find any file that might be associated
// This is a last resort for cases where images exist but DB is not updated
final cardsDir = Directory('${assetsDir.path}/cards');
if (!cardsDir.existsSync()) return null;
final files = cardsDir.listSync().whereType<File>().toList();
// Look for files that might be named after the card
// For example, if cardId is UUID, look for files starting with same prefix
if (cardId.length >= 8) {
final prefix = cardId.substring(0, 8);
for (final file in files) {
final fileName = file.path.split('/').last;
if (fileName.startsWith(prefix)) {
final bytes = await file.readAsBytes();
final contentType = _detectContentType(bytes) ?? _contentTypeFromFileName(fileName);
print('Fallback found file by prefix: $fileName for card $cardId');
return ResolvedCardImage(
fileName: fileName,
bytes: bytes,
contentType: contentType,
);
}
}
}
print('Fallback search failed for card $cardId');
return null;
}
static String _contentTypeFromFileName(String fileName) {
final lower = fileName.toLowerCase();
if (lower.endsWith('.webp')) return 'image/webp';