Files
venera/lib/network/download.dart
2024-11-13 12:21:57 +08:00

603 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/images.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart';
abstract class DownloadTask with ChangeNotifier {
/// 0-1
double get progress;
bool get isComplete;
bool get isError;
bool get isPaused;
/// bytes per second
int get speed;
void cancel();
void pause();
void resume();
String get title;
String? get cover;
String get message;
/// root path for the comic. If null, the task is not scheduled.
String? path;
/// convert current state to json, which can be used to restore the task
Map<String, dynamic> toJson();
LocalComic toLocalComic();
String get id;
ComicType get comicType;
static DownloadTask? fromJson(Map<String, dynamic> json) {
switch (json["type"]) {
case "ImagesDownloadTask":
return ImagesDownloadTask.fromJson(json);
default:
return null;
}
}
}
class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
final ComicSource source;
final String comicId;
/// comic details. If null, the comic details will be fetched from the source.
ComicDetails? comic;
/// chapters to download. If null, all chapters will be downloaded.
final List<String>? chapters;
@override
String get id => comicId;
@override
ComicType get comicType => ComicType(source.key.hashCode);
ImagesDownloadTask({
required this.source,
required this.comicId,
this.comic,
this.chapters,
});
@override
void cancel() {
_isRunning = false;
LocalManager().removeTask(this);
var local = LocalManager().find(id, comicType);
if (path != null) {
if (local == null) {
Directory(path!).deleteIgnoreError(recursive: true);
} else if (chapters != null) {
for (var c in chapters!) {
var dir = Directory(FilePath.join(path!, c));
if (dir.existsSync()) {
dir.deleteSync(recursive: true);
}
}
}
}
}
@override
String? get cover => _cover;
@override
bool get isComplete => _totalCount == _downloadedCount;
@override
String get message => _message;
@override
void pause() {
if (isPaused) {
return;
}
_isRunning = false;
_message = "Paused";
_currentSpeed = 0;
var shouldMove = <int>[];
for (var entry in tasks.entries) {
if (!entry.value.isComplete) {
entry.value.cancel();
shouldMove.add(entry.key);
}
}
for (var i in shouldMove) {
tasks.remove(i);
}
stopRecorder();
notifyListeners();
}
@override
double get progress => _totalCount == 0 ? 0 : _downloadedCount / _totalCount;
bool _isRunning = false;
bool _isError = false;
String _message = "Fetching comic info...";
String? _cover;
Map<String, List<String>>? _images;
int _downloadedCount = 0;
int _totalCount = 0;
int _index = 0;
int _chapter = 0;
var tasks = <int, _ImageDownloadWrapper>{};
int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt();
void _scheduleTasks() {
var images = _images![_images!.keys.elementAt(_chapter)]!;
var downloading = 0;
for (var i = _index; i < images.length; i++) {
if (downloading >= _maxConcurrentTasks) {
return;
}
if (tasks[i] != null) {
if (!tasks[i]!.isComplete) {
downloading++;
}
if (tasks[i]!.error == null) {
continue;
}
}
Directory saveTo;
if (comic!.chapters != null) {
saveTo = Directory(FilePath.join(
path!,
comic!.chapters!.keys.elementAt(_chapter),
));
if (!saveTo.existsSync()) {
saveTo.createSync();
}
} else {
saveTo = Directory(path!);
}
var task = _ImageDownloadWrapper(
this,
_images!.keys.elementAt(_chapter),
images[i],
saveTo,
i,
);
tasks[i] = task;
task.wait().then((task) {
if (task.isComplete) {
_scheduleTasks();
}
});
downloading++;
}
}
@override
void resume() async {
if (_isRunning) return;
_isError = false;
_message = "Resuming...";
_isRunning = true;
notifyListeners();
runRecorder();
if (comic == null) {
var res = await runWithRetry(() async {
var r = await source.loadComicInfo!(comicId);
if (r.error) {
throw r.errorMessage!;
} else {
return r.data;
}
});
if (!_isRunning) {
return;
}
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
comic = res.data;
}
}
if (path == null) {
var dir = await LocalManager().findValidDirectory(
comicId,
comicType,
comic!.title,
);
if (!(await dir.exists())) {
try {
await dir.create();
} catch (e) {
_setError("Error: $e");
return;
}
}
path = dir.path;
}
await LocalManager().saveCurrentDownloadingTasks();
if (cover == null) {
var res = await runWithRetry(() async {
Uint8List? data;
await for (var progress
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
if (progress.imageBytes != null) {
data = progress.imageBytes;
}
}
if (data == null) {
throw "Failed to download cover";
}
var fileType = detectFileType(data);
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data);
return file.path;
});
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
_cover = res.data;
notifyListeners();
}
await LocalManager().saveCurrentDownloadingTasks();
}
if (_images == null) {
if (comic!.chapters == null) {
var res = await runWithRetry(() async {
var r = await source.loadComicPages!(comicId, null);
if (r.error) {
throw r.errorMessage!;
} else {
return r.data;
}
});
if (!_isRunning) {
return;
}
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
_images = {'': res.data};
_totalCount = _images!['']!.length;
}
} else {
_images = {};
_totalCount = 0;
for (var i in comic!.chapters!.keys) {
if (chapters != null && !chapters!.contains(i)) {
continue;
}
if (_images![i] != null) {
_totalCount += _images![i]!.length;
continue;
}
var res = await runWithRetry(() async {
var r = await source.loadComicPages!(comicId, i);
if (r.error) {
throw r.errorMessage!;
} else {
return r.data;
}
});
if (!_isRunning) {
return;
}
if (res.error) {
_setError("Error: ${res.errorMessage}");
return;
} else {
_images![i] = res.data;
_totalCount += _images![i]!.length;
}
}
}
_message = "$_downloadedCount/$_totalCount";
notifyListeners();
await LocalManager().saveCurrentDownloadingTasks();
}
while (_chapter < _images!.length) {
var images = _images![_images!.keys.elementAt(_chapter)]!;
tasks.clear();
while (_index < images.length) {
_scheduleTasks();
var task = tasks[_index]!;
await task.wait();
if (isPaused) {
return;
}
if (task.error != null) {
_setError("Error: ${task.error}");
return;
}
_index++;
_downloadedCount++;
_message = "$_downloadedCount/$_totalCount";
await LocalManager().saveCurrentDownloadingTasks();
}
_index = 0;
_chapter++;
}
LocalManager().completeTask(this);
stopRecorder();
}
@override
void onNextSecond(Timer t) {
notifyListeners();
super.onNextSecond(t);
}
void _setError(String message) {
_isRunning = false;
_isError = true;
_message = message;
notifyListeners();
stopRecorder();
Log.error("Download", message);
}
@override
int get speed => currentSpeed;
@override
String get title => comic?.title ?? "Loading...";
@override
Map<String, dynamic> toJson() {
return {
"type": "ImagesDownloadTask",
"source": source.key,
"comicId": comicId,
"comic": comic?.toJson(),
"chapters": chapters,
"path": path,
"cover": cover,
"images": _images,
"downloadedCount": _downloadedCount,
"totalCount": _totalCount,
"index": _index,
"chapter": _chapter,
};
}
static ImagesDownloadTask? fromJson(Map<String, dynamic> json) {
if (json["type"] != "ImagesDownloadTask") {
return null;
}
Map<String, List<String>>? images;
if (json["images"] != null) {
images = {};
for (var entry in json["images"].entries) {
images[entry.key] = List<String>.from(entry.value);
}
}
return ImagesDownloadTask(
source: ComicSource.find(json["source"])!,
comicId: json["comicId"],
comic:
json["comic"] == null ? null : ComicDetails.fromJson(json["comic"]),
chapters: ListOrNull.from(json["chapters"]),
)
..path = json["path"]
.._cover = json["cover"]
.._images = images
.._downloadedCount = json["downloadedCount"]
.._totalCount = json["totalCount"]
.._index = json["index"]
.._chapter = json["chapter"];
}
@override
bool get isError => _isError;
@override
bool get isPaused => !_isRunning;
@override
LocalComic toLocalComic() {
return LocalComic(
id: comic!.id,
title: title,
subtitle: comic!.subTitle ?? '',
tags: comic!.tags.entries.expand((e) {
return e.value.map((v) => "${e.key}:$v");
}).toList(),
directory: Directory(path!).name,
chapters: comic!.chapters,
cover: File(_cover!).uri.pathSegments.last,
comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [],
createdAt: DateTime.now(),
);
}
@override
bool operator ==(Object other) {
if (other is ImagesDownloadTask) {
return other.comicId == comicId && other.source.key == source.key;
}
return false;
}
@override
int get hashCode => Object.hash(comicId, source.key);
}
Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
{int retry = 3}) async {
for (var i = 0; i < retry; i++) {
try {
return Res(await task());
} catch (e) {
if (i == retry - 1) {
return Res.error(e.toString());
}
}
}
throw UnimplementedError();
}
class _ImageDownloadWrapper {
final ImagesDownloadTask task;
final String chapter;
final int index;
final String image;
final Directory saveTo;
_ImageDownloadWrapper(
this.task,
this.chapter,
this.image,
this.saveTo,
this.index,
) {
start();
}
bool isComplete = false;
String? error;
bool isCancelled = false;
void cancel() {
isCancelled = true;
}
var completers = <Completer<_ImageDownloadWrapper>>[];
var retry = 3;
void start() async {
int lastBytes = 0;
try {
await for (var p in ImageDownloader.loadComicImage(
image, task.source.key, task.comicId, chapter)) {
if (isCancelled) {
return;
}
task.onData(p.currentBytes - lastBytes);
lastBytes = p.currentBytes;
if (p.imageBytes != null) {
var fileType = detectFileType(p.imageBytes!);
var file = saveTo.joinFile("$index${fileType.ext}");
await file.writeAsBytes(p.imageBytes!);
isComplete = true;
for (var c in completers) {
c.complete(this);
}
completers.clear();
}
}
} catch (e, s) {
if (isCancelled) {
return;
}
Log.error("Download", e.toString(), s);
retry--;
if (retry > 0) {
start();
return;
}
error = e.toString();
for (var c in completers) {
if (!c.isCompleted) {
c.complete(this);
}
}
}
}
Future<_ImageDownloadWrapper> wait() {
if (isComplete) {
return Future.value(this);
}
var c = Completer<_ImageDownloadWrapper>();
completers.add(c);
return c.future;
}
}
abstract mixin class _TransferSpeedMixin {
int _bytesSinceLastSecond = 0;
int _currentSpeed = 0;
int get currentSpeed => _currentSpeed;
Timer? timer;
void onData(int length) {
if (timer == null) return;
if(length < 0) {
return;
}
_bytesSinceLastSecond += length;
}
void onNextSecond(Timer t) {
_currentSpeed = _bytesSinceLastSecond;
_bytesSinceLastSecond = 0;
}
void runRecorder() {
if (timer != null) {
timer!.cancel();
}
_bytesSinceLastSecond = 0;
timer = Timer.periodic(const Duration(seconds: 1), onNextSecond);
}
void stopRecorder() {
timer?.cancel();
timer = null;
_currentSpeed = 0;
_bytesSinceLastSecond = 0;
}
}