parent
52c449356a
commit
72d2469dce
@ -0,0 +1,14 @@
|
||||
/// Exception thrown when Blossom operations fail.
|
||||
class BlossomException implements Exception {
|
||||
/// Error message.
|
||||
final String message;
|
||||
|
||||
/// HTTP status code (if applicable).
|
||||
final int? statusCode;
|
||||
|
||||
BlossomException(this.message, [this.statusCode]);
|
||||
|
||||
@override
|
||||
String toString() => 'BlossomException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
|
||||
}
|
||||
|
||||
@ -0,0 +1,262 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../core/logger.dart';
|
||||
import '../../core/exceptions/blossom_exception.dart';
|
||||
import '../local/local_storage_service.dart';
|
||||
import '../media/media_service_interface.dart';
|
||||
import '../nostr/models/nostr_event.dart';
|
||||
import '../nostr/models/nostr_keypair.dart';
|
||||
import 'models/blossom_upload_response.dart';
|
||||
|
||||
/// Service for interacting with Blossom Media Server API.
|
||||
///
|
||||
/// Blossom uses Nostr authentication (kind 24242 authorization events).
|
||||
/// See: https://github.com/hzrd149/blossom
|
||||
class BlossomService implements MediaServiceInterface {
|
||||
/// HTTP client for API requests.
|
||||
final Dio _dio;
|
||||
|
||||
/// Local storage service for caching metadata.
|
||||
final LocalStorageService _localStorage;
|
||||
|
||||
/// Blossom API base URL.
|
||||
final String _baseUrl;
|
||||
|
||||
/// Nostr keypair for authentication.
|
||||
NostrKeyPair? _nostrKeyPair;
|
||||
|
||||
/// Cache directory for storing fetched images.
|
||||
Directory? _imageCacheDirectory;
|
||||
|
||||
/// Creates a [BlossomService] instance.
|
||||
///
|
||||
/// [baseUrl] - Blossom server base URL (e.g., 'https://blossom.example.com').
|
||||
/// [localStorage] - Local storage service for caching metadata.
|
||||
/// [dio] - Optional Dio instance for dependency injection (useful for testing).
|
||||
BlossomService({
|
||||
required String baseUrl,
|
||||
required LocalStorageService localStorage,
|
||||
Dio? dio,
|
||||
}) : _baseUrl = baseUrl,
|
||||
_localStorage = localStorage,
|
||||
_dio = dio ?? Dio() {
|
||||
_dio.options.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/// Sets the Nostr keypair for authentication.
|
||||
void setNostrKeyPair(NostrKeyPair keypair) {
|
||||
_nostrKeyPair = keypair;
|
||||
}
|
||||
|
||||
/// Creates an authorization event (kind 24242) for Blossom authentication.
|
||||
NostrEvent _createAuthorizationEvent() {
|
||||
if (_nostrKeyPair == null) {
|
||||
throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.');
|
||||
}
|
||||
|
||||
// Create authorization event (kind 24242) per BUD-01
|
||||
return NostrEvent.create(
|
||||
content: '',
|
||||
kind: 24242,
|
||||
privateKey: _nostrKeyPair!.privateKey,
|
||||
tags: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Uploads an image file to Blossom.
|
||||
///
|
||||
/// [imageFile] - The image file to upload.
|
||||
///
|
||||
/// Returns [BlossomUploadResponse] containing the blob hash and URL.
|
||||
///
|
||||
/// Throws [BlossomException] if upload fails.
|
||||
Future<BlossomUploadResponse> uploadImageToBlossom(File imageFile) async {
|
||||
if (_nostrKeyPair == null) {
|
||||
throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (!await imageFile.exists()) {
|
||||
throw BlossomException('Image file does not exist: ${imageFile.path}');
|
||||
}
|
||||
|
||||
// Read file bytes and calculate SHA256 hash
|
||||
final fileBytes = await imageFile.readAsBytes();
|
||||
final hash = sha256.convert(fileBytes);
|
||||
final blobHash = hash.toString();
|
||||
|
||||
// Create authorization event
|
||||
final authEvent = _createAuthorizationEvent();
|
||||
|
||||
// Prepare multipart form data
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(
|
||||
imageFile.path,
|
||||
filename: imageFile.path.split('/').last,
|
||||
),
|
||||
});
|
||||
|
||||
// Upload to Blossom (PUT /upload per BUD-02)
|
||||
final response = await _dio.put(
|
||||
'/upload',
|
||||
data: formData,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': jsonEncode(authEvent.toJson()), // Signed Nostr event
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw BlossomException(
|
||||
'Upload failed: ${response.statusMessage}',
|
||||
response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse response (blob descriptor per BUD-02)
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
final uploadResponse = BlossomUploadResponse.fromJson(responseData);
|
||||
|
||||
Logger.info('Image uploaded to Blossom: ${uploadResponse.hash}');
|
||||
return uploadResponse;
|
||||
} on DioException catch (e) {
|
||||
throw BlossomException(
|
||||
'Upload failed: ${e.message ?? 'Unknown error'}',
|
||||
e.response?.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
throw BlossomException('Upload failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads an image file (implements MediaServiceInterface).
|
||||
@override
|
||||
Future<Map<String, dynamic>> uploadImage(File imageFile) async {
|
||||
final response = await uploadImageToBlossom(imageFile);
|
||||
return {
|
||||
'hash': response.hash,
|
||||
'id': response.hash, // For compatibility
|
||||
'url': response.url,
|
||||
};
|
||||
}
|
||||
|
||||
/// Fetches image bytes for a blob by hash with local caching.
|
||||
///
|
||||
/// [blobHash] - The SHA256 hash of the blob (or full URL).
|
||||
/// [isThumbnail] - Whether to fetch thumbnail (true) or original image (false). Default: true.
|
||||
///
|
||||
/// Returns the image bytes as Uint8List.
|
||||
///
|
||||
/// Throws [BlossomException] if fetch fails.
|
||||
Future<Uint8List> fetchImageBytes(String blobHash, {bool isThumbnail = true}) async {
|
||||
await _ensureImageCacheDirectory();
|
||||
|
||||
// Check cache first
|
||||
final cacheFilePath = _getCacheFilePath(blobHash, isThumbnail);
|
||||
if (cacheFilePath.isNotEmpty) {
|
||||
final cacheFile = File(cacheFilePath);
|
||||
if (await cacheFile.exists()) {
|
||||
try {
|
||||
final cachedBytes = await cacheFile.readAsBytes();
|
||||
Logger.debug('Loaded image from cache: $blobHash');
|
||||
return Uint8List.fromList(cachedBytes);
|
||||
} catch (e) {
|
||||
Logger.warning('Failed to read cached image, fetching fresh: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - fetch from Blossom
|
||||
try {
|
||||
// If blobHash is a full URL, use it directly
|
||||
String imageUrl;
|
||||
if (blobHash.startsWith('http://') || blobHash.startsWith('https://')) {
|
||||
imageUrl = blobHash;
|
||||
} else {
|
||||
// Construct URL: GET /<sha256> per BUD-01
|
||||
imageUrl = '$_baseUrl/$blobHash';
|
||||
}
|
||||
|
||||
final response = await _dio.get<List<int>>(
|
||||
imageUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw BlossomException(
|
||||
'Failed to fetch image: ${response.statusMessage}',
|
||||
response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final imageBytes = Uint8List.fromList(response.data ?? []);
|
||||
|
||||
// Cache the fetched image
|
||||
if (cacheFilePath.isNotEmpty && imageBytes.isNotEmpty) {
|
||||
try {
|
||||
final cacheFile = File(cacheFilePath);
|
||||
await cacheFile.writeAsBytes(imageBytes);
|
||||
Logger.debug('Cached image: $blobHash');
|
||||
} catch (e) {
|
||||
Logger.warning('Failed to cache image (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
return imageBytes;
|
||||
} on DioException catch (e) {
|
||||
throw BlossomException(
|
||||
'Failed to fetch image: ${e.message ?? 'Unknown error'}',
|
||||
e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the full image URL for a blob hash.
|
||||
///
|
||||
/// [blobHash] - The SHA256 hash of the blob (or full URL).
|
||||
///
|
||||
/// Returns the full URL to the blob.
|
||||
String getImageUrl(String blobHash) {
|
||||
// If it's already a full URL, return it
|
||||
if (blobHash.startsWith('http://') || blobHash.startsWith('https://')) {
|
||||
return blobHash;
|
||||
}
|
||||
// GET /<sha256> per BUD-01
|
||||
return '$_baseUrl/$blobHash';
|
||||
}
|
||||
|
||||
/// Gets the base URL for Blossom API.
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
/// Initializes the image cache directory.
|
||||
Future<void> _ensureImageCacheDirectory() async {
|
||||
if (_imageCacheDirectory != null) return;
|
||||
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
_imageCacheDirectory = Directory(path.join(appDir.path, 'blossom_image_cache'));
|
||||
|
||||
if (!await _imageCacheDirectory!.exists()) {
|
||||
await _imageCacheDirectory!.create(recursive: true);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.warning('Failed to initialize image cache directory: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the cache file path for an image.
|
||||
String _getCacheFilePath(String blobHash, bool isThumbnail) {
|
||||
if (_imageCacheDirectory == null) return '';
|
||||
final cacheKey = '${blobHash}_${isThumbnail ? 'thumb' : 'full'}';
|
||||
return path.join(_imageCacheDirectory!.path, cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
/// Response model for Blossom blob upload (blob descriptor per BUD-02).
|
||||
class BlossomUploadResponse {
|
||||
/// The SHA256 hash of the blob.
|
||||
final String hash;
|
||||
|
||||
/// The URL to access the blob.
|
||||
final String url;
|
||||
|
||||
/// Optional size in bytes.
|
||||
final int? size;
|
||||
|
||||
BlossomUploadResponse({
|
||||
required this.hash,
|
||||
required this.url,
|
||||
this.size,
|
||||
});
|
||||
|
||||
factory BlossomUploadResponse.fromJson(Map<String, dynamic> json) {
|
||||
return BlossomUploadResponse(
|
||||
hash: json['hash'] as String? ?? json['sha256'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
size: json['size'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Interface for media services (Immich, Blossom, etc.)
|
||||
abstract class MediaServiceInterface {
|
||||
/// Uploads an image file.
|
||||
/// Returns a map with 'id' (or 'hash') and 'url' keys.
|
||||
Future<Map<String, dynamic>> uploadImage(File imageFile);
|
||||
|
||||
/// Fetches image bytes for an asset/blob.
|
||||
Future<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true});
|
||||
|
||||
/// Gets the full image URL for an asset/blob.
|
||||
String getImageUrl(String assetId);
|
||||
|
||||
/// Gets the base URL.
|
||||
String get baseUrl;
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue