mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
467 lines
12 KiB
Dart
467 lines
12 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:sqlite3/sqlite3.dart';
|
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
|
import 'package:venera/foundation/comic_type.dart';
|
|
import 'package:venera/foundation/log.dart';
|
|
import 'package:venera/network/download.dart';
|
|
import 'package:venera/pages/reader/reader.dart';
|
|
import 'package:venera/utils/ext.dart';
|
|
import 'package:venera/utils/io.dart';
|
|
|
|
import 'app.dart';
|
|
import 'history.dart';
|
|
|
|
class LocalComic with HistoryMixin implements Comic {
|
|
@override
|
|
final String id;
|
|
|
|
@override
|
|
final String title;
|
|
|
|
@override
|
|
final String subtitle;
|
|
|
|
@override
|
|
final List<String> tags;
|
|
|
|
/// The name of the directory where the comic is stored
|
|
final String directory;
|
|
|
|
/// key: chapter id, value: chapter title
|
|
///
|
|
/// chapter id is the name of the directory in `LocalManager.path/$directory`
|
|
final Map<String, String>? chapters;
|
|
|
|
/// relative path to the cover image
|
|
@override
|
|
final String cover;
|
|
|
|
final ComicType comicType;
|
|
|
|
final List<String> downloadedChapters;
|
|
|
|
final DateTime createdAt;
|
|
|
|
const LocalComic({
|
|
required this.id,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.tags,
|
|
required this.directory,
|
|
required this.chapters,
|
|
required this.cover,
|
|
required this.comicType,
|
|
required this.downloadedChapters,
|
|
required this.createdAt,
|
|
});
|
|
|
|
LocalComic.fromRow(Row row)
|
|
: id = row[0] as String,
|
|
title = row[1] as String,
|
|
subtitle = row[2] as String,
|
|
tags = List.from(jsonDecode(row[3] as String)),
|
|
directory = row[4] as String,
|
|
chapters = MapOrNull.from(jsonDecode(row[5] as String)),
|
|
cover = row[6] as String,
|
|
comicType = ComicType(row[7] as int),
|
|
downloadedChapters = List.from(jsonDecode(row[8] as String)),
|
|
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
|
|
|
|
File get coverFile => File(FilePath.join(
|
|
LocalManager().path,
|
|
directory,
|
|
cover,
|
|
));
|
|
|
|
@override
|
|
String get description => "";
|
|
|
|
@override
|
|
String get sourceKey => comicType == ComicType.local
|
|
? "local"
|
|
: comicType.sourceKey;
|
|
|
|
@override
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
"title": title,
|
|
"cover": cover,
|
|
"id": id,
|
|
"subTitle": subtitle,
|
|
"tags": tags,
|
|
"description": description,
|
|
"sourceKey": sourceKey,
|
|
};
|
|
}
|
|
|
|
@override
|
|
int? get maxPage => null;
|
|
|
|
void read() async {
|
|
var history = await HistoryManager().find(id, comicType);
|
|
App.rootContext.to(
|
|
() => Reader(
|
|
type: comicType,
|
|
cid: id,
|
|
name: title,
|
|
chapters: chapters,
|
|
initialChapter: history?.ep,
|
|
initialPage: history?.page,
|
|
history: history ?? History.fromModel(
|
|
model: this,
|
|
ep: 0,
|
|
page: 0,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
HistoryType get historyType => comicType;
|
|
|
|
@override
|
|
String? get subTitle => subtitle;
|
|
|
|
@override
|
|
String? get language => null;
|
|
|
|
@override
|
|
String? get favoriteId => null;
|
|
|
|
@override
|
|
double? get stars => null;
|
|
}
|
|
|
|
class LocalManager with ChangeNotifier {
|
|
static LocalManager? _instance;
|
|
|
|
LocalManager._();
|
|
|
|
factory LocalManager() {
|
|
return _instance ??= LocalManager._();
|
|
}
|
|
|
|
late Database _db;
|
|
|
|
/// path to the directory where all the comics are stored
|
|
late String path;
|
|
|
|
// return error message if failed
|
|
Future<String?> setNewPath(String newPath) async {
|
|
var newDir = Directory(newPath);
|
|
if (!await newDir.exists()) {
|
|
return "Directory does not exist";
|
|
}
|
|
if (!await newDir.list().isEmpty) {
|
|
return "Directory is not empty";
|
|
}
|
|
try {
|
|
await copyDirectoryIsolate(
|
|
Directory(path),
|
|
newDir,
|
|
);
|
|
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath);
|
|
} catch (e, s) {
|
|
Log.error("IO", e, s);
|
|
return e.toString();
|
|
}
|
|
await Directory(path).deleteIgnoreError(recursive:true);
|
|
path = newPath;
|
|
return null;
|
|
}
|
|
|
|
Future<void> init() async {
|
|
_db = sqlite3.open(
|
|
'${App.dataPath}/local.db',
|
|
);
|
|
_db.execute('''
|
|
CREATE TABLE IF NOT EXISTS comics (
|
|
id TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
subtitle TEXT NOT NULL,
|
|
tags TEXT NOT NULL,
|
|
directory TEXT NOT NULL,
|
|
chapters TEXT NOT NULL,
|
|
cover TEXT NOT NULL,
|
|
comic_type INTEGER NOT NULL,
|
|
downloadedChapters TEXT NOT NULL,
|
|
created_at INTEGER,
|
|
PRIMARY KEY (id, comic_type)
|
|
);
|
|
''');
|
|
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
|
|
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
|
|
} else {
|
|
if (App.isAndroid) {
|
|
var external = await getExternalStorageDirectories();
|
|
if (external != null && external.isNotEmpty) {
|
|
path = FilePath.join(external.first.path, 'local');
|
|
} else {
|
|
path = FilePath.join(App.dataPath, 'local');
|
|
}
|
|
} else {
|
|
path = FilePath.join(App.dataPath, 'local');
|
|
}
|
|
}
|
|
if (!Directory(path).existsSync()) {
|
|
await Directory(path).create();
|
|
}
|
|
restoreDownloadingTasks();
|
|
}
|
|
|
|
String findValidId(ComicType type) {
|
|
final res = _db.select(
|
|
'''
|
|
SELECT id FROM comics WHERE comic_type = ?
|
|
ORDER BY CAST(id AS INTEGER) DESC
|
|
LIMIT 1;
|
|
''', [type.value],
|
|
);
|
|
if (res.isEmpty) {
|
|
return '1';
|
|
}
|
|
return (int.parse((res.first[0])) + 1).toString();
|
|
}
|
|
|
|
Future<void> add(LocalComic comic, [String? id]) async {
|
|
var old = find(id ?? comic.id, comic.comicType);
|
|
var downloaded = comic.downloadedChapters;
|
|
if (old != null) {
|
|
downloaded.addAll(old.downloadedChapters);
|
|
}
|
|
_db.execute(
|
|
'INSERT OR REPLACE INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
|
[
|
|
id ?? comic.id,
|
|
comic.title,
|
|
comic.subtitle,
|
|
jsonEncode(comic.tags),
|
|
comic.directory,
|
|
jsonEncode(comic.chapters),
|
|
comic.cover,
|
|
comic.comicType.value,
|
|
jsonEncode(downloaded),
|
|
comic.createdAt.millisecondsSinceEpoch,
|
|
],
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void remove(String id, ComicType comicType) async {
|
|
_db.execute(
|
|
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
|
[id, comicType.value],
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void removeComic(LocalComic comic) {
|
|
remove(comic.id, comic.comicType);
|
|
notifyListeners();
|
|
}
|
|
|
|
List<LocalComic> getComics(LocalSortType sortType) {
|
|
var res = _db.select('''
|
|
SELECT * FROM comics
|
|
ORDER BY
|
|
${sortType.value == 'name' ? 'title' : 'created_at'}
|
|
${sortType.value == 'time_asc' ? 'ASC' : 'DESC'}
|
|
;
|
|
''');
|
|
return res.map((row) => LocalComic.fromRow(row)).toList();
|
|
}
|
|
|
|
LocalComic? find(String id, ComicType comicType) {
|
|
final res = _db.select(
|
|
'SELECT * FROM comics WHERE id = ? AND comic_type = ?;',
|
|
[id, comicType.value],
|
|
);
|
|
if (res.isEmpty) {
|
|
return null;
|
|
}
|
|
return LocalComic.fromRow(res.first);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
_db.dispose();
|
|
}
|
|
|
|
List<LocalComic> getRecent() {
|
|
final res = _db.select('''
|
|
SELECT * FROM comics
|
|
ORDER BY created_at DESC
|
|
LIMIT 20;
|
|
''');
|
|
return res.map((row) => LocalComic.fromRow(row)).toList();
|
|
}
|
|
|
|
int get count {
|
|
final res = _db.select('''
|
|
SELECT COUNT(*) FROM comics;
|
|
''');
|
|
return res.first[0] as int;
|
|
}
|
|
|
|
LocalComic? findByName(String name) {
|
|
final res = _db.select('''
|
|
SELECT * FROM comics
|
|
WHERE title = ? OR directory = ?;
|
|
''', [name, name]);
|
|
if (res.isEmpty) {
|
|
return null;
|
|
}
|
|
return LocalComic.fromRow(res.first);
|
|
}
|
|
|
|
List<LocalComic> search(String keyword) {
|
|
final res = _db.select('''
|
|
SELECT * FROM comics
|
|
WHERE title LIKE ? OR tags LIKE ? OR subtitle LIKE ?
|
|
ORDER BY created_at DESC;
|
|
''', ['%$keyword%', '%$keyword%', '%$keyword%']);
|
|
return res.map((row) => LocalComic.fromRow(row)).toList();
|
|
}
|
|
|
|
Future<List<String>> getImages(String id, ComicType type, Object ep) async {
|
|
if(ep is! String && ep is! int) {
|
|
throw "Invalid ep";
|
|
}
|
|
var comic = find(id, type) ?? (throw "Comic Not Found");
|
|
var directory = Directory(FilePath.join(path, comic.directory));
|
|
if (comic.chapters != null) {
|
|
var cid = ep is int
|
|
? comic.chapters!.keys.elementAt(ep - 1)
|
|
: (ep as String);
|
|
directory = Directory(FilePath.join(directory.path, cid));
|
|
}
|
|
var files = <File>[];
|
|
await for (var entity in directory.list()) {
|
|
if (entity is File) {
|
|
if (entity.absolute.path.replaceFirst(path, '').substring(1) ==
|
|
comic.cover) {
|
|
continue;
|
|
}
|
|
files.add(entity);
|
|
}
|
|
}
|
|
files.sort((a, b) {
|
|
var ai = int.tryParse(a.name.split('.').first);
|
|
var bi = int.tryParse(b.name.split('.').first);
|
|
if (ai != null && bi != null) {
|
|
return ai.compareTo(bi);
|
|
}
|
|
return a.name.compareTo(b.name);
|
|
});
|
|
return files.map((e) => "file://${e.path}").toList();
|
|
}
|
|
|
|
Future<bool> isDownloaded(String id, ComicType type, int ep) async {
|
|
var comic = find(id, type);
|
|
if (comic == null) return false;
|
|
if (comic.chapters == null) return true;
|
|
return comic.downloadedChapters
|
|
.contains(comic.chapters!.keys.elementAt(ep-1));
|
|
}
|
|
|
|
List<DownloadTask> downloadingTasks = [];
|
|
|
|
bool isDownloading(String id, ComicType type) {
|
|
return downloadingTasks
|
|
.any((element) => element.id == id && element.comicType == type);
|
|
}
|
|
|
|
Future<Directory> findValidDirectory(
|
|
String id, ComicType type, String name) async {
|
|
var comic = find(id, type);
|
|
if (comic != null) {
|
|
return Directory(FilePath.join(path, comic.directory));
|
|
}
|
|
var dir = findValidDirectoryName(path, name);
|
|
return Directory(FilePath.join(path, dir)).create().then((value) => value);
|
|
}
|
|
|
|
void completeTask(DownloadTask task) {
|
|
add(task.toLocalComic());
|
|
downloadingTasks.remove(task);
|
|
notifyListeners();
|
|
saveCurrentDownloadingTasks();
|
|
downloadingTasks.firstOrNull?.resume();
|
|
}
|
|
|
|
void removeTask(DownloadTask task) {
|
|
downloadingTasks.remove(task);
|
|
notifyListeners();
|
|
saveCurrentDownloadingTasks();
|
|
}
|
|
|
|
void moveToFirst(DownloadTask task) {
|
|
if (downloadingTasks.first != task) {
|
|
var shouldResume = !downloadingTasks.first.isPaused;
|
|
downloadingTasks.first.pause();
|
|
downloadingTasks.remove(task);
|
|
downloadingTasks.insert(0, task);
|
|
notifyListeners();
|
|
saveCurrentDownloadingTasks();
|
|
if (shouldResume) {
|
|
downloadingTasks.first.resume();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> saveCurrentDownloadingTasks() async {
|
|
var tasks = downloadingTasks.map((e) => e.toJson()).toList();
|
|
await File(FilePath.join(App.dataPath, 'downloading_tasks.json'))
|
|
.writeAsString(jsonEncode(tasks));
|
|
}
|
|
|
|
void restoreDownloadingTasks() {
|
|
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
|
|
if (file.existsSync()) {
|
|
var tasks = jsonDecode(file.readAsStringSync());
|
|
for (var e in tasks) {
|
|
var task = DownloadTask.fromJson(e);
|
|
if (task != null) {
|
|
downloadingTasks.add(task);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void addTask(DownloadTask task) {
|
|
downloadingTasks.add(task);
|
|
notifyListeners();
|
|
saveCurrentDownloadingTasks();
|
|
downloadingTasks.first.resume();
|
|
}
|
|
|
|
void deleteComic(LocalComic c) {
|
|
var dir = Directory(FilePath.join(path, c.directory));
|
|
dir.deleteIgnoreError(recursive: true);
|
|
remove(c.id, c.comicType);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
enum LocalSortType {
|
|
name("name"),
|
|
timeAsc("time_asc"),
|
|
timeDesc("time_desc");
|
|
|
|
final String value;
|
|
|
|
const LocalSortType(this.value);
|
|
|
|
static LocalSortType fromString(String value) {
|
|
for (var type in values) {
|
|
if (type.value == value) {
|
|
return type;
|
|
}
|
|
}
|
|
return name;
|
|
}
|
|
} |