import 'dart:io'; import 'dart:typed_data'; 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. /// /// Based on official Immich API documentation: https://api.immich.app/endpoints/search/searchAssets /// Uses POST /api/search/metadata endpoint with search parameters. /// /// [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 { // Official endpoint: POST /api/search/metadata // Response structure: {"assets": {"items": [...], "total": N, "count": N, "nextPage": ...}} final response = await _dio.post( '/api/search/metadata', data: { 'limit': limit, 'skip': skip, // Empty search to get all assets }, ); if (response.statusCode != 200 && response.statusCode != 201) { throw ImmichException( 'Failed to fetch assets: ${response.statusMessage}', response.statusCode, ); } // Parse response structure: {"assets": {"items": [...], "total": N, "count": N}} final responseData = response.data as Map; if (!responseData.containsKey('assets')) { throw ImmichException('Unexpected response format: missing "assets" field'); } final assetsData = responseData['assets'] as Map; if (!assetsData.containsKey('items')) { throw ImmichException('Unexpected response format: missing "items" field in assets'); } final assetsJson = assetsData['items'] as List; final total = assetsData['total'] as int? ?? 0; final count = assetsData['count'] as int? ?? 0; 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) { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; if (errorData is Map) { errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } throw ImmichException( 'Failed to fetch assets: $errorMessage', statusCode, ); } catch (e) { if (e is ImmichException) { rethrow; } throw ImmichException('Failed to fetch assets: $e'); } } /// Fetches a single asset by ID. /// /// Based on official Immich API documentation: https://api.immich.app/endpoints/assets /// Endpoint: GET /api/assets/{id} /// /// [assetId] - The unique identifier (UUID) of the asset. /// /// Returns [ImmichAsset] if found. /// /// Throws [ImmichException] if fetch fails. Future _getAssetById(String assetId) async { try { // Official endpoint: GET /api/assets/{id} final response = await _dio.get('/api/assets/$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) { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; if (errorData is Map) { errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } throw ImmichException( 'Failed to fetch asset: $errorMessage', 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 []; } } /// Gets the thumbnail URL for an asset. /// /// Uses GET /api/assets/{id}/thumbnail endpoint. /// /// [assetId] - The unique identifier of the asset. /// /// Returns the full URL to the thumbnail image. String getThumbnailUrl(String assetId) { return '$_baseUrl/api/assets/$assetId/thumbnail'; } /// Gets the full image URL for an asset. /// /// Uses GET /api/assets/{id}/original endpoint. /// /// [assetId] - The unique identifier of the asset. /// /// Returns the full URL to the original image file. String getImageUrl(String assetId) { return '$_baseUrl/api/assets/$assetId/original'; } /// Gets the base URL for Immich API. String get baseUrl => _baseUrl; /// Fetches image bytes for an asset. /// /// Uses GET /api/assets/{id}/thumbnail for thumbnails or GET /api/assets/{id}/original for full images. /// /// [assetId] - The unique identifier of the asset (from metadata response). /// [isThumbnail] - Whether to fetch thumbnail (true) or original image (false). Default: true. /// /// Returns the image bytes as Uint8List. /// /// Throws [ImmichException] if fetch fails. Future fetchImageBytes(String assetId, {bool isThumbnail = true}) async { try { // Use correct endpoint based on thumbnail vs original final endpoint = isThumbnail ? '/api/assets/$assetId/thumbnail' : '/api/assets/$assetId/original'; final response = await _dio.get>( endpoint, options: Options( responseType: ResponseType.bytes, ), ); if (response.statusCode != 200) { throw ImmichException( 'Failed to fetch image: ${response.statusMessage}', response.statusCode, ); } return Uint8List.fromList(response.data ?? []); } on DioException catch (e) { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; if (errorData is Map) { errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } throw ImmichException( 'Failed to fetch image: $errorMessage', statusCode, ); } catch (e) { throw ImmichException('Failed to fetch image: $e'); } } /// Gets the headers needed for authenticated image requests. /// /// Returns a map of headers including the API key. Map getImageHeaders() { return { 'x-api-key': _apiKey, }; } /// Tests the connection to Immich server by calling the /api/server/about endpoint. /// /// Returns server information including version and status. /// /// Throws [ImmichException] if the request fails. Future> getServerInfo() async { try { final response = await _dio.get('/api/server/about'); if (response.statusCode != 200) { throw ImmichException( 'Failed to get server info: ${response.statusMessage}', response.statusCode, ); } return response.data as Map; } on DioException catch (e) { final statusCode = e.response?.statusCode; final errorData = e.response?.data; String errorMessage; if (errorData is Map) { errorMessage = errorData['message']?.toString() ?? errorData.toString(); } else { errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error'; } throw ImmichException( 'Failed to get server info: $errorMessage', statusCode, ); } catch (e) { throw ImmichException('Failed to get server info: $e'); } } }