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:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
@@ -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;
}
}