diff --git a/lib/providers/extension_models.dart b/lib/providers/extension_models.dart new file mode 100644 index 00000000..2adff7b3 --- /dev/null +++ b/lib/providers/extension_models.dart @@ -0,0 +1,771 @@ +part of 'extension_provider.dart'; + +// Manifest-backed models: extension metadata, capabilities, health, and +// install results. + +class ExtensionRestoreResult { + final int installed; + final int alreadyPresent; + final int failed; + final List failedIds; + + const ExtensionRestoreResult({ + this.installed = 0, + this.alreadyPresent = 0, + this.failed = 0, + this.failedIds = const [], + }); +} + +bool _stringListEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +List? _tryDecodeStringListPreference(String rawJson, String key) { + try { + final decoded = jsonDecode(rawJson); + if (decoded is! List) { + throw const FormatException('expected a JSON list'); + } + + final values = []; + for (final item in decoded) { + if (item is! String) { + throw const FormatException('expected string entries'); + } + final trimmed = item.trim(); + if (trimmed.isNotEmpty) { + values.add(trimmed); + } + } + return values; + } catch (e) { + _log.w('Ignoring invalid $key preference: $e'); + return null; + } +} + +/// First enabled custom-search extension, preferring ones marked primary. +Extension? defaultSearchExtension(List extensions) { + return extensions + .where( + (ext) => + ext.enabled && + ext.hasCustomSearch && + ext.searchBehavior?.primary == true, + ) + .firstOrNull ?? + extensions.where((ext) => ext.enabled && ext.hasCustomSearch).firstOrNull; +} + +class Extension { + final String id; + final String name; + final String displayName; + final String version; + final String description; + final bool enabled; + final String status; + final String? errorMessage; + final String? iconPath; + final List permissions; + final List settings; + final List qualityOptions; + final bool hasMetadataProvider; + final bool hasDownloadProvider; + final bool hasLyricsProvider; + final bool skipMetadataEnrichment; + final bool skipLyrics; + final bool stopProviderFallback; + final SearchBehavior? searchBehavior; + final URLHandler? urlHandler; + final TrackMatching? trackMatching; + final PostProcessing? postProcessing; + final List serviceHealth; + final Map capabilities; + + const Extension({ + required this.id, + required this.name, + required this.displayName, + required this.version, + required this.description, + required this.enabled, + required this.status, + this.errorMessage, + this.iconPath, + this.permissions = const [], + this.settings = const [], + this.qualityOptions = const [], + this.hasMetadataProvider = false, + this.hasDownloadProvider = false, + this.hasLyricsProvider = false, + this.skipMetadataEnrichment = false, + this.skipLyrics = false, + this.stopProviderFallback = false, + this.searchBehavior, + this.urlHandler, + this.trackMatching, + this.postProcessing, + this.serviceHealth = const [], + this.capabilities = const {}, + }); + + factory Extension.fromJson(Map json) { + return Extension( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + displayName: + json['display_name'] as String? ?? json['name'] as String? ?? '', + version: json['version'] as String? ?? '0.0.0', + description: json['description'] as String? ?? '', + enabled: json['enabled'] as bool? ?? false, + status: json['status'] as String? ?? 'loaded', + errorMessage: json['error_message'] as String?, + iconPath: json['icon_path'] as String?, + permissions: + (json['permissions'] as List?)?.cast() ?? [], + settings: + (json['settings'] as List?) + ?.map((s) => ExtensionSetting.fromJson(s as Map)) + .toList() ?? + [], + qualityOptions: + (json['quality_options'] as List?) + ?.map((q) => QualityOption.fromJson(q as Map)) + .toList() ?? + [], + hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, + hasDownloadProvider: json['has_download_provider'] as bool? ?? false, + hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false, + skipMetadataEnrichment: + json['skip_metadata_enrichment'] as bool? ?? false, + skipLyrics: json['skip_lyrics'] as bool? ?? false, + stopProviderFallback: json['stop_provider_fallback'] as bool? ?? false, + searchBehavior: json['search_behavior'] != null + ? SearchBehavior.fromJson( + json['search_behavior'] as Map, + ) + : null, + urlHandler: json['url_handler'] != null + ? URLHandler.fromJson(json['url_handler'] as Map) + : null, + trackMatching: json['track_matching'] != null + ? TrackMatching.fromJson( + json['track_matching'] as Map, + ) + : null, + postProcessing: json['post_processing'] != null + ? PostProcessing.fromJson( + json['post_processing'] as Map, + ) + : null, + serviceHealth: + (json['service_health'] as List?) + ?.map( + (h) => ExtensionServiceHealthCheck.fromJson( + h as Map, + ), + ) + .toList() ?? + [], + capabilities: (json['capabilities'] as Map?) ?? const {}, + ); + } + + Extension copyWith({ + String? id, + String? name, + String? displayName, + String? version, + String? description, + bool? enabled, + String? status, + String? errorMessage, + String? iconPath, + List? permissions, + List? settings, + List? qualityOptions, + bool? hasMetadataProvider, + bool? hasDownloadProvider, + bool? hasLyricsProvider, + bool? skipMetadataEnrichment, + bool? skipLyrics, + bool? stopProviderFallback, + SearchBehavior? searchBehavior, + URLHandler? urlHandler, + TrackMatching? trackMatching, + PostProcessing? postProcessing, + List? serviceHealth, + Map? capabilities, + }) { + return Extension( + id: id ?? this.id, + name: name ?? this.name, + displayName: displayName ?? this.displayName, + version: version ?? this.version, + description: description ?? this.description, + enabled: enabled ?? this.enabled, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + iconPath: iconPath ?? this.iconPath, + permissions: permissions ?? this.permissions, + settings: settings ?? this.settings, + qualityOptions: qualityOptions ?? this.qualityOptions, + hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, + hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, + hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider, + skipMetadataEnrichment: + skipMetadataEnrichment ?? this.skipMetadataEnrichment, + skipLyrics: skipLyrics ?? this.skipLyrics, + stopProviderFallback: stopProviderFallback ?? this.stopProviderFallback, + searchBehavior: searchBehavior ?? this.searchBehavior, + urlHandler: urlHandler ?? this.urlHandler, + trackMatching: trackMatching ?? this.trackMatching, + postProcessing: postProcessing ?? this.postProcessing, + serviceHealth: serviceHealth ?? this.serviceHealth, + capabilities: capabilities ?? this.capabilities, + ); + } + + bool get hasCustomSearch => searchBehavior?.enabled ?? false; + bool get hasURLHandler => urlHandler?.enabled ?? false; + bool get hasCustomMatching => trackMatching?.customMatching ?? false; + bool get hasPostProcessing => postProcessing?.enabled ?? false; + bool get hasServiceHealth => serviceHealth.isNotEmpty; + bool get hasHomeFeed => capabilities['homeFeed'] == true; + bool get requiresNativeContainerConversion => + capabilities['requiresContainerConversion'] == true || + capabilities['requiresNativeContainerConversion'] == true; + List get replacesBuiltInProviders { + final value = capabilities['replacesBuiltInProviders']; + if (value is! List) return const []; + + final normalized = []; + for (final item in value) { + if (item is! String) continue; + final trimmed = item.trim().toLowerCase(); + if (trimmed.isEmpty || normalized.contains(trimmed)) continue; + normalized.add(trimmed); + } + return normalized; + } + + String? get preferredDownloadOutputExtension { + final value = capabilities['downloadOutputExtension']; + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + List get preservedNativeOutputExtensions { + final value = capabilities['preserveNativeOutputExtensions']; + if (value is! List) return const []; + + final normalized = []; + for (final item in value) { + if (item is! String) continue; + final trimmed = item.trim().toLowerCase(); + if (trimmed.isEmpty) continue; + normalized.add(trimmed.startsWith('.') ? trimmed : '.$trimmed'); + } + return normalized; + } +} + +String resolveEffectiveDownloadService( + String requestedService, + ExtensionState extensionState, +) => _resolveEffectiveProvider( + requestedService, + extensionState, + (ext) => ext.hasDownloadProvider, +); + +String resolveEffectiveMetadataProvider( + String requestedProvider, + ExtensionState extensionState, +) => _resolveEffectiveProvider( + requestedProvider, + extensionState, + (ext) => ext.hasMetadataProvider, +); + +String _resolveEffectiveProvider( + String requested, + ExtensionState extensionState, + bool Function(Extension) hasProvider, +) { + final normalizedRequested = requested.trim().toLowerCase(); + final enabled = extensionState.extensions + .where((ext) => ext.enabled && hasProvider(ext)) + .toList(growable: false); + + if (normalizedRequested.isNotEmpty) { + final matching = enabled + .where((ext) => ext.id.trim().toLowerCase() == normalizedRequested) + .firstOrNull; + if (matching != null) { + return matching.id; + } + + final replacement = enabled + .where( + (ext) => ext.replacesBuiltInProviders.contains(normalizedRequested), + ) + .firstOrNull; + if (replacement != null) { + return replacement.id; + } + } + + return enabled.firstOrNull?.id ?? ''; +} + +bool isDeezerCompatibleDownloadService( + String service, + ExtensionState extensionState, +) { + final normalizedService = service.trim().toLowerCase(); + if (normalizedService.isEmpty) { + return false; + } + + return extensionState.extensions.any( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + ext.id.trim().toLowerCase() == normalizedService && + ext.replacesBuiltInProviders.contains('deezer'), + ); +} + +class SearchFilter { + final String id; + final String? label; + final String? icon; + + const SearchFilter({required this.id, this.label, this.icon}); + + factory SearchFilter.fromJson(Map json) { + return SearchFilter( + id: json['id'] as String? ?? '', + label: json['label'] as String?, + icon: json['icon'] as String?, + ); + } +} + +class SearchBehavior { + final bool enabled; + final String? placeholder; + final bool primary; + final String? icon; + final String? thumbnailRatio; + final int? thumbnailWidth; + final int? thumbnailHeight; + final List filters; + + const SearchBehavior({ + required this.enabled, + this.placeholder, + this.primary = false, + this.icon, + this.thumbnailRatio, + this.thumbnailWidth, + this.thumbnailHeight, + this.filters = const [], + }); + + factory SearchBehavior.fromJson(Map json) { + return SearchBehavior( + enabled: json['enabled'] as bool? ?? false, + placeholder: json['placeholder'] as String?, + primary: json['primary'] as bool? ?? false, + icon: json['icon'] as String?, + thumbnailRatio: json['thumbnailRatio'] as String?, + thumbnailWidth: json['thumbnailWidth'] as int?, + thumbnailHeight: json['thumbnailHeight'] as int?, + filters: + (json['filters'] as List?) + ?.map((f) => SearchFilter.fromJson(f as Map)) + .toList() ?? + [], + ); + } + + (double, double) getThumbnailSize({double defaultSize = 56}) { + if (thumbnailWidth != null && thumbnailHeight != null) { + return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); + } + + switch (thumbnailRatio) { + case 'wide': + return (defaultSize * 16 / 9, defaultSize); + case 'portrait': + return (defaultSize * 2 / 3, defaultSize); + case 'square': + default: + return (defaultSize, defaultSize); + } + } +} + +class TrackMatching { + final bool customMatching; + final String? strategy; + final int durationTolerance; + + const TrackMatching({ + required this.customMatching, + this.strategy, + this.durationTolerance = 3, + }); + + factory TrackMatching.fromJson(Map json) { + return TrackMatching( + customMatching: json['customMatching'] as bool? ?? false, + strategy: json['strategy'] as String?, + durationTolerance: json['durationTolerance'] as int? ?? 3, + ); + } +} + +class PostProcessing { + final bool enabled; + final List hooks; + + const PostProcessing({required this.enabled, this.hooks = const []}); + + factory PostProcessing.fromJson(Map json) { + return PostProcessing( + enabled: json['enabled'] as bool? ?? false, + hooks: + (json['hooks'] as List?) + ?.map( + (h) => PostProcessingHook.fromJson(h as Map), + ) + .toList() ?? + [], + ); + } +} + +class URLHandler { + final bool enabled; + final List patterns; + + const URLHandler({required this.enabled, this.patterns = const []}); + + factory URLHandler.fromJson(Map json) { + return URLHandler( + enabled: json['enabled'] as bool? ?? false, + patterns: (json['patterns'] as List?)?.cast() ?? [], + ); + } +} + +class ExtensionServiceHealthCheck { + final String id; + final String? label; + final String url; + final String method; + final String? serviceKey; + final int? timeoutMs; + final int? cacheTtlSeconds; + final bool required; + + const ExtensionServiceHealthCheck({ + required this.id, + this.label, + required this.url, + this.method = 'GET', + this.serviceKey, + this.timeoutMs, + this.cacheTtlSeconds, + this.required = false, + }); + + factory ExtensionServiceHealthCheck.fromJson(Map json) { + return ExtensionServiceHealthCheck( + id: json['id'] as String? ?? '', + label: json['label'] as String?, + url: json['url'] as String? ?? '', + method: json['method'] as String? ?? 'GET', + serviceKey: json['serviceKey'] as String?, + timeoutMs: json['timeoutMs'] as int?, + cacheTtlSeconds: json['cacheTtlSeconds'] as int?, + required: json['required'] as bool? ?? false, + ); + } +} + +class ExtensionHealthStatus { + final String extensionId; + final String status; + final DateTime? checkedAt; + final List checks; + + const ExtensionHealthStatus({ + required this.extensionId, + required this.status, + this.checkedAt, + this.checks = const [], + }); + + factory ExtensionHealthStatus.fromJson(Map json) { + return ExtensionHealthStatus( + extensionId: json['extension_id'] as String? ?? '', + status: json['status'] as String? ?? 'unknown', + checkedAt: DateTime.tryParse(json['checked_at'] as String? ?? ''), + checks: + (json['checks'] as List?) + ?.map( + (c) => ExtensionHealthCheckStatus.fromJson( + c as Map, + ), + ) + .toList() ?? + [], + ); + } + + bool get isSupported => status != 'unsupported'; +} + +class ExtensionHealthCheckStatus { + final String id; + final String? label; + final String url; + final String method; + final String? serviceKey; + final bool required; + final String status; + final int? httpStatus; + final int latencyMs; + final String? message; + final String? error; + final DateTime? checkedAt; + + const ExtensionHealthCheckStatus({ + required this.id, + this.label, + required this.url, + required this.method, + this.serviceKey, + this.required = false, + required this.status, + this.httpStatus, + this.latencyMs = 0, + this.message, + this.error, + this.checkedAt, + }); + + factory ExtensionHealthCheckStatus.fromJson(Map json) { + return ExtensionHealthCheckStatus( + id: json['id'] as String? ?? '', + label: json['label'] as String?, + url: json['url'] as String? ?? '', + method: json['method'] as String? ?? 'GET', + serviceKey: json['service_key'] as String?, + required: json['required'] as bool? ?? false, + status: json['status'] as String? ?? 'unknown', + httpStatus: json['http_status'] as int?, + latencyMs: json['latency_ms'] as int? ?? 0, + message: json['message'] as String?, + error: json['error'] as String?, + checkedAt: DateTime.tryParse(json['checked_at'] as String? ?? ''), + ); + } + + String get displayLabel => label?.trim().isNotEmpty == true ? label! : id; +} + +class PostProcessingHook { + final String id; + final String name; + final String? description; + final bool defaultEnabled; + final List supportedFormats; + + const PostProcessingHook({ + required this.id, + required this.name, + this.description, + this.defaultEnabled = false, + this.supportedFormats = const [], + }); + + factory PostProcessingHook.fromJson(Map json) { + return PostProcessingHook( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + description: json['description'] as String?, + defaultEnabled: json['defaultEnabled'] as bool? ?? false, + supportedFormats: + (json['supportedFormats'] as List?)?.cast() ?? [], + ); + } +} + +class QualityOption { + final String id; + final String label; + final String? description; + final List settings; + + const QualityOption({ + required this.id, + required this.label, + this.description, + this.settings = const [], + }); + + factory QualityOption.fromJson(Map json) { + return QualityOption( + id: json['id'] as String? ?? '', + label: json['label'] as String? ?? '', + description: json['description'] as String?, + settings: + (json['settings'] as List?) + ?.map( + (s) => + QualitySpecificSetting.fromJson(s as Map), + ) + .toList() ?? + [], + ); + } +} + +class QualitySpecificSetting { + final String key; + final String label; + final String type; + final dynamic defaultValue; + final String? description; + final List? options; + final bool required; + final bool secret; + + const QualitySpecificSetting({ + required this.key, + required this.label, + required this.type, + this.defaultValue, + this.description, + this.options, + this.required = false, + this.secret = false, + }); + + factory QualitySpecificSetting.fromJson(Map json) { + return QualitySpecificSetting( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + type: json['type'] as String? ?? 'string', + defaultValue: json['default'], + description: json['description'] as String?, + options: (json['options'] as List?)?.cast(), + required: json['required'] as bool? ?? false, + secret: json['secret'] as bool? ?? false, + ); + } +} + +class ExtensionSetting { + final String key; + final String label; + final String type; + final dynamic defaultValue; + final String? description; + final List? options; + final bool required; + final String? action; + + const ExtensionSetting({ + required this.key, + required this.label, + required this.type, + this.defaultValue, + this.description, + this.options, + this.required = false, + this.action, + }); + + factory ExtensionSetting.fromJson(Map json) { + return ExtensionSetting( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + type: json['type'] as String? ?? 'string', + defaultValue: json['default'], + description: json['description'] as String?, + options: (json['options'] as List?)?.cast(), + required: json['required'] as bool? ?? false, + action: json['action'] as String?, + ); + } +} + +class ExtensionState { + final List extensions; + final List providerPriority; + final List metadataProviderPriority; + final Map healthStatuses; + final bool isLoading; + final String? error; + final bool isInitialized; + + const ExtensionState({ + this.extensions = const [], + this.providerPriority = const [], + this.metadataProviderPriority = const [], + this.healthStatuses = const {}, + this.isLoading = false, + this.error, + this.isInitialized = false, + }); + + ExtensionState copyWith({ + List? extensions, + List? providerPriority, + List? metadataProviderPriority, + Map? healthStatuses, + bool? isLoading, + String? error, + bool? isInitialized, + }) { + return ExtensionState( + extensions: extensions ?? this.extensions, + providerPriority: providerPriority ?? this.providerPriority, + metadataProviderPriority: + metadataProviderPriority ?? this.metadataProviderPriority, + healthStatuses: healthStatuses ?? this.healthStatuses, + isLoading: isLoading ?? this.isLoading, + error: error, + isInitialized: isInitialized ?? this.isInitialized, + ); + } +} + +class ExtensionInstallBatchResult { + final int attempted; + final int installed; + final Map failures; + + const ExtensionInstallBatchResult({ + required this.attempted, + required this.installed, + this.failures = const {}, + }); + + bool get hasFailures => failures.isNotEmpty; + bool get anyInstalled => installed > 0; +} diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 949bed2d..cd2c8b0e 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -9,6 +9,9 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +part 'extension_models.dart'; +part 'extension_provider_priority.dart'; + final _log = AppLogger('ExtensionProvider'); const _metadataProviderPriorityKey = 'metadata_provider_priority'; @@ -16,776 +19,6 @@ const _providerPriorityKey = 'provider_priority'; const _storeRegistryUrlPrefKey = 'store_registry_url'; /// Result of restoring extensions from a backup. -class ExtensionRestoreResult { - final int installed; - final int alreadyPresent; - final int failed; - final List failedIds; - - const ExtensionRestoreResult({ - this.installed = 0, - this.alreadyPresent = 0, - this.failed = 0, - this.failedIds = const [], - }); -} - -bool _stringListEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; -} - -List? _tryDecodeStringListPreference(String rawJson, String key) { - try { - final decoded = jsonDecode(rawJson); - if (decoded is! List) { - throw const FormatException('expected a JSON list'); - } - - final values = []; - for (final item in decoded) { - if (item is! String) { - throw const FormatException('expected string entries'); - } - final trimmed = item.trim(); - if (trimmed.isNotEmpty) { - values.add(trimmed); - } - } - return values; - } catch (e) { - _log.w('Ignoring invalid $key preference: $e'); - return null; - } -} - -/// First enabled custom-search extension, preferring ones marked primary. -Extension? defaultSearchExtension(List extensions) { - return extensions - .where( - (ext) => - ext.enabled && - ext.hasCustomSearch && - ext.searchBehavior?.primary == true, - ) - .firstOrNull ?? - extensions - .where((ext) => ext.enabled && ext.hasCustomSearch) - .firstOrNull; -} - -class Extension { - final String id; - final String name; - final String displayName; - final String version; - final String description; - final bool enabled; - final String status; - final String? errorMessage; - final String? iconPath; - final List permissions; - final List settings; - final List qualityOptions; - final bool hasMetadataProvider; - final bool hasDownloadProvider; - final bool hasLyricsProvider; - final bool skipMetadataEnrichment; - final bool skipLyrics; - final bool stopProviderFallback; - final SearchBehavior? searchBehavior; - final URLHandler? urlHandler; - final TrackMatching? trackMatching; - final PostProcessing? postProcessing; - final List serviceHealth; - final Map capabilities; - - const Extension({ - required this.id, - required this.name, - required this.displayName, - required this.version, - required this.description, - required this.enabled, - required this.status, - this.errorMessage, - this.iconPath, - this.permissions = const [], - this.settings = const [], - this.qualityOptions = const [], - this.hasMetadataProvider = false, - this.hasDownloadProvider = false, - this.hasLyricsProvider = false, - this.skipMetadataEnrichment = false, - this.skipLyrics = false, - this.stopProviderFallback = false, - this.searchBehavior, - this.urlHandler, - this.trackMatching, - this.postProcessing, - this.serviceHealth = const [], - this.capabilities = const {}, - }); - - factory Extension.fromJson(Map json) { - return Extension( - id: json['id'] as String? ?? '', - name: json['name'] as String? ?? '', - displayName: - json['display_name'] as String? ?? json['name'] as String? ?? '', - version: json['version'] as String? ?? '0.0.0', - description: json['description'] as String? ?? '', - enabled: json['enabled'] as bool? ?? false, - status: json['status'] as String? ?? 'loaded', - errorMessage: json['error_message'] as String?, - iconPath: json['icon_path'] as String?, - permissions: - (json['permissions'] as List?)?.cast() ?? [], - settings: - (json['settings'] as List?) - ?.map((s) => ExtensionSetting.fromJson(s as Map)) - .toList() ?? - [], - qualityOptions: - (json['quality_options'] as List?) - ?.map((q) => QualityOption.fromJson(q as Map)) - .toList() ?? - [], - hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, - hasDownloadProvider: json['has_download_provider'] as bool? ?? false, - hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false, - skipMetadataEnrichment: - json['skip_metadata_enrichment'] as bool? ?? false, - skipLyrics: json['skip_lyrics'] as bool? ?? false, - stopProviderFallback: json['stop_provider_fallback'] as bool? ?? false, - searchBehavior: json['search_behavior'] != null - ? SearchBehavior.fromJson( - json['search_behavior'] as Map, - ) - : null, - urlHandler: json['url_handler'] != null - ? URLHandler.fromJson(json['url_handler'] as Map) - : null, - trackMatching: json['track_matching'] != null - ? TrackMatching.fromJson( - json['track_matching'] as Map, - ) - : null, - postProcessing: json['post_processing'] != null - ? PostProcessing.fromJson( - json['post_processing'] as Map, - ) - : null, - serviceHealth: - (json['service_health'] as List?) - ?.map( - (h) => ExtensionServiceHealthCheck.fromJson( - h as Map, - ), - ) - .toList() ?? - [], - capabilities: (json['capabilities'] as Map?) ?? const {}, - ); - } - - Extension copyWith({ - String? id, - String? name, - String? displayName, - String? version, - String? description, - bool? enabled, - String? status, - String? errorMessage, - String? iconPath, - List? permissions, - List? settings, - List? qualityOptions, - bool? hasMetadataProvider, - bool? hasDownloadProvider, - bool? hasLyricsProvider, - bool? skipMetadataEnrichment, - bool? skipLyrics, - bool? stopProviderFallback, - SearchBehavior? searchBehavior, - URLHandler? urlHandler, - TrackMatching? trackMatching, - PostProcessing? postProcessing, - List? serviceHealth, - Map? capabilities, - }) { - return Extension( - id: id ?? this.id, - name: name ?? this.name, - displayName: displayName ?? this.displayName, - version: version ?? this.version, - description: description ?? this.description, - enabled: enabled ?? this.enabled, - status: status ?? this.status, - errorMessage: errorMessage ?? this.errorMessage, - iconPath: iconPath ?? this.iconPath, - permissions: permissions ?? this.permissions, - settings: settings ?? this.settings, - qualityOptions: qualityOptions ?? this.qualityOptions, - hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, - hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, - hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider, - skipMetadataEnrichment: - skipMetadataEnrichment ?? this.skipMetadataEnrichment, - skipLyrics: skipLyrics ?? this.skipLyrics, - stopProviderFallback: stopProviderFallback ?? this.stopProviderFallback, - searchBehavior: searchBehavior ?? this.searchBehavior, - urlHandler: urlHandler ?? this.urlHandler, - trackMatching: trackMatching ?? this.trackMatching, - postProcessing: postProcessing ?? this.postProcessing, - serviceHealth: serviceHealth ?? this.serviceHealth, - capabilities: capabilities ?? this.capabilities, - ); - } - - bool get hasCustomSearch => searchBehavior?.enabled ?? false; - bool get hasURLHandler => urlHandler?.enabled ?? false; - bool get hasCustomMatching => trackMatching?.customMatching ?? false; - bool get hasPostProcessing => postProcessing?.enabled ?? false; - bool get hasServiceHealth => serviceHealth.isNotEmpty; - bool get hasHomeFeed => capabilities['homeFeed'] == true; - bool get requiresNativeContainerConversion => - capabilities['requiresContainerConversion'] == true || - capabilities['requiresNativeContainerConversion'] == true; - List get replacesBuiltInProviders { - final value = capabilities['replacesBuiltInProviders']; - if (value is! List) return const []; - - final normalized = []; - for (final item in value) { - if (item is! String) continue; - final trimmed = item.trim().toLowerCase(); - if (trimmed.isEmpty || normalized.contains(trimmed)) continue; - normalized.add(trimmed); - } - return normalized; - } - - String? get preferredDownloadOutputExtension { - final value = capabilities['downloadOutputExtension']; - if (value is! String) return null; - final trimmed = value.trim(); - return trimmed.isEmpty ? null : trimmed; - } - - List get preservedNativeOutputExtensions { - final value = capabilities['preserveNativeOutputExtensions']; - if (value is! List) return const []; - - final normalized = []; - for (final item in value) { - if (item is! String) continue; - final trimmed = item.trim().toLowerCase(); - if (trimmed.isEmpty) continue; - normalized.add(trimmed.startsWith('.') ? trimmed : '.$trimmed'); - } - return normalized; - } -} - -String resolveEffectiveDownloadService( - String requestedService, - ExtensionState extensionState, -) => _resolveEffectiveProvider( - requestedService, - extensionState, - (ext) => ext.hasDownloadProvider, -); - -String resolveEffectiveMetadataProvider( - String requestedProvider, - ExtensionState extensionState, -) => _resolveEffectiveProvider( - requestedProvider, - extensionState, - (ext) => ext.hasMetadataProvider, -); - -String _resolveEffectiveProvider( - String requested, - ExtensionState extensionState, - bool Function(Extension) hasProvider, -) { - final normalizedRequested = requested.trim().toLowerCase(); - final enabled = extensionState.extensions - .where((ext) => ext.enabled && hasProvider(ext)) - .toList(growable: false); - - if (normalizedRequested.isNotEmpty) { - final matching = enabled - .where((ext) => ext.id.trim().toLowerCase() == normalizedRequested) - .firstOrNull; - if (matching != null) { - return matching.id; - } - - final replacement = enabled - .where( - (ext) => ext.replacesBuiltInProviders.contains(normalizedRequested), - ) - .firstOrNull; - if (replacement != null) { - return replacement.id; - } - } - - return enabled.firstOrNull?.id ?? ''; -} - -bool isDeezerCompatibleDownloadService( - String service, - ExtensionState extensionState, -) { - final normalizedService = service.trim().toLowerCase(); - if (normalizedService.isEmpty) { - return false; - } - - return extensionState.extensions.any( - (ext) => - ext.enabled && - ext.hasDownloadProvider && - ext.id.trim().toLowerCase() == normalizedService && - ext.replacesBuiltInProviders.contains('deezer'), - ); -} - -class SearchFilter { - final String id; - final String? label; - final String? icon; - - const SearchFilter({required this.id, this.label, this.icon}); - - factory SearchFilter.fromJson(Map json) { - return SearchFilter( - id: json['id'] as String? ?? '', - label: json['label'] as String?, - icon: json['icon'] as String?, - ); - } -} - -class SearchBehavior { - final bool enabled; - final String? placeholder; - final bool primary; - final String? icon; - final String? thumbnailRatio; - final int? thumbnailWidth; - final int? thumbnailHeight; - final List filters; - - const SearchBehavior({ - required this.enabled, - this.placeholder, - this.primary = false, - this.icon, - this.thumbnailRatio, - this.thumbnailWidth, - this.thumbnailHeight, - this.filters = const [], - }); - - factory SearchBehavior.fromJson(Map json) { - return SearchBehavior( - enabled: json['enabled'] as bool? ?? false, - placeholder: json['placeholder'] as String?, - primary: json['primary'] as bool? ?? false, - icon: json['icon'] as String?, - thumbnailRatio: json['thumbnailRatio'] as String?, - thumbnailWidth: json['thumbnailWidth'] as int?, - thumbnailHeight: json['thumbnailHeight'] as int?, - filters: - (json['filters'] as List?) - ?.map((f) => SearchFilter.fromJson(f as Map)) - .toList() ?? - [], - ); - } - - (double, double) getThumbnailSize({double defaultSize = 56}) { - if (thumbnailWidth != null && thumbnailHeight != null) { - return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); - } - - switch (thumbnailRatio) { - case 'wide': - return (defaultSize * 16 / 9, defaultSize); - case 'portrait': - return (defaultSize * 2 / 3, defaultSize); - case 'square': - default: - return (defaultSize, defaultSize); - } - } -} - -class TrackMatching { - final bool customMatching; - final String? strategy; - final int durationTolerance; - - const TrackMatching({ - required this.customMatching, - this.strategy, - this.durationTolerance = 3, - }); - - factory TrackMatching.fromJson(Map json) { - return TrackMatching( - customMatching: json['customMatching'] as bool? ?? false, - strategy: json['strategy'] as String?, - durationTolerance: json['durationTolerance'] as int? ?? 3, - ); - } -} - -class PostProcessing { - final bool enabled; - final List hooks; - - const PostProcessing({required this.enabled, this.hooks = const []}); - - factory PostProcessing.fromJson(Map json) { - return PostProcessing( - enabled: json['enabled'] as bool? ?? false, - hooks: - (json['hooks'] as List?) - ?.map( - (h) => PostProcessingHook.fromJson(h as Map), - ) - .toList() ?? - [], - ); - } -} - -class URLHandler { - final bool enabled; - final List patterns; - - const URLHandler({required this.enabled, this.patterns = const []}); - - factory URLHandler.fromJson(Map json) { - return URLHandler( - enabled: json['enabled'] as bool? ?? false, - patterns: (json['patterns'] as List?)?.cast() ?? [], - ); - } - -} - -class ExtensionServiceHealthCheck { - final String id; - final String? label; - final String url; - final String method; - final String? serviceKey; - final int? timeoutMs; - final int? cacheTtlSeconds; - final bool required; - - const ExtensionServiceHealthCheck({ - required this.id, - this.label, - required this.url, - this.method = 'GET', - this.serviceKey, - this.timeoutMs, - this.cacheTtlSeconds, - this.required = false, - }); - - factory ExtensionServiceHealthCheck.fromJson(Map json) { - return ExtensionServiceHealthCheck( - id: json['id'] as String? ?? '', - label: json['label'] as String?, - url: json['url'] as String? ?? '', - method: json['method'] as String? ?? 'GET', - serviceKey: json['serviceKey'] as String?, - timeoutMs: json['timeoutMs'] as int?, - cacheTtlSeconds: json['cacheTtlSeconds'] as int?, - required: json['required'] as bool? ?? false, - ); - } -} - -class ExtensionHealthStatus { - final String extensionId; - final String status; - final DateTime? checkedAt; - final List checks; - - const ExtensionHealthStatus({ - required this.extensionId, - required this.status, - this.checkedAt, - this.checks = const [], - }); - - factory ExtensionHealthStatus.fromJson(Map json) { - return ExtensionHealthStatus( - extensionId: json['extension_id'] as String? ?? '', - status: json['status'] as String? ?? 'unknown', - checkedAt: DateTime.tryParse(json['checked_at'] as String? ?? ''), - checks: - (json['checks'] as List?) - ?.map( - (c) => ExtensionHealthCheckStatus.fromJson( - c as Map, - ), - ) - .toList() ?? - [], - ); - } - - bool get isSupported => status != 'unsupported'; -} - -class ExtensionHealthCheckStatus { - final String id; - final String? label; - final String url; - final String method; - final String? serviceKey; - final bool required; - final String status; - final int? httpStatus; - final int latencyMs; - final String? message; - final String? error; - final DateTime? checkedAt; - - const ExtensionHealthCheckStatus({ - required this.id, - this.label, - required this.url, - required this.method, - this.serviceKey, - this.required = false, - required this.status, - this.httpStatus, - this.latencyMs = 0, - this.message, - this.error, - this.checkedAt, - }); - - factory ExtensionHealthCheckStatus.fromJson(Map json) { - return ExtensionHealthCheckStatus( - id: json['id'] as String? ?? '', - label: json['label'] as String?, - url: json['url'] as String? ?? '', - method: json['method'] as String? ?? 'GET', - serviceKey: json['service_key'] as String?, - required: json['required'] as bool? ?? false, - status: json['status'] as String? ?? 'unknown', - httpStatus: json['http_status'] as int?, - latencyMs: json['latency_ms'] as int? ?? 0, - message: json['message'] as String?, - error: json['error'] as String?, - checkedAt: DateTime.tryParse(json['checked_at'] as String? ?? ''), - ); - } - - String get displayLabel => label?.trim().isNotEmpty == true ? label! : id; -} - -class PostProcessingHook { - final String id; - final String name; - final String? description; - final bool defaultEnabled; - final List supportedFormats; - - const PostProcessingHook({ - required this.id, - required this.name, - this.description, - this.defaultEnabled = false, - this.supportedFormats = const [], - }); - - factory PostProcessingHook.fromJson(Map json) { - return PostProcessingHook( - id: json['id'] as String? ?? '', - name: json['name'] as String? ?? '', - description: json['description'] as String?, - defaultEnabled: json['defaultEnabled'] as bool? ?? false, - supportedFormats: - (json['supportedFormats'] as List?)?.cast() ?? [], - ); - } -} - -class QualityOption { - final String id; - final String label; - final String? description; - final List settings; - - const QualityOption({ - required this.id, - required this.label, - this.description, - this.settings = const [], - }); - - factory QualityOption.fromJson(Map json) { - return QualityOption( - id: json['id'] as String? ?? '', - label: json['label'] as String? ?? '', - description: json['description'] as String?, - settings: - (json['settings'] as List?) - ?.map( - (s) => - QualitySpecificSetting.fromJson(s as Map), - ) - .toList() ?? - [], - ); - } -} - -class QualitySpecificSetting { - final String key; - final String label; - final String type; - final dynamic defaultValue; - final String? description; - final List? options; - final bool required; - final bool secret; - - const QualitySpecificSetting({ - required this.key, - required this.label, - required this.type, - this.defaultValue, - this.description, - this.options, - this.required = false, - this.secret = false, - }); - - factory QualitySpecificSetting.fromJson(Map json) { - return QualitySpecificSetting( - key: json['key'] as String? ?? '', - label: json['label'] as String? ?? '', - type: json['type'] as String? ?? 'string', - defaultValue: json['default'], - description: json['description'] as String?, - options: (json['options'] as List?)?.cast(), - required: json['required'] as bool? ?? false, - secret: json['secret'] as bool? ?? false, - ); - } -} - -class ExtensionSetting { - final String key; - final String label; - final String type; - final dynamic defaultValue; - final String? description; - final List? options; - final bool required; - final String? action; - - const ExtensionSetting({ - required this.key, - required this.label, - required this.type, - this.defaultValue, - this.description, - this.options, - this.required = false, - this.action, - }); - - factory ExtensionSetting.fromJson(Map json) { - return ExtensionSetting( - key: json['key'] as String? ?? '', - label: json['label'] as String? ?? '', - type: json['type'] as String? ?? 'string', - defaultValue: json['default'], - description: json['description'] as String?, - options: (json['options'] as List?)?.cast(), - required: json['required'] as bool? ?? false, - action: json['action'] as String?, - ); - } -} - -class ExtensionState { - final List extensions; - final List providerPriority; - final List metadataProviderPriority; - final Map healthStatuses; - final bool isLoading; - final String? error; - final bool isInitialized; - - const ExtensionState({ - this.extensions = const [], - this.providerPriority = const [], - this.metadataProviderPriority = const [], - this.healthStatuses = const {}, - this.isLoading = false, - this.error, - this.isInitialized = false, - }); - - ExtensionState copyWith({ - List? extensions, - List? providerPriority, - List? metadataProviderPriority, - Map? healthStatuses, - bool? isLoading, - String? error, - bool? isInitialized, - }) { - return ExtensionState( - extensions: extensions ?? this.extensions, - providerPriority: providerPriority ?? this.providerPriority, - metadataProviderPriority: - metadataProviderPriority ?? this.metadataProviderPriority, - healthStatuses: healthStatuses ?? this.healthStatuses, - isLoading: isLoading ?? this.isLoading, - error: error, - isInitialized: isInitialized ?? this.isInitialized, - ); - } -} - -class ExtensionInstallBatchResult { - final int attempted; - final int installed; - final Map failures; - - const ExtensionInstallBatchResult({ - required this.attempted, - required this.installed, - this.failures = const {}, - }); - - bool get hasFailures => failures.isNotEmpty; - bool get anyInstalled => installed > 0; -} - class ExtensionNotifier extends Notifier { static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10); static const _extensionHealthMinimumCacheTtl = Duration(minutes: 1); @@ -1215,420 +448,6 @@ class ExtensionNotifier extends Notifier { } } - Future _reconcileDownloadProviderPriority() async { - if (state.providerPriority.isEmpty) { - return; - } - - final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority); - if (_stringListEquals(sanitized, state.providerPriority)) { - return; - } - - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); - await PlatformBridge.setProviderPriority(sanitized); - state = state.copyWith(providerPriority: sanitized); - _log.d('Reconciled provider priority after extension update: $sanitized'); - } - - Future _reconcileMetadataProviderPriority() async { - if (state.metadataProviderPriority.isEmpty) { - return; - } - - final replaced = _replaceRetiredBuiltInMetadataProviders( - state.metadataProviderPriority, - ); - final sanitized = _sanitizeMetadataProviderPriority(replaced); - if (_stringListEquals(sanitized, state.metadataProviderPriority)) { - return; - } - - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_metadataProviderPriorityKey, jsonEncode(sanitized)); - await PlatformBridge.setMetadataProviderPriority(sanitized); - state = state.copyWith(metadataProviderPriority: sanitized); - _log.d( - 'Reconciled metadata provider priority after extension update: $sanitized', - ); - } - - String? _firstEnabledExtensionDownloadProviderId() { - return state.extensions - .where((ext) => ext.enabled && ext.hasDownloadProvider) - .map((ext) => ext.id) - .firstOrNull; - } - - String? _firstEnabledSearchProviderId() { - return defaultSearchExtension(state.extensions)?.id; - } - - String? _replacedBuiltInProviderFor( - String providerId, - bool Function(Extension ext) hasCapability, - ) { - final normalized = providerId.trim().toLowerCase(); - if (normalized.isEmpty) return null; - - return state.extensions - .where( - (ext) => - ext.enabled && - hasCapability(ext) && - ext.replacesBuiltInProviders.contains(normalized), - ) - .map((ext) => ext.id) - .firstOrNull; - } - - String? replacedBuiltInDownloadProviderFor(String providerId) => - _replacedBuiltInProviderFor(providerId, (ext) => ext.hasDownloadProvider); - - String? replacedBuiltInSearchProviderFor(String providerId) => - _replacedBuiltInProviderFor(providerId, (ext) => ext.hasCustomSearch); - - String? replacedBuiltInMetadataProviderFor(String providerId) => - _replacedBuiltInProviderFor(providerId, (ext) => ext.hasMetadataProvider); - - bool downloadProviderReplacesLegacyProvider( - String providerId, - String legacyProviderId, - ) { - final normalizedProvider = providerId.trim().toLowerCase(); - final normalizedLegacy = legacyProviderId.trim().toLowerCase(); - if (normalizedProvider.isEmpty || normalizedLegacy.isEmpty) return false; - if (normalizedProvider == normalizedLegacy) return true; - - final extension = state.extensions - .where((ext) => ext.enabled && ext.hasDownloadProvider) - .where((ext) => ext.id.toLowerCase() == normalizedProvider) - .firstOrNull; - return extension?.replacesBuiltInProviders.contains(normalizedLegacy) ?? - false; - } - - Future _reconcileDefaultDownloadService() async { - final settings = ref.read(settingsProvider); - final preferredExtensionId = _firstEnabledExtensionDownloadProviderId(); - final currentService = settings.defaultService.trim(); - - if (currentService.isEmpty) { - if (preferredExtensionId != null) { - ref - .read(settingsProvider.notifier) - .setDefaultService(preferredExtensionId); - _log.d( - 'Adopted first enabled download extension as default service: $preferredExtensionId', - ); - } - return; - } - - final replacementExtensionId = replacedBuiltInDownloadProviderFor( - currentService, - ); - if (replacementExtensionId != null) { - ref - .read(settingsProvider.notifier) - .setDefaultService(replacementExtensionId); - _log.d( - 'Migrated retired built-in service $currentService to $replacementExtensionId', - ); - return; - } - - final currentExtension = state.extensions - .where((ext) => ext.id == currentService) - .firstOrNull; - final isMissingOrInvalidExtension = - currentExtension == null || - !currentExtension.enabled || - !currentExtension.hasDownloadProvider; - if (isMissingOrInvalidExtension) { - final fallbackService = preferredExtensionId ?? ''; - ref.read(settingsProvider.notifier).setDefaultService(fallbackService); - _log.d( - fallbackService.isEmpty - ? 'Cleared default service because $currentService is no longer available' - : 'Reset default service to $fallbackService because $currentService is no longer available', - ); - } - } - - void _reconcileSearchProvider() { - final settings = ref.read(settingsProvider); - final currentSearchProvider = settings.searchProvider?.trim() ?? ''; - final preferredSearchProvider = _firstEnabledSearchProviderId() ?? ''; - - if (currentSearchProvider.isEmpty) { - if (preferredSearchProvider.isNotEmpty) { - ref - .read(settingsProvider.notifier) - .setSearchProvider(preferredSearchProvider); - _log.d( - 'Adopted first enabled search provider as default: $preferredSearchProvider', - ); - } - return; - } - - final replacementExtensionId = replacedBuiltInSearchProviderFor( - currentSearchProvider, - ); - if (replacementExtensionId != null) { - ref - .read(settingsProvider.notifier) - .setSearchProvider(replacementExtensionId); - _log.d( - 'Migrated retired built-in search provider $currentSearchProvider to $replacementExtensionId', - ); - return; - } - - final hasMatchingExtension = state.extensions.any( - (ext) => - ext.enabled && ext.hasCustomSearch && ext.id == currentSearchProvider, - ); - if (!hasMatchingExtension) { - ref - .read(settingsProvider.notifier) - .setSearchProvider( - preferredSearchProvider.isNotEmpty ? preferredSearchProvider : null, - ); - _log.d( - preferredSearchProvider.isNotEmpty - ? 'Reset stale search provider $currentSearchProvider to $preferredSearchProvider' - : 'Cleared stale search provider because $currentSearchProvider is no longer available', - ); - } - } - - Future> getExtensionSettings(String extensionId) async { - try { - return await PlatformBridge.getExtensionSettings(extensionId); - } catch (e) { - _log.e('Failed to get extension settings: $e'); - return {}; - } - } - - Future setExtensionSettings( - String extensionId, - Map settings, - ) async { - try { - await PlatformBridge.setExtensionSettings(extensionId, settings); - _log.d('Updated settings for extension: $extensionId'); - } catch (e) { - _log.e('Failed to set extension settings: $e'); - state = state.copyWith(error: e.toString()); - } - } - - /// Shared load path for the download/metadata priority lists: prefs first - /// (sanitized), falling back to backend defaults, then persist + push the - /// result back to the backend. - Future> _loadPriorityList({ - required String prefsKey, - required String label, - required List Function(List) sanitizeStored, - required List Function(List) sanitizeBackend, - required Future> Function() fetchBackend, - required Future Function(List) pushBackend, - }) async { - final prefs = await SharedPreferences.getInstance(); - final savedJson = prefs.getString(prefsKey); - - List priority; - if (savedJson != null) { - final saved = _tryDecodeStringListPreference(savedJson, prefsKey); - if (saved != null) { - priority = sanitizeStored(saved); - _log.d('Loaded $label from prefs: $priority'); - } else { - await prefs.remove(prefsKey); - priority = sanitizeBackend(await fetchBackend()); - _log.d('Recovered $label from defaults: $priority'); - } - } else { - priority = sanitizeBackend(await fetchBackend()); - _log.d('Using default $label: $priority'); - } - await prefs.setString(prefsKey, jsonEncode(priority)); - await pushBackend(priority); - return priority; - } - - Future loadProviderPriority() async { - try { - final priority = await _loadPriorityList( - prefsKey: _providerPriorityKey, - label: 'provider priority', - sanitizeStored: _sanitizeDownloadProviderPriority, - sanitizeBackend: _sanitizeDownloadProviderPriority, - fetchBackend: PlatformBridge.getProviderPriority, - pushBackend: PlatformBridge.setProviderPriority, - ); - state = state.copyWith(providerPriority: priority); - } catch (e) { - _log.e('Failed to load provider priority: $e'); - } - } - - Future setProviderPriority(List priority) async { - try { - final sanitized = _sanitizeDownloadProviderPriority(priority); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); - - await PlatformBridge.setProviderPriority(sanitized); - state = state.copyWith(providerPriority: sanitized); - _log.d('Saved provider priority: $sanitized'); - } catch (e) { - _log.e('Failed to set provider priority: $e'); - state = state.copyWith(error: e.toString()); - } - } - - List _sanitizeDownloadProviderPriority(List input) { - final allowed = getAllDownloadProviders().toSet(); - final preferredOrder = getAllDownloadProviders(); - final result = []; - - for (final provider in input) { - if (allowed.contains(provider) && !result.contains(provider)) { - result.add(provider); - } - } - - for (final provider in preferredOrder) { - if (!result.contains(provider)) { - result.add(provider); - } - } - - return result; - } - - Future loadMetadataProviderPriority() async { - try { - final priority = await _loadPriorityList( - prefsKey: _metadataProviderPriorityKey, - label: 'metadata provider priority', - sanitizeStored: (saved) => _sanitizeMetadataProviderPriority( - _replaceRetiredBuiltInMetadataProviders(saved), - ), - sanitizeBackend: _sanitizeMetadataProviderPriority, - fetchBackend: PlatformBridge.getMetadataProviderPriority, - pushBackend: PlatformBridge.setMetadataProviderPriority, - ); - state = state.copyWith(metadataProviderPriority: priority); - } catch (e) { - _log.e('Failed to load metadata provider priority: $e'); - } - } - - Future setMetadataProviderPriority(List priority) async { - try { - final prefs = await SharedPreferences.getInstance(); - final sanitized = _sanitizeMetadataProviderPriority( - _replaceRetiredBuiltInMetadataProviders(priority), - ); - await prefs.setString( - _metadataProviderPriorityKey, - jsonEncode(sanitized), - ); - - await PlatformBridge.setMetadataProviderPriority(sanitized); - state = state.copyWith(metadataProviderPriority: sanitized); - _log.d('Saved metadata provider priority: $sanitized'); - } catch (e) { - _log.e('Failed to set metadata provider priority: $e'); - state = state.copyWith(error: e.toString()); - } - } - - Future cleanup() async { - if (_cleanupInFlight) return; - _cleanupInFlight = true; - await _cleanupExtensions(reason: 'manual'); - } - - List getAllDownloadProviders() { - return _distinctProviderIds( - state.extensions - .where((ext) => ext.enabled && ext.hasDownloadProvider) - .map((ext) => ext.id), - ); - } - - List getAllMetadataProviders() { - final metadataExtensions = state.extensions - .where((ext) => ext.enabled && ext.hasMetadataProvider) - .toList(); - final primarySearchMetadataExtensions = metadataExtensions - .where((ext) => ext.searchBehavior?.primary == true) - .map((ext) => ext.id); - final otherMetadataExtensions = metadataExtensions - .where((ext) => ext.searchBehavior?.primary != true) - .map((ext) => ext.id); - - return _distinctProviderIds([ - ...primarySearchMetadataExtensions, - ...otherMetadataExtensions, - ]); - } - - List _distinctProviderIds(Iterable ids) { - final seen = {}; - final result = []; - for (final id in ids) { - final normalized = id.trim(); - if (normalized.isNotEmpty && seen.add(normalized)) { - result.add(normalized); - } - } - return result; - } - - List _replaceRetiredBuiltInMetadataProviders(List input) { - final result = []; - for (final provider in input) { - final replacement = replacedBuiltInMetadataProviderFor(provider); - final resolved = replacement ?? provider; - if (!result.contains(resolved)) { - result.add(resolved); - } - } - return result; - } - - List _sanitizeMetadataProviderPriority(List input) { - final allowed = getAllMetadataProviders().toSet(); - final preferredOrder = getAllMetadataProviders(); - final result = []; - - for (final provider in input) { - if (allowed.contains(provider) && !result.contains(provider)) { - result.add(provider); - } - } - - if (result.isEmpty && preferredOrder.isNotEmpty) { - return List.from(preferredOrder); - } - - for (final provider in preferredOrder) { - if (!result.contains(provider)) { - result.add(provider); - } - } - - return result; - } - List searchProviders() { return state.extensions .where((ext) => ext.enabled && ext.hasCustomSearch) diff --git a/lib/providers/extension_provider_priority.dart b/lib/providers/extension_provider_priority.dart new file mode 100644 index 00000000..c91d8d5b --- /dev/null +++ b/lib/providers/extension_provider_priority.dart @@ -0,0 +1,420 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'extension_provider.dart'; + +/// Download/metadata/search provider priority: persistence, sanitizing, and +/// reconciliation when extensions are installed, removed, or toggled. +extension ExtensionNotifierProviderPriority on ExtensionNotifier { + Future _reconcileDownloadProviderPriority() async { + if (state.providerPriority.isEmpty) { + return; + } + + final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority); + if (_stringListEquals(sanitized, state.providerPriority)) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); + await PlatformBridge.setProviderPriority(sanitized); + state = state.copyWith(providerPriority: sanitized); + _log.d('Reconciled provider priority after extension update: $sanitized'); + } + + Future _reconcileMetadataProviderPriority() async { + if (state.metadataProviderPriority.isEmpty) { + return; + } + + final replaced = _replaceRetiredBuiltInMetadataProviders( + state.metadataProviderPriority, + ); + final sanitized = _sanitizeMetadataProviderPriority(replaced); + if (_stringListEquals(sanitized, state.metadataProviderPriority)) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_metadataProviderPriorityKey, jsonEncode(sanitized)); + await PlatformBridge.setMetadataProviderPriority(sanitized); + state = state.copyWith(metadataProviderPriority: sanitized); + _log.d( + 'Reconciled metadata provider priority after extension update: $sanitized', + ); + } + + String? _firstEnabledExtensionDownloadProviderId() { + return state.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .map((ext) => ext.id) + .firstOrNull; + } + + String? _firstEnabledSearchProviderId() { + return defaultSearchExtension(state.extensions)?.id; + } + + String? _replacedBuiltInProviderFor( + String providerId, + bool Function(Extension ext) hasCapability, + ) { + final normalized = providerId.trim().toLowerCase(); + if (normalized.isEmpty) return null; + + return state.extensions + .where( + (ext) => + ext.enabled && + hasCapability(ext) && + ext.replacesBuiltInProviders.contains(normalized), + ) + .map((ext) => ext.id) + .firstOrNull; + } + + String? replacedBuiltInDownloadProviderFor(String providerId) => + _replacedBuiltInProviderFor(providerId, (ext) => ext.hasDownloadProvider); + + String? replacedBuiltInSearchProviderFor(String providerId) => + _replacedBuiltInProviderFor(providerId, (ext) => ext.hasCustomSearch); + + String? replacedBuiltInMetadataProviderFor(String providerId) => + _replacedBuiltInProviderFor(providerId, (ext) => ext.hasMetadataProvider); + + bool downloadProviderReplacesLegacyProvider( + String providerId, + String legacyProviderId, + ) { + final normalizedProvider = providerId.trim().toLowerCase(); + final normalizedLegacy = legacyProviderId.trim().toLowerCase(); + if (normalizedProvider.isEmpty || normalizedLegacy.isEmpty) return false; + if (normalizedProvider == normalizedLegacy) return true; + + final extension = state.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .where((ext) => ext.id.toLowerCase() == normalizedProvider) + .firstOrNull; + return extension?.replacesBuiltInProviders.contains(normalizedLegacy) ?? + false; + } + + Future _reconcileDefaultDownloadService() async { + final settings = ref.read(settingsProvider); + final preferredExtensionId = _firstEnabledExtensionDownloadProviderId(); + final currentService = settings.defaultService.trim(); + + if (currentService.isEmpty) { + if (preferredExtensionId != null) { + ref + .read(settingsProvider.notifier) + .setDefaultService(preferredExtensionId); + _log.d( + 'Adopted first enabled download extension as default service: $preferredExtensionId', + ); + } + return; + } + + final replacementExtensionId = replacedBuiltInDownloadProviderFor( + currentService, + ); + if (replacementExtensionId != null) { + ref + .read(settingsProvider.notifier) + .setDefaultService(replacementExtensionId); + _log.d( + 'Migrated retired built-in service $currentService to $replacementExtensionId', + ); + return; + } + + final currentExtension = state.extensions + .where((ext) => ext.id == currentService) + .firstOrNull; + final isMissingOrInvalidExtension = + currentExtension == null || + !currentExtension.enabled || + !currentExtension.hasDownloadProvider; + if (isMissingOrInvalidExtension) { + final fallbackService = preferredExtensionId ?? ''; + ref.read(settingsProvider.notifier).setDefaultService(fallbackService); + _log.d( + fallbackService.isEmpty + ? 'Cleared default service because $currentService is no longer available' + : 'Reset default service to $fallbackService because $currentService is no longer available', + ); + } + } + + void _reconcileSearchProvider() { + final settings = ref.read(settingsProvider); + final currentSearchProvider = settings.searchProvider?.trim() ?? ''; + final preferredSearchProvider = _firstEnabledSearchProviderId() ?? ''; + + if (currentSearchProvider.isEmpty) { + if (preferredSearchProvider.isNotEmpty) { + ref + .read(settingsProvider.notifier) + .setSearchProvider(preferredSearchProvider); + _log.d( + 'Adopted first enabled search provider as default: $preferredSearchProvider', + ); + } + return; + } + + final replacementExtensionId = replacedBuiltInSearchProviderFor( + currentSearchProvider, + ); + if (replacementExtensionId != null) { + ref + .read(settingsProvider.notifier) + .setSearchProvider(replacementExtensionId); + _log.d( + 'Migrated retired built-in search provider $currentSearchProvider to $replacementExtensionId', + ); + return; + } + + final hasMatchingExtension = state.extensions.any( + (ext) => + ext.enabled && ext.hasCustomSearch && ext.id == currentSearchProvider, + ); + if (!hasMatchingExtension) { + ref + .read(settingsProvider.notifier) + .setSearchProvider( + preferredSearchProvider.isNotEmpty ? preferredSearchProvider : null, + ); + _log.d( + preferredSearchProvider.isNotEmpty + ? 'Reset stale search provider $currentSearchProvider to $preferredSearchProvider' + : 'Cleared stale search provider because $currentSearchProvider is no longer available', + ); + } + } + + Future> getExtensionSettings(String extensionId) async { + try { + return await PlatformBridge.getExtensionSettings(extensionId); + } catch (e) { + _log.e('Failed to get extension settings: $e'); + return {}; + } + } + + Future setExtensionSettings( + String extensionId, + Map settings, + ) async { + try { + await PlatformBridge.setExtensionSettings(extensionId, settings); + _log.d('Updated settings for extension: $extensionId'); + } catch (e) { + _log.e('Failed to set extension settings: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Shared load path for the download/metadata priority lists: prefs first + /// (sanitized), falling back to backend defaults, then persist + push the + /// result back to the backend. + Future> _loadPriorityList({ + required String prefsKey, + required String label, + required List Function(List) sanitizeStored, + required List Function(List) sanitizeBackend, + required Future> Function() fetchBackend, + required Future Function(List) pushBackend, + }) async { + final prefs = await SharedPreferences.getInstance(); + final savedJson = prefs.getString(prefsKey); + + List priority; + if (savedJson != null) { + final saved = _tryDecodeStringListPreference(savedJson, prefsKey); + if (saved != null) { + priority = sanitizeStored(saved); + _log.d('Loaded $label from prefs: $priority'); + } else { + await prefs.remove(prefsKey); + priority = sanitizeBackend(await fetchBackend()); + _log.d('Recovered $label from defaults: $priority'); + } + } else { + priority = sanitizeBackend(await fetchBackend()); + _log.d('Using default $label: $priority'); + } + await prefs.setString(prefsKey, jsonEncode(priority)); + await pushBackend(priority); + return priority; + } + + Future loadProviderPriority() async { + try { + final priority = await _loadPriorityList( + prefsKey: _providerPriorityKey, + label: 'provider priority', + sanitizeStored: _sanitizeDownloadProviderPriority, + sanitizeBackend: _sanitizeDownloadProviderPriority, + fetchBackend: PlatformBridge.getProviderPriority, + pushBackend: PlatformBridge.setProviderPriority, + ); + state = state.copyWith(providerPriority: priority); + } catch (e) { + _log.e('Failed to load provider priority: $e'); + } + } + + Future setProviderPriority(List priority) async { + try { + final sanitized = _sanitizeDownloadProviderPriority(priority); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); + + await PlatformBridge.setProviderPriority(sanitized); + state = state.copyWith(providerPriority: sanitized); + _log.d('Saved provider priority: $sanitized'); + } catch (e) { + _log.e('Failed to set provider priority: $e'); + state = state.copyWith(error: e.toString()); + } + } + + List _sanitizeDownloadProviderPriority(List input) { + final allowed = getAllDownloadProviders().toSet(); + final preferredOrder = getAllDownloadProviders(); + final result = []; + + for (final provider in input) { + if (allowed.contains(provider) && !result.contains(provider)) { + result.add(provider); + } + } + + for (final provider in preferredOrder) { + if (!result.contains(provider)) { + result.add(provider); + } + } + + return result; + } + + Future loadMetadataProviderPriority() async { + try { + final priority = await _loadPriorityList( + prefsKey: _metadataProviderPriorityKey, + label: 'metadata provider priority', + sanitizeStored: (saved) => _sanitizeMetadataProviderPriority( + _replaceRetiredBuiltInMetadataProviders(saved), + ), + sanitizeBackend: _sanitizeMetadataProviderPriority, + fetchBackend: PlatformBridge.getMetadataProviderPriority, + pushBackend: PlatformBridge.setMetadataProviderPriority, + ); + state = state.copyWith(metadataProviderPriority: priority); + } catch (e) { + _log.e('Failed to load metadata provider priority: $e'); + } + } + + Future setMetadataProviderPriority(List priority) async { + try { + final prefs = await SharedPreferences.getInstance(); + final sanitized = _sanitizeMetadataProviderPriority( + _replaceRetiredBuiltInMetadataProviders(priority), + ); + await prefs.setString( + _metadataProviderPriorityKey, + jsonEncode(sanitized), + ); + + await PlatformBridge.setMetadataProviderPriority(sanitized); + state = state.copyWith(metadataProviderPriority: sanitized); + _log.d('Saved metadata provider priority: $sanitized'); + } catch (e) { + _log.e('Failed to set metadata provider priority: $e'); + state = state.copyWith(error: e.toString()); + } + } + + Future cleanup() async { + if (_cleanupInFlight) return; + _cleanupInFlight = true; + await _cleanupExtensions(reason: 'manual'); + } + + List getAllDownloadProviders() { + return _distinctProviderIds( + state.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .map((ext) => ext.id), + ); + } + + List getAllMetadataProviders() { + final metadataExtensions = state.extensions + .where((ext) => ext.enabled && ext.hasMetadataProvider) + .toList(); + final primarySearchMetadataExtensions = metadataExtensions + .where((ext) => ext.searchBehavior?.primary == true) + .map((ext) => ext.id); + final otherMetadataExtensions = metadataExtensions + .where((ext) => ext.searchBehavior?.primary != true) + .map((ext) => ext.id); + + return _distinctProviderIds([ + ...primarySearchMetadataExtensions, + ...otherMetadataExtensions, + ]); + } + + List _distinctProviderIds(Iterable ids) { + final seen = {}; + final result = []; + for (final id in ids) { + final normalized = id.trim(); + if (normalized.isNotEmpty && seen.add(normalized)) { + result.add(normalized); + } + } + return result; + } + + List _replaceRetiredBuiltInMetadataProviders(List input) { + final result = []; + for (final provider in input) { + final replacement = replacedBuiltInMetadataProviderFor(provider); + final resolved = replacement ?? provider; + if (!result.contains(resolved)) { + result.add(resolved); + } + } + return result; + } + + List _sanitizeMetadataProviderPriority(List input) { + final allowed = getAllMetadataProviders().toSet(); + final preferredOrder = getAllMetadataProviders(); + final result = []; + + for (final provider in input) { + if (allowed.contains(provider) && !result.contains(provider)) { + result.add(provider); + } + } + + if (result.isEmpty && preferredOrder.isNotEmpty) { + return List.from(preferredOrder); + } + + for (final provider in preferredOrder) { + if (!result.contains(provider)) { + result.add(provider); + } + } + + return result; + } +}