import 'dart:io'; import 'dart:typed_data'; import '../../core/logger.dart'; import '../../core/service_locator.dart'; import '../../data/local/local_storage_service.dart'; import '../../data/local/models/item.dart'; import '../../data/nostr/models/nostr_keypair.dart'; import '../../data/immich/immich_service.dart'; import '../../data/blossom/blossom_service.dart'; import 'media_service_interface.dart'; import 'models/media_server_config.dart'; /// Wrapper service that manages multiple media servers with fallback support. /// /// When uploading, it tries the default server first, then falls back to other servers /// if the default fails. class MultiMediaService implements MediaServiceInterface { final LocalStorageService _localStorage; final List _servers = []; MediaServerConfig? _defaultServer; /// Creates a [MultiMediaService] instance. MultiMediaService({ required LocalStorageService localStorage, }) : _localStorage = localStorage; /// Loads media server configurations from storage. Future loadServers() async { try { final serversItem = await _localStorage.getItem('media_servers'); if (serversItem != null && serversItem.data['servers'] != null) { final serversList = serversItem.data['servers'] as List; _servers.clear(); for (final serverJson in serversList) { final config = MediaServerConfig.fromJson(serverJson as Map); _servers.add(config); if (config.isDefault) { _defaultServer = config; } } Logger.info('Loaded ${_servers.length} media server(s)'); } } catch (e) { Logger.error('Failed to load media servers: $e', e); } } /// Saves media server configurations to storage. Future saveServers() async { try { await _localStorage.insertItem( Item( id: 'media_servers', data: { 'servers': _servers.map((s) => s.toJson()).toList(), }, ), ); Logger.info('Saved ${_servers.length} media server(s)'); } catch (e) { Logger.error('Failed to save media servers: $e', e); } } /// Adds a new media server configuration. Future addServer(MediaServerConfig config) async { // If this is the first server or it's marked as default, set it as default if (_servers.isEmpty || config.isDefault) { // Unset other defaults for (var server in _servers) { if (server.isDefault) { final index = _servers.indexOf(server); _servers[index] = server.copyWith(isDefault: false); } } _defaultServer = config; } _servers.add(config); await saveServers(); } /// Updates an existing media server configuration. Future updateServer(String id, MediaServerConfig config) async { final index = _servers.indexWhere((s) => s.id == id); if (index != -1) { // If setting as default, unset other defaults if (config.isDefault) { for (var server in _servers) { if (server.isDefault && server.id != id) { final otherIndex = _servers.indexOf(server); _servers[otherIndex] = server.copyWith(isDefault: false); } } _defaultServer = config; } _servers[index] = config; await saveServers(); } } /// Removes a media server configuration. Future removeServer(String id) async { _servers.removeWhere((s) => s.id == id); if (_defaultServer?.id == id) { _defaultServer = _servers.isNotEmpty ? _servers.first : null; if (_defaultServer != null) { final index = _servers.indexOf(_defaultServer!); _servers[index] = _defaultServer!.copyWith(isDefault: true); } } await saveServers(); } /// Gets all media server configurations. List getServers() => List.unmodifiable(_servers); /// Gets the default media server configuration. MediaServerConfig? getDefaultServer() => _defaultServer; /// Sets the default media server. Future setDefaultServer(String id) async { // Unset current default for (var server in _servers) { if (server.isDefault) { final index = _servers.indexOf(server); _servers[index] = server.copyWith(isDefault: false); } } // Set new default final index = _servers.indexWhere((s) => s.id == id); if (index != -1) { _servers[index] = _servers[index].copyWith(isDefault: true); _defaultServer = _servers[index]; await saveServers(); } } /// Creates a media service instance from a configuration. MediaServiceInterface? _createServiceFromConfig(MediaServerConfig config) { try { if (config.type == 'blossom') { final service = BlossomService( baseUrl: config.baseUrl, localStorage: _localStorage, ); // Set Nostr keypair if user is logged in final sessionService = ServiceLocator.instance.sessionService; if (sessionService != null && sessionService.isLoggedIn) { final currentUser = sessionService.currentUser; if (currentUser?.nostrPrivateKey != null) { try { final privateKey = currentUser!.nostrPrivateKey!; final keypair = privateKey.startsWith('nsec') ? NostrKeyPair.fromNsec(privateKey) : NostrKeyPair.fromHexPrivateKey(privateKey); service.setNostrKeyPair(keypair); } catch (e) { Logger.warning('Failed to set Nostr keypair for Blossom: $e'); } } } return service; } else if (config.type == 'immich') { if (config.apiKey == null || config.apiKey!.isEmpty) { Logger.warning('Immich server ${config.id} missing API key'); return null; } return ImmichService( baseUrl: config.baseUrl, apiKey: config.apiKey!, localStorage: _localStorage, ); } } catch (e) { Logger.error('Failed to create media service from config: $e', e); } return null; } @override Future> uploadImage(File imageFile) async { if (_servers.isEmpty) { throw Exception('No media servers configured'); } // Start with default server, then try others final serversToTry = []; if (_defaultServer != null) { serversToTry.add(_defaultServer!); } serversToTry.addAll(_servers.where((s) => s.id != _defaultServer?.id)); Exception? lastException; for (final config in serversToTry) { try { Logger.info('Attempting upload to ${config.type} server: ${config.baseUrl}'); final service = _createServiceFromConfig(config); if (service == null) { Logger.warning('Failed to create service for ${config.id}, trying next...'); continue; } final result = await service.uploadImage(imageFile); Logger.info('Upload successful to ${config.type} server: ${config.baseUrl}'); return result; } catch (e) { Logger.warning('Upload failed to ${config.type} server ${config.baseUrl}: $e'); lastException = e is Exception ? e : Exception(e.toString()); // Continue to next server } } // All servers failed throw lastException ?? Exception('All media servers failed'); } @override Future fetchImageBytes(String assetId, {bool isThumbnail = true}) async { if (_servers.isEmpty) { throw Exception('No media servers configured'); } // Try default server first, then others final serversToTry = []; if (_defaultServer != null) { serversToTry.add(_defaultServer!); } serversToTry.addAll(_servers.where((s) => s.id != _defaultServer?.id)); Exception? lastException; for (final config in serversToTry) { try { final service = _createServiceFromConfig(config); if (service == null) continue; return await service.fetchImageBytes(assetId, isThumbnail: isThumbnail); } catch (e) { Logger.warning('Failed to fetch image from ${config.type} server ${config.baseUrl}: $e'); lastException = e is Exception ? e : Exception(e.toString()); // Continue to next server } } throw lastException ?? Exception('All media servers failed'); } @override String getImageUrl(String assetId) { if (_defaultServer != null) { final service = _createServiceFromConfig(_defaultServer!); if (service != null) { return service.getImageUrl(assetId); } } // Fallback to first available server if (_servers.isNotEmpty) { final service = _createServiceFromConfig(_servers.first); if (service != null) { return service.getImageUrl(assetId); } } return assetId; // Fallback to raw asset ID } @override String get baseUrl { if (_defaultServer != null) { return _defaultServer!.baseUrl; } return _servers.isNotEmpty ? _servers.first.baseUrl : ''; } /// Clears the cache for a specific image URL or asset ID. /// Tries all configured servers to clear the cache. @override Future clearImageCache(String imageUrlOrAssetId) async { // Try to clear cache from all configured servers for (final config in _servers) { try { final service = _createServiceFromConfig(config); if (service != null) { await service.clearImageCache(imageUrlOrAssetId); } } catch (e) { Logger.warning('Failed to clear cache from ${config.type} server ${config.baseUrl}: $e'); // Continue to next server } } } }