mirror of
https://github.com/lifegpc/eh_downloader_flutter.git
synced 2026-06-21 11:24:19 +08:00
Add support to cache thumbnail images to disk
This commit is contained in:
2
.github/workflows/linux.yml
vendored
2
.github/workflows/linux.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
channel: stable
|
||||
- name: Install dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev liblzma-dev libstdc++-12-dev ninja-build
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev liblzma-dev libstdc++-12-dev ninja-build libsqlite3-dev
|
||||
- name: Build
|
||||
run: flutter build linux --release
|
||||
- name: Package files
|
||||
|
||||
@@ -315,11 +315,11 @@ class EHApi extends __EHApi {
|
||||
ThumbnailAlign? align}) {
|
||||
final uri = Uri.parse(_combineBaseUrls(_dio.options.baseUrl, baseUrl));
|
||||
final queryParameters = <String, dynamic>{
|
||||
r'max': max,
|
||||
r'width': width,
|
||||
r'height': height,
|
||||
r'quality': quality,
|
||||
r'force': force,
|
||||
r'max': max?.toString(),
|
||||
r'width': width?.toString(),
|
||||
r'height': height?.toString(),
|
||||
r'quality': quality?.toString(),
|
||||
r'force': force?.toString(),
|
||||
r'method': method?.name,
|
||||
r'align': align?.name,
|
||||
};
|
||||
|
||||
@@ -84,6 +84,7 @@ class _Thumbnail extends State<Thumbnail> {
|
||||
Color? _iconColor;
|
||||
double? _iconSize;
|
||||
bool _disposed = false;
|
||||
String? _originalUrl;
|
||||
void _onNsfwChanged(dynamic args) {
|
||||
final arguments = args as (String, bool)?;
|
||||
if (arguments == null) return;
|
||||
@@ -134,6 +135,28 @@ class _Thumbnail extends State<Thumbnail> {
|
||||
.files[token]![0]!
|
||||
.id;
|
||||
}
|
||||
_originalUrl ??= api.getThumbnailUrl(_fileId!,
|
||||
max: widget._max,
|
||||
width: widget._width,
|
||||
height: widget._height,
|
||||
method: ThumbnailMethod.contain,
|
||||
align: ThumbnailAlign.center);
|
||||
if (isImageCacheEnabled) {
|
||||
try {
|
||||
final cache = await imageCaches.getCache(_originalUrl!);
|
||||
if (cache != null) {
|
||||
setState(() {
|
||||
_data = cache!.$1;
|
||||
_uri = cache!.$3 ?? _originalUrl;
|
||||
_isLoading = false;
|
||||
_cancel = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.warning("Failed to get cache for $_originalUrl: $e");
|
||||
}
|
||||
}
|
||||
final re = await api.getThumbnail(_fileId!,
|
||||
max: widget._max,
|
||||
width: widget._width,
|
||||
@@ -148,6 +171,14 @@ class _Thumbnail extends State<Thumbnail> {
|
||||
_uri = re.response.realUri.toString();
|
||||
final data = Uint8List.fromList(re.data);
|
||||
if (!_cancel!.isCancelled) {
|
||||
if (isImageCacheEnabled) {
|
||||
try {
|
||||
await imageCaches.putCache(
|
||||
_originalUrl!, data, re.response.headers.map, _uri);
|
||||
} catch (e) {
|
||||
_log.warning("Failed to put cache for $_originalUrl: $e");
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_data = data;
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'main.dart';
|
||||
import 'platform/clipboard.dart';
|
||||
import 'platform/display.dart';
|
||||
import 'platform/get_jar.dart';
|
||||
import 'platform/image_cache.dart';
|
||||
import 'platform/path.dart';
|
||||
import 'platform/set_title.dart';
|
||||
import 'tags.dart';
|
||||
@@ -38,6 +39,7 @@ final dio = Dio()
|
||||
Config? _prefs;
|
||||
EHApi? _api;
|
||||
PersistCookieJar? _jar;
|
||||
ImageCaches? _imageCaches;
|
||||
|
||||
Future<void> prepareJar() async {
|
||||
final jar = PersistCookieJar(storage: FileStorage(await getJarPath()));
|
||||
@@ -66,6 +68,21 @@ Config get prefs {
|
||||
return _prefs!;
|
||||
}
|
||||
|
||||
final _globalLog = Logger("global");
|
||||
|
||||
Future<void> prepareImageCaches() async {
|
||||
_imageCaches = ImageCaches();
|
||||
try {
|
||||
await _imageCaches!.init();
|
||||
} catch (e) {
|
||||
_globalLog.warning("Failed to initiailzed image caches: $e");
|
||||
}
|
||||
}
|
||||
|
||||
ImageCaches get imageCaches => _imageCaches!;
|
||||
|
||||
bool get isImageCacheEnabled => prefs.getBool("enableImageCache") ?? true;
|
||||
|
||||
void initApi(String baseUrl) {
|
||||
_api = EHApi(dio, baseUrl: baseUrl);
|
||||
}
|
||||
|
||||
@@ -195,5 +195,6 @@
|
||||
"dlUseAvgSpeed": "Show average speed in task details.",
|
||||
"refresh": "Refresh",
|
||||
"originalImg": "Original image",
|
||||
"overwriteDefaultConfig": "Overwrite default config"
|
||||
"overwriteDefaultConfig": "Overwrite default config",
|
||||
"enableImageCache": "Cache images to disk."
|
||||
}
|
||||
|
||||
@@ -195,5 +195,6 @@
|
||||
"dlUseAvgSpeed": "在任务详情中显示平均速度。",
|
||||
"refresh": "刷新",
|
||||
"originalImg": "原图",
|
||||
"overwriteDefaultConfig": "覆盖默认设置"
|
||||
"overwriteDefaultConfig": "覆盖默认设置",
|
||||
"enableImageCache": "将图片缓存到本地硬盘。"
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@ void main() async {
|
||||
if (prefs.getBool("preventScreenCapture") ?? false) {
|
||||
await platformDisplay.enableProtect();
|
||||
}
|
||||
await prepareImageCaches();
|
||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||
runApp(const MainApp());
|
||||
}
|
||||
|
||||
1
lib/platform/image_cache.dart
Normal file
1
lib/platform/image_cache.dart
Normal file
@@ -0,0 +1 @@
|
||||
export './image_cache_ffi.dart' if (dart.library.html) './image_cache_web.dart';
|
||||
149
lib/platform/image_cache_ffi.dart
Normal file
149
lib/platform/image_cache_ffi.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' as io;
|
||||
import 'dart:typed_data';
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import '../globals.dart';
|
||||
import '../utils.dart';
|
||||
|
||||
const _imagesTable = """CREATE TABLE images (
|
||||
url TEXT,
|
||||
path TEXT,
|
||||
last_used INT,
|
||||
headers TEXT,
|
||||
realUrl TEXT,
|
||||
PRIMARY KEY(url)
|
||||
);""";
|
||||
const _allTables = ['images'];
|
||||
|
||||
final _log = Logger("ImageCachesDb");
|
||||
|
||||
class ImageCaches {
|
||||
Database? _db;
|
||||
final _fs = const LocalFileSystem();
|
||||
Directory? _cacheDir;
|
||||
String? _exeDir;
|
||||
final Set<String> _existingTable = {};
|
||||
bool _inited = false;
|
||||
ImageCaches();
|
||||
Future<String?> _desktopFilePath() async {
|
||||
final String? exe = await platformPath.getCurrentExe();
|
||||
if (exe == null) return null;
|
||||
_exeDir = path.dirname(exe);
|
||||
return path.join(_exeDir!, "image_caches.db");
|
||||
}
|
||||
|
||||
Future<String> get _filePath async {
|
||||
if (isWindows || isLinux) {
|
||||
try {
|
||||
final tmp = await _desktopFilePath();
|
||||
if (tmp != null) return tmp;
|
||||
} catch (e) {
|
||||
_log.warning("Failed to get database file location.");
|
||||
}
|
||||
}
|
||||
final io.Directory appSupportDir = await getApplicationSupportDirectory();
|
||||
return path.join(appSupportDir.path, "image_caches.db");
|
||||
}
|
||||
|
||||
Future<Directory> get cacheDir async {
|
||||
if (_cacheDir != null) return _cacheDir!;
|
||||
if ((isWindows || isLinux) && _exeDir != null) {
|
||||
return _cacheDir = _fs.directory(path.join(_exeDir!, "image_caches"));
|
||||
}
|
||||
final io.Directory cacheDir = await getApplicationCacheDirectory();
|
||||
return _cacheDir = _fs.directory(path.join(cacheDir.path, "image_caches"));
|
||||
}
|
||||
|
||||
Future<void> _createDir() async {
|
||||
final dir = await cacheDir;
|
||||
if (!(await dir.exists())) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _checkDatabase() async {
|
||||
await _updateExistsTable();
|
||||
final v = await _db!.getVersion();
|
||||
_log.fine("Database version: $v");
|
||||
if (_allTables.length != _existingTable.length ||
|
||||
!_allTables.every((e) => _existingTable.contains(e))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _createTable() async {
|
||||
if (!_existingTable.contains("images")) {
|
||||
await _db!.execute(_imagesTable);
|
||||
}
|
||||
await _updateExistsTable();
|
||||
}
|
||||
|
||||
Future<void> _updateExistsTable() async {
|
||||
_existingTable.clear();
|
||||
final cur = await _db!
|
||||
.query("sqlite_master", where: 'type = ?', whereArgs: ['table']);
|
||||
for (final c in cur) {
|
||||
_existingTable.add(c["name"]! as String);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
sqfliteFfiInit();
|
||||
_db = await databaseFactoryFfi.openDatabase(await _filePath);
|
||||
await _createDir();
|
||||
if (!(await _checkDatabase())) await _createTable();
|
||||
_inited = true;
|
||||
}
|
||||
|
||||
Future<(Uint8List, Map<String, List<String>>, String?)?> getCache(
|
||||
String uri) async {
|
||||
if (!_inited) return null;
|
||||
final d = await _db!.query("images", where: 'url = ?', whereArgs: [uri]);
|
||||
if (d.isEmpty) return null;
|
||||
final data = d.first;
|
||||
final path = data["path"] as String;
|
||||
final header = data["headers"] as String;
|
||||
final realUrl = data["readUrl"] as String?;
|
||||
final lastUsed = DateTime.now().millisecondsSinceEpoch;
|
||||
try {
|
||||
await _db!.rawUpdate(
|
||||
"UPDATE images SET last_used = ? WHERE url = ?;", [lastUsed, uri]);
|
||||
} catch (e) {
|
||||
_log.warning("Failed to set last_used to $lastUsed for $uri.");
|
||||
}
|
||||
final f = _fs.file(path);
|
||||
final da = await f.readAsBytes();
|
||||
final h = jsonDecode(header) as Map<String, dynamic>;
|
||||
return (
|
||||
da,
|
||||
h.map((k, v) => MapEntry(k, (v as List<dynamic>).cast<String>())),
|
||||
realUrl
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> putCache(String uri, Uint8List data,
|
||||
Map<String, List<String>> headers, String? realUri) async {
|
||||
if (!_inited) return;
|
||||
final u = Uri.parse(uri);
|
||||
final dir = await cacheDir;
|
||||
final p = path.join(dir.path, u.host.isEmpty ? "nohost" : u.host,
|
||||
u.path.substring(1) + u.query);
|
||||
final d = _fs.directory(path.dirname(p));
|
||||
if (!(await d.exists())) {
|
||||
await d.create(recursive: true);
|
||||
}
|
||||
final f = _fs.file(p);
|
||||
await f.writeAsBytes(data.toList());
|
||||
final lastUsed = DateTime.now().millisecondsSinceEpoch;
|
||||
final header = jsonEncode(headers);
|
||||
await _db!.rawInsert(
|
||||
"INSERT OR REPLACE INTO images VALUES (?, ?, ?, ?, ?);",
|
||||
[uri, p, lastUsed, header, realUri]);
|
||||
}
|
||||
}
|
||||
45
lib/platform/image_cache_web.dart
Normal file
45
lib/platform/image_cache_web.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
import 'dart:typed_data';
|
||||
import 'package:web/web.dart';
|
||||
|
||||
class ImageCaches {
|
||||
Cache? cache;
|
||||
ImageCaches();
|
||||
Future<void> init() async {
|
||||
cache = await window.caches.open("image_caches").toDart;
|
||||
}
|
||||
|
||||
Future<(Uint8List, Map<String, List<String>>, String?)?> getCache(
|
||||
String uri) async {
|
||||
if (cache == null) return null;
|
||||
final init = RequestInit(credentials: 'include');
|
||||
final req = Request(URL(uri), init);
|
||||
final opts = CacheQueryOptions(ignoreVary: true);
|
||||
final re = await cache!.match(req, opts).toDart;
|
||||
if (re == null || re!.body == null) return null;
|
||||
final ab = await re!.arrayBuffer().toDart;
|
||||
final buffer = ab.toDart;
|
||||
final he = re!.headers;
|
||||
final forEach = he.getProperty("forEach".toJS) as JSFunction?;
|
||||
Map<String, List<String>> h = {};
|
||||
void forE(JSString value, JSString key, Headers headers) {
|
||||
h[key.toDart] = value.toDart.split(",");
|
||||
}
|
||||
|
||||
forEach?.callAsFunction(he, forE.toJS);
|
||||
return (buffer.asUint8List(), h, null);
|
||||
}
|
||||
|
||||
Future<void> putCache(String uri, Uint8List data,
|
||||
Map<String, List<String>> headers, String? realUri) async {
|
||||
if (cache == null) return;
|
||||
final he = JSObject();
|
||||
for (final e in headers.entries) {
|
||||
he.setProperty(e.key.toJS, e.value.map((e) => e.toJS).toList().toJS);
|
||||
}
|
||||
final opts = ResponseInit(status: 200, statusText: 'OK', headers: he);
|
||||
final res = Response(data.toJS, opts);
|
||||
await cache!.put(uri.toJS, res).toDart;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class _SettingsPage extends State<SettingsPage> with ThemeModeWidget {
|
||||
bool _oriShowTranslatedTag = false;
|
||||
bool _oriUseTitleJpn = false;
|
||||
bool _oriDlUseAvgSpeed = false;
|
||||
bool _oriEnableImageCache = false;
|
||||
bool _displayAd = false;
|
||||
Lang _lang = Lang.system;
|
||||
bool _preventScreenCapture = false;
|
||||
@@ -32,6 +33,7 @@ class _SettingsPage extends State<SettingsPage> with ThemeModeWidget {
|
||||
bool _showTranslatedTag = false;
|
||||
bool _useTitleJpn = false;
|
||||
bool _dlUseAvgSpeed = false;
|
||||
bool _enableImageCache = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -92,6 +94,14 @@ class _SettingsPage extends State<SettingsPage> with ThemeModeWidget {
|
||||
_oriDlUseAvgSpeed = false;
|
||||
_dlUseAvgSpeed = false;
|
||||
}
|
||||
try {
|
||||
_oriEnableImageCache = prefs.getBool("enableImageCache") ?? true;
|
||||
_enableImageCache = _oriEnableImageCache;
|
||||
} catch (e) {
|
||||
_log.warning("Failed to get enableImageCache:", e);
|
||||
_oriEnableImageCache = true;
|
||||
_enableImageCache = true;
|
||||
}
|
||||
}
|
||||
|
||||
void fallback(BuildContext context) {
|
||||
@@ -110,6 +120,7 @@ class _SettingsPage extends State<SettingsPage> with ThemeModeWidget {
|
||||
_showTranslatedTag = _oriLang.toLocale().languageCode == "zh";
|
||||
_preventScreenCapture = false;
|
||||
_dlUseAvgSpeed = false;
|
||||
_enableImageCache = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +189,14 @@ class _SettingsPage extends State<SettingsPage> with ThemeModeWidget {
|
||||
_oriDlUseAvgSpeed = _dlUseAvgSpeed;
|
||||
}
|
||||
}
|
||||
if (_enableImageCache != _oriEnableImageCache) {
|
||||
if (!await prefs.setBool("enableImageCache", _enableImageCache)) {
|
||||
re = false;
|
||||
_log.warning("Failed to save enableImageCache.");
|
||||
} else {
|
||||
_oriEnableImageCache = _enableImageCache;
|
||||
}
|
||||
}
|
||||
return re;
|
||||
}
|
||||
|
||||
@@ -331,6 +350,21 @@ class _SettingsPage extends State<SettingsPage> with ThemeModeWidget {
|
||||
.dlUseAvgSpeed),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: CheckboxMenuButton(
|
||||
value: _enableImageCache,
|
||||
onChanged: (bool? value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_enableImageCache = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!
|
||||
.enableImageCache),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
34
pubspec.lock
34
pubspec.lock
@@ -875,6 +875,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: "4d6137c29e930d6e4a8ff373989dd9de7bac12e3bc87bce950f6e844e8ad3bb5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: b384f598b813b347c5a7e5ffad82cbaff1bec3d1561af267041e66f6f0899295
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -931,6 +955,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.15"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0+1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1028,7 +1060,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||
|
||||
@@ -38,10 +38,12 @@ dependencies:
|
||||
photo_view: ^0.15.0
|
||||
retrofit: ^4.0.1
|
||||
shared_preferences: ^2.2.0
|
||||
sqflite_common_ffi: ^2.3.3
|
||||
super_clipboard: ^0.8.15
|
||||
super_context_menu: ^0.8.15
|
||||
ua_parser_js: ^1.0.1
|
||||
user_agent_analyzer: ^5.0.0
|
||||
web: ^0.5.0
|
||||
web_socket_channel: ^3.0.0
|
||||
window_manager: ^0.3.6
|
||||
|
||||
@@ -59,6 +61,7 @@ dev_dependencies:
|
||||
build_runner: ^2.4.6
|
||||
retrofit_generator: ^8.1.0
|
||||
json_serializable: ^6.7.1
|
||||
sqflite_common_ffi: ^2.3.3
|
||||
|
||||
flutter:
|
||||
generate: true
|
||||
|
||||
Reference in New Issue
Block a user