import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.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; // Don't set Content-Type globally - it should be set per request // For JSON requests, it will be set automatically // For multipart uploads, Dio will set it with the correct boundary } /// 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}'); } // Get file metadata final fileName = imageFile.path.split('/').last; final fileStat = await imageFile.stat(); final fileCreatedAt = fileStat.changed; final fileModifiedAt = fileStat.modified; // Determine MIME type from file extension String mimeType = 'image/jpeg'; // default final extension = fileName.split('.').last.toLowerCase(); switch (extension) { case 'png': mimeType = 'image/png'; break; case 'jpg': case 'jpeg': mimeType = 'image/jpeg'; break; case 'gif': mimeType = 'image/gif'; break; case 'webp': mimeType = 'image/webp'; break; case 'heic': case 'heif': mimeType = 'image/heic'; break; } // Generate device IDs (required by Immich API) // Using a consistent device ID based on the app const deviceId = 'flutter-app-boilerplate'; final deviceAssetId = 'device-asset-${fileCreatedAt.millisecondsSinceEpoch}'; // Format dates in ISO 8601 format (UTC) final fileCreatedAtIso = fileCreatedAt.toUtc().toIso8601String(); final fileModifiedAtIso = fileModifiedAt.toUtc().toIso8601String(); // Prepare metadata according to Immich API format // Format: [{"key":"mobile-app","value":{"caption":"...","tags":[]}}] final metadata = [ { 'key': 'mobile-app', 'value': { 'caption': fileName, 'tags': [], } } ]; final metadataJson = jsonEncode(metadata); // Prepare form data for multipart upload // Based on working curl command: // curl -X POST "https://photos.satoshinakamoto.win/api/assets" \ // -H "x-api-key: ..." \ // -H "Content-Type: multipart/form-data" \ // -F "assetData=@file.png;type=image/png" \ // -F "deviceAssetId=device-asset-001" \ // -F "deviceId=device-123" \ // -F "fileCreatedAt=2025-11-06T12:34:56Z" \ // -F "fileModifiedAt=2025-11-06T12:34:56Z" \ // -F 'metadata=[{"key":"mobile-app","value":{"caption":"Test image","tags":[]}}]' final formData = FormData.fromMap({ 'assetData': await MultipartFile.fromFile( imageFile.path, filename: fileName, ), 'deviceAssetId': deviceAssetId, 'deviceId': deviceId, 'fileCreatedAt': fileCreatedAtIso, 'fileModifiedAt': fileModifiedAtIso, 'metadata': metadataJson, if (albumId != null) 'albumId': albumId, }); // Upload to Immich // According to Immich API documentation: POST /api/assets // Note: Don't set Content-Type header manually for multipart/form-data // Dio will set it automatically with the correct boundary // Determine the correct endpoint path // If baseUrl already ends with /api, don't add it again String endpointPath; if (_baseUrl.endsWith('/api')) { endpointPath = '/assets'; } else if (_baseUrl.endsWith('/api/')) { endpointPath = 'assets'; } else { endpointPath = '/api/assets'; } final uploadUrl = '$_baseUrl$endpointPath'; debugPrint('=== Immich Upload Request ==='); debugPrint('URL: $uploadUrl'); debugPrint('Base URL: $_baseUrl'); debugPrint('File: $fileName, Size: ${await imageFile.length()} bytes'); debugPrint('Device ID: $deviceId'); debugPrint('Device Asset ID: $deviceAssetId'); debugPrint('File Created At: $fileCreatedAtIso'); debugPrint('File Modified At: $fileModifiedAtIso'); debugPrint('Metadata: $metadataJson'); final response = await _dio.post( endpointPath, data: formData, options: Options( headers: { 'x-api-key': _apiKey, // Don't set Content-Type - Dio handles it automatically for FormData }, ), ); debugPrint('=== Immich Upload Response ==='); debugPrint('Status Code: ${response.statusCode}'); debugPrint('Response Data: ${response.data}'); debugPrint('Response Headers: ${response.headers}'); if (response.statusCode != 200 && response.statusCode != 201) { final errorMessage = response.data is Map ? (response.data as Map)['message']?.toString() ?? response.statusMessage : response.statusMessage; debugPrint('Upload failed with status ${response.statusCode}: $errorMessage'); throw ImmichException( 'Upload failed: $errorMessage', response.statusCode, ); } // Log the response data structure if (response.data is Map) { debugPrint('Response is Map with keys: ${(response.data as Map).keys}'); debugPrint('Full response map: ${response.data}'); } else if (response.data is List) { debugPrint('Response is List with ${(response.data as List).length} items'); debugPrint('First item: ${(response.data as List).first}'); } else { debugPrint('Response type: ${response.data.runtimeType}'); debugPrint('Response value: ${response.data}'); } // Handle response - it might be a single object or an array Map responseData; if (response.data is List && (response.data as List).isNotEmpty) { // If response is an array, take the first item responseData = (response.data as List).first as Map; debugPrint('Using first item from array response'); } else if (response.data is Map) { responseData = response.data as Map; } else { throw ImmichException( 'Unexpected response format: ${response.data.runtimeType}', response.statusCode, ); } final uploadResponse = UploadResponse.fromJson(responseData); debugPrint('Parsed Upload Response:'); debugPrint(' ID: ${uploadResponse.id}'); debugPrint(' Duplicate: ${uploadResponse.duplicate}'); // Fetch full asset details to store complete metadata debugPrint('Fetching full asset details for ID: ${uploadResponse.id}'); try { final asset = await _getAssetById(uploadResponse.id); debugPrint('Fetched asset: ${asset.id}, ${asset.fileName}'); // Store metadata in local storage debugPrint('Storing asset metadata in local storage'); await _storeAssetMetadata(asset); debugPrint('Asset metadata stored successfully'); } catch (e) { // Log error but don't fail the upload - asset was uploaded successfully debugPrint('Warning: Failed to fetch/store asset metadata: $e'); debugPrint('Upload was successful, but metadata caching failed'); } 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 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, }; } /// Deletes assets from Immich. /// /// [assetIds] - List of asset UUIDs to delete. /// /// Throws [ImmichException] if deletion fails. Future deleteAssets(List assetIds) async { if (assetIds.isEmpty) { throw ImmichException('No asset IDs provided for deletion'); } try { debugPrint('=== Immich Delete Assets ==='); debugPrint('Asset IDs to delete: $assetIds'); debugPrint('Count: ${assetIds.length}'); // DELETE /api/assets with ids in request body // According to Immich API: DELETE /api/assets with body: {"ids": ["uuid1", "uuid2", ...]} final requestBody = { 'ids': assetIds, }; debugPrint('Request body: $requestBody'); final response = await _dio.delete( '/api/assets', data: requestBody, options: Options( headers: { 'x-api-key': _apiKey, 'Content-Type': 'application/json', }, ), ); debugPrint('=== Immich Delete Response ==='); debugPrint('Status Code: ${response.statusCode}'); debugPrint('Response Data: ${response.data}'); if (response.statusCode != 200 && response.statusCode != 204) { final errorMessage = response.data is Map ? (response.data as Map)['message']?.toString() ?? response.statusMessage : response.statusMessage; throw ImmichException( 'Delete failed: $errorMessage', response.statusCode, ); } // Remove deleted assets from local cache for (final assetId in assetIds) { try { await _localStorage.deleteItem('immich_$assetId'); } catch (e) { debugPrint('Warning: Failed to remove asset $assetId from cache: $e'); } } debugPrint('Successfully deleted ${assetIds.length} asset(s)'); } 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( 'Delete failed: $errorMessage', statusCode, ); } catch (e) { throw ImmichException('Delete failed: $e'); } } /// 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'); } } }