upload image to nostr working

This commit is contained in:
gitea
2025-11-06 23:36:24 +01:00
parent 49dd0fdaf1
commit 2c5f0eaefc
7 changed files with 734 additions and 152 deletions
+218 -77
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
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';
@@ -10,7 +11,7 @@ import 'models/upload_response.dart';
class ImmichException implements Exception {
/// Error message.
final String message;
/// HTTP status code if available.
final int? statusCode;
@@ -27,12 +28,12 @@ class ImmichException implements Exception {
}
/// 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.
@@ -48,7 +49,7 @@ class ImmichService {
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.
@@ -64,16 +65,18 @@ class ImmichService {
_dio = dio ?? Dio() {
_dio.options.baseUrl = baseUrl;
_dio.options.headers['x-api-key'] = apiKey;
_dio.options.headers['Content-Type'] = 'application/json';
// 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<UploadResponse> uploadImage(
@@ -85,35 +88,183 @@ class ImmichService {
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': <String>[],
}
}
];
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 "[email protected];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: imageFile.path.split('/').last,
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(
'/api/asset/upload',
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: ${response.statusMessage}',
'Upload failed: $errorMessage',
response.statusCode,
);
}
final uploadResponse = UploadResponse.fromJson(response.data);
// 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<String, dynamic> 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<String, dynamic>;
debugPrint('Using first item from array response');
} else if (response.data is Map) {
responseData = response.data as Map<String, dynamic>;
} 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
final asset = await _getAssetById(uploadResponse.id);
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
await _storeAssetMetadata(asset);
// 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) {
@@ -127,15 +278,15 @@ class ImmichService {
}
/// 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<List<ImmichAsset>> fetchAssets({
@@ -163,15 +314,17 @@ class ImmichService {
// Parse response structure: {"assets": {"items": [...], "total": N, "count": N}}
final responseData = response.data as Map<String, dynamic>;
if (!responseData.containsKey('assets')) {
throw ImmichException('Unexpected response format: missing "assets" field');
throw ImmichException(
'Unexpected response format: missing "assets" field');
}
final assetsData = responseData['assets'] as Map<String, dynamic>;
if (!assetsData.containsKey('items')) {
throw ImmichException('Unexpected response format: missing "items" field in assets');
throw ImmichException(
'Unexpected response format: missing "items" field in assets');
}
final assetsJson = assetsData['items'] as List<dynamic>;
@@ -190,16 +343,13 @@ class ImmichService {
final statusCode = e.response?.statusCode;
final errorData = e.response?.data;
String errorMessage;
if (errorData is Map) {
errorMessage = errorData['message']?.toString() ??
errorData.toString();
errorMessage = errorData['message']?.toString() ?? errorData.toString();
} else {
errorMessage = errorData?.toString() ??
e.message ??
'Unknown error';
errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error';
}
throw ImmichException(
'Failed to fetch assets: $errorMessage',
statusCode,
@@ -213,14 +363,14 @@ class ImmichService {
}
/// 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<ImmichAsset> _getAssetById(String assetId) async {
try {
@@ -239,16 +389,13 @@ class ImmichService {
final statusCode = e.response?.statusCode;
final errorData = e.response?.data;
String errorMessage;
if (errorData is Map) {
errorMessage = errorData['message']?.toString() ??
errorData.toString();
errorMessage = errorData['message']?.toString() ?? errorData.toString();
} else {
errorMessage = errorData?.toString() ??
e.message ??
'Unknown error';
errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error';
}
throw ImmichException(
'Failed to fetch asset: $errorMessage',
statusCode,
@@ -259,7 +406,7 @@ class ImmichService {
}
/// Stores asset metadata in local storage.
///
///
/// [asset] - The asset to store.
Future<void> _storeAssetMetadata(ImmichAsset asset) async {
try {
@@ -279,9 +426,9 @@ class ImmichService {
}
/// Gets locally cached asset metadata.
///
///
/// [assetId] - The unique identifier of the asset.
///
///
/// Returns [ImmichAsset] if found in local storage, null otherwise.
Future<ImmichAsset?> getCachedAsset(String assetId) async {
try {
@@ -296,7 +443,7 @@ class ImmichService {
}
/// Gets all locally cached assets.
///
///
/// Returns a list of [ImmichAsset] instances from local storage.
Future<List<ImmichAsset>> getCachedAssets() async {
try {
@@ -317,22 +464,22 @@ class ImmichService {
}
/// 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';
@@ -342,22 +489,23 @@ class ImmichService {
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<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true}) async {
Future<Uint8List> fetchImageBytes(String assetId,
{bool isThumbnail = true}) async {
try {
// Use correct endpoint based on thumbnail vs original
final endpoint = isThumbnail
final endpoint = isThumbnail
? '/api/assets/$assetId/thumbnail'
: '/api/assets/$assetId/original';
final response = await _dio.get<List<int>>(
endpoint,
options: Options(
@@ -377,16 +525,13 @@ class ImmichService {
final statusCode = e.response?.statusCode;
final errorData = e.response?.data;
String errorMessage;
if (errorData is Map) {
errorMessage = errorData['message']?.toString() ??
errorData.toString();
errorMessage = errorData['message']?.toString() ?? errorData.toString();
} else {
errorMessage = errorData?.toString() ??
e.message ??
'Unknown error';
errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error';
}
throw ImmichException(
'Failed to fetch image: $errorMessage',
statusCode,
@@ -397,7 +542,7 @@ class ImmichService {
}
/// Gets the headers needed for authenticated image requests.
///
///
/// Returns a map of headers including the API key.
Map<String, String> getImageHeaders() {
return {
@@ -406,9 +551,9 @@ class ImmichService {
}
/// 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<Map<String, dynamic>> getServerInfo() async {
try {
@@ -426,16 +571,13 @@ class ImmichService {
final statusCode = e.response?.statusCode;
final errorData = e.response?.data;
String errorMessage;
if (errorData is Map) {
errorMessage = errorData['message']?.toString() ??
errorData.toString();
errorMessage = errorData['message']?.toString() ?? errorData.toString();
} else {
errorMessage = errorData?.toString() ??
e.message ??
'Unknown error';
errorMessage = errorData?.toString() ?? e.message ?? 'Unknown error';
}
throw ImmichException(
'Failed to get server info: $errorMessage',
statusCode,
@@ -445,4 +587,3 @@ class ImmichService {
}
}
}