import 'dart:io'; import 'package:dio/dio.dart'; import '../local/local_storage_service.dart'; import '../local/models/item.dart'; import 'models/immich_asset.dart'; import 'models/upload_response.dart'; /// Exception thrown when Immich API operations fail. class ImmichException implements Exception { /// Error message. final String message; /// HTTP status code if available. final int? statusCode; /// Creates an [ImmichException] with the provided message. ImmichException(this.message, [this.statusCode]); @override String toString() { if (statusCode != null) { return 'ImmichException: $message (Status: $statusCode)'; } return 'ImmichException: $message'; } } /// Service for interacting with Immich API. /// /// This service provides: /// - Upload images to Immich /// - Fetch image lists from Immich /// - Store image metadata locally after uploads /// /// The service is modular and UI-independent, designed for offline-first behavior. class ImmichService { /// HTTP client for API requests. final Dio _dio; /// Local storage service for caching metadata. final LocalStorageService _localStorage; /// Immich API base URL. final String _baseUrl; /// Immich API key for authentication. final String _apiKey; /// Creates an [ImmichService] instance. /// /// [baseUrl] - Immich server base URL (e.g., 'https://immich.example.com'). /// [apiKey] - Immich API key for authentication. /// [localStorage] - Local storage service for caching metadata. /// [dio] - Optional Dio instance for dependency injection (useful for testing). ImmichService({ required String baseUrl, required String apiKey, required LocalStorageService localStorage, Dio? dio, }) : _baseUrl = baseUrl, _apiKey = apiKey, _localStorage = localStorage, _dio = dio ?? Dio() { _dio.options.baseUrl = baseUrl; _dio.options.headers['x-api-key'] = apiKey; _dio.options.headers['Content-Type'] = 'application/json'; } /// Uploads an image file to Immich. /// /// [imageFile] - The image file to upload. /// [albumId] - Optional album ID to add the image to. /// /// Returns [UploadResponse] containing the uploaded asset ID. /// /// Throws [ImmichException] if upload fails. /// Automatically stores metadata in local storage upon successful upload. Future uploadImage( File imageFile, { String? albumId, }) async { try { if (!await imageFile.exists()) { throw ImmichException('Image file does not exist: ${imageFile.path}'); } // Prepare form data for multipart upload final formData = FormData.fromMap({ 'assetData': await MultipartFile.fromFile( imageFile.path, filename: imageFile.path.split('/').last, ), if (albumId != null) 'albumId': albumId, }); // Upload to Immich final response = await _dio.post( '/api/asset/upload', data: formData, ); if (response.statusCode != 200 && response.statusCode != 201) { throw ImmichException( 'Upload failed: ${response.statusMessage}', response.statusCode, ); } final uploadResponse = UploadResponse.fromJson(response.data); // Fetch full asset details to store complete metadata final asset = await _getAssetById(uploadResponse.id); // Store metadata in local storage await _storeAssetMetadata(asset); return uploadResponse; } on DioException catch (e) { throw ImmichException( 'Upload failed: ${e.message ?? 'Unknown error'}', e.response?.statusCode, ); } catch (e) { throw ImmichException('Upload failed: $e'); } } /// Fetches a list of assets from Immich. /// /// [limit] - Maximum number of assets to fetch (default: 100). /// [skip] - Number of assets to skip (for pagination). /// /// Returns a list of [ImmichAsset] instances. /// /// Throws [ImmichException] if fetch fails. /// Automatically stores fetched metadata in local storage. Future> fetchAssets({ int limit = 100, int skip = 0, }) async { try { final response = await _dio.get( '/api/asset', queryParameters: { 'limit': limit, 'skip': skip, }, ); if (response.statusCode != 200) { throw ImmichException( 'Failed to fetch assets: ${response.statusMessage}', response.statusCode, ); } final List assetsJson = response.data; final List assets = assetsJson .map((json) => ImmichAsset.fromJson(json as Map)) .toList(); // Store all fetched assets in local storage for (final asset in assets) { await _storeAssetMetadata(asset); } return assets; } on DioException catch (e) { throw ImmichException( 'Failed to fetch assets: ${e.message ?? 'Unknown error'}', e.response?.statusCode, ); } catch (e) { throw ImmichException('Failed to fetch assets: $e'); } } /// Fetches a single asset by ID. /// /// [assetId] - The unique identifier of the asset. /// /// Returns [ImmichAsset] if found. /// /// Throws [ImmichException] if fetch fails. Future _getAssetById(String assetId) async { try { final response = await _dio.get('/api/asset/$assetId'); if (response.statusCode != 200) { throw ImmichException( 'Failed to fetch asset: ${response.statusMessage}', response.statusCode, ); } return ImmichAsset.fromJson(response.data as Map); } on DioException catch (e) { throw ImmichException( 'Failed to fetch asset: ${e.message ?? 'Unknown error'}', e.response?.statusCode, ); } catch (e) { throw ImmichException('Failed to fetch asset: $e'); } } /// Stores asset metadata in local storage. /// /// [asset] - The asset to store. Future _storeAssetMetadata(ImmichAsset asset) async { try { final item = Item( id: 'immich_${asset.id}', data: { 'type': 'immich_asset', 'asset': asset.toJson(), }, ); await _localStorage.insertItem(item); } catch (e) { // Log error but don't fail the upload/fetch operation // Local storage is a cache, not critical for the operation } } /// Gets locally cached asset metadata. /// /// [assetId] - The unique identifier of the asset. /// /// Returns [ImmichAsset] if found in local storage, null otherwise. Future getCachedAsset(String assetId) async { try { final item = await _localStorage.getItem('immich_$assetId'); if (item == null) return null; final assetData = item.data['asset'] as Map; return ImmichAsset.fromLocalJson(assetData); } catch (e) { return null; } } /// Gets all locally cached assets. /// /// Returns a list of [ImmichAsset] instances from local storage. Future> getCachedAssets() async { try { final items = await _localStorage.getAllItems(); final List assets = []; for (final item in items) { if (item.data['type'] == 'immich_asset') { final assetData = item.data['asset'] as Map; assets.add(ImmichAsset.fromLocalJson(assetData)); } } return assets; } catch (e) { return []; } } }