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; }