Add cxyz Android project structure and initial configuration
- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration. - Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files. - Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown. - Configured analysis options and dependencies in pubspec.yaml to support Flutter development. - Established a pnpm workspace and lockfile for dependency management. - Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../sources/comicland/urls.dart';
|
||||
import '../sources/source.dart';
|
||||
|
||||
/// Saves CBZs to public Download/Comics via a native MediaStore channel.
|
||||
///
|
||||
/// Transfers use temp file paths (not byte arrays) to avoid binder size limits.
|
||||
class PublicCbzStore {
|
||||
PublicCbzStore({MethodChannel? channel})
|
||||
: _channel = channel ??
|
||||
const MethodChannel('org.cxyz.cxyz_android/cbz_store');
|
||||
|
||||
final MethodChannel _channel;
|
||||
int? _sdkInt;
|
||||
|
||||
Future<int> sdkInt() async {
|
||||
if (_sdkInt != null) return _sdkInt!;
|
||||
try {
|
||||
final v = await _channel.invokeMethod<int>('sdkInt');
|
||||
_sdkInt = v ?? 30;
|
||||
} catch (_) {
|
||||
_sdkInt = 30;
|
||||
}
|
||||
return _sdkInt!;
|
||||
}
|
||||
|
||||
/// Request WRITE_EXTERNAL_STORAGE on API 26–28 only.
|
||||
Future<void> ensureWritePermission() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
final sdk = await sdkInt();
|
||||
if (sdk >= 29) return;
|
||||
final status = await Permission.storage.request();
|
||||
if (!status.isGranted) {
|
||||
throw StateError('Storage permission is required to save CBZ files');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> saveCbzFromPath({
|
||||
required String seriesDirName,
|
||||
required String displayName,
|
||||
required String sourcePath,
|
||||
}) async {
|
||||
await ensureWritePermission();
|
||||
final relativeDir = 'Comics/$seriesDirName';
|
||||
final uri = await _channel.invokeMethod<String>('saveCbzFromPath', {
|
||||
'relativeDir': relativeDir,
|
||||
'displayName': displayName,
|
||||
'sourcePath': sourcePath,
|
||||
});
|
||||
if (uri == null || uri.isEmpty) {
|
||||
throw StateError('Native saveCbzFromPath returned empty uri');
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
Future<Uint8List> readCbz(String uriOrPath) async {
|
||||
if (!uriOrPath.startsWith('content://')) {
|
||||
return File(uriOrPath).readAsBytes();
|
||||
}
|
||||
final tmp = await File(
|
||||
p.join(
|
||||
Directory.systemTemp.path,
|
||||
'cxyz-read-${DateTime.now().millisecondsSinceEpoch}.cbz',
|
||||
),
|
||||
).create();
|
||||
try {
|
||||
await _channel.invokeMethod<String>('readCbzToPath', {
|
||||
'uri': uriOrPath,
|
||||
'destPath': tmp.path,
|
||||
});
|
||||
return await tmp.readAsBytes();
|
||||
} finally {
|
||||
try {
|
||||
await tmp.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteCbz(String uriOrPath) async {
|
||||
if (uriOrPath.startsWith('content://')) {
|
||||
await _channel.invokeMethod<bool>('deleteCbz', {'uri': uriOrPath});
|
||||
return;
|
||||
}
|
||||
final file = File(uriOrPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CbzStore {
|
||||
CbzStore({Dio? dio, PublicCbzStore? publicStore})
|
||||
: _dio = dio ?? Dio(),
|
||||
_public = publicStore ?? PublicCbzStore();
|
||||
|
||||
final Dio _dio;
|
||||
final PublicCbzStore _public;
|
||||
|
||||
Future<Directory> comicsRoot() async {
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory(p.join(docs.path, 'Comics'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
Future<Directory> cacheRoot() async {
|
||||
final cache = await getTemporaryDirectory();
|
||||
final dir = Directory(p.join(cache.path, 'chapter_cache'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
String cbzDisplayName({
|
||||
required String chapterUrl,
|
||||
String? chapterNumber,
|
||||
String? label,
|
||||
}) {
|
||||
return transferLinkBasename(
|
||||
chapterNumber,
|
||||
label,
|
||||
'${sanitizeFsStem(stemForComicLandChapterCbz(chapterUrl))}.cbz',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> resolveCacheDir({
|
||||
required String comicTitle,
|
||||
required String chapterKey,
|
||||
}) async {
|
||||
final root = await cacheRoot();
|
||||
final dir = Directory(
|
||||
p.join(root.path, sanitizeComicDirName(comicTitle), sanitizeFsStem(chapterKey)),
|
||||
);
|
||||
return dir.path;
|
||||
}
|
||||
|
||||
String extensionFromContentType(String? contentType) {
|
||||
if (contentType == null || contentType.isEmpty) return '';
|
||||
final ct = contentType.split(';').first.trim().toLowerCase();
|
||||
const map = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/pjpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
'image/avif': '.avif',
|
||||
};
|
||||
return map[ct] ?? '';
|
||||
}
|
||||
|
||||
String extensionFromImageUrl(String imageUrl) {
|
||||
try {
|
||||
final ext = p.extension(Uri.parse(imageUrl).path).toLowerCase();
|
||||
if (ext.isNotEmpty &&
|
||||
ext.length <= 8 &&
|
||||
RegExp(r'^\.[a-z0-9]+$').hasMatch(ext)) {
|
||||
return ext;
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Download images → pack CBZ → extract to cache. Returns paths and sizes.
|
||||
/// [cbzPath] may be a content:// URI (MediaStore) or a filesystem path.
|
||||
Future<
|
||||
({
|
||||
String cbzPath,
|
||||
String cacheDir,
|
||||
int imageCount,
|
||||
int rawBytes,
|
||||
int cbzBytes,
|
||||
String? firstImagePath,
|
||||
int firstImageBytes,
|
||||
})> downloadChapter({
|
||||
required ComicSource source,
|
||||
required String comicTitle,
|
||||
required ChapterLink chapter,
|
||||
void Function(double progress, String message)? onProgress,
|
||||
bool Function()? isCancelled,
|
||||
}) async {
|
||||
onProgress?.call(0.02, 'Fetching…');
|
||||
final urls = await source.fetchPageUrls(chapter.url);
|
||||
if (urls.isEmpty) {
|
||||
throw StateError('No chapter images were found for ${chapter.url}');
|
||||
}
|
||||
|
||||
final tmp = await Directory.systemTemp.createTemp('cxyz-chapter-');
|
||||
try {
|
||||
final saved = <File>[];
|
||||
var rawBytes = 0;
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
if (isCancelled?.call() == true) {
|
||||
throw StateError('Download cancelled');
|
||||
}
|
||||
final imageUrl = urls[i];
|
||||
onProgress?.call(
|
||||
0.05 + (0.7 * (i / urls.length)),
|
||||
'Downloading…',
|
||||
);
|
||||
final res = await _dio.get<List<int>>(
|
||||
imageUrl,
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes,
|
||||
headers: source.imageHeaders,
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
validateStatus: (s) => s != null && s >= 200 && s < 300,
|
||||
),
|
||||
);
|
||||
var ext = extensionFromImageUrl(imageUrl);
|
||||
if (ext.isEmpty) {
|
||||
ext = extensionFromContentType(res.headers.value('content-type'));
|
||||
if (ext.isEmpty) ext = '.bin';
|
||||
}
|
||||
final filename = 'page-${(i + 1).toString().padLeft(4, '0')}$ext';
|
||||
final file = File(p.join(tmp.path, filename));
|
||||
final data = Uint8List.fromList(res.data ?? const []);
|
||||
await file.writeAsBytes(data, flush: true);
|
||||
rawBytes += data.length;
|
||||
saved.add(file);
|
||||
}
|
||||
|
||||
onProgress?.call(0.8, 'Packing…');
|
||||
final archive = Archive();
|
||||
for (final file in saved) {
|
||||
final bytes = await file.readAsBytes();
|
||||
archive.addFile(
|
||||
ArchiveFile(p.basename(file.path), bytes.length, bytes),
|
||||
);
|
||||
}
|
||||
final encoded = ZipEncoder().encode(archive);
|
||||
final zipBytes = Uint8List.fromList(encoded);
|
||||
final cbzBytes = zipBytes.length;
|
||||
|
||||
final packed = File(p.join(tmp.path, 'chapter.cbz'));
|
||||
await packed.writeAsBytes(zipBytes, flush: true);
|
||||
|
||||
final seriesDir = sanitizeComicDirName(comicTitle);
|
||||
final displayName = cbzDisplayName(
|
||||
chapterUrl: chapter.url,
|
||||
chapterNumber: chapter.chapterNumber,
|
||||
label: chapter.title,
|
||||
);
|
||||
|
||||
String cbzPath;
|
||||
if (Platform.isAndroid) {
|
||||
cbzPath = await _public.saveCbzFromPath(
|
||||
seriesDirName: seriesDir,
|
||||
displayName: displayName,
|
||||
sourcePath: packed.path,
|
||||
);
|
||||
} else {
|
||||
// Desktop / tests: keep writing under app documents.
|
||||
final root = await comicsRoot();
|
||||
final titleDir = Directory(p.join(root.path, seriesDir));
|
||||
if (!await titleDir.exists()) {
|
||||
await titleDir.create(recursive: true);
|
||||
}
|
||||
cbzPath = p.join(titleDir.path, displayName);
|
||||
await packed.copy(cbzPath);
|
||||
}
|
||||
|
||||
onProgress?.call(0.9, 'Extracting…');
|
||||
final cacheDir = await resolveCacheDir(
|
||||
comicTitle: comicTitle,
|
||||
chapterKey: chapter.chapterKey,
|
||||
);
|
||||
final extracted = await extractBytesToCache(zipBytes, cacheDir);
|
||||
extracted.sort();
|
||||
final firstImagePath = extracted.isEmpty ? null : extracted.first;
|
||||
final firstImageBytes = firstImagePath == null
|
||||
? 0
|
||||
: await File(firstImagePath).length();
|
||||
|
||||
onProgress?.call(1.0, 'Done');
|
||||
return (
|
||||
cbzPath: cbzPath,
|
||||
cacheDir: cacheDir,
|
||||
imageCount: saved.length,
|
||||
rawBytes: rawBytes,
|
||||
cbzBytes: cbzBytes,
|
||||
firstImagePath: firstImagePath,
|
||||
firstImageBytes: firstImageBytes,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await tmp.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> extractBytesToCache(
|
||||
Uint8List bytes,
|
||||
String cacheDirPath,
|
||||
) async {
|
||||
final dir = Directory(cacheDirPath);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
await dir.create(recursive: true);
|
||||
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
final out = <String>[];
|
||||
for (final file in archive.files) {
|
||||
if (!file.isFile) continue;
|
||||
final name = p.basename(file.name);
|
||||
if (name.isEmpty || name.startsWith('.')) continue;
|
||||
final outPath = p.join(cacheDirPath, name);
|
||||
final outFile = File(outPath);
|
||||
await outFile.writeAsBytes(file.content as List<int>, flush: true);
|
||||
out.add(outPath);
|
||||
}
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<List<String>> extractCbzToCache(String cbzPath, String cacheDirPath) async {
|
||||
final bytes = await readCbzBytes(cbzPath);
|
||||
return extractBytesToCache(bytes, cacheDirPath);
|
||||
}
|
||||
|
||||
Future<Uint8List> readCbzBytes(String cbzPath) async {
|
||||
if (cbzPath.startsWith('content://')) {
|
||||
return _public.readCbz(cbzPath);
|
||||
}
|
||||
return File(cbzPath).readAsBytes();
|
||||
}
|
||||
|
||||
Future<List<String>> pagesFromCacheOrCbz({
|
||||
required String? cacheDir,
|
||||
required String? cbzPath,
|
||||
required String comicTitle,
|
||||
required String chapterKey,
|
||||
}) async {
|
||||
if (cacheDir != null) {
|
||||
final dir = Directory(cacheDir);
|
||||
if (await dir.exists()) {
|
||||
final files = await dir
|
||||
.list()
|
||||
.where((e) => e is File)
|
||||
.map((e) => e.path)
|
||||
.toList();
|
||||
files.sort();
|
||||
if (files.isNotEmpty) return files;
|
||||
}
|
||||
}
|
||||
if (cbzPath != null && cbzPath.isNotEmpty) {
|
||||
final exists = cbzPath.startsWith('content://') ||
|
||||
await File(cbzPath).exists();
|
||||
if (exists) {
|
||||
final target = cacheDir ??
|
||||
await resolveCacheDir(comicTitle: comicTitle, chapterKey: chapterKey);
|
||||
return extractCbzToCache(cbzPath, target);
|
||||
}
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<void> deleteDownload({
|
||||
String? cbzPath,
|
||||
String? cacheDir,
|
||||
}) async {
|
||||
if (cacheDir != null) {
|
||||
try {
|
||||
final dir = Directory(cacheDir);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (cbzPath != null && cbzPath.isNotEmpty) {
|
||||
try {
|
||||
await _public.deleteCbz(cbzPath);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../data/database.dart';
|
||||
import '../sources/registry.dart';
|
||||
import '../sources/source.dart';
|
||||
import 'cbz_store.dart';
|
||||
import 'ntfy_notifier.dart';
|
||||
|
||||
class DownloadQueue extends ChangeNotifier {
|
||||
DownloadQueue({
|
||||
required AppDatabase db,
|
||||
required SourceRegistry sources,
|
||||
required this._store,
|
||||
NtfyNotifier? ntfy,
|
||||
}) : _db = db,
|
||||
_sources = sources,
|
||||
_ntfy = ntfy ?? NtfyNotifier(store: _store);
|
||||
|
||||
final AppDatabase _db;
|
||||
final SourceRegistry _sources;
|
||||
final CbzStore _store;
|
||||
final NtfyNotifier _ntfy;
|
||||
|
||||
final Queue<int> _chapterIds = Queue<int>();
|
||||
final Set<int> _cancelled = {};
|
||||
/// chapterId → jobId for open jobs created at enqueue.
|
||||
final Map<int, int> _jobByChapter = {};
|
||||
bool _running = false;
|
||||
int? _activeChapterId;
|
||||
double _progress = 0;
|
||||
String _message = '';
|
||||
int _sessionOk = 0;
|
||||
int _sessionFailed = 0;
|
||||
bool _sessionHadWork = false;
|
||||
DateTime? _sessionStartedAt;
|
||||
final List<Future<void>> _pendingNtfy = [];
|
||||
|
||||
int? get activeChapterId => _activeChapterId;
|
||||
double get progress => _progress;
|
||||
String get message => _message;
|
||||
bool get isRunning => _running;
|
||||
int get queuedCount => _chapterIds.length;
|
||||
|
||||
String _label(String seriesTitle, String chapterLabel) =>
|
||||
'$seriesTitle — $chapterLabel';
|
||||
|
||||
Future<void> enqueueChapters(Iterable<int> chapterIds) async {
|
||||
for (final id in chapterIds) {
|
||||
if (_chapterIds.contains(id) || id == _activeChapterId) continue;
|
||||
_cancelled.remove(id);
|
||||
|
||||
final chapter = await _db.getChapter(id);
|
||||
if (chapter == null) continue;
|
||||
final title = await _db.getTitle(chapter.titleId);
|
||||
if (title == null) continue;
|
||||
|
||||
_chapterIds.add(id);
|
||||
await (_db.update(_db.chapters)..where((c) => c.id.equals(id))).write(
|
||||
const ChaptersCompanion(
|
||||
status: Value('pending'),
|
||||
error: Value(null),
|
||||
),
|
||||
);
|
||||
|
||||
final existing = await _db.findOpenJobForChapter(id);
|
||||
late final int jobId;
|
||||
if (existing != null) {
|
||||
jobId = existing.id;
|
||||
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId))).write(
|
||||
JobsCompanion(
|
||||
titleId: Value(title.id),
|
||||
chapterId: Value(id),
|
||||
status: const Value('queued'),
|
||||
progress: const Value(0),
|
||||
message: Value(_label(title.title, chapter.label)),
|
||||
finishedAt: const Value(null),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
jobId = await _db.into(_db.jobs).insert(
|
||||
JobsCompanion.insert(
|
||||
type: 'download',
|
||||
titleId: Value(title.id),
|
||||
chapterId: Value(id),
|
||||
status: const Value('queued'),
|
||||
message: Value(_label(title.title, chapter.label)),
|
||||
),
|
||||
);
|
||||
}
|
||||
_jobByChapter[id] = jobId;
|
||||
}
|
||||
notifyListeners();
|
||||
unawaited(_pump());
|
||||
}
|
||||
|
||||
/// Cancel / dismiss a queue row for [chapterId].
|
||||
///
|
||||
/// Always closes open jobs. Only resets chapter status when it was still
|
||||
/// pending/downloading/failed — never wipes an already-downloaded chapter.
|
||||
Future<void> cancelChapter(int chapterId) async {
|
||||
_cancelled.add(chapterId);
|
||||
_chapterIds.removeWhere((id) => id == chapterId);
|
||||
_jobByChapter.remove(chapterId);
|
||||
|
||||
await (_db.update(_db.jobs)
|
||||
..where(
|
||||
(j) =>
|
||||
j.chapterId.equals(chapterId) &
|
||||
j.status.isIn(['queued', 'running', 'failed']),
|
||||
))
|
||||
.write(
|
||||
JobsCompanion(
|
||||
status: const Value('done'),
|
||||
message: const Value('Cancelled'),
|
||||
finishedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
final chapter = await _db.getChapter(chapterId);
|
||||
if (chapter != null && chapter.status != 'downloaded') {
|
||||
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId)))
|
||||
.write(
|
||||
const ChaptersCompanion(status: Value('pending'), error: Value(null)),
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> retryChapter(int chapterId) async {
|
||||
await enqueueChapters([chapterId]);
|
||||
}
|
||||
|
||||
/// Remove offline download: delete CBZ + cache, reset chapter to pending.
|
||||
Future<void> deleteDownload(int chapterId) async {
|
||||
final chapter = await _db.getChapter(chapterId);
|
||||
if (chapter == null) return;
|
||||
await _store.deleteDownload(
|
||||
cbzPath: chapter.cbzPath,
|
||||
cacheDir: chapter.cacheDir,
|
||||
);
|
||||
await _closeOpenJobs(chapterId, message: 'Deleted');
|
||||
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
|
||||
const ChaptersCompanion(
|
||||
status: Value('pending'),
|
||||
cbzPath: Value(null),
|
||||
cacheDir: Value(null),
|
||||
downloadedAt: Value(null),
|
||||
error: Value(null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark all open jobs for [chapterId] as done (dismisses Queue rows).
|
||||
Future<void> _closeOpenJobs(
|
||||
int chapterId, {
|
||||
required String message,
|
||||
double? progress,
|
||||
}) async {
|
||||
await (_db.update(_db.jobs)
|
||||
..where(
|
||||
(j) =>
|
||||
j.chapterId.equals(chapterId) &
|
||||
j.status.isIn(['queued', 'running', 'failed']),
|
||||
))
|
||||
.write(
|
||||
JobsCompanion(
|
||||
status: const Value('done'),
|
||||
progress: progress != null ? Value(progress) : const Value.absent(),
|
||||
message: Value(message),
|
||||
finishedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drop stale queue rows for chapters that are already downloaded.
|
||||
Future<void> reconcileStaleJobs() async {
|
||||
final open = await (_db.select(_db.jobs)
|
||||
..where((j) => j.status.isIn(['queued', 'running', 'failed'])))
|
||||
.get();
|
||||
for (final job in open) {
|
||||
final chapterId = job.chapterId;
|
||||
if (chapterId == null) continue;
|
||||
final chapter = await _db.getChapter(chapterId);
|
||||
if (chapter?.status == 'downloaded') {
|
||||
await _closeOpenJobs(chapterId, message: chapter!.label, progress: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pump() async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_sessionOk = 0;
|
||||
_sessionFailed = 0;
|
||||
_sessionHadWork = false;
|
||||
_sessionStartedAt = DateTime.now();
|
||||
_pendingNtfy.clear();
|
||||
notifyListeners();
|
||||
try {
|
||||
while (_chapterIds.isNotEmpty) {
|
||||
final chapterId = _chapterIds.removeFirst();
|
||||
if (_cancelled.contains(chapterId)) {
|
||||
await _closeOpenJobs(chapterId, message: 'Cancelled');
|
||||
final chapter = await _db.getChapter(chapterId);
|
||||
if (chapter != null && chapter.status != 'downloaded') {
|
||||
await (_db.update(_db.chapters)
|
||||
..where((c) => c.id.equals(chapterId)))
|
||||
.write(
|
||||
const ChaptersCompanion(
|
||||
status: Value('pending'),
|
||||
error: Value(null),
|
||||
),
|
||||
);
|
||||
}
|
||||
_jobByChapter.remove(chapterId);
|
||||
continue;
|
||||
}
|
||||
_sessionHadWork = true;
|
||||
await _downloadOne(chapterId);
|
||||
}
|
||||
} finally {
|
||||
final hadWork = _sessionHadWork;
|
||||
final ok = _sessionOk;
|
||||
final failed = _sessionFailed;
|
||||
final started = _sessionStartedAt;
|
||||
final pending = List<Future<void>>.from(_pendingNtfy);
|
||||
_pendingNtfy.clear();
|
||||
_running = false;
|
||||
_activeChapterId = null;
|
||||
_progress = 0;
|
||||
_message = '';
|
||||
_sessionOk = 0;
|
||||
_sessionFailed = 0;
|
||||
_sessionHadWork = false;
|
||||
_sessionStartedAt = null;
|
||||
notifyListeners();
|
||||
if (hadWork) {
|
||||
// Per-chapter completed/failed webhooks first, then queue-empty.
|
||||
await Future.wait(pending);
|
||||
final duration =
|
||||
started == null ? null : DateTime.now().difference(started);
|
||||
await _ntfy.queueDrained(ok: ok, failed: failed, duration: duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadOne(int chapterId) async {
|
||||
_activeChapterId = chapterId;
|
||||
_progress = 0;
|
||||
|
||||
final chapter = await _db.getChapter(chapterId);
|
||||
if (chapter == null) {
|
||||
await _closeOpenJobs(chapterId, message: 'Chapter missing');
|
||||
return;
|
||||
}
|
||||
final title = await _db.getTitle(chapter.titleId);
|
||||
if (title == null) {
|
||||
await _closeOpenJobs(chapterId, message: 'Series missing');
|
||||
return;
|
||||
}
|
||||
|
||||
final label = _label(title.title, chapter.label);
|
||||
_message = label;
|
||||
notifyListeners();
|
||||
|
||||
var jobId = _jobByChapter[chapterId];
|
||||
if (jobId == null) {
|
||||
final existing = await _db.findOpenJobForChapter(chapterId);
|
||||
if (existing != null) {
|
||||
jobId = existing.id;
|
||||
} else {
|
||||
jobId = await _db.into(_db.jobs).insert(
|
||||
JobsCompanion.insert(
|
||||
type: 'download',
|
||||
titleId: Value(title.id),
|
||||
chapterId: Value(chapterId),
|
||||
status: const Value('running'),
|
||||
message: Value(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
_jobByChapter[chapterId] = jobId;
|
||||
}
|
||||
|
||||
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
|
||||
JobsCompanion(
|
||||
titleId: Value(title.id),
|
||||
status: const Value('running'),
|
||||
progress: const Value(0),
|
||||
message: Value(label),
|
||||
finishedAt: const Value(null),
|
||||
),
|
||||
);
|
||||
|
||||
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
|
||||
const ChaptersCompanion(status: Value('downloading'), error: Value(null)),
|
||||
);
|
||||
|
||||
try {
|
||||
final source = _sources.get(SourceId.values.byName(title.source));
|
||||
final link = ChapterLink(
|
||||
url: chapter.url,
|
||||
title: chapter.label,
|
||||
chapterKey: chapter.chapterKey,
|
||||
chapterNumber: chapter.chapterNumber,
|
||||
);
|
||||
final result = await _store.downloadChapter(
|
||||
source: source,
|
||||
comicTitle: title.title,
|
||||
chapter: link,
|
||||
onProgress: (p, _) {
|
||||
_progress = p;
|
||||
_message = label;
|
||||
notifyListeners();
|
||||
unawaited(
|
||||
(_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
|
||||
JobsCompanion(progress: Value(p), message: Value(label)),
|
||||
),
|
||||
);
|
||||
},
|
||||
isCancelled: () => _cancelled.contains(chapterId),
|
||||
);
|
||||
|
||||
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
|
||||
ChaptersCompanion(
|
||||
status: const Value('downloaded'),
|
||||
cbzPath: Value(result.cbzPath),
|
||||
cacheDir: Value(result.cacheDir),
|
||||
downloadedAt: Value(DateTime.now()),
|
||||
error: const Value(null),
|
||||
),
|
||||
);
|
||||
// Close every open job for this chapter (covers stale duplicates).
|
||||
await _closeOpenJobs(chapterId, message: label, progress: 1);
|
||||
_sessionOk++;
|
||||
_pendingNtfy.add(
|
||||
_ntfy.chapterCompleted(
|
||||
ChapterCompletedPayload(
|
||||
seriesTitle: title.title,
|
||||
chapterLabel: chapter.label,
|
||||
imageCount: result.imageCount,
|
||||
rawBytes: result.rawBytes,
|
||||
cbzBytes: result.cbzBytes,
|
||||
cbzPath: result.cbzPath,
|
||||
cacheDir: result.cacheDir,
|
||||
coverUrl: title.coverUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
final cancelled = _cancelled.contains(chapterId);
|
||||
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
|
||||
ChaptersCompanion(
|
||||
status: Value(cancelled ? 'pending' : 'failed'),
|
||||
error: Value(cancelled ? null : e.toString()),
|
||||
),
|
||||
);
|
||||
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
|
||||
JobsCompanion(
|
||||
status: Value(cancelled ? 'done' : 'failed'),
|
||||
message: Value(cancelled ? 'Cancelled' : label),
|
||||
finishedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
if (!cancelled) {
|
||||
_sessionFailed++;
|
||||
_pendingNtfy.add(
|
||||
_ntfy.jobFailed(
|
||||
seriesTitle: title.title,
|
||||
chapterLabel: chapter.label,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_cancelled.remove(chapterId);
|
||||
_jobByChapter.remove(chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsert series into library from a remote [SeriesDetail].
|
||||
Future<int> upsertSeries(SeriesDetail series) async {
|
||||
final existing = await _db.findTitleByUrl(series.listingUrl);
|
||||
late final int titleId;
|
||||
if (existing == null) {
|
||||
titleId = await _db.into(_db.titles).insert(
|
||||
TitlesCompanion.insert(
|
||||
source: series.source.id,
|
||||
url: series.listingUrl,
|
||||
slug: series.slug,
|
||||
title: series.title,
|
||||
coverUrl: Value(series.coverUrl),
|
||||
description: Value(series.description),
|
||||
lastCheckedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
titleId = existing.id;
|
||||
await (_db.update(_db.titles)..where((t) => t.id.equals(titleId))).write(
|
||||
TitlesCompanion(
|
||||
title: Value(series.title),
|
||||
coverUrl: Value(series.coverUrl),
|
||||
description: Value(series.description),
|
||||
lastCheckedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final ch in series.chapters) {
|
||||
final rows = await (_db.select(_db.chapters)
|
||||
..where(
|
||||
(c) =>
|
||||
c.titleId.equals(titleId) & c.chapterKey.equals(ch.chapterKey),
|
||||
))
|
||||
.get();
|
||||
if (rows.isEmpty) {
|
||||
await _db.into(_db.chapters).insert(
|
||||
ChaptersCompanion.insert(
|
||||
titleId: titleId,
|
||||
chapterKey: ch.chapterKey,
|
||||
chapterNumber: Value(ch.chapterNumber),
|
||||
label: ch.title,
|
||||
url: ch.url,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await (_db.update(_db.chapters)..where((c) => c.id.equals(rows.first.id)))
|
||||
.write(
|
||||
ChaptersCompanion(
|
||||
label: Value(ch.title),
|
||||
url: Value(ch.url),
|
||||
chapterNumber: Value(ch.chapterNumber),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return titleId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
/// Cache manager that attaches ComicLand CDN Referer/Origin headers.
|
||||
class HeaderCacheManager extends CacheManager {
|
||||
HeaderCacheManager(this.headers)
|
||||
: super(
|
||||
Config(
|
||||
'cxyzHeaderCache',
|
||||
fileService: _HeaderHttpFileService(headers),
|
||||
),
|
||||
);
|
||||
|
||||
final Map<String, String> headers;
|
||||
}
|
||||
|
||||
class _HeaderHttpFileService extends HttpFileService {
|
||||
_HeaderHttpFileService(this.headers);
|
||||
|
||||
final Map<String, String> headers;
|
||||
|
||||
@override
|
||||
Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) {
|
||||
final merged = <String, String>{...this.headers, ...?headers};
|
||||
return super.get(url, headers: merged);
|
||||
}
|
||||
}
|
||||
|
||||
ImageProvider headerNetworkImage(String url, Map<String, String> headers) {
|
||||
return NetworkImage(url, headers: headers);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'cbz_store.dart';
|
||||
import 'ntfy_settings.dart';
|
||||
|
||||
const int _kMaxCbzAttachBytes = 15 * 1024 * 1024;
|
||||
const int _kPreviewMaxEdge = 720;
|
||||
const int _kPreviewMaxBytes = 250 * 1024;
|
||||
|
||||
/// Encode for HTTP headers (Dart rejects non-ASCII / CR/LF).
|
||||
/// ntfy decodes RFC 2047 (`=?UTF-8?B?…?=`).
|
||||
String encodeNtfyHeader(String value) {
|
||||
final flat = value.replaceAll(RegExp(r'[\r\n]+'), '\n').trim();
|
||||
if (flat.isEmpty) return flat;
|
||||
final needsEncode =
|
||||
flat.codeUnits.any((c) => c < 0x20 || c > 0x7e);
|
||||
if (!needsEncode) return flat;
|
||||
final b64 = base64.encode(utf8.encode(flat));
|
||||
return '=?UTF-8?B?$b64?=';
|
||||
}
|
||||
|
||||
class ChapterCompletedPayload {
|
||||
const ChapterCompletedPayload({
|
||||
required this.seriesTitle,
|
||||
required this.chapterLabel,
|
||||
required this.imageCount,
|
||||
required this.rawBytes,
|
||||
required this.cbzBytes,
|
||||
this.cbzPath,
|
||||
this.cacheDir,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
final String seriesTitle;
|
||||
final String chapterLabel;
|
||||
final int imageCount;
|
||||
final int rawBytes;
|
||||
final int cbzBytes;
|
||||
final String? cbzPath;
|
||||
final String? cacheDir;
|
||||
final String? coverUrl;
|
||||
}
|
||||
|
||||
/// Fire-and-forget ntfy publisher for download queue events.
|
||||
class NtfyNotifier {
|
||||
NtfyNotifier({Dio? dio, CbzStore? store})
|
||||
: _dio = dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 20),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
sendTimeout: const Duration(seconds: 120),
|
||||
validateStatus: (s) => s != null && s >= 200 && s < 300,
|
||||
),
|
||||
),
|
||||
_store = store ?? CbzStore();
|
||||
|
||||
final Dio _dio;
|
||||
final CbzStore _store;
|
||||
|
||||
static String formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
final kb = bytes / 1024;
|
||||
if (kb < 1024) return '${kb.toStringAsFixed(kb < 10 ? 1 : 0)} KB';
|
||||
final mb = kb / 1024;
|
||||
if (mb < 1024) return '${mb.toStringAsFixed(mb < 10 ? 1 : 0)} MB';
|
||||
final gb = mb / 1024;
|
||||
return '${gb.toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
static String completedTitle(String series, String chapter) =>
|
||||
'Downloaded · $series · $chapter';
|
||||
|
||||
static String failedTitle(String series, String chapter) =>
|
||||
'Download failed · $series · $chapter';
|
||||
|
||||
static String drainedTitle({required int ok, required int failed}) =>
|
||||
'Queue finished · $ok downloaded · $failed failed';
|
||||
|
||||
static String completedBody({
|
||||
required int imageCount,
|
||||
required int rawBytes,
|
||||
required int cbzBytes,
|
||||
String? note,
|
||||
}) {
|
||||
final saved = rawBytes > 0
|
||||
? (((rawBytes - cbzBytes) / rawBytes) * 100).clamp(0, 100).round()
|
||||
: 0;
|
||||
final lines = <String>[
|
||||
'Pages: $imageCount',
|
||||
'Images: ${formatBytes(rawBytes)}',
|
||||
'CBZ: ${formatBytes(cbzBytes)}${rawBytes > 0 ? ' (saved $saved%)' : ''}',
|
||||
];
|
||||
if (note != null && note.isNotEmpty) lines.add(note);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
Future<void> chapterCompleted(ChapterCompletedPayload payload) async {
|
||||
final settings = await NtfySettings.load();
|
||||
if (!settings.enabled) return;
|
||||
final url = settings.urlForCompleted;
|
||||
if (url == null) return;
|
||||
|
||||
final title = completedTitle(payload.seriesTitle, payload.chapterLabel);
|
||||
var note = '';
|
||||
Uint8List? attachBytes;
|
||||
String? filename;
|
||||
String? attachUrl;
|
||||
|
||||
switch (settings.attachmentMode) {
|
||||
case NtfyAttachmentMode.none:
|
||||
break;
|
||||
case NtfyAttachmentMode.cover:
|
||||
final cover = payload.coverUrl?.trim();
|
||||
if (cover == null || cover.isEmpty) {
|
||||
note = 'Attachment skipped (no cover URL)';
|
||||
} else {
|
||||
attachUrl = cover;
|
||||
}
|
||||
break;
|
||||
case NtfyAttachmentMode.firstImage:
|
||||
final preview = await _buildFirstPagePreview(payload.cacheDir);
|
||||
if (preview == null) {
|
||||
note = 'Preview resize failed';
|
||||
} else {
|
||||
attachBytes = preview;
|
||||
filename = 'preview.jpg';
|
||||
}
|
||||
break;
|
||||
case NtfyAttachmentMode.cbz:
|
||||
final path = payload.cbzPath;
|
||||
if (path == null || path.isEmpty) {
|
||||
note = 'Attachment skipped (no CBZ path)';
|
||||
} else if (payload.cbzBytes > _kMaxCbzAttachBytes) {
|
||||
note = 'Attachment skipped (15 MB limit)';
|
||||
} else {
|
||||
try {
|
||||
attachBytes = await _store.readCbzBytes(path);
|
||||
if (attachBytes.length > _kMaxCbzAttachBytes) {
|
||||
attachBytes = null;
|
||||
note = 'Attachment skipped (15 MB limit)';
|
||||
} else {
|
||||
filename = _cbzFilename(payload.chapterLabel);
|
||||
}
|
||||
} catch (_) {
|
||||
note = 'Attachment skipped (read failed)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final body = completedBody(
|
||||
imageCount: payload.imageCount,
|
||||
rawBytes: payload.rawBytes,
|
||||
cbzBytes: payload.cbzBytes,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
|
||||
try {
|
||||
if (attachBytes != null && filename != null) {
|
||||
await _putAttachment(
|
||||
url: url,
|
||||
title: title,
|
||||
message: body,
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
filename: filename,
|
||||
bytes: attachBytes,
|
||||
token: settings.authToken,
|
||||
);
|
||||
} else if (attachUrl != null) {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: title,
|
||||
message: body,
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
attachUrl: attachUrl,
|
||||
);
|
||||
} else {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: title,
|
||||
message: body,
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('ntfy chapterCompleted failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> jobFailed({
|
||||
required String seriesTitle,
|
||||
required String chapterLabel,
|
||||
required String error,
|
||||
}) async {
|
||||
final settings = await NtfySettings.load();
|
||||
if (!settings.enabled) return;
|
||||
final url = settings.urlForFailed;
|
||||
if (url == null) return;
|
||||
|
||||
final trimmed = error.trim();
|
||||
final snippet =
|
||||
trimmed.length > 500 ? '${trimmed.substring(0, 500)}…' : trimmed;
|
||||
try {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: failedTitle(seriesTitle, chapterLabel),
|
||||
message: snippet.isEmpty ? 'Unknown error' : snippet,
|
||||
tags: 'x',
|
||||
priority: '4',
|
||||
token: settings.authToken,
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('ntfy jobFailed failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> queueDrained({
|
||||
required int ok,
|
||||
required int failed,
|
||||
Duration? duration,
|
||||
}) async {
|
||||
final settings = await NtfySettings.load();
|
||||
if (!settings.enabled) return;
|
||||
final url = settings.urlForDrained;
|
||||
if (url == null) return;
|
||||
|
||||
final lines = <String>[];
|
||||
if (duration != null) {
|
||||
lines.add('Finished in ${_formatDuration(duration)}');
|
||||
}
|
||||
lines.add('$ok downloaded · $failed failed');
|
||||
try {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: drainedTitle(ok: ok, failed: failed),
|
||||
message: lines.join('\n'),
|
||||
tags: 'inbox_tray',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('ntfy queueDrained failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a sample notification to [url] (for Settings Test buttons).
|
||||
Future<void> testTopic(String url, {required String kind}) async {
|
||||
final settings = await NtfySettings.load();
|
||||
final trimmed = url.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw StateError('URL is empty');
|
||||
}
|
||||
switch (kind) {
|
||||
case 'completed':
|
||||
await _postText(
|
||||
url: trimmed,
|
||||
title: completedTitle('Sample Series', 'Chapter 1'),
|
||||
message: completedBody(
|
||||
imageCount: 12,
|
||||
rawBytes: 5 * 1024 * 1024,
|
||||
cbzBytes: 4 * 1024 * 1024,
|
||||
note: 'Test notification (${settings.attachmentMode.label})',
|
||||
),
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
case 'failed':
|
||||
await _postText(
|
||||
url: trimmed,
|
||||
title: failedTitle('Sample Series', 'Chapter 2'),
|
||||
message: 'Sample error for testing',
|
||||
tags: 'x',
|
||||
priority: '4',
|
||||
token: settings.authToken,
|
||||
);
|
||||
case 'drained':
|
||||
await _postText(
|
||||
url: trimmed,
|
||||
title: drainedTitle(ok: 3, failed: 1),
|
||||
message: 'Finished in 1m 5s\n3 downloaded · 1 failed',
|
||||
tags: 'inbox_tray',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
default:
|
||||
throw StateError('Unknown test kind: $kind');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _postText({
|
||||
required String url,
|
||||
required String title,
|
||||
required String message,
|
||||
required String tags,
|
||||
required String priority,
|
||||
required String token,
|
||||
String? attachUrl,
|
||||
}) async {
|
||||
await _dio.post<void>(
|
||||
url,
|
||||
data: message,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Title': encodeNtfyHeader(title),
|
||||
'Tags': tags,
|
||||
'Priority': priority,
|
||||
if (attachUrl != null && attachUrl.isNotEmpty)
|
||||
'Attach': encodeNtfyHeader(attachUrl),
|
||||
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
|
||||
},
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _putAttachment({
|
||||
required String url,
|
||||
required String title,
|
||||
required String message,
|
||||
required String tags,
|
||||
required String priority,
|
||||
required String filename,
|
||||
required Uint8List bytes,
|
||||
required String token,
|
||||
}) async {
|
||||
await _dio.put<void>(
|
||||
url,
|
||||
data: bytes,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Title': encodeNtfyHeader(title),
|
||||
// Multiline body must be header-safe (RFC 2047), not raw newlines.
|
||||
'Message': encodeNtfyHeader(message),
|
||||
'Tags': tags,
|
||||
'Priority': priority,
|
||||
'Filename': encodeNtfyHeader(filename),
|
||||
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
|
||||
},
|
||||
contentType: 'application/octet-stream',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _buildFirstPagePreview(String? cacheDir) async {
|
||||
if (cacheDir == null || cacheDir.isEmpty) return null;
|
||||
final dir = Directory(cacheDir);
|
||||
if (!await dir.exists()) return null;
|
||||
final files = await dir
|
||||
.list()
|
||||
.where((e) => e is File)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
files.sort((a, b) => p.basename(a.path).compareTo(p.basename(b.path)));
|
||||
if (files.isEmpty) return null;
|
||||
final bytes = await files.first.readAsBytes();
|
||||
return compute(_resizePreviewIsolate, bytes);
|
||||
}
|
||||
|
||||
static String _cbzFilename(String chapterLabel) {
|
||||
final stem = chapterLabel
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'[^a-zA-Z0-9._-]+'), '_')
|
||||
.replaceAll(RegExp(r'^_+|_+$'), '');
|
||||
final name = stem.isEmpty ? 'chapter' : stem;
|
||||
return name.toLowerCase().endsWith('.cbz') ? name : '$name.cbz';
|
||||
}
|
||||
|
||||
static String _formatDuration(Duration d) {
|
||||
final s = d.inSeconds;
|
||||
if (s < 60) return '${s}s';
|
||||
final m = s ~/ 60;
|
||||
final rem = s % 60;
|
||||
if (m < 60) return rem == 0 ? '${m}m' : '${m}m ${rem}s';
|
||||
final h = m ~/ 60;
|
||||
final rm = m % 60;
|
||||
return rm == 0 ? '${h}h' : '${h}h ${rm}m';
|
||||
}
|
||||
}
|
||||
|
||||
/// Isolate entry: decode → max edge 720 → JPEG ≤ 250 KB.
|
||||
Uint8List? _resizePreviewIsolate(Uint8List input) {
|
||||
return resizePreviewForTest(input);
|
||||
}
|
||||
|
||||
/// Visible for unit tests.
|
||||
Uint8List? resizePreviewForTest(Uint8List input) {
|
||||
final decoded = img.decodeImage(input);
|
||||
if (decoded == null) return null;
|
||||
var image = decoded;
|
||||
final longest = image.width > image.height ? image.width : image.height;
|
||||
if (longest > _kPreviewMaxEdge) {
|
||||
if (image.width >= image.height) {
|
||||
image = img.copyResize(image, width: _kPreviewMaxEdge);
|
||||
} else {
|
||||
image = img.copyResize(image, height: _kPreviewMaxEdge);
|
||||
}
|
||||
}
|
||||
var quality = 75;
|
||||
Uint8List? best;
|
||||
while (quality >= 40) {
|
||||
final encoded = Uint8List.fromList(img.encodeJpg(image, quality: quality));
|
||||
best = encoded;
|
||||
if (encoded.length <= _kPreviewMaxBytes) return encoded;
|
||||
quality -= 15;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// What to attach on chapter-completed ntfy notifications.
|
||||
enum NtfyAttachmentMode {
|
||||
none,
|
||||
cover,
|
||||
firstImage,
|
||||
cbz;
|
||||
|
||||
String get storageValue => name;
|
||||
|
||||
static NtfyAttachmentMode fromStorage(String? raw) {
|
||||
for (final m in NtfyAttachmentMode.values) {
|
||||
if (m.name == raw) return m;
|
||||
}
|
||||
return NtfyAttachmentMode.none;
|
||||
}
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case NtfyAttachmentMode.none:
|
||||
return 'None';
|
||||
case NtfyAttachmentMode.cover:
|
||||
return 'Series cover';
|
||||
case NtfyAttachmentMode.firstImage:
|
||||
return 'First page';
|
||||
case NtfyAttachmentMode.cbz:
|
||||
return 'CBZ file';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NtfySettings {
|
||||
static const _kEnabled = 'ntfy_enabled';
|
||||
static const _kCompletedUrl = 'ntfy_completed_url';
|
||||
static const _kFailedUrl = 'ntfy_failed_url';
|
||||
static const _kDrainedUrl = 'ntfy_drained_url';
|
||||
static const _kAttachment = 'ntfy_attachment_mode';
|
||||
static const _tokenKey = 'ntfy_auth_token';
|
||||
|
||||
static const _secure = FlutterSecureStorage();
|
||||
|
||||
final bool enabled;
|
||||
final String completedUrl;
|
||||
final String failedUrl;
|
||||
final String drainedUrl;
|
||||
final NtfyAttachmentMode attachmentMode;
|
||||
final String authToken;
|
||||
|
||||
const NtfySettings({
|
||||
required this.enabled,
|
||||
required this.completedUrl,
|
||||
required this.failedUrl,
|
||||
required this.drainedUrl,
|
||||
required this.attachmentMode,
|
||||
required this.authToken,
|
||||
});
|
||||
|
||||
String? get urlForCompleted => _nonEmpty(completedUrl);
|
||||
String? get urlForFailed => _nonEmpty(failedUrl);
|
||||
String? get urlForDrained => _nonEmpty(drainedUrl);
|
||||
|
||||
static String? _nonEmpty(String raw) {
|
||||
final t = raw.trim();
|
||||
return t.isEmpty ? null : t;
|
||||
}
|
||||
|
||||
static Future<NtfySettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = await _secure.read(key: _tokenKey) ?? '';
|
||||
return NtfySettings(
|
||||
enabled: prefs.getBool(_kEnabled) ?? false,
|
||||
completedUrl: prefs.getString(_kCompletedUrl) ?? '',
|
||||
failedUrl: prefs.getString(_kFailedUrl) ?? '',
|
||||
drainedUrl: prefs.getString(_kDrainedUrl) ?? '',
|
||||
attachmentMode: NtfyAttachmentMode.fromStorage(
|
||||
prefs.getString(_kAttachment),
|
||||
),
|
||||
authToken: token,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> save({
|
||||
required bool enabled,
|
||||
required String completedUrl,
|
||||
required String failedUrl,
|
||||
required String drainedUrl,
|
||||
required NtfyAttachmentMode attachmentMode,
|
||||
required String authToken,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kEnabled, enabled);
|
||||
await prefs.setString(_kCompletedUrl, completedUrl.trim());
|
||||
await prefs.setString(_kFailedUrl, failedUrl.trim());
|
||||
await prefs.setString(_kDrainedUrl, drainedUrl.trim());
|
||||
await prefs.setString(_kAttachment, attachmentMode.storageValue);
|
||||
await _secure.write(key: _tokenKey, value: authToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Privacy mode: hide/blur comic covers in lists.
|
||||
class PeekMode {
|
||||
static const prefKey = 'peek_mode_enabled';
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> save(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Vertical continuous scroll vs horizontal page turns.
|
||||
enum ReaderMode {
|
||||
webtoon,
|
||||
paged;
|
||||
|
||||
String get storageValue => this == ReaderMode.paged ? 'paged' : 'webtoon';
|
||||
|
||||
static ReaderMode fromStorage(String? raw) {
|
||||
return raw == 'paged' ? ReaderMode.paged : ReaderMode.webtoon;
|
||||
}
|
||||
}
|
||||
|
||||
/// Global / per-title paged-reader tap/swipe direction.
|
||||
enum ReadingDirection {
|
||||
ltr,
|
||||
rtl;
|
||||
|
||||
String get storageValue => name;
|
||||
|
||||
static ReadingDirection fromStorage(String? raw) {
|
||||
return raw == 'rtl' ? ReadingDirection.rtl : ReadingDirection.ltr;
|
||||
}
|
||||
}
|
||||
|
||||
class ReaderDirection {
|
||||
static const prefKey = 'reader_reading_direction';
|
||||
|
||||
static Future<ReadingDirection> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return ReadingDirection.fromStorage(prefs.getString(prefKey));
|
||||
}
|
||||
|
||||
static Future<void> save(ReadingDirection direction) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, direction.storageValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Layout zoom for the in-app reader (`pageWidth = screenWidth * scale`).
|
||||
class ReaderZoom {
|
||||
static const prefKey = 'reader_zoom_scale';
|
||||
static const double min = 0.4;
|
||||
static const double max = 1.5;
|
||||
static const double step = 0.1;
|
||||
static const double defaultScale = 1.0;
|
||||
|
||||
static double clamp(double value) {
|
||||
final stepped = (value / step).round() * step;
|
||||
return stepped.clamp(min, max);
|
||||
}
|
||||
|
||||
static Future<double> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getDouble(prefKey);
|
||||
if (raw == null) return defaultScale;
|
||||
return clamp(raw);
|
||||
}
|
||||
|
||||
static Future<void> save(double scale) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(prefKey, clamp(scale));
|
||||
}
|
||||
|
||||
static String percentLabel(double scale) {
|
||||
final pct = (clamp(scale) * 100).round();
|
||||
return '$pct%';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import '../sources/source.dart';
|
||||
import 'reader_direction.dart';
|
||||
|
||||
/// Built-in reader prefs per catalog source (used when a title has no override).
|
||||
class SourceReaderDefaults {
|
||||
const SourceReaderDefaults._();
|
||||
|
||||
/// ComicLand → vertical webtoon; MangaRead / MangaTown → horizontal paged.
|
||||
static ReaderMode modeFor(String sourceId) {
|
||||
switch (SourceId.tryParse(sourceId)) {
|
||||
case SourceId.mangaread:
|
||||
case SourceId.mangatown:
|
||||
return ReaderMode.paged;
|
||||
case SourceId.comicland:
|
||||
case null:
|
||||
return ReaderMode.webtoon;
|
||||
}
|
||||
}
|
||||
|
||||
/// MangaRead / MangaTown → RTL; ComicLand falls back to global Settings.
|
||||
static ReadingDirection? directionFor(String sourceId) {
|
||||
switch (SourceId.tryParse(sourceId)) {
|
||||
case SourceId.mangaread:
|
||||
case SourceId.mangatown:
|
||||
return ReadingDirection.rtl;
|
||||
case SourceId.comicland:
|
||||
case null:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve stored title mode, else [modeFor].
|
||||
static ReaderMode resolveMode(String sourceId, String? storedMode) {
|
||||
final raw = storedMode?.trim() ?? '';
|
||||
if (raw == 'paged' || raw == 'webtoon') {
|
||||
return ReaderMode.fromStorage(raw);
|
||||
}
|
||||
return modeFor(sourceId);
|
||||
}
|
||||
|
||||
/// Resolve stored title direction, else [directionFor], else [globalFallback].
|
||||
static ReadingDirection resolveDirection(
|
||||
String sourceId,
|
||||
String? storedDirection, {
|
||||
required ReadingDirection globalFallback,
|
||||
}) {
|
||||
final raw = storedDirection?.trim() ?? '';
|
||||
if (raw == 'ltr' || raw == 'rtl') {
|
||||
return ReadingDirection.fromStorage(raw);
|
||||
}
|
||||
return directionFor(sourceId) ?? globalFallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class WebDavSettings {
|
||||
static const _kEnabled = 'webdav_enabled';
|
||||
static const _kUrl = 'webdav_url';
|
||||
static const _kUsername = 'webdav_username';
|
||||
static const _kPath = 'webdav_path';
|
||||
static const _kLastSync = 'webdav_last_sync';
|
||||
static const _kLastError = 'webdav_last_error';
|
||||
static const _kLastStatus = 'webdav_last_status';
|
||||
static const _passwordKey = 'webdav_password';
|
||||
|
||||
static const defaultPath = '/cxyz/progress.json';
|
||||
|
||||
static const _secure = FlutterSecureStorage();
|
||||
|
||||
final bool enabled;
|
||||
final String url;
|
||||
final String username;
|
||||
final String password;
|
||||
final String path;
|
||||
final DateTime? lastSyncAt;
|
||||
final String? lastError;
|
||||
final String? lastStatus;
|
||||
|
||||
const WebDavSettings({
|
||||
required this.enabled,
|
||||
required this.url,
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.path,
|
||||
this.lastSyncAt,
|
||||
this.lastError,
|
||||
this.lastStatus,
|
||||
});
|
||||
|
||||
bool get isConfigured =>
|
||||
url.trim().isNotEmpty && username.trim().isNotEmpty;
|
||||
|
||||
static Future<WebDavSettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final password = await _secure.read(key: _passwordKey) ?? '';
|
||||
final lastSyncRaw = prefs.getString(_kLastSync);
|
||||
return WebDavSettings(
|
||||
enabled: prefs.getBool(_kEnabled) ?? false,
|
||||
url: prefs.getString(_kUrl) ?? '',
|
||||
username: prefs.getString(_kUsername) ?? '',
|
||||
password: password,
|
||||
path: prefs.getString(_kPath) ?? defaultPath,
|
||||
lastSyncAt:
|
||||
lastSyncRaw == null ? null : DateTime.tryParse(lastSyncRaw),
|
||||
lastError: prefs.getString(_kLastError),
|
||||
lastStatus: prefs.getString(_kLastStatus),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> save({
|
||||
required bool enabled,
|
||||
required String url,
|
||||
required String username,
|
||||
required String password,
|
||||
required String path,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kEnabled, enabled);
|
||||
await prefs.setString(_kUrl, url.trim());
|
||||
await prefs.setString(_kUsername, username.trim());
|
||||
await prefs.setString(
|
||||
_kPath,
|
||||
path.trim().isEmpty ? defaultPath : path.trim(),
|
||||
);
|
||||
await _secure.write(key: _passwordKey, value: password);
|
||||
}
|
||||
|
||||
static Future<void> setSyncResult({
|
||||
String? status,
|
||||
String? error,
|
||||
DateTime? syncedAt,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (status != null) await prefs.setString(_kLastStatus, status);
|
||||
if (error != null) {
|
||||
await prefs.setString(_kLastError, error);
|
||||
} else {
|
||||
await prefs.remove(_kLastError);
|
||||
}
|
||||
if (syncedAt != null) {
|
||||
await prefs.setString(_kLastSync, syncedAt.toUtc().toIso8601String());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../data/database.dart';
|
||||
import 'webdav_settings.dart';
|
||||
|
||||
class WebDavSyncService {
|
||||
WebDavSyncService(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
bool _busy = false;
|
||||
Timer? _debounce;
|
||||
|
||||
void scheduleSync() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(seconds: 10), () {
|
||||
unawaited(sync());
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> testConnection() async {
|
||||
final settings = await WebDavSettings.load();
|
||||
if (!settings.isConfigured) {
|
||||
throw StateError('Enter WebDAV URL and username first');
|
||||
}
|
||||
final dio = _dio(settings);
|
||||
final url = _fileUrl(settings);
|
||||
try {
|
||||
final res = await dio.request(
|
||||
url,
|
||||
options: Options(
|
||||
method: 'PROPFIND',
|
||||
headers: {'Depth': '0'},
|
||||
validateStatus: (s) => s != null && s < 500,
|
||||
),
|
||||
);
|
||||
// Some hosts reject PROPFIND; try GET as fallback.
|
||||
if (res.statusCode == 404 || res.statusCode == 405 || res.statusCode == 501) {
|
||||
final get = await dio.get<List<int>>(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes,
|
||||
validateStatus: (s) => s != null && (s == 200 || s == 404),
|
||||
),
|
||||
);
|
||||
if (get.statusCode == 200 || get.statusCode == 404) {
|
||||
return 'Connection OK';
|
||||
}
|
||||
throw StateError('HTTP ${get.statusCode}');
|
||||
}
|
||||
if (res.statusCode != null && res.statusCode! >= 200 && res.statusCode! < 300) {
|
||||
return 'Connection OK';
|
||||
}
|
||||
if (res.statusCode == 401 || res.statusCode == 403) {
|
||||
throw StateError('Auth failed (HTTP ${res.statusCode})');
|
||||
}
|
||||
// Parent exists even if file missing
|
||||
if (res.statusCode == 404) return 'Connection OK (file not created yet)';
|
||||
throw StateError('HTTP ${res.statusCode}');
|
||||
} on DioException catch (e) {
|
||||
throw StateError(e.message ?? e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sync() async {
|
||||
if (_busy) return;
|
||||
final settings = await WebDavSettings.load();
|
||||
if (!settings.enabled || !settings.isConfigured) return;
|
||||
_busy = true;
|
||||
try {
|
||||
await _syncOnce(settings);
|
||||
await WebDavSettings.setSyncResult(
|
||||
status: 'Synced',
|
||||
error: null,
|
||||
syncedAt: DateTime.now(),
|
||||
);
|
||||
} catch (e) {
|
||||
await WebDavSettings.setSyncResult(
|
||||
status: 'Failed',
|
||||
error: e.toString(),
|
||||
);
|
||||
rethrow;
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncOnce(WebDavSettings settings, {int attempt = 0}) async {
|
||||
final dio = _dio(settings);
|
||||
final url = _fileUrl(settings);
|
||||
|
||||
String? etag;
|
||||
Map<String, dynamic> remote = {
|
||||
'updatedAt': null,
|
||||
'titles': <dynamic>[],
|
||||
'chapters': <dynamic>[],
|
||||
};
|
||||
|
||||
final get = await dio.get<List<int>>(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes,
|
||||
validateStatus: (s) => s != null && (s == 200 || s == 404),
|
||||
),
|
||||
);
|
||||
if (get.statusCode == 200 && get.data != null) {
|
||||
etag = get.headers.value('etag');
|
||||
final text = utf8.decode(get.data!);
|
||||
if (text.trim().isNotEmpty) {
|
||||
remote = jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
final localBundle = await _exportLocal();
|
||||
final merged = _merge(remote, localBundle);
|
||||
await _applyRemote(merged);
|
||||
|
||||
final body = utf8.encode(const JsonEncoder.withIndent(' ').convert(merged));
|
||||
final headers = <String, dynamic>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (etag != null && etag.isNotEmpty) {
|
||||
headers['If-Match'] = etag;
|
||||
} else if (get.statusCode == 404) {
|
||||
// Create only if absent
|
||||
headers['If-None-Match'] = '*';
|
||||
}
|
||||
|
||||
try {
|
||||
final put = await dio.put<List<int>>(
|
||||
url,
|
||||
data: body,
|
||||
options: Options(
|
||||
headers: headers,
|
||||
validateStatus: (s) =>
|
||||
s != null && ((s >= 200 && s < 300) || s == 412),
|
||||
),
|
||||
);
|
||||
if (put.statusCode == 412) {
|
||||
if (attempt >= 2) {
|
||||
throw StateError('Sync conflict: too many retries');
|
||||
}
|
||||
await _syncOnce(settings, attempt: attempt + 1);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
// Weak hosts may ignore precondition; retry once without If-Match.
|
||||
if (attempt == 0 && etag != null) {
|
||||
await dio.put(
|
||||
url,
|
||||
data: body,
|
||||
options: Options(headers: {'Content-Type': 'application/json'}),
|
||||
);
|
||||
} else {
|
||||
throw StateError(e.message ?? e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dio _dio(WebDavSettings settings) {
|
||||
return Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 20),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
'Authorization':
|
||||
'Basic ${base64Encode(utf8.encode('${settings.username}:${settings.password}'))}',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fileUrl(WebDavSettings settings) {
|
||||
final base = settings.url.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
var path = settings.path.trim().isEmpty
|
||||
? WebDavSettings.defaultPath
|
||||
: settings.path.trim();
|
||||
if (!path.startsWith('/')) path = '/$path';
|
||||
return '$base$path';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _exportLocal() async {
|
||||
final titles = await _db.select(_db.titles).get();
|
||||
final chapters = await _db.select(_db.chapters).get();
|
||||
final titleById = {for (final t in titles) t.id: t};
|
||||
|
||||
final titleMaps = <Map<String, dynamic>>[];
|
||||
for (final t in titles) {
|
||||
String? chapterKey;
|
||||
if (t.lastReadChapterId != null) {
|
||||
final ch = await _db.getChapter(t.lastReadChapterId!);
|
||||
chapterKey = ch?.chapterKey;
|
||||
}
|
||||
titleMaps.add({
|
||||
'source': t.source,
|
||||
'url': t.url,
|
||||
'slug': t.slug,
|
||||
'title': t.title,
|
||||
'coverUrl': t.coverUrl,
|
||||
'inLibrary': t.inLibrary,
|
||||
'readerMode': t.readerMode,
|
||||
'readingDirection': t.readingDirection,
|
||||
'lastReadChapterKey': chapterKey,
|
||||
'lastReadAt': t.lastReadAt?.toUtc().toIso8601String(),
|
||||
'libraryUpdatedAt': t.libraryUpdatedAt?.toUtc().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
final chapterMaps = <Map<String, dynamic>>[];
|
||||
for (final c in chapters) {
|
||||
final t = titleById[c.titleId];
|
||||
if (t == null) continue;
|
||||
if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue;
|
||||
chapterMaps.add({
|
||||
'source': t.source,
|
||||
'seriesUrl': t.url,
|
||||
'chapterKey': c.chapterKey,
|
||||
'label': c.label,
|
||||
'url': c.url,
|
||||
'chapterNumber': c.chapterNumber,
|
||||
'lastPage': c.lastPage,
|
||||
'pageCount': c.pageCount,
|
||||
'completed': c.completed,
|
||||
'lastReadAt': c.lastReadAt?.toUtc().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
'updatedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'titles': titleMaps,
|
||||
'chapters': chapterMaps,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _merge(
|
||||
Map<String, dynamic> remote,
|
||||
Map<String, dynamic> local,
|
||||
) {
|
||||
final remoteTitles = _asMapList(remote['titles']);
|
||||
final localTitles = _asMapList(local['titles']);
|
||||
final titles = <String, Map<String, dynamic>>{};
|
||||
for (final t in [...remoteTitles, ...localTitles]) {
|
||||
final key = '${t['source']}|${t['url']}';
|
||||
final existing = titles[key];
|
||||
if (existing == null) {
|
||||
titles[key] = Map<String, dynamic>.from(t);
|
||||
continue;
|
||||
}
|
||||
titles[key] = _mergeTitle(existing, t);
|
||||
}
|
||||
|
||||
final remoteChapters = _asMapList(remote['chapters']);
|
||||
final localChapters = _asMapList(local['chapters']);
|
||||
final chapters = <String, Map<String, dynamic>>{};
|
||||
for (final c in [...remoteChapters, ...localChapters]) {
|
||||
final key = '${c['source']}|${c['seriesUrl']}|${c['chapterKey']}';
|
||||
final existing = chapters[key];
|
||||
if (existing == null) {
|
||||
chapters[key] = Map<String, dynamic>.from(c);
|
||||
continue;
|
||||
}
|
||||
chapters[key] = _newerBy(existing, c, 'lastReadAt');
|
||||
}
|
||||
|
||||
return {
|
||||
'updatedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'titles': titles.values.toList(),
|
||||
'chapters': chapters.values.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mergeTitle(
|
||||
Map<String, dynamic> a,
|
||||
Map<String, dynamic> b,
|
||||
) {
|
||||
final out = Map<String, dynamic>.from(a);
|
||||
// Metadata: prefer non-empty
|
||||
for (final field in ['title', 'coverUrl', 'slug']) {
|
||||
final bv = b[field];
|
||||
if ((out[field] == null || (out[field] as String?)?.isEmpty == true) &&
|
||||
bv is String &&
|
||||
bv.isNotEmpty) {
|
||||
out[field] = bv;
|
||||
}
|
||||
}
|
||||
// History / continue by lastReadAt
|
||||
final newerRead = _newerBy(a, b, 'lastReadAt');
|
||||
out['lastReadAt'] = newerRead['lastReadAt'];
|
||||
out['lastReadChapterKey'] = newerRead['lastReadChapterKey'];
|
||||
out['readerMode'] = newerRead['readerMode'] ?? out['readerMode'];
|
||||
out['readingDirection'] =
|
||||
newerRead['readingDirection'] ?? out['readingDirection'];
|
||||
// Library star by libraryUpdatedAt
|
||||
final newerLib = _newerBy(a, b, 'libraryUpdatedAt');
|
||||
out['inLibrary'] = newerLib['inLibrary'] ?? false;
|
||||
out['libraryUpdatedAt'] = newerLib['libraryUpdatedAt'];
|
||||
return out;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _newerBy(
|
||||
Map<String, dynamic> a,
|
||||
Map<String, dynamic> b,
|
||||
String field,
|
||||
) {
|
||||
final at = _parseTime(a[field]);
|
||||
final bt = _parseTime(b[field]);
|
||||
if (bt == null) return a;
|
||||
if (at == null) return b;
|
||||
return bt.isAfter(at) ? b : a;
|
||||
}
|
||||
|
||||
DateTime? _parseTime(dynamic v) {
|
||||
if (v is! String || v.isEmpty) return null;
|
||||
return DateTime.tryParse(v)?.toUtc();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _asMapList(dynamic v) {
|
||||
if (v is! List) return const [];
|
||||
return v
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _applyRemote(Map<String, dynamic> merged) async {
|
||||
final titleMaps = _asMapList(merged['titles']);
|
||||
final chapterMaps = _asMapList(merged['chapters']);
|
||||
|
||||
for (final t in titleMaps) {
|
||||
final source = t['source'] as String? ?? '';
|
||||
final url = t['url'] as String? ?? '';
|
||||
if (source.isEmpty || url.isEmpty) continue;
|
||||
final existing = await _db.findTitleBySourceUrl(source, url);
|
||||
final lastReadAt = _parseTime(t['lastReadAt']);
|
||||
final libraryUpdatedAt = _parseTime(t['libraryUpdatedAt']);
|
||||
final inLibrary = t['inLibrary'] == true;
|
||||
late final int titleId;
|
||||
if (existing == null) {
|
||||
titleId = await _db.into(_db.titles).insert(
|
||||
TitlesCompanion.insert(
|
||||
source: source,
|
||||
url: url,
|
||||
slug: (t['slug'] as String?) ?? '',
|
||||
title: (t['title'] as String?) ?? url,
|
||||
coverUrl: Value(t['coverUrl'] as String?),
|
||||
readerMode: Value(
|
||||
(t['readerMode'] as String?) ?? '',
|
||||
),
|
||||
readingDirection: Value(t['readingDirection'] as String?),
|
||||
lastReadAt: Value(lastReadAt),
|
||||
inLibrary: Value(inLibrary),
|
||||
libraryUpdatedAt: Value(libraryUpdatedAt),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
titleId = existing.id;
|
||||
await (_db.update(_db.titles)..where((x) => x.id.equals(titleId)))
|
||||
.write(
|
||||
TitlesCompanion(
|
||||
title: Value(
|
||||
(t['title'] as String?)?.isNotEmpty == true
|
||||
? t['title'] as String
|
||||
: existing.title,
|
||||
),
|
||||
coverUrl: Value(
|
||||
(t['coverUrl'] as String?) ?? existing.coverUrl,
|
||||
),
|
||||
slug: Value(
|
||||
(t['slug'] as String?)?.isNotEmpty == true
|
||||
? t['slug'] as String
|
||||
: existing.slug,
|
||||
),
|
||||
readerMode: Value(
|
||||
(t['readerMode'] as String?) ?? existing.readerMode,
|
||||
),
|
||||
readingDirection: Value(
|
||||
(t['readingDirection'] as String?) ?? existing.readingDirection,
|
||||
),
|
||||
lastReadAt: Value(lastReadAt ?? existing.lastReadAt),
|
||||
inLibrary: Value(inLibrary),
|
||||
libraryUpdatedAt: Value(libraryUpdatedAt ?? existing.libraryUpdatedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final chapterKey = t['lastReadChapterKey'] as String?;
|
||||
if (chapterKey != null && chapterKey.isNotEmpty) {
|
||||
final rows = await (_db.select(_db.chapters)
|
||||
..where(
|
||||
(c) =>
|
||||
c.titleId.equals(titleId) & c.chapterKey.equals(chapterKey),
|
||||
))
|
||||
.get();
|
||||
if (rows.isNotEmpty) {
|
||||
await (_db.update(_db.titles)..where((x) => x.id.equals(titleId)))
|
||||
.write(
|
||||
TitlesCompanion(lastReadChapterId: Value(rows.first.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final c in chapterMaps) {
|
||||
final source = c['source'] as String? ?? '';
|
||||
final seriesUrl = c['seriesUrl'] as String? ?? '';
|
||||
final chapterKey = c['chapterKey'] as String? ?? '';
|
||||
if (source.isEmpty || seriesUrl.isEmpty || chapterKey.isEmpty) continue;
|
||||
var title = await _db.findTitleBySourceUrl(source, seriesUrl);
|
||||
if (title == null) {
|
||||
final titleId = await _db.into(_db.titles).insert(
|
||||
TitlesCompanion.insert(
|
||||
source: source,
|
||||
url: seriesUrl,
|
||||
slug: '',
|
||||
title: seriesUrl,
|
||||
),
|
||||
);
|
||||
title = await _db.getTitle(titleId);
|
||||
}
|
||||
if (title == null) continue;
|
||||
|
||||
final rows = await (_db.select(_db.chapters)
|
||||
..where(
|
||||
(ch) =>
|
||||
ch.titleId.equals(title!.id) &
|
||||
ch.chapterKey.equals(chapterKey),
|
||||
))
|
||||
.get();
|
||||
final lastReadAt = _parseTime(c['lastReadAt']);
|
||||
final lastPage = (c['lastPage'] as num?)?.toInt() ?? 0;
|
||||
final pageCount = (c['pageCount'] as num?)?.toInt() ?? 0;
|
||||
final completed = c['completed'] == true;
|
||||
if (rows.isEmpty) {
|
||||
await _db.into(_db.chapters).insert(
|
||||
ChaptersCompanion.insert(
|
||||
titleId: title.id,
|
||||
chapterKey: chapterKey,
|
||||
label: (c['label'] as String?) ?? chapterKey,
|
||||
url: (c['url'] as String?) ?? seriesUrl,
|
||||
chapterNumber: Value(c['chapterNumber'] as String?),
|
||||
lastPage: Value(lastPage),
|
||||
pageCount: Value(pageCount),
|
||||
completed: Value(completed),
|
||||
lastReadAt: Value(lastReadAt),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final local = rows.first;
|
||||
final remoteTime = lastReadAt;
|
||||
final localTime = local.lastReadAt;
|
||||
final takeRemote = remoteTime != null &&
|
||||
(localTime == null || remoteTime.isAfter(localTime));
|
||||
if (takeRemote) {
|
||||
await (_db.update(_db.chapters)..where((x) => x.id.equals(local.id)))
|
||||
.write(
|
||||
ChaptersCompanion(
|
||||
lastPage: Value(lastPage),
|
||||
pageCount: Value(pageCount > 0 ? pageCount : local.pageCount),
|
||||
completed: Value(completed),
|
||||
lastReadAt: Value(lastReadAt),
|
||||
label: Value(
|
||||
(c['label'] as String?)?.isNotEmpty == true
|
||||
? c['label'] as String
|
||||
: local.label,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user