891 lines
29 KiB
Dart
891 lines
29 KiB
Dart
import 'dart:convert';
|
|
import 'dart:async';
|
|
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../data/models.dart';
|
|
import '../data/content_policy.dart';
|
|
import 'media_platform.dart';
|
|
import '../state/app_state.dart';
|
|
import 'media_helpers.dart';
|
|
import 'media_files_stub.dart'
|
|
if (dart.library.io) 'media_files_io.dart'
|
|
as files;
|
|
|
|
/// A snapshot of the persistent native task, not an optimistic success flag.
|
|
class DownloadEntry {
|
|
final Task task;
|
|
final String id,
|
|
title,
|
|
quality,
|
|
filename,
|
|
path,
|
|
url,
|
|
status,
|
|
error,
|
|
episodeTitle,
|
|
posterImage;
|
|
final int titleId;
|
|
final String titleKind;
|
|
final bool? downloadNotifications;
|
|
final int? episodeId;
|
|
final double progress, speed;
|
|
final int receivedBytes, totalBytes;
|
|
final TaskStatus taskStatus;
|
|
|
|
DownloadEntry._({
|
|
required this.task,
|
|
required this.id,
|
|
required this.title,
|
|
required this.quality,
|
|
required this.filename,
|
|
required this.path,
|
|
required this.url,
|
|
required this.status,
|
|
required this.error,
|
|
required this.episodeTitle,
|
|
required this.posterImage,
|
|
required this.titleId,
|
|
required this.titleKind,
|
|
required this.downloadNotifications,
|
|
required this.episodeId,
|
|
required this.progress,
|
|
required this.speed,
|
|
required this.receivedBytes,
|
|
required this.totalBytes,
|
|
required this.taskStatus,
|
|
});
|
|
|
|
factory DownloadEntry.fromTask(
|
|
Task task, {
|
|
TaskStatus taskStatus = TaskStatus.enqueued,
|
|
double progress = 0,
|
|
int totalBytes = 0,
|
|
int? receivedBytes,
|
|
double speed = 0,
|
|
String path = '',
|
|
String error = '',
|
|
}) {
|
|
Map<String, dynamic> meta = {};
|
|
try {
|
|
final decoded = jsonDecode(task.metaData);
|
|
if (decoded is Map) meta = Map<String, dynamic>.from(decoded);
|
|
} catch (_) {
|
|
/* Tasks from older installs can lack metadata. */
|
|
}
|
|
final fraction = taskStatus == TaskStatus.complete
|
|
? 1.0
|
|
: progress.isFinite
|
|
? progress.clamp(0.0, 1.0)
|
|
: 0.0;
|
|
final size = totalBytes > 0 ? totalBytes : 0;
|
|
return DownloadEntry._(
|
|
task: task,
|
|
id: task.taskId,
|
|
title: meta['title'] as String? ?? task.displayName,
|
|
quality: meta['quality'] as String? ?? '',
|
|
filename: task.filename,
|
|
path: path,
|
|
url: task.url,
|
|
status: taskStatus.name,
|
|
error: error,
|
|
episodeTitle: meta['episodeTitle'] as String? ?? '',
|
|
posterImage: meta['posterImage'] as String? ?? '',
|
|
titleId: meta['titleId'] as int? ?? 0,
|
|
titleKind: (meta['titleKind'] as String?)?.isNotEmpty == true
|
|
? meta['titleKind'] as String
|
|
: meta['episodeId'] == null
|
|
? 'movie'
|
|
: 'serie',
|
|
downloadNotifications:
|
|
meta['downloadNotifications'] as bool? ??
|
|
(task.notificationConfig != null ? true : null),
|
|
episodeId: meta['episodeId'] as int?,
|
|
progress: fraction,
|
|
speed: speed.isFinite && speed > 0 ? speed : 0,
|
|
receivedBytes: receivedBytes ?? (fraction * size).round(),
|
|
totalBytes: size,
|
|
taskStatus: taskStatus,
|
|
);
|
|
}
|
|
|
|
TitleRecord get restoredTitle => TitleRecord(
|
|
id: titleId,
|
|
title: title,
|
|
image: posterImage,
|
|
type: titleKind,
|
|
);
|
|
|
|
String get sourceType {
|
|
try {
|
|
return (jsonDecode(task.metaData) as Map)['sourceType'] as String? ?? '';
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
bool get isSegmented => task.group == DownloadService.packageGroup;
|
|
bool get visible =>
|
|
ContentPolicy.allowsTitleJson(restoredTitle.toJson()) &&
|
|
ContentPolicy.allowsText(episodeTitle) &&
|
|
ContentPolicy.allowsUrl(url);
|
|
bool get isComplete => taskStatus == TaskStatus.complete;
|
|
bool get canPause => taskStatus == TaskStatus.running;
|
|
bool get canResume => taskStatus == TaskStatus.paused;
|
|
bool get isActive => {
|
|
TaskStatus.running,
|
|
TaskStatus.enqueued,
|
|
TaskStatus.waitingToRetry,
|
|
}.contains(taskStatus);
|
|
}
|
|
|
|
/// Resolve a fresh durable snapshot, awaiting both storage and file paths.
|
|
Future<List<DownloadEntry>> readDownloadEntries(
|
|
Future<List<TaskRecord>> Function() load,
|
|
) async {
|
|
final records = await load();
|
|
return Future.wait(
|
|
records.map(
|
|
(record) async => DownloadEntry.fromTask(
|
|
record.task,
|
|
taskStatus: record.status,
|
|
progress: record.progress,
|
|
totalBytes: record.expectedFileSize,
|
|
path: await record.task.filePath(),
|
|
error: record.exception?.description ?? '',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
TaskNotificationConfig? downloadNotificationConfig(bool enabled) => enabled
|
|
? TaskNotificationConfig(
|
|
running: const TaskNotification(
|
|
'Reelbox • downloading',
|
|
'{displayName}\n{progress} • {networkSpeed}',
|
|
),
|
|
complete: const TaskNotification(
|
|
'Reelbox • downloaded',
|
|
'{displayName}',
|
|
),
|
|
error: const TaskNotification(
|
|
'Reelbox • download failed',
|
|
'{displayName}',
|
|
),
|
|
paused: const TaskNotification('Reelbox • paused', '{displayName}'),
|
|
progressBar: true,
|
|
tapOpensFile: true,
|
|
)
|
|
: null;
|
|
|
|
// Shared by enqueue and restart recovery tests; metadata is the durable policy.
|
|
String downloadMetadata({
|
|
required TitleRecord title,
|
|
required MediaSource source,
|
|
required bool notifications,
|
|
EpisodeRecord? episode,
|
|
}) => jsonEncode({
|
|
'title': title.title,
|
|
'titleId': title.id,
|
|
'titleKind': title.type,
|
|
'downloadNotifications': notifications,
|
|
'quality': source.quality,
|
|
'sourceType': source.type,
|
|
'episodeId': episode?.id,
|
|
'episodeTitle': episode?.title ?? '',
|
|
'posterImage': title.image,
|
|
});
|
|
|
|
Future<bool> resumeDownloadTask(
|
|
DownloadEntry entry, {
|
|
required void Function(Task task, TaskNotificationConfig config) configure,
|
|
required Future<bool> Function(DownloadTask task) resume,
|
|
}) async {
|
|
if (entry.task is! DownloadTask) return false;
|
|
// Task.notificationConfig is deliberately omitted by plugin serialization.
|
|
// Unknown legacy policy stays silent rather than adopting today's setting.
|
|
final config = downloadNotificationConfig(
|
|
entry.downloadNotifications ?? false,
|
|
);
|
|
// The pinned plugin rejects an all-null notification configuration. Off-policy
|
|
// tasks have no templates registered (and this service has no group/default).
|
|
if (config != null) configure(entry.task, config);
|
|
return resume(entry.task as DownloadTask);
|
|
}
|
|
|
|
DownloadTask createDirectDownloadTask({
|
|
required String id,
|
|
required TitleRecord title,
|
|
required MediaSource source,
|
|
required AppSettings preferences,
|
|
EpisodeRecord? episode,
|
|
bool supportsParallel = false,
|
|
}) {
|
|
final task = DownloadTask(
|
|
taskId: id,
|
|
url: source.url,
|
|
filename: downloadFilename(
|
|
title: episode == null ? title.title : '${title.title} ${episode.title}',
|
|
identity: id,
|
|
url: source.url,
|
|
),
|
|
baseDirectory: BaseDirectory.applicationSupport,
|
|
directory: 'offline',
|
|
group: DownloadService.group,
|
|
notificationConfig: downloadNotificationConfig(
|
|
preferences.downloadNotifications,
|
|
),
|
|
updates: Updates.statusAndProgress,
|
|
requiresWiFi: preferences.wifiOnlyDownloads,
|
|
allowPause: true,
|
|
retries: 2,
|
|
displayName: episode == null
|
|
? title.title
|
|
: '${title.title} • ${episode.title}',
|
|
metaData: downloadMetadata(
|
|
title: title,
|
|
source: source,
|
|
episode: episode,
|
|
notifications: preferences.downloadNotifications,
|
|
),
|
|
);
|
|
|
|
// A normal GET is the compatibility path. Multipart workers require HEAD,
|
|
// a known length and advertised byte ranges; settings alone prove none of it.
|
|
if (preferences.parallelDownloads <= 1 || !supportsParallel) return task;
|
|
// 9.6.5 exposes real HTTP range chunk tasks, not concurrent whole-file copies.
|
|
return ParallelDownloadTask(
|
|
taskId: task.taskId,
|
|
url: task.url,
|
|
filename: task.filename,
|
|
chunks: preferences.parallelDownloads.clamp(1, 8),
|
|
baseDirectory: task.baseDirectory,
|
|
directory: task.directory,
|
|
group: task.group,
|
|
notificationConfig: task.notificationConfig,
|
|
updates: task.updates,
|
|
requiresWiFi: task.requiresWiFi,
|
|
allowPause: true,
|
|
retries: task.retries,
|
|
displayName: task.displayName,
|
|
metaData: task.metaData,
|
|
);
|
|
}
|
|
|
|
/// Rebuild a failed task without requiring a broken multipart server feature.
|
|
/// Identity, original network/notification policy and source headers survive.
|
|
DownloadTask createRetryDownloadTask(
|
|
DownloadEntry entry, {
|
|
required String id,
|
|
bool supportsParallel = false,
|
|
}) {
|
|
if (entry.task is! DownloadTask) {
|
|
throw UnsupportedError('This task is not downloadable.');
|
|
}
|
|
final original = entry.task is ParallelDownloadTask && !supportsParallel
|
|
? DownloadTask.fromJson({
|
|
...entry.task.toJson(),
|
|
'taskType': 'DownloadTask',
|
|
})
|
|
: entry.task as DownloadTask;
|
|
return original.copyWith(
|
|
taskId: id,
|
|
retriesRemaining: original.retries,
|
|
filename: downloadFilename(
|
|
title: entry.title,
|
|
identity: id,
|
|
url: entry.url,
|
|
),
|
|
notificationConfig: downloadNotificationConfig(
|
|
entry.downloadNotifications ?? false,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> retireExcludedDownload(
|
|
TaskRecord record, {
|
|
required Future<bool> Function(String) cancel,
|
|
}) async {
|
|
final entry = DownloadEntry.fromTask(record.task, taskStatus: record.status);
|
|
if (!entry.visible && (entry.isActive || entry.canResume)) {
|
|
// Cancel suppresses stale OS notifications; no files or records are deleted.
|
|
if (!await cancel(entry.id)) {
|
|
throw StateError('Could not stop an excluded legacy download.');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> replaceFailedDownload(
|
|
Task old,
|
|
DownloadTask replacement, {
|
|
required Future<bool> Function(DownloadTask) enqueue,
|
|
required Future<void> Function(String) deleteRecord,
|
|
}) async {
|
|
if (!await enqueue(replacement)) {
|
|
throw StateError(
|
|
'Android rejected the replacement download; the old record was retained.',
|
|
);
|
|
}
|
|
await deleteRecord(old.taskId);
|
|
}
|
|
|
|
/// Native WorkManager/UIDT jobs with the plugin's durable task database.
|
|
/// No database auto-cleanup: completed downloads remain in the offline library.
|
|
class DownloadService extends ChangeNotifier {
|
|
DownloadService._();
|
|
static final DownloadService instance = DownloadService._();
|
|
static const group = 'reelbox.video';
|
|
static const packageGroup = 'reelbox.offline.packages';
|
|
final Map<String, DownloadEntry> _entries = {};
|
|
final Set<String> _removing = {};
|
|
Future<void>? _initialization;
|
|
StreamSubscription<TaskRecord>? _databaseSubscription;
|
|
AppState? _state;
|
|
bool _ready = false;
|
|
bool get supported =>
|
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
|
String? initializationError;
|
|
List<DownloadEntry> get items {
|
|
// Hide excluded legacy records without deleting the user's actual files.
|
|
final result = _entries.values.where((e) => e.visible).toList()
|
|
..sort((a, b) => b.task.creationTime.compareTo(a.task.creationTime));
|
|
return List.unmodifiable(result);
|
|
}
|
|
|
|
FileDownloader get _downloader => FileDownloader();
|
|
|
|
/// Optional live settings binding; otherwise read durable AppState settings
|
|
/// before each enqueue so the public enqueue signature needs no context.
|
|
void bindSettings(AppState state) {
|
|
if (identical(_state, state)) return;
|
|
_state?.removeListener(_settingsChanged);
|
|
_state = state;
|
|
state.addListener(_settingsChanged);
|
|
_settingsChanged();
|
|
}
|
|
|
|
void _settingsChanged() {
|
|
if (_state != null) {
|
|
configureNotifications(_state!.prefs.downloadNotifications);
|
|
}
|
|
if (_ready) {
|
|
unawaited(
|
|
_applyNetworkPolicy().catchError((Object e) {
|
|
initializationError = 'Could not apply download network settings.';
|
|
notifyListeners();
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<AppSettings> _preferences() async {
|
|
if (_state != null) return _state!.prefs;
|
|
final storage = await SharedPreferences.getInstance();
|
|
final raw = storage.getString(AppState.storageKey);
|
|
if (raw == null) return AppSettings();
|
|
final library = jsonDecode(raw) as Map<String, dynamic>;
|
|
return AppSettings.fromJson(
|
|
Map<String, dynamic>.from(library['settings'] as Map),
|
|
);
|
|
}
|
|
|
|
Future<void> _applyNetworkPolicy() async {
|
|
final prefs = await _preferences();
|
|
final ok = await _downloader.requireWiFi(
|
|
prefs.wifiOnlyDownloads
|
|
? RequireWiFi.forAllTasks
|
|
: RequireWiFi.forNoTasks,
|
|
);
|
|
if (!ok) {
|
|
throw StateError(
|
|
'The OS could not update the download network constraint.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> initialize() {
|
|
if (!supported) {
|
|
initializationError =
|
|
'Background video downloads are available on Android only.';
|
|
return Future.value();
|
|
}
|
|
return _initialization ??= _initialize().catchError((
|
|
Object error,
|
|
StackTrace stack,
|
|
) {
|
|
_initialization = null;
|
|
initializationError =
|
|
'Native download initialization failed. Retry on Android.';
|
|
notifyListeners();
|
|
Error.throwWithStackTrace(error, stack);
|
|
});
|
|
}
|
|
|
|
Future<void> _initialize() async {
|
|
// Group callbacks avoid stealing the plugin's single-subscription stream.
|
|
_downloader.registerCallbacks(
|
|
group: group,
|
|
taskStatusCallback: _status,
|
|
taskProgressCallback: _progress,
|
|
);
|
|
_databaseSubscription ??= _downloader.database.updates.listen((record) {
|
|
if (record.task.group == group) unawaited(_acceptRecord(record));
|
|
});
|
|
for (final record in await _downloader.database.allRecords(group: group)) {
|
|
await retireExcludedDownload(
|
|
record,
|
|
cancel: _downloader.cancelTaskWithId,
|
|
);
|
|
}
|
|
await _downloader.start(autoCleanDatabase: false);
|
|
await _applyNetworkPolicy();
|
|
for (final record in await _downloader.database.allRecords(group: group)) {
|
|
await _acceptRecord(record);
|
|
}
|
|
for (final package in await files.recoverPackages()) {
|
|
_acceptPackage(package);
|
|
}
|
|
configureNotifications((await _preferences()).downloadNotifications);
|
|
_ready = true;
|
|
initializationError = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
bool _notificationsEnabled = true;
|
|
TaskNotificationConfig? get notificationConfigForNewTask =>
|
|
downloadNotificationConfig(_notificationsEnabled);
|
|
|
|
/// Task-scoped configuration prevents stale group templates from leaking into
|
|
/// new downloads after the preference is disabled. Active OS jobs keep their
|
|
/// original notification policy until they finish.
|
|
void configureNotifications(bool enabled) {
|
|
_notificationsEnabled = enabled;
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
if (!supported) {
|
|
throw UnsupportedError('Download refresh requires Android.');
|
|
}
|
|
await initialize();
|
|
final before = Map<String, DownloadEntry>.of(_entries);
|
|
final snapshot = await readDownloadEntries(
|
|
() => _downloader.database.allRecords(group: group),
|
|
);
|
|
final ids = snapshot.map((entry) => entry.id).toSet();
|
|
for (final entry in snapshot) {
|
|
// A native callback that arrived during I/O is newer than this snapshot.
|
|
if (_removing.contains(entry.id) ||
|
|
!identical(_entries[entry.id], before[entry.id])) {
|
|
continue;
|
|
}
|
|
_entries[entry.id] = entry;
|
|
}
|
|
_entries.removeWhere(
|
|
(id, entry) =>
|
|
!entry.isSegmented &&
|
|
!ids.contains(id) &&
|
|
identical(entry, before[id]) &&
|
|
!entry.isActive,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _acceptRecord(TaskRecord record) async {
|
|
if (_removing.contains(record.taskId)) return;
|
|
await retireExcludedDownload(record, cancel: _downloader.cancelTaskWithId);
|
|
final previous = _entries[record.taskId];
|
|
final path = await record.task.filePath();
|
|
if (_removing.contains(record.taskId)) return;
|
|
// A progress callback may have overtaken asynchronous path resolution.
|
|
final latest = _entries[record.taskId];
|
|
if (latest != null && !identical(latest, previous)) return;
|
|
_entries[record.taskId] = DownloadEntry.fromTask(
|
|
record.task,
|
|
taskStatus: record.status,
|
|
progress: record.progress,
|
|
totalBytes: record.expectedFileSize,
|
|
path: path,
|
|
speed: previous?.speed ?? 0,
|
|
error: record.exception?.description ?? '',
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _status(TaskStatusUpdate update) {
|
|
if (_removing.contains(update.task.taskId)) return;
|
|
final old = _entries[update.task.taskId];
|
|
_entries[update.task.taskId] = DownloadEntry.fromTask(
|
|
update.task,
|
|
taskStatus: update.status,
|
|
progress: old?.progress ?? 0,
|
|
totalBytes: old?.totalBytes ?? 0,
|
|
path: old?.path ?? '',
|
|
error: update.exception?.description ?? '',
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _progress(TaskProgressUpdate update) {
|
|
if (_removing.contains(update.task.taskId)) return;
|
|
final old = _entries[update.task.taskId];
|
|
_entries[update.task.taskId] = DownloadEntry.fromTask(
|
|
update.task,
|
|
taskStatus: old?.taskStatus ?? TaskStatus.running,
|
|
progress: update.progress >= 0 ? update.progress : old?.progress ?? 0,
|
|
totalBytes: update.expectedFileSize > 0
|
|
? update.expectedFileSize
|
|
: old?.totalBytes ?? 0,
|
|
path: old?.path ?? '',
|
|
speed: update.hasNetworkSpeed ? update.networkSpeed * 1000000 : 0,
|
|
error: old?.error ?? '',
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> enqueue({
|
|
required TitleRecord title,
|
|
required MediaSource source,
|
|
EpisodeRecord? episode,
|
|
}) async {
|
|
if (!supported) {
|
|
throw UnsupportedError('Background downloads require the Android app.');
|
|
}
|
|
if (title.id <= 0 ||
|
|
title.title.trim().isEmpty ||
|
|
!ContentPolicy.allowsTitleJson(title.toJson()) ||
|
|
!ContentPolicy.allowsText(episode?.title ?? '') ||
|
|
!ContentPolicy.allowsUrl(source.url)) {
|
|
throw UnsupportedError(
|
|
'This title or destination is excluded from Reelbox.',
|
|
);
|
|
}
|
|
if (!isRemoteMedia(source.url) ||
|
|
RegExp(
|
|
r'drm|widevine|playready|fairplay|protected',
|
|
caseSensitive: false,
|
|
).hasMatch(source.type)) {
|
|
throw UnsupportedError(
|
|
'Only authorized, non-DRM HTTP(S) media can be downloaded.',
|
|
);
|
|
}
|
|
await initialize();
|
|
await _applyNetworkPolicy();
|
|
if (_entries.values.any(
|
|
(entry) =>
|
|
entry.url == source.url &&
|
|
(entry.isActive || entry.canResume || entry.isComplete),
|
|
)) {
|
|
throw StateError('This source is already in Downloads.');
|
|
}
|
|
final prefs = await _preferences();
|
|
configureNotifications(prefs.downloadNotifications);
|
|
if (prefs.downloadNotifications &&
|
|
await _downloader.permissions.status(PermissionType.notifications) !=
|
|
PermissionStatus.granted) {
|
|
await _downloader.permissions.request(PermissionType.notifications);
|
|
}
|
|
final id =
|
|
'${title.id}_${episode?.id ?? 0}_${DateTime.now().microsecondsSinceEpoch}';
|
|
final task = createDirectDownloadTask(
|
|
id: id,
|
|
title: title,
|
|
source: source,
|
|
preferences: prefs,
|
|
episode: episode,
|
|
supportsParallel:
|
|
!_segmentedSource(source) &&
|
|
prefs.parallelDownloads > 1 &&
|
|
await files.supportsParallelDownload(source.url),
|
|
);
|
|
if (_segmentedSource(source)) {
|
|
if (prefs.wifiOnlyDownloads && !_downloader.isWiFi) {
|
|
throw StateError(
|
|
'Connect to Wi-Fi before preparing an offline package.',
|
|
);
|
|
}
|
|
final snapshot = await files.preparePackage(
|
|
id: id,
|
|
url: source.url,
|
|
type: source.type,
|
|
parallel: prefs.parallelDownloads,
|
|
metadata: {
|
|
'task': task.copyWith(group: packageGroup).toJson(),
|
|
'notifications': prefs.downloadNotifications,
|
|
},
|
|
);
|
|
_acceptPackage(snapshot);
|
|
_startPackage(id);
|
|
return;
|
|
}
|
|
final enqueued = await _downloader.enqueue(task);
|
|
if (!enqueued) {
|
|
throw StateError('Android rejected the background download job.');
|
|
}
|
|
// Read back the exact persistent task instead of calling queued success complete.
|
|
final record = await _downloader.database.recordForId(task.taskId);
|
|
if (record != null) {
|
|
await _acceptRecord(record);
|
|
} else {
|
|
_entries.putIfAbsent(id, () => DownloadEntry.fromTask(task));
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
static bool _segmentedSource(MediaSource source) =>
|
|
isSegmentedMediaSource(source);
|
|
final _packageRuns = <String>{};
|
|
void _acceptPackage(Map<String, dynamic> snapshot) {
|
|
final metadata = Map<String, dynamic>.from(snapshot['metadata'] as Map);
|
|
final original = Task.createFromJson(
|
|
Map<String, dynamic>.from(metadata['task'] as Map),
|
|
);
|
|
final task = original.copyWith(filename: snapshot['manifest'] as String);
|
|
if (_removing.contains(task.taskId)) return;
|
|
final status = TaskStatus.values.firstWhere(
|
|
(s) => s.name == snapshot['status'],
|
|
orElse: () => TaskStatus.failed,
|
|
);
|
|
final bytes = (snapshot['receivedBytes'] as num?)?.toInt() ?? 0;
|
|
_entries[task.taskId] = DownloadEntry.fromTask(
|
|
task,
|
|
taskStatus: status,
|
|
progress: (snapshot['progress'] as num?)?.toDouble() ?? 0,
|
|
receivedBytes: bytes,
|
|
totalBytes: status == TaskStatus.complete ? bytes : 0,
|
|
path: snapshot['path'] as String,
|
|
error: snapshot['error'] as String? ?? '',
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _startPackage(String id) {
|
|
if (!_packageRuns.add(id)) {
|
|
throw StateError('This package is already active.');
|
|
}
|
|
unawaited(
|
|
files
|
|
.runPackage(id, _acceptPackage)
|
|
.catchError((Object e) {
|
|
final previous = _entries[id];
|
|
if (previous != null && !_removing.contains(id)) {
|
|
_entries[id] = DownloadEntry.fromTask(
|
|
previous.task,
|
|
taskStatus: TaskStatus.failed,
|
|
path: previous.path,
|
|
progress: previous.progress,
|
|
receivedBytes: previous.receivedBytes,
|
|
error: 'Offline package could not start: $e',
|
|
);
|
|
notifyListeners();
|
|
}
|
|
})
|
|
.whenComplete(() => _packageRuns.remove(id)),
|
|
);
|
|
}
|
|
|
|
/// Retries preserve the original URL/type, identity and notification policy.
|
|
/// The old failed RECORD is removed only after successful new native enqueue;
|
|
/// legacy user files are not removed as a side effect of replacing a record.
|
|
Future<void> retry(DownloadEntry entry) async {
|
|
await initialize();
|
|
if (!entry.visible || entry.isActive || entry.canResume) {
|
|
throw StateError('This download cannot be retried now.');
|
|
}
|
|
if (entry.isSegmented) {
|
|
_startPackage(entry.id);
|
|
return;
|
|
}
|
|
if (entry.task is! DownloadTask) {
|
|
throw UnsupportedError('This task is not downloadable.');
|
|
}
|
|
final source = MediaSource(
|
|
url: entry.url,
|
|
quality: entry.quality,
|
|
type: entry.sourceType,
|
|
);
|
|
if (_segmentedSource(source)) {
|
|
await enqueue(
|
|
title: entry.restoredTitle,
|
|
source: source,
|
|
episode: entry.episodeId == null
|
|
? null
|
|
: EpisodeRecord(id: entry.episodeId!, title: entry.episodeTitle),
|
|
);
|
|
await _downloader.database.deleteRecordWithId(entry.id);
|
|
} else {
|
|
final id =
|
|
'${entry.titleId}_${entry.episodeId ?? 0}_${DateTime.now().microsecondsSinceEpoch}';
|
|
// A failed multipart attempt must not loop on the same server feature,
|
|
// even if HEAD incorrectly advertised ranges. Retry as one normal GET.
|
|
final next = createRetryDownloadTask(entry, id: id);
|
|
await replaceFailedDownload(
|
|
entry.task,
|
|
next,
|
|
enqueue: _downloader.enqueue,
|
|
deleteRecord: (id) async {
|
|
await _downloader.database.deleteRecordWithId(id);
|
|
if (await _downloader.database.recordForId(id) != null) {
|
|
throw StateError('Could not replace the old download record.');
|
|
}
|
|
},
|
|
);
|
|
final record = await _downloader.database.recordForId(id);
|
|
if (record != null) {
|
|
await _acceptRecord(record);
|
|
} else {
|
|
_entries.putIfAbsent(id, () => DownloadEntry.fromTask(next));
|
|
}
|
|
}
|
|
_entries.remove(entry.id);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> reveal(DownloadEntry entry) async {
|
|
final local = await offlinePath(entry);
|
|
final path = entry.isSegmented
|
|
? await files.exportPackage(entry.id)
|
|
: local;
|
|
if (await MediaPlatform.invoke<bool>('revealFile', {'path': path}) !=
|
|
true) {
|
|
throw StateError(
|
|
'No application could reveal or export this offline media.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> externalDownloader(
|
|
DownloadEntry entry, {
|
|
String packageName = 'ask',
|
|
}) async {
|
|
if (!entry.visible || !isRemoteMedia(entry.url)) {
|
|
throw UnsupportedError('This media cannot be handed off.');
|
|
}
|
|
if (_segmentedSource(MediaSource(url: entry.url, type: entry.sourceType))) {
|
|
throw UnsupportedError(
|
|
'Export a complete offline package instead; external downloaders may save only the manifest.',
|
|
);
|
|
}
|
|
if (await MediaPlatform.invoke<bool>('openDownloader', {
|
|
'url': entry.url,
|
|
'filename': entry.filename,
|
|
'packageName': packageName,
|
|
}) !=
|
|
true) {
|
|
throw StateError('No external downloader could accept this file.');
|
|
}
|
|
}
|
|
|
|
Future<void> pause(DownloadEntry entry) async {
|
|
await initialize();
|
|
if (entry.isSegmented) {
|
|
await files.pausePackage(entry.id);
|
|
_acceptPackage(await files.loadPackage(entry.id));
|
|
return;
|
|
}
|
|
if (entry.task is! DownloadTask ||
|
|
!await _downloader.pause(entry.task as DownloadTask)) {
|
|
throw StateError(
|
|
'Pause is unavailable. This server may not support byte ranges.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> resume(DownloadEntry entry) async {
|
|
await initialize();
|
|
if (!entry.visible) {
|
|
throw UnsupportedError('This legacy title is excluded from Reelbox.');
|
|
}
|
|
if (entry.isSegmented) {
|
|
_startPackage(entry.id);
|
|
return;
|
|
}
|
|
if (!await resumeDownloadTask(
|
|
entry,
|
|
configure: (task, config) => _downloader.configureNotificationForTask(
|
|
task,
|
|
running: config.running,
|
|
complete: config.complete,
|
|
error: config.error,
|
|
paused: config.paused,
|
|
progressBar: config.progressBar,
|
|
tapOpensFile: config.tapOpensFile,
|
|
),
|
|
resume: _downloader.resume,
|
|
)) {
|
|
throw StateError(
|
|
'Cannot resume this task; remove it and start a new download.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> cancel(DownloadEntry entry) async {
|
|
await initialize();
|
|
if (entry.isSegmented) {
|
|
await files.pausePackage(entry.id, cancel: true);
|
|
_acceptPackage(await files.loadPackage(entry.id));
|
|
return;
|
|
}
|
|
if (!await _downloader.cancelTaskWithId(entry.id)) {
|
|
throw StateError('The OS could not cancel this download.');
|
|
}
|
|
}
|
|
|
|
Future<void> remove(DownloadEntry entry) async {
|
|
await initialize();
|
|
if (entry.isActive || entry.canResume) await cancel(entry);
|
|
_removing.add(entry.id);
|
|
try {
|
|
if (entry.isSegmented) {
|
|
await files.deletePackage(entry.id);
|
|
_entries.remove(entry.id);
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
final path = await entry.task.filePath();
|
|
await files.delete(path);
|
|
await _downloader.database.deleteRecordWithId(entry.id);
|
|
if (await _downloader.database.recordForId(entry.id) != null) {
|
|
throw StateError('Could not remove download record.');
|
|
}
|
|
_entries.remove(entry.id);
|
|
notifyListeners();
|
|
} finally {
|
|
_removing.remove(entry.id);
|
|
}
|
|
}
|
|
|
|
Future<String> offlinePath(DownloadEntry entry) async {
|
|
if (!entry.visible) {
|
|
throw UnsupportedError('This legacy title is excluded from Reelbox.');
|
|
}
|
|
if (!entry.isComplete) throw StateError('The download is not complete.');
|
|
if (entry.isSegmented) {
|
|
if (!await files.validatePackage(entry.id)) {
|
|
throw StateError(
|
|
'Offline package files are missing or damaged. Retry to repair.',
|
|
);
|
|
}
|
|
return (await files.loadPackage(entry.id))['path'] as String;
|
|
}
|
|
final path = await entry.task.filePath();
|
|
if (!await files.exists(path) ||
|
|
entry.totalBytes > 0 && await files.size(path) != entry.totalBytes) {
|
|
throw StateError(
|
|
'The downloaded file is missing. Remove the entry and download again.',
|
|
);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
Future<void> open(DownloadEntry entry) async {
|
|
final path = await offlinePath(entry);
|
|
if (!await _downloader.openFile(filePath: path, mimeType: 'video/*')) {
|
|
throw StateError(
|
|
'No installed application could open this video. Use Play offline in Reelbox.',
|
|
);
|
|
}
|
|
}
|
|
}
|