diff --git a/app/lib/acquisition/acquisition_api_client.dart b/app/lib/acquisition/acquisition_api_client.dart new file mode 100644 index 0000000..4a6c638 --- /dev/null +++ b/app/lib/acquisition/acquisition_api_client.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; + +class AcquisitionApiClient { + final PapyrusApiConfig config; + final http.Client _httpClient; + final bool _ownsHttpClient; + + AcquisitionApiClient({required this.config, http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null; + + void close() { + if (_ownsHttpClient) { + _httpClient.close(); + } + } + + Future capabilities(String accessToken) async { + final response = await _httpClient.get( + config.endpoint('/acquisition/capabilities'), + headers: _headers(accessToken), + ); + return AcquisitionCapabilities.fromJson(_decodeObject(response)); + } + + Future> listEndpoints(String accessToken) async { + final response = await _httpClient.get(config.endpoint('/acquisition/endpoints'), headers: _headers(accessToken)); + return _decodeList(response).map(AcquisitionEndpoint.fromJson).toList(); + } + + Future createEndpoint({ + required String accessToken, + required String name, + required AcquisitionEndpointKind kind, + required Uri baseUrl, + String? apiKey, + String? username, + String? password, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/endpoints'), + headers: _headers(accessToken), + body: jsonEncode({ + 'name': name, + 'kind': kind.apiValue, + 'base_url': baseUrl.toString(), + 'api_key': ?apiKey, + 'username': ?username, + 'password': ?password, + }), + ); + return AcquisitionEndpoint.fromJson(_decodeObject(response)); + } + + Future updateEndpoint({ + required String accessToken, + required String endpointId, + String? name, + Uri? baseUrl, + String? apiKey, + String? username, + String? password, + bool? enabled, + }) async { + final response = await _httpClient.patch( + config.endpoint('/acquisition/endpoints/$endpointId'), + headers: _headers(accessToken), + body: jsonEncode({ + 'name': ?name, + 'base_url': ?baseUrl?.toString(), + 'api_key': ?apiKey, + 'username': ?username, + 'password': ?password, + 'enabled': ?enabled, + }), + ); + return AcquisitionEndpoint.fromJson(_decodeObject(response)); + } + + Future testEndpoint({ + required String accessToken, + String? endpointId, + AcquisitionEndpointKind? kind, + Uri? baseUrl, + String? apiKey, + String? username, + String? password, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/endpoints/test'), + headers: _headers(accessToken), + body: jsonEncode({ + 'endpoint_id': ?endpointId, + 'kind': ?kind?.apiValue, + 'base_url': ?baseUrl?.toString(), + 'api_key': ?apiKey, + 'username': ?username, + 'password': ?password, + }), + ); + final result = _decodeObject(response); + if (result['ok'] != true) { + throw const AuthApiException(statusCode: 502, message: 'Connection test returned an invalid response'); + } + } + + Future deleteEndpoint({required String accessToken, required String endpointId}) async { + final response = await _httpClient.delete( + config.endpoint('/acquisition/endpoints/$endpointId'), + headers: _headers(accessToken), + ); + if (response.statusCode >= 200 && response.statusCode < 300) return; + _decodeObject(response); + } + + Future> search({ + required String accessToken, + required String query, + List? endpointIds, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/search'), + headers: _headers(accessToken), + body: jsonEncode({'query': query, 'endpoint_ids': ?endpointIds}), + ); + return _decodeList(response).map(TorrentRelease.fromJson).toList(); + } + + Future submitRelease({ + required String accessToken, + required String endpointId, + required TorrentRelease release, + String? category, + String? savePath, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/submissions'), + headers: _headers(accessToken), + body: jsonEncode({ + 'endpoint_id': endpointId, + 'title': release.title, + 'download_url': release.downloadUrl, + 'category': ?category, + 'save_path': ?savePath, + }), + ); + return AcquisitionJob.fromJson(_decodeObject(response)); + } + + Future runArrCommand({ + required String accessToken, + required String endpointId, + required String command, + required List ids, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/arr/$endpointId/commands'), + headers: _headers(accessToken), + body: jsonEncode({'command': command, 'ids': ids}), + ); + return AcquisitionJob.fromJson(_decodeObject(response)); + } + + Map _headers(String accessToken) => { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $accessToken', + }; + + Map _decodeObject(http.Response response) { + final decoded = response.body.isEmpty ? {} : jsonDecode(response.body) as Map; + if (response.statusCode >= 200 && response.statusCode < 300) return decoded; + final error = decoded['error']; + final detail = decoded['detail']; + throw AuthApiException( + statusCode: response.statusCode, + message: error is Map + ? error['message'] as String? ?? 'Acquisition request failed' + : detail is String + ? detail + : 'Acquisition request failed', + ); + } + + List> _decodeList(http.Response response) { + if (response.statusCode < 200 || response.statusCode >= 300) { + _decodeObject(response); + } + return (jsonDecode(response.body) as List).cast>(); + } +} diff --git a/app/lib/acquisition/acquisition_models.dart b/app/lib/acquisition/acquisition_models.dart new file mode 100644 index 0000000..aae6e22 --- /dev/null +++ b/app/lib/acquisition/acquisition_models.dart @@ -0,0 +1,174 @@ +enum AcquisitionEndpointKind { + qbittorrent, + transmission, + deluge, + prowlarr, + torznab, + readarr, + sonarr, + radarr, + lidarr, + whisparr; + + String get apiValue => name; + + String get label => switch (this) { + AcquisitionEndpointKind.qbittorrent => 'qBittorrent', + AcquisitionEndpointKind.transmission => 'Transmission', + AcquisitionEndpointKind.deluge => 'Deluge', + AcquisitionEndpointKind.prowlarr => 'Prowlarr', + AcquisitionEndpointKind.torznab => 'Torznab', + AcquisitionEndpointKind.readarr => 'Readarr', + AcquisitionEndpointKind.sonarr => 'Sonarr', + AcquisitionEndpointKind.radarr => 'Radarr', + AcquisitionEndpointKind.lidarr => 'Lidarr', + AcquisitionEndpointKind.whisparr => 'Whisparr', + }; + + bool get isDownloadClient => switch (this) { + AcquisitionEndpointKind.qbittorrent || + AcquisitionEndpointKind.transmission || + AcquisitionEndpointKind.deluge => true, + _ => false, + }; + + bool get isIndexer => switch (this) { + AcquisitionEndpointKind.prowlarr || AcquisitionEndpointKind.torznab => true, + _ => false, + }; + + bool get isArr => switch (this) { + AcquisitionEndpointKind.readarr || + AcquisitionEndpointKind.sonarr || + AcquisitionEndpointKind.radarr || + AcquisitionEndpointKind.lidarr || + AcquisitionEndpointKind.whisparr => true, + _ => false, + }; +} + +class AcquisitionCapabilities { + final bool enabled; + final List endpointKinds; + final List indexerKinds; + final List downloadClientKinds; + final List arrKinds; + final Map> arrCommands; + + const AcquisitionCapabilities({ + required this.enabled, + required this.endpointKinds, + required this.indexerKinds, + required this.downloadClientKinds, + required this.arrKinds, + required this.arrCommands, + }); + + factory AcquisitionCapabilities.fromJson(Map json) { + return AcquisitionCapabilities( + enabled: json['enabled'] as bool? ?? true, + endpointKinds: _kinds(json['endpoint_kinds']), + indexerKinds: _kinds(json['indexer_kinds']), + downloadClientKinds: _kinds(json['download_client_kinds']), + arrKinds: _kinds(json['arr_kinds']), + arrCommands: ((json['arr_commands'] as Map?) ?? {}).map( + (key, value) => MapEntry(AcquisitionEndpointKind.values.byName(key), (value as List).cast()), + ), + ); + } + + static List _kinds(Object? value) { + return ((value as List?) ?? []).cast().map(AcquisitionEndpointKind.values.byName).toList(); + } +} + +class AcquisitionEndpoint { + final String id; + final String name; + final AcquisitionEndpointKind kind; + final Uri baseUrl; + final bool enabled; + + const AcquisitionEndpoint({ + required this.id, + required this.name, + required this.kind, + required this.baseUrl, + required this.enabled, + }); + + factory AcquisitionEndpoint.fromJson(Map json) => AcquisitionEndpoint( + id: json['endpoint_id'] as String, + name: json['name'] as String, + kind: AcquisitionEndpointKind.values.byName(json['kind'] as String), + baseUrl: Uri.parse(json['base_url'] as String), + enabled: json['enabled'] as bool, + ); +} + +class TorrentRelease { + final String title; + final String downloadUrl; + final String protocol; + final String indexer; + final int? seeders; + final int? sizeBytes; + + const TorrentRelease({ + required this.title, + required this.downloadUrl, + required this.protocol, + required this.indexer, + this.seeders, + this.sizeBytes, + }); + + bool get isMagnet => downloadUrl.startsWith('magnet:'); + + factory TorrentRelease.fromJson(Map json) => TorrentRelease( + title: json['title'] as String, + downloadUrl: json['download_url'] as String, + protocol: json['protocol'] as String, + indexer: json['indexer'] as String, + seeders: json['seeders'] as int?, + sizeBytes: json['size_bytes'] as int?, + ); +} + +class AcquisitionJob { + final String id; + final String? endpointId; + final String? ruleId; + final String title; + final String downloadUrl; + final String status; + final String? clientReference; + final String? error; + final DateTime? createdAt; + + const AcquisitionJob({ + required this.id, + required this.endpointId, + required this.ruleId, + required this.title, + required this.downloadUrl, + required this.status, + required this.clientReference, + required this.error, + required this.createdAt, + }); + + bool get isSubmitted => status == 'submitted'; + + factory AcquisitionJob.fromJson(Map json) => AcquisitionJob( + id: json['job_id'] as String, + endpointId: json['endpoint_id'] as String?, + ruleId: json['rule_id'] as String?, + title: json['title'] as String, + downloadUrl: json['download_url'] as String, + status: json['status'] as String, + clientReference: json['client_reference'] as String?, + error: json['error'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + ); +} diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index ce990a9..d074a29 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -19,8 +19,6 @@ class AuthRepository { AuthRepository({required this.apiClient, required this.tokenStore}); - String? get accessToken => tokenStore.accessToken; - bool get _usesDesktopLoopbackOAuth { if (kIsWeb) { return false; @@ -223,6 +221,10 @@ class AuthRepository { return tokenStore.clear(); } + Future withFreshAccessToken(Future Function(String accessToken) action) { + return _withFreshAccessToken(action); + } + Future _requireAccessToken() async { final currentAccessToken = tokenStore.accessToken; diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index 3de412f..d46d1f0 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -21,21 +21,38 @@ import 'package:papyrus/pages/shelf_contents_page.dart'; import 'package:papyrus/pages/shelves_page.dart'; import 'package:papyrus/pages/statistics_page.dart'; import 'package:papyrus/pages/annotations_page.dart'; +import 'package:papyrus/pages/acquisition_page.dart'; import 'package:papyrus/pages/notes_page.dart'; import 'package:papyrus/pages/welcome_page.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; class AppRouter { final AuthProvider authProvider; + final PreferencesProvider preferencesProvider; + final SyncSettingsProvider syncSettingsProvider; + final AcquisitionAvailabilityProvider acquisitionAvailabilityProvider; final rootNavigatorKey = GlobalKey(); final shellNavigatorKey = GlobalKey(); - AppRouter({required this.authProvider}); + AppRouter({ + required this.authProvider, + required this.preferencesProvider, + required this.syncSettingsProvider, + required this.acquisitionAvailabilityProvider, + }); late final GoRouter router = GoRouter( debugLogDiagnostics: true, navigatorKey: rootNavigatorKey, - refreshListenable: authProvider, + refreshListenable: Listenable.merge([ + authProvider, + preferencesProvider, + syncSettingsProvider, + acquisitionAvailabilityProvider, + ]), routes: [ GoRoute( path: '/', @@ -177,6 +194,11 @@ class AppRouter { path: '/statistics', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const StatisticsPage()), ), + GoRoute( + name: 'ACQUISITION', + path: '/acquisition', + pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const AcquisitionPage()), + ), // Profile GoRoute( name: 'PROFILE', @@ -231,6 +253,13 @@ class AppRouter { return '/library/books'; } + final acquisitionAvailable = acquisitionAvailabilityProvider.isAvailableFor( + syncSettingsProvider.activeApiConfig.serverBaseUri, + ); + if (location == '/acquisition' && (!preferencesProvider.acquisitionEnabled || !acquisitionAvailable)) { + return '/profile'; + } + return null; } } diff --git a/app/lib/main.dart b/app/lib/main.dart index 40016cb..e84c2e2 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -18,6 +18,7 @@ import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; import 'package:papyrus/powersync/sync_profile_switch_queue.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; @@ -52,6 +53,8 @@ class Papyrus extends StatefulWidget { class _PapyrusState extends State { late final DataStore _dataStore; late final AuthProvider _authProvider; + late final AcquisitionAvailabilityProvider _acquisitionAvailabilityProvider; + late final PreferencesProvider _preferencesProvider; late final SyncSettingsProvider _syncSettingsProvider; late final MediaUploadQueue _mediaUploadQueue; late final BookImportService _bookImportService; @@ -83,9 +86,11 @@ class _PapyrusState extends State { ); _dataStore = DataStore(); + _preferencesProvider = PreferencesProvider(widget.prefs); _mediaUploadQueue = MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); _bookImportService = BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); + _acquisitionAvailabilityProvider = AcquisitionAvailabilityProvider(authProvider: _authProvider); _powerSyncService = PapyrusPowerSyncService( connectorFactory: () => PapyrusPowerSyncConnector( authRepository: _authRepository, @@ -95,20 +100,30 @@ class _PapyrusState extends State { ); registerHotRestartCleanup(_disposeDataServices); unawaited(_dataStore.attachBookRepository(_powerSyncService)); - _appRouter = AppRouter(authProvider: _authProvider); + _appRouter = AppRouter( + authProvider: _authProvider, + preferencesProvider: _preferencesProvider, + syncSettingsProvider: _syncSettingsProvider, + acquisitionAvailabilityProvider: _acquisitionAvailabilityProvider, + ); _authProvider.addListener(_syncPowerSyncAuthState); + _authProvider.addListener(_syncAcquisitionAvailability); _syncSettingsProvider.addListener(_handleSyncSettingsChanged); _syncPowerSyncAuthState(); + _syncAcquisitionAvailability(); } @override void dispose() { _authProvider.removeListener(_syncPowerSyncAuthState); + _authProvider.removeListener(_syncAcquisitionAvailability); _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); _bookImportService.dispose(); + _acquisitionAvailabilityProvider.dispose(); _authProvider.dispose(); _syncSettingsProvider.dispose(); + _preferencesProvider.dispose(); super.dispose(); } @@ -184,11 +199,21 @@ class _PapyrusState extends State { } void _handleSyncSettingsChanged() { + _acquisitionAvailabilityProvider.clear(); final nextProfileKey = _syncSettingsProvider.activeProfileKey; final nextConfig = _syncSettingsProvider.activeApiConfig; _profileSwitchQueue.request(nextProfileKey, () => _switchActiveSyncProfile(nextProfileKey, nextConfig)); } + void _syncAcquisitionAvailability() { + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) { + _acquisitionAvailabilityProvider.clear(); + return; + } + + unawaited(_acquisitionAvailabilityProvider.refresh(_syncSettingsProvider.activeApiConfig.serverBaseUri)); + } + Future _switchActiveSyncProfile(String nextProfileKey, PapyrusApiConfig nextConfig) async { _switchingSyncProfile = true; try { @@ -273,9 +298,10 @@ class _PapyrusState extends State { StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), // Auth and UI state providers ChangeNotifierProvider.value(value: _authProvider), + ChangeNotifierProvider.value(value: _acquisitionAvailabilityProvider), ChangeNotifierProvider(create: (_) => SidebarProvider()), ChangeNotifierProvider(create: (_) => LibraryProvider()), - ChangeNotifierProvider(create: (_) => PreferencesProvider(widget.prefs)), + ChangeNotifierProvider.value(value: _preferencesProvider), ], child: Consumer( builder: (context, preferencesProvider, child) { diff --git a/app/lib/pages/acquisition_page.dart b/app/lib/pages/acquisition_page.dart new file mode 100644 index 0000000..3a2a4ff --- /dev/null +++ b/app/lib/pages/acquisition_page.dart @@ -0,0 +1,981 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:provider/provider.dart'; + +typedef AcquisitionApiClientFactory = AcquisitionApiClient Function(PapyrusApiConfig config); + +class AcquisitionPage extends StatefulWidget { + final AcquisitionApiClientFactory? clientFactory; + + const AcquisitionPage({super.key, this.clientFactory}); + + @override + State createState() => _AcquisitionPageState(); +} + +class _AcquisitionPageState extends State { + final _queryController = TextEditingController(); + AcquisitionApiClient? _client; + Uri? _clientBaseUri; + AcquisitionCapabilities? _capabilities; + List _endpoints = []; + List _releases = []; + Set _selectedIndexerIds = {}; + final Set _submittingKeys = {}; + bool _hasExplicitIndexerSelection = false; + bool _loading = true; + bool _searching = false; + String? _error; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final config = context.read().activeApiConfig; + if (_clientBaseUri == config.serverBaseUri) return; + _client?.close(); + _client = widget.clientFactory?.call(config) ?? AcquisitionApiClient(config: config); + _clientBaseUri = config.serverBaseUri; + _selectedIndexerIds = {}; + _hasExplicitIndexerSelection = false; + _load(); + } + + @override + void dispose() { + _client?.close(); + _queryController.dispose(); + super.dispose(); + } + + AcquisitionApiClient get _apiClient => _client!; + + Future _authenticated(Future Function(String accessToken) action) { + return context.read().withFreshAccessToken(action); + } + + Future _load() async { + if (!context.read().acquisitionEnabled) { + context.go('/profile'); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final capabilities = await _authenticated(_apiClient.capabilities); + final endpoints = await _authenticated(_apiClient.listEndpoints); + if (!mounted) return; + final enabledIndexerIds = endpoints + .where((endpoint) => endpoint.enabled && endpoint.kind.isIndexer) + .map((endpoint) => endpoint.id) + .toSet(); + setState(() { + _capabilities = capabilities; + _endpoints = endpoints; + if (_hasExplicitIndexerSelection) { + _selectedIndexerIds = _selectedIndexerIds.intersection(enabledIndexerIds); + } else { + _selectedIndexerIds = enabledIndexerIds; + } + }); + } on AuthApiException catch (error) { + if (!mounted) return; + setState(() => _error = _messageFor(error)); + } catch (_) { + if (!mounted) return; + setState(() { + _error = 'This Papyrus server does not expose the torrent acquisition API.'; + }); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _search() async { + final query = _queryController.text.trim(); + final indexerIds = _endpoints + .where((endpoint) => endpoint.enabled && endpoint.kind.isIndexer && _selectedIndexerIds.contains(endpoint.id)) + .map((endpoint) => endpoint.id) + .toList(); + final hasClient = _endpoints.any((endpoint) => endpoint.enabled && endpoint.kind.isDownloadClient); + if (query.isEmpty || _capabilities == null || indexerIds.isEmpty || !hasClient) return; + + setState(() { + _searching = true; + _error = null; + _releases = []; + }); + + try { + final releases = await _authenticated((token) { + return _apiClient.search(accessToken: token, query: query, endpointIds: indexerIds); + }); + if (mounted) setState(() => _releases = releases); + } on AuthApiException catch (error) { + if (mounted) setState(() => _error = _messageFor(error)); + } catch (_) { + if (mounted) { + setState(() => _error = 'Search failed. Check your torrent indexers.'); + } + } finally { + if (mounted) setState(() => _searching = false); + } + } + + Future _submitRelease(TorrentRelease release, AcquisitionEndpoint client) async { + final submissionKey = _submissionKey(release, client); + if (_submittingKeys.contains(submissionKey)) return; + + setState(() => _submittingKeys.add(submissionKey)); + try { + final job = await _authenticated((token) { + return _apiClient.submitRelease(accessToken: token, endpointId: client.id, release: release); + }); + if (!mounted) return; + + _showMessage(job.isSubmitted ? 'Sent to ${client.name}.' : job.error ?? 'Submission failed.'); + } on AuthApiException catch (error) { + if (mounted) _showMessage(error.message); + } catch (_) { + if (mounted) _showMessage('Could not submit this release.'); + } finally { + if (mounted) setState(() => _submittingKeys.remove(submissionKey)); + } + } + + Future _runArrCommand(AcquisitionEndpoint endpoint) async { + final capabilities = _capabilities; + final commands = capabilities?.arrCommands[endpoint.kind] ?? const []; + if (commands.isEmpty) return; + + final command = await _pickArrCommand(endpoint, commands); + if (command == null) return; + + final ids = await _askForIds(command); + if (ids == null) return; + + final submissionKey = 'arr:${endpoint.id}'; + if (_submittingKeys.contains(submissionKey)) return; + + setState(() => _submittingKeys.add(submissionKey)); + try { + final job = await _authenticated((token) { + return _apiClient.runArrCommand(accessToken: token, endpointId: endpoint.id, command: command, ids: ids); + }); + if (!mounted) return; + + _showMessage(job.isSubmitted ? '$command sent to ${endpoint.name}.' : job.error ?? 'Arr action failed.'); + } on AuthApiException catch (error) { + if (mounted) _showMessage(error.message); + } catch (_) { + if (mounted) _showMessage('Could not run this Arr action.'); + } finally { + if (mounted) setState(() => _submittingKeys.remove(submissionKey)); + } + } + + Future _pickArrCommand(AcquisitionEndpoint endpoint, List commands) { + return showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(endpoint.name), subtitle: Text(endpoint.kind.label)), + ...commands.map( + (command) => ListTile( + leading: const Icon(Icons.play_arrow_outlined), + title: Text(_arrCommandLabel(command)), + subtitle: Text(command), + onTap: () => Navigator.pop(context, command), + ), + ), + ], + ), + ), + ); + } + + Future?> _askForIds(String command) async { + final controller = TextEditingController(); + try { + return showDialog>( + context: context, + builder: (context) => AlertDialog( + title: Text(_arrCommandLabel(command)), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'IDs', + helperText: 'Comma-separated IDs from the Arr application', + ), + keyboardType: TextInputType.text, + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + FilledButton( + onPressed: () { + final ids = controller.text + .split(',') + .map((value) => int.tryParse(value.trim())) + .whereType() + .toList(); + Navigator.pop(context, ids); + }, + child: const Text('Run'), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + } + + Future _showEndpointDialog({AcquisitionEndpoint? endpoint}) async { + final capabilities = _capabilities; + if (capabilities == null || capabilities.endpointKinds.isEmpty) return; + + final saved = await showDialog( + context: context, + builder: (context) => _EndpointDialog( + endpoint: endpoint, + endpointKinds: capabilities.endpointKinds, + onTest: ({required kind, required baseUrl, apiKey, username, password}) { + return _authenticated((token) { + return _apiClient.testEndpoint( + accessToken: token, + endpointId: endpoint?.id, + kind: endpoint == null ? kind : null, + baseUrl: baseUrl, + apiKey: apiKey, + username: username, + password: password, + ); + }); + }, + onSave: ({required name, required kind, required baseUrl, required enabled, apiKey, username, password}) async { + await _authenticated((token) async { + if (endpoint == null) { + await _apiClient.createEndpoint( + accessToken: token, + name: name, + kind: kind, + baseUrl: baseUrl, + apiKey: apiKey, + username: username, + password: password, + ); + return; + } + + await _apiClient.updateEndpoint( + accessToken: token, + endpointId: endpoint.id, + name: name, + baseUrl: baseUrl, + apiKey: apiKey, + username: username, + password: password, + enabled: enabled, + ); + }); + }, + ), + ); + + if (saved == true) await _load(); + } + + Future _deleteEndpoint(AcquisitionEndpoint endpoint) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Remove ${endpoint.name}?'), + content: const Text('Saved credentials for this integration will be removed.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Remove')), + ], + ), + ); + if (confirmed != true) return; + + try { + await _authenticated((token) { + return _apiClient.deleteEndpoint(accessToken: token, endpointId: endpoint.id); + }); + await _load(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not remove this integration.'))); + } + } + } + + @override + Widget build(BuildContext context) { + final capabilities = _capabilities; + final clients = _endpoints.where((endpoint) => endpoint.enabled && endpoint.kind.isDownloadClient).toList(); + final indexers = _endpoints.where((endpoint) => endpoint.kind.isIndexer).toList(); + final enabledIndexers = indexers.where((endpoint) => endpoint.enabled).toList(); + final arrApps = _endpoints.where((endpoint) => endpoint.kind.isArr).toList(); + final canSearch = + clients.isNotEmpty && enabledIndexers.any((endpoint) => _selectedIndexerIds.contains(endpoint.id)); + + return Scaffold( + appBar: AppBar(title: const Text('Torrent acquisition')), + floatingActionButton: capabilities == null + ? null + : FloatingActionButton.extended( + onPressed: () => _showEndpointDialog(), + icon: const Icon(Icons.add), + label: const Text('Integration'), + ), + body: RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.all(Spacing.md), + children: [ + if (_loading) const LinearProgressIndicator(), + if (_submittingKeys.isNotEmpty) const LinearProgressIndicator(), + if (_error != null) _ErrorBanner(message: _error!, onRetry: _load), + if (!_loading && _error == null) ...[ + _SearchCard( + queryController: _queryController, + searching: _searching, + canSearch: canSearch, + indexers: enabledIndexers, + selectedIndexerIds: _selectedIndexerIds, + hasEnabledClient: clients.isNotEmpty, + onIndexerSelected: _setIndexerSelected, + onSearch: _search, + ), + const SizedBox(height: Spacing.md), + _EndpointSection( + title: 'Torrent indexers', + emptyLabel: 'No torrent indexers configured', + endpoints: indexers, + onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), + onDelete: _deleteEndpoint, + ), + _EndpointSection( + title: 'Download clients', + emptyLabel: 'No download clients configured', + endpoints: _endpoints.where((endpoint) => endpoint.kind.isDownloadClient).toList(), + onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), + onDelete: _deleteEndpoint, + ), + _ArrSection( + endpoints: arrApps, + submittingKeys: _submittingKeys, + onRun: _runArrCommand, + onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), + onDelete: _deleteEndpoint, + ), + if (_releases.isNotEmpty) ...[ + const SizedBox(height: Spacing.md), + Text('Results', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + ..._releases.map( + (release) => _ReleaseTile( + release: release, + clients: clients, + submittingKeys: _submittingKeys, + onSubmit: _submitRelease, + ), + ), + ] else if (!_searching && _queryController.text.trim().isNotEmpty) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('No releases found.'), + ), + ], + ], + ), + ), + ); + } + + String _messageFor(AuthApiException error) { + if (error.statusCode == 404) { + return 'This Papyrus server does not expose the torrent acquisition API.'; + } + return error.message; + } + + void _setIndexerSelected(AcquisitionEndpoint endpoint, bool selected) { + setState(() { + _hasExplicitIndexerSelection = true; + if (selected) { + _selectedIndexerIds.add(endpoint.id); + } else { + _selectedIndexerIds.remove(endpoint.id); + } + }); + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + String _arrCommandLabel(String command) => switch (command) { + 'AuthorSearch' => 'Search authors', + 'BookSearch' => 'Search books', + 'SeriesSearch' => 'Search series', + 'EpisodeSearch' => 'Search episodes', + 'MissingEpisodeSearch' => 'Search missing episodes', + 'MoviesSearch' => 'Search movies', + 'MissingMoviesSearch' => 'Search missing movies', + 'ArtistSearch' => 'Search artists', + 'AlbumSearch' => 'Search albums', + 'MissingAlbumSearch' => 'Search missing albums', + _ => command, + }; +} + +typedef _EndpointTestCallback = + Future Function({ + required AcquisitionEndpointKind kind, + required Uri baseUrl, + String? apiKey, + String? username, + String? password, + }); + +typedef _EndpointSaveCallback = + Future Function({ + required String name, + required AcquisitionEndpointKind kind, + required Uri baseUrl, + required bool enabled, + String? apiKey, + String? username, + String? password, + }); + +class _EndpointDialog extends StatefulWidget { + const _EndpointDialog({ + required this.endpoint, + required this.endpointKinds, + required this.onTest, + required this.onSave, + }); + + final AcquisitionEndpoint? endpoint; + final List endpointKinds; + final _EndpointTestCallback onTest; + final _EndpointSaveCallback onSave; + + @override + State<_EndpointDialog> createState() => _EndpointDialogState(); +} + +class _EndpointDialogState extends State<_EndpointDialog> { + late final TextEditingController _nameController; + late final TextEditingController _urlController; + final _apiKeyController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + late AcquisitionEndpointKind _kind; + late bool _enabled; + bool _testing = false; + bool _saving = false; + String? _message; + bool _messageIsError = false; + + bool get _busy => _testing || _saving; + + bool get _usesApiKey => _kind.isIndexer || _kind.isArr; + + bool get _usesUsername => + _kind == AcquisitionEndpointKind.qbittorrent || _kind == AcquisitionEndpointKind.transmission; + + bool get _usesPassword => _usesUsername || _kind == AcquisitionEndpointKind.deluge; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.endpoint?.name ?? ''); + _urlController = TextEditingController(text: widget.endpoint?.baseUrl.toString() ?? ''); + _kind = widget.endpoint?.kind ?? widget.endpointKinds.first; + _enabled = widget.endpoint?.enabled ?? true; + } + + @override + void dispose() { + _nameController.dispose(); + _urlController.dispose(); + _apiKeyController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AlertDialog( + title: Text(widget.endpoint == null ? 'Add integration' : 'Edit integration'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + enabled: !_busy, + decoration: const InputDecoration(labelText: 'Name'), + ), + DropdownButtonFormField( + initialValue: _kind, + items: widget.endpointKinds + .map((value) => DropdownMenuItem(value: value, child: Text(value.label))) + .toList(), + onChanged: widget.endpoint == null && !_busy ? (value) => setState(() => _kind = value ?? _kind) : null, + decoration: const InputDecoration(labelText: 'Type'), + ), + TextField( + controller: _urlController, + enabled: !_busy, + decoration: const InputDecoration(labelText: 'Server URL'), + keyboardType: TextInputType.url, + ), + if (_usesApiKey) + TextField( + controller: _apiKeyController, + enabled: !_busy, + obscureText: true, + decoration: const InputDecoration(labelText: 'API key'), + ), + if (_usesUsername) + TextField( + controller: _usernameController, + enabled: !_busy, + decoration: const InputDecoration(labelText: 'Username'), + ), + if (_usesPassword) + TextField( + controller: _passwordController, + enabled: !_busy, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: _enabled, + onChanged: _busy ? null : (value) => setState(() => _enabled = value), + ), + if (_testing) const LinearProgressIndicator(), + if (_message != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text(_message!, style: TextStyle(color: _messageIsError ? colorScheme.error : null)), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: _busy ? null : () => Navigator.pop(context, false), child: const Text('Cancel')), + OutlinedButton( + key: const Key('acquisition-test-connection'), + onPressed: _busy ? null : _testConnection, + child: const Text('Test connection'), + ), + FilledButton(key: const Key('acquisition-save'), onPressed: _busy ? null : _save, child: const Text('Save')), + ], + ); + } + + Future _testConnection() async { + final baseUrl = _baseUrl(); + if (baseUrl == null) return; + + setState(() { + _testing = true; + _message = null; + }); + + try { + await widget.onTest( + kind: _kind, + baseUrl: baseUrl, + apiKey: _usesApiKey ? _optional(_apiKeyController.text) : null, + username: _usesUsername ? _optional(_usernameController.text) : null, + password: _usesPassword ? _optional(_passwordController.text) : null, + ); + if (!mounted) return; + setState(() { + _message = 'Connection successful.'; + _messageIsError = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _message = error is AuthApiException ? error.message : 'Could not connect to this integration.'; + _messageIsError = true; + }); + } finally { + if (mounted) setState(() => _testing = false); + } + } + + Future _save() async { + final name = _nameController.text.trim(); + final baseUrl = _baseUrl(); + if (name.isEmpty || baseUrl == null) { + if (name.isEmpty) { + setState(() { + _message = 'Name is required.'; + _messageIsError = true; + }); + } + return; + } + + setState(() { + _saving = true; + _message = null; + }); + + try { + await widget.onSave( + name: name, + kind: _kind, + baseUrl: baseUrl, + enabled: _enabled, + apiKey: _usesApiKey ? _optional(_apiKeyController.text) : null, + username: _usesUsername ? _optional(_usernameController.text) : null, + password: _usesPassword ? _optional(_passwordController.text) : null, + ); + if (mounted) Navigator.pop(context, true); + } catch (error) { + if (!mounted) return; + setState(() { + _message = error is AuthApiException ? error.message : 'Could not save this integration.'; + _messageIsError = true; + }); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Uri? _baseUrl() { + final baseUrl = Uri.tryParse(_urlController.text.trim()); + if (baseUrl == null || !baseUrl.hasScheme || !baseUrl.hasAuthority || baseUrl.userInfo.isNotEmpty) { + setState(() { + _message = 'Enter a valid server URL without embedded credentials.'; + _messageIsError = true; + }); + return null; + } + return baseUrl; + } + + String? _optional(String value) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } +} + +class _SearchCard extends StatelessWidget { + const _SearchCard({ + required this.queryController, + required this.searching, + required this.canSearch, + required this.indexers, + required this.selectedIndexerIds, + required this.hasEnabledClient, + required this.onIndexerSelected, + required this.onSearch, + }); + + final TextEditingController queryController; + final bool searching; + final bool canSearch; + final List indexers; + final Set selectedIndexerIds; + final bool hasEnabledClient; + final void Function(AcquisitionEndpoint endpoint, bool selected) onIndexerSelected; + final VoidCallback onSearch; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Search torrents', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (indexers.isNotEmpty) ...[ + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.xs, + children: indexers + .map( + (endpoint) => FilterChip( + label: Text(endpoint.name), + selected: selectedIndexerIds.contains(endpoint.id), + onSelected: searching ? null : (selected) => onIndexerSelected(endpoint, selected), + ), + ) + .toList(), + ), + const SizedBox(height: Spacing.sm), + ], + TextField( + controller: queryController, + enabled: canSearch && !searching, + onSubmitted: (_) => onSearch(), + decoration: InputDecoration( + labelText: 'Title, author, movie, album, or series', + suffixIcon: searching + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + ) + : IconButton(icon: const Icon(Icons.search), onPressed: canSearch ? onSearch : null), + ), + ), + if (!canSearch) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text( + indexers.isEmpty + ? 'Add an enabled Prowlarr or Torznab indexer first.' + : !hasEnabledClient + ? 'Add an enabled download client before searching.' + : 'Select at least one torrent indexer.', + ), + ), + ], + ), + ), + ); + } +} + +class _EndpointSection extends StatelessWidget { + const _EndpointSection({ + required this.title, + required this.emptyLabel, + required this.endpoints, + required this.onEdit, + required this.onDelete, + }); + + final String title; + final String emptyLabel; + final List endpoints; + final ValueChanged onEdit; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (endpoints.isEmpty) + Card( + child: ListTile(leading: const Icon(Icons.info_outline), title: Text(emptyLabel)), + ) + else + ...endpoints.map((endpoint) => _EndpointTile(endpoint: endpoint, onEdit: onEdit, onDelete: onDelete)), + ], + ), + ); + } +} + +class _ArrSection extends StatelessWidget { + const _ArrSection({ + required this.endpoints, + required this.submittingKeys, + required this.onRun, + required this.onEdit, + required this.onDelete, + }); + + final List endpoints; + final Set submittingKeys; + final ValueChanged onRun; + final ValueChanged onEdit; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Arr applications', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (endpoints.isEmpty) + const Card( + child: ListTile(leading: Icon(Icons.info_outline), title: Text('No Arr applications configured')), + ) + else + ...endpoints.map( + (endpoint) => _EndpointTile( + endpoint: endpoint, + onEdit: onEdit, + onDelete: onDelete, + trailingAction: IconButton( + tooltip: 'Run action', + icon: const Icon(Icons.play_arrow_outlined), + onPressed: endpoint.enabled && !submittingKeys.contains('arr:${endpoint.id}') + ? () => onRun(endpoint) + : null, + ), + ), + ), + ], + ), + ); + } +} + +class _EndpointTile extends StatelessWidget { + const _EndpointTile({required this.endpoint, required this.onEdit, required this.onDelete, this.trailingAction}); + + final AcquisitionEndpoint endpoint; + final ValueChanged onEdit; + final ValueChanged onDelete; + final Widget? trailingAction; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + leading: Icon(_iconFor(endpoint.kind)), + title: Text(endpoint.name), + subtitle: Text('${endpoint.kind.label} • ${endpoint.baseUrl.host}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ?trailingAction, + Icon( + endpoint.enabled ? Icons.check_circle_outline : Icons.pause_circle_outline, + color: endpoint.enabled ? Theme.of(context).colorScheme.primary : null, + ), + PopupMenuButton( + onSelected: (value) { + if (value == 'edit') onEdit(endpoint); + if (value == 'delete') onDelete(endpoint); + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem(value: 'delete', child: Text('Remove')), + ], + ), + ], + ), + ), + ); + } + + IconData _iconFor(AcquisitionEndpointKind kind) => switch (kind) { + AcquisitionEndpointKind.qbittorrent || + AcquisitionEndpointKind.transmission || + AcquisitionEndpointKind.deluge => Icons.downloading_outlined, + AcquisitionEndpointKind.prowlarr || AcquisitionEndpointKind.torznab => Icons.travel_explore, + _ => Icons.auto_awesome_motion_outlined, + }; +} + +class _ReleaseTile extends StatelessWidget { + const _ReleaseTile({ + required this.release, + required this.clients, + required this.submittingKeys, + required this.onSubmit, + }); + + final TorrentRelease release; + final List clients; + final Set submittingKeys; + final void Function(TorrentRelease release, AcquisitionEndpoint client) onSubmit; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + title: Text(release.title), + subtitle: Text( + [ + release.indexer, + if (release.seeders != null) '${release.seeders} seeders', + if (release.sizeBytes != null) _formatBytes(release.sizeBytes!), + if (release.isMagnet) 'magnet', + ].join(' • '), + ), + trailing: PopupMenuButton( + enabled: clients.any((client) => !submittingKeys.contains(_submissionKey(release, client))), + icon: const Icon(Icons.send_outlined), + tooltip: 'Send to client', + itemBuilder: (context) => clients.map((client) { + final key = _submissionKey(release, client); + return PopupMenuItem( + key: Key('submission:$key'), + value: client, + enabled: !submittingKeys.contains(key), + child: Text(client.name), + ); + }).toList(), + onSelected: (client) => onSubmit(release, client), + ), + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes >= 1073741824) return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; + if (bytes >= 1048576) return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '$bytes B'; + } +} + +String _submissionKey(TorrentRelease release, AcquisitionEndpoint client) { + return '${release.downloadUrl}:${client.id}'; +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( + color: colorScheme.errorContainer, + child: ListTile( + leading: Icon(Icons.error_outline, color: colorScheme.onErrorContainer), + title: Text(message, style: TextStyle(color: colorScheme.onErrorContainer)), + trailing: TextButton(onPressed: onRetry, child: const Text('Retry')), + ), + ); + } +} diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 9875984..a9d1301 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -8,6 +8,7 @@ import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/powersync/sync_state.dart'; @@ -205,6 +206,8 @@ class _ProfilePageState extends State { Widget _buildMobileStorageSyncSection(BuildContext context) { final controller = _storageSyncController(context); + final prefs = context.watch(); + final acquisitionAvailable = _acquisitionAvailable(context); if (controller.isGuest) { return Column( @@ -249,6 +252,13 @@ class _ProfilePageState extends State { onTap: () => _retryFailedMediaUploads(context), ), SettingsRow(label: 'Manage servers', onTap: () => _showManageSyncServersSheet(context)), + SettingsToggleRow( + label: 'Torrent acquisition', + value: prefs.acquisitionEnabled, + onChanged: (value) => prefs.acquisitionEnabled = value, + ), + if (prefs.acquisitionEnabled && acquisitionAvailable) + SettingsRow(label: 'Torrent & automation', onTap: () => context.push('/acquisition')), if (controller.canReconnect) SettingsRow(label: 'Reconnect', onTap: () => _handleReconnectSync(context)), if (controller.canClearGuestLibrary) SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), @@ -930,6 +940,8 @@ class _ProfilePageState extends State { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final controller = _storageSyncController(context); + final prefs = context.watch(); + final acquisitionAvailable = _acquisitionAvailable(context); if (controller.isGuest) return _buildOfflineStorageSyncContent(context); @@ -947,6 +959,12 @@ class _ProfilePageState extends State { child: Text(controller.syncDetail, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), ), const SizedBox(height: Spacing.sm), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Torrent acquisition'), + value: prefs.acquisitionEnabled, + onChanged: (value) => prefs.acquisitionEnabled = value, + ), Wrap( spacing: Spacing.sm, runSpacing: Spacing.sm, @@ -963,6 +981,12 @@ class _ProfilePageState extends State { icon: const Icon(Icons.dns_outlined, size: IconSizes.small), label: const Text('Manage servers'), ), + if (prefs.acquisitionEnabled && acquisitionAvailable) + OutlinedButton.icon( + onPressed: () => context.push('/acquisition'), + icon: const Icon(Icons.downloading_outlined, size: IconSizes.small), + label: const Text('Torrent & automation'), + ), if (controller.hasFailedMediaUploads) OutlinedButton.icon( onPressed: () => _retryFailedMediaUploads(context), @@ -1040,6 +1064,11 @@ class _ProfilePageState extends State { ); } + bool _acquisitionAvailable(BuildContext context) { + final serverBaseUri = context.watch().activeApiConfig.serverBaseUri; + return context.watch().isAvailableFor(serverBaseUri); + } + int _fileStorageUsedBytes(DataStore dataStore) { return dataStore.books.fold(0, (total, book) => total + (book.fileSize ?? 0)); } diff --git a/app/lib/providers/acquisition_availability_provider.dart b/app/lib/providers/acquisition_availability_provider.dart new file mode 100644 index 0000000..2a16c7a --- /dev/null +++ b/app/lib/providers/acquisition_availability_provider.dart @@ -0,0 +1,113 @@ +import 'package:flutter/foundation.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/providers/auth_provider.dart'; + +enum AcquisitionAvailabilityState { unknown, loading, available, unavailable } + +typedef AcquisitionCapabilitiesLoader = Future Function(Uri serverBaseUri); + +class AcquisitionAvailabilityProvider extends ChangeNotifier { + final AuthProvider? _authProvider; + final AcquisitionCapabilitiesLoader? _loadCapabilities; + + AcquisitionApiClient? _apiClient; + Uri? _serverBaseUri; + Future? _refreshOperation; + int _generation = 0; + AcquisitionAvailabilityState _state = AcquisitionAvailabilityState.unknown; + + AcquisitionAvailabilityProvider({AuthProvider? authProvider, AcquisitionCapabilitiesLoader? loadCapabilities}) + : assert((authProvider == null) != (loadCapabilities == null), 'Provide either authProvider or loadCapabilities'), + _authProvider = authProvider, + _loadCapabilities = loadCapabilities; + + AcquisitionAvailabilityState get state => _state; + + bool isAvailableFor(Uri serverBaseUri) { + return _serverBaseUri == serverBaseUri && _state == AcquisitionAvailabilityState.available; + } + + Future refresh(Uri serverBaseUri, {bool force = false}) { + if (_serverBaseUri == serverBaseUri) { + if (_state == AcquisitionAvailabilityState.loading) { + return _refreshOperation ?? Future.value(); + } + if (!force && _state != AcquisitionAvailabilityState.unknown) { + return Future.value(); + } + } + + if (_serverBaseUri != serverBaseUri) { + _replaceServer(serverBaseUri); + } + + final generation = ++_generation; + _state = AcquisitionAvailabilityState.loading; + notifyListeners(); + + final operation = _load(serverBaseUri, generation); + _refreshOperation = operation; + return operation.whenComplete(() { + if (identical(_refreshOperation, operation)) { + _refreshOperation = null; + } + }); + } + + void clear() { + _generation += 1; + _apiClient?.close(); + _apiClient = null; + _serverBaseUri = null; + _refreshOperation = null; + + if (_state != AcquisitionAvailabilityState.unknown) { + _state = AcquisitionAvailabilityState.unknown; + notifyListeners(); + } + } + + Future _load(Uri serverBaseUri, int generation) async { + try { + final capabilities = await (_loadCapabilities != null + ? _loadCapabilities(serverBaseUri) + : _loadFromServer(serverBaseUri)); + if (generation != _generation || _serverBaseUri != serverBaseUri) { + return; + } + + _state = capabilities.enabled ? AcquisitionAvailabilityState.available : AcquisitionAvailabilityState.unavailable; + } catch (_) { + if (generation != _generation || _serverBaseUri != serverBaseUri) { + return; + } + + _state = AcquisitionAvailabilityState.unavailable; + } + + notifyListeners(); + } + + Future _loadFromServer(Uri serverBaseUri) { + final apiClient = _apiClient ??= AcquisitionApiClient(config: PapyrusApiConfig(serverBaseUri: serverBaseUri)); + return _authProvider!.withFreshAccessToken(apiClient.capabilities); + } + + void _replaceServer(Uri serverBaseUri) { + _generation += 1; + _apiClient?.close(); + _apiClient = null; + _serverBaseUri = serverBaseUri; + _refreshOperation = null; + _state = AcquisitionAvailabilityState.unknown; + } + + @override + void dispose() { + _generation += 1; + _apiClient?.close(); + super.dispose(); + } +} diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index a692c7d..9a9bd67 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -185,6 +185,19 @@ class AuthProvider extends ChangeNotifier { return _repository.downloadMedia(assetId); } + Future withFreshAccessToken(Future Function(String accessToken) action) async { + try { + return await _repository.withFreshAccessToken(action); + } catch (error) { + if (error is AuthApiException && error.statusCode == 401) { + _user = null; + _error = _messageFor(error); + _setStatus(AuthStatus.signedOut); + } + rethrow; + } + } + void setOfflineMode(bool value) { _isOfflineMode = value; _prefs.setBool(_keyOfflineMode, value); diff --git a/app/lib/providers/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index 15c3abb..2f501ed 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -44,6 +44,7 @@ class PreferencesProvider extends ChangeNotifier { static const _keyServerType = 'server_type'; static const _keySyncInterval = 'sync_interval'; static const _keyConflictResolution = 'conflict_resolution'; + static const _keyAcquisitionEnabled = 'acquisition_enabled'; // Privacy static const _keyAnalyticsOptIn = 'analytics_opt_in'; @@ -245,6 +246,13 @@ class PreferencesProvider extends ChangeNotifier { notifyListeners(); } + bool get acquisitionEnabled => _prefs.getBool(_keyAcquisitionEnabled) ?? false; + + set acquisitionEnabled(bool value) { + _prefs.setBool(_keyAcquisitionEnabled, value); + notifyListeners(); + } + // -- Privacy -------------------------------------------------------------- bool get analyticsOptIn => _prefs.getBool(_keyAnalyticsOptIn) ?? false; diff --git a/app/test/acquisition/acquisition_api_client_test.dart b/app/test/acquisition/acquisition_api_client_test.dart new file mode 100644 index 0000000..9bdb582 --- /dev/null +++ b/app/test/acquisition/acquisition_api_client_test.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; + +void main() { + test('uses acquisition capabilities endpoint with bearer auth', () async { + final seenPaths = []; + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient((request) async { + seenPaths.add(request.url.path); + expect(request.headers['authorization'], 'Bearer access-token'); + return http.Response( + jsonEncode({ + 'enabled': true, + 'endpoint_kinds': ['qbittorrent', 'prowlarr', 'readarr'], + 'indexer_kinds': ['prowlarr'], + 'download_client_kinds': ['qbittorrent'], + 'arr_kinds': ['readarr'], + 'arr_commands': { + 'readarr': ['BookSearch'], + }, + }), + 200, + ); + }), + ); + + final capabilities = await client.capabilities('access-token'); + + expect(seenPaths, ['/v1/acquisition/capabilities']); + expect(capabilities.downloadClientKinds, [AcquisitionEndpointKind.qbittorrent]); + expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], ['BookSearch']); + }); + + test('submits a selected release to a torrent client', () async { + late Map requestBody; + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient((request) async { + expect(request.url.path, '/v1/acquisition/submissions'); + requestBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'job_id': 'job-1', + 'endpoint_id': 'client-1', + 'rule_id': null, + 'title': 'Example', + 'download_url': 'magnet:?xt=urn:btih:example', + 'status': 'failed', + 'client_reference': null, + 'error': 'Transmission rejected the release', + 'created_at': null, + }), + 201, + ); + }), + ); + + final job = await client.submitRelease( + accessToken: 'access-token', + endpointId: 'client-1', + release: const TorrentRelease( + title: 'Example', + downloadUrl: 'magnet:?xt=urn:btih:example', + protocol: 'torrent', + indexer: 'Prowlarr', + ), + ); + + expect(requestBody['endpoint_id'], 'client-1'); + expect(requestBody['download_url'], 'magnet:?xt=urn:btih:example'); + expect(job.isSubmitted, isFalse); + expect(job.error, 'Transmission rejected the release'); + }); + + test('tests an unsaved endpoint without sending irrelevant credentials', () async { + late Map requestBody; + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient((request) async { + expect(request.url.path, '/v1/acquisition/endpoints/test'); + requestBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'ok': true}), 200); + }), + ); + + await client.testEndpoint( + accessToken: 'access-token', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://prowlarr.local:9696'), + apiKey: 'secret', + ); + + expect(requestBody, {'kind': 'prowlarr', 'base_url': 'http://prowlarr.local:9696', 'api_key': 'secret'}); + }); + + test('tests an edited endpoint with only supplied overrides', () async { + late Map requestBody; + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient((request) async { + requestBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'ok': true}), 200); + }), + ); + + await client.testEndpoint( + accessToken: 'access-token', + endpointId: 'endpoint-1', + baseUrl: Uri.parse('http://edited.local:9696'), + ); + + expect(requestBody, {'endpoint_id': 'endpoint-1', 'base_url': 'http://edited.local:9696'}); + }); + + test('surfaces FastAPI detail errors from connection tests', () async { + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient( + (_) async => http.Response(jsonEncode({'detail': 'Prowlarr connection test failed'}), 502), + ), + ); + + await expectLater( + client.testEndpoint( + accessToken: 'access-token', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://prowlarr.local:9696'), + ), + throwsA( + isA().having((error) => error.toString(), 'message', contains('Prowlarr connection test failed')), + ), + ); + }); +} diff --git a/app/test/acquisition/acquisition_availability_provider_test.dart b/app/test/acquisition/acquisition_availability_provider_test.dart new file mode 100644 index 0000000..75ab5a5 --- /dev/null +++ b/app/test/acquisition/acquisition_availability_provider_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; + +void main() { + test('loads and caches enabled capability state by server', () async { + final calls = []; + final provider = AcquisitionAvailabilityProvider( + loadCapabilities: (serverBaseUri) async { + calls.add(serverBaseUri); + return const AcquisitionCapabilities( + enabled: true, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ); + }, + ); + final server = Uri.parse('https://api.test'); + + await provider.refresh(server); + await provider.refresh(server); + + expect(provider.state, AcquisitionAvailabilityState.available); + expect(provider.isAvailableFor(server), isTrue); + expect(calls, [server]); + }); + + test('treats disabled, failed, and unknown servers as unavailable', () async { + final provider = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ); + final server = Uri.parse('https://api.test'); + + expect(provider.isAvailableFor(server), isFalse); + + await provider.refresh(server); + + expect(provider.state, AcquisitionAvailabilityState.unavailable); + expect(provider.isAvailableFor(server), isFalse); + expect(provider.isAvailableFor(Uri.parse('https://another.test')), isFalse); + }); +} diff --git a/app/test/acquisition/acquisition_models_test.dart b/app/test/acquisition/acquisition_models_test.dart new file mode 100644 index 0000000..4efb204 --- /dev/null +++ b/app/test/acquisition/acquisition_models_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; + +void main() { + test('recognizes a magnet release returned by an indexer', () { + final release = TorrentRelease.fromJson({ + 'title': 'Example book', + 'download_url': 'magnet:?xt=urn:btih:example', + 'protocol': 'torrent', + 'indexer': 'Prowlarr', + 'seeders': 12, + }); + + expect(release.isMagnet, isTrue); + expect(release.seeders, 12); + }); + + test('parses torrent-only capabilities', () { + final capabilities = AcquisitionCapabilities.fromJson({ + 'enabled': true, + 'endpoint_kinds': ['qbittorrent', 'transmission', 'deluge', 'prowlarr', 'torznab', 'readarr'], + 'indexer_kinds': ['prowlarr', 'torznab'], + 'download_client_kinds': ['qbittorrent', 'transmission', 'deluge'], + 'arr_kinds': ['readarr'], + 'arr_commands': { + 'readarr': ['AuthorSearch', 'BookSearch'], + }, + }); + + expect(capabilities.indexerKinds, [AcquisitionEndpointKind.prowlarr, AcquisitionEndpointKind.torznab]); + expect(capabilities.downloadClientKinds, [ + AcquisitionEndpointKind.qbittorrent, + AcquisitionEndpointKind.transmission, + AcquisitionEndpointKind.deluge, + ]); + expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], ['AuthorSearch', 'BookSearch']); + expect(capabilities.enabled, isTrue); + }); + + test('parses disabled capabilities and failed jobs', () { + final capabilities = AcquisitionCapabilities.fromJson({'enabled': false}); + final job = AcquisitionJob.fromJson({ + 'job_id': 'job-1', + 'endpoint_id': null, + 'rule_id': null, + 'title': 'Release', + 'download_url': 'magnet:?xt=urn:btih:test', + 'status': 'failed', + 'client_reference': null, + 'error': 'Transmission rejected the release', + 'created_at': '2026-07-17T12:00:00Z', + }); + + expect(capabilities.enabled, isFalse); + expect(capabilities.endpointKinds, isEmpty); + expect(job.endpointId, isNull); + expect(job.isSubmitted, isFalse); + expect(job.error, 'Transmission rejected the release'); + }); +} diff --git a/app/test/config/app_router_test.dart b/app/test/config/app_router_test.dart index d2b6197..c9a1c12 100644 --- a/app/test/config/app_router_test.dart +++ b/app/test/config/app_router_test.dart @@ -1,11 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/config/app_router.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; class MemoryRefreshTokenStorage implements RefreshTokenStorage { @@ -51,7 +55,7 @@ void main() { await provider.bootstrap(); - final appRouter = AppRouter(authProvider: provider); + final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); expect(appRouter.redirectForPath('/library/books'), '/'); expect(appRouter.redirectForPath('/login'), isNull); @@ -65,7 +69,7 @@ void main() { await provider.bootstrap(); - final appRouter = AppRouter(authProvider: provider); + final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); expect(appRouter.redirectForPath('/login'), '/library/books'); expect(appRouter.redirectForPath('/reset-password'), '/library/books'); @@ -79,7 +83,7 @@ void main() { await provider.bootstrap(); provider.setOfflineMode(true); - final appRouter = AppRouter(authProvider: provider); + final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); expect(appRouter.redirectForPath('/library/books'), isNull); }); @@ -87,10 +91,70 @@ void main() { test('book edit has a stable reloadable URL', () async { final prefs = await SharedPreferences.getInstance(); final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); - final appRouter = AppRouter(authProvider: provider); + final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); expect(appRouter.router.namedLocation('BOOK_EDIT', pathParameters: {'bookId': 'book-1'}), '/library/edit/book-1'); }); + + test('acquisition route requires explicit opt-in', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = FakeAuthRepository()..bootstrapResult = _tokens(); + final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + final preferences = PreferencesProvider(prefs); + final syncSettings = _syncSettings(prefs); + final availability = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: true, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ); + + await provider.bootstrap(); + + final appRouter = AppRouter( + authProvider: provider, + preferencesProvider: preferences, + syncSettingsProvider: syncSettings, + acquisitionAvailabilityProvider: availability, + ); + + expect(appRouter.redirectForPath('/acquisition'), '/profile'); + + preferences.acquisitionEnabled = true; + + expect(appRouter.redirectForPath('/acquisition'), '/profile'); + + await availability.refresh(syncSettings.activeApiConfig.serverBaseUri); + + expect(appRouter.redirectForPath('/acquisition'), isNull); + }); +} + +Future _buildRouter({required AuthProvider authProvider, required SharedPreferences prefs}) async { + final syncSettings = _syncSettings(prefs); + return AppRouter( + authProvider: authProvider, + preferencesProvider: PreferencesProvider(prefs), + syncSettingsProvider: syncSettings, + acquisitionAvailabilityProvider: AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ), + ); +} + +SyncSettingsProvider _syncSettings(SharedPreferences prefs) { + return SyncSettingsProvider(prefs, officialConfig: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test'))); } AuthTokens _tokens() { diff --git a/app/test/pages/acquisition_page_test.dart b/app/test/pages/acquisition_page_test.dart new file mode 100644 index 0000000..b4b49b7 --- /dev/null +++ b/app/test/pages/acquisition_page_test.dart @@ -0,0 +1,355 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/pages/acquisition_page.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemoryRefreshTokenStorage implements RefreshTokenStorage { + @override + Future delete() async {} + + @override + Future read() async => null; + + @override + Future write(String refreshToken) async {} +} + +class _FakeAuthRepository extends AuthRepository { + _FakeAuthRepository() + : super( + apiClient: AuthApiClient(config: _config), + tokenStore: TokenStore(_MemoryRefreshTokenStorage()), + ); + + @override + Future bootstrap() async => _tokens(); + + @override + Future withFreshAccessToken(Future Function(String accessToken) action) { + return action('access-token'); + } +} + +class _FakeAcquisitionApiClient extends AcquisitionApiClient { + _FakeAcquisitionApiClient() : super(config: _config, httpClient: MockClient((_) async => throw UnimplementedError())); + + final capabilitiesResult = const AcquisitionCapabilities( + enabled: true, + endpointKinds: [ + AcquisitionEndpointKind.prowlarr, + AcquisitionEndpointKind.qbittorrent, + AcquisitionEndpointKind.deluge, + ], + indexerKinds: [AcquisitionEndpointKind.prowlarr], + downloadClientKinds: [AcquisitionEndpointKind.qbittorrent, AcquisitionEndpointKind.deluge], + arrKinds: [], + arrCommands: {}, + ); + Completer? connectionTestCompleter; + int connectionTestCalls = 0; + List endpointsResult = []; + List releasesResult = []; + final searchEndpointIds = ?>[]; + final submissionCompleters = >{}; + + @override + Future capabilities(String accessToken) async { + return capabilitiesResult; + } + + @override + Future> listEndpoints(String accessToken) async { + return endpointsResult; + } + + @override + Future testEndpoint({ + required String accessToken, + String? endpointId, + AcquisitionEndpointKind? kind, + Uri? baseUrl, + String? apiKey, + String? username, + String? password, + }) { + connectionTestCalls += 1; + return connectionTestCompleter?.future ?? Future.value(); + } + + @override + Future> search({ + required String accessToken, + required String query, + List? endpointIds, + }) async { + searchEndpointIds.add(endpointIds); + return releasesResult; + } + + @override + Future submitRelease({ + required String accessToken, + required String endpointId, + required TorrentRelease release, + String? category, + String? savePath, + }) { + final key = '${release.downloadUrl}:$endpointId'; + return submissionCompleters.putIfAbsent(key, Completer.new).future; + } +} + +final _config = PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')); + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({'acquisition_enabled': true}); + }); + + testWidgets('integration dialog shows credentials required by type', (tester) async { + final apiClient = _FakeAcquisitionApiClient(); + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Integration')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'API key'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Username'), findsNothing); + expect(find.widgetWithText(TextField, 'Password'), findsNothing); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('qBittorrent').last); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'API key'), findsNothing); + expect(find.widgetWithText(TextField, 'Username'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Deluge').last); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'API key'), findsNothing); + expect(find.widgetWithText(TextField, 'Username'), findsNothing); + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + }); + + testWidgets('integration dialog locks actions and renders test errors', (tester) async { + final apiClient = _FakeAcquisitionApiClient(); + final completer = Completer(); + apiClient.connectionTestCompleter = completer; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Integration')); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextField, 'Server URL'), 'http://prowlarr.local:9696'); + await tester.tap(find.byKey(const Key('acquisition-test-connection'))); + await tester.pump(); + + expect(apiClient.connectionTestCalls, 1); + expect(tester.widget(find.byKey(const Key('acquisition-test-connection'))).onPressed, isNull); + expect(tester.widget(find.byKey(const Key('acquisition-save'))).onPressed, isNull); + + await tester.tap(find.byKey(const Key('acquisition-test-connection')), warnIfMissed: false); + await tester.pump(); + expect(apiClient.connectionTestCalls, 1); + + completer.completeError(const AuthApiException(statusCode: 502, message: 'Prowlarr connection test failed')); + await tester.pumpAndSettle(); + + expect(find.text('Prowlarr connection test failed'), findsOneWidget); + }); + + testWidgets('search and submission requires selected indexer and client', (tester) async { + final apiClient = _FakeAcquisitionApiClient()..endpointsResult = [_indexerOne, _indexerTwo, _clientOne]; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + expect(find.byType(FilterChip), findsNWidgets(2)); + + await tester.tap(find.widgetWithText(FilterChip, 'Indexer One')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilterChip, 'Indexer Two')); + await tester.pump(); + + expect(tester.widget(_searchField).enabled, isFalse); + + await tester.tap(find.widgetWithText(FilterChip, 'Indexer One')); + await tester.pump(); + await tester.enterText(_searchField, 'book'); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + expect(apiClient.searchEndpointIds, [ + ['indexer-1'], + ]); + }); + + testWidgets('search and submission requires an enabled download client', (tester) async { + final apiClient = _FakeAcquisitionApiClient()..endpointsResult = [_indexerOne]; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + expect(tester.widget(_searchField).enabled, isFalse); + }); + + testWidgets('search and submission scopes progress and shows job failure', (tester) async { + final apiClient = _FakeAcquisitionApiClient() + ..endpointsResult = [_indexerOne, _clientOne, _clientTwo] + ..releasesResult = [_releaseOne, _releaseTwo]; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + await tester.enterText(_searchField, 'book'); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Release One'), 400, scrollable: find.byType(Scrollable).first); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.send_outlined).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Client One').last); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.send_outlined).first); + await tester.pumpAndSettle(); + expect(_submissionItem(tester, _releaseOne, _clientOne).enabled, isFalse); + expect(_submissionItem(tester, _releaseOne, _clientTwo).enabled, isTrue); + + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Release Two'), 300, scrollable: find.byType(Scrollable).first); + await tester.pumpAndSettle(); + final releaseTwoTile = find.ancestor(of: find.text('Release Two'), matching: find.byType(ListTile)); + final releaseTwoMenu = find.descendant( + of: releaseTwoTile, + matching: find.byType(PopupMenuButton), + ); + expect(tester.widget>(releaseTwoMenu).enabled, isTrue); + + apiClient.submissionCompleters['${_releaseOne.downloadUrl}:${_clientOne.id}']!.complete( + _job(status: 'failed', error: 'Transmission rejected the release'), + ); + await tester.pumpAndSettle(); + + expect(find.text('Transmission rejected the release'), findsOneWidget); + expect(find.text('Sent to Client One.'), findsNothing); + }); +} + +final _searchField = find.widgetWithText(TextField, 'Title, author, movie, album, or series'); + +PopupMenuItem _submissionItem( + WidgetTester tester, + TorrentRelease release, + AcquisitionEndpoint client, +) { + return tester.widget>( + find.byKey(Key('submission:${release.downloadUrl}:${client.id}')), + ); +} + +final _indexerOne = AcquisitionEndpoint( + id: 'indexer-1', + name: 'Indexer One', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://indexer-one.local'), + enabled: true, +); +final _indexerTwo = AcquisitionEndpoint( + id: 'indexer-2', + name: 'Indexer Two', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://indexer-two.local'), + enabled: true, +); +final _clientOne = AcquisitionEndpoint( + id: 'client-1', + name: 'Client One', + kind: AcquisitionEndpointKind.transmission, + baseUrl: Uri.parse('http://client-one.local'), + enabled: true, +); +final _clientTwo = AcquisitionEndpoint( + id: 'client-2', + name: 'Client Two', + kind: AcquisitionEndpointKind.qbittorrent, + baseUrl: Uri.parse('http://client-two.local'), + enabled: true, +); +const _releaseOne = TorrentRelease( + title: 'Release One', + downloadUrl: 'magnet:?xt=urn:btih:release-one', + protocol: 'torrent', + indexer: 'Indexer One', +); +const _releaseTwo = TorrentRelease( + title: 'Release Two', + downloadUrl: 'magnet:?xt=urn:btih:release-two', + protocol: 'torrent', + indexer: 'Indexer One', +); + +AcquisitionJob _job({required String status, String? error}) { + return AcquisitionJob( + id: 'job-1', + endpointId: 'client-1', + ruleId: null, + title: 'Release One', + downloadUrl: _releaseOne.downloadUrl, + status: status, + clientReference: null, + error: error, + createdAt: null, + ); +} + +Future _buildPage(_FakeAcquisitionApiClient apiClient) async { + final prefs = await SharedPreferences.getInstance(); + final authProvider = AuthProvider(prefs, repository: _FakeAuthRepository(), bootstrapOnCreate: false); + await authProvider.bootstrap(); + + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), + ChangeNotifierProvider(create: (_) => SyncSettingsProvider(prefs, officialConfig: _config)), + ], + child: MaterialApp(home: AcquisitionPage(clientFactory: (_) => apiClient)), + ); +} + +AuthTokens _tokens() { + return AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresIn: 3600, + user: PapyrusUser( + userId: '11111111-1111-1111-1111-111111111111', + email: 'reader@example.com', + displayName: 'Reader', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + ), + ); +} diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 6deabba..2560b81 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; @@ -13,6 +14,7 @@ import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:powersync/powersync.dart'; @@ -123,6 +125,7 @@ void main() { SyncSettingsProvider? syncSettingsProvider, DataStore? dataStore, MediaUploadQueue? mediaUploadQueue, + AcquisitionAvailabilityProvider? acquisitionAvailabilityProvider, }) async { final prefs = await SharedPreferences.getInstance(); final config = PapyrusApiConfig( @@ -140,6 +143,20 @@ void main() { Provider.value(value: powerSyncService), StreamProvider.value(value: powerSyncService.syncStates, initialData: powerSyncService.syncState), ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider.value( + value: + acquisitionAvailabilityProvider ?? + AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ), + ), ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), ], child: MaterialApp( @@ -183,6 +200,39 @@ void main() { expect(find.text('No pending local writes'), findsNothing); }); + testWidgets('authenticated profile hides acquisition management when server is unavailable', (tester) async { + final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( + currentMode: LibraryDatabaseMode.authenticated, + currentSyncState: const SyncState(connected: true), + ); + final availability = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ); + await availability.refresh(Uri.parse('https://api.test')); + + await tester.pumpWidget( + await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage'), 400); + await tester.pumpAndSettle(); + + expect(find.text('Torrent acquisition'), findsOneWidget); + + await tester.tap(find.text('Torrent acquisition')); + await tester.pumpAndSettle(); + + expect(find.text('Torrent & automation'), findsNothing); + }); + testWidgets('offline desktop storage sync is local-first and hides sync internals', (tester) async { final auth = await buildAuthProvider(guest: true); final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); @@ -222,6 +272,17 @@ void main() { currentMode: LibraryDatabaseMode.authenticated, currentSyncState: SyncState(connected: true, lastSyncedAt: DateTime.utc(2026, 6, 27, 10, 30)), ); + final availability = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: true, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ); + await availability.refresh(Uri.parse('https://api.test')); await tester.pumpWidget( await buildPage( @@ -229,6 +290,7 @@ void main() { powerSyncService: service, screenSize: const Size(1200, 900), dataStore: dataStore, + acquisitionAvailabilityProvider: availability, ), ); await tester.pumpAndSettle(); @@ -241,6 +303,8 @@ void main() { expect(find.text('Connected'), findsWidgets); expect(find.text('Reconnect'), findsOneWidget); expect(find.text('Manage servers'), findsOneWidget); + expect(find.text('Torrent acquisition'), findsOneWidget); + expect(find.text('Torrent & automation'), findsNothing); expect(find.text('Clear local copy'), findsOneWidget); expect(find.text('Clear account local cache'), findsNothing); expect(find.text('Pending changes'), findsNothing); @@ -258,6 +322,11 @@ void main() { await tester.pump(); expect(service.reconnectCalls, 1); + + await tester.tap(find.text('Torrent acquisition')); + await tester.pumpAndSettle(); + + expect(find.text('Torrent & automation'), findsOneWidget); }); testWidgets('manage servers lists official and custom servers for switching', (tester) async {