mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
download & view local comics
This commit is contained in:
@@ -1,14 +1,27 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
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 {
|
||||
int get current;
|
||||
/// 0-1
|
||||
double get progress;
|
||||
|
||||
int get total;
|
||||
bool get isComplete;
|
||||
|
||||
double get progress => current / total;
|
||||
bool get isError;
|
||||
|
||||
bool get isComplete => current == total;
|
||||
bool get isPaused;
|
||||
|
||||
/// bytes per second
|
||||
int get speed;
|
||||
|
||||
void cancel();
|
||||
@@ -20,4 +33,549 @@ abstract class DownloadTask with ChangeNotifier {
|
||||
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();
|
||||
} 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 => 5;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void resume() async {
|
||||
if (_isRunning) return;
|
||||
_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);
|
||||
}
|
||||
|
||||
@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>>[];
|
||||
|
||||
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) {
|
||||
Log.error("Download", e.toString(), s);
|
||||
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;
|
||||
_bytesSinceLastSecond += length;
|
||||
}
|
||||
|
||||
void onNextSecond(Timer t) {
|
||||
_currentSpeed = _bytesSinceLastSecond;
|
||||
_bytesSinceLastSecond = 0;
|
||||
}
|
||||
|
||||
void runRecorder() {
|
||||
if (timer != null) {
|
||||
timer!.cancel();
|
||||
}
|
||||
timer = Timer.periodic(const Duration(seconds: 1), onNextSecond);
|
||||
}
|
||||
|
||||
void stopRecorder() {
|
||||
timer?.cancel();
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user