diff --git a/Documentation/torrent-download.md b/Documentation/torrent-download.md index 625911249..13f5bcf8b 100644 --- a/Documentation/torrent-download.md +++ b/Documentation/torrent-download.md @@ -7,8 +7,9 @@ NipaPlay 的下载器可以管理种子文件和磁力链接任务,并在任 1. 打开下载器,选择添加种子文件或磁力链接。 2. 选择下载目录;移动设备需先确认系统存储权限。 3. 如需按任务分类,启用“为任务创建目录”。 -4. 查看元数据和文件列表后开始任务;可暂停、继续或移除任务。 -5. 删除任务前确认是否同时删除本地数据。该操作可能不可恢复。 +4. 预览元数据和文件列表后添加任务;添加直接复用预览结果。解析最多等待 60 秒,可取消或修改链接后重试。 +5. 下载中可选择视频边下边播;初始化暂停后可继续,错误任务可重试,完成后的做种也可暂停。 +6. 删除任务前确认是否同时删除本地数据。该操作可能不可恢复。 ## 使用建议 @@ -20,3 +21,10 @@ NipaPlay 的下载器可以管理种子文件和磁力链接任务,并在任 ## 排障信息 报告问题时附上任务状态、保存目录是否可写、可用磁盘空间、平台、NipaPlay 版本和脱敏日志;不要公开含有私人 tracker、token 或个人路径的 magnet/日志。 + +## 任务状态与保存 + +- 添加过程中显示处理提示;操作完成后立即刷新对应状态,其他任务仍可操作。 +- 重复添加会提示任务已存在,并保留原任务的状态和保存位置。继续下载请使用任务的继续或重试按钮。 +- Android 和鸿蒙将任务记录保存在应用支持目录,不随下载位置改变。升级时会从当前下载目录、最近使用的目录和默认下载目录导入旧记录;保留原文件、暂停状态和旧记录备份。 +- 自动入库只扫描该任务已经完成的视频文件。失败会重试,不会把共享下载目录中的其他未完成文件一并扫描。旧版本的扫描完成标记会重新验证一次。 diff --git a/lib/constants/settings_keys.dart b/lib/constants/settings_keys.dart index c3c61c7c2..b77796362 100644 --- a/lib/constants/settings_keys.dart +++ b/lib/constants/settings_keys.dart @@ -88,6 +88,7 @@ class SettingsKeys { static const String downloaderAutoScanCompletedTasks = 'downloader_auto_scan_completed_tasks'; + static const String downloaderVerifiedScannedCompletedTaskKeys = 'downloader_verified_scanned_completed_task_keys'; static const String downloaderAutoScannedCompletedTaskKeys = 'downloader_auto_scanned_completed_task_keys'; diff --git a/lib/downloads/torrent_task_controller.dart b/lib/downloads/torrent_task_controller.dart new file mode 100644 index 000000000..60c09b791 --- /dev/null +++ b/lib/downloads/torrent_task_controller.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:nipaplay/models/torrent_task.dart'; + +/// Serializes snapshots and locks individual tasks until their changed state has +/// been read back. A slow add or another task never blocks list polling. +class TorrentTaskController extends ChangeNotifier { + TorrentTaskController({required this.loadTasks}); + + final Future> Function() loadTasks; + List _tasks = const []; + List get tasks => _tasks; + final Set _busyIds = {}; + bool isBusy(int id) => _busyIds.contains(id); + Future? _refresh; + bool _refreshAgain = false; + bool _disposed = false; + + Future refresh() { + if (_disposed) return Future.value(); + _refreshAgain = true; + return _refresh ??= _drainRefresh().whenComplete(() => _refresh = null); + } + + Future _drainRefresh() async { + do { + _refreshAgain = false; + final tasks = await loadTasks(); + if (_disposed) return; + _tasks = List.unmodifiable(tasks); + notifyListeners(); + } while (_refreshAgain); + } + + Future run(int id, Future Function() action) async { + if (_disposed || !_busyIds.add(id)) return false; + notifyListeners(); + try { + await action(); + // If a pre-action poll was already in flight, refresh() schedules another + // read before releasing this task's controls. + await refresh(); + return true; + } finally { + _busyIds.remove(id); + if (!_disposed) notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/downloads/unified_torrent_page_model.dart b/lib/downloads/unified_torrent_page_model.dart index 77c96e25f..7db9000b5 100644 --- a/lib/downloads/unified_torrent_page_model.dart +++ b/lib/downloads/unified_torrent_page_model.dart @@ -2,6 +2,7 @@ import 'package:nipaplay/models/torrent_task.dart'; import 'package:nipaplay/models/torrent_task_scan_summary.dart'; import 'package:flutter/widgets.dart'; import 'package:nipaplay/models/torrent_magnet_preview.dart'; +import 'package:nipaplay/services/torrent_download_service.dart'; enum UnifiedTorrentTaskViewMode { cards, list } @@ -19,7 +20,7 @@ class UnifiedTorrentTaskActionViewModel { final UnifiedTorrentTaskAction action; final String label; - final VoidCallback onPressed; + final VoidCallback? onPressed; final bool destructive; } @@ -47,9 +48,10 @@ class UnifiedTorrentTaskItemViewModel { return null; } - UnifiedTorrentTaskActionViewModel get primaryAction => task.finished - ? action(UnifiedTorrentTaskAction.play)! - : action(UnifiedTorrentTaskAction.toggle)!; + UnifiedTorrentTaskActionViewModel get primaryAction => + task.finished && task.canPlay + ? action(UnifiedTorrentTaskAction.play)! + : action(UnifiedTorrentTaskAction.toggle)!; String? get scanStatusText { if (isAutoScanning) return '正在加入媒体库...'; @@ -134,8 +136,12 @@ class AddTorrentDialogResult { required this.magnetUri, required this.downloadDirectory, required this.createFolderForTask, + required this.preview, + required this.previewRequest, }); + final TorrentPreviewRequest previewRequest; + final TorrentMagnetPreview preview; final String magnetUri; final String downloadDirectory; final bool createFolderForTask; diff --git a/lib/models/torrent_add_result.dart b/lib/models/torrent_add_result.dart new file mode 100644 index 000000000..6a4c242b2 --- /dev/null +++ b/lib/models/torrent_add_result.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +class TorrentAddResult { + const TorrentAddResult( + {required this.id, + required this.alreadyExists, + required this.outputFolder}); + + final int id; + final bool alreadyExists; + final String outputFolder; + + factory TorrentAddResult.fromJson(String json) { + final data = jsonDecode(json) as Map; + return TorrentAddResult( + id: (data['id'] as num).toInt(), + alreadyExists: data['already_exists'] == true, + outputFolder: data['output_folder'] as String, + ); + } + + String get message => + alreadyExists ? '任务已存在,保留原状态和保存位置:$outputFolder' : '已添加下载任务'; +} diff --git a/lib/models/torrent_magnet_preview.dart b/lib/models/torrent_magnet_preview.dart index 57558d9b4..fa211f78f 100644 --- a/lib/models/torrent_magnet_preview.dart +++ b/lib/models/torrent_magnet_preview.dart @@ -30,14 +30,16 @@ class TorrentMagnetPreview { required this.suggestedFolderName, required this.totalSize, required this.files, + this.requestId = 0, }); + final int requestId; final String name; final String suggestedFolderName; final int totalSize; final List files; - factory TorrentMagnetPreview.fromJson(String jsonText) { + factory TorrentMagnetPreview.fromJson(String jsonText, {int requestId = 0}) { final decoded = jsonDecode(jsonText); if (decoded is! Map) { return const TorrentMagnetPreview( @@ -50,6 +52,7 @@ class TorrentMagnetPreview { final rawFiles = decoded['files']; return TorrentMagnetPreview( + requestId: requestId, name: _asString(decoded['name'], fallback: '未命名任务'), suggestedFolderName: _asString(decoded['suggested_folder_name']), totalSize: _asInt(decoded['total_size']), diff --git a/lib/models/torrent_task.dart b/lib/models/torrent_task.dart index ac9ce68fe..0e3bd5433 100644 --- a/lib/models/torrent_task.dart +++ b/lib/models/torrent_task.dart @@ -74,6 +74,7 @@ class TorrentTask { required this.uploadSpeedBytesPerSecond, required this.error, this.files = const [], + this.initializingPaused = false, }); final int id; @@ -85,6 +86,7 @@ class TorrentTask { final int uploadedBytes; final int totalBytes; final bool finished; + final bool initializingPaused; final int downloadSpeedBytesPerSecond; final int uploadSpeedBytesPerSecond; final String? error; @@ -95,9 +97,24 @@ class TorrentTask { return (progressBytes / totalBytes).clamp(0.0, 1.0); } - bool get isPaused => state == 'paused'; + bool get isPaused => + state == 'paused' || (state == 'initializing' && initializingPaused); - bool get isActive => state == 'live' || state == 'initializing'; + bool get canResume => isPaused || hasError; + + bool get canPlay => !hasError && state != 'initializing'; + + String get toggleLabel => hasError + ? '重试' + : isPaused + ? (finished ? '继续做种' : '继续下载') + : (finished ? '暂停做种' : '暂停下载'); + + bool get isActive => + !finished && + !isPaused && + !hasError && + (state == 'live' || state == 'initializing'); bool get hasError => state == 'error' || (error?.isNotEmpty ?? false); @@ -105,7 +122,8 @@ class TorrentTask { String get displayState { if (hasError) return '错误'; - if (finished) return '已完成'; + if (finished) return isPaused ? '已完成(已暂停)' : '已完成(做种中)'; + if (isPaused) return '已暂停'; switch (state) { case 'initializing': return '初始化'; @@ -136,6 +154,7 @@ class TorrentTask { uploadedBytes: _asInt(stats['uploaded_bytes']), totalBytes: _asInt(stats['total_bytes']), finished: stats['finished'] == true, + initializingPaused: stats['initializing_paused'] == true, downloadSpeedBytesPerSecond: _speedBytes(live['download_speed'] as Map?), uploadSpeedBytesPerSecond: @@ -158,6 +177,7 @@ class TorrentTask { uploadedBytes: uploadedBytes, totalBytes: totalBytes, finished: finished, + initializingPaused: initializingPaused, downloadSpeedBytesPerSecond: downloadSpeedBytesPerSecond, uploadSpeedBytesPerSecond: uploadSpeedBytesPerSecond, error: error, diff --git a/lib/pages/torrent_download_page.dart b/lib/pages/torrent_download_page.dart index 998edec81..271e8713e 100644 --- a/lib/pages/torrent_download_page.dart +++ b/lib/pages/torrent_download_page.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; import 'package:kmbal_ionicons/kmbal_ionicons.dart'; import 'package:nipaplay/models/torrent_magnet_preview.dart'; import 'package:nipaplay/models/torrent_task.dart'; +import 'package:nipaplay/models/torrent_add_result.dart'; +import 'package:nipaplay/downloads/torrent_task_controller.dart'; import 'package:nipaplay/models/playable_item.dart'; import 'package:nipaplay/models/torrent_task_scan_summary.dart'; import 'package:nipaplay/providers/downloader_settings_provider.dart'; @@ -51,7 +53,11 @@ class _TorrentDownloadPageState extends State final TorrentDownloadService _service = TorrentDownloadService.instance; final TextEditingController _searchController = TextEditingController(); Timer? _refreshTimer; - List _tasks = const []; + late final TorrentTaskController _taskController; + List get _tasks => _taskController.tasks; + List? _lastScannedSnapshot; + bool _isPlaying = false; + final Map _autoScanRetryAfter = {}; final Set _autoScannedCompletedTaskKeys = {}; final Set _autoScanningTaskKeys = {}; Future _autoScanChain = Future.value(); @@ -89,13 +95,16 @@ class _TorrentDownloadPageState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _taskController = TorrentTaskController(loadTasks: _service.listTasks); + _taskController.addListener(_onTasksChanged); _initialize(); - _startRefreshTimer(); } @override void dispose() { _refreshTimer?.cancel(); + _taskController.removeListener(_onTasksChanged); + _taskController.dispose(); WidgetsBinding.instance.removeObserver(this); _searchController.dispose(); super.dispose(); @@ -106,6 +115,7 @@ class _TorrentDownloadPageState extends State // Stop refreshing when app is backgrounded, resume when foregrounded. if (state == AppLifecycleState.resumed) { _startRefreshTimer(); + unawaited(_refreshTasks(silent: true)); } else if (state == AppLifecycleState.paused) { _refreshTimer?.cancel(); } @@ -123,15 +133,12 @@ class _TorrentDownloadPageState extends State try { final directory = await _service.getDownloadDirectory(); await _service.initialize(); - final tasks = await _service.listTasks(); + await _taskController.refresh(); if (!mounted) return; setState(() { _downloadDirectory = directory; - _tasks = tasks; _isLoading = false; }); - unawaited(_loadScanSummariesForTasks(tasks)); - unawaited(_handleAutoScanCompletedTasks(tasks, silent: true)); } catch (e, stackTrace) { debugPrint('初始化种子下载失败: $e\n$stackTrace'); if (!mounted) return; @@ -139,19 +146,28 @@ class _TorrentDownloadPageState extends State _isLoading = false; }); _showTorrentMessage('初始化种子下载失败: $e'); + } finally { + if (mounted) _startRefreshTimer(); } } + void _onTasksChanged() { + if (!mounted) return; + setState(() {}); + if (identical(_lastScannedSnapshot, _tasks)) return; + _lastScannedSnapshot = _tasks; + unawaited(_loadScanSummariesForTasks(_tasks).catchError((Object error) { + debugPrint('读取下载任务入库结果失败: $error'); + })); + unawaited(_handleAutoScanCompletedTasks(_tasks, silent: true) + .catchError((Object error) { + debugPrint('检查下载任务自动入库失败: $error'); + })); + } + Future _refreshTasks({bool silent = false}) async { - if (_isBusy && silent) return; try { - final tasks = await _service.listTasks(); - if (!mounted) return; - setState(() { - _tasks = tasks; - }); - unawaited(_loadScanSummariesForTasks(tasks)); - unawaited(_handleAutoScanCompletedTasks(tasks, silent: silent)); + await _taskController.refresh(); } catch (e) { if (!mounted || silent) return; _showTorrentMessage('刷新下载列表失败: $e'); @@ -176,7 +192,12 @@ class _TorrentDownloadPageState extends State content: dialog, ); if (result == null) return; + if (!mounted) { + result.previewRequest.cancel(); + return; + } + TorrentAddResult? addResult; await _runBusyAction( action: () async { await _service.setDownloadDirectory(result.downloadDirectory); @@ -186,13 +207,18 @@ class _TorrentDownloadPageState extends State result.createFolderForTask, ); } - await _service.addMagnet( - result.magnetUri, - downloadDirectory: result.downloadDirectory, - createFolderForTask: result.createFolderForTask, - ); + try { + addResult = await _service.addMagnet( + result.magnetUri, + previewId: result.preview.requestId, + downloadDirectory: result.downloadDirectory, + createFolderForTask: result.createFolderForTask, + ); + } finally { + result.previewRequest.cancel(); + } }, - successMessage: '已添加下载任务', + successMessage: () => addResult?.message ?? '已添加下载任务', afterSuccess: () { if (mounted) { setState(() { @@ -201,6 +227,7 @@ class _TorrentDownloadPageState extends State } }, ); + result.previewRequest.cancel(); } void _updateSearchQuery(String value) { @@ -247,16 +274,18 @@ class _TorrentDownloadPageState extends State final downloadDirectory = selectedDirectory.trim(); final downloaderSettings = context.read(); + TorrentAddResult? addResult; await _runBusyAction( action: () async { await _service.setDownloadDirectory(downloadDirectory); - await _service.addTorrentFile( + addResult = await _service.addTorrentFile( file.path, downloadDirectory: downloadDirectory, createFolderForTask: downloaderSettings.createFolderForTask, ); }, - successMessage: '已添加 ${p.basename(file.path)}', + successMessage: () => + addResult?.message ?? '已添加 ${p.basename(file.path)}', afterSuccess: () { if (!mounted) return; setState(() { @@ -268,43 +297,46 @@ class _TorrentDownloadPageState extends State Future _runBusyAction({ required Future Function() action, - required String successMessage, + required String Function() successMessage, VoidCallback? afterSuccess, + int? taskId, }) async { - if (_isBusy) return; - setState(() { - _isBusy = true; - }); + if (!mounted || (taskId == null && _isBusy)) return; + if (taskId == null) setState(() => _isBusy = true); try { - await action(); - afterSuccess?.call(); - await _refreshTasks(silent: true); - if (!mounted) return; - _showTorrentMessage(successMessage); + Future perform() async { + await action(); + afterSuccess?.call(); + } + + if (taskId == null) { + await perform(); + await _taskController.refresh(); + } else if (!await _taskController.run(taskId, perform)) { + return; + } + if (mounted) _showTorrentMessage(successMessage()); } catch (e) { - if (!mounted) return; - _showTorrentMessage('操作失败: $e'); + if (mounted) _showTorrentMessage('操作失败: $e'); } finally { - if (mounted) { - setState(() { - _isBusy = false; - }); - } + if (mounted && taskId == null) setState(() => _isBusy = false); } } Future _toggleTask(TorrentTask task) async { await _runBusyAction( action: () => - task.isPaused ? _service.resume(task.id) : _service.pause(task.id), - successMessage: task.isPaused ? '已继续下载' : '已暂停下载', + task.canResume ? _service.resume(task.id) : _service.pause(task.id), + taskId: task.id, + successMessage: () => task.canResume ? '已恢复任务' : '已暂停任务', ); } Future _forgetTask(TorrentTask task) async { await _runBusyAction( action: () => _service.forget(task.id), - successMessage: '已移除下载任务', + taskId: task.id, + successMessage: () => '已移除下载任务', ); } @@ -316,7 +348,8 @@ class _TorrentDownloadPageState extends State if (!confirm) return; await _runBusyAction( action: () => _service.delete(task.id), - successMessage: '已删除任务和文件', + taskId: task.id, + successMessage: () => '已删除任务和文件', ); } @@ -441,6 +474,8 @@ class _TorrentDownloadPageState extends State for (final task in tasks) { if (!task.finished || task.outputFolder.trim().isEmpty) continue; final key = task.autoScanKey; + final retryAfter = _autoScanRetryAfter[key]; + if (retryAfter != null && DateTime.now().isBefore(retryAfter)) continue; if (_autoScannedCompletedTaskKeys.contains(key) || _autoScanningTaskKeys.contains(key)) { continue; @@ -460,17 +495,23 @@ class _TorrentDownloadPageState extends State required bool silent, }) async { try { - final scanService = ServiceProvider.scanService; - await scanService.addScannedFolder(task.outputFolder); - while (mounted && scanService.isScanning) { - await Future.delayed(const Duration(seconds: 2)); + if (!mounted || + !_tasks.any( + (current) => current.autoScanKey == key && current.finished)) { + return; } + final scanService = ServiceProvider.scanService; + if (scanService.isScanning) return; + final paths = await _service.listCompletedVideoPaths(task); if (!mounted) return; - - await scanService.startDirectoryScan( - task.outputFolder, - skipPreviouslyMatchedUnwatched: true, - ); + final succeeded = await scanService.scanCompletedFiles(paths, + folderPath: task.outputFolder); + if (!succeeded) { + _autoScanRetryAfter[key] = + DateTime.now().add(const Duration(minutes: 1)); + return; + } + _autoScanRetryAfter.remove(key); await ServiceProvider.watchHistoryProvider.refresh(); await _service.markAutoScannedCompletedTask(key); _autoScannedCompletedTaskKeys.add(key); @@ -485,6 +526,8 @@ class _TorrentDownloadPageState extends State if (!mounted || silent) return; _showTorrentMessage('已自动扫描并加入媒体库: ${task.name}'); } catch (e) { + _autoScanRetryAfter[key] = DateTime.now().add(const Duration(minutes: 1)); + debugPrint('自动扫描下载任务失败: $e'); if (!mounted || silent) return; _showTorrentMessage('自动扫描下载任务失败: $e'); } finally { @@ -493,10 +536,8 @@ class _TorrentDownloadPageState extends State } Future _playTask(TorrentTask task) async { - if (_isBusy) return; - setState(() { - _isBusy = true; - }); + if (_isPlaying) return; + _isPlaying = true; try { final files = await _service.listPlayableFiles(task); @@ -510,6 +551,13 @@ class _TorrentDownloadPageState extends State ? files.first : await _showPlayableFilesDialog(files); if (selected == null || !mounted) return; + if (!task.finished && task.isPaused) { + if (!await _taskController.run( + task.id, () => _service.resume(task.id))) { + return; + } + if (!mounted) return; + } final source = await _service.getPlaybackSource(task, selected); if (!mounted) return; @@ -526,11 +574,7 @@ class _TorrentDownloadPageState extends State if (!mounted) return; _showTorrentMessage('播放下载任务失败: $e'); } finally { - if (mounted) { - setState(() { - _isBusy = false; - }); - } + _isPlaying = false; } } @@ -574,7 +618,10 @@ class _TorrentDownloadPageState extends State return UnifiedTorrentTaskActionViewModel( action: id, label: label, - onPressed: () => unawaited(callback()), + onPressed: id != UnifiedTorrentTaskAction.openFolder && + _taskController.isBusy(task.id) + ? null + : () => unawaited(callback()), destructive: destructive, ); } @@ -585,18 +632,17 @@ class _TorrentDownloadPageState extends State isAutoScanning: _autoScanningTaskKeys.contains(task.autoScanKey), isAutoScanned: _autoScannedCompletedTaskKeys.contains(task.autoScanKey), actions: [ - if (task.finished) + if (task.canPlay) action( UnifiedTorrentTaskAction.play, '播放', () => _playTask(task), ), - if (!task.finished) - action( - UnifiedTorrentTaskAction.toggle, - task.isPaused ? '继续下载' : '暂停下载', - () => _toggleTask(task), - ), + action( + UnifiedTorrentTaskAction.toggle, + task.toggleLabel, + () => _toggleTask(task), + ), action( UnifiedTorrentTaskAction.openFolder, '查看文件夹', @@ -621,13 +667,21 @@ class _TorrentDownloadPageState extends State Widget build(BuildContext context) { final data = _buildPageViewModel(); final surface = AppDisplaySurfaceScope.of(context); + final Widget view; if (surface == AppDisplaySurface.phone) { - return CupertinoTorrentDownloadView(data: data); - } - if (surface == AppDisplaySurface.television) { - return TelevisionTorrentDownloadView(data: data); + view = CupertinoTorrentDownloadView(data: data); + } else if (surface == AppDisplaySurface.television) { + view = TelevisionTorrentDownloadView(data: data); + } else { + view = DesktopTorrentDownloadView(data: data); } - return DesktopTorrentDownloadView(data: data); + return Column(children: [ + if (_isBusy) ...[ + const LinearProgressIndicator(minHeight: 2), + const Padding(padding: EdgeInsets.all(8), child: Text('正在添加下载任务…')), + ], + Expanded(child: view), + ]); } } @@ -749,12 +803,9 @@ class TelevisionTorrentDownloadView extends StatelessWidget { onChanged: data.onSearchChanged, suffix: data.searchController.text.isEmpty ? null - : IconButton( - icon: Icon( - Icons.clear_rounded, - color: - colorScheme.onSurface.withValues(alpha: 0.60), - ), + : _TorrentHoverAction( + icon: Icons.clear_rounded, + tooltip: '清空搜索', onPressed: () { data.searchController.clear(); data.onClearSearch(); @@ -840,11 +891,11 @@ class TelevisionTorrentDownloadView extends StatelessWidget { isAutoScanned: item.isAutoScanned, autofocus: index == 0, onPrimary: item.primaryAction.onPressed, - onPlay: _onAction(item, UnifiedTorrentTaskAction.play) ?? () {}, - onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle) ?? () {}, + onPlay: _onAction(item, UnifiedTorrentTaskAction.play), + onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle), onOpenFolder: _onAction(item, UnifiedTorrentTaskAction.openFolder)!, - onForget: _onAction(item, UnifiedTorrentTaskAction.forget)!, - onDelete: _onAction(item, UnifiedTorrentTaskAction.delete)!, + onForget: _onAction(item, UnifiedTorrentTaskAction.forget), + onDelete: _onAction(item, UnifiedTorrentTaskAction.delete), ); }, ); @@ -868,11 +919,11 @@ class TelevisionTorrentDownloadView extends StatelessWidget { autofocus: index == 0, compact: true, onPrimary: item.primaryAction.onPressed, - onPlay: _onAction(item, UnifiedTorrentTaskAction.play) ?? () {}, - onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle) ?? () {}, + onPlay: _onAction(item, UnifiedTorrentTaskAction.play), + onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle), onOpenFolder: _onAction(item, UnifiedTorrentTaskAction.openFolder)!, - onForget: _onAction(item, UnifiedTorrentTaskAction.forget)!, - onDelete: _onAction(item, UnifiedTorrentTaskAction.delete)!, + onForget: _onAction(item, UnifiedTorrentTaskAction.forget), + onDelete: _onAction(item, UnifiedTorrentTaskAction.delete), ); }, ); @@ -990,11 +1041,11 @@ extension _DesktopTorrentDownloadViewControls scanSummary: item.scanSummary, isAutoScanning: item.isAutoScanning, isAutoScanned: item.isAutoScanned, - onPlay: _onAction(item, UnifiedTorrentTaskAction.play) ?? () {}, - onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle) ?? () {}, + onPlay: _onAction(item, UnifiedTorrentTaskAction.play), + onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle), onOpenFolder: _onAction(item, UnifiedTorrentTaskAction.openFolder)!, - onForget: _onAction(item, UnifiedTorrentTaskAction.forget)!, - onDelete: _onAction(item, UnifiedTorrentTaskAction.delete)!, + onForget: _onAction(item, UnifiedTorrentTaskAction.forget), + onDelete: _onAction(item, UnifiedTorrentTaskAction.delete), ), ); } @@ -1014,11 +1065,11 @@ extension _DesktopTorrentDownloadViewControls scanSummary: item.scanSummary, isAutoScanning: item.isAutoScanning, isAutoScanned: item.isAutoScanned, - onPlay: _onAction(item, UnifiedTorrentTaskAction.play) ?? () {}, - onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle) ?? () {}, + onPlay: _onAction(item, UnifiedTorrentTaskAction.play), + onToggle: _onAction(item, UnifiedTorrentTaskAction.toggle), onOpenFolder: _onAction(item, UnifiedTorrentTaskAction.openFolder)!, - onForget: _onAction(item, UnifiedTorrentTaskAction.forget)!, - onDelete: _onAction(item, UnifiedTorrentTaskAction.delete)!, + onForget: _onAction(item, UnifiedTorrentTaskAction.forget), + onDelete: _onAction(item, UnifiedTorrentTaskAction.delete), ); }, ), @@ -1215,6 +1266,8 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { String? _error; bool _isPreviewing = false; int _previewRequestId = 0; + TorrentPreviewRequest? _previewRequest; + bool _submitted = false; @override void initState() { @@ -1227,6 +1280,7 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { @override void dispose() { + if (!_submitted) _previewRequest?.cancel(); _magnetController.dispose(); super.dispose(); } @@ -1238,7 +1292,7 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { ); if (selected == null || selected.trim().isEmpty) return; await widget.service.rememberRecentDownloadDirectory(selected.trim()); - _selectDirectory(selected.trim()); + if (mounted) _selectDirectory(selected.trim()); } void _selectDirectory(String directory) { @@ -1248,7 +1302,6 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { directory, ..._recentDirectories.where((value) => value != directory), ].take(8).toList(growable: false); - _preview = null; _error = null; }); } @@ -1277,6 +1330,9 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { return; } + _previewRequest?.cancel(); + final request = TorrentPreviewRequest(); + _previewRequest = request; final requestId = ++_previewRequestId; setState(() { _isPreviewing = true; @@ -1286,10 +1342,11 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { try { final preview = await widget.service.previewMagnet( magnet, + request: request, downloadDirectory: downloadDirectory, ); if (!mounted) return; - if (!_isCurrentPreviewRequest(requestId, magnet, downloadDirectory)) { + if (!_isCurrentPreviewRequest(requestId, magnet)) { return; } setState(() { @@ -1297,7 +1354,7 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { }); } catch (error) { if (!mounted) return; - if (!_isCurrentPreviewRequest(requestId, magnet, downloadDirectory)) { + if (!_isCurrentPreviewRequest(requestId, magnet)) { return; } setState(() { @@ -1315,11 +1372,9 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { bool _isCurrentPreviewRequest( int requestId, String magnet, - String downloadDirectory, ) { return requestId == _previewRequestId && - _magnetController.text.trim() == magnet && - _downloadDirectory.trim() == downloadDirectory; + _magnetController.text.trim() == magnet; } void _confirm() { @@ -1328,8 +1383,11 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { if (_preview == null || magnet.isEmpty || downloadDirectory.isEmpty) { return; } + _submitted = true; Navigator.of(context).pop( AddTorrentDialogResult( + preview: _preview!, + previewRequest: _previewRequest!, magnetUri: magnet, downloadDirectory: downloadDirectory, createFolderForTask: _createFolderForTask, @@ -1337,19 +1395,22 @@ class _AddMagnetDialogState extends State<_AddMagnetDialog> { ); } + void _invalidatePreview() { + _previewRequest?.cancel(); + ++_previewRequestId; + _previewRequest = null; + _preview = null; + _isPreviewing = false; + _error = null; + } + void _onMagnetChanged(String _) { - if (_preview == null && _error == null) return; - setState(() { - _preview = null; - _error = null; - }); + setState(_invalidatePreview); } void _onCreateFolderChanged(bool value) { - setState(() { - _createFolderForTask = value; - _preview = null; - }); + // Folder choice does not change metadata and must not force another lookup. + setState(() => _createFolderForTask = value); } AddTorrentDialogViewModel _buildViewModel() { @@ -1467,7 +1528,7 @@ class DesktopAddTorrentView extends StatelessWidget { const SizedBox(width: 12), HoverScaleTextButton( text: '取消', - onPressed: data.isPreviewing ? null : data.onCancel, + onPressed: data.onCancel, idleColor: colorScheme.onSurface.withValues(alpha: 0.62), ), const SizedBox(width: 8), @@ -1909,11 +1970,11 @@ class _TorrentTaskCard extends StatelessWidget { final TorrentTaskScanSummary? scanSummary; final bool isAutoScanning; final bool isAutoScanned; - final VoidCallback onPlay; - final VoidCallback onToggle; + final VoidCallback? onPlay; + final VoidCallback? onToggle; final VoidCallback onOpenFolder; - final VoidCallback onForget; - final VoidCallback onDelete; + final VoidCallback? onForget; + final VoidCallback? onDelete; @override Widget build(BuildContext context) { @@ -1964,7 +2025,7 @@ class _TorrentTaskCard extends StatelessWidget { ), ), const SizedBox(width: 8), - _StateBadge(task: task), + _TorrentStateText(task: task), ], ), const SizedBox(height: 14), @@ -2019,18 +2080,18 @@ class _TorrentTaskCard extends StatelessWidget { spacing: 8, runSpacing: 4, children: [ - if (task.finished) + if (task.canPlay) _TorrentHoverAction( icon: Ionicons.play_circle_outline, label: '播放', onPressed: onPlay, ), - if (!task.finished) ...[ + ...[ _TorrentHoverAction( - icon: task.isPaused + icon: task.canResume ? Ionicons.play_outline : Ionicons.pause_outline, - label: task.isPaused ? '继续' : '暂停', + label: task.toggleLabel, onPressed: onToggle, ), ], @@ -2096,11 +2157,11 @@ class _TorrentTaskListItem extends StatelessWidget { final TorrentTaskScanSummary? scanSummary; final bool isAutoScanning; final bool isAutoScanned; - final VoidCallback onPlay; - final VoidCallback onToggle; + final VoidCallback? onPlay; + final VoidCallback? onToggle; final VoidCallback onOpenFolder; - final VoidCallback onForget; - final VoidCallback onDelete; + final VoidCallback? onForget; + final VoidCallback? onDelete; @override Widget build(BuildContext context) { @@ -2156,7 +2217,7 @@ class _TorrentTaskListItem extends StatelessWidget { ), ), const SizedBox(width: 10), - _StateBadge(task: task), + _TorrentStateText(task: task), if (!compact) ...[ const SizedBox(width: 8), Row( @@ -2240,7 +2301,7 @@ class _TorrentTaskListItem extends StatelessWidget { List _buildActions() { return [ - if (task.finished) + if (task.canPlay) _TorrentHoverAction( icon: Ionicons.play_circle_outline, tooltip: '播放', @@ -2248,14 +2309,13 @@ class _TorrentTaskListItem extends StatelessWidget { padding: const EdgeInsets.all(8), iconSize: 18, ), - if (!task.finished) - _TorrentHoverAction( - icon: task.isPaused ? Ionicons.play_outline : Ionicons.pause_outline, - tooltip: task.isPaused ? '继续' : '暂停', - onPressed: onToggle, - padding: const EdgeInsets.all(8), - iconSize: 18, - ), + _TorrentHoverAction( + icon: task.canResume ? Ionicons.play_outline : Ionicons.pause_outline, + tooltip: task.toggleLabel, + onPressed: onToggle, + padding: const EdgeInsets.all(8), + iconSize: 18, + ), _TorrentHoverAction( icon: Ionicons.folder_open_outline, tooltip: '打开文件夹', @@ -2312,8 +2372,8 @@ class _TorrentTaskScanText extends StatelessWidget { } } -class _StateBadge extends StatelessWidget { - const _StateBadge({required this.task}); +class _TorrentStateText extends StatelessWidget { + const _TorrentStateText({required this.task}); final TorrentTask task; @@ -2327,20 +2387,12 @@ class _StateBadge extends StatelessWidget { : task.isPaused ? colorScheme.onSurface.withValues(alpha: 0.55) : AppAccentColors.current; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.35), width: 0.5), - ), - child: Text( - task.displayState, - style: TextStyle( - color: color, - fontSize: 12, - fontWeight: FontWeight.bold, - ), + return Text( + task.displayState, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.bold, ), ); } @@ -2366,12 +2418,12 @@ class _LargeScreenTorrentTaskCard extends StatelessWidget { final TorrentTaskScanSummary? scanSummary; final bool isAutoScanning; final bool isAutoScanned; - final VoidCallback onPrimary; - final VoidCallback onPlay; - final VoidCallback onToggle; + final VoidCallback? onPrimary; + final VoidCallback? onPlay; + final VoidCallback? onToggle; final VoidCallback onOpenFolder; - final VoidCallback onForget; - final VoidCallback onDelete; + final VoidCallback? onForget; + final VoidCallback? onDelete; final bool autofocus; final bool compact; @@ -2440,7 +2492,7 @@ class _LargeScreenTorrentTaskCard extends StatelessWidget { ), ), const SizedBox(width: 10), - _LargeScreenTorrentBadge(task: task), + _LargeScreenTorrentStateText(task: task), ], ), SizedBox(height: compact ? 12 : 16), @@ -2506,33 +2558,38 @@ class _LargeScreenTorrentTaskCard extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - if (task.finished) + if (task.canPlay) NipaplayLargeScreenActionButton( icon: Ionicons.play_circle_outline, label: '播放', compact: true, onPressed: onPlay, - ) - else - NipaplayLargeScreenActionButton( - icon: task.isPaused - ? Ionicons.play_outline - : Ionicons.pause_outline, - label: task.isPaused ? '继续' : '暂停', - compact: true, - onPressed: onToggle, ), - NipaplayLargeScreenIconButton( + NipaplayLargeScreenActionButton( + icon: task.canResume + ? Ionicons.play_outline + : Ionicons.pause_outline, + label: task.toggleLabel, + compact: true, + onPressed: onToggle, + ), + _TorrentHoverAction( + iconSize: 22, + padding: const EdgeInsets.all(12), icon: Ionicons.folder_open_outline, tooltip: '打开文件夹', onPressed: onOpenFolder, ), - NipaplayLargeScreenIconButton( + _TorrentHoverAction( + iconSize: 22, + padding: const EdgeInsets.all(12), icon: Ionicons.remove_circle_outline, tooltip: '移除任务', onPressed: onForget, ), - NipaplayLargeScreenIconButton( + _TorrentHoverAction( + iconSize: 22, + padding: const EdgeInsets.all(12), icon: Ionicons.trash_outline, tooltip: '删除任务和文件', onPressed: onDelete, @@ -2545,8 +2602,8 @@ class _LargeScreenTorrentTaskCard extends StatelessWidget { } } -class _LargeScreenTorrentBadge extends StatelessWidget { - const _LargeScreenTorrentBadge({required this.task}); +class _LargeScreenTorrentStateText extends StatelessWidget { + const _LargeScreenTorrentStateText({required this.task}); final TorrentTask task; @@ -2559,22 +2616,14 @@ class _LargeScreenTorrentBadge extends StatelessWidget { : task.isPaused ? Colors.orangeAccent : AppAccentColors.current; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.36)), - ), - child: Text( - task.displayState, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: color, - fontSize: 12, - fontWeight: FontWeight.w900, - ), + return Text( + task.displayState, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w900, ), ); } diff --git a/lib/services/scan_service.dart b/lib/services/scan_service.dart index c02b8715f..db4362cd2 100644 --- a/lib/services/scan_service.dart +++ b/lib/services/scan_service.dart @@ -174,7 +174,21 @@ class ScanService with ChangeNotifier { int _totalFilesFound = 0; int get totalFilesFound => _totalFilesFound; - ScanService() { + final Future> Function(List) + _completedFilesProcessor; + + @visibleForTesting + ScanService.forTesting({ + required Future> Function(List) + completedFilesProcessor, + }) : _completedFilesProcessor = completedFilesProcessor; + + static Future> _processCompletedFiles( + List paths) => + ConcurrentVideoProcessor.processVideoPaths(paths, + skipPreviouslyMatchedUnwatched: true); + + ScanService() : _completedFilesProcessor = _processCompletedFiles { // 媒体文件夹列表从 SharedPreferences 异步加载, // 保留这个 Future,等它加载完成后再触发启动智能刷新。 final foldersLoaded = _loadScannedFolders(); @@ -955,6 +969,37 @@ class ScanService with ChangeNotifier { } } + /// Import the exact completed files, without registering a shared download + /// directory that could later expose other torrents' unfinished files. + Future scanCompletedFiles(List paths, + {required String folderPath}) async { + if (kIsWeb || _isScanning) return false; + if (paths.isEmpty) return true; + _updateScanState(scanning: true, progress: 0, message: '正在扫描已完成的下载文件'); + try { + final results = await _completedFilesProcessor(paths); + if (!_isScanning) return false; + final failures = results.where((result) => !result.success).toList(); + final scannedPaths = paths.toSet(); + _failedScanFiles.removeWhere((file) => scannedPaths.contains(file.filePath)); + _recordFailedScanFiles(folderPath, failures); + _totalFilesFound = paths.length; + final succeeded = failures.isEmpty; + _updateScanState( + scanning: false, + progress: 1, + message: succeeded ? '下载文件已加入媒体库' : '部分下载文件扫描失败,稍后重试', + completed: true, + ); + return succeeded; + } catch (error) { + _updateScanState(scanning: false, message: '扫描下载文件失败: $error', completed: true); + return false; + } finally { + if (_isScanning) _updateScanState(scanning: false); + } + } + Future addScannedFolder(String folderPath) async { if (kIsWeb) { _updateScanMessage("Web版不支持扫描本地媒体库。"); diff --git a/lib/services/torrent_download_service.dart b/lib/services/torrent_download_service.dart index dd7cf517e..1a057af10 100644 --- a/lib/services/torrent_download_service.dart +++ b/lib/services/torrent_download_service.dart @@ -1,8 +1,12 @@ +import 'dart:async'; import 'dart:io' as io; import 'package:flutter/foundation.dart'; import 'package:nipaplay/constants/settings_keys.dart'; import 'package:nipaplay/models/torrent_magnet_preview.dart'; +import 'package:nipaplay/models/torrent_add_result.dart'; +import 'package:nipaplay/services/torrent_session_storage.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:nipaplay/models/torrent_task.dart'; import 'package:nipaplay/models/watch_history_model.dart'; import 'package:nipaplay/services/security_bookmark_service.dart'; @@ -17,7 +21,9 @@ class TorrentDownloadService { bool Function()? isIos, Future Function()? getDownloadsDirectory, Future Function(String path)? directoryExists, - }) : _isIos = isIos ?? (() => io.Platform.isIOS), + Future Function(String)? initializeSession, + }) : _initializeSessionOverride = initializeSession, + _isIos = isIos ?? (() => io.Platform.isIOS), _getDownloadsDirectory = getDownloadsDirectory ?? StorageService.getDownloadsDirectory, _directoryExists = @@ -30,8 +36,10 @@ class TorrentDownloadService { required bool Function() isIos, required Future Function() getDownloadsDirectory, required Future Function(String path) directoryExists, + Future Function(String)? initializeSession, }) { return TorrentDownloadService._( + initializeSession: initializeSession, isIos: isIos, getDownloadsDirectory: getDownloadsDirectory, directoryExists: directoryExists, @@ -40,12 +48,13 @@ class TorrentDownloadService { static const int _maxRecentDownloadDirectories = 8; + final Future Function(String)? _initializeSessionOverride; final bool Function() _isIos; final Future Function() _getDownloadsDirectory; final Future Function(String path) _directoryExists; bool _sessionInitialized = false; - String _sessionDownloadDir = ''; + Future? _sessionInitialization; Future getDownloadDirectory() async { final saved = await SettingsStorage.loadString( @@ -122,7 +131,6 @@ class TorrentDownloadService { resolved, ); await rememberRecentDownloadDirectory(resolved); - _sessionDownloadDir = resolved; } Future> loadRecentDownloadDirectories() async { @@ -271,20 +279,31 @@ class TorrentDownloadService { Future previewMagnet( String magnetUri, { String? downloadDirectory, + required TorrentPreviewRequest request, }) async { - final downloadDir = await _resolveDownloadDirectoryForAction( - downloadDirectory, - ); + final downloadDir = + await _resolveDownloadDirectoryForAction(downloadDirectory); await _initSession(downloadDir); - final jsonText = await rust_torrent.torrentPreviewMagnet( - magnetUri: magnetUri, - downloadDir: downloadDir, - ); - return TorrentMagnetPreview.fromJson(jsonText); + request.throwIfCancelled(); + final id = rust_torrent.torrentBeginPreview(); + request.attach(id); + try { + final jsonText = await rust_torrent.torrentPreviewMagnet( + magnetUri: magnetUri, + downloadDir: downloadDir, + requestId: id, + ); + request.throwIfCancelled(); + return TorrentMagnetPreview.fromJson(jsonText, requestId: id); + } catch (_) { + request.cancel(); + rethrow; + } } - Future addMagnet( + Future addMagnet( String magnetUri, { + required int previewId, String? downloadDirectory, bool? createFolderForTask, }) async { @@ -298,12 +317,13 @@ class TorrentDownloadService { ); try { await _initSession(downloadDir); - await rust_torrent.torrentAddMagnet( + final json = await rust_torrent.torrentAddMagnet( magnetUri: magnetUri, + previewId: previewId, downloadDir: downloadDir, createFolderForTask: createFolder, ); - _log('addMagnet success: ${_summarizeMagnetForLog(magnetUri)}'); + return TorrentAddResult.fromJson(json); } catch (error, stackTrace) { _log('addMagnet failed: $error'); _log('addMagnet stackTrace: $stackTrace'); @@ -311,7 +331,7 @@ class TorrentDownloadService { } } - Future addTorrentFile( + Future addTorrentFile( String torrentFilePath, { String? downloadDirectory, bool? createFolderForTask, @@ -326,12 +346,12 @@ class TorrentDownloadService { ); try { await _initSession(downloadDir); - await rust_torrent.torrentAddFile( + final json = await rust_torrent.torrentAddFile( torrentFilePath: torrentFilePath, downloadDir: downloadDir, createFolderForTask: createFolder, ); - _log('addTorrentFile success: path="$torrentFilePath"'); + return TorrentAddResult.fromJson(json); } catch (error, stackTrace) { _log('addTorrentFile failed: $error'); _log('addTorrentFile stackTrace: $stackTrace'); @@ -360,13 +380,57 @@ class TorrentDownloadService { } Future _initSession(String downloadDir) async { - if (_sessionInitialized && _sessionDownloadDir == downloadDir) { + if (_sessionInitialized) return; + final pending = _sessionInitialization ??= _initializeSession(downloadDir); + try { + await pending; + } finally { + if (identical(_sessionInitialization, pending)) { + _sessionInitialization = null; + } + } + } + + Future _initializeSession(String downloadDir) async { + if (_initializeSessionOverride != null) { + await _initializeSessionOverride(downloadDir); + _sessionInitialized = true; return; } await ensureRustInitialized(); - await rust_torrent.torrentInitSession(downloadDir: downloadDir); + String? sessionDir; + if (io.Platform.isAndroid || io.Platform.operatingSystem == 'ohos') { + final support = await getApplicationSupportDirectory(); + sessionDir = await TorrentSessionStorage.prepare( + io.Directory(p.join(support.path, 'torrent_session')), + [ + downloadDir, + ...await loadRecentDownloadDirectories(), + (await _getDownloadsDirectory()).path + ], + ); + } + await rust_torrent.torrentInitSession( + downloadDir: downloadDir, sessionDir: sessionDir); _sessionInitialized = true; - _sessionDownloadDir = downloadDir; + } + + Future> listCompletedVideoPaths(TorrentTask task) async { + if (!task.finished) throw StateError('下载尚未完成'); + final files = await listPlayableFiles(task); + final paths = []; + for (final file in files) { + final root = await _resolveDownloadDirectoryAccess(task.outputFolder); + final path = p.normalize(p.join(root, file.displayName)); + if (!p.isWithin(p.normalize(root), path)) throw StateError('下载文件路径无效'); + final localFile = io.File(path); + if (!await localFile.exists() || + await localFile.length() != file.length) { + throw StateError('已完成的文件缺失或大小不符:${file.displayName}'); + } + paths.add(path); + } + return paths; } Future _resolveDownloadDirectoryForAction(String? directory) async { @@ -486,7 +550,7 @@ class TorrentDownloadService { Future> loadAutoScannedCompletedTaskKeys() async { final keys = await SettingsStorage.loadStringList( - SettingsKeys.downloaderAutoScannedCompletedTaskKeys, + SettingsKeys.downloaderVerifiedScannedCompletedTaskKeys, ); return keys.toSet(); } @@ -497,7 +561,7 @@ class TorrentDownloadService { final keys = await loadAutoScannedCompletedTaskKeys(); keys.add(trimmed); await SettingsStorage.saveStringList( - SettingsKeys.downloaderAutoScannedCompletedTaskKeys, + SettingsKeys.downloaderVerifiedScannedCompletedTaskKeys, keys.toList(growable: false), ); } @@ -560,3 +624,29 @@ class TorrentPlaybackSource { final String? actualPlayUrl; final WatchHistoryItem? historyItem; } + +/// Owns one native resolution request; cancellation releases network work and +/// cached metadata, including when the dialog closes before FFI dispatch. +class TorrentPreviewRequest { + int? _id; + bool _cancelled = false; + + void throwIfCancelled() { + if (_cancelled) throw StateError('预览已取消'); + } + + void attach(int id) { + _id = id; + if (_cancelled) { + rust_torrent.torrentCancelPreview(requestId: id); + throwIfCancelled(); + } + } + + void cancel() { + if (_cancelled) return; + _cancelled = true; + final id = _id; + if (id != null) rust_torrent.torrentCancelPreview(requestId: id); + } +} diff --git a/lib/services/torrent_session_storage.dart b/lib/services/torrent_session_storage.dart new file mode 100644 index 000000000..cb317f7da --- /dev/null +++ b/lib/services/torrent_session_storage.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Move the task registry out of user-selected download folders. Media files +/// stay in place; old registries remain available as backups. +class TorrentSessionStorage { + static Future prepare( + Directory destination, + Iterable legacyDownloadDirectories, + ) async { + await destination.create(recursive: true); + final database = File(p.join(destination.path, 'session.json')); + final marker = File(p.join(destination.path, 'legacy-imports.json')); + final imported = await marker.exists() + ? (jsonDecode(await marker.readAsString()) as List) + .cast() + .toSet() + : {}; + final data = await database.exists() + ? Map.from( + jsonDecode(await database.readAsString()) as Map) + : {'torrents': {}}; + final torrents = Map.from(data['torrents'] as Map); + final hashes = torrents.values + .map((value) => (value as Map)['info_hash'] as String) + .toSet(); + var nextId = torrents.keys.fold(0, (next, id) { + final value = int.parse(id) + 1; + return value > next ? value : next; + }); + var changed = false; + for (final directory in legacyDownloadDirectories.toSet()) { + final legacy = + p.normalize(p.join(directory, '.nipaplay_torrent_session')); + if (imported.contains(legacy) || p.equals(legacy, destination.path)) { + continue; + } + final oldDatabase = File(p.join(legacy, 'session.json')); + if (!await oldDatabase.exists()) continue; + final old = jsonDecode(await oldDatabase.readAsString()) as Map; + for (final value in (old['torrents'] as Map).values) { + final task = Map.from(value as Map); + final hash = task['info_hash'] as String; + if (!RegExp(r'^[a-fA-F0-9]{40}$').hasMatch(hash)) { + throw FormatException('Invalid torrent hash in $legacy'); + } + if (!hashes.add(hash)) continue; + for (final extension in ['torrent', 'bitv']) { + final source = File(p.join(legacy, '$hash.$extension')); + final target = File(p.join(destination.path, '$hash.$extension')); + if (await source.exists() && !await target.exists()) { + final copy = await source.copy('${target.path}.migrating'); + await copy.rename(target.path); + } + } + torrents['${nextId++}'] = task; + } + imported.add(legacy); + changed = true; + } + if (changed) { + data['torrents'] = torrents; + // Save tasks before the marker. Re-running after interruption deduplicates + // hashes; once imported, removing a task must not resurrect it next launch. + await _writeAtomically(database, data); + await _writeAtomically(marker, imported.toList()); + } + return destination.path; + } + + static Future _writeAtomically(File file, Object value) async { + final temporary = File('${file.path}.migrating'); + await temporary.writeAsString(jsonEncode(value), flush: true); + await temporary.rename(file.path); + } +} diff --git a/lib/settings/pages/downloader_settings_content.dart b/lib/settings/pages/downloader_settings_content.dart index fb7a4b77f..b397e01fd 100644 --- a/lib/settings/pages/downloader_settings_content.dart +++ b/lib/settings/pages/downloader_settings_content.dart @@ -74,9 +74,9 @@ class DownloaderSettingsContent extends StatelessWidget { ), subtitle: _text( context, - '任务下载完成后自动把输出文件夹加入库管理并扫描', - '任務下載完成後自動把輸出文件夾加入庫管理並掃描', - 'Scan the output folder into the media library after completion.', + '任务下载完成后,仅扫描该任务的视频文件并加入媒体库', + '任務下載完成後,僅掃描該任務的影片檔案並加入媒體庫', + 'Scan only this task’s completed video files into the media library.', ), icon: Ionicons.library_outline, phoneIcon: cupertino.CupertinoIcons.collections, diff --git a/lib/settings/pages/remote_access_receiver_settings_section.dart b/lib/settings/pages/remote_access_receiver_settings_section.dart index 263c59c21..e261f1a1e 100644 --- a/lib/settings/pages/remote_access_receiver_settings_section.dart +++ b/lib/settings/pages/remote_access_receiver_settings_section.dart @@ -2,7 +2,6 @@ import 'package:flutter/cupertino.dart' as cupertino; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; -import 'package:kmbal_ionicons/kmbal_ionicons.dart'; import 'package:nipaplay/providers/remote_access_settings_provider.dart'; import 'package:nipaplay/providers/service_provider.dart'; import 'package:nipaplay/services/remote_access_qr_service.dart'; @@ -590,7 +589,6 @@ class _RemoteAccessReceiverSettingsSectionState children: [ _buildRemoteHeader( context, - icon: Icons.cast_connected, title: '远程访问与遥控', subtitle: '在同一网络内访问本机媒体库,也可以让手机或其他客户端遥控播放。', status: statusText, @@ -599,7 +597,6 @@ class _RemoteAccessReceiverSettingsSectionState const SizedBox(height: 16), _buildRemoteSwitchRow( context, - icon: Icons.power_settings_new, title: '启用远程访问服务', subtitle: '允许其他 NipaPlay 客户端远程访问本机媒体库', value: _webServerEnabled, @@ -607,7 +604,6 @@ class _RemoteAccessReceiverSettingsSectionState ), _buildRemoteSwitchRow( context, - icon: Icons.settings_remote, title: '启用被遥控端', subtitle: '允许控制端读取播放器状态、菜单参数并进行遥控', value: _receiverEnabled, @@ -615,7 +611,6 @@ class _RemoteAccessReceiverSettingsSectionState ), _buildRemoteSwitchRow( context, - icon: Icons.auto_awesome, title: '软件打开自动开启', subtitle: '启动 NipaPlay 时自动开启远程访问服务', value: _autoStartEnabled, @@ -623,7 +618,6 @@ class _RemoteAccessReceiverSettingsSectionState ), _buildRemoteSwitchRow( context, - icon: Icons.router, title: '启用 IPv6 访问地址', subtitle: '地址列表和二维码会包含可用的 IPv6 地址', value: _ipv6Enabled, @@ -631,7 +625,6 @@ class _RemoteAccessReceiverSettingsSectionState ), _buildRemoteSwitchRow( context, - icon: Icons.qr_code_2, title: '显示远程访问二维码', subtitle: '用于另一台设备扫码连接共享媒体库与遥控器', value: showRemoteAccessQrCode, @@ -639,7 +632,6 @@ class _RemoteAccessReceiverSettingsSectionState ), _buildRemoteActionRow( context, - icon: Icons.settings_ethernet, title: '远程访问端口', subtitle: '当前端口: $_currentPort', onTap: _showPortDialog, @@ -667,29 +659,16 @@ class _RemoteAccessReceiverSettingsSectionState Widget _buildRemoteHeader( BuildContext context, { - required IconData icon, required String title, required String subtitle, required String status, required bool active, }) { final colorScheme = Theme.of(context).colorScheme; - final statusColor = - active ? colorScheme.primary : colorScheme.onSurfaceVariant; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: statusColor), - ), - const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -746,7 +725,6 @@ class _RemoteAccessReceiverSettingsSectionState Widget _buildRemoteSwitchRow( BuildContext context, { - required IconData icon, required String title, required String subtitle, required bool value, @@ -754,7 +732,6 @@ class _RemoteAccessReceiverSettingsSectionState }) { return _buildRemoteActionRow( context, - icon: icon, title: title, subtitle: subtitle, onTap: () => onChanged(!value), @@ -767,7 +744,6 @@ class _RemoteAccessReceiverSettingsSectionState Widget _buildRemoteActionRow( BuildContext context, { - required IconData icon, required String title, required String subtitle, VoidCallback? onTap, @@ -775,7 +751,6 @@ class _RemoteAccessReceiverSettingsSectionState bool destructive = false, }) { final colorScheme = Theme.of(context).colorScheme; - final accentColor = destructive ? colorScheme.error : colorScheme.primary; return MouseRegion( cursor: onTap == null ? MouseCursor.defer : SystemMouseCursors.click, @@ -786,16 +761,6 @@ class _RemoteAccessReceiverSettingsSectionState padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: accentColor.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, size: 18, color: accentColor), - ), - const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1036,7 +1001,6 @@ class _RemoteAccessReceiverSettingsSectionState children: [ _buildRemoteHeader( context, - icon: Ionicons.shield_checkmark_outline, title: '受信任设备', subtitle: '已经确认过的遥控端会显示在这里,移除后下次连接需要重新确认。', status: '${_trustedDevices.length} 台', @@ -1061,7 +1025,6 @@ class _RemoteAccessReceiverSettingsSectionState final clientKey = device['clientKey'] as String?; return _buildRemoteActionRow( context, - icon: Ionicons.phone_portrait_outline, title: device['clientName'] as String? ?? '未知设备', subtitle: _trustedDeviceSubtitle(device), trailing: AdaptiveSettingsActionButton( diff --git a/lib/settings/pages/remote_media_library_settings_content.dart b/lib/settings/pages/remote_media_library_settings_content.dart index 670144ebb..2862cdc05 100644 --- a/lib/settings/pages/remote_media_library_settings_content.dart +++ b/lib/settings/pages/remote_media_library_settings_content.dart @@ -127,7 +127,6 @@ class _RemoteMediaLibrarySettingsContentState (library) => library.name, ), disconnectedDescription: l10n.jellyfinDisconnectedDescription, - icon: Ionicons.tv_outline, ), const SizedBox(height: 16), _buildNetworkServerSection( @@ -149,7 +148,6 @@ class _RemoteMediaLibrarySettingsContentState (library) => library.name, ), disconnectedDescription: l10n.embyDisconnectedDescription, - icon: Ionicons.play_circle_outline, ), const SizedBox(height: 16), _buildDandanplaySection(context, dandanProvider), @@ -197,7 +195,6 @@ class _RemoteMediaLibrarySettingsContentState required int mediaItemCount, required List selectedLibraries, required String disconnectedDescription, - required IconData icon, }) { final l10n = context.l10n; final status = isLoading @@ -221,7 +218,6 @@ class _RemoteMediaLibrarySettingsContentState title: title, status: status, summary: summary, - icon: icon, active: isConnected, loading: isLoading, hasError: hasError, @@ -294,7 +290,6 @@ class _RemoteMediaLibrarySettingsContentState title: l10n.dandanRemoteCardTitle, status: status, summary: summary, - icon: Ionicons.chatbubbles_outline, active: isConnected, loading: isLoading, hasError: hasError, @@ -340,7 +335,6 @@ class _RemoteMediaLibrarySettingsContentState required String title, required String status, required String summary, - required IconData icon, required bool active, required bool loading, required bool hasError, @@ -349,12 +343,6 @@ class _RemoteMediaLibrarySettingsContentState required List actions, }) { final colorScheme = Theme.of(context).colorScheme; - final statusColor = _serverStatusColor( - context, - active: active, - loading: loading, - hasError: hasError, - ); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -362,16 +350,6 @@ class _RemoteMediaLibrarySettingsContentState Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: statusColor), - ), - const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/src/rust/api/torrent.dart b/lib/src/rust/api/torrent.dart index c0bf78bad..216ef623b 100644 --- a/lib/src/rust/api/torrent.dart +++ b/lib/src/rust/api/torrent.dart @@ -6,22 +6,34 @@ import '../frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `add_torrent_options`, `current_api`, `current_session`, `default_session_dir`, `ensure_stream_server`, `file_stem_folder_name`, `file_stem_or_name`, `format_error_chain`, `handle_stream_request`, `magnet_log_summary`, `normalize_download_dir`, `normalize_torrent_id`, `parse_http_request`, `parse_range`, `parse_stream_path`, `parsed_magnet_log_summary`, `read_http_request`, `resolve_magnet_metadata_for_add`, `response_to_json`, `sanitize_folder_name`, `torrent_log`, `torrent_metadata_display_name`, `torrent_metadata_folder_name`, `torrent_preview_to_json`, `torrent_runtime`, `truncate_for_log`, `url_path_segment_encode`, `write_simple_response` -// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `HttpRange`, `TorrentRuntime`, `TorrentStreamServer` +// These functions are ignored because they are not marked as `pub`: `add_to_session`, `add_torrent_options`, `begin`, `bounded_preview`, `cancel`, `current_api`, `current_session`, `default_session_dir`, `ensure_stream_server`, `file_stem_folder_name`, `file_stem_or_name`, `handle_stream_request`, `normalize_download_dir`, `normalize_torrent_id`, `parse_http_request`, `parse_range`, `parse_stream_path`, `read_http_request`, `resolve_preview`, `response_to_json`, `sanitize_folder_name`, `torrent_log`, `torrent_metadata_display_name`, `torrent_metadata_folder_name`, `torrent_preview_to_json`, `torrent_runtime`, `url_path_segment_encode`, `write_simple_response` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `HttpRange`, `PreviewRegistry`, `PreviewRequest`, `TorrentRuntime`, `TorrentStreamServer` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone` +// These functions are ignored (category: IgnoreBecauseOwnerTyShouldIgnore): `default` -Future torrentInitSession({required String downloadDir}) => - RustLib.instance.api - .crateApiTorrentTorrentInitSession(downloadDir: downloadDir); +Future torrentInitSession( + {required String downloadDir, String? sessionDir}) => + RustLib.instance.api.crateApiTorrentTorrentInitSession( + downloadDir: downloadDir, sessionDir: sessionDir); + +/// Reserve a request before dispatching the asynchronous FFI call, so that +/// cancellation also works when the call has not started running yet. +int torrentBeginPreview() => + RustLib.instance.api.crateApiTorrentTorrentBeginPreview(); + +void torrentCancelPreview({required int requestId}) => RustLib.instance.api + .crateApiTorrentTorrentCancelPreview(requestId: requestId); Future torrentAddMagnet( {required String magnetUri, required String downloadDir, - required bool createFolderForTask}) => + required bool createFolderForTask, + required int previewId}) => RustLib.instance.api.crateApiTorrentTorrentAddMagnet( magnetUri: magnetUri, downloadDir: downloadDir, - createFolderForTask: createFolderForTask); + createFolderForTask: createFolderForTask, + previewId: previewId); Future torrentAddFile( {required String torrentFilePath, @@ -33,9 +45,11 @@ Future torrentAddFile( createFolderForTask: createFolderForTask); Future torrentPreviewMagnet( - {required String magnetUri, required String downloadDir}) => + {required String magnetUri, + required String downloadDir, + required int requestId}) => RustLib.instance.api.crateApiTorrentTorrentPreviewMagnet( - magnetUri: magnetUri, downloadDir: downloadDir); + magnetUri: magnetUri, downloadDir: downloadDir, requestId: requestId); Future torrentList({required String downloadDir}) => RustLib.instance.api.crateApiTorrentTorrentList(downloadDir: downloadDir); diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index 34f8c9078..46ff0925e 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -83,7 +83,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1076718673; + int get rustContentHash => -281536625; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -291,7 +291,12 @@ abstract class RustLibApi extends BaseApi { Future crateApiTorrentTorrentAddMagnet( {required String magnetUri, required String downloadDir, - required bool createFolderForTask}); + required bool createFolderForTask, + required int previewId}); + + int crateApiTorrentTorrentBeginPreview(); + + void crateApiTorrentTorrentCancelPreview({required int requestId}); Future crateApiTorrentTorrentDelete({required int id}); @@ -299,14 +304,17 @@ abstract class RustLibApi extends BaseApi { Future crateApiTorrentTorrentForget({required int id}); - Future crateApiTorrentTorrentInitSession({required String downloadDir}); + Future crateApiTorrentTorrentInitSession( + {required String downloadDir, String? sessionDir}); Future crateApiTorrentTorrentList({required String downloadDir}); Future crateApiTorrentTorrentPause({required int id}); Future crateApiTorrentTorrentPreviewMagnet( - {required String magnetUri, required String downloadDir}); + {required String magnetUri, + required String downloadDir, + required int requestId}); Future crateApiTorrentTorrentResume({required int id}); @@ -1777,13 +1785,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { Future crateApiTorrentTorrentAddMagnet( {required String magnetUri, required String downloadDir, - required bool createFolderForTask}) { + required bool createFolderForTask, + required int previewId}) { return handler.executeNormal(NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(magnetUri, serializer); sse_encode_String(downloadDir, serializer); sse_encode_bool(createFolderForTask, serializer); + sse_encode_i_32(previewId, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 50, port: port_); }, @@ -1792,7 +1802,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_String, ), constMeta: kCrateApiTorrentTorrentAddMagnetConstMeta, - argValues: [magnetUri, downloadDir, createFolderForTask], + argValues: [magnetUri, downloadDir, createFolderForTask, previewId], apiImpl: this, )); } @@ -1800,7 +1810,59 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiTorrentTorrentAddMagnetConstMeta => const TaskConstMeta( debugName: "torrent_add_magnet", - argNames: ["magnetUri", "downloadDir", "createFolderForTask"], + argNames: [ + "magnetUri", + "downloadDir", + "createFolderForTask", + "previewId" + ], + ); + + @override + int crateApiTorrentTorrentBeginPreview() { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 51)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_i_32, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiTorrentTorrentBeginPreviewConstMeta, + argValues: [], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiTorrentTorrentBeginPreviewConstMeta => + const TaskConstMeta( + debugName: "torrent_begin_preview", + argNames: [], + ); + + @override + void crateApiTorrentTorrentCancelPreview({required int requestId}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_32(requestId, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 52)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiTorrentTorrentCancelPreviewConstMeta, + argValues: [requestId], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiTorrentTorrentCancelPreviewConstMeta => + const TaskConstMeta( + debugName: "torrent_cancel_preview", + argNames: ["requestId"], ); @override @@ -1810,7 +1872,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_i_32(id, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 51, port: port_); + funcId: 53, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1835,7 +1897,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_i_32(id, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 52, port: port_); + funcId: 54, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1860,7 +1922,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_i_32(id, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 53, port: port_); + funcId: 55, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1880,20 +1942,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { @override Future crateApiTorrentTorrentInitSession( - {required String downloadDir}) { + {required String downloadDir, String? sessionDir}) { return handler.executeNormal(NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(downloadDir, serializer); + sse_encode_opt_String(sessionDir, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 54, port: port_); + funcId: 56, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, decodeErrorData: sse_decode_String, ), constMeta: kCrateApiTorrentTorrentInitSessionConstMeta, - argValues: [downloadDir], + argValues: [downloadDir, sessionDir], apiImpl: this, )); } @@ -1901,7 +1964,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiTorrentTorrentInitSessionConstMeta => const TaskConstMeta( debugName: "torrent_init_session", - argNames: ["downloadDir"], + argNames: ["downloadDir", "sessionDir"], ); @override @@ -1911,7 +1974,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(downloadDir, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 55, port: port_); + funcId: 57, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1935,7 +1998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_i_32(id, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 56, port: port_); + funcId: 58, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1955,21 +2018,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { @override Future crateApiTorrentTorrentPreviewMagnet( - {required String magnetUri, required String downloadDir}) { + {required String magnetUri, + required String downloadDir, + required int requestId}) { return handler.executeNormal(NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(magnetUri, serializer); sse_encode_String(downloadDir, serializer); + sse_encode_i_32(requestId, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 57, port: port_); + funcId: 59, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, decodeErrorData: sse_decode_String, ), constMeta: kCrateApiTorrentTorrentPreviewMagnetConstMeta, - argValues: [magnetUri, downloadDir], + argValues: [magnetUri, downloadDir, requestId], apiImpl: this, )); } @@ -1977,7 +2043,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiTorrentTorrentPreviewMagnetConstMeta => const TaskConstMeta( debugName: "torrent_preview_magnet", - argNames: ["magnetUri", "downloadDir"], + argNames: ["magnetUri", "downloadDir", "requestId"], ); @override @@ -1987,7 +2053,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_i_32(id, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 58, port: port_); + funcId: 60, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2015,7 +2081,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(fileId, serializer); sse_encode_String(filename, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 59, port: port_); + funcId: 61, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, diff --git a/lib/themes/cupertino/pages/account/sections/bangumi_section.dart b/lib/themes/cupertino/pages/account/sections/bangumi_section.dart index 3843d2ea6..e06c86e3e 100644 --- a/lib/themes/cupertino/pages/account/sections/bangumi_section.dart +++ b/lib/themes/cupertino/pages/account/sections/bangumi_section.dart @@ -137,9 +137,6 @@ class CupertinoBangumiSection extends StatelessWidget { Widget _buildStatusCard(BuildContext context) { final String title = data.connectionTitle; - final Color iconColor = data.isAuthorized - ? CupertinoColors.activeGreen - : CupertinoColors.systemGrey; final Color textColor = CupertinoDynamicColor.resolve( data.isAuthorized ? CupertinoColors.activeGreen @@ -162,16 +159,6 @@ class CupertinoBangumiSection extends StatelessWidget { children: [ Row( children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(CupertinoIcons.cloud_upload, color: iconColor), - ), - const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/cupertino/widgets/cupertino_torrent_download_controls.dart b/lib/themes/cupertino/widgets/cupertino_torrent_download_controls.dart index cfeaeec3f..60229caa6 100644 --- a/lib/themes/cupertino/widgets/cupertino_torrent_download_controls.dart +++ b/lib/themes/cupertino/widgets/cupertino_torrent_download_controls.dart @@ -415,7 +415,8 @@ class _CupertinoTorrentDownloadViewState context: context, title: item.task.name, options: [ - for (final action in item.actions) + for (final action + in item.actions.where((action) => action.onPressed != null)) CupertinoBottomSheetOption( label: action.label, value: action.action, @@ -424,7 +425,7 @@ class _CupertinoTorrentDownloadViewState ], ); if (selected == null) return; - item.action(selected)?.onPressed(); + item.action(selected)?.onPressed?.call(); } Widget _buildEmpty(BuildContext context) { diff --git a/lib/themes/nipaplay/widgets/bangumi_collection_dialog.dart b/lib/themes/nipaplay/widgets/bangumi_collection_dialog.dart index 7cdba733a..7e1cc5ea4 100644 --- a/lib/themes/nipaplay/widgets/bangumi_collection_dialog.dart +++ b/lib/themes/nipaplay/widgets/bangumi_collection_dialog.dart @@ -267,19 +267,6 @@ class _BangumiCollectionDialogState extends State { Widget _buildHeader() { return Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.star_rate_rounded, - color: _accentColor, - size: 20, - ), - ), - SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/nipaplay/widgets/bangumi_comment_dialog.dart b/lib/themes/nipaplay/widgets/bangumi_comment_dialog.dart index f1427e896..e260efc7d 100644 --- a/lib/themes/nipaplay/widgets/bangumi_comment_dialog.dart +++ b/lib/themes/nipaplay/widgets/bangumi_comment_dialog.dart @@ -224,19 +224,6 @@ class _BangumiCommentDialogState extends State { Widget _buildHeader() { return Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Ionicons.chatbubble_ellipses_outline, - color: _accentColor, - size: 20, - ), - ), - SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/nipaplay/widgets/batch_danmaku_dialog.dart b/lib/themes/nipaplay/widgets/batch_danmaku_dialog.dart index 837a08212..b02933199 100644 --- a/lib/themes/nipaplay/widgets/batch_danmaku_dialog.dart +++ b/lib/themes/nipaplay/widgets/batch_danmaku_dialog.dart @@ -697,19 +697,6 @@ class _BatchDanmakuMatchDialogState extends State Widget _buildHeader() { return Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.playlist_add_check, - color: _accentColor, - size: 20, - ), - ), - SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/nipaplay/widgets/custom_media_info_dialog.dart b/lib/themes/nipaplay/widgets/custom_media_info_dialog.dart index 46ae57ba2..65445cbbe 100644 --- a/lib/themes/nipaplay/widgets/custom_media_info_dialog.dart +++ b/lib/themes/nipaplay/widgets/custom_media_info_dialog.dart @@ -154,19 +154,6 @@ class CustomMediaInfoDialog { if (!isPhone) ...[ Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.edit_note, - color: _accentColor, - size: 20, - ), - ), - SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1152,20 +1139,6 @@ class _Step2Dialog extends StatefulWidget { if (!isPhone) ...[ Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: CustomMediaInfoDialog._accentColor - .withOpacity(0.18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.playlist_add_check, - color: CustomMediaInfoDialog._accentColor, - size: 20, - ), - ), - SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/nipaplay/widgets/manual_danmaku_dialog.dart b/lib/themes/nipaplay/widgets/manual_danmaku_dialog.dart index 35ddb0caa..242b1f76e 100644 --- a/lib/themes/nipaplay/widgets/manual_danmaku_dialog.dart +++ b/lib/themes/nipaplay/widgets/manual_danmaku_dialog.dart @@ -297,19 +297,6 @@ class _ManualDanmakuMatchDialogState extends State return Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.subtitles, - color: _accentColor, - size: 20, - ), - ), - SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/nipaplay/widgets/media_capture_dialog.dart b/lib/themes/nipaplay/widgets/media_capture_dialog.dart index f485c03de..34f010a02 100644 --- a/lib/themes/nipaplay/widgets/media_capture_dialog.dart +++ b/lib/themes/nipaplay/widgets/media_capture_dialog.dart @@ -438,17 +438,6 @@ class _MediaCaptureDialogContentState extends State children: [ Row( children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: AppAccentColors.current.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.photo_camera_back_rounded, - color: AppAccentColors.current), - ), - const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/nipaplay/widgets/network_media_server_dialog.dart b/lib/themes/nipaplay/widgets/network_media_server_dialog.dart index e9b0a78b4..9ffb4cd87 100644 --- a/lib/themes/nipaplay/widgets/network_media_server_dialog.dart +++ b/lib/themes/nipaplay/widgets/network_media_server_dialog.dart @@ -15,7 +15,6 @@ import 'package:nipaplay/themes/nipaplay/widgets/multi_address_manager_widget.da import 'package:nipaplay/themes/nipaplay/widgets/nipaplay_window.dart'; import 'package:nipaplay/themes/cupertino/widgets/cupertino_bottom_sheet.dart'; import 'package:provider/provider.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:nipaplay/providers/jellyfin_provider.dart'; import 'package:nipaplay/providers/emby_provider.dart'; import 'package:nipaplay/utils/globals.dart' as globals; @@ -642,15 +641,6 @@ class _NetworkMediaServerDialogState extends State { } } - String get _serverIconAsset { - switch (widget.serverType) { - case MediaServerType.jellyfin: - return 'assets/jellyfin.svg'; - case MediaServerType.emby: - return 'assets/emby.svg'; - } - } - bool get _supportsTranscode => widget.serverType == MediaServerType.jellyfin || widget.serverType == MediaServerType.emby; @@ -783,27 +773,6 @@ class _NetworkMediaServerDialogState extends State { Widget _buildHeader() { return Row( children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _accentColor.withOpacity(0.3), - width: 1, - ), - ), - child: SvgPicture.asset( - _serverIconAsset, - width: 28, - height: 28, - colorFilter: ColorFilter.mode( - _accentColor, - BlendMode.srcIn, - ), - ), - ), - SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -844,15 +813,6 @@ class _NetworkMediaServerDialogState extends State { children: [ Row( children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.dns, color: _accentColor, size: 20), - ), - SizedBox(width: 12), Text( '服务器:', locale: Locale("zh-Hans", "zh"), @@ -871,15 +831,6 @@ class _NetworkMediaServerDialogState extends State { SizedBox(height: 12), Row( children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.person, color: _accentColor, size: 20), - ), - SizedBox(width: 12), Text( '用户:', locale: Locale("zh-Hans", "zh"), @@ -903,15 +854,6 @@ class _NetworkMediaServerDialogState extends State { children: [ Row( children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(Icons.library_books, color: _accentColor, size: 20), - ), - SizedBox(width: 12), Text( '媒体库选择', locale: Locale("zh-Hans", "zh"), @@ -1077,19 +1019,6 @@ class _NetworkMediaServerDialogState extends State { ), child: Row( children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.high_quality, - color: _accentColor, - size: 20, - ), - ), - SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/themes/nipaplay/widgets/text_input_dialog.dart b/lib/themes/nipaplay/widgets/text_input_dialog.dart index 783e6c842..bdac75621 100644 --- a/lib/themes/nipaplay/widgets/text_input_dialog.dart +++ b/lib/themes/nipaplay/widgets/text_input_dialog.dart @@ -250,19 +250,6 @@ class _TextInputDialogState extends State { Widget _buildHeader() { return Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _accentColor.withOpacity(0.18), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.edit, - color: _accentColor, - size: 22, - ), - ), - const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/rust/src/api/torrent.rs b/rust/src/api/torrent.rs index 3439037ce..9c2498d65 100644 --- a/rust/src/api/torrent.rs +++ b/rust/src/api/torrent.rs @@ -1,24 +1,75 @@ +use std::collections::HashMap; use std::io::{Read, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread; +use std::time::Duration; + +use futures_util::future::{AbortHandle, AbortRegistration, Abortable}; use librqbit::{ - AddTorrent, AddTorrentOptions, AddTorrentResponse, Api, ListOnlyResponse, Magnet, Session, - DhtSessionConfig, SessionOptions, SessionPersistenceConfig, + AddTorrent, AddTorrentOptions, AddTorrentResponse, Api, DhtSessionConfig, ListOnlyResponse, + Magnet, Session, SessionOptions, SessionPersistenceConfig, }; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio::runtime::{Builder, Runtime}; struct TorrentRuntime { runtime: Runtime, + initialization: Mutex<()>, + previews: Mutex, api: Mutex>, session: Mutex>>, download_dir: Mutex>, stream_server: Mutex>, } +// A preview owns both the cancellation handle and its resolved metadata until +// the dialog is cancelled or the exact preview is consumed by an add request. +#[flutter_rust_bridge::frb(ignore)] +#[derive(Default)] +struct PreviewRegistry { + next_id: i32, + requests: HashMap, +} + +#[flutter_rust_bridge::frb(ignore)] +struct PreviewRequest { + abort: AbortHandle, + registration: Option, + metadata: Option>, +} + +#[flutter_rust_bridge::frb(ignore)] +impl PreviewRegistry { + fn begin(&mut self) -> Result { + if self.requests.len() >= 32 { + return Err("too many pending torrent previews".to_string()); + } + self.next_id = self + .next_id + .checked_add(1) + .ok_or_else(|| "preview request ID exhausted".to_string())?; + let (abort, registration) = AbortHandle::new_pair(); + self.requests.insert( + self.next_id, + PreviewRequest { + abort, + registration: Some(registration), + metadata: None, + }, + ); + Ok(self.next_id) + } + + fn cancel(&mut self, id: i32) { + if let Some(request) = self.requests.remove(&id) { + request.abort.abort(); + } + } +} + struct TorrentStreamServer { port: u16, } @@ -38,6 +89,8 @@ fn torrent_runtime() -> &'static TorrentRuntime { .thread_name("nipaplay-torrent") .build() .expect("failed to create torrent runtime"), + initialization: Mutex::new(()), + previews: Mutex::new(PreviewRegistry::default()), api: Mutex::new(None), session: Mutex::new(None), download_dir: Mutex::new(None), @@ -45,9 +98,17 @@ fn torrent_runtime() -> &'static TorrentRuntime { }) } -pub fn torrent_init_session(download_dir: String) -> Result<(), String> { +pub fn torrent_init_session( + download_dir: String, + session_dir: Option, +) -> Result<(), String> { let normalized_dir = normalize_download_dir(download_dir)?; let state = torrent_runtime(); + // Hold this lock through creation and publication of both API and session. + let _initialization = state + .initialization + .lock() + .map_err(|_| "torrent initialization lock poisoned".to_string())?; std::fs::create_dir_all(&normalized_dir) .map_err(|error| format!("failed to create download directory: {error}"))?; @@ -70,8 +131,9 @@ pub fn torrent_init_session(download_dir: String) -> Result<(), String> { } } - // Session does not exist or download directory changed – (re)create it. - let session_dir = default_session_dir(&normalized_dir); + let session_dir = session_dir + .map(PathBuf::from) + .unwrap_or_else(|| default_session_dir(&normalized_dir)); torrent_log(format_args!( "init_session create session: download_dir={normalized_dir:?}, session_dir={session_dir:?}" )); @@ -122,202 +184,188 @@ pub fn torrent_init_session(download_dir: String) -> Result<(), String> { Ok(()) } +/// Reserve a request before dispatching the asynchronous FFI call, so that +/// cancellation also works when the call has not started running yet. +#[flutter_rust_bridge::frb(sync)] +pub fn torrent_begin_preview() -> Result { + torrent_runtime() + .previews + .lock() + .map_err(|_| "torrent preview lock poisoned".to_string())? + .begin() +} + +#[flutter_rust_bridge::frb(sync)] +pub fn torrent_cancel_preview(request_id: i32) -> Result<(), String> { + torrent_runtime() + .previews + .lock() + .map_err(|_| "torrent preview lock poisoned".to_string())? + .cancel(request_id); + Ok(()) +} + pub fn torrent_add_magnet( magnet_uri: String, download_dir: String, create_folder_for_task: bool, + preview_id: i32, ) -> Result { - let raw_len = magnet_uri.len(); - let magnet_uri = magnet_uri.trim().to_string(); - let had_outer_whitespace = raw_len != magnet_uri.len(); - torrent_log(format_args!( - "add_magnet start: {}, had_outer_whitespace={had_outer_whitespace}, download_dir={download_dir:?}, create_folder_for_task={create_folder_for_task}", - magnet_log_summary(&magnet_uri) - )); - if magnet_uri.is_empty() { - torrent_log(format_args!("add_magnet rejected: magnet URI is empty")); - return Err("magnet URI is empty".to_string()); + let magnet = Magnet::parse(magnet_uri.trim()) + .map_err(|error| format!("invalid magnet URI: {error:#}"))?; + let state = torrent_runtime(); + let metadata = state + .previews + .lock() + .map_err(|_| "torrent preview lock poisoned".to_string())? + .requests + .remove(&preview_id) + .and_then(|request| request.metadata) + .ok_or_else(|| "预览已失效,请重新预览后添加任务".to_string())?; + if magnet.as_id20() != Some(metadata.info_hash) { + return Err("磁力链接已改变,请重新预览".to_string()); } - - let parsed_magnet = match Magnet::parse(&magnet_uri) { - Ok(magnet) => { - torrent_log(format_args!( - "add_magnet parsed: {}", - parsed_magnet_log_summary(&magnet) - )); - magnet - } - Err(error) => { - let detail = format!("{error:#}"); - torrent_log(format_args!( - "add_magnet parse failed: {}, error={}", - magnet_log_summary(&magnet_uri), - truncate_for_log(&detail, 240) - )); - return Err(format!("invalid magnet URI: {detail}")); - } + let normalized_dir = normalize_download_dir(download_dir)?; + torrent_init_session(normalized_dir.clone(), None)?; + let folder = if create_folder_for_task { + torrent_metadata_folder_name(&metadata).or_else(|| Some(metadata.info_hash.as_string())) + } else { + None }; + let mut options = add_torrent_options(normalized_dir, folder); + options.only_files = metadata.only_files.clone(); + options.initial_peers = Some(metadata.seen_peers.clone()); + options.trackers = Some(magnet.trackers); + add_to_session( + state, + AddTorrent::from_bytes(metadata.torrent_bytes.clone()), + options, + ) +} +pub fn torrent_add_file( + torrent_file_path: String, + download_dir: String, + create_folder_for_task: bool, +) -> Result { + let torrent_file_path = torrent_file_path.trim(); let normalized_dir = normalize_download_dir(download_dir)?; - torrent_init_session(normalized_dir.clone())?; - let state = torrent_runtime(); - let api = current_api(state)?; - let magnet_trackers = parsed_magnet.trackers.clone(); - - if create_folder_for_task { - torrent_log(format_args!( - "add_magnet resolving metadata for task folder: {}", - magnet_log_summary(&magnet_uri) - )); - let metadata = resolve_magnet_metadata_for_add(state, &magnet_uri)?; - let fallback_folder_name = parsed_magnet - .name - .as_deref() - .and_then(sanitize_folder_name) - .or_else(|| parsed_magnet.as_id20().map(|id| id.as_string())); - let folder_name = torrent_metadata_folder_name(&metadata).or(fallback_folder_name); - let mut options = add_torrent_options(normalized_dir, folder_name.clone()); - if !metadata.seen_peers.is_empty() { - options.initial_peers = Some(metadata.seen_peers.clone()); - } - if !magnet_trackers.is_empty() { - options.trackers = Some(magnet_trackers); - } - let output_folder = options.output_folder.clone(); - torrent_log(format_args!( - "add_magnet rqbit_add start: folder_name={folder_name:?}, output_folder={output_folder:?}, source=resolved_metadata" - )); - - return state.runtime.block_on(async { - match api - .api_add_torrent( - AddTorrent::from_bytes(metadata.torrent_bytes), - Some(options), - ) - .await - { - Ok(response) => { - torrent_log(format_args!( - "add_magnet rqbit_add success: {}", - magnet_log_summary(&magnet_uri) - )); - response_to_json(&response) - } - Err(error) => { - let detail = format_error_chain(&error); - torrent_log(format_args!( - "add_magnet rqbit_add failed: {}, output_folder={output_folder:?}, error={}", - magnet_log_summary(&magnet_uri), - truncate_for_log(&detail, 480) - )); - Err(format!("failed to add magnet: {detail}")) - } - } - }); - } - - let folder_name = None; - let options = add_torrent_options(normalized_dir, folder_name.clone()); - let output_folder = options.output_folder.clone(); - torrent_log(format_args!( - "add_magnet rqbit_add start: folder_name={folder_name:?}, output_folder={output_folder:?}" - )); + torrent_init_session(normalized_dir.clone(), None)?; + let folder = create_folder_for_task.then(|| file_stem_folder_name(torrent_file_path)); + let add = AddTorrent::from_local_filename(torrent_file_path) + .map_err(|error| format!("failed to read torrent file: {error:#}"))?; + add_to_session( + torrent_runtime(), + add, + add_torrent_options(normalized_dir, folder), + ) +} +fn add_to_session( + state: &TorrentRuntime, + add: AddTorrent<'_>, + options: AddTorrentOptions, +) -> Result { + let session = current_session(state)?; state.runtime.block_on(async { - match api - .api_add_torrent(AddTorrent::from_url(magnet_uri.clone()), Some(options)) + let response = session + .add_torrent(add, Some(options)) .await - { - Ok(response) => { - torrent_log(format_args!( - "add_magnet rqbit_add success: {}", - magnet_log_summary(&magnet_uri) - )); - response_to_json(&response) - } - Err(error) => { - let detail = format_error_chain(&error); - torrent_log(format_args!( - "add_magnet rqbit_add failed: {}, output_folder={output_folder:?}, error={}", - magnet_log_summary(&magnet_uri), - truncate_for_log(&detail, 480) - )); - Err(format!("failed to add magnet: {detail}")) + .map_err(|error| format!("failed to add torrent: {error:#}"))?; + let (id, handle, already_exists) = match response { + AddTorrentResponse::Added(id, handle) => (id, handle, false), + AddTorrentResponse::AlreadyManaged(id, handle) => (id, handle, true), + AddTorrentResponse::ListOnly(_) => { + return Err("unexpected preview response".to_string()) } - } + }; + response_to_json(&serde_json::json!({ + "id": id, + "already_exists": already_exists, + "output_folder": handle.output_folder(), + })) }) } -pub fn torrent_add_file( - torrent_file_path: String, +pub fn torrent_preview_magnet( + magnet_uri: String, download_dir: String, - create_folder_for_task: bool, + request_id: i32, ) -> Result { - let torrent_file_path = torrent_file_path.trim().to_string(); - torrent_log(format_args!( - "add_torrent_file start: torrent_file_path={torrent_file_path:?}, download_dir={download_dir:?}, create_folder_for_task={create_folder_for_task}" - )); - if torrent_file_path.is_empty() { - torrent_log(format_args!( - "add_torrent_file rejected: torrent file path is empty" - )); - return Err("torrent file path is empty".to_string()); - } - let normalized_dir = normalize_download_dir(download_dir)?; - torrent_init_session(normalized_dir.clone())?; let state = torrent_runtime(); - let api = current_api(state)?; - let folder_name = create_folder_for_task.then(|| file_stem_folder_name(&torrent_file_path)); - let options = add_torrent_options(normalized_dir, folder_name.clone()); - let output_folder = options.output_folder.clone(); - torrent_log(format_args!( - "add_torrent_file rqbit_add start: folder_name={folder_name:?}, output_folder={output_folder:?}" - )); - - state.runtime.block_on(async { - let add = AddTorrent::from_local_filename(&torrent_file_path).map_err(|error| { - let detail = format!("{error:#}"); - torrent_log(format_args!( - "add_torrent_file read failed: torrent_file_path={torrent_file_path:?}, error={}", - truncate_for_log(&detail, 480) - )); - format!("failed to read torrent file: {detail}") + let result = (|| { + let registration = state + .previews + .lock() + .map_err(|_| "torrent preview lock poisoned".to_string())? + .requests + .get_mut(&request_id) + .and_then(|request| request.registration.take()) + .ok_or_else(|| "预览已取消".to_string())?; + let magnet_uri = magnet_uri.trim(); + Magnet::parse(magnet_uri).map_err(|error| format!("invalid magnet URI: {error:#}"))?; + torrent_init_session(download_dir, None)?; + let session = current_session(state)?; + let metadata = state.runtime.block_on(async { + resolve_preview(session, magnet_uri, registration, Duration::from_secs(60)).await })?; - match api.api_add_torrent(add, Some(options)).await { - Ok(response) => { - torrent_log(format_args!( - "add_torrent_file rqbit_add success: torrent_file_path={torrent_file_path:?}" - )); - response_to_json(&response) - } - Err(error) => { - let detail = format_error_chain(&error); - torrent_log(format_args!( - "add_torrent_file rqbit_add failed: torrent_file_path={torrent_file_path:?}, output_folder={output_folder:?}, error={}", - truncate_for_log(&detail, 480) - )); - Err(format!("failed to add torrent file: {detail}")) - } + let json = torrent_preview_to_json(&metadata)?; + let mut previews = state + .previews + .lock() + .map_err(|_| "torrent preview lock poisoned".to_string())?; + let request = previews + .requests + .get_mut(&request_id) + .ok_or_else(|| "预览已取消".to_string())?; + request.metadata = Some(Arc::new(metadata)); + Ok(json) + })(); + if result.is_err() { + torrent_cancel_preview(request_id)?; + } + result +} + +async fn resolve_preview( + session: Arc, + magnet_uri: &str, + registration: AbortRegistration, + timeout: Duration, +) -> Result { + let resolve = async { + match session + .add_torrent( + AddTorrent::from_url(magnet_uri.to_string()), + Some(AddTorrentOptions { + list_only: true, + ..Default::default() + }), + ) + .await + { + Ok(AddTorrentResponse::ListOnly(metadata)) => Ok(metadata), + Ok(_) => Err("unexpected torrent response while resolving metadata".to_string()), + Err(error) => Err(format!("failed to resolve magnet metadata: {error:#}")), } - }) + }; + bounded_preview(resolve, registration, timeout).await } -pub fn torrent_preview_magnet(magnet_uri: String, download_dir: String) -> Result { - let magnet_uri = magnet_uri.trim().to_string(); - if magnet_uri.is_empty() { - return Err("magnet URI is empty".to_string()); +async fn bounded_preview( + resolve: impl std::future::Future>, + registration: AbortRegistration, + timeout: Duration, +) -> Result { + match tokio::time::timeout(timeout, Abortable::new(resolve, registration)).await { + Err(_) => Err("磁力链接解析超时,请检查网络或稍后重试".to_string()), + Ok(Err(_)) => Err("预览已取消".to_string()), + Ok(Ok(result)) => result, } - Magnet::parse(&magnet_uri).map_err(|error| format!("invalid magnet URI: {error:#}"))?; - - let normalized_dir = normalize_download_dir(download_dir)?; - torrent_init_session(normalized_dir)?; - let state = torrent_runtime(); - let metadata = resolve_magnet_metadata_for_add(state, &magnet_uri)?; - torrent_preview_to_json(&metadata) } pub fn torrent_list(download_dir: String) -> Result { - torrent_init_session(download_dir)?; + torrent_init_session(download_dir, None)?; let state = torrent_runtime(); let api = current_api(state)?; @@ -414,115 +462,10 @@ fn current_session(state: &TorrentRuntime) -> Result, String> { .ok_or_else(|| "torrent session is not initialized".to_string()) } -fn resolve_magnet_metadata_for_add( - state: &TorrentRuntime, - magnet_uri: &str, -) -> Result { - let session = current_session(state)?; - state.runtime.block_on(async { - match session - .add_torrent( - AddTorrent::from_url(magnet_uri.to_string()), - Some(AddTorrentOptions { - list_only: true, - overwrite: true, - ..Default::default() - }), - ) - .await - { - Ok(AddTorrentResponse::ListOnly(response)) => Ok(response), - Ok(AddTorrentResponse::Added(_, _)) => { - Err("unexpected torrent add response while resolving metadata".to_string()) - } - Ok(AddTorrentResponse::AlreadyManaged(_, _)) => { - Err("unexpected managed torrent while resolving metadata".to_string()) - } - Err(error) => { - let detail = format!("{error:#}"); - torrent_log(format_args!( - "add_magnet metadata resolve failed: {}, error={}", - magnet_log_summary(magnet_uri), - truncate_for_log(&detail, 480) - )); - Err(format!("failed to resolve magnet metadata: {detail}")) - } - } - }) -} - fn torrent_log(args: std::fmt::Arguments<'_>) { eprintln!("[nipaplay_torrent] {args}"); } -fn magnet_log_summary(magnet_uri: &str) -> String { - let has_whitespace = magnet_uri.chars().any(char::is_whitespace); - let starts_with_magnet_ci = magnet_uri.to_ascii_lowercase().starts_with("magnet:"); - let mut summary = format!( - "len={}, starts_with_magnet={}, starts_with_magnet_ci={}, has_whitespace={has_whitespace}", - magnet_uri.len(), - magnet_uri.starts_with("magnet:"), - starts_with_magnet_ci - ); - - match Magnet::parse(magnet_uri) { - Ok(magnet) => { - summary.push_str(", parse=ok, "); - summary.push_str(&parsed_magnet_log_summary(&magnet)); - } - Err(error) => { - let detail = format!("{error:#}"); - summary.push_str(", parse=err("); - summary.push_str(&truncate_for_log(&detail, 160)); - summary.push(')'); - } - } - - summary -} - -fn parsed_magnet_log_summary(magnet: &Magnet) -> String { - let btv1 = magnet - .as_id20() - .map(|id| id.as_string()) - .unwrap_or_else(|| "".to_string()); - let btv2 = magnet - .as_id32() - .map(|id| id.as_string()) - .unwrap_or_else(|| "".to_string()); - let display_name = magnet - .name - .as_deref() - .map(|name| truncate_for_log(name, 120)) - .unwrap_or_else(|| "".to_string()); - - format!( - "btv1_info_hash={btv1}, btv2_info_hash={btv2}, tracker_count={}, dn={display_name:?}", - magnet.trackers.len() - ) -} - -fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String { - let mut message = error.to_string(); - let mut source = error.source(); - while let Some(error) = source { - message.push_str(" | caused by: "); - message.push_str(&error.to_string()); - source = error.source(); - } - message -} - -fn truncate_for_log(value: &str, max_chars: usize) -> String { - let mut chars = value.chars(); - let truncated: String = chars.by_ref().take(max_chars).collect(); - if chars.next().is_some() { - format!("{truncated}...") - } else { - truncated - } -} - fn ensure_stream_server(state: &'static TorrentRuntime) -> Result { let mut server_slot = state .stream_server @@ -583,7 +526,8 @@ fn handle_stream_request(mut socket: TcpStream) -> Result<(), String> { parse_stream_path(&path).ok_or_else(|| format!("invalid torrent stream path: {path}"))?; let state = torrent_runtime(); let api = current_api(state)?; - let mut stream = state.runtime + let mut stream = state + .runtime .block_on(api.api_stream(torrent_id.into(), file_id)) .map_err(|error| format!("failed to create torrent stream: {error:#}"))?; let file_len = stream.len(); @@ -986,3 +930,144 @@ fn normalize_torrent_id(id: i32) -> Result { fn response_to_json(response: &T) -> Result { serde_json::to_string(response).map_err(|error| format!("failed to encode JSON: {error}")) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn runtime() -> Runtime { + Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap() + } + + #[test] + fn cancellation_before_dispatch_releases_the_request() { + let mut requests = PreviewRegistry::default(); + let id = requests.begin().unwrap(); + requests.cancel(id); + assert!(!requests.requests.contains_key(&id)); + requests.cancel(id); // Closing an already completed dialog is harmless. + } + + #[test] + fn cancellation_aborts_an_in_flight_resolution() { + let mut requests = PreviewRegistry::default(); + let id = requests.begin().unwrap(); + let registration = requests + .requests + .get_mut(&id) + .unwrap() + .registration + .take() + .unwrap(); + let handle = requests.requests.get(&id).unwrap().abort.clone(); + runtime().block_on(async { + let resolving = bounded_preview( + std::future::pending::>(), + registration, + Duration::from_secs(60), + ); + let cancel = async { + tokio::task::yield_now().await; + handle.abort(); + }; + let (result, _) = tokio::join!(resolving, cancel); + assert_eq!(result.unwrap_err(), "预览已取消"); + }); + } + + #[test] + fn resolution_timeout_drops_network_work() { + struct OnDrop(Arc); + impl Drop for OnDrop { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let dropped = Arc::new(AtomicUsize::new(0)); + let guard = OnDrop(dropped.clone()); + let (_, registration) = AbortHandle::new_pair(); + runtime().block_on(async { + let result = bounded_preview( + async move { + let _guard = guard; + std::future::pending::>().await + }, + registration, + Duration::from_millis(10), + ) + .await; + assert!(result.unwrap_err().contains("超时")); + }); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + } + + #[test] + fn adding_resolved_bytes_offline_reports_duplicates_and_keeps_original_directory() { + let directory = + std::env::temp_dir().join(format!("nipaplay-torrent-test-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let runtime = runtime(); + let session = runtime + .block_on(Session::new_with_opts( + directory.clone(), + SessionOptions { + dht: None, + disable_trackers: true, + disable_local_service_discovery: true, + ..Default::default() + }, + )) + .unwrap(); + let state = TorrentRuntime { + runtime, + initialization: Mutex::new(()), + previews: Mutex::new(PreviewRegistry::default()), + api: Mutex::new(Some(Api::new(session.clone(), None))), + session: Mutex::new(Some(session.clone())), + download_dir: Mutex::new(None), + stream_server: Mutex::new(None), + }; + // One byte, no trackers. Neither adding nor re-adding may resolve peers. + let bytes = b"d4:infod6:lengthi1e4:name8:test.mp412:piece lengthi16384e6:pieces20:00000000000000000000ee"; + let first_folder = directory.join("first").to_string_lossy().into_owned(); + let first = add_to_session( + &state, + AddTorrent::from_bytes(bytes.as_slice()), + AddTorrentOptions { + paused: true, + ..add_torrent_options(first_folder.clone(), None) + }, + ) + .unwrap(); + let second = add_to_session( + &state, + AddTorrent::from_bytes(bytes.as_slice()), + AddTorrentOptions { + paused: true, + ..add_torrent_options( + directory.join("second").to_string_lossy().into_owned(), + None, + ) + }, + ) + .unwrap(); + let first: serde_json::Value = serde_json::from_str(&first).unwrap(); + let second: serde_json::Value = serde_json::from_str(&second).unwrap(); + assert_eq!(first["already_exists"], false); + assert_eq!(second["already_exists"], true); + assert_eq!(first["id"], second["id"]); + assert_eq!(second["output_folder"], first_folder); + assert!(session + .get((first["id"].as_u64().unwrap() as usize).into()) + .unwrap() + .is_paused()); + state.runtime.block_on(session.stop()); + drop(state); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index a781e08ca..a6ee82b83 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1076718673; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -281536625; // Section: executor @@ -1864,6 +1864,7 @@ fn wire__crate__api__torrent__torrent_add_magnet_impl( let api_magnet_uri = ::sse_decode(&mut deserializer); let api_download_dir = ::sse_decode(&mut deserializer); let api_create_folder_for_task = ::sse_decode(&mut deserializer); + let api_preview_id = ::sse_decode(&mut deserializer); deserializer.end(); move |context| { transform_result_sse::<_, String>((move || { @@ -1871,6 +1872,7 @@ fn wire__crate__api__torrent__torrent_add_magnet_impl( api_magnet_uri, api_download_dir, api_create_folder_for_task, + api_preview_id, )?; Ok(output_ok) })()) @@ -1878,6 +1880,65 @@ fn wire__crate__api__torrent__torrent_add_magnet_impl( }, ) } +fn wire__crate__api__torrent__torrent_begin_preview_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "torrent_begin_preview", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::torrent::torrent_begin_preview()?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__torrent__torrent_cancel_preview_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "torrent_cancel_preview", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_request_id = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::torrent::torrent_cancel_preview(api_request_id)?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__torrent__torrent_delete_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2000,10 +2061,14 @@ fn wire__crate__api__torrent__torrent_init_session_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_download_dir = ::sse_decode(&mut deserializer); + let api_session_dir = >::sse_decode(&mut deserializer); deserializer.end(); move |context| { transform_result_sse::<_, String>((move || { - let output_ok = crate::api::torrent::torrent_init_session(api_download_dir)?; + let output_ok = crate::api::torrent::torrent_init_session( + api_download_dir, + api_session_dir, + )?; Ok(output_ok) })()) } @@ -2100,12 +2165,14 @@ fn wire__crate__api__torrent__torrent_preview_magnet_impl( flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_magnet_uri = ::sse_decode(&mut deserializer); let api_download_dir = ::sse_decode(&mut deserializer); + let api_request_id = ::sse_decode(&mut deserializer); deserializer.end(); move |context| { transform_result_sse::<_, String>((move || { let output_ok = crate::api::torrent::torrent_preview_magnet( api_magnet_uri, api_download_dir, + api_request_id, )?; Ok(output_ok) })()) @@ -3527,22 +3594,22 @@ fn pde_ffi_dispatcher_primary_impl( ), 49 => wire__crate__api__torrent__torrent_add_file_impl(port, ptr, rust_vec_len, data_len), 50 => wire__crate__api__torrent__torrent_add_magnet_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__api__torrent__torrent_delete_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__api__torrent__torrent_details_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__torrent__torrent_forget_impl(port, ptr, rust_vec_len, data_len), - 54 => { + 53 => wire__crate__api__torrent__torrent_delete_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__torrent__torrent_details_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__torrent__torrent_forget_impl(port, ptr, rust_vec_len, data_len), + 56 => { wire__crate__api__torrent__torrent_init_session_impl(port, ptr, rust_vec_len, data_len) } - 55 => wire__crate__api__torrent__torrent_list_impl(port, ptr, rust_vec_len, data_len), - 56 => wire__crate__api__torrent__torrent_pause_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__torrent__torrent_preview_magnet_impl( + 57 => wire__crate__api__torrent__torrent_list_impl(port, ptr, rust_vec_len, data_len), + 58 => wire__crate__api__torrent__torrent_pause_impl(port, ptr, rust_vec_len, data_len), + 59 => wire__crate__api__torrent__torrent_preview_magnet_impl( port, ptr, rust_vec_len, data_len, ), - 58 => wire__crate__api__torrent__torrent_resume_impl(port, ptr, rust_vec_len, data_len), - 59 => wire__crate__api__torrent__torrent_stream_url_impl(port, ptr, rust_vec_len, data_len), + 60 => wire__crate__api__torrent__torrent_resume_impl(port, ptr, rust_vec_len, data_len), + 61 => wire__crate__api__torrent__torrent_stream_url_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -3620,6 +3687,8 @@ fn pde_ffi_dispatcher_sync_impl( rust_vec_len, data_len, ), + 51 => wire__crate__api__torrent__torrent_begin_preview_impl(ptr, rust_vec_len, data_len), + 52 => wire__crate__api__torrent__torrent_cancel_preview_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/test/television_media_library_test.dart b/test/television_media_library_test.dart index e4f860817..f4c0864c3 100644 --- a/test/television_media_library_test.dart +++ b/test/television_media_library_test.dart @@ -65,8 +65,15 @@ void main() { addTearDown(selectedSource.dispose); await tester.pumpWidget( - ChangeNotifierProvider( - create: (_) => _LoadedEmptyWatchHistoryProvider(), + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => _LoadedEmptyWatchHistoryProvider(), + ), + ChangeNotifierProvider( + create: (_) => AppearanceSettingsProvider(), + ), + ], child: _testApp( home: Scaffold( body: AppDisplaySurfaceScope( diff --git a/test/torrent_completed_scan_test.dart b/test/torrent_completed_scan_test.dart new file mode 100644 index 000000000..67d58d980 --- /dev/null +++ b/test/torrent_completed_scan_test.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nipaplay/models/torrent_task.dart'; +import 'package:nipaplay/services/concurrent_video_processor.dart'; +import 'package:nipaplay/services/scan_service.dart'; +import 'package:nipaplay/services/torrent_download_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('failed and throwing scans return failure and can be retried', () async { + var attempt = 0; + final scanner = + ScanService.forTesting(completedFilesProcessor: (paths) async { + attempt++; + if (attempt == 1) throw StateError('unreadable'); + return [VideoProcessResult(filePath: paths.single, success: attempt > 2)]; + }); + addTearDown(scanner.dispose); + expect( + await scanner + .scanCompletedFiles(['/download/a.mp4'], folderPath: '/download'), + isFalse); + expect(scanner.isScanning, isFalse); + expect( + await scanner + .scanCompletedFiles(['/download/a.mp4'], folderPath: '/download'), + isFalse); + expect( + await scanner + .scanCompletedFiles(['/download/a.mp4'], folderPath: '/download'), + isTrue); + expect(scanner.scannedFolders, isEmpty); + expect(scanner.failedScanFiles, isEmpty); + }); + + test('an occupied scanner does not claim the next task was imported', + () async { + final pending = Completer>(); + var calls = 0; + final scanner = ScanService.forTesting(completedFilesProcessor: (_) { + calls++; + return pending.future; + }); + addTearDown(scanner.dispose); + final first = + scanner.scanCompletedFiles(['a.mp4'], folderPath: '/download'); + expect(await scanner.scanCompletedFiles(['b.mp4'], folderPath: '/download'), + isFalse); + expect(calls, 1); + pending.complete([]); + expect(await first, isTrue); + }); + + test('only exact completed task files are selected from a shared directory', + () async { + final root = await Directory.systemTemp.createTemp('torrent-files-test-'); + addTearDown(() => root.delete(recursive: true)); + await File('${root.path}/complete.mp4').writeAsString('done'); + await File('${root.path}/unfinished.mp4').writeAsString('partial'); + final service = TorrentDownloadService.forTesting( + isIos: () => false, + getDownloadsDirectory: () async => root, + directoryExists: (_) async => true); + final task = TorrentTask.fromMap({ + 'id': 1, + 'output_folder': root.path, + 'stats': {'finished': true, 'state': 'live'}, + 'files': [ + {'name': 'complete.mp4', 'length': 4, 'included': true} + ] + }); + expect(await service.listCompletedVideoPaths(task), + ['${root.path}/complete.mp4']); + await File('${root.path}/complete.mp4').delete(); + await expectLater(service.listCompletedVideoPaths(task), throwsStateError); + }); +} diff --git a/test/torrent_download_service_test.dart b/test/torrent_download_service_test.dart index 332a5465a..e387c86ac 100644 --- a/test/torrent_download_service_test.dart +++ b/test/torrent_download_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -9,6 +10,36 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'concurrent initialization shares one future and failed initialization can retry', + () async { + SharedPreferences.setMockInitialValues( + {SettingsKeys.torrentDownloadDirectory: '/downloads'}); + final gate = Completer(); + var calls = 0; + final service = TorrentDownloadService.forTesting( + isIos: () => false, + getDownloadsDirectory: () async => Directory('/downloads'), + directoryExists: (_) async => true, + initializeSession: (_) async { + calls++; + if (calls == 1) { + await gate.future; + throw StateError('initialization failed'); + } + }, + ); + final first = expectLater(service.initialize(), throwsStateError); + final second = expectLater(service.initialize(), throwsStateError); + await Future.delayed(Duration.zero); + expect(calls, 1); + gate.complete(); + await Future.wait([first, second]); + await service.initialize(); + await service.initialize(); + expect(calls, 2); + }); + group('TorrentDownloadService.getDownloadDirectory', () { const staleDirectory = '/old/Application/CONTAINER/Documents/downloads'; const currentDirectory = diff --git a/test/torrent_download_widgets_test.dart b/test/torrent_download_widgets_test.dart new file mode 100644 index 000000000..34e32f8d2 --- /dev/null +++ b/test/torrent_download_widgets_test.dart @@ -0,0 +1,272 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' as cupertino; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nipaplay/app/app_display_surface.dart'; +import 'package:nipaplay/app/app_display_surface_scope.dart'; +import 'package:nipaplay/downloads/unified_torrent_page_model.dart'; +import 'package:nipaplay/models/torrent_task.dart'; +import 'package:nipaplay/pages/torrent_download_page.dart'; +import 'package:nipaplay/providers/appearance_settings_provider.dart'; +import 'package:nipaplay/providers/bottom_bar_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUpAll(() async { + final loader = FontLoader('subfont') + ..addFont(rootBundle.load('assets/subfont.ttf')); + await loader.load(); + await (FontLoader('packages/kmbal_ionicons/Ionicons') + ..addFont(rootBundle + .load('packages/kmbal_ionicons/assets/fonts/Ionicons.ttf'))) + .load(); + }); + + for (final surface in [ + AppDisplaySurface.desktopTablet, + AppDisplaySurface.television + ]) { + for (final brightness in Brightness.values) { + for (final mode in UnifiedTorrentTaskViewMode.values) { + testWidgets( + '$surface $brightness $mode exposes streaming and seeding controls', + (tester) async { + tester.view.physicalSize = const Size(1200, 820); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final search = TextEditingController(); + addTearDown(search.dispose); + var plays = 0; + var pauses = 0; + final tasks = [ + TorrentTask.fromMap({ + 'id': 1, + 'name': '正在下载的视频', + 'output_folder': '/Downloads/Video', + 'stats': { + 'state': 'live', + 'finished': false, + 'progress_bytes': 400, + 'total_bytes': 1000 + } + }), + TorrentTask.fromMap({ + 'id': 2, + 'name': '已经完成的视频', + 'output_folder': '/Downloads/Video2', + 'stats': { + 'state': 'live', + 'finished': true, + 'progress_bytes': 1000, + 'total_bytes': 1000 + } + }), + ]; + final items = tasks + .map((task) => UnifiedTorrentTaskItemViewModel( + task: task, + scanSummary: null, + isAutoScanning: false, + isAutoScanned: false, + actions: [ + UnifiedTorrentTaskActionViewModel( + action: UnifiedTorrentTaskAction.play, + label: '播放', + onPressed: () => plays++), + UnifiedTorrentTaskActionViewModel( + action: UnifiedTorrentTaskAction.toggle, + label: task.toggleLabel, + onPressed: () => pauses++), + for (final action in [ + UnifiedTorrentTaskAction.openFolder, + UnifiedTorrentTaskAction.forget, + UnifiedTorrentTaskAction.delete + ]) + UnifiedTorrentTaskActionViewModel( + action: action, + label: action.name, + onPressed: () {}), + ], + )) + .toList(); + final data = UnifiedTorrentPageViewModel( + isLoading: false, + isBusy: false, + tasks: tasks, + visibleTasks: items, + searchController: search, + sort: UnifiedTorrentTaskSort.latest, + viewMode: mode, + onSearchChanged: (_) {}, + onClearSearch: () {}, + onSortChanged: (_) {}, + onToggleViewMode: () {}, + onRefresh: () {}, + onAddMagnet: () {}, + onPickTorrent: () {}, + ); + final boundaryKey = GlobalKey(); + await tester.pumpWidget(MaterialApp( + theme: ThemeData(brightness: brightness, fontFamily: 'subfont'), + home: RepaintBoundary( + key: boundaryKey, + child: Scaffold( + body: AppDisplaySurfaceScope( + surface: surface, + child: surface == AppDisplaySurface.television + ? TelevisionTorrentDownloadView(data: data) + : DesktopTorrentDownloadView(data: data)))), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final play = surface == AppDisplaySurface.television || + mode == UnifiedTorrentTaskViewMode.cards + ? find.text('播放') + : find.byTooltip('播放'); + expect(play, findsNWidgets(2)); + await tester.tap(play.first); + expect(plays, 1); + final pause = surface == AppDisplaySurface.television || + mode == UnifiedTorrentTaskViewMode.cards + ? find.text('暂停做种') + : find.byTooltip('暂停做种'); + await tester.tap(pause); + expect(pauses, 1); + await tester.pumpAndSettle(); + if (const bool.fromEnvironment('TORRENT_CAPTURE')) { + final boundary = boundaryKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = + await image.toByteData(format: ui.ImageByteFormat.png); + final directory = Directory('.codex_work/downloader-previews'); + await directory.create(recursive: true); + await File( + '${directory.path}/${surface.name}-${brightness.name}-${mode.name}.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + } + }); + } + } + } + + for (final brightness in Brightness.values) { + testWidgets( + 'phone $brightness menu exposes playback and pause while seeding', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final search = TextEditingController(); + addTearDown(search.dispose); + var paused = false; + final task = TorrentTask.fromMap({ + 'id': 1, + 'name': '已完成的视频', + 'output_folder': '/Downloads', + 'stats': { + 'state': 'live', + 'finished': true, + 'progress_bytes': 1000, + 'total_bytes': 1000 + } + }); + final item = UnifiedTorrentTaskItemViewModel( + task: task, + scanSummary: null, + isAutoScanning: false, + isAutoScanned: false, + actions: [ + UnifiedTorrentTaskActionViewModel( + action: UnifiedTorrentTaskAction.play, + label: '播放', + onPressed: () {}), + UnifiedTorrentTaskActionViewModel( + action: UnifiedTorrentTaskAction.toggle, + label: task.toggleLabel, + onPressed: () => paused = true), + ]); + final data = UnifiedTorrentPageViewModel( + isLoading: false, + isBusy: false, + tasks: [task], + visibleTasks: [item], + searchController: search, + sort: UnifiedTorrentTaskSort.latest, + viewMode: UnifiedTorrentTaskViewMode.cards, + onSearchChanged: (_) {}, + onClearSearch: () {}, + onSortChanged: (_) {}, + onToggleViewMode: () {}, + onRefresh: () {}, + onAddMagnet: () {}, + onPickTorrent: () {}); + await tester.pumpWidget(ChangeNotifierProvider( + create: (_) => BottomBarProvider(), + child: MaterialApp( + theme: ThemeData(brightness: brightness, fontFamily: 'subfont'), + home: AppDisplaySurfaceScope( + surface: AppDisplaySurface.phone, + child: Scaffold( + body: CupertinoTorrentDownloadView(data: data)))))); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(cupertino.CupertinoIcons.ellipsis)); + await tester.pumpAndSettle(); + expect(find.text('播放'), findsOneWidget); + await tester.tap(find.text('暂停做种')); + await tester.pumpAndSettle(); + expect(paused, isTrue); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('preview can be cancelled while resolving', (tester) async { + tester.view.physicalSize = const Size(1100, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final magnet = TextEditingController(); + addTearDown(magnet.dispose); + SharedPreferences.setMockInitialValues({}); + final appearance = AppearanceSettingsProvider(); + addTearDown(appearance.dispose); + var cancelled = false; + final data = AddTorrentDialogViewModel( + magnetController: magnet, + downloadDirectory: '/Downloads', + createFolderForTask: true, + recentDirectories: const [], + preview: null, + error: null, + isPreviewing: true, + onMagnetChanged: (_) {}, + onChooseDirectory: () {}, + onSelectDirectory: (_) {}, + onRemoveRecentDirectory: (_) {}, + onCreateFolderChanged: (_) {}, + onPreview: () {}, + onConfirm: () {}, + onCancel: () => cancelled = true, + ); + await tester.pumpWidget(MaterialApp( + home: AppDisplaySurfaceScope( + surface: AppDisplaySurface.desktopTablet, + child: ChangeNotifierProvider.value( + value: appearance, + child: Scaffold(body: DesktopAddTorrentView(data: data))), + ))); + await tester.pump(); + await tester.tap(find.text('取消')); + expect(cancelled, isTrue); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/torrent_session_storage_test.dart b/test/torrent_session_storage_test.dart new file mode 100644 index 000000000..c185c10ee --- /dev/null +++ b/test/torrent_session_storage_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nipaplay/services/torrent_session_storage.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + setUp(() async => + root = await Directory.systemTemp.createTemp('torrent-session-test-')); + tearDown(() async => root.delete(recursive: true)); + + Future legacy(String folder, String hash) async { + final directory = + Directory(p.join(root.path, folder, '.nipaplay_torrent_session')); + await directory.create(recursive: true); + await File(p.join(directory.path, 'session.json')) + .writeAsString(jsonEncode({ + 'torrents': { + '0': { + 'info_hash': hash, + 'output_folder': p.join(root.path, folder), + 'is_paused': true, + 'trackers': [], + 'only_files': null, + } + } + })); + await File(p.join(directory.path, '$hash.torrent')) + .writeAsString('metadata-$hash'); + await File(p.join(directory.path, '$hash.bitv')) + .writeAsString('resume-$hash'); + } + + test( + 'merges directories with colliding IDs, preserves paths and pause state, and copies resume data', + () async { + final hashA = 'a' * 40; + final hashB = 'b' * 40; + await legacy('A', hashA); + await legacy('B', hashB); + final stable = Directory(p.join(root.path, 'support', 'torrent_session')); + final result = await TorrentSessionStorage.prepare( + stable, [p.join(root.path, 'B'), p.join(root.path, 'A')]); + expect(result, stable.path); + final data = + jsonDecode(await File(p.join(result, 'session.json')).readAsString()) + as Map; + final tasks = (data['torrents'] as Map).values.toList(); + expect(tasks, hasLength(2)); + expect(tasks.map((t) => t['output_folder']), + unorderedEquals([p.join(root.path, 'A'), p.join(root.path, 'B')])); + expect(tasks.every((t) => t['is_paused'] == true), isTrue); + expect(await File(p.join(result, '$hashA.bitv')).readAsString(), + 'resume-$hashA'); + expect( + await File(p.join( + root.path, 'A', '.nipaplay_torrent_session', 'session.json')) + .exists(), + isTrue); + }); + + test( + 'restarting with a new default directory does not resurrect removed tasks', + () async { + await legacy('A', 'a' * 40); + await legacy('B', 'b' * 40); + final stable = Directory(p.join(root.path, 'support')); + await TorrentSessionStorage.prepare( + stable, [p.join(root.path, 'A'), p.join(root.path, 'B')]); + // rqbit rewrites its database when tasks are removed. + await File(p.join(stable.path, 'session.json')) + .writeAsString('{"torrents":{}}'); + await TorrentSessionStorage.prepare( + stable, [p.join(root.path, 'B'), p.join(root.path, 'A')]); + expect( + (jsonDecode( + await File(p.join(stable.path, 'session.json')).readAsString()) + as Map)['torrents'], + isEmpty); + }); + + test('the same hash from multiple old directories is imported once', + () async { + await legacy('A', 'a' * 40); + await legacy('B', 'a' * 40); + final stable = Directory(p.join(root.path, 'support')); + await TorrentSessionStorage.prepare( + stable, [p.join(root.path, 'A'), p.join(root.path, 'B')]); + final data = jsonDecode( + await File(p.join(stable.path, 'session.json')).readAsString()) as Map; + expect(data['torrents'], hasLength(1)); + }); +} diff --git a/test/torrent_task_controller_test.dart b/test/torrent_task_controller_test.dart new file mode 100644 index 000000000..5389513a7 --- /dev/null +++ b/test/torrent_task_controller_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nipaplay/downloads/torrent_task_controller.dart'; +import 'package:nipaplay/models/torrent_task.dart'; + +TorrentTask task(String state, + {bool finished = false, bool initializingPaused = false}) => + TorrentTask.fromMap({ + 'id': 1, + 'stats': { + 'state': state, + 'finished': finished, + 'initializing_paused': initializingPaused + } + }); + +void main() { + test('refreshes immediately after a task action without a timer tick', + () async { + var state = 'live'; + final controller = + TorrentTaskController(loadTasks: () async => [task(state)]); + addTearDown(controller.dispose); + await controller.refresh(); + await controller.run(1, () async => state = 'paused'); + expect(controller.tasks.single.isPaused, isTrue); + expect(controller.isBusy(1), isFalse); + }); + + test('a stale in-flight poll cannot overwrite the post-action snapshot', + () async { + final stalePoll = Completer>(); + var calls = 0; + final controller = TorrentTaskController(loadTasks: () { + calls++; + return calls == 1 ? stalePoll.future : Future.value([task('paused')]); + }); + addTearDown(controller.dispose); + final poll = controller.refresh(); + final action = controller.run(1, () async {}); + await Future.delayed(Duration.zero); + expect(controller.isBusy(1), isTrue); + stalePoll.complete([task('live')]); + await Future.wait([poll, action]); + expect(calls, 2); + expect(controller.tasks.single.isPaused, isTrue); + }); + + test( + 'a busy task prevents duplicate actions but does not block another task or refresh', + () async { + final blocked = Completer(); + var loads = 0; + final controller = TorrentTaskController(loadTasks: () async { + loads++; + return []; + }); + addTearDown(controller.dispose); + final first = controller.run(1, () => blocked.future); + expect( + await controller.run(1, () async => fail('duplicate action')), isFalse); + expect(await controller.run(2, () async {}), isTrue); + await controller.refresh(); + expect(loads, greaterThanOrEqualTo(2)); + expect(controller.isBusy(1), isTrue); + blocked.complete(); + await first; + }); + + test('errors release a task and allow retry', () async { + final controller = TorrentTaskController(loadTasks: () async => []); + addTearDown(controller.dispose); + await expectLater(controller.run(1, () async => throw StateError('disk')), + throwsStateError); + expect(controller.isBusy(1), isFalse); + expect(await controller.run(1, () async {}), isTrue); + }); + + test('disposal during an action does not notify disposed listeners', + () async { + final blocked = Completer(); + final controller = TorrentTaskController(loadTasks: () async => []); + final action = controller.run(1, () => blocked.future); + controller.dispose(); + blocked.complete(); + await action; + }); + + test('initialization pause survives JSON parsing and can resume', () { + final paused = task('initializing', initializingPaused: true); + expect(paused.isPaused, isTrue); + expect(paused.canResume, isTrue); + expect(paused.isActive, isFalse); + expect(paused.displayState, '已暂停'); + expect(paused.copyWith(files: []).isPaused, isTrue); + }); + + test( + 'error offers retry; a finished seeding task can pause without counting as downloading', + () { + expect(task('error').canResume, isTrue); + expect(task('error').toggleLabel, '重试'); + final seeding = task('live', finished: true); + expect(seeding.isActive, isFalse); + expect(seeding.canResume, isFalse); + expect(seeding.toggleLabel, '暂停做种'); + expect(task('paused', finished: true).toggleLabel, '继续做种'); + expect(task('live').canPlay, isTrue); + expect(task('initializing').canPlay, isFalse); + }); +} diff --git a/test/unified_app_architecture_test.dart b/test/unified_app_architecture_test.dart index e85fd31b9..d1669aca8 100644 --- a/test/unified_app_architecture_test.dart +++ b/test/unified_app_architecture_test.dart @@ -1543,7 +1543,8 @@ void main() { const deleteDialog = TorrentDeleteDialogViewModel(task: task); expect(item.primaryAction.label, '播放'); - item.primaryAction.onPressed(); + expect(item.primaryAction.onPressed, isNotNull); + item.primaryAction.onPressed!(); expect(played, isTrue); expect(item.scanStatusText, '已加入媒体库'); expect(deleteDialog.title, '删除任务和文件');