mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
add archive download
This commit is contained in:
@@ -154,8 +154,6 @@ class ComicTile extends StatelessWidget {
|
|||||||
ImageProvider image;
|
ImageProvider image;
|
||||||
if (comic is LocalComic) {
|
if (comic is LocalComic) {
|
||||||
image = FileImage((comic as LocalComic).coverFile);
|
image = FileImage((comic as LocalComic).coverFile);
|
||||||
} else if (comic.cover.startsWith('file://')) {
|
|
||||||
image = FileImage(File(comic.cover.substring(7)));
|
|
||||||
} else if (comic.sourceKey == 'local') {
|
} else if (comic.sourceKey == 'local') {
|
||||||
var localComic = LocalManager().find(comic.id, ComicType.local);
|
var localComic = LocalManager().find(comic.id, ComicType.local);
|
||||||
if (localComic == null) {
|
if (localComic == null) {
|
||||||
|
@@ -215,6 +215,8 @@ class ComicSource {
|
|||||||
|
|
||||||
final StarRatingFunc? starRatingFunc;
|
final StarRatingFunc? starRatingFunc;
|
||||||
|
|
||||||
|
final ArchiveDownloader? archiveDownloader;
|
||||||
|
|
||||||
Future<void> loadData() async {
|
Future<void> loadData() async {
|
||||||
var file = File("${App.dataPath}/comic_source/$key.data");
|
var file = File("${App.dataPath}/comic_source/$key.data");
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
@@ -284,6 +286,7 @@ class ComicSource {
|
|||||||
this.enableTagsSuggestions,
|
this.enableTagsSuggestions,
|
||||||
this.enableTagsTranslate,
|
this.enableTagsTranslate,
|
||||||
this.starRatingFunc,
|
this.starRatingFunc,
|
||||||
|
this.archiveDownloader,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,3 +468,11 @@ class LinkHandler {
|
|||||||
|
|
||||||
const LinkHandler(this.domains, this.linkToId);
|
const LinkHandler(this.domains, this.linkToId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ArchiveDownloader {
|
||||||
|
final Future<Res<List<ArchiveInfo>>> Function(String cid) getArchives;
|
||||||
|
|
||||||
|
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
|
||||||
|
|
||||||
|
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
|
||||||
|
}
|
@@ -232,3 +232,14 @@ class ComicDetails with HistoryMixin {
|
|||||||
|
|
||||||
ComicType get comicType => ComicType(sourceKey.hashCode);
|
ComicType get comicType => ComicType(sourceKey.hashCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ArchiveInfo {
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
ArchiveInfo.fromJson(Map<String, dynamic> json)
|
||||||
|
: title = json["title"],
|
||||||
|
description = json["description"],
|
||||||
|
id = json["id"];
|
||||||
|
}
|
@@ -153,11 +153,12 @@ class ComicSourceParser {
|
|||||||
_getValue("search.enableTagsSuggestions") ?? false,
|
_getValue("search.enableTagsSuggestions") ?? false,
|
||||||
_getValue("comic.enableTagsTranslate") ?? false,
|
_getValue("comic.enableTagsTranslate") ?? false,
|
||||||
_parseStarRatingFunc(),
|
_parseStarRatingFunc(),
|
||||||
|
_parseArchiveDownloader(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await source.loadData();
|
await source.loadData();
|
||||||
|
|
||||||
if(_checkExists("init")) {
|
if (_checkExists("init")) {
|
||||||
Future.delayed(const Duration(milliseconds: 50), () {
|
Future.delayed(const Duration(milliseconds: 50), () {
|
||||||
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
||||||
});
|
});
|
||||||
@@ -988,4 +989,35 @@ class ComicSourceParser {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ArchiveDownloader? _parseArchiveDownloader() {
|
||||||
|
if (!_checkExists("comic.archive")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ArchiveDownloader(
|
||||||
|
(cid) async {
|
||||||
|
try {
|
||||||
|
var res = await JsEngine().runCode("""
|
||||||
|
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
|
||||||
|
""");
|
||||||
|
return Res(
|
||||||
|
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
|
||||||
|
} catch (e, s) {
|
||||||
|
Log.error("Network", "$e\n$s");
|
||||||
|
return Res.error(e.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(cid, aid) async {
|
||||||
|
try {
|
||||||
|
var res = await JsEngine().runCode("""
|
||||||
|
ComicSource.sources.$_key.comic.archive.getDownloadUrl(${jsonEncode(cid)}, ${jsonEncode(aid)})
|
||||||
|
""");
|
||||||
|
return Res(res as String);
|
||||||
|
} catch (e, s) {
|
||||||
|
Log.error("Network", "$e\n$s");
|
||||||
|
return Res.error(e.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async' show Future, StreamController;
|
import 'dart:async' show Future, StreamController;
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:venera/network/images.dart';
|
import 'package:venera/network/images.dart';
|
||||||
@@ -8,6 +9,8 @@ import 'cached_image.dart' as image_provider;
|
|||||||
class CachedImageProvider
|
class CachedImageProvider
|
||||||
extends BaseImageProvider<image_provider.CachedImageProvider> {
|
extends BaseImageProvider<image_provider.CachedImageProvider> {
|
||||||
/// Image provider for normal image.
|
/// Image provider for normal image.
|
||||||
|
///
|
||||||
|
/// [url] is the url of the image. Local file path is also supported.
|
||||||
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
|
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
|
||||||
|
|
||||||
final String url;
|
final String url;
|
||||||
@@ -20,6 +23,10 @@ class CachedImageProvider
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||||
|
if(url.startsWith("file://")) {
|
||||||
|
var file = File(url.substring(7));
|
||||||
|
return file.readAsBytes();
|
||||||
|
}
|
||||||
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
|
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
|
||||||
chunkEvents.add(ImageChunkEvent(
|
chunkEvents.add(ImageChunkEvent(
|
||||||
cumulativeBytesLoaded: progress.currentBytes,
|
cumulativeBytesLoaded: progress.currentBytes,
|
||||||
|
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:dio/io.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:html/parser.dart' as html;
|
import 'package:html/parser.dart' as html;
|
||||||
import 'package:html/dom.dart' as dom;
|
import 'package:html/dom.dart' as dom;
|
||||||
@@ -184,7 +185,23 @@ class JsEngine with _JSEngineApi {
|
|||||||
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
|
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
|
||||||
headers["User-Agent"] = webUA;
|
headers["User-Agent"] = webUA;
|
||||||
}
|
}
|
||||||
response = await _dio!.request(req["url"],
|
var dio = _dio;
|
||||||
|
if (headers['http_client'] == "dart:io") {
|
||||||
|
dio = Dio(BaseOptions(
|
||||||
|
responseType: ResponseType.plain,
|
||||||
|
validateStatus: (status) => true,
|
||||||
|
));
|
||||||
|
var proxy = await AppDio.getProxy();
|
||||||
|
dio.httpClientAdapter = IOHttpClientAdapter(
|
||||||
|
createHttpClient: () {
|
||||||
|
return HttpClient()
|
||||||
|
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
dio.interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||||
|
dio.interceptors.add(LogInterceptor());
|
||||||
|
}
|
||||||
|
response = await dio!.request(req["url"],
|
||||||
data: req["data"],
|
data: req["data"],
|
||||||
options: Options(
|
options: Options(
|
||||||
method: req['http_method'],
|
method: req['http_method'],
|
||||||
|
@@ -97,6 +97,9 @@ class MyLogInterceptor implements Interceptor {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
Log.info("Network", "${options.method} ${options.uri}\n"
|
||||||
|
"headers:\n${options.headers}\n"
|
||||||
|
"data:\n${options.data}");
|
||||||
options.connectTimeout = const Duration(seconds: 15);
|
options.connectTimeout = const Duration(seconds: 15);
|
||||||
options.receiveTimeout = const Duration(seconds: 15);
|
options.receiveTimeout = const Duration(seconds: 15);
|
||||||
options.sendTimeout = const Duration(seconds: 15);
|
options.sendTimeout = const Duration(seconds: 15);
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||||
import 'package:venera/foundation/appdata.dart';
|
import 'package:venera/foundation/appdata.dart';
|
||||||
@@ -11,13 +12,14 @@ import 'package:venera/network/images.dart';
|
|||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
import 'package:venera/utils/file_type.dart';
|
import 'package:venera/utils/file_type.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
|
import 'package:zip_flutter/zip_flutter.dart';
|
||||||
|
|
||||||
|
import 'file_downloader.dart';
|
||||||
|
|
||||||
abstract class DownloadTask with ChangeNotifier {
|
abstract class DownloadTask with ChangeNotifier {
|
||||||
/// 0-1
|
/// 0-1
|
||||||
double get progress;
|
double get progress;
|
||||||
|
|
||||||
bool get isComplete;
|
|
||||||
|
|
||||||
bool get isError;
|
bool get isError;
|
||||||
|
|
||||||
bool get isPaused;
|
bool get isPaused;
|
||||||
@@ -106,10 +108,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get cover => _cover;
|
String? get cover => _cover ?? comic?.cover;
|
||||||
|
|
||||||
@override
|
|
||||||
bool get isComplete => _totalCount == _downloadedCount;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get message => _message;
|
String get message => _message;
|
||||||
@@ -159,7 +158,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
|||||||
|
|
||||||
var tasks = <int, _ImageDownloadWrapper>{};
|
var tasks = <int, _ImageDownloadWrapper>{};
|
||||||
|
|
||||||
int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt();
|
int get _maxConcurrentTasks =>
|
||||||
|
(appdata.settings["downloadThreads"] as num).toInt();
|
||||||
|
|
||||||
void _scheduleTasks() {
|
void _scheduleTasks() {
|
||||||
var images = _images![_images!.keys.elementAt(_chapter)]!;
|
var images = _images![_images!.keys.elementAt(_chapter)]!;
|
||||||
@@ -268,7 +268,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
|||||||
var fileType = detectFileType(data);
|
var fileType = detectFileType(data);
|
||||||
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
|
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
|
||||||
file.writeAsBytesSync(data);
|
file.writeAsBytesSync(data);
|
||||||
return file.path;
|
return "file://${file.path}";
|
||||||
});
|
});
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
_setError("Error: ${res.errorMessage}");
|
_setError("Error: ${res.errorMessage}");
|
||||||
@@ -382,7 +382,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
|||||||
int get speed => currentSpeed;
|
int get speed => currentSpeed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get title => comic?.title ?? comicTitle ?? "Loading...";
|
String get title => comic?.title ?? comicTitle ?? "Loading...";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
@@ -448,7 +448,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
directory: Directory(path!).name,
|
directory: Directory(path!).name,
|
||||||
chapters: comic!.chapters,
|
chapters: comic!.chapters,
|
||||||
cover: File(_cover!).uri.pathSegments.last,
|
cover: File(_cover!.split("file://").last).uri.pathSegments.last,
|
||||||
comicType: ComicType(source.key.hashCode),
|
comicType: ComicType(source.key.hashCode),
|
||||||
downloadedChapters: chapters ?? [],
|
downloadedChapters: chapters ?? [],
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
@@ -577,7 +577,7 @@ abstract mixin class _TransferSpeedMixin {
|
|||||||
|
|
||||||
void onData(int length) {
|
void onData(int length) {
|
||||||
if (timer == null) return;
|
if (timer == null) return;
|
||||||
if(length < 0) {
|
if (length < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_bytesSinceLastSecond += length;
|
_bytesSinceLastSecond += length;
|
||||||
@@ -603,3 +603,217 @@ abstract mixin class _TransferSpeedMixin {
|
|||||||
_bytesSinceLastSecond = 0;
|
_bytesSinceLastSecond = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ArchiveDownloadTask extends DownloadTask {
|
||||||
|
final String archiveUrl;
|
||||||
|
|
||||||
|
final ComicDetails comic;
|
||||||
|
|
||||||
|
late ComicSource source;
|
||||||
|
|
||||||
|
/// Download comic by archive url
|
||||||
|
///
|
||||||
|
/// Currently only support zip file and comics without chapters
|
||||||
|
ArchiveDownloadTask(this.archiveUrl, this.comic) {
|
||||||
|
source = ComicSource.find(comic.sourceKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDownloader? _downloader;
|
||||||
|
|
||||||
|
String _message = "Fetching comic info...";
|
||||||
|
|
||||||
|
bool _isRunning = false;
|
||||||
|
|
||||||
|
bool _isError = false;
|
||||||
|
|
||||||
|
void _setError(String message) {
|
||||||
|
_isRunning = false;
|
||||||
|
_isError = true;
|
||||||
|
_message = message;
|
||||||
|
notifyListeners();
|
||||||
|
Log.error("Download", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void cancel() async {
|
||||||
|
_isRunning = false;
|
||||||
|
await _downloader?.stop();
|
||||||
|
if (path != null) {
|
||||||
|
Directory(path!).deleteIgnoreError(recursive: true);
|
||||||
|
}
|
||||||
|
path = null;
|
||||||
|
LocalManager().removeTask(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ComicType get comicType => ComicType(source.key.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get cover => comic.cover;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => comic.id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isError => _isError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isPaused => !_isRunning;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get message => _message;
|
||||||
|
|
||||||
|
int _currentBytes = 0;
|
||||||
|
|
||||||
|
int _expectedBytes = 0;
|
||||||
|
|
||||||
|
int _speed = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pause() {
|
||||||
|
_isRunning = false;
|
||||||
|
_message = "Paused";
|
||||||
|
_downloader?.stop();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get progress =>
|
||||||
|
_expectedBytes == 0 ? 0 : _currentBytes / _expectedBytes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void resume() async {
|
||||||
|
if (_isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isError = false;
|
||||||
|
_isRunning = true;
|
||||||
|
notifyListeners();
|
||||||
|
_message = "Downloading...";
|
||||||
|
|
||||||
|
if (path == null) {
|
||||||
|
var dir = await LocalManager().findValidDirectory(
|
||||||
|
comic.id,
|
||||||
|
comicType,
|
||||||
|
comic.title,
|
||||||
|
);
|
||||||
|
if (!(await dir.exists())) {
|
||||||
|
try {
|
||||||
|
await dir.create();
|
||||||
|
} catch (e) {
|
||||||
|
_setError("Error: $e");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path = dir.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultFile = File(FilePath.join(path!, "archive.zip"));
|
||||||
|
|
||||||
|
Log.info("Download", "Downloading $archiveUrl");
|
||||||
|
|
||||||
|
_downloader = FileDownloader(archiveUrl, resultFile.path);
|
||||||
|
|
||||||
|
bool isDownloaded = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await for (var status in _downloader!.start()) {
|
||||||
|
_currentBytes = status.downloadedBytes;
|
||||||
|
_expectedBytes = status.totalBytes;
|
||||||
|
_message =
|
||||||
|
"${bytesToReadableString(_currentBytes)}/${bytesToReadableString(_expectedBytes)}";
|
||||||
|
_speed = status.bytesPerSecond;
|
||||||
|
isDownloaded = status.isFinished;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
_setError("Error: $e");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDownloaded) {
|
||||||
|
_setError("Error: Download failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await extractArchive(path!);
|
||||||
|
} catch (e) {
|
||||||
|
_setError("Failed to extract archive: $e");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await resultFile.deleteIgnoreError();
|
||||||
|
|
||||||
|
LocalManager().completeTask(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> extractArchive(String path) async {
|
||||||
|
var resultFile = FilePath.join(path, "archive.zip");
|
||||||
|
await Isolate.run(() {
|
||||||
|
ZipFile.openAndExtract(resultFile, path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get speed => _speed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get title => comic.title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"type": "ArchiveDownloadTask",
|
||||||
|
"archiveUrl": archiveUrl,
|
||||||
|
"comic": comic.toJson(),
|
||||||
|
"path": path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static ArchiveDownloadTask? fromJson(Map<String, dynamic> json) {
|
||||||
|
if (json["type"] != "ArchiveDownloadTask") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ArchiveDownloadTask(
|
||||||
|
json["archiveUrl"],
|
||||||
|
ComicDetails.fromJson(json["comic"]),
|
||||||
|
)..path = json["path"];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _findCover() {
|
||||||
|
var files = Directory(path!).listSync();
|
||||||
|
for (var f in files) {
|
||||||
|
if (f.name.startsWith('cover')) {
|
||||||
|
return f.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort((a, b) {
|
||||||
|
return a.name.compareTo(b.name);
|
||||||
|
});
|
||||||
|
return files.first.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: null,
|
||||||
|
cover: _findCover(),
|
||||||
|
comicType: ComicType(source.key.hashCode),
|
||||||
|
downloadedChapters: [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
298
lib/network/file_downloader.dart
Normal file
298
lib/network/file_downloader.dart
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/io.dart';
|
||||||
|
import 'package:venera/network/app_dio.dart';
|
||||||
|
import 'package:venera/utils/ext.dart';
|
||||||
|
|
||||||
|
class FileDownloader {
|
||||||
|
final String url;
|
||||||
|
final String savePath;
|
||||||
|
final int maxConcurrent;
|
||||||
|
|
||||||
|
FileDownloader(this.url, this.savePath, {this.maxConcurrent = 4});
|
||||||
|
|
||||||
|
int _currentBytes = 0;
|
||||||
|
|
||||||
|
int _lastBytes = 0;
|
||||||
|
|
||||||
|
late int _fileSize;
|
||||||
|
|
||||||
|
final _dio = Dio();
|
||||||
|
|
||||||
|
RandomAccessFile? _file;
|
||||||
|
|
||||||
|
bool _isWriting = false;
|
||||||
|
|
||||||
|
int _kChunkSize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
bool _canceled = false;
|
||||||
|
|
||||||
|
late List<_DownloadBlock> _blocks;
|
||||||
|
|
||||||
|
Future<void> _writeStatus() async {
|
||||||
|
var file = File("$savePath.download");
|
||||||
|
await file.writeAsString(_blocks.map((e) => e.toString()).join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _readStatus() async {
|
||||||
|
var file = File("$savePath.download");
|
||||||
|
if (!await file.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = await file.readAsLines();
|
||||||
|
_blocks = lines.map((e) => _DownloadBlock.fromString(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// create file and write empty bytes
|
||||||
|
Future<void> _prepareFile() async {
|
||||||
|
var file = File(savePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
if (file.lengthSync() == _fileSize &&
|
||||||
|
File("$savePath.download").existsSync()) {
|
||||||
|
_file = await file.open(mode: FileMode.append);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await file.create(recursive: true);
|
||||||
|
_file = await file.open(mode: FileMode.append);
|
||||||
|
await _file!.truncate(_fileSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createTasks() async {
|
||||||
|
var res = await _dio.head(url);
|
||||||
|
var length = res.headers["content-length"]?.first;
|
||||||
|
_fileSize = length == null ? 0 : int.parse(length);
|
||||||
|
|
||||||
|
await _prepareFile();
|
||||||
|
|
||||||
|
if (File("$savePath.download").existsSync()) {
|
||||||
|
await _readStatus();
|
||||||
|
_currentBytes = _blocks.fold<int>(0,
|
||||||
|
(previousValue, element) => previousValue + element.downloadedBytes);
|
||||||
|
} else {
|
||||||
|
if (_fileSize > 1024 * 1024 * 1024) {
|
||||||
|
_kChunkSize = 64 * 1024 * 1024;
|
||||||
|
} else if (_fileSize > 512 * 1024 * 1024) {
|
||||||
|
_kChunkSize = 32 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blocks = [];
|
||||||
|
for (var i = 0; i < _fileSize; i += _kChunkSize) {
|
||||||
|
var end = i + _kChunkSize;
|
||||||
|
if (end > _fileSize) {
|
||||||
|
_blocks.add(_DownloadBlock(i, _fileSize, 0, false));
|
||||||
|
} else {
|
||||||
|
_blocks.add(_DownloadBlock(i, i + _kChunkSize, 0, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<DownloadingStatus> start() {
|
||||||
|
var stream = StreamController<DownloadingStatus>();
|
||||||
|
_download(stream);
|
||||||
|
return stream.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reportStatus(StreamController<DownloadingStatus> stream) {
|
||||||
|
stream.add(DownloadingStatus(_currentBytes, _fileSize, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _download(StreamController<DownloadingStatus> resultStream) async {
|
||||||
|
try {
|
||||||
|
var proxy = await AppDio.getProxy();
|
||||||
|
_dio.httpClientAdapter = IOHttpClientAdapter(
|
||||||
|
createHttpClient: () {
|
||||||
|
return HttpClient()
|
||||||
|
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// get file size
|
||||||
|
await _createTasks();
|
||||||
|
|
||||||
|
if (_canceled) return;
|
||||||
|
|
||||||
|
// check if file is downloaded
|
||||||
|
if (_currentBytes >= _fileSize) {
|
||||||
|
await _file!.close();
|
||||||
|
_file = null;
|
||||||
|
_reportStatus(resultStream);
|
||||||
|
resultStream.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reportStatus(resultStream);
|
||||||
|
|
||||||
|
Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (_canceled || _currentBytes >= _fileSize) {
|
||||||
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultStream.add(DownloadingStatus(
|
||||||
|
_currentBytes, _fileSize, _currentBytes - _lastBytes));
|
||||||
|
_lastBytes = _currentBytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
// start downloading
|
||||||
|
await _scheduleDownload();
|
||||||
|
if (_canceled) {
|
||||||
|
resultStream.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _file!.close();
|
||||||
|
_file = null;
|
||||||
|
await File("$savePath.download").delete();
|
||||||
|
|
||||||
|
// check if download is finished
|
||||||
|
if (_currentBytes < _fileSize) {
|
||||||
|
resultStream
|
||||||
|
.addError(Exception("Download failed: Expected $_fileSize bytes, "
|
||||||
|
"but only $_currentBytes bytes downloaded."));
|
||||||
|
resultStream.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
resultStream.add(DownloadingStatus(_currentBytes, _fileSize, 0, true));
|
||||||
|
resultStream.close();
|
||||||
|
} catch (e, s) {
|
||||||
|
await _file?.close();
|
||||||
|
_file = null;
|
||||||
|
resultStream.addError(e, s);
|
||||||
|
resultStream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _scheduleDownload() async {
|
||||||
|
var tasks = <Future>[];
|
||||||
|
while (true) {
|
||||||
|
if (_canceled) return;
|
||||||
|
if (tasks.length >= maxConcurrent) {
|
||||||
|
await Future.any(tasks);
|
||||||
|
}
|
||||||
|
final block = _blocks.firstWhereOrNull((element) =>
|
||||||
|
!element.downloading &&
|
||||||
|
element.end - element.start > element.downloadedBytes);
|
||||||
|
if (block == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
block.downloading = true;
|
||||||
|
var task = _fetchBlock(block);
|
||||||
|
task.then((value) => tasks.remove(task), onError: (e) {
|
||||||
|
if(_canceled) return;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
tasks.add(task);
|
||||||
|
}
|
||||||
|
await Future.wait(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchBlock(_DownloadBlock block) async {
|
||||||
|
final start = block.start;
|
||||||
|
final end = block.end;
|
||||||
|
|
||||||
|
if (start > _fileSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = Options(
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
headers: {
|
||||||
|
"Range": "bytes=${start + block.downloadedBytes}-${end - 1}",
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Accept-Encoding": "deflate, gzip",
|
||||||
|
},
|
||||||
|
preserveHeaderCase: true,
|
||||||
|
);
|
||||||
|
var res = await _dio.get<ResponseBody>(url, options: options);
|
||||||
|
if (_canceled) return;
|
||||||
|
if (res.data == null) {
|
||||||
|
throw Exception("Failed to block $start-$end");
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = <int>[];
|
||||||
|
await for (var data in res.data!.stream) {
|
||||||
|
if (_canceled) return;
|
||||||
|
buffer.addAll(data);
|
||||||
|
if (buffer.length > 16 * 1024) {
|
||||||
|
if (_isWriting) continue;
|
||||||
|
_currentBytes += buffer.length;
|
||||||
|
_isWriting = true;
|
||||||
|
await _file!.setPosition(start + block.downloadedBytes);
|
||||||
|
await _file!.writeFrom(buffer);
|
||||||
|
block.downloadedBytes += buffer.length;
|
||||||
|
buffer.clear();
|
||||||
|
await _writeStatus();
|
||||||
|
_isWriting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.isNotEmpty) {
|
||||||
|
while (_isWriting) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 10));
|
||||||
|
}
|
||||||
|
_isWriting = true;
|
||||||
|
_currentBytes += buffer.length;
|
||||||
|
await _file!.setPosition(start + block.downloadedBytes);
|
||||||
|
await _file!.writeFrom(buffer);
|
||||||
|
block.downloadedBytes += buffer.length;
|
||||||
|
await _writeStatus();
|
||||||
|
_isWriting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
block.downloading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
_canceled = true;
|
||||||
|
await _file?.close();
|
||||||
|
_file = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadingStatus {
|
||||||
|
/// The current downloaded bytes
|
||||||
|
final int downloadedBytes;
|
||||||
|
|
||||||
|
/// The total bytes of the file
|
||||||
|
final int totalBytes;
|
||||||
|
|
||||||
|
/// Whether the download is finished
|
||||||
|
final bool isFinished;
|
||||||
|
|
||||||
|
/// The download speed in bytes per second
|
||||||
|
final int bytesPerSecond;
|
||||||
|
|
||||||
|
const DownloadingStatus(
|
||||||
|
this.downloadedBytes, this.totalBytes, this.bytesPerSecond,
|
||||||
|
[this.isFinished = false]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "Downloaded: $downloadedBytes/$totalBytes ${isFinished ? "Finished" : ""}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadBlock {
|
||||||
|
final int start;
|
||||||
|
final int end;
|
||||||
|
int downloadedBytes;
|
||||||
|
bool downloading;
|
||||||
|
|
||||||
|
_DownloadBlock(this.start, this.end, this.downloadedBytes, this.downloading);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return "$start-$end-$downloadedBytes";
|
||||||
|
}
|
||||||
|
|
||||||
|
_DownloadBlock.fromString(String str)
|
||||||
|
: start = int.parse(str.split("-")[0]),
|
||||||
|
end = int.parse(str.split("-")[1]),
|
||||||
|
downloadedBytes = int.parse(str.split("-")[2]),
|
||||||
|
downloading = false;
|
||||||
|
}
|
@@ -682,6 +682,122 @@ abstract mixin class _ComicPageActions {
|
|||||||
App.rootContext.showMessage(message: "The comic is downloaded".tl);
|
App.rootContext.showMessage(message: "The comic is downloaded".tl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (comicSource.archiveDownloader != null) {
|
||||||
|
bool useNormalDownload = false;
|
||||||
|
List<ArchiveInfo>? archives;
|
||||||
|
int selected = -1;
|
||||||
|
bool isLoading = false;
|
||||||
|
bool isGettingLink = false;
|
||||||
|
await showDialog(
|
||||||
|
context: App.rootContext,
|
||||||
|
builder: (context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return ContentDialog(
|
||||||
|
title: "Download".tl,
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RadioListTile<int>(
|
||||||
|
value: -1,
|
||||||
|
groupValue: selected,
|
||||||
|
title: Text("Normal".tl),
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
selected = v!;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text("Archive".tl),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
),
|
||||||
|
collapsedShape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
),
|
||||||
|
onExpansionChanged: (b) {
|
||||||
|
if (!isLoading && b && archives == null) {
|
||||||
|
isLoading = true;
|
||||||
|
comicSource.archiveDownloader!
|
||||||
|
.getArchives(comic.id)
|
||||||
|
.then((value) {
|
||||||
|
if (value.success) {
|
||||||
|
archives = value.data;
|
||||||
|
} else {
|
||||||
|
App.rootContext
|
||||||
|
.showMessage(message: value.errorMessage!);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
if (archives == null)
|
||||||
|
const ListLoadingIndicator().toCenter()
|
||||||
|
else
|
||||||
|
for (int i = 0; i < archives!.length; i++)
|
||||||
|
RadioListTile<int>(
|
||||||
|
value: i,
|
||||||
|
groupValue: selected,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
selected = v!;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text(archives![i].title),
|
||||||
|
subtitle: Text(archives![i].description),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Button.filled(
|
||||||
|
isLoading: isGettingLink,
|
||||||
|
onPressed: () async {
|
||||||
|
if (selected == -1) {
|
||||||
|
useNormalDownload = true;
|
||||||
|
context.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
isGettingLink = true;
|
||||||
|
});
|
||||||
|
var res =
|
||||||
|
await comicSource.archiveDownloader!.getDownloadUrl(
|
||||||
|
comic.id,
|
||||||
|
archives![selected].id,
|
||||||
|
);
|
||||||
|
if (res.error) {
|
||||||
|
App.rootContext.showMessage(message: res.errorMessage!);
|
||||||
|
setState(() {
|
||||||
|
isGettingLink = false;
|
||||||
|
});
|
||||||
|
} else if (context.mounted) {
|
||||||
|
LocalManager()
|
||||||
|
.addTask(ArchiveDownloadTask(res.data, comic));
|
||||||
|
App.rootContext
|
||||||
|
.showMessage(message: "Download started".tl);
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text("Confirm".tl),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!useNormalDownload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (comic.chapters == null) {
|
if (comic.chapters == null) {
|
||||||
LocalManager().addTask(ImagesDownloadTask(
|
LocalManager().addTask(ImagesDownloadTask(
|
||||||
source: comicSource,
|
source: comicSource,
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:venera/components/components.dart';
|
import 'package:venera/components/components.dart';
|
||||||
import 'package:venera/foundation/app.dart';
|
import 'package:venera/foundation/app.dart';
|
||||||
|
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||||
import 'package:venera/foundation/local.dart';
|
import 'package:venera/foundation/local.dart';
|
||||||
import 'package:venera/network/download.dart';
|
import 'package:venera/network/download.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
@@ -161,8 +162,8 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: widget.task.cover == null
|
child: widget.task.cover == null
|
||||||
? null
|
? null
|
||||||
: Image.file(
|
: Image(
|
||||||
File(widget.task.cover!),
|
image: CachedImageProvider(widget.task.cover!),
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
@@ -206,6 +207,7 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
|
|||||||
Text(
|
Text(
|
||||||
widget.task.message,
|
widget.task.message,
|
||||||
style: ts.s12,
|
style: ts.s12,
|
||||||
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
|
Reference in New Issue
Block a user