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
+4
View File
@@ -3,3 +3,7 @@ docs/
data/
public/*
!public/.gitkeep
.tools/
cxyz-android/.dart_tool/
cxyz-android/.flutter-plugins-dependencies
cxyz-android/build/
+86
View File
@@ -0,0 +1,86 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ / Android Studio
*.iml
*.ipr
*.iws
.idea/
# VS Code (uncomment to ignore local workspace settings)
#.vscode/
# Flutter / Dart / Pub
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
.fvm/
build/
coverage/
.widget_preview/
# Local analysis / temp HOME used by scripts and tooling
.tmp-home/
.tmp/
# Symbolication / obfuscation
app.*.symbols
app.*.map.json
# Android build outputs (also covered under android/)
/android/app/debug
/android/app/profile
/android/app/release
**/android/**/gradle-wrapper.jar
**/android/.gradle/
**/android/captures/
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/android/.cxx/
# Signing — never commit keystores or local key config
**/android/key.properties
**/*.keystore
**/*.jks
*.apk
*.aab
*.ap_
# Secrets / local env
.env
.env.*
!.env.example
# Test / coverage extras
coverage/
.test_coverage/
*.lcov
# Platform ephemeral (if added later)
**/ios/Pods/
**/ios/.symlinks/
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/ephemeral/
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
**/macos/Flutter/ephemeral/
**/linux/flutter/ephemeral/
**/windows/flutter/ephemeral/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "6655482ec06e547f90abf8ae7590466f4415978d"
channel: "[user-branch]"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 6655482ec06e547f90abf8ae7590466f4415978d
base_revision: 6655482ec06e547f90abf8ae7590466f4415978d
- platform: android
create_revision: 6655482ec06e547f90abf8ae7590466f4415978d
base_revision: 6655482ec06e547f90abf8ae7590466f4415978d
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+91
View File
@@ -0,0 +1,91 @@
# cxyz Android (Flutter)
On-device manga/comic reader/downloader — Flutter reimplementation of [cxyz](../) adapters for Android.
## Features (v1)
- **Catalog**: source picker → per-source menus (not one shared tab bar)
- **Sources** (build-time): ComicLand, MangaRead, and/or MangaTown via `--dart-define=SOURCES=…` (or `SOURCES=all`)
- **Series**: chapter list, sync, multi-select / download-all (unread filter)
- **Reader**: gapless vertical webtoon + horizontal paged mode; progress + star
- **Library**: starred series only; **History**: recently read
- **Downloads**: running job + queue link; CBZ under public `Download/Comics/`
- **Settings**: RTL + WebDAV sync (progress, library, history)
## Stack
Flutter 3 · Riverpod · go_router · Dio · Drift (SQLite) · archive (CBZ) · cached_network_image · html
## Setup
Use a local Flutter SDK (this repo may use `../.tools/flutter-sdk` on the machine that cloned it):
```bash
export PATH="/Users/Shared/LocalData/Projects/cxyz/.tools/bin:/Users/Shared/LocalData/Projects/cxyz/.tools/flutter-sdk/bin:$PATH"
export CI=true BOT=true
cd cxyz-android
flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter run
```
Sideload APK only (not Play Store–friendly).
## Build-time sources (`SOURCES`)
Compile with Flutter `--dart-define`. Unset / invalid → **ComicLand only** (not all sources).
Prefer the helper (copies a tagged filename and prints the path):
```bash
./scripts/build_apk.sh # → …/cxyz-comicland-release.apk
./scripts/build_apk.sh mangaread # → …/cxyz-mangaread-release.apk
./scripts/build_apk.sh comicland,mangaread # → …/cxyz-comicland-mangaread-release.apk
./scripts/build_apk.sh all # → …/cxyz-all-release.apk
./scripts/build_apk.sh mangaread --debug # → …/cxyz-mangaread-debug.apk
```
Or call Flutter directly (always writes `app-release.apk` / `app-debug.apk`):
```bash
flutter build apk
flutter build apk --dart-define=SOURCES=mangaread
flutter build apk --dart-define=SOURCES=comicland,mangaread,mangatown
flutter build apk --dart-define=SOURCES=all
flutter run --dart-define=SOURCES=all
```
With two or more sources enabled, Catalog opens a **source picker**; each source shows only its own menus (e.g. ComicLand: Newest / Popular / …; MangaRead: Latest / Search; MangaTown: Latest / Hot / Directory / …). A single-source build skips the picker and opens that source’s home.
## ComicLand API
Base: `https://api.comicland.org/api`
| Endpoint | Use |
|---|---|
| `GET /comics/popular` | Popular shelf |
| `GET /comics?offset&limit` | Latest / browse |
| `GET /comic/search?q&offset&limit` | Search |
| `GET /comic/detail?slug=` | Series + chapters |
| `GET /chapter/pages_by_index?slug&index` | Page image URLs |
CDN images need Referer/Origin `https://comicland.org/`.
## MangaRead (HTML / Madara)
Origin: `https://www.mangaread.org/`
- Series: listing HTML `li.wp-manga-chapter`, fallback `POST …/ajax/chapters/`
- Pages: chapter HTML with mobile UA → `img.wp-manga-chapter-img`
- Catalog: Latest (`/manga/?m_orderby=latest`) + Search (`?s=&post_type=wp-manga`)
- Cloudflare: direct Dio only; HTTP 403 surfaces a clear error (no FlareSolverr on phone)
## MangaTown (HTML / mobile)
Origin: `https://m.mangatown.com`
- Catalog: Latest (`/latest`), Hot (`/hot`, `?tviews.za` / `?wviews.za` / `?mviews.za`), Directory (`/directory/{demo}-{genre}-0-{status}-{letter}-0`), Search (`/search?name=`)
- Series: `/manga/{slug}` → `ul.detail-ch-list`
- Pages: one image per reader page (`#viewer img#image`); page N at `/manga/{slug}/c005/N.html`
- CDN images need Referer `https://m.mangatown.com/`
+19
View File
@@ -0,0 +1,19 @@
# Extra analysis options for Drift / Flutter.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
errors:
invalid_annotation_target: ignore
linter:
rules:
prefer_const_constructors: false
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "org.cxyz.cxyz_android"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "org.cxyz.cxyz_android"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 26
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,52 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<application
android:label="cxyz"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,153 @@
package org.cxyz.cxyz_android
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
class MainActivity : FlutterActivity() {
private val channelName = "org.cxyz.cxyz_android/cbz_store"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"saveCbzFromPath" -> {
val relativeDir = call.argument<String>("relativeDir")
val displayName = call.argument<String>("displayName")
val sourcePath = call.argument<String>("sourcePath")
if (relativeDir == null || displayName == null || sourcePath == null) {
result.error(
"bad_args",
"relativeDir, displayName, sourcePath required",
null,
)
return@setMethodCallHandler
}
try {
result.success(saveCbzFromPath(relativeDir, displayName, sourcePath))
} catch (e: Exception) {
result.error("save_failed", e.message, null)
}
}
"readCbzToPath" -> {
val uriOrPath = call.argument<String>("uri")
val destPath = call.argument<String>("destPath")
if (uriOrPath == null || destPath == null) {
result.error("bad_args", "uri and destPath required", null)
return@setMethodCallHandler
}
try {
result.success(readCbzToPath(uriOrPath, destPath))
} catch (e: Exception) {
result.error("read_failed", e.message, null)
}
}
"deleteCbz" -> {
val uriOrPath = call.argument<String>("uri")
if (uriOrPath == null) {
result.error("bad_args", "uri required", null)
return@setMethodCallHandler
}
try {
result.success(deleteCbz(uriOrPath))
} catch (e: Exception) {
result.error("delete_failed", e.message, null)
}
}
"sdkInt" -> result.success(Build.VERSION.SDK_INT)
else -> result.notImplemented()
}
}
}
private fun saveCbzFromPath(
relativeDir: String,
displayName: String,
sourcePath: String,
): String {
val source = File(sourcePath)
if (!source.exists()) {
throw IllegalStateException("Source file missing: $sourcePath")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = applicationContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.comicbook+zip")
put(
MediaStore.MediaColumns.RELATIVE_PATH,
"${Environment.DIRECTORY_DOWNLOADS}/$relativeDir",
)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = resolver.insert(collection, values)
?: throw IllegalStateException("MediaStore insert returned null")
resolver.openOutputStream(uri)?.use { out ->
FileInputStream(source).use { input -> input.copyTo(out) }
out.flush()
} ?: throw IllegalStateException("Could not open output stream for $uri")
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
return uri.toString()
}
val downloads =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val dir = File(downloads, relativeDir)
if (!dir.exists() && !dir.mkdirs()) {
throw IllegalStateException("Could not create ${dir.absolutePath}")
}
val file = File(dir, displayName)
FileInputStream(source).use { input ->
FileOutputStream(file).use { out ->
input.copyTo(out)
out.flush()
}
}
return file.absolutePath
}
private fun readCbzToPath(uriOrPath: String, destPath: String): String {
val dest = File(destPath)
dest.parentFile?.mkdirs()
if (uriOrPath.startsWith("content://")) {
val uri = Uri.parse(uriOrPath)
applicationContext.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(dest).use { out ->
input.copyTo(out)
out.flush()
}
} ?: throw IllegalStateException("Could not open $uriOrPath")
} else {
File(uriOrPath).inputStream().use { input ->
FileOutputStream(dest).use { out ->
input.copyTo(out)
out.flush()
}
}
}
return dest.absolutePath
}
private fun deleteCbz(uriOrPath: String): Boolean {
return if (uriOrPath.startsWith("content://")) {
val uri = Uri.parse(uriOrPath)
applicationContext.contentResolver.delete(uri, null, null) > 0
} else {
val file = File(uriOrPath)
!file.exists() || file.delete()
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+59
View File
@@ -0,0 +1,59 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
// flutter_secure_storage 11 requests compileSdk 37; some SDKs install that
// platform as android-37.0, which AGP cannot resolve. Cap plugins at 36.
subprojects {
afterEvaluate {
val android = project.extensions.findByName("android") ?: return@afterEvaluate
try {
val method = android.javaClass.methods.firstOrNull {
it.name == "setCompileSdkVersion" || it.name == "setCompileSdk"
}
// Prefer the Int setter used by AGP 8+
val setCompileSdk = android.javaClass.methods.firstOrNull { m ->
m.name == "setCompileSdk" && m.parameterTypes.size == 1
}
if (setCompileSdk != null) {
val current = android.javaClass.methods
.firstOrNull { it.name == "getCompileSdk" && it.parameterCount == 0 }
?.invoke(android)
val api = when (current) {
is Int -> current
is String -> current.toIntOrNull() ?: 0
else -> 0
}
if (api > 36) {
setCompileSdk.invoke(android, 36)
}
} else if (method != null) {
method.invoke(android, 36)
}
} catch (_: Exception) {
// Non-Android subprojects — ignore.
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/providers.dart';
import 'router.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: CxyzApp()));
}
class CxyzApp extends ConsumerStatefulWidget {
const CxyzApp({super.key});
@override
ConsumerState<CxyzApp> createState() => _CxyzAppState();
}
class _CxyzAppState extends ConsumerState<CxyzApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(webDavSyncProvider).scheduleSync();
}
}
@override
Widget build(BuildContext context) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'cxyz',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1B6CA8),
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1B6CA8),
brightness: Brightness.dark,
),
useMaterial3: true,
),
routerConfig: router,
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/database.dart';
import '../services/cbz_store.dart';
import '../services/download_queue.dart';
import '../services/image_headers.dart';
import '../services/ntfy_notifier.dart';
import '../services/peek_mode.dart';
import '../services/reader_direction.dart';
import '../services/webdav_sync.dart';
import '../sources/registry.dart';
final sourceRegistryProvider = Provider<SourceRegistry>((ref) {
return SourceRegistry();
});
final databaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
ref.onDispose(db.close);
return db;
});
final cbzStoreProvider = Provider<CbzStore>((ref) => CbzStore());
final ntfyNotifierProvider = Provider<NtfyNotifier>((ref) {
return NtfyNotifier(store: ref.watch(cbzStoreProvider));
});
final downloadQueueProvider = ChangeNotifierProvider<DownloadQueue>((ref) {
final queue = DownloadQueue(
db: ref.watch(databaseProvider),
sources: ref.watch(sourceRegistryProvider),
store: ref.watch(cbzStoreProvider),
ntfy: ref.watch(ntfyNotifierProvider),
);
// Clear leftover queue rows for chapters already on disk (e.g. older builds).
unawaited(queue.reconcileStaleJobs());
return queue;
});
/// Per-request headers come from [ComicCover] / reader via `httpHeaders`.
final imageCacheManagerProvider = Provider<HeaderCacheManager>((ref) {
return HeaderCacheManager(const {});
});
final libraryTitlesProvider = StreamProvider<List<Title>>((ref) {
final db = ref.watch(databaseProvider);
return (db.select(db.titles)
..where((t) => t.inLibrary.equals(true))
..orderBy([(t) => OrderingTerm.asc(t.title)]))
.watch();
});
final historyTitlesProvider = StreamProvider<List<Title>>((ref) {
final db = ref.watch(databaseProvider);
return (db.select(db.titles)
..where((t) => t.lastReadAt.isNotNull())
..orderBy([(t) => OrderingTerm.desc(t.lastReadAt)]))
.watch();
});
final queueJobsProvider = StreamProvider<List<QueueJobView>>((ref) {
final db = ref.watch(databaseProvider);
return db.watchQueueJobs();
});
final downloadedSeriesProvider =
StreamProvider<List<DownloadedSeriesGroup>>((ref) {
final db = ref.watch(databaseProvider);
return db.watchDownloadedSeries();
});
class PeekModeNotifier extends StateNotifier<bool> {
PeekModeNotifier() : super(false) {
_load();
}
Future<void> _load() async {
state = await PeekMode.load();
}
Future<void> toggle() async {
state = !state;
await PeekMode.save(state);
}
Future<void> setEnabled(bool enabled) async {
state = enabled;
await PeekMode.save(enabled);
}
}
final peekModeProvider =
StateNotifierProvider<PeekModeNotifier, bool>((ref) => PeekModeNotifier());
class ReadingDirectionNotifier extends StateNotifier<ReadingDirection> {
ReadingDirectionNotifier() : super(ReadingDirection.ltr) {
_load();
}
Future<void> _load() async {
state = await ReaderDirection.load();
}
Future<void> setDirection(ReadingDirection direction) async {
state = direction;
await ReaderDirection.save(direction);
}
Future<void> toggle() async {
await setDirection(
state == ReadingDirection.ltr
? ReadingDirection.rtl
: ReadingDirection.ltr,
);
}
}
final readingDirectionProvider =
StateNotifierProvider<ReadingDirectionNotifier, ReadingDirection>(
(ref) => ReadingDirectionNotifier(),
);
final webDavSyncProvider = Provider<WebDavSyncService>((ref) {
return WebDavSyncService(ref.watch(databaseProvider));
});
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../services/reader_direction.dart';
import '../sources/source.dart';
import '../sources/source_flags.dart';
import '../ui/catalog/source_picker_page.dart';
import '../ui/catalog_page.dart';
import '../ui/downloads_page.dart';
import '../ui/history_page.dart';
import '../ui/library_page.dart';
import '../ui/reader_page.dart';
import '../ui/settings_page.dart';
import '../ui/shell.dart';
import '../ui/title_page.dart';
final _rootKey = GlobalKey<NavigatorState>();
SourceId? _parseSourceId(String? raw) {
if (raw == null) return null;
return SourceId.tryParse(raw);
}
CatalogMenuKind? _parseMenu(String? raw) {
if (raw == null) return null;
for (final k in CatalogMenuKind.values) {
if (k.pathSegment == raw) return k;
}
return null;
}
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
navigatorKey: _rootKey,
initialLocation: '/catalog',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return AppShell(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
// Sibling routes (not nested under picker) so back stack is clean.
GoRoute(
path: '/catalog',
redirect: (context, state) {
final enabled = enabledSourceIds();
if (enabled.length == 1) {
return '/catalog/${enabled.first.name}';
}
return null;
},
builder: (context, state) => const SourcePickerPage(),
),
GoRoute(
path: '/catalog/:sourceId',
builder: (context, state) {
final id = _parseSourceId(state.pathParameters['sourceId']);
if (id == null) {
return const Scaffold(
body: Center(child: Text('Unknown source')),
);
}
final single = enabledSourceIds().length == 1;
return SourceHomePage(
sourceId: id,
embeddedAsRoot: single,
);
},
routes: [
GoRoute(
path: ':menu',
builder: (context, state) {
final id =
_parseSourceId(state.pathParameters['sourceId']);
final menu = _parseMenu(state.pathParameters['menu']);
if (id == null || menu == null) {
return const Scaffold(
body: Center(child: Text('Unknown catalog feed')),
);
}
return CatalogFeedPage(sourceId: id, menuKind: menu);
},
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/library',
builder: (context, state) => const LibraryPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/history',
builder: (context, state) => const HistoryPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/downloads',
builder: (context, state) => const DownloadsPage(),
routes: [
GoRoute(
path: 'queue',
builder: (context, state) => const DownloadsQueuePage(),
),
],
),
],
),
],
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/title/:id',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return TitlePage(titleId: id);
},
),
GoRoute(
path: '/read/:titleId/:chapterId',
builder: (context, state) {
final titleId = int.parse(state.pathParameters['titleId']!);
final chapterId = int.parse(state.pathParameters['chapterId']!);
final modeParam = state.uri.queryParameters['mode'];
ReaderMode? initialMode;
if (modeParam == 'paged') {
initialMode = ReaderMode.paged;
} else if (modeParam == 'webtoon') {
initialMode = ReaderMode.webtoon;
}
return ReaderPage(
titleId: titleId,
chapterId: chapterId,
initialMode: initialMode,
);
},
),
],
);
});
+388
View File
@@ -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);
}
}
+16
View File
@@ -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());
}
}
}
+474
View File
@@ -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,
),
),
);
}
}
}
}
}
@@ -0,0 +1,235 @@
import 'package:dio/dio.dart';
import '../source.dart';
import 'constants.dart';
import 'urls.dart';
/// ComicLand JSON adapter (port of cxyz `src/comicland/*`).
class ComicLandSource implements ComicSource {
ComicLandSource({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
baseUrl: comicLandApiBase,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: comicLandApiHeaders,
responseType: ResponseType.json,
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
final Dio _dio;
@override
SourceId get id => SourceId.comicland;
@override
String get displayName => 'ComicLand';
@override
String get blurb =>
'Newest, popular, official, uncensored, recommended, and search via ComicLand API';
@override
List<CatalogMenu> get catalogMenus => const [
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Newest'),
CatalogMenu(kind: CatalogMenuKind.popular, title: 'Popular'),
CatalogMenu(kind: CatalogMenuKind.official, title: 'Official'),
CatalogMenu(kind: CatalogMenuKind.uncensored, title: 'Uncensored'),
CatalogMenu(kind: CatalogMenuKind.recommended, title: 'Recommended'),
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
];
@override
Map<String, String> get imageHeaders => Map.unmodifiable(comicLandCdnHeaders);
Map<String, dynamic> _unwrap(dynamic payload, String label) {
if (payload is! Map) {
throw StateError('ComicLand $label: empty response');
}
final map = Map<String, dynamic>.from(payload);
final code = map['code'];
if (code != 0) {
final msg = (map['message'] as String?)?.trim();
throw StateError(
'ComicLand $label failed: ${msg?.isNotEmpty == true ? msg : 'code $code'}',
);
}
final data = map['data'];
if (data is! Map) {
throw StateError('ComicLand $label: missing data');
}
return Map<String, dynamic>.from(data);
}
Future<Map<String, dynamic>> _get(
String path, {
Map<String, dynamic>? query,
String label = 'request',
}) async {
final res = await _dio.get<dynamic>(path, queryParameters: query);
return _unwrap(res.data, label);
}
List<CatalogComic> _listFrom(Map<String, dynamic> data, {String listKey = 'list'}) {
final raw = data[listKey];
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((e) => catalogFromJson(Map<String, dynamic>.from(e)))
.toList();
}
@override
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
}) async {
switch (kind) {
case CatalogMenuKind.hotToday:
case CatalogMenuKind.hotWeek:
case CatalogMenuKind.hotMonth:
case CatalogMenuKind.directory:
throw UnsupportedError('ComicLand has no ${kind.name} catalog feed');
case CatalogMenuKind.popular:
final data = await _get('/comics/popular', label: 'comics/popular');
final all = _listFrom(data);
if (offset == 0 && all.length <= limit) return all;
final end = (offset + limit).clamp(0, all.length);
if (offset >= all.length) return const [];
return all.sublist(offset, end);
case CatalogMenuKind.latest:
final data = await _get(
'/comics',
query: {'offset': offset, 'limit': limit, 'status': 'ongoing'},
label: 'comics/newest',
);
return _listFrom(data);
case CatalogMenuKind.official:
final data = await _get(
'/comics/official',
query: {'offset': offset, 'limit': limit},
label: 'comics/official',
);
return _listFrom(data);
case CatalogMenuKind.uncensored:
final data = await _get(
'/comic/search',
query: {'q': 'uncensored', 'offset': offset, 'limit': limit},
label: 'comic/search/uncensored',
);
return _listFrom(data, listKey: 'items');
case CatalogMenuKind.recommended:
final data = await _get(
'/comics',
query: {'offset': offset, 'limit': limit},
label: 'comics/recommended',
);
return _listFrom(data);
case CatalogMenuKind.search:
final q = query.trim();
if (q.length < 2) return const [];
final data = await _get(
'/comic/search',
query: {'q': q, 'offset': offset, 'limit': limit},
label: 'comic/search',
);
return _listFrom(data, listKey: 'items');
}
}
@override
Future<SeriesDetail> fetchSeries(String listingUrl) async {
final parts = parseComicLandUrl(listingUrl);
final data = await _get(
'/comic/detail',
query: {'slug': parts.slug},
label: 'comic/detail',
);
final title = (data['title'] as String?)?.trim().isNotEmpty == true
? (data['title'] as String).trim()
: parts.slug;
final rawChapters = data['chapters'];
final chapters = <ChapterLink>[];
if (rawChapters is List) {
for (final row in rawChapters) {
if (row is! Map) continue;
final map = Map<String, dynamic>.from(row);
final index = _chapterIndexString(map['chapter_index']);
if (index == null) continue;
final label = (map['title'] as String?)?.trim().replaceAll(RegExp(r'\s+'), ' ');
final chapterTitle =
(label != null && label.isNotEmpty) ? label : 'Chapter $index';
final url = chapterUrlFromParts(parts.origin, parts.slug, index);
chapters.add(
ChapterLink(
url: url,
title: chapterTitle,
chapterKey: chapterKeyFromUrl(url),
chapterNumber: chapterNumberFromTitle(chapterTitle) ?? index,
),
);
}
}
chapters.sort((a, b) {
final na = double.tryParse(Uri.parse(a.url).pathSegments.last);
final nb = double.tryParse(Uri.parse(b.url).pathSegments.last);
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.url.compareTo(b.url);
});
final listing = listingUrlForSlug(parts.slug);
return SeriesDetail(
source: SourceId.comicland,
listingUrl: listing,
slug: parts.slug,
title: title,
coverUrl: data['cover_url'] as String?,
description: data['description'] as String?,
chapters: chapters,
);
}
@override
Future<List<String>> fetchPageUrls(String chapterUrl) async {
final parts = parseComicLandUrl(chapterUrl);
if (parts.chapterIndex == null) {
throw StateError('ComicLand chapter URL is missing an index: $chapterUrl');
}
final data = await _get(
'/chapter/pages_by_index',
query: {'slug': parts.slug, 'index': parts.chapterIndex},
label: 'chapter/pages_by_index',
);
final pages = data['pages'];
if (pages is! List) return const [];
final urls = <String>[];
for (final raw in pages) {
if (raw is! String || raw.trim().isEmpty) continue;
try {
urls.add(Uri.parse(raw.trim()).toString());
} catch (_) {}
}
return urls;
}
String? _chapterIndexString(dynamic rawIndex) {
if (rawIndex is num && rawIndex.isFinite) {
if (rawIndex == rawIndex.roundToDouble()) {
return rawIndex.toInt().toString();
}
return rawIndex.toString();
}
if (rawIndex is String && rawIndex.trim().isNotEmpty) {
return rawIndex.trim();
}
return null;
}
}
@@ -0,0 +1,33 @@
// ComicLand API / CDN constants (port of cxyz `src/comicland/constants.js`).
const comicLandHosts = <String>['comicland.org', 'www.comicland.org'];
const comicLandOrigin = 'https://comicland.org/';
const comicLandApiOrigin = 'https://api.comicland.org';
/// Axios-style base used by the SPA: `https://api.comicland.org/api`.
const comicLandApiBase = '$comicLandApiOrigin/api';
const comicLandDefaultListing =
'https://comicland.org/comic/the-delinquent-girl';
const comicLandUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const comicLandApiHeaders = <String, String>{
'Accept': 'application/json,text/plain,*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Origin': 'https://comicland.org',
'Referer': comicLandOrigin,
'User-Agent': comicLandUserAgent,
};
const comicLandCdnHeaders = <String, String>{
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': comicLandOrigin,
'Origin': 'https://comicland.org',
'User-Agent': comicLandUserAgent,
};
@@ -0,0 +1,149 @@
import '../source.dart';
import 'constants.dart';
class ComicLandUrlParts {
const ComicLandUrlParts({
required this.origin,
required this.slug,
this.chapterIndex,
});
final String origin;
final String slug;
final String? chapterIndex;
}
bool isLikelyComicLandSite(String url) {
try {
final host = Uri.parse(url).host.toLowerCase();
return comicLandHosts.contains(host) || host.contains('comicland');
} catch (_) {
return false;
}
}
String seriesSlugFromListingUrl(String pageUrl) {
try {
final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = parts.indexOf('comic');
if (comicIdx != -1 && comicIdx + 1 < parts.length) {
return parts[comicIdx + 1];
}
} catch (_) {}
return 'comic';
}
ComicLandUrlParts parseComicLandUrl(String pageUrl) {
final u = Uri.parse(pageUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = parts.indexOf('comic');
final slug =
comicIdx != -1 && comicIdx + 1 < parts.length ? parts[comicIdx + 1] : 'comic';
String? chapterIndex;
final chapterIdx = parts.indexOf('chapter');
if (chapterIdx != -1 && chapterIdx + 1 < parts.length) {
chapterIndex = parts[chapterIdx + 1];
}
return ComicLandUrlParts(
origin: '${u.scheme}://${u.host}',
slug: slug,
chapterIndex: chapterIndex,
);
}
String chapterUrlFromParts(String siteOrigin, String slug, Object chapterIndex) {
final origin = siteOrigin.replaceAll(RegExp(r'/+$'), '');
return '$origin/comic/$slug/chapter/$chapterIndex';
}
String listingUrlForSlug(String slug) =>
'${comicLandOrigin.replaceAll(RegExp(r'/+$'), '')}/comic/$slug';
String chapterKeyFromUrl(String chapterUrl) {
try {
final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList();
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String? chapterNumberFromTitle(String title) {
final m = RegExp(r'^Chapter\s+([\d.]+)', caseSensitive: false)
.firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' '));
return m?.group(1);
}
String sanitizeFsStem(String stem) {
final cleaned = stem
.replaceAll(RegExp(r'[^a-z0-9._-]+', caseSensitive: false), '_')
.replaceAll(RegExp(r'^_+|_+$'), '');
if (cleaned.isEmpty) return 'chapter';
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
}
String sanitizeComicDirName(String name) {
final collapsed = name.trim().replaceAll(RegExp(r'\s+'), ' ');
final cleaned = String.fromCharCodes(
collapsed.runes.where((code) {
if (code < 32 || code == 127) return false;
final ch = String.fromCharCode(code);
return !RegExp(r'[<>:"/\\|?*]').hasMatch(ch);
}),
).replaceAll(RegExp(r'\.+$'), '').trim();
if (cleaned.isEmpty) return 'comic';
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
}
String stemForComicLandChapterCbz(String chapterPageUrl) {
try {
final segs =
Uri.parse(chapterPageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = segs.indexOf('comic');
if (comicIdx != -1 &&
segs.length >= comicIdx + 4 &&
segs[comicIdx + 2] == 'chapter') {
return '${segs[comicIdx + 1]}-chapter-${segs[comicIdx + 3]}';
}
if (segs.length >= 2) {
return '${segs[segs.length - 2]}-${segs.last}';
}
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String transferLinkBasename(String? chapterNumber, String? label, String? cbzPath) {
final candidates = [chapterNumber, label]
.whereType<String>()
.where((s) => s.trim().isNotEmpty);
for (final raw in candidates) {
final text = raw.trim();
final m = RegExp(r'(\d+)((?:\.\d+)*)([a-zA-Z]*)').firstMatch(text);
if (m != null) {
final padded = m.group(1)!.padLeft(3, '0');
return 'chapter $padded${m.group(2) ?? ''}${m.group(3) ?? ''}.cbz';
}
}
if (cbzPath != null && cbzPath.isNotEmpty) {
final base = cbzPath.split('/').last;
if (base.isNotEmpty) return base;
}
return 'chapter unknown.cbz';
}
CatalogComic catalogFromJson(Map<String, dynamic> row) {
final slug = (row['slug'] as String?)?.trim() ?? 'comic';
return CatalogComic(
source: SourceId.comicland,
slug: slug,
title: (row['title'] as String?)?.trim().isNotEmpty == true
? (row['title'] as String).trim()
: slug,
listingUrl: listingUrlForSlug(slug),
coverUrl: row['cover_url'] as String?,
description: row['description'] as String?,
rating: (row['rating'] is num) ? (row['rating'] as num).toDouble() : null,
);
}
@@ -0,0 +1,32 @@
/// MangaRead / Madara constants (port of cxyz mangaread fetch helpers).
library;
const mangaReadHosts = <String>['www.mangaread.org', 'mangaread.org'];
const mangaReadOrigin = 'https://www.mangaread.org/';
const mangaReadDesktopUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const mangaReadMobileUa =
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 '
'(KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1';
const mangaReadHtmlHeaders = <String, String>{
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': mangaReadDesktopUa,
};
const mangaReadReaderHeaders = <String, String>{
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': mangaReadMobileUa,
};
const mangaReadCdnHeaders = <String, String>{
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': mangaReadOrigin,
'User-Agent': mangaReadDesktopUa,
};
@@ -0,0 +1,328 @@
import 'package:dio/dio.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import '../source.dart';
import 'constants.dart';
import 'urls.dart';
/// MangaRead / Madara HTML adapter (port of cxyz `mangareadFetchChapters.js`).
class MangaReadSource implements ComicSource {
MangaReadSource({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: mangaReadHtmlHeaders,
responseType: ResponseType.plain,
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
final Dio _dio;
@override
SourceId get id => SourceId.mangaread;
@override
String get displayName => 'MangaRead';
@override
String get blurb => 'Latest and search via mangaread.org (Madara HTML)';
@override
List<CatalogMenu> get catalogMenus => const [
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'),
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
];
@override
Map<String, String> get imageHeaders => Map.unmodifiable(mangaReadCdnHeaders);
Never _cloudflareOrRethrow(DioException e, String label) {
final code = e.response?.statusCode;
if (code == 403) {
throw StateError(
'MangaRead $label blocked (HTTP 403 / Cloudflare). '
'Direct fetch is not available right now.',
);
}
throw StateError('MangaRead $label failed: ${e.message ?? e}');
}
Future<String> _getHtml(
String url, {
Map<String, String>? headers,
String label = 'request',
}) async {
try {
final res = await _dio.get<String>(
url,
options: Options(headers: headers ?? mangaReadHtmlHeaders),
);
return res.data ?? '';
} on DioException catch (e) {
_cloudflareOrRethrow(e, label);
}
}
Future<String> _postAjaxChapters(String mangaBaseUrl, String refererUrl) async {
final base = mangaBaseUrl.endsWith('/') ? mangaBaseUrl : '$mangaBaseUrl/';
final ajaxUrl = Uri.parse(base).resolve('ajax/chapters/').toString();
final origin = Uri.parse(refererUrl).origin;
try {
final res = await _dio.post<String>(
ajaxUrl,
data: '',
options: Options(
headers: {
...mangaReadHtmlHeaders,
'Accept': '*/*',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Referer': refererUrl,
'Origin': origin,
'X-Requested-With': 'XMLHttpRequest',
},
responseType: ResponseType.plain,
),
);
return res.data ?? '';
} on DioException catch (e) {
_cloudflareOrRethrow(e, 'ajax/chapters');
}
}
@override
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
}) async {
switch (kind) {
case CatalogMenuKind.popular:
throw UnsupportedError('MangaRead has no popular catalog feed');
case CatalogMenuKind.official:
throw UnsupportedError('MangaRead has no official catalog feed');
case CatalogMenuKind.uncensored:
throw UnsupportedError('MangaRead has no uncensored catalog feed');
case CatalogMenuKind.recommended:
throw UnsupportedError('MangaRead has no recommended catalog feed');
case CatalogMenuKind.hotToday:
case CatalogMenuKind.hotWeek:
case CatalogMenuKind.hotMonth:
case CatalogMenuKind.directory:
throw UnsupportedError('MangaRead has no ${kind.name} catalog feed');
case CatalogMenuKind.latest:
// Madara HTML pages are ~12 titles each. Callers pass offset as
// (pageIndex * theirLimit); map that to a 1-based site page.
final page = (offset ~/ (limit > 0 ? limit : 12)) + 1;
final url = page <= 1
? '${mangaReadOrigin}manga/?m_orderby=latest'
: '${mangaReadOrigin}manga/page/$page/?m_orderby=latest';
final html = await _getHtml(url, label: 'latest');
return _parseCatalogCards(html, baseHref: url);
case CatalogMenuKind.search:
final q = query.trim();
if (q.length < 2) return const [];
// Same page mapping as latest; Madara search paginates via /page/N/.
final page = (offset ~/ (limit > 0 ? limit : 12)) + 1;
final uri = page <= 1
? Uri.parse(mangaReadOrigin).replace(
queryParameters: {
's': q,
'post_type': 'wp-manga',
},
)
: Uri.parse(
'${mangaReadOrigin}page/$page/',
).replace(
queryParameters: {
's': q,
'post_type': 'wp-manga',
},
);
final html = await _getHtml(uri.toString(), label: 'search');
return _parseCatalogCards(html, baseHref: uri.toString());
}
}
List<CatalogComic> _parseCatalogCards(String html, {required String baseHref}) {
final doc = html_parser.parse(html);
final cards = <dom.Element>[
...doc.querySelectorAll('.page-item-detail'),
...doc.querySelectorAll('.c-tabs-item__content'),
];
final seen = <String>{};
final out = <CatalogComic>[];
for (final card in cards) {
final titleA = card.querySelector('.post-title a') ??
card.querySelector('h3 a') ??
card.querySelector('h5 a') ??
card.querySelector('a');
if (titleA == null) continue;
final href = titleA.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final listingUrl = absoluteUrl(href, baseHref);
if (!listingUrl.contains('/manga/')) continue;
final slug = mangaSlugFromUrl(listingUrl);
if (slug == 'comic' || !seen.add(slug)) continue;
final title = titleA.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (title.isEmpty) continue;
String? coverUrl;
final img = card.querySelector('img');
if (img != null) {
coverUrl = _pickImageSrc(img);
if (coverUrl != null) {
coverUrl = absoluteUrl(coverUrl, baseHref);
}
}
out.add(
CatalogComic(
source: SourceId.mangaread,
slug: slug,
title: title,
listingUrl: listingUrlForSlug(slug),
coverUrl: coverUrl,
),
);
}
return out;
}
String? _pickImageSrc(dom.Element img) {
for (final attr in [
'data-src',
'data-lazy-src',
'data-original',
'data-full-url',
'src',
]) {
final raw = img.attributes[attr]?.trim();
if (raw != null && raw.isNotEmpty && !raw.startsWith('data:')) {
return raw;
}
}
return null;
}
List<ChapterLink> _extractChapters(String html, String baseHref) {
final doc = html_parser.parse(html);
final items = doc.querySelectorAll('li.wp-manga-chapter');
final chapters = <ChapterLink>[];
for (final li in items) {
final a = li.querySelector('a');
if (a == null) continue;
final href = a.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final title = a.text.trim().replaceAll(RegExp(r'\s+'), ' ');
final url = absoluteUrl(href, baseHref);
chapters.add(
ChapterLink(
url: url,
title: title.isNotEmpty ? title : 'Chapter',
chapterKey: chapterKeyFromUrl(url),
chapterNumber: chapterNumberFromTitle(title),
),
);
}
return chapters;
}
String? _extractTitle(String html) {
final doc = html_parser.parse(html);
final og = doc.querySelector('meta[property="og:title"]');
final ogContent = og?.attributes['content']?.trim();
if (ogContent != null && ogContent.isNotEmpty) {
return ogContent.replaceAll(RegExp(r'\s+'), ' ');
}
final h1 = doc.querySelector('.profile-manga .post-title h1');
final h1Text = h1?.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (h1Text != null && h1Text.isNotEmpty) return h1Text;
final titleEl = doc.querySelector('title');
final titleText = titleEl?.text.trim() ?? '';
final m = RegExp(r'^Read\s+(.+?)\s+-\s+manga', caseSensitive: false)
.firstMatch(titleText);
if (m != null) {
return m.group(1)!.trim().replaceAll(RegExp(r'\s+'), ' ');
}
return null;
}
String? _extractCover(String html, String baseHref) {
final doc = html_parser.parse(html);
final og = doc.querySelector('meta[property="og:image"]');
final ogUrl = og?.attributes['content']?.trim();
if (ogUrl != null && ogUrl.isNotEmpty) {
return absoluteUrl(ogUrl, baseHref);
}
final img = doc.querySelector('.summary_image img') ??
doc.querySelector('.tab-summary img');
if (img == null) return null;
final raw = _pickImageSrc(img);
if (raw == null) return null;
return absoluteUrl(raw, baseHref);
}
@override
Future<SeriesDetail> fetchSeries(String listingUrl) async {
final base = mangaBaseUrlFromListingUrl(listingUrl);
final html = await _getHtml(base, label: 'series');
var chapters = _extractChapters(html, base);
if (chapters.isEmpty) {
final ajaxHtml = await _postAjaxChapters(base, listingUrl);
chapters = _extractChapters(ajaxHtml, base);
}
// Madara lists newest first; store oldest → newest for consistent numbering.
if (chapters.length > 1) {
chapters = chapters.reversed.toList(growable: false);
}
final slug = mangaSlugFromUrl(base);
final title = _extractTitle(html) ?? slug;
return SeriesDetail(
source: SourceId.mangaread,
listingUrl: base,
slug: slug,
title: title,
coverUrl: _extractCover(html, base),
chapters: chapters,
);
}
List<String> _collectPageUrls(String html, String baseHref) {
final doc = html_parser.parse(html);
final imgs = doc.querySelectorAll('img.wp-manga-chapter-img');
final urls = <String>[];
for (final img in imgs) {
final raw = _pickImageSrc(img);
if (raw == null) continue;
try {
urls.add(absoluteUrl(raw, baseHref));
} catch (_) {}
}
return urls;
}
@override
Future<List<String>> fetchPageUrls(String chapterUrl) async {
var html = await _getHtml(
chapterUrl,
headers: mangaReadReaderHeaders,
label: 'chapter (mobile)',
);
var urls = _collectPageUrls(html, chapterUrl);
if (urls.isEmpty) {
html = await _getHtml(chapterUrl, label: 'chapter (desktop)');
urls = _collectPageUrls(html, chapterUrl);
}
return urls;
}
}
@@ -0,0 +1,47 @@
String mangaSlugFromUrl(String pageUrl) {
try {
final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi != -1 && mi + 1 < parts.length) {
return parts[mi + 1];
}
} catch (_) {}
return 'comic';
}
String mangaBaseUrlFromListingUrl(String pageUrl) {
final u = Uri.parse(pageUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi == -1 || mi + 1 >= parts.length) {
throw StateError(
'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaRead.',
);
}
final slug = parts[mi + 1];
return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug/').toString();
}
String listingUrlForSlug(String slug, {String origin = 'https://www.mangaread.org'}) {
final base = origin.replaceAll(RegExp(r'/+$'), '');
return '$base/manga/$slug/';
}
String chapterKeyFromUrl(String chapterUrl) {
try {
final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList();
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String? chapterNumberFromTitle(String title) {
final m = RegExp(r'(?:Chapter|Ch\.?)\s*([\d.]+)', caseSensitive: false)
.firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' '));
return m?.group(1);
}
String absoluteUrl(String href, String baseHref) {
return Uri.parse(baseHref).resolve(href.trim()).toString();
}
@@ -0,0 +1,24 @@
/// MangaTown mobile (`m.mangatown.com`) constants.
library;
const mangaTownOrigin = 'https://m.mangatown.com';
const mangaTownMobileUa =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 '
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const mangaTownHtmlHeaders = <String, String>{
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': mangaTownMobileUa,
};
const mangaTownImageHeaders = <String, String>{
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': '$mangaTownOrigin/',
'User-Agent': mangaTownMobileUa,
};
/// Site catalog page size (Latest / Hot / Directory).
const mangaTownCatalogPageSize = 20;
@@ -0,0 +1,307 @@
import 'package:dio/dio.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import '../source.dart';
import 'constants.dart';
import 'urls.dart';
/// MangaTown mobile HTML adapter (`m.mangatown.com`).
class MangaTownSource implements ComicSource {
MangaTownSource({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: mangaTownHtmlHeaders,
responseType: ResponseType.plain,
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
final Dio _dio;
static const _pageFetchConcurrency = 4;
@override
SourceId get id => SourceId.mangatown;
@override
String get displayName => 'MangaTown';
@override
String get blurb =>
'Latest, hot rankings, directory filters, and search via m.mangatown.com';
@override
List<CatalogMenu> get catalogMenus => const [
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'),
CatalogMenu(kind: CatalogMenuKind.popular, title: 'Hot'),
CatalogMenu(kind: CatalogMenuKind.hotToday, title: 'Hot Today'),
CatalogMenu(kind: CatalogMenuKind.hotWeek, title: 'Hot Week'),
CatalogMenu(kind: CatalogMenuKind.hotMonth, title: 'Hot Month'),
CatalogMenu(kind: CatalogMenuKind.directory, title: 'Directory'),
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
];
@override
Map<String, String> get imageHeaders =>
Map.unmodifiable(mangaTownImageHeaders);
Future<String> _getHtml(
String url, {
Map<String, String>? headers,
String label = 'request',
}) async {
try {
final res = await _dio.get<String>(
url,
options: Options(headers: headers ?? mangaTownHtmlHeaders),
);
return res.data ?? '';
} on DioException catch (e) {
throw StateError('MangaTown $label failed: ${e.message ?? e}');
}
}
/// CatalogFeedPage uses caller `limit` as page stride (like MangaRead).
int _sitePage(int offset, int limit) {
final stride = limit > 0 ? limit : mangaTownCatalogPageSize;
return (offset ~/ stride) + 1;
}
@override
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
}) async {
final page = _sitePage(offset, limit);
late final String url;
switch (kind) {
case CatalogMenuKind.official:
case CatalogMenuKind.uncensored:
case CatalogMenuKind.recommended:
throw UnsupportedError('MangaTown has no ${kind.name} catalog feed');
case CatalogMenuKind.latest:
url = catalogLatestUrl(page: page);
case CatalogMenuKind.popular:
case CatalogMenuKind.hotToday:
case CatalogMenuKind.hotWeek:
case CatalogMenuKind.hotMonth:
final period = hotPeriodForMenu(kind)!;
url = catalogHotUrl(period: period, page: page);
case CatalogMenuKind.directory:
url = directoryUrl(browse ?? const CatalogBrowseParams(), page: page);
case CatalogMenuKind.search:
final q = query.trim();
if (q.length < 2) return const [];
url = catalogSearchUrl(query: q, page: page);
}
final html = await _getHtml(url, label: kind.name);
return _parseCatalogCards(html, baseHref: url);
}
List<CatalogComic> _parseCatalogCards(String html, {required String baseHref}) {
final doc = html_parser.parse(html);
final posts = doc.querySelectorAll('ul.post-list li div.post');
final seen = <String>{};
final out = <CatalogComic>[];
for (final post in posts) {
final coverA = post.querySelector('a.manga-cover');
final href = coverA?.attributes['href']?.trim() ??
post.querySelector('a[href*="/manga/"]')?.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final listingUrl = absoluteUrl(href, baseHref);
if (!listingUrl.contains('/manga/')) continue;
final slug = mangaSlugFromUrl(listingUrl);
if (slug == 'comic' || !seen.add(slug)) continue;
var title = coverA?.attributes['rel']?.trim() ?? '';
if (title.isEmpty) {
title = post
.querySelector('.post-info .title')
?.text
.trim()
.replaceAll(RegExp(r'\s+'), ' ') ??
'';
}
if (title.isEmpty) title = slug;
String? coverUrl;
final img = post.querySelector('img');
final rawSrc = img?.attributes['src']?.trim();
if (rawSrc != null && rawSrc.isNotEmpty && !rawSrc.startsWith('data:')) {
coverUrl = absoluteUrl(rawSrc, baseHref);
}
out.add(
CatalogComic(
source: SourceId.mangatown,
slug: slug,
title: title,
listingUrl: listingUrlForSlug(slug),
coverUrl: coverUrl,
),
);
}
return out;
}
String? _extractTitle(String html) {
final doc = html_parser.parse(html);
final t = doc
.querySelector('.manga-detail-top .title')
?.text
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
if (t != null && t.isNotEmpty) return t;
final titleEl = doc.querySelector('title')?.text.trim() ?? '';
final m = RegExp(
r'Read\s+(.+?)\s+Manga',
caseSensitive: false,
).firstMatch(titleEl);
return m?.group(1)?.trim();
}
String? _extractCover(String html, String baseHref) {
final doc = html_parser.parse(html);
final img = doc.querySelector('img.detail-cover');
final raw = img?.attributes['src']?.trim();
if (raw == null || raw.isEmpty) return null;
return absoluteUrl(raw, baseHref);
}
String? _extractDescription(String html) {
final doc = html_parser.parse(html);
final show = doc.querySelector('#show');
if (show != null) {
final clone = show.clone(true);
clone.querySelectorAll('a').forEach((a) => a.remove());
final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (text.isNotEmpty) return text;
}
final hide = doc.querySelector('#hide');
if (hide != null) {
final clone = hide.clone(true);
clone.querySelectorAll('a').forEach((a) => a.remove());
final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (text.isNotEmpty) return text;
}
return null;
}
List<ChapterLink> _extractChapters(String html, String baseHref) {
final doc = html_parser.parse(html);
final items = doc.querySelectorAll('ul.detail-ch-list li a');
final chapters = <ChapterLink>[];
for (final a in items) {
final href = a.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final clone = a.clone(true);
clone.querySelectorAll('span').forEach((s) => s.remove());
final title = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
final url = absoluteUrl(href, baseHref);
final key = chapterKeyFromUrl(url);
chapters.add(
ChapterLink(
url: chapterBaseUrl(url),
title: title.isNotEmpty ? title : key,
chapterKey: key,
chapterNumber: chapterNumberFromTitle(title),
),
);
}
return chapters;
}
@override
Future<SeriesDetail> fetchSeries(String listingUrl) async {
final base = mangaBaseUrlFromListingUrl(listingUrl);
final html = await _getHtml(base, label: 'series');
var chapters = _extractChapters(html, base);
if (chapters.length > 1) {
chapters = chapters.reversed.toList(growable: false);
}
final slug = mangaSlugFromUrl(base);
return SeriesDetail(
source: SourceId.mangatown,
listingUrl: listingUrlForSlug(slug),
slug: slug,
title: _extractTitle(html) ?? slug,
coverUrl: _extractCover(html, base),
description: _extractDescription(html),
chapters: chapters,
);
}
int _pageCountFromHtml(String html) {
final doc = html_parser.parse(html);
final options = doc.querySelectorAll('select.index-page option');
if (options.isNotEmpty) return options.length;
final selects = doc.querySelectorAll('.ch-select select option');
if (selects.isNotEmpty) return selects.length;
final pager = doc.querySelector('#pager')?.text ?? '';
final m = RegExp(r'/\s*(\d+)').firstMatch(pager);
if (m != null) {
return int.tryParse(m.group(1)!) ?? 1;
}
return 1;
}
String? _imageFromReaderHtml(String html, String baseHref) {
final doc = html_parser.parse(html);
dom.Element? img =
doc.querySelector('#viewer img#image') ?? doc.querySelector('img#image');
final raw = img?.attributes['src']?.trim();
if (raw == null || raw.isEmpty || raw.startsWith('data:')) return null;
return absoluteUrl(raw, baseHref);
}
Future<List<String>> _fetchPagesParallel(
String chapterBase,
int fromPage,
int total,
) async {
if (fromPage > total) return const [];
final count = total - fromPage + 1;
final urls = List<String?>.filled(count, null);
var next = fromPage;
Future<void> worker() async {
while (true) {
final i = next;
next++;
if (i > total) return;
final pageUrl = chapterPageUrl(chapterBase, i);
final html = await _getHtml(pageUrl, label: 'chapter page $i');
urls[i - fromPage] = _imageFromReaderHtml(html, pageUrl);
}
}
final workers = List.generate(
_pageFetchConcurrency.clamp(1, count),
(_) => worker(),
);
await Future.wait(workers);
return urls.whereType<String>().toList(growable: false);
}
@override
Future<List<String>> fetchPageUrls(String chapterUrl) async {
final base = chapterBaseUrl(chapterUrl);
final firstHtml = await _getHtml(base, label: 'chapter');
final total = _pageCountFromHtml(firstHtml);
final first = _imageFromReaderHtml(firstHtml, base);
if (total <= 1) {
return first == null ? const [] : [first];
}
final rest = await _fetchPagesParallel(base, 2, total);
if (first == null) return rest;
return [first, ...rest];
}
}
@@ -0,0 +1,245 @@
import '../source.dart';
import 'constants.dart';
/// Hot ranking period for `/hot` feeds.
enum MangaTownHotPeriod {
total,
today,
week,
month,
}
String? hotPeriodQuery(MangaTownHotPeriod period) {
switch (period) {
case MangaTownHotPeriod.total:
return null;
case MangaTownHotPeriod.today:
return 'tviews.za';
case MangaTownHotPeriod.week:
return 'wviews.za';
case MangaTownHotPeriod.month:
return 'mviews.za';
}
}
MangaTownHotPeriod? hotPeriodForMenu(CatalogMenuKind kind) {
switch (kind) {
case CatalogMenuKind.popular:
return MangaTownHotPeriod.total;
case CatalogMenuKind.hotToday:
return MangaTownHotPeriod.today;
case CatalogMenuKind.hotWeek:
return MangaTownHotPeriod.week;
case CatalogMenuKind.hotMonth:
return MangaTownHotPeriod.month;
default:
return null;
}
}
/// Page 1 uses the bare path; page 2+ appends `/{n}.html`.
String _pagedPath(String path, int page) {
final base = path.replaceAll(RegExp(r'/+$'), '');
if (page <= 1) return base;
return '$base/$page.html';
}
Uri _withOptionalQuery(String path, String? queryToken) {
final uri = Uri.parse('$mangaTownOrigin$path');
if (queryToken == null || queryToken.isEmpty) return uri;
// Site uses bare tokens like `?wviews.za` (no `=` value).
return Uri.parse('${uri.toString()}?$queryToken');
}
String catalogLatestUrl({int page = 1}) {
return _withOptionalQuery(_pagedPath('/latest', page), null).toString();
}
String catalogHotUrl({
MangaTownHotPeriod period = MangaTownHotPeriod.total,
int page = 1,
}) {
return _withOptionalQuery(
_pagedPath('/hot', page),
hotPeriodQuery(period),
).toString();
}
/// `/directory/{demographic}-{genre}-0-{status}-{initial}-0`
String directoryUrl(
CatalogBrowseParams browse, {
int page = 1,
}) {
final demo = _seg(browse.demographic);
final genre = _seg(browse.genre);
final status = _seg(browse.status);
final initial = _seg(browse.initial);
final path = _pagedPath(
'/directory/$demo-$genre-0-$status-$initial-0',
page,
);
return '$mangaTownOrigin$path';
}
String catalogSearchUrl({required String query, int page = 1}) {
final path = _pagedPath('/search', page);
return Uri.parse('$mangaTownOrigin$path').replace(
queryParameters: {'name': query},
).toString();
}
String _seg(String raw) {
final t = raw.trim();
if (t.isEmpty) return '0';
return t;
}
String mangaSlugFromUrl(String pageUrl) {
try {
final parts =
Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi != -1 && mi + 1 < parts.length) {
return parts[mi + 1];
}
} catch (_) {}
return 'comic';
}
String listingUrlForSlug(String slug, {String origin = mangaTownOrigin}) {
final base = origin.replaceAll(RegExp(r'/+$'), '');
return '$base/manga/$slug';
}
String mangaBaseUrlFromListingUrl(String pageUrl) {
final u = Uri.parse(pageUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi == -1 || mi + 1 >= parts.length) {
throw StateError(
'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaTown.',
);
}
final slug = parts[mi + 1];
return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug').toString();
}
/// Strip reader page suffixes like `/2.html` so we keep `/manga/{slug}/c005/`.
String chapterBaseUrl(String chapterUrl) {
final u = Uri.parse(chapterUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
if (parts.isEmpty) return chapterUrl;
final last = parts.last;
if (RegExp(r'^\d+\.html$', caseSensitive: false).hasMatch(last)) {
parts.removeLast();
}
final path = '/${parts.join('/')}/';
return Uri(scheme: u.scheme, host: u.host, path: path).toString();
}
/// 1-based reader page URL under a chapter base.
String chapterPageUrl(String chapterBase, int pageIndex) {
final base = chapterBaseUrl(chapterBase);
if (pageIndex <= 1) return base;
final trimmed = base.replaceAll(RegExp(r'/+$'), '');
return '$trimmed/$pageIndex.html';
}
String chapterKeyFromUrl(String chapterUrl) {
try {
final segs = Uri.parse(chapterBaseUrl(chapterUrl))
.pathSegments
.where((s) => s.isNotEmpty)
.toList();
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String? chapterNumberFromTitle(String title) {
final cleaned = title.trim().replaceAll(RegExp(r'\s+'), ' ');
final m = RegExp(
r'(?:Chapter|Ch\.?|C\.?)\s*([\d.]+)',
caseSensitive: false,
).firstMatch(cleaned);
return m?.group(1);
}
String absoluteUrl(String href, String baseHref) {
final resolved = Uri.parse(baseHref).resolve(href.trim()).toString();
if (resolved.startsWith('//')) {
return 'https:$resolved';
}
return resolved;
}
/// Demographics offered by MangaTown directory filters.
const mangaTownDemographics = <MapEntry<String, String>>[
MapEntry('0', 'All'),
MapEntry('josei', 'Josei'),
MapEntry('seinen', 'Seinen'),
MapEntry('shoujo', 'Shoujo'),
MapEntry('shoujo_ai', 'Shoujo Ai'),
MapEntry('shounen', 'Shounen'),
MapEntry('shounen_ai', 'Shounen Ai'),
MapEntry('yaoi', 'Yaoi'),
MapEntry('yuri', 'Yuri'),
];
/// Genre slug → label for directory filters.
const mangaTownGenres = <MapEntry<String, String>>[
MapEntry('0', 'All'),
MapEntry('4_koma', '4 Koma'),
MapEntry('action', 'Action'),
MapEntry('adventure', 'Adventure'),
MapEntry('comedy', 'Comedy'),
MapEntry('cooking', 'Cooking'),
MapEntry('doujinshi', 'Doujinshi'),
MapEntry('drama', 'Drama'),
MapEntry('ecchi', 'Ecchi'),
MapEntry('fantasy', 'Fantasy'),
MapEntry('gender_bender', 'Gender Bender'),
MapEntry('harem', 'Harem'),
MapEntry('historical', 'Historical'),
MapEntry('horror', 'Horror'),
MapEntry('martial_arts', 'Martial Arts'),
MapEntry('mature', 'Mature'),
MapEntry('mecha', 'Mecha'),
MapEntry('music', 'Music'),
MapEntry('mystery', 'Mystery'),
MapEntry('one_shot', 'One Shot'),
MapEntry('psychological', 'Psychological'),
MapEntry('reverse_harem', 'Reverse Harem'),
MapEntry('romance', 'Romance'),
MapEntry('school_life', 'School Life'),
MapEntry('sci_fi', 'Sci Fi'),
MapEntry('shotacon', 'Shotacon'),
MapEntry('slice_of_life', 'Slice Of Life'),
MapEntry('smut', 'Smut'),
MapEntry('sports', 'Sports'),
MapEntry('supernatural', 'Supernatural'),
MapEntry('suspense', 'Suspense'),
MapEntry('tragedy', 'Tragedy'),
MapEntry('vampire', 'Vampire'),
MapEntry('webtoons', 'Webtoons'),
MapEntry('youkai', 'Youkai'),
];
const mangaTownStatuses = <MapEntry<String, String>>[
MapEntry('0', 'All'),
MapEntry('ongoing', 'Ongoing'),
MapEntry('new', 'New'),
MapEntry('completed', 'Completed'),
];
/// A–Z plus `0-9`.
List<MapEntry<String, String>> mangaTownInitials() {
final out = <MapEntry<String, String>>[const MapEntry('0', 'All')];
for (var c = 0; c < 26; c++) {
final letter = String.fromCharCode('a'.codeUnitAt(0) + c);
out.add(MapEntry(letter, letter.toUpperCase()));
}
out.add(const MapEntry('0-9', '0-9'));
return out;
}
+66
View File
@@ -0,0 +1,66 @@
import 'comicland/comicland_source.dart';
import 'mangaread/mangaread_source.dart';
import 'mangatown/mangatown_source.dart';
import 'source.dart';
import 'source_flags.dart';
class SourceRegistry {
SourceRegistry({
Set<SourceId>? enabled,
ComicSource? comicLand,
ComicSource? mangaRead,
ComicSource? mangaTown,
}) : _sources = _build(
enabled ?? enabledSourceIds(),
comicLand: comicLand,
mangaRead: mangaRead,
mangaTown: mangaTown,
);
final Map<SourceId, ComicSource> _sources;
static Map<SourceId, ComicSource> _build(
Set<SourceId> enabled, {
ComicSource? comicLand,
ComicSource? mangaRead,
ComicSource? mangaTown,
}) {
final map = <SourceId, ComicSource>{};
for (final id in enabled) {
switch (id) {
case SourceId.comicland:
map[id] = comicLand ?? ComicLandSource();
case SourceId.mangaread:
map[id] = mangaRead ?? MangaReadSource();
case SourceId.mangatown:
map[id] = mangaTown ?? MangaTownSource();
}
}
if (map.isEmpty) {
map[SourceId.comicland] = comicLand ?? ComicLandSource();
}
return map;
}
List<ComicSource> get all {
// Stable order matching SourceId declaration.
return SourceId.values
.where(_sources.containsKey)
.map((id) => _sources[id]!)
.toList(growable: false);
}
bool get hasMultiple => _sources.length > 1;
ComicSource get(SourceId id) {
final s = _sources[id];
if (s == null) {
throw StateError('Source not enabled in this build: $id');
}
return s;
}
bool isEnabled(SourceId id) => _sources.containsKey(id);
ComicSource get defaultSource => all.first;
}
+176
View File
@@ -0,0 +1,176 @@
// Shared models and source-adapter contract (port of cxyz `sources.js`).
enum SourceId {
comicland,
mangaread,
mangatown;
String get id => name;
String get label {
switch (this) {
case SourceId.comicland:
return 'ComicLand';
case SourceId.mangaread:
return 'MangaRead';
case SourceId.mangatown:
return 'MangaTown';
}
}
/// Bundled favicon asset (not fetched at runtime).
String get logoAsset => 'assets/sources/$name.png';
static SourceId? tryParse(String raw) {
final key = raw.trim().toLowerCase();
for (final id in SourceId.values) {
if (id.name == key) return id;
}
return null;
}
}
/// Browse menus a source may expose on its Catalog home.
enum CatalogMenuKind {
popular,
latest,
official,
uncensored,
recommended,
search,
hotToday,
hotWeek,
hotMonth,
directory;
String get pathSegment => name;
}
/// Optional directory filters (MangaTown). Segment `0` means All.
class CatalogBrowseParams {
const CatalogBrowseParams({
this.demographic = '0',
this.genre = '0',
this.status = '0',
this.initial = '0',
});
final String demographic;
final String genre;
final String status;
final String initial;
CatalogBrowseParams copyWith({
String? demographic,
String? genre,
String? status,
String? initial,
}) {
return CatalogBrowseParams(
demographic: demographic ?? this.demographic,
genre: genre ?? this.genre,
status: status ?? this.status,
initial: initial ?? this.initial,
);
}
@override
bool operator ==(Object other) {
return other is CatalogBrowseParams &&
other.demographic == demographic &&
other.genre == genre &&
other.status == status &&
other.initial == initial;
}
@override
int get hashCode => Object.hash(demographic, genre, status, initial);
}
class CatalogMenu {
const CatalogMenu({required this.kind, required this.title});
final CatalogMenuKind kind;
final String title;
}
class CatalogComic {
const CatalogComic({
required this.source,
required this.slug,
required this.title,
required this.listingUrl,
this.coverUrl,
this.description,
this.rating,
});
final SourceId source;
final String slug;
final String title;
final String listingUrl;
final String? coverUrl;
final String? description;
final double? rating;
}
class ChapterLink {
const ChapterLink({
required this.url,
required this.title,
required this.chapterKey,
this.chapterNumber,
});
final String url;
final String title;
final String chapterKey;
final String? chapterNumber;
}
class SeriesDetail {
const SeriesDetail({
required this.source,
required this.listingUrl,
required this.slug,
required this.title,
required this.chapters,
this.coverUrl,
this.description,
});
final SourceId source;
final String listingUrl;
final String slug;
final String title;
final String? coverUrl;
final String? description;
final List<ChapterLink> chapters;
}
/// Site adapter: per-source catalog menus + series + page URLs.
abstract class ComicSource {
SourceId get id;
String get displayName;
String get blurb;
/// Menus on this source’s Catalog home. Omit unsupported feeds (e.g. no search).
List<CatalogMenu> get catalogMenus;
/// Headers for page/CDN image requests.
Map<String, String> get imageHeaders;
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
});
Future<SeriesDetail> fetchSeries(String listingUrl);
Future<List<String>> fetchPageUrls(String chapterUrl);
}
@@ -0,0 +1,37 @@
import 'source.dart';
/// Compile-time source gate: `--dart-define=SOURCES=comicland,mangaread,mangatown`
/// or `--dart-define=SOURCES=all`.
///
/// Unset / invalid → ComicLand only. Default is **not** all sources.
const String kSourcesDefine = String.fromEnvironment(
'SOURCES',
defaultValue: 'comicland',
);
/// Parses [raw] into enabled [SourceId]s.
///
/// - Token `all` (case-insensitive) enables every [SourceId].
/// - Unknown tokens ignored.
/// - Empty result falls back to `{SourceId.comicland}`.
Set<SourceId> parseEnabledSourceIds([String raw = kSourcesDefine]) {
final parts = raw.split(',').map((p) => p.trim().toLowerCase()).where(
(p) => p.isNotEmpty,
);
for (final part in parts) {
if (part == 'all') {
return SourceId.values.toSet();
}
}
final out = <SourceId>{};
for (final part in parts) {
final id = SourceId.tryParse(part);
if (id != null) out.add(id);
}
if (out.isEmpty) {
return {SourceId.comicland};
}
return out;
}
Set<SourceId> enabledSourceIds() => parseEnabledSourceIds();
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../sources/mangatown/urls.dart';
import '../../sources/source.dart';
/// MangaTown Directory filter bar (demographic / genre / status / initial).
class MangaTownDirectoryFilters extends StatelessWidget {
const MangaTownDirectoryFilters({
super.key,
required this.value,
required this.onChanged,
});
final CatalogBrowseParams value;
final ValueChanged<CatalogBrowseParams> onChanged;
@override
Widget build(BuildContext context) {
final initials = mangaTownInitials();
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Column(
children: [
Row(
children: [
Expanded(
child: _FilterDropdown(
label: 'Demographic',
value: value.demographic,
entries: mangaTownDemographics,
onChanged: (v) => onChanged(value.copyWith(demographic: v)),
),
),
const SizedBox(width: 8),
Expanded(
child: _FilterDropdown(
label: 'Status',
value: value.status,
entries: mangaTownStatuses,
onChanged: (v) => onChanged(value.copyWith(status: v)),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _FilterDropdown(
label: 'Genre',
value: value.genre,
entries: mangaTownGenres,
onChanged: (v) => onChanged(value.copyWith(genre: v)),
),
),
const SizedBox(width: 8),
Expanded(
child: _FilterDropdown(
label: 'Letter',
value: value.initial,
entries: initials,
onChanged: (v) => onChanged(value.copyWith(initial: v)),
),
),
],
),
],
),
);
}
}
class _FilterDropdown extends StatelessWidget {
const _FilterDropdown({
required this.label,
required this.value,
required this.entries,
required this.onChanged,
});
final String label;
final String value;
final List<MapEntry<String, String>> entries;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final known = entries.any((e) => e.key == value) ? value : '0';
return InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
isDense: true,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: known,
items: [
for (final e in entries)
DropdownMenuItem(value: e.key, child: Text(e.value)),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
),
),
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../providers/providers.dart';
import '../../sources/source.dart';
import '../widgets/comic_cover.dart';
import '../widgets/source_logo.dart';
/// Catalog tab root helpers live in the router redirect; picker is [SourcePickerPage].
const _kLastCatalogSource = 'catalog_last_source_id';
Future<void> saveLastCatalogSource(SourceId id) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastCatalogSource, id.name);
}
Future<SourceId?> loadLastCatalogSource() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kLastCatalogSource);
if (raw == null) return null;
return SourceId.tryParse(raw);
}
class SourcePickerPage extends ConsumerStatefulWidget {
const SourcePickerPage({super.key});
@override
ConsumerState<SourcePickerPage> createState() => _SourcePickerPageState();
}
class _SourcePickerPageState extends ConsumerState<SourcePickerPage> {
SourceId? _last;
@override
void initState() {
super.initState();
loadLastCatalogSource().then((id) {
if (!mounted) return;
setState(() => _last = id);
});
}
@override
Widget build(BuildContext context) {
final registry = ref.watch(sourceRegistryProvider);
final sources = registry.all;
final last = _last;
final continueSource =
last != null && registry.isEnabled(last) ? registry.get(last) : null;
return Scaffold(
appBar: AppBar(
title: const Text('Catalog'),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (continueSource != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Align(
alignment: Alignment.centerLeft,
child: ActionChip(
avatar: SourceLogo(sourceId: continueSource.id, size: 18),
label: Text('Continue · ${continueSource.displayName}'),
onPressed: () => _open(continueSource.id),
),
),
),
for (final s in sources)
ListTile(
leading: SourceLogo(sourceId: s.id),
title: Text(s.displayName),
subtitle: Text(s.blurb),
trailing: const Icon(Icons.chevron_right),
onTap: () => _open(s.id),
),
],
),
);
}
void _open(SourceId id) {
saveLastCatalogSource(id);
context.push('/catalog/${id.name}');
}
}
/// Lists [ComicSource.catalogMenus] for one enabled source.
class SourceHomePage extends ConsumerWidget {
const SourceHomePage({
super.key,
required this.sourceId,
this.embeddedAsRoot = false,
});
final SourceId sourceId;
final bool embeddedAsRoot;
@override
Widget build(BuildContext context, WidgetRef ref) {
final registry = ref.watch(sourceRegistryProvider);
if (!registry.isEnabled(sourceId)) {
return Scaffold(
appBar: AppBar(title: const Text('Catalog')),
body: const Center(child: Text('Source not enabled in this build.')),
);
}
final source = registry.get(sourceId);
final menus = source.catalogMenus;
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
SourceLogo(sourceId: sourceId, size: 24),
const SizedBox(width: 8),
Text(source.displayName),
],
),
leading: embeddedAsRoot
? null
: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/catalog');
}
},
),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: ListView.separated(
itemCount: menus.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final menu = menus[i];
return ListTile(
leading: Icon(_iconFor(menu.kind)),
title: Text(menu.title),
trailing: const Icon(Icons.chevron_right),
onTap: () {
saveLastCatalogSource(sourceId);
context.push('/catalog/${sourceId.name}/${menu.kind.pathSegment}');
},
);
},
),
);
}
IconData _iconFor(CatalogMenuKind kind) {
switch (kind) {
case CatalogMenuKind.popular:
return Icons.local_fire_department_outlined;
case CatalogMenuKind.latest:
return Icons.schedule_outlined;
case CatalogMenuKind.official:
return Icons.verified_outlined;
case CatalogMenuKind.uncensored:
return Icons.visibility_outlined;
case CatalogMenuKind.recommended:
return Icons.thumb_up_outlined;
case CatalogMenuKind.search:
return Icons.search;
case CatalogMenuKind.hotToday:
return Icons.wb_sunny_outlined;
case CatalogMenuKind.hotWeek:
return Icons.date_range_outlined;
case CatalogMenuKind.hotMonth:
return Icons.calendar_month_outlined;
case CatalogMenuKind.directory:
return Icons.grid_view_outlined;
}
}
}
+272
View File
@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'catalog/mangatown_directory_filters.dart';
import 'widgets/comic_cover.dart';
/// Catalog feed for one `(source, menuKind)` — grid + search field when needed.
class CatalogFeedPage extends ConsumerStatefulWidget {
const CatalogFeedPage({
super.key,
required this.sourceId,
required this.menuKind,
});
final SourceId sourceId;
final CatalogMenuKind menuKind;
@override
ConsumerState<CatalogFeedPage> createState() => _CatalogFeedPageState();
}
class _CatalogFeedPageState extends ConsumerState<CatalogFeedPage> {
final _searchCtrl = TextEditingController();
List<CatalogComic> _items = const [];
bool _loading = true;
String? _error;
String _lastQuery = '';
/// Next 0-based page index (mapped to `offset: page * _pageSize` for sources).
int _page = 0;
static const _pageSize = 30;
bool _hasMore = true;
bool _loadingMore = false;
CatalogBrowseParams _browse = const CatalogBrowseParams();
ComicSource get _source =>
ref.read(sourceRegistryProvider).get(widget.sourceId);
bool get _showDirectoryFilters =>
widget.sourceId == SourceId.mangatown &&
widget.menuKind == CatalogMenuKind.directory;
String get _menuTitle {
final menus = _source.catalogMenus;
for (final m in menus) {
if (m.kind == widget.menuKind) return m.title;
}
return widget.menuKind.name;
}
@override
void initState() {
super.initState();
if (widget.menuKind != CatalogMenuKind.search) {
_load(reset: true);
} else {
_loading = false;
}
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load({required bool reset}) async {
if (reset) {
setState(() {
_loading = true;
_error = null;
_page = 0;
_hasMore = true;
});
} else {
if (!_hasMore || _loadingMore) return;
setState(() => _loadingMore = true);
}
final pageIndex = reset ? 0 : _page;
try {
final list = await _source.loadCatalog(
widget.menuKind,
query: _lastQuery,
offset: pageIndex * _pageSize,
limit: _pageSize,
browse: _showDirectoryFilters ? _browse : null,
);
if (!mounted) return;
setState(() {
if (reset) {
_items = list;
} else {
// Drop accidental overlaps if a source re-served the same page.
final seen = _items.map((c) => c.listingUrl).toSet();
_items = [
..._items,
...list.where((c) => seen.add(c.listingUrl)),
];
}
_page = pageIndex + 1;
// Madara (and similar) may return < limit per HTML page; keep going
// until a page comes back empty.
_hasMore = list.isNotEmpty;
_loading = false;
_loadingMore = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
_loadingMore = false;
});
}
}
Future<void> _runSearch(String q) async {
final query = q.trim();
if (query.length < 2) {
setState(() {
_items = const [];
_lastQuery = '';
_error = null;
});
return;
}
_lastQuery = query;
await _load(reset: true);
}
void _onBrowseChanged(CatalogBrowseParams next) {
if (next == _browse) return;
setState(() => _browse = next);
_load(reset: true);
}
Future<void> _openComic(CatalogComic comic) async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(content: Text('Loading ${comic.title}…')),
);
try {
final source = ref.read(sourceRegistryProvider).get(comic.source);
final queue = ref.read(downloadQueueProvider);
final series = await source.fetchSeries(comic.listingUrl);
final titleId = await queue.upsertSeries(series);
if (!mounted) return;
messenger.hideCurrentSnackBar();
context.push('/title/$titleId');
} catch (e) {
if (!mounted) return;
messenger.hideCurrentSnackBar();
messenger.showSnackBar(SnackBar(content: Text('Failed: $e')));
}
}
Widget _grid() {
if (_loading && _items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_items.isEmpty) {
final empty = widget.menuKind == CatalogMenuKind.search
? 'Type at least 2 characters to search'
: 'No comics found';
return Center(child: Text(empty));
}
return RefreshIndicator(
onRefresh: () => _load(reset: true),
child: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels > n.metrics.maxScrollExtent - 400) {
_load(reset: false);
}
return false;
},
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: (() {
final tablet =
MediaQuery.sizeOf(context).shortestSide >= 600;
return SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: tablet ? 5 : 3,
childAspectRatio: tablet ? 0.58 : 0.55,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
);
})(),
itemCount: _items.length + (_loadingMore ? 1 : 0),
itemBuilder: (context, i) {
if (i >= _items.length) {
return const Center(child: CircularProgressIndicator());
}
final c = _items[i];
final tablet =
MediaQuery.sizeOf(context).shortestSide >= 600;
return ComicGridTile(
comic: c,
onTap: () => _openComic(c),
titleMaxLines: tablet ? 1 : 2,
);
},
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${_source.displayName} · $_menuTitle'),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: Column(
children: [
if (_error != null)
MaterialBanner(
content: Text(_error!, maxLines: 3, overflow: TextOverflow.ellipsis),
actions: [
TextButton(
onPressed: () => setState(() => _error = null),
child: const Text('Dismiss'),
),
],
),
if (widget.menuKind == CatalogMenuKind.search)
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
controller: _searchCtrl,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search comics…',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchCtrl.clear();
_runSearch('');
},
),
),
onChanged: (v) {
Future<void>.delayed(const Duration(milliseconds: 250), () {
if (_searchCtrl.text == v) _runSearch(v);
});
},
onSubmitted: _runSearch,
),
),
if (_showDirectoryFilters)
MangaTownDirectoryFilters(
value: _browse,
onChanged: _onBrowseChanged,
),
Expanded(child: _grid()),
],
),
);
}
}
+320
View File
@@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/database.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class DownloadsPage extends ConsumerWidget {
const DownloadsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queue = ref.watch(downloadQueueProvider);
final queueAsync = ref.watch(queueJobsProvider);
final downloadedAsync = ref.watch(downloadedSeriesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Downloads')),
body: queueAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (queueJobs) {
return downloadedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (groups) {
final queued =
queueJobs.where((j) => j.status == 'queued').length;
final failed =
queueJobs.where((j) => j.status == 'failed').length;
final pendingTotal = queued + failed;
final hasDownloads = groups.isNotEmpty;
final showRunning = queue.isRunning;
if (!showRunning && !hasDownloads && pendingTotal == 0) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No downloads yet.\nSelect chapters on a series and tap Download.',
textAlign: TextAlign.center,
),
),
);
}
final queueLabel = failed > 0
? 'Queue ($pendingTotal · $failed failed)'
: 'Queue ($pendingTotal)';
return CustomScrollView(
slivers: [
if (showRunning)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
queue.message.isEmpty
? 'Downloading…'
: queue.message,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: queue.progress.clamp(0.0, 1.0),
),
],
),
),
),
if (pendingTotal > 0)
SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.queue),
title: Text(queueLabel),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/downloads/queue'),
),
),
if (groups.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Downloaded',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
SliverList.builder(
itemCount: groups.length,
itemBuilder: (context, i) {
return _DownloadedSeriesTile(group: groups[i]);
},
),
],
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
);
},
),
);
}
}
class DownloadsQueuePage extends ConsumerWidget {
const DownloadsQueuePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queueAsync = ref.watch(queueJobsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Queue')),
body: queueAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (jobs) {
final list = jobs
.where((j) => j.status == 'queued' || j.status == 'failed')
.toList();
if (list.isEmpty) {
return const Center(child: Text('Queue is empty.'));
}
return ListView.builder(
itemCount: list.length,
itemBuilder: (context, i) => _QueueJobTile(job: list[i]),
);
},
),
);
}
}
class _QueueJobTile extends ConsumerWidget {
const _QueueJobTile({required this.job});
final QueueJobView job;
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusLabel = switch (job.status) {
'running' => 'Downloading',
'queued' => 'Queued',
'failed' => 'Failed',
_ => job.status,
};
return ListTile(
leading: Icon(_iconFor(job.status)),
title: Text(
job.seriesTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${job.chapterLabel} · $statusLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (job.status == 'failed' && job.error != null) ...[
const SizedBox(height: 2),
Text(
job.error!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
],
),
isThreeLine: job.status == 'failed',
trailing: job.status == 'failed'
? IconButton(
tooltip: 'Retry',
icon: const Icon(Icons.refresh),
onPressed: job.chapterId == null
? null
: () => ref
.read(downloadQueueProvider)
.retryChapter(job.chapterId!),
)
: job.status == 'queued'
? IconButton(
tooltip: 'Cancel',
icon: const Icon(Icons.cancel_outlined),
onPressed: job.chapterId == null
? null
: () => ref
.read(downloadQueueProvider)
.cancelChapter(job.chapterId!),
)
: null,
onTap: job.titleId == null
? null
: () => context.push('/title/${job.titleId}'),
);
}
IconData _iconFor(String status) {
switch (status) {
case 'failed':
return Icons.error_outline;
case 'queued':
return Icons.hourglass_empty;
default:
return Icons.download_outlined;
}
}
}
class _DownloadedSeriesTile extends ConsumerWidget {
const _DownloadedSeriesTile({required this.group});
final DownloadedSeriesGroup group;
@override
Widget build(BuildContext context, WidgetRef ref) {
final sourceId = SourceId.tryParse(group.source);
return ExpansionTile(
leading: SizedBox(
width: 40,
height: 56,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ComicCover(url: group.coverUrl, sourceId: sourceId),
),
),
title: Text(
group.seriesTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${group.chapters.length} chapter${group.chapters.length == 1 ? '' : 's'}',
),
children: [
for (final ch in group.chapters)
Dismissible(
key: ValueKey('dl-${ch.chapterId}'),
direction: DismissDirection.endToStart,
background: Container(
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete download?'),
content: Text(
'Remove “${ch.chapterLabel}” from Downloads and delete the CBZ file.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
return ok == true;
},
onDismissed: (_) {
ref.read(downloadQueueProvider).deleteDownload(ch.chapterId);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 72, right: 8),
title: Text(ch.chapterLabel),
subtitle: ch.downloadedAt == null
? null
: Text(_formatDate(ch.downloadedAt!)),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'delete') {
ref
.read(downloadQueueProvider)
.deleteDownload(ch.chapterId);
}
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
onTap: () {
context.push('/read/${ch.titleId}/${ch.chapterId}');
},
),
),
],
);
}
String _formatDate(DateTime dt) {
final local = dt.toLocal();
final y = local.year.toString().padLeft(4, '0');
final m = local.month.toString().padLeft(2, '0');
final d = local.day.toString().padLeft(2, '0');
return 'Downloaded $y-$m-$d';
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class HistoryPage extends ConsumerWidget {
const HistoryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titlesAsync = ref.watch(historyTitlesProvider);
return Scaffold(
appBar: AppBar(title: const Text('History')),
body: titlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (titles) {
if (titles.isEmpty) {
return const Center(
child: Text('Chapters you read will show up here.'),
);
}
return ListView.separated(
itemCount: titles.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final t = titles[i];
final sourceId = SourceId.tryParse(t.source);
final label = sourceId?.label ?? t.source;
final when = t.lastReadAt;
final subtitle = when == null
? label
: '$label · ${_formatWhen(when)}';
return ListTile(
leading: SizedBox(
width: 48,
height: 64,
child: ComicCover(url: t.coverUrl, sourceId: sourceId),
),
title: Text(t.title),
subtitle: Text(subtitle),
onTap: () => context.push('/title/${t.id}'),
);
},
);
},
),
);
}
String _formatWhen(DateTime dt) {
final local = dt.toLocal();
final y = local.year.toString().padLeft(4, '0');
final m = local.month.toString().padLeft(2, '0');
final d = local.day.toString().padLeft(2, '0');
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
return '$y-$m-$d $hh:$mm';
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titlesAsync = ref.watch(libraryTitlesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Library')),
body: titlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (titles) {
if (titles.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Star a series on its title page to add it to your Library.',
textAlign: TextAlign.center,
),
),
);
}
return ListView.separated(
itemCount: titles.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final t = titles[i];
final sourceId = SourceId.tryParse(t.source);
final label = sourceId?.label ?? t.source;
return ListTile(
leading: SizedBox(
width: 48,
height: 64,
child: ComicCover(url: t.coverUrl, sourceId: sourceId),
),
title: Text(t.title),
subtitle: Text(label),
trailing: const Icon(Icons.star, size: 18),
onTap: () => context.push('/title/${t.id}'),
);
},
);
},
),
);
}
}
File diff suppressed because it is too large Load Diff
+397
View File
@@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/providers.dart';
import '../services/ntfy_settings.dart';
import '../services/reader_direction.dart';
import '../services/webdav_settings.dart';
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
final _urlCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
final _pathCtrl = TextEditingController();
final _ntfyCompletedCtrl = TextEditingController();
final _ntfyFailedCtrl = TextEditingController();
final _ntfyDrainedCtrl = TextEditingController();
final _ntfyTokenCtrl = TextEditingController();
bool _enabled = false;
bool _ntfyEnabled = false;
NtfyAttachmentMode _ntfyAttachment = NtfyAttachmentMode.none;
bool _loading = true;
bool _busy = false;
String? _status;
String? _error;
DateTime? _lastSync;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_urlCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
_pathCtrl.dispose();
_ntfyCompletedCtrl.dispose();
_ntfyFailedCtrl.dispose();
_ntfyDrainedCtrl.dispose();
_ntfyTokenCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final s = await WebDavSettings.load();
final n = await NtfySettings.load();
if (!mounted) return;
setState(() {
_enabled = s.enabled;
_urlCtrl.text = s.url;
_userCtrl.text = s.username;
_passCtrl.text = s.password;
_pathCtrl.text = s.path;
_status = s.lastStatus;
_error = s.lastError;
_lastSync = s.lastSyncAt;
_ntfyEnabled = n.enabled;
_ntfyCompletedCtrl.text = n.completedUrl;
_ntfyFailedCtrl.text = n.failedUrl;
_ntfyDrainedCtrl.text = n.drainedUrl;
_ntfyTokenCtrl.text = n.authToken;
_ntfyAttachment = n.attachmentMode;
_loading = false;
});
}
Future<void> _save() async {
await WebDavSettings.save(
enabled: _enabled,
url: _urlCtrl.text,
username: _userCtrl.text,
password: _passCtrl.text,
path: _pathCtrl.text,
);
await NtfySettings.save(
enabled: _ntfyEnabled,
completedUrl: _ntfyCompletedCtrl.text,
failedUrl: _ntfyFailedCtrl.text,
drainedUrl: _ntfyDrainedCtrl.text,
attachmentMode: _ntfyAttachment,
authToken: _ntfyTokenCtrl.text,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')),
);
}
Future<void> _test() async {
setState(() => _busy = true);
try {
await _save();
final msg = await ref.read(webDavSyncProvider).testConnection();
if (!mounted) return;
setState(() {
_status = msg;
_error = null;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} catch (e) {
if (!mounted) return;
setState(() {
_status = 'Test failed';
_error = e.toString();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Test failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await _save();
await ref.read(webDavSyncProvider).sync();
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sync finished')),
);
} catch (e) {
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _testNtfy(String kind, TextEditingController ctrl) async {
setState(() => _busy = true);
try {
await _save();
await ref.read(ntfyNotifierProvider).testTopic(ctrl.text, kind: kind);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test notification sent')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('ntfy test failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Widget _ntfyUrlRow({
required String label,
required TextEditingController controller,
required String kind,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: 'https://ntfy.sh/mytopic',
),
keyboardType: TextInputType.url,
autocorrect: false,
enabled: _ntfyEnabled,
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: !_ntfyEnabled || _busy
? null
: () => _testNtfy(kind, controller),
child: const Text('Test'),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final direction = ref.watch(readingDirectionProvider);
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: [
TextButton(
onPressed: _busy ? null : _save,
child: const Text('Save'),
),
],
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Reader', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Fallback page direction when a title has no saved preference '
'(ComicLand). MangaRead / MangaTown default to Right → Left; '
'overrides are saved per title and sync via WebDAV.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
SegmentedButton<ReadingDirection>(
segments: const [
ButtonSegment(
value: ReadingDirection.ltr,
label: Text('Left → Right'),
icon: Icon(Icons.format_textdirection_l_to_r),
),
ButtonSegment(
value: ReadingDirection.rtl,
label: Text('Right → Left'),
icon: Icon(Icons.format_textdirection_r_to_l),
),
],
selected: {direction},
onSelectionChanged: (s) {
ref.read(readingDirectionProvider.notifier).setDirection(s.first);
},
),
const SizedBox(height: 24),
Text('WebDAV sync', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Syncs reading progress, reader mode/direction, Library stars, '
'and History across devices.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable sync'),
value: _enabled,
onChanged: (v) => setState(() => _enabled = v),
),
TextField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'WebDAV URL',
hintText: 'https://dav.example.com/remote.php/dav/files/user',
),
keyboardType: TextInputType.url,
autocorrect: false,
),
TextField(
controller: _userCtrl,
decoration: const InputDecoration(labelText: 'Username'),
autocorrect: false,
),
TextField(
controller: _passCtrl,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
autocorrect: false,
),
TextField(
controller: _pathCtrl,
decoration: const InputDecoration(
labelText: 'Remote file path',
hintText: WebDavSettings.defaultPath,
),
autocorrect: false,
),
const SizedBox(height: 12),
if (_status != null)
Text('Status: $_status', style: Theme.of(context).textTheme.bodySmall),
if (_lastSync != null)
Text(
'Last sync: ${_lastSync!.toLocal()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_error != null)
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
Row(
children: [
FilledButton(
onPressed: _busy ? null : _test,
child: const Text('Test connection'),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _busy || !_enabled ? null : _syncNow,
child: const Text('Sync now'),
),
],
),
const SizedBox(height: 32),
Text(
'Notifications (ntfy)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Send download events to ntfy topics. Leave a URL empty to disable that event.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable notifications'),
value: _ntfyEnabled,
onChanged: (v) => setState(() => _ntfyEnabled = v),
),
_ntfyUrlRow(
label: 'Chapter completed URL',
controller: _ntfyCompletedCtrl,
kind: 'completed',
),
_ntfyUrlRow(
label: 'Job failed URL',
controller: _ntfyFailedCtrl,
kind: 'failed',
),
_ntfyUrlRow(
label: 'Queue drained URL',
controller: _ntfyDrainedCtrl,
kind: 'drained',
),
const SizedBox(height: 8),
Text(
'Attachment on complete',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
DropdownMenu<NtfyAttachmentMode>(
initialSelection: _ntfyAttachment,
enabled: _ntfyEnabled,
width: double.infinity,
dropdownMenuEntries: [
for (final m in NtfyAttachmentMode.values)
DropdownMenuEntry(value: m, label: m.label),
],
onSelected: !_ntfyEnabled
? null
: (v) {
if (v != null) setState(() => _ntfyAttachment = v);
},
),
const SizedBox(height: 4),
Text(
'Cover uses the series cover URL. First page is resized to ≤720px / ≤250 KB. CBZ skips if over 15 MB.',
style: Theme.of(context).textTheme.bodySmall,
),
TextField(
controller: _ntfyTokenCtrl,
decoration: const InputDecoration(
labelText: 'Auth token (optional)',
hintText: 'Bearer token for private topics',
),
obscureText: true,
autocorrect: false,
enabled: _ntfyEnabled,
),
if (_busy) ...[
const SizedBox(height: 16),
const LinearProgressIndicator(),
],
const SizedBox(height: 32),
Text('About', style: Theme.of(context).textTheme.titleMedium),
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Version'),
subtitle: Text('1.0'),
),
],
),
),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
void _onTap(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _onTap,
destinations: const [
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Catalog',
),
NavigationDestination(
icon: Icon(Icons.collections_bookmark_outlined),
selectedIcon: Icon(Icons.collections_bookmark),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
NavigationDestination(
icon: Icon(Icons.download_outlined),
selectedIcon: Icon(Icons.download),
label: 'Downloads',
),
],
),
);
}
}
+451
View File
@@ -0,0 +1,451 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/database.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
import 'widgets/source_logo.dart';
class TitlePage extends ConsumerStatefulWidget {
const TitlePage({super.key, required this.titleId});
final int titleId;
@override
ConsumerState<TitlePage> createState() => _TitlePageState();
}
class _TitlePageState extends ConsumerState<TitlePage> {
Title? _title;
List<Chapter> _chapters = const [];
final Set<int> _selected = {};
bool _newestFirst = true;
bool _loading = true;
bool _syncing = false;
String? _error;
@override
void initState() {
super.initState();
_reload();
}
List<Chapter> _sortedReadingOrder(List<Chapter> chapters) {
final copy = [...chapters];
copy.sort((a, b) {
final na = double.tryParse(a.chapterNumber ?? '') ?? 0;
final nb = double.tryParse(b.chapterNumber ?? '') ?? 0;
return na.compareTo(nb);
});
return copy;
}
Future<void> _reload({bool showLoading = true}) async {
if (showLoading) {
setState(() {
_loading = true;
_error = null;
});
}
final db = ref.read(databaseProvider);
final title = await db.getTitle(widget.titleId);
final chapters =
await db.listChapters(widget.titleId, newestFirst: _newestFirst);
chapters.sort((a, b) {
final na = double.tryParse(a.chapterNumber ?? '') ?? 0;
final nb = double.tryParse(b.chapterNumber ?? '') ?? 0;
return _newestFirst ? nb.compareTo(na) : na.compareTo(nb);
});
if (!mounted) return;
setState(() {
_title = title;
_chapters = chapters;
_loading = false;
});
}
Future<void> _sync() async {
final title = _title;
if (title == null) return;
setState(() => _syncing = true);
try {
final source = ref.read(sourceRegistryProvider).get(
SourceId.values.byName(title.source),
);
final series = await source.fetchSeries(title.url);
await ref.read(downloadQueueProvider).upsertSeries(series);
await _reload();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _syncing = false);
}
}
Future<void> _downloadSelected() async {
if (_selected.isEmpty) return;
await ref.read(downloadQueueProvider).enqueueChapters(_selected);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Queued ${_selected.length} chapter(s)')),
);
setState(() => _selected.clear());
await _reload();
}
Future<void> _downloadAll() async {
if (_chapters.isEmpty) return;
var unreadOnly = true;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setLocal) {
final ids = _chapters.where((c) {
if (c.status == 'downloaded') return false;
if (unreadOnly && c.completed) return false;
return true;
}).map((c) => c.id).toList();
return AlertDialog(
title: const Text('Download all'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ids.isEmpty
? 'No chapters match the current filter.'
: 'Queue ${ids.length} chapter(s) for download?',
),
const SizedBox(height: 12),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Download unread only'),
subtitle: const Text(
'Skip chapters already marked completed',
),
value: unreadOnly,
onChanged: (v) => setLocal(() => unreadOnly = v ?? true),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: ids.isEmpty
? null
: () => Navigator.pop(ctx, true),
child: const Text('Download'),
),
],
);
},
);
},
);
if (confirmed != true || !mounted) return;
final ids = _chapters.where((c) {
if (c.status == 'downloaded') return false;
if (unreadOnly && c.completed) return false;
return true;
}).map((c) => c.id);
await ref.read(downloadQueueProvider).enqueueChapters(ids);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Queued ${ids.length} chapter(s)')),
);
await _reload();
}
bool get _hasReadingHistory {
final title = _title;
if (title?.lastReadChapterId != null) return true;
return _chapters.any((c) => c.lastReadAt != null || c.lastPage > 0 || c.completed);
}
Chapter? get _continueChapter {
final title = _title;
if (title?.lastReadChapterId != null) {
final match =
_chapters.where((c) => c.id == title!.lastReadChapterId).firstOrNull;
if (match != null) return match;
}
Chapter? best;
for (final c in _chapters) {
if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue;
if (best == null) {
best = c;
continue;
}
final a = c.lastReadAt;
final b = best.lastReadAt;
if (a != null && (b == null || a.isAfter(b))) best = c;
}
return best;
}
Chapter? get _firstChapter {
final ordered = _sortedReadingOrder(_chapters);
return ordered.isEmpty ? null : ordered.first;
}
Future<void> _toggleInLibrary() async {
final title = _title;
if (title == null) return;
final next = !title.inLibrary;
await ref.read(databaseProvider).setInLibrary(title.id, next);
ref.read(webDavSyncProvider).scheduleSync();
await _reload();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next ? 'Added to Library' : 'Removed from Library'),
),
);
}
void _openChapter(Chapter chapter) {
context.push('/read/${widget.titleId}/${chapter.id}').then((_) async {
if (!mounted) return;
await _reload(showLoading: false);
});
}
void _onReadOrContinue() {
if (_hasReadingHistory) {
final ch = _continueChapter ?? _firstChapter;
if (ch != null) _openChapter(ch);
} else {
final ch = _firstChapter;
if (ch != null) _openChapter(ch);
}
}
Color? _statusColor(BuildContext context, Chapter ch) {
final scheme = Theme.of(context).colorScheme;
if (ch.completed) return scheme.primary;
if (ch.status == 'downloaded') return scheme.tertiary;
if (ch.status == 'downloading') return scheme.tertiary;
if (ch.status == 'failed') return scheme.error;
return null;
}
String _chapterSubtitle(Chapter ch) {
final parts = <String>[];
if (ch.completed) {
parts.add('Completed');
} else if (ch.pageCount > 0 && (ch.lastPage > 0 || ch.lastReadAt != null)) {
parts.add('Page ${ch.lastPage + 1}/${ch.pageCount}');
} else if (ch.lastReadAt != null || ch.lastPage > 0) {
parts.add('Reading');
}
if (ch.status == 'downloaded') {
parts.add('Downloaded');
} else if (ch.status == 'downloading') {
parts.add('Downloading');
} else if (ch.status == 'failed') {
parts.add('Failed');
}
return parts.isEmpty ? 'Unread' : parts.join(' · ');
}
@override
Widget build(BuildContext context) {
final title = _title;
final canRead = _chapters.isNotEmpty;
final continueLabel = _hasReadingHistory ? 'Continue' : 'Read first chapter';
final continueChapter =
_hasReadingHistory ? (_continueChapter ?? _firstChapter) : _firstChapter;
return Scaffold(
appBar: AppBar(
title: Text(title?.title ?? 'Title'),
actions: [
if (title != null)
IconButton(
tooltip: title.inLibrary ? 'Remove from Library' : 'Add to Library',
onPressed: _toggleInLibrary,
icon: Icon(title.inLibrary ? Icons.star : Icons.star_border),
),
IconButton(
tooltip: 'Download all',
onPressed: _chapters.isEmpty ? null : _downloadAll,
icon: const Icon(Icons.download_for_offline_outlined),
),
IconButton(
tooltip: _newestFirst ? 'Oldest first' : 'Newest first',
onPressed: () async {
setState(() => _newestFirst = !_newestFirst);
await _reload();
},
icon: Icon(_newestFirst ? Icons.arrow_downward : Icons.arrow_upward),
),
IconButton(
tooltip: 'Sync chapters',
onPressed: _syncing ? null : _sync,
icon: _syncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
],
),
floatingActionButton: _selected.isEmpty
? null
: FloatingActionButton.extended(
onPressed: _downloadSelected,
icon: const Icon(Icons.download),
label: Text('Download ${_selected.length}'),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: title == null
? const Center(child: Text('Title not found'))
: Column(
children: [
if (_error != null)
MaterialBanner(
content: Text(_error!),
actions: [
TextButton(
onPressed: () => setState(() => _error = null),
child: const Text('Dismiss'),
),
],
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
height: 128,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ComicCover(
url: title.coverUrl,
sourceId: SourceId.tryParse(title.source),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Row(
children: [
if (SourceId.tryParse(title.source)
case final sourceId?)
SourceLogo(
sourceId: sourceId,
size: 16,
borderRadius: 4,
),
if (SourceId.tryParse(title.source) != null)
const SizedBox(width: 6),
Expanded(
child: Text(
'${SourceId.tryParse(title.source)?.label ?? title.source} · ${_chapters.length} chapters',
style:
Theme.of(context).textTheme.bodySmall,
),
),
],
),
if (title.description != null) ...[
const SizedBox(height: 8),
Text(
title.description!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
if (canRead) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _onReadOrContinue,
icon: Icon(
_hasReadingHistory
? Icons.play_arrow
: Icons.menu_book,
),
label: Text(
continueChapter == null
? continueLabel
: _hasReadingHistory
? 'Continue · ${continueChapter.label}'
: continueLabel,
),
),
],
],
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
itemCount: _chapters.length,
itemBuilder: (context, i) {
final ch = _chapters[i];
final selected = _selected.contains(ch.id);
return ListTile(
leading: Checkbox(
value: selected,
onChanged: (v) {
setState(() {
if (v == true) {
_selected.add(ch.id);
} else {
_selected.remove(ch.id);
}
});
},
),
title: Text(ch.label),
subtitle: Text(_chapterSubtitle(ch)),
trailing: Icon(
ch.completed
? Icons.check_circle
: ch.status == 'downloaded'
? Icons.offline_pin
: Icons.chevron_right,
color: _statusColor(context, ch),
),
onTap: () => _openChapter(ch),
onLongPress: () {
setState(() {
if (selected) {
_selected.remove(ch.id);
} else {
_selected.add(ch.id);
}
});
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,178 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/providers.dart';
import '../../sources/source.dart';
class ComicCover extends ConsumerStatefulWidget {
const ComicCover({
super.key,
required this.url,
this.sourceId,
this.width,
this.height,
this.fit = BoxFit.cover,
});
final String? url;
final SourceId? sourceId;
final double? width;
final double? height;
final BoxFit fit;
@override
ConsumerState<ComicCover> createState() => _ComicCoverState();
}
class _ComicCoverState extends ConsumerState<ComicCover> {
bool _revealed = false;
void _setRevealed(bool value) {
if (_revealed == value) return;
setState(() => _revealed = value);
}
@override
Widget build(BuildContext context) {
final peek = ref.watch(peekModeProvider);
final cache = ref.watch(imageCacheManagerProvider);
Map<String, String> headers = const {};
final sid = widget.sourceId;
if (sid != null) {
final registry = ref.watch(sourceRegistryProvider);
if (registry.isEnabled(sid)) {
headers = registry.get(sid).imageHeaders;
}
}
if (widget.url == null || widget.url!.isEmpty) {
return Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.menu_book_outlined),
);
}
final image = CachedNetworkImage(
imageUrl: widget.url!,
cacheManager: cache,
httpHeaders: headers,
width: widget.width,
height: widget.height,
fit: widget.fit,
placeholder: (context, url) => Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image_outlined),
),
);
if (!peek) return image;
if (_revealed) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressEnd: (_) => _setRevealed(false),
onLongPressCancel: () => _setRevealed(false),
child: image,
);
}
// Heavy blur + dark veil so covers are unreadable in public.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (_) => _setRevealed(true),
child: SizedBox(
width: widget.width,
height: widget.height,
child: Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.clamp,
),
child: image,
),
),
ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
child: const Center(
child: Icon(
Icons.visibility_off,
color: Colors.white54,
size: 28,
),
),
),
],
),
),
);
}
}
class PeekModeToggleButton extends ConsumerWidget {
const PeekModeToggleButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final peek = ref.watch(peekModeProvider);
return IconButton(
tooltip: peek ? 'Peek mode on (covers hidden)' : 'Peek mode off',
onPressed: () => ref.read(peekModeProvider.notifier).toggle(),
icon: Icon(peek ? Icons.visibility_off : Icons.visibility),
);
}
}
class ComicGridTile extends StatelessWidget {
const ComicGridTile({
super.key,
required this.comic,
required this.onTap,
this.titleMaxLines = 2,
});
final CatalogComic comic;
final VoidCallback onTap;
final int titleMaxLines;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ComicCover(url: comic.coverUrl, sourceId: comic.source),
),
),
const SizedBox(height: 6),
Text(
comic.title,
maxLines: titleMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../../sources/source.dart';
/// Bundled source favicon from [SourceId.logoAsset].
class SourceLogo extends StatelessWidget {
const SourceLogo({
super.key,
required this.sourceId,
this.size = 32,
this.borderRadius = 6,
});
final SourceId sourceId;
final double size;
final double borderRadius;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: Image.asset(
sourceId.logoAsset,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.public,
size: size * 0.75,
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
name: cxyz_android
description: On-device ComicLand manga/comic reader and downloader (cxyz Android).
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.1+1
environment:
sdk: ^3.13.1
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_riverpod: ^2.6.1
go_router: ^17.5.0
dio: ^5.11.0
path_provider: ^2.1.6
path: ^1.9.1
archive: ^4.2.0
cached_network_image: ^3.4.1
flutter_cache_manager: ^3.4.2
drift: ^2.34.3
sqlite3_flutter_libs: ^0.6.0+eol
shared_preferences: ^2.5.5
html: ^0.15.6
permission_handler: ^12.0.1
flutter_secure_storage: ^11.0.0
image: ^4.9.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
drift_dev: ^2.34.5
build_runner: ^2.16.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
assets:
- assets/sources/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Build cxyz Android APK and copy to a SOURCES-tagged filename.
#
# Usage:
# ./scripts/build_apk.sh
# ./scripts/build_apk.sh mangaread
# ./scripts/build_apk.sh all
# ./scripts/build_apk.sh comicland,mangaread,mangatown
# ./scripts/build_apk.sh mangaread --debug
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
SOURCES="${1:-comicland}"
MODE="release"
if [[ "${2:-}" == "--debug" ]] || [[ "${1:-}" == "--debug" ]]; then
MODE="debug"
if [[ "${1:-}" == "--debug" ]]; then
SOURCES="comicland"
fi
fi
# Normalize tag for filename: comicland,mangaread → comicland-mangaread
SOURCES_TAG="$(echo "$SOURCES" | tr '[:upper:]' '[:lower:]' | tr ',' '-' | tr -cd 'a-z0-9-')"
if [[ -z "$SOURCES_TAG" ]]; then
SOURCES_TAG="comicland"
SOURCES="comicland"
fi
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
export PATH="${REPO_ROOT}/.tools/bin:${REPO_ROOT}/.tools/flutter-sdk/bin:${PATH:-}"
export CI=true BOT=true
echo "Building APK (SOURCES=${SOURCES}, mode=${MODE})…"
BUILD_ARGS=(build apk --dart-define="SOURCES=${SOURCES}")
if [[ "$MODE" == "debug" ]]; then
BUILD_ARGS+=(--debug)
fi
flutter "${BUILD_ARGS[@]}"
SRC_APK="build/app/outputs/flutter-apk/app-${MODE}.apk"
OUT_NAME="cxyz-${SOURCES_TAG}-${MODE}.apk"
OUT_APK="build/app/outputs/flutter-apk/${OUT_NAME}"
if [[ ! -f "$SRC_APK" ]]; then
echo "error: expected APK missing: $SRC_APK" >&2
exit 1
fi
cp -f "$SRC_APK" "$OUT_APK"
echo
echo "APK: ${ROOT}/${OUT_APK}"
echo " (also ${SRC_APK})"
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/comicland/urls.dart';
void main() {
test('parseComicLandUrl extracts slug and chapter', () {
final parts = parseComicLandUrl(
'https://comicland.org/comic/the-delinquent-girl/chapter/12',
);
expect(parts.slug, 'the-delinquent-girl');
expect(parts.chapterIndex, '12');
});
test('transferLinkBasename pads chapter numbers', () {
expect(transferLinkBasename('12', 'Chapter 12', null), 'chapter 012.cbz');
expect(transferLinkBasename('1.5', null, null), 'chapter 001.5.cbz');
});
test('sanitizeComicDirName strips illegal chars', () {
expect(sanitizeComicDirName('A/B:C*'), 'ABC');
});
}
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangaread/mangaread_source.dart';
import 'package:cxyz_android/sources/source.dart';
void main() {
test('latest pagination maps offset/limit to distinct site pages', () async {
final source = MangaReadSource();
final page1 = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 0,
limit: 30,
);
final page2 = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 30,
limit: 30,
);
expect(page1, isNotEmpty);
expect(page2, isNotEmpty);
expect(page1.first.slug, isNot(page2.first.slug));
final overlap = page1.map((c) => c.slug).toSet().intersection(
page2.map((c) => c.slug).toSet(),
);
expect(overlap, isEmpty);
}, timeout: const Timeout(Duration(seconds: 60)));
}
@@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangatown/mangatown_source.dart';
import 'package:cxyz_android/sources/source.dart';
@Tags(['network'])
void main() {
test(
'latest pagination: offset 0 and 30 yield different first slugs',
() async {
final source = MangaTownSource();
final first = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 0,
limit: 30,
);
final second = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 30,
limit: 30,
);
expect(first, isNotEmpty);
expect(second, isNotEmpty);
expect(first.first.slug, isNot(second.first.slug));
final firstSlugs = first.map((c) => c.slug).toSet();
expect(
second.any((c) => firstSlugs.contains(c.slug)),
isFalse,
reason: 'page 2 should not overlap page 1',
);
},
timeout: const Timeout(Duration(seconds: 90)),
);
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangatown/urls.dart';
import 'package:cxyz_android/sources/source.dart';
void main() {
group('mangatown catalog URLs', () {
test('latest page 1 and 2', () {
expect(catalogLatestUrl(), 'https://m.mangatown.com/latest');
expect(catalogLatestUrl(page: 2), 'https://m.mangatown.com/latest/2.html');
});
test('hot total and period queries with pagination', () {
expect(catalogHotUrl(), 'https://m.mangatown.com/hot');
expect(
catalogHotUrl(page: 2),
'https://m.mangatown.com/hot/2.html',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.today),
'https://m.mangatown.com/hot?tviews.za',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.week, page: 2),
'https://m.mangatown.com/hot/2.html?wviews.za',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.month, page: 2),
'https://m.mangatown.com/hot/2.html?mviews.za',
);
});
test('directory default and filters', () {
expect(
directoryUrl(const CatalogBrowseParams()),
'https://m.mangatown.com/directory/0-0-0-0-0-0',
);
expect(
directoryUrl(
const CatalogBrowseParams(
demographic: 'seinen',
genre: 'action',
status: 'ongoing',
initial: 'a',
),
page: 2,
),
'https://m.mangatown.com/directory/seinen-action-0-ongoing-a-0/2.html',
);
});
test('search URL', () {
expect(
catalogSearchUrl(query: 'naruto'),
'https://m.mangatown.com/search?name=naruto',
);
expect(
catalogSearchUrl(query: 'naruto', page: 2),
'https://m.mangatown.com/search/2.html?name=naruto',
);
});
});
group('mangatown series/chapter helpers', () {
test('mangaSlugFromUrl and listingUrlForSlug', () {
expect(
mangaSlugFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'),
'ao_no_souyoku',
);
expect(
listingUrlForSlug('ao_no_souyoku'),
'https://m.mangatown.com/manga/ao_no_souyoku',
);
});
test('chapterBaseUrl and chapterPageUrl', () {
expect(
chapterBaseUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/3.html'),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/',
);
expect(
chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 1),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/',
);
expect(
chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 5),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/5.html',
);
});
test('chapterKeyFromUrl and chapterNumberFromTitle', () {
expect(
chapterKeyFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'),
'c005',
);
expect(chapterNumberFromTitle('C.5'), '5');
expect(chapterNumberFromTitle('C.031'), '031');
});
test('absoluteUrl upgrades protocol-relative', () {
expect(
absoluteUrl('//zjcdn.mangahere.org/a.jpg', 'https://m.mangatown.com/'),
'https://zjcdn.mangahere.org/a.jpg',
);
});
test('hotPeriodForMenu mapping', () {
expect(hotPeriodForMenu(CatalogMenuKind.popular), MangaTownHotPeriod.total);
expect(hotPeriodForMenu(CatalogMenuKind.hotToday), MangaTownHotPeriod.today);
expect(hotPeriodForMenu(CatalogMenuKind.hotWeek), MangaTownHotPeriod.week);
expect(hotPeriodForMenu(CatalogMenuKind.hotMonth), MangaTownHotPeriod.month);
expect(hotPeriodForMenu(CatalogMenuKind.latest), isNull);
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
/// Mirrors DownloadQueue session drain gating used in `_pump`.
bool shouldNotifyQueueDrained({
required bool sessionHadWork,
required int queuedRemaining,
}) {
return sessionHadWork && queuedRemaining == 0;
}
void main() {
group('queue drained gating', () {
test('does not fire when no work was processed', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: false, queuedRemaining: 0),
isFalse,
);
});
test('fires when work ran and queue is empty', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 0),
isTrue,
);
});
test('does not fire while items remain', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 2),
isFalse,
);
});
});
}
+115
View File
@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:cxyz_android/services/ntfy_notifier.dart';
import 'package:cxyz_android/services/ntfy_settings.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
void main() {
group('NtfyNotifier.formatBytes', () {
test('formats B KB MB', () {
expect(NtfyNotifier.formatBytes(500), '500 B');
expect(NtfyNotifier.formatBytes(1536), '1.5 KB');
expect(NtfyNotifier.formatBytes(5 * 1024 * 1024), '5.0 MB');
});
});
group('payload titles and body', () {
test('completed title is scannable', () {
expect(
NtfyNotifier.completedTitle('One Piece', 'Chapter 1100'),
'Downloaded · One Piece · Chapter 1100',
);
});
test('failed title includes series and chapter', () {
expect(
NtfyNotifier.failedTitle('One Piece', 'Chapter 1100'),
'Download failed · One Piece · Chapter 1100',
);
});
test('drained title includes counts', () {
expect(
NtfyNotifier.drainedTitle(ok: 5, failed: 1),
'Queue finished · 5 downloaded · 1 failed',
);
});
test('completed body includes sizes and savings', () {
final body = NtfyNotifier.completedBody(
imageCount: 36,
rawBytes: 10 * 1024 * 1024,
cbzBytes: 8 * 1024 * 1024,
);
expect(body, contains('Pages: 36'));
expect(body, contains('Images:'));
expect(body, contains('CBZ:'));
expect(body, contains('saved 20%'));
});
test('completed body appends note', () {
final body = NtfyNotifier.completedBody(
imageCount: 1,
rawBytes: 100,
cbzBytes: 90,
note: 'Attachment skipped (15 MB limit)',
);
expect(body, contains('Attachment skipped (15 MB limit)'));
});
});
group('NtfyAttachmentMode', () {
test('fromStorage defaults to none', () {
expect(NtfyAttachmentMode.fromStorage(null), NtfyAttachmentMode.none);
expect(NtfyAttachmentMode.fromStorage('nope'), NtfyAttachmentMode.none);
expect(
NtfyAttachmentMode.fromStorage('firstImage'),
NtfyAttachmentMode.firstImage,
);
});
});
group('preview resize', () {
test('resizePreviewIsolate shrinks large image under 250 KB', () {
final big = img.Image(width: 2000, height: 3000);
img.fill(big, color: img.ColorRgb8(40, 80, 120));
// Add noise so JPEG does not compress to near-zero.
for (var y = 0; y < big.height; y += 17) {
for (var x = 0; x < big.width; x += 13) {
big.setPixelRgb(x, y, (x * 3) % 255, (y * 5) % 255, 90);
}
}
final raw = Uint8List.fromList(img.encodeJpg(big, quality: 95));
expect(raw.length, greaterThan(250 * 1024));
final out = resizePreviewForTest(raw);
expect(out, isNotNull);
expect(out!.length, lessThanOrEqualTo(250 * 1024));
final decoded = img.decodeImage(out)!;
final longest =
decoded.width > decoded.height ? decoded.width : decoded.height;
expect(longest, lessThanOrEqualTo(720));
});
});
group('encodeNtfyHeader', () {
test('leaves plain ASCII unchanged', () {
expect(encodeNtfyHeader('Downloaded Chapter 1'), 'Downloaded Chapter 1');
});
test('RFC 2047-encodes middle-dot titles', () {
final encoded = encodeNtfyHeader('Downloaded · Series · Ch 1');
expect(encoded.startsWith('=?UTF-8?B?'), isTrue);
expect(encoded.endsWith('?='), isTrue);
expect(encoded.codeUnits.every((c) => c >= 0x20 && c <= 0x7e), isTrue);
});
test('RFC 2047-encodes multiline message', () {
final encoded = encodeNtfyHeader('Pages: 3\nImages: 1 MB\nCBZ: 0.9 MB');
expect(encoded.startsWith('=?UTF-8?B?'), isTrue);
expect(encoded.contains('\n'), isFalse);
});
});
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/services/reader_zoom.dart';
void main() {
test('clamp stays in range and snaps to step', () {
expect(ReaderZoom.clamp(0.1), ReaderZoom.min);
expect(ReaderZoom.clamp(3.0), ReaderZoom.max);
expect(ReaderZoom.clamp(0.66), closeTo(0.7, 0.001));
expect(ReaderZoom.clamp(1.0), 1.0);
});
test('percentLabel formats', () {
expect(ReaderZoom.percentLabel(0.7), '70%');
expect(ReaderZoom.percentLabel(1.0), '100%');
});
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/source.dart';
import 'package:cxyz_android/sources/source_flags.dart';
import 'package:cxyz_android/sources/comicland/comicland_source.dart';
import 'package:cxyz_android/sources/mangaread/mangaread_source.dart';
import 'package:cxyz_android/sources/mangaread/urls.dart';
import 'package:cxyz_android/sources/mangatown/mangatown_source.dart';
import 'package:cxyz_android/sources/registry.dart';
void main() {
group('parseEnabledSourceIds', () {
test('default comicland token', () {
expect(parseEnabledSourceIds('comicland'), {SourceId.comicland});
});
test('comma-separated both', () {
expect(
parseEnabledSourceIds('comicland,mangaread'),
{SourceId.comicland, SourceId.mangaread},
);
});
test('mangaread only', () {
expect(parseEnabledSourceIds('mangaread'), {SourceId.mangaread});
});
test('all enables every SourceId', () {
expect(parseEnabledSourceIds('all'), SourceId.values.toSet());
expect(parseEnabledSourceIds('ALL'), SourceId.values.toSet());
expect(parseEnabledSourceIds('all,mangaread'), SourceId.values.toSet());
});
test('mangatown token', () {
expect(parseEnabledSourceIds('mangatown'), {SourceId.mangatown});
});
test('ignores unknown and falls back when empty', () {
expect(parseEnabledSourceIds(''), {SourceId.comicland});
expect(parseEnabledSourceIds('nope,also-bad'), {SourceId.comicland});
});
test('trims and is case-insensitive', () {
expect(
parseEnabledSourceIds(' MangaRead , ComicLand '),
{SourceId.mangaread, SourceId.comicland},
);
});
});
group('SourceRegistry gating', () {
test('registers only enabled ids', () {
final reg = SourceRegistry(
enabled: {SourceId.mangaread},
mangaRead: MangaReadSource(),
);
expect(reg.all.map((s) => s.id), [SourceId.mangaread]);
expect(reg.hasMultiple, isFalse);
});
test('dual sources preserve declaration order', () {
final reg = SourceRegistry(
enabled: {SourceId.mangaread, SourceId.comicland},
comicLand: ComicLandSource(),
mangaRead: MangaReadSource(),
);
expect(reg.all.map((s) => s.id).toList(), [
SourceId.comicland,
SourceId.mangaread,
]);
expect(reg.hasMultiple, isTrue);
});
test('all three sources preserve declaration order', () {
final reg = SourceRegistry(
enabled: {
SourceId.mangatown,
SourceId.mangaread,
SourceId.comicland,
},
comicLand: ComicLandSource(),
mangaRead: MangaReadSource(),
mangaTown: MangaTownSource(),
);
expect(reg.all.map((s) => s.id).toList(), [
SourceId.comicland,
SourceId.mangaread,
SourceId.mangatown,
]);
});
});
group('catalogMenus capabilities', () {
test('ComicLand exposes newest, popular, official, uncensored, recommended, search', () {
final kinds =
ComicLandSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.popular,
CatalogMenuKind.official,
CatalogMenuKind.uncensored,
CatalogMenuKind.recommended,
CatalogMenuKind.search,
]);
expect(
ComicLandSource().catalogMenus.first.title,
'Newest',
);
});
test('MangaRead exposes latest and search only', () {
final kinds =
MangaReadSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.search,
]);
expect(kinds.contains(CatalogMenuKind.popular), isFalse);
});
test('MangaTown exposes latest, hot variants, directory, search', () {
final kinds =
MangaTownSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.popular,
CatalogMenuKind.hotToday,
CatalogMenuKind.hotWeek,
CatalogMenuKind.hotMonth,
CatalogMenuKind.directory,
CatalogMenuKind.search,
]);
});
});
group('mangaread urls', () {
test('mangaSlugFromUrl', () {
expect(
mangaSlugFromUrl(
'https://www.mangaread.org/manga/the-chaebeols-youngest-son/chapter-560/',
),
'the-chaebeols-youngest-son',
);
});
test('mangaBaseUrlFromListingUrl', () {
expect(
mangaBaseUrlFromListingUrl(
'https://www.mangaread.org/manga/foo-bar/chapter-1/',
),
'https://www.mangaread.org/manga/foo-bar/',
);
});
test('listingUrlForSlug', () {
expect(
listingUrlForSlug('foo-bar'),
'https://www.mangaread.org/manga/foo-bar/',
);
});
test('chapterNumberFromTitle', () {
expect(chapterNumberFromTitle('Chapter 560'), '560');
expect(chapterNumberFromTitle('Ch. 12.5'), '12.5');
});
});
}
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/services/reader_direction.dart';
import 'package:cxyz_android/services/source_reader_defaults.dart';
void main() {
group('SourceReaderDefaults.modeFor', () {
test('comicland is webtoon', () {
expect(SourceReaderDefaults.modeFor('comicland'), ReaderMode.webtoon);
});
test('mangaread and mangatown are paged', () {
expect(SourceReaderDefaults.modeFor('mangaread'), ReaderMode.paged);
expect(SourceReaderDefaults.modeFor('mangatown'), ReaderMode.paged);
});
});
group('SourceReaderDefaults.directionFor', () {
test('mangaread and mangatown are rtl', () {
expect(
SourceReaderDefaults.directionFor('mangaread'),
ReadingDirection.rtl,
);
expect(
SourceReaderDefaults.directionFor('mangatown'),
ReadingDirection.rtl,
);
});
test('comicland has no built-in direction', () {
expect(SourceReaderDefaults.directionFor('comicland'), isNull);
});
});
group('SourceReaderDefaults.resolveMode', () {
test('stored override wins', () {
expect(
SourceReaderDefaults.resolveMode('mangatown', 'webtoon'),
ReaderMode.webtoon,
);
expect(
SourceReaderDefaults.resolveMode('comicland', 'paged'),
ReaderMode.paged,
);
});
test('empty / unknown falls back to source default', () {
expect(
SourceReaderDefaults.resolveMode('mangatown', ''),
ReaderMode.paged,
);
expect(
SourceReaderDefaults.resolveMode('mangatown', null),
ReaderMode.paged,
);
expect(
SourceReaderDefaults.resolveMode('comicland', ''),
ReaderMode.webtoon,
);
});
});
group('SourceReaderDefaults.resolveDirection', () {
test('stored override wins', () {
expect(
SourceReaderDefaults.resolveDirection(
'mangatown',
'ltr',
globalFallback: ReadingDirection.rtl,
),
ReadingDirection.ltr,
);
});
test('source default when unset', () {
expect(
SourceReaderDefaults.resolveDirection(
'mangaread',
null,
globalFallback: ReadingDirection.ltr,
),
ReadingDirection.rtl,
);
});
test('global fallback for comicland when unset', () {
expect(
SourceReaderDefaults.resolveDirection(
'comicland',
null,
globalFallback: ReadingDirection.rtl,
),
ReadingDirection.rtl,
);
expect(
SourceReaderDefaults.resolveDirection(
'comicland',
'',
globalFallback: ReadingDirection.ltr,
),
ReadingDirection.ltr,
);
});
});
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/main.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
testWidgets('App builds', (tester) async {
await tester.pumpWidget(const ProviderScope(child: CxyzApp()));
// Catalog app bar title from ComicLand source.
expect(find.text('ComicLand'), findsOneWidget);
});
}
+3 -6
View File
@@ -11,12 +11,9 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"docs": "jsdoc -c jsdoc.conf.json",
"mangaread:fetch": "node src/mangareadFetchChapters.js",
"manhwasusu:fetch": "node src/manhwasusu/fetchChapters.cli.js",
"manhwasusu:chapter-cbz": "node src/manhwasusu/downloadChapterCbz.js",
"manhwasusu:chapters-cbz": "node src/manhwasusu/downloadChaptersQueue.js",
"mangaread:chapter-cbz": "node src/downloadChapterCbz.js",
"mangaread:chapters-cbz": "node src/downloadChaptersQueue.js",
"fetch": "node src/cli/fetch.js",
"chapter-cbz": "node src/cli/chapter-cbz.js",
"chapters-cbz": "node src/cli/chapters-cbz.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
+2379
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
better-sqlite3: true
+397
View File
@@ -0,0 +1,397 @@
const dns = require("dns");
const fs = require("fs");
const https = require("https");
const net = require("net");
const path = require("path");
const axios = require("axios");
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
/**
* @typedef {object} ParsedCli
* @property {boolean} help - Print usage and exit when true.
* @property {string|undefined} url - Absolute page URL.
* @property {string|undefined} outPath - Optional file to write the body to.
* @property {number} timeoutMs - Axios timeout in milliseconds.
* @property {string|null} domain - Optional Host header / logical hostname.
*/
/**
* Parse CLI args: `node scripts/fetch-page.js <url> [--domain <host>] [--out <file>] [--timeout <ms>]`.
* @param {string[]} argv - `process.argv.slice(2)`.
* @returns {ParsedCli} Parsed flags and positional URL.
*/
function parseCli(argv) {
/** @type {ParsedCli} */
const result = {
help: false,
url: undefined,
outPath: undefined,
timeoutMs: 15_000,
domain: null,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "-h" || arg === "--help") {
result.help = true;
continue;
}
if (arg === "--domain" || arg === "-d") {
result.domain = argv[i + 1];
i += 1;
continue;
}
if (arg === "--out" || arg === "-o") {
result.outPath = argv[i + 1];
i += 1;
continue;
}
if (arg.startsWith("--out=")) {
result.outPath = arg.slice("--out=".length);
continue;
}
if (arg === "--timeout") {
result.timeoutMs = Number(argv[i + 1]);
i += 1;
continue;
}
if (arg.startsWith("--timeout=")) {
result.timeoutMs = Number(arg.slice("--timeout=".length));
continue;
}
if (!arg.startsWith("-") && result.url === undefined) {
result.url = arg;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return result;
}
/**
* Write a diagnostic line to stderr so stdout stays the page body.
* @param {string} line - Message.
* @returns {void}
*/
function log(line) {
console.error(line);
}
/**
* Flatten Axios or Node headers into a plain object.
* @param {unknown} headers - AxiosHeaders, IncomingHttpHeaders, or plain object.
* @returns {Record<string, string>} Header names mapped to string values.
*/
function plainHeaders(headers) {
if (!headers || typeof headers !== "object") {
return {};
}
const raw =
typeof /** @type {{ toJSON?: () => unknown }} */ (headers).toJSON ===
"function"
? /** @type {{ toJSON: () => unknown }} */ (headers).toJSON()
: headers;
/** @type {Record<string, string>} */
const out = {};
for (const [key, value] of Object.entries(raw || {})) {
if (value == null) {
continue;
}
out[key] = Array.isArray(value) ? value.join(", ") : String(value);
}
return out;
}
/**
* Log HTTP headers, one per line.
* @param {unknown} headers - Response or request headers.
* @returns {void}
*/
function logHeaders(headers) {
const entries = Object.entries(plainHeaders(headers));
if (entries.length === 0) {
log(" (no headers)");
return;
}
for (const [key, value] of entries) {
log(` ${key}: ${value}`);
}
}
/**
* Resolve A/AAAA records and OS lookup for a hostname; log the query.
* @param {string} hostname - Name or IP to resolve.
* @param {string} [label] - Extra context, e.g. "connect" or "Host header".
* @returns {Promise<void>}
*/
async function logDnsQuery(hostname, label) {
const tag = label ? ` (${label})` : "";
if (net.isIP(hostname)) {
log(`dns query${tag}: ${hostname}`);
log(` skipped — host is already an IP (IPv${String(net.isIP(hostname))})`);
return;
}
const servers = dns.getServers();
log(`dns query${tag}: ${hostname}`);
log(` servers: ${servers.join(", ")}`);
const started = Date.now();
const [a, aaaa, lookup] = await Promise.all([
dns.promises.resolve4(hostname, { ttl: true }).catch((err) => err),
dns.promises.resolve6(hostname, { ttl: true }).catch((err) => err),
dns.promises.lookup(hostname, { all: true }).catch((err) => err),
]);
const elapsed = Date.now() - started;
if (a instanceof Error) {
log(` A: ${a.message}`);
} else {
for (const rec of a) {
log(` A: ${rec.address} ttl=${String(rec.ttl)}`);
}
if (a.length === 0) {
log(" A: (empty)");
}
}
if (aaaa instanceof Error) {
log(` AAAA: ${aaaa.message}`);
} else {
for (const rec of aaaa) {
log(` AAAA: ${rec.address} ttl=${String(rec.ttl)}`);
}
if (aaaa.length === 0) {
log(" AAAA: (empty)");
}
}
if (lookup instanceof Error) {
log(` lookup (getaddrinfo): ${lookup.message}`);
} else {
for (const rec of lookup) {
log(` lookup (getaddrinfo): ${rec.address} IPv${String(rec.family)}`);
}
if (lookup.length === 0) {
log(" lookup (getaddrinfo): (empty)");
}
}
log(` elapsed: ${String(elapsed)}ms`);
}
/**
* Agent `lookup` that logs the hostname Node actually resolves for the socket.
* @param {string} hostname - Name passed by http/https.
* @param {import("dns").LookupOptions} options - dns.lookup options.
* @param {(...args: unknown[]) => void} callback - Node lookup callback.
* @returns {void}
*/
function loggedLookup(hostname, options, callback) {
const started = Date.now();
dns.lookup(hostname, options, (err, address, family) => {
const elapsed = Date.now() - started;
if (err) {
log(
`dns lookup (socket): ${hostname} failed in ${String(elapsed)}ms: ${err.message}`,
);
} else {
const addrs = Array.isArray(address)
? address
.map((row) => `${row.address} IPv${String(row.family)}`)
.join(", ")
: `${String(address)} IPv${String(family)}`;
log(
`dns lookup (socket): ${hostname} -> ${addrs} (${String(elapsed)}ms)`,
);
}
callback(err, address, family);
});
}
/**
* Log axios failure details (TLS handshake, no response, or HTTP error).
* @param {unknown} err - Caught error.
* @returns {void}
*/
function logAxiosError(err) {
const ax = /** @type {import("axios").AxiosError} */ (err);
log("response: (failed)");
if (ax.code) {
log(` code: ${ax.code}`);
}
log(` message: ${ax instanceof Error ? ax.message : String(ax)}`);
const syscall =
ax && typeof ax === "object" && "syscall" in ax
? /** @type {{ syscall?: string }} */ (ax).syscall
: undefined;
const address =
ax && typeof ax === "object" && "address" in ax
? /** @type {{ address?: string }} */ (ax).address
: undefined;
const port =
ax && typeof ax === "object" && "port" in ax
? /** @type {{ port?: number }} */ (ax).port
: undefined;
if (syscall) {
log(` syscall: ${syscall}`);
}
if (address != null) {
log(` peer: ${address}${port != null ? `:${String(port)}` : ""}`);
}
if (ax.response) {
log(
` http status: ${String(ax.response.status)} ${ax.response.statusText || ""}`.trim(),
);
log(" http headers:");
logHeaders(ax.response.headers);
const body = ax.response.data;
if (typeof body === "string" && body.length > 0) {
const preview = body.length > 500 ? `${body.slice(0, 500)}…` : body;
log(` http body preview: ${preview}`);
}
return;
}
log(" (no HTTP response — connection or TLS failed before headers)");
if (ax.code === "EPROTO" || /ssl|tls|handshake/iu.test(String(ax.message))) {
log(
" hint: HTTPS to an IP uses that IP as TLS SNI unless servername is set; CDNs often send alert 40",
);
}
}
/**
* GET a page URL and return the response body as text.
* @param {string} url - Absolute page URL.
* @param {number} timeoutMs - Axios timeout.
* @param {string|null} domain - Optional Host header / logical hostname.
* @returns {Promise<{ status: number, contentType: string, body: string }>} HTTP status, content-type, and body text.
*/
async function fetchPage(url, timeoutMs, domain) {
const parsedUrl = new URL(url);
const connectHost = parsedUrl.hostname;
const hostHeader = domain || parsedUrl.host;
const sni = domain && net.isIP(connectHost) ? domain : connectHost;
await logDnsQuery(connectHost, "connect");
if (domain && domain !== connectHost) {
await logDnsQuery(domain, "Host / --domain");
}
log("request:");
log(` GET ${url}`);
log(` connect host: ${connectHost}`);
log(` Host header: ${hostHeader}`);
log(` tls servername (SNI): ${sni}`);
const httpsAgent = new https.Agent({
lookup: loggedLookup,
servername: sni,
});
let data;
let status;
let headers;
try {
({ data, status, headers } = await axios.get(url, {
responseType: "text",
timeout: timeoutMs,
httpsAgent,
validateStatus: (s) => s >= 200 && s < 300,
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": USER_AGENT,
Host: hostHeader,
},
}));
} catch (err) {
logAxiosError(err);
throw err;
}
log(`response: ${String(status)}`);
logHeaders(headers);
return {
status,
contentType: String(headers["content-type"] || ""),
body: typeof data === "string" ? data : String(data ?? ""),
};
}
/**
* Fetch a website page with axios and print the body (or write it to a file).
*
* Usage:
* node scripts/fetch-page.js <url> [--domain <host>] [--out <file>] [--timeout <ms>]
* @returns {Promise<void>}
*/
async function main() {
let parsed;
try {
parsed = parseCli(process.argv.slice(2));
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
if (parsed.help || !parsed.url) {
console.error(
"Usage: node scripts/fetch-page.js <url> [--domain <host>] [--out <file>] [--timeout <ms>]",
);
if (!parsed.help) {
process.exitCode = 1;
}
return;
}
if (!Number.isFinite(parsed.timeoutMs) || parsed.timeoutMs <= 0) {
console.error("--timeout must be a positive number of milliseconds");
process.exitCode = 1;
return;
}
let page;
try {
page = await fetchPage(parsed.url, parsed.timeoutMs, parsed.domain);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
log(`bytes: ${String(Buffer.byteLength(page.body, "utf8"))}`);
if (parsed.outPath) {
const abs = path.resolve(parsed.outPath);
fs.writeFileSync(abs, page.body, "utf8");
log(`wrote: ${abs}`);
return;
}
process.stdout.write(page.body);
if (!page.body.endsWith("\n")) {
process.stdout.write("\n");
}
}
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}
module.exports = { fetchPage, main, parseCli };
+25
View File
@@ -0,0 +1,25 @@
const axios = require("axios");
axios.get("https://104.26.5.47/", {
"headers": {
"Host": "104.26.5.47",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "en-US,en;q=0.9",
"cache-control": "no-cache",
"pragma": "no-cache",
"priority": "u=0, i",
"sec-ch-ua": "\"Not;A=Brand\";v=\"8\", \"Chromium\";v=\"150\", \"Google Chrome\";v=\"150\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"macOS\"",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
},
})
.then(response => {
console.log(response.data);
})
.catch(error => {
console.error(error);
});
+142
View File
@@ -0,0 +1,142 @@
const path = require("path");
/**
* Resolve an explicit CLI output path (`.cbz` file or directory).
* @param {string|undefined} cliOut - Second positional arg.
* @param {string} defaultCbzPath - Path from resolveCbzPath / default layout.
* @returns {string} Absolute `.cbz` path.
*/
function resolveExplicitOutput(cliOut, defaultCbzPath) {
if (cliOut === undefined || cliOut.trim() === "") {
return defaultCbzPath;
}
const abs = path.resolve(cliOut.trim());
if (abs.toLowerCase().endsWith(".cbz")) {
return abs;
}
return path.join(abs, path.basename(defaultCbzPath));
}
/**
* Unified single-chapter CBZ CLI: detect source from chapter URL.
*
* Usage:
* node src/cli/chapter-cbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]
* @returns {Promise<void>}
*/
async function main() {
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const positional = argv.filter((a) => !a.startsWith("-"));
const chapterUrl = positional[0];
if (!chapterUrl) {
console.error(
"Usage: node src/cli/chapter-cbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]",
);
process.exitCode = 1;
return;
}
const outArg = positional[1];
const {
detectSource,
downloadChapter,
fetchSeriesChapters,
resolveCbzPath,
siteLabel,
} = require("../server/sources.js");
let source;
try {
source = detectSource(chapterUrl);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
console.log(`source: ${source} (${siteLabel(source)})`);
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let chapterRow;
/** @type {string} */
let target;
try {
if (!noDb) {
const cliStore = require("../server/cli-store.js");
({ db, dataDir } = cliStore.openCliDatabase());
const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir);
console.log(
`SQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`${String(result.total)} chapter(s)`,
);
chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl);
const defaultPath = resolveCbzPath(
source,
result.title.title,
chapterUrl,
dataDir,
);
target = resolveExplicitOutput(outArg, defaultPath);
if (chapterRow) {
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
} else {
let comicTitle = "comic";
try {
const series = await fetchSeriesChapters(chapterUrl, source);
comicTitle = series.comicTitle;
} catch {
/* keep fallback for path resolution */
}
const dataRoot = path.resolve(
process.env.DATA_DIR || path.join(process.cwd(), "data"),
);
const defaultPath = resolveCbzPath(source, comicTitle, chapterUrl, dataRoot);
target = resolveExplicitOutput(outArg, defaultPath);
}
const { imageCount, outputPath } = await downloadChapter(source, chapterUrl, target);
console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
const cliStore = require("../server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(msg);
if (db && chapterRow) {
const cliStore = require("../server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
process.exitCode = 1;
} finally {
if (db) {
db.close();
}
}
}
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}
module.exports = { main, resolveExplicitOutput };
+750
View File
@@ -0,0 +1,750 @@
const path = require("path");
/**
* @typedef {object} ParsedCli
* @property {boolean} [help]
* @property {string|undefined} listingUrl
* @property {boolean} listOnly
* @property {boolean} allChapters
* @property {boolean} missingOnly
* @property {string|undefined} selectSpec
* @property {string|undefined} numbersSpec
* @property {string|undefined} outDir
* @property {boolean} noDb
* @property {number|undefined} titleId
* @property {boolean} outDirExplicit
* @property {boolean} verbose
* @property {number} delaySeconds
*/
/**
* @typedef {object} ChapterRow
* @property {string} url
* @property {string} title
* @property {string} chapter_key
* @property {string|null} chapter_number
*/
/**
* @param {string[]} argv - `process.argv`.
* @returns {ParsedCli}
*/
function parseCli(argv) {
/** @type {ParsedCli} */
const result = {
help: false,
listingUrl: undefined,
listOnly: false,
allChapters: false,
missingOnly: false,
selectSpec: undefined,
numbersSpec: undefined,
outDir: undefined,
noDb: false,
titleId: undefined,
outDirExplicit: false,
verbose: false,
delaySeconds: 0,
};
for (let i = 2; i < argv.length; i += 1) {
const a = argv[i];
if (a === "--help" || a === "-h") {
result.help = true;
} else if (a === "--list-only" || a === "-l") {
result.listOnly = true;
} else if (a === "--all") {
result.allChapters = true;
} else if (a === "--missing") {
result.missingOnly = true;
} else if (a === "--verbose" || a === "-v") {
result.verbose = true;
} else if (a === "--no-db") {
result.noDb = true;
} else if (a === "--delay") {
const next = argv[i + 1];
if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) {
throw new Error("Missing value for --delay");
}
const seconds = Number.parseInt(next, 10);
if (!Number.isFinite(seconds) || seconds < 0) {
throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`);
}
result.delaySeconds = seconds;
i += 1;
} else if (a === "--title-id") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --title-id");
}
const id = Number.parseInt(next, 10);
if (!Number.isFinite(id) || id < 1) {
throw new Error(`Invalid --title-id: ${next}`);
}
result.titleId = id;
i += 1;
} else if (a === "--select" || a === "-s") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --select");
}
result.selectSpec = next;
i += 1;
} else if (a === "--numbers" || a === "-n") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --numbers");
}
result.numbersSpec = next;
i += 1;
} else if (a === "--out" || a === "-o") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --out");
}
result.outDir = path.resolve(next);
result.outDirExplicit = true;
i += 1;
} else if (!a.startsWith("-")) {
if (result.listingUrl) {
throw new Error(`Unexpected extra argument: ${a}`);
}
result.listingUrl = a;
} else {
throw new Error(`Unknown option: ${a}`);
}
}
return result;
}
/**
* @param {boolean} verbose
* @returns {(msg: string) => void}
*/
function makeVlog(verbose) {
return (msg) => {
if (verbose) {
console.log(`info: ${msg}`);
}
};
}
/**
* @param {number} seconds
* @returns {Promise<void>}
*/
function sleepSeconds(seconds) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1000);
});
}
/**
* @param {ChapterRow[]} chapters
* @param {object[]} needingRows
* @returns {Array<{ url: string, title: string, pickLabel: string }>}
*/
function queueItemsMissing(chapters, needingRows) {
/** @type {Map<string, object>} */
const byUrl = new Map();
for (const row of needingRows) {
if (typeof row.url === "string" && row.url) {
byUrl.set(row.url, row);
}
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
const items = [];
/** @type {Set<string>} */
const used = new Set();
for (const ch of chapters) {
const row = byUrl.get(ch.url);
if (!row) {
continue;
}
used.add(ch.url);
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: ch.url,
title: ch.title,
pickLabel: `missing:${reason}`,
});
}
for (const row of needingRows) {
if (typeof row.url !== "string" || !row.url || used.has(row.url)) {
continue;
}
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: row.url,
title: row.label || row.chapter_key || row.url,
pickLabel: `missing:${reason}`,
});
}
return items;
}
/**
* @param {import("better-sqlite3").Database} db
* @param {number} titleId
* @param {(p: string|null|undefined) => boolean} cbzExistsFn
* @returns {object[]}
*/
function listChaptersMissing(db, titleId, cbzExistsFn) {
const dbApi = require("../server/db.js");
const rows = dbApi.listChapters(db, titleId);
return rows.filter((ch) => {
if (ch.status === "pending" || ch.status === "failed") {
return true;
}
if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) {
return true;
}
return false;
});
}
/**
* @param {string} spec
* @param {number} count
* @returns {number[]}
*/
function parseChapterSelection(spec, count) {
if (count <= 0) {
throw new Error("Chapter list is empty; cannot select indices.");
}
const trimmed = spec.trim();
if (trimmed.length === 0) {
throw new Error("--select value is empty.");
}
/** @type {Set<number>} */
const seen = new Set();
for (const rawPart of trimmed.split(",")) {
const part = rawPart.trim();
if (part.length === 0) {
continue;
}
if (/^\d+$/u.test(part)) {
const n = Number.parseInt(part, 10);
if (n < 1 || n > count) {
throw new Error(
`Index ${String(n)} is out of range (valid: 1–${String(count)}).`,
);
}
seen.add(n);
continue;
}
const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part);
if (rangeMatch) {
const lo = Number.parseInt(rangeMatch[1], 10);
const hi = Number.parseInt(rangeMatch[2], 10);
const a = Math.min(lo, hi);
const b = Math.max(lo, hi);
for (let j = a; j <= b; j += 1) {
if (j < 1 || j > count) {
throw new Error(
`Range ${part} includes invalid index ${String(j)} (valid: 1–${String(count)}).`,
);
}
seen.add(j);
}
continue;
}
throw new Error(`Invalid --select segment: "${part}"`);
}
if (seen.size === 0) {
throw new Error("No indices parsed from --select.");
}
return [...seen].sort((x, y) => x - y);
}
/**
* @param {string} title
* @returns {string|null}
*/
function chapterNumberFromListingTitle(title) {
const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/gu, " "));
return m ? m[1] : null;
}
/**
* @param {string} spec
* @returns {string[]}
*/
function parseChapterNumbersSpec(spec) {
const trimmed = spec.trim();
if (trimmed.length === 0) {
throw new Error("--numbers value is empty.");
}
/** @type {string[]} */
const ordered = [];
/** @type {Set<string>} */
const seen = new Set();
for (const rawPart of trimmed.split(",")) {
const part = rawPart.trim();
if (part.length === 0) {
continue;
}
const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part);
if (rangeMatch) {
const lo = Number.parseInt(rangeMatch[1], 10);
const hi = Number.parseInt(rangeMatch[2], 10);
const a = Math.min(lo, hi);
const b = Math.max(lo, hi);
for (let n = a; n <= b; n += 1) {
const k = String(n);
if (!seen.has(k)) {
seen.add(k);
ordered.push(k);
}
}
continue;
}
if (/^\d+(\.\d+)?$/u.test(part)) {
if (!seen.has(part)) {
seen.add(part);
ordered.push(part);
}
continue;
}
throw new Error(`Invalid --numbers segment: "${part}"`);
}
if (ordered.length === 0) {
throw new Error("No chapter numbers parsed from --numbers.");
}
return ordered;
}
/**
* @param {string[]} orderedNums
* @param {ChapterRow[]} chapters
* @returns {Array<{ url: string, title: string, pickLabel: string }>}
*/
function queueItemsByChapterNumbers(orderedNums, chapters) {
/** @type {Map<string, ChapterRow>} */
const first = new Map();
for (const ch of chapters) {
const fromField =
typeof ch.chapter_number === "string" && ch.chapter_number.trim()
? ch.chapter_number.trim()
: null;
const fromTitle = chapterNumberFromListingTitle(ch.title);
const num = fromField || fromTitle;
if (num !== null && !first.has(num)) {
first.set(num, ch);
}
}
return orderedNums.map((num) => {
const ch = first.get(num);
if (!ch) {
throw new Error(
`No chapter numbered "${num}" in the listing. ` +
"Use --list-only and check titles / chapter numbers.",
);
}
return {
url: ch.url,
title: ch.title,
pickLabel: `Ch.${num}`,
};
});
}
/**
* @param {ChapterRow[]} chapters
* @returns {void}
*/
function printChapterMenu(chapters) {
const digits = String(chapters.length).length;
for (let i = 0; i < chapters.length; i += 1) {
const n = i + 1;
const pad = String(n).padStart(digits, " ");
const title = chapters[i].title.replace(/\s+/gu, " ").trim();
console.log(`${pad}. ${title}`);
console.log(` ${chapters[i].url}`);
}
}
/**
* @param {number[]} indices1
* @param {ChapterRow[]} chapters
* @returns {Array<{ url: string, title: string, pickLabel: string }>}
*/
function chaptersByIndices(indices1, chapters) {
return indices1.map((idx) => ({
url: chapters[idx - 1].url,
title: chapters[idx - 1].title,
pickLabel: `#${String(idx)}`,
}));
}
/**
* Unified chapter queue: detect source from URL (or SQLite title.source).
* @returns {Promise<void>}
*/
async function main() {
let parsed;
try {
parsed = parseCli(process.argv);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
if (parsed.help) {
console.log(`Unified chapter queue (auto-detects mangaread / manhwasusu / comicland):
Usage:
node src/cli/chapters-cbz.js <listing-url> [--list-only|-l]
node src/cli/chapters-cbz.js <listing-url> --numbers|-n <spec> [--out|-o <dir>]
node src/cli/chapters-cbz.js <listing-url> --select|-s <spec> [--out|-o <dir>]
node src/cli/chapters-cbz.js <listing-url> --all [--out|-o <dir>]
node src/cli/chapters-cbz.js <listing-url> --missing [--out|-o <dir>]
node src/cli/chapters-cbz.js --title-id <id> --missing …
node src/cli/chapters-cbz.js --title-id <id> --numbers|-n <spec> …
Listing URL: series page (chapter URL is also accepted; source is auto-detected).
--title-id: load listing URL + source from SQLite (DATA_DIR/app.db)
--missing: download chapters that are pending, failed, or downloaded but CBZ missing
--delay N: wait N seconds between chapter downloads
--no-db: write CBZ files only; do not update SQLite
--verbose|-v: print informational progress logs
DATA_DIR: data root (default: ./data)
Default CBZ layout (no --out): $DATA_DIR/<source>/comics/<title>/…
(mangaread uses $DATA_DIR/comics/<title>/)
--numbers: chapter numbers, e.g. 22,23,24 or 22-24
--select: 1-based rows on printed list (line 1 = first in reading order)
`);
return;
}
const vlog = makeVlog(parsed.verbose);
const cliStore = require("../server/cli-store.js");
const {
detectSource,
fetchSeriesChapters,
downloadChapter,
resolveCbzPath,
cbzExists,
comicsRoot,
sanitizeComicDirNameForSource,
siteLabel,
} = require("../server/sources.js");
const dbApi = require("../server/db.js");
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let dbTitle;
/** @type {string|undefined} */
let source;
try {
if (parsed.missingOnly && parsed.noDb) {
console.error("Cannot combine --missing with --no-db (SQLite is required).");
process.exitCode = 1;
return;
}
if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) {
if (parsed.noDb && parsed.titleId !== undefined) {
console.error("Cannot combine --no-db with --title-id.");
process.exitCode = 1;
return;
}
vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`);
({ db, dataDir } = cliStore.openCliDatabase());
vlog(`database ready: ${path.join(dataDir, "app.db")}`);
}
if (parsed.titleId !== undefined) {
vlog(`looking up --title-id ${String(parsed.titleId)}`);
dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId);
if (!dbTitle) {
console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`);
process.exitCode = 1;
return;
}
parsed.listingUrl = dbTitle.url;
source = dbTitle.source;
vlog(`resolved title "${dbTitle.title}" (${source}) → ${dbTitle.url}`);
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/cli/chapters-cbz.js <listing-url> [options]\n" +
"Or: --title-id <id> [options]",
);
process.exitCode = 1;
return;
}
if (!source) {
try {
source = detectSource(parsed.listingUrl);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
}
console.log(`source: ${source} (${siteLabel(source)})`);
vlog(`fetching chapter listing: ${parsed.listingUrl}`);
/** @type {ChapterRow[]} */
let chapters;
let comicTitle;
try {
const series = await fetchSeriesChapters(parsed.listingUrl, source);
source = series.source;
chapters = series.chapters;
comicTitle = series.comicTitle;
parsed.listingUrl = series.listingUrl;
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
console.log(`Found ${String(chapters.length)} chapter link(s).`);
printChapterMenu(chapters);
vlog(`comic title from listing: ${comicTitle}`);
if (parsed.listOnly) {
if (db && dataDir) {
vlog("upserting title/chapters into SQLite (--list-only)");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
console.log(
`\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
return;
}
const wantsAll = parsed.allChapters;
const wantsMissing = parsed.missingOnly;
const wantsSelect = Boolean(parsed.selectSpec);
const wantsNumbers = Boolean(parsed.numbersSpec);
const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter(
Boolean,
).length;
if (pickModes === 0) {
console.error("Choose --numbers, --select, --all, or --missing (or --list-only).");
process.exitCode = 1;
return;
}
if (pickModes > 1) {
console.error("Use only one of --all, --missing, --select, or --numbers.");
process.exitCode = 1;
return;
}
if (db && dataDir) {
vlog("upserting title/chapters into SQLite");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
dbTitle = result.title;
console.log(
`SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
let queue;
try {
if (wantsMissing) {
if (!db || !dbTitle) {
console.error("--missing requires SQLite (omit --no-db).");
process.exitCode = 1;
return;
}
vlog("selecting chapters that need download (pending/failed/missing CBZ)");
const needing = listChaptersMissing(db, dbTitle.id, cbzExists);
queue = queueItemsMissing(chapters, needing);
console.log(
`Missing: ${String(queue.length)} chapter(s) need download ` +
`(of ${String(chapters.length)} on listing).`,
);
if (queue.length === 0) {
console.log("Nothing to download.");
return;
}
} else if (wantsAll) {
vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`);
const indices = Array.from({ length: chapters.length }, (_, i) => i + 1);
queue = chaptersByIndices(indices, chapters);
} else if (wantsSelect) {
vlog(`queue mode: --select ${parsed.selectSpec}`);
const indices = parseChapterSelection(
/** @type {string} */ (parsed.selectSpec),
chapters.length,
);
queue = chaptersByIndices(indices, chapters);
} else {
vlog(`queue mode: --numbers ${parsed.numbersSpec}`);
const orderedNums = parseChapterNumbersSpec(
/** @type {string} */ (parsed.numbersSpec),
);
queue = queueItemsByChapterNumbers(orderedNums, chapters);
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
vlog(`download queue size: ${String(queue.length)}`);
const titleForPaths = (dbTitle && dbTitle.title) || comicTitle;
const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit);
const dataRoot =
dataDir ||
path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
const fallbackOutDir = parsed.outDirExplicit
? path.join(
/** @type {string} */ (parsed.outDir),
sanitizeComicDirNameForSource(source, comicTitle),
)
: path.join(
comicsRoot(source, dataRoot),
sanitizeComicDirNameForSource(source, comicTitle),
);
const outDir =
useDbPaths && queue.length > 0
? path.dirname(
resolveCbzPath(
source,
titleForPaths,
queue[0].url,
/** @type {string} */ (dataDir),
),
)
: fallbackOutDir;
console.log(`Comic: ${comicTitle}`);
console.log(`Output directory: ${outDir}`);
if (parsed.delaySeconds > 0) {
console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`);
}
let failedCount = 0;
let done = 0;
for (const item of queue) {
done += 1;
let targetPath = resolveCbzPath(source, titleForPaths, item.url, dataRoot);
if (parsed.outDirExplicit) {
targetPath = path.join(fallbackOutDir, path.basename(targetPath));
} else if (useDbPaths && dataDir) {
targetPath = resolveCbzPath(source, titleForPaths, item.url, dataDir);
}
/** @type {object|undefined} */
let chapterRow;
if (db && dbTitle) {
chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url);
}
console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`);
console.log(` → ${item.url}`);
vlog(`target CBZ: ${targetPath}`);
try {
if (db && chapterRow) {
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
const { imageCount, outputPath } = await downloadChapter(
source,
item.url,
targetPath,
);
console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ✗ ${msg}`);
if (db && chapterRow) {
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
failedCount += 1;
}
if (parsed.delaySeconds > 0 && done < queue.length) {
console.log(
` … waiting ${String(parsed.delaySeconds)}s before next chapter`,
);
await sleepSeconds(parsed.delaySeconds);
}
}
if (failedCount > 0) {
console.error(
`\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`,
);
process.exitCode = 1;
}
} finally {
if (db) {
db.close();
}
}
}
module.exports = {
chapterNumberFromListingTitle,
chaptersByIndices,
listChaptersMissing,
parseChapterNumbersSpec,
parseChapterSelection,
parseCli,
printChapterMenu,
queueItemsByChapterNumbers,
queueItemsMissing,
};
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Unified fetch CLI: detect source from URL, list chapters, optionally upsert SQLite.
*
* Usage:
* node src/cli/fetch.js <listing-or-chapter-url> [--no-db]
* @returns {Promise<void>}
*/
async function main() {
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const pageUrl = argv.find((a) => !a.startsWith("-"));
if (!pageUrl) {
console.error(
"Usage: node src/cli/fetch.js <listing-or-chapter-url> [--no-db]",
);
process.exitCode = 1;
return;
}
const {
detectSource,
fetchSeriesChapters,
siteLabel,
} = require("../server/sources.js");
let source;
try {
source = detectSource(pageUrl);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
console.log(`source: ${source} (${siteLabel(source)})`);
let series;
try {
series = await fetchSeriesChapters(pageUrl, source);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
console.log(`listing: ${series.listingUrl}`);
console.log(`title: ${series.comicTitle}`);
console.log(`slug: ${series.slug}`);
console.log(`Found ${String(series.chapters.length)} chapter(s):`);
console.log(
JSON.stringify(
series.chapters.map((ch) => ({
url: ch.url,
title: ch.title,
chapter_key: ch.chapter_key,
chapter_number: ch.chapter_number,
})),
null,
2,
),
);
if (noDb) {
return;
}
const { openCliDatabase, ensureTitleFromUrl } = require("../server/cli-store.js");
const path = require("path");
const { db, dataDir } = openCliDatabase();
try {
const result = await ensureTitleFromUrl(db, pageUrl, dataDir);
console.log(
`\nSQLite (${path.join(dataDir, "app.db")}): ` +
`title id=${String(result.title.id)} ` +
`"${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
} finally {
db.close();
}
}
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}
module.exports = { main };
+177
View File
@@ -0,0 +1,177 @@
const {
CHAPTER_PATHNAME_PATTERN,
COMICLAND_API_ORIGIN,
COMICLAND_HOSTS,
LISTING_PATHNAME_PATTERN,
} = require("./constants.js");
const { fetchComicLandJson, unwrapApiData } = require("./io.js");
/**
* @typedef {object} ChapterLink
* @property {string} url - Absolute chapter reader URL.
* @property {string} title - Chapter label (trimmed).
*/
/**
* @param {string} pathname - URL pathname.
* @returns {string} Path without trailing slashes.
*/
function stripTrailingSlash(pathname) {
return pathname.replace(/\/+$/u, "") || "/";
}
/**
* @param {string} pathname - URL pathname.
* @returns {boolean} Whether this path is a ComicLand chapter reader URL.
*/
function isChapterPathname(pathname) {
return CHAPTER_PATHNAME_PATTERN.test(stripTrailingSlash(pathname));
}
/**
* @param {string} pathname - URL pathname.
* @returns {boolean} Whether this path is a ComicLand series listing URL.
*/
function isListingPathname(pathname) {
return LISTING_PATHNAME_PATTERN.test(stripTrailingSlash(pathname));
}
/**
* @param {string} url - Any http(s) URL.
* @returns {boolean} True when hostname is ComicLand (hint only).
*/
function isLikelyComicLandSite(url) {
try {
const { hostname } = new URL(url);
const host = hostname.toLowerCase();
return COMICLAND_HOSTS.some((h) => h === host) || host.includes("comicland");
} catch {
return false;
}
}
/**
* Series slug from `/comic/{slug}` or `/comic/{slug}/chapter/{index}`.
* @param {string} pageUrl - Series or chapter URL.
* @returns {string} Slug segment, or `"comic"`.
*/
function seriesSlugFromListingUrl(pageUrl) {
try {
const { pathname } = new URL(pageUrl);
const parts = pathname.split("/").filter(Boolean);
const comicIdx = parts.indexOf("comic");
if (comicIdx !== -1 && parts[comicIdx + 1] !== undefined) {
return parts[comicIdx + 1];
}
} catch {
/* invalid URL */
}
return "comic";
}
/**
* @param {string} pageUrl - Series or chapter URL.
* @returns {{ origin: string, slug: string, chapterIndex: string|null }} Parsed parts.
*/
function parseComicLandUrl(pageUrl) {
const u = new URL(pageUrl);
const parts = u.pathname.split("/").filter(Boolean);
const comicIdx = parts.indexOf("comic");
const slug =
comicIdx !== -1 && parts[comicIdx + 1] !== undefined
? parts[comicIdx + 1]
: "comic";
let chapterIndex = null;
const chapterIdx = parts.indexOf("chapter");
if (chapterIdx !== -1 && parts[chapterIdx + 1] !== undefined) {
chapterIndex = parts[chapterIdx + 1];
}
return { origin: u.origin, slug, chapterIndex };
}
/**
* @param {string} siteOrigin - e.g. `https://comicland.org`.
* @param {string} slug - Series slug.
* @param {string|number} chapterIndex - API `chapter_index`.
* @returns {string} Canonical reader URL (no trailing slash).
*/
function chapterUrlFromParts(siteOrigin, slug, chapterIndex) {
const origin = siteOrigin.replace(/\/+$/u, "");
return `${origin}/comic/${slug}/chapter/${String(chapterIndex)}`;
}
/**
* @param {unknown} rawIndex - API `chapter_index`.
* @returns {string} Index as used in the reader URL.
*/
function chapterIndexString(rawIndex) {
if (typeof rawIndex === "number" && Number.isFinite(rawIndex)) {
return Number.isInteger(rawIndex) ? String(rawIndex) : String(rawIndex);
}
if (typeof rawIndex === "string" && rawIndex.trim()) {
return rawIndex.trim();
}
throw new Error("ComicLand chapter is missing chapter_index");
}
/**
* GET `/api/comic/detail?slug=` and return mapped chapter links (oldest first).
* @param {string} listingPageUrl - e.g. `https://comicland.org/comic/{slug}`
* @returns {Promise<{ chapters: ChapterLink[], comicTitle: string }>} Chapter rows and series title.
*/
async function fetchChapterLinks(listingPageUrl) {
const { origin, slug } = parseComicLandUrl(listingPageUrl);
const apiUrl = `${COMICLAND_API_ORIGIN}/api/comic/detail?slug=${encodeURIComponent(slug)}`;
const payload = await fetchComicLandJson(apiUrl);
const data = unwrapApiData(payload, "comic/detail");
const comicTitle =
typeof data.title === "string" && data.title.trim()
? data.title.trim()
: slug;
const rawChapters = Array.isArray(data.chapters) ? data.chapters : [];
/** @type {ChapterLink[]} */
const chapters = [];
for (const row of rawChapters) {
if (!row || typeof row !== "object") {
continue;
}
let index;
try {
index = chapterIndexString(row.chapter_index);
} catch {
continue;
}
const title =
typeof row.title === "string" && row.title.trim()
? row.title.trim().replace(/\s+/gu, " ")
: `Chapter ${index}`;
chapters.push({
url: chapterUrlFromParts(origin, slug, index),
title,
});
}
chapters.sort((a, b) => {
const na = Number.parseFloat(String(new URL(a.url).pathname.split("/").pop()));
const nb = Number.parseFloat(String(new URL(b.url).pathname.split("/").pop()));
if (Number.isFinite(na) && Number.isFinite(nb) && na !== nb) {
return na - nb;
}
return a.url.localeCompare(b.url);
});
return { chapters, comicTitle };
}
module.exports = {
chapterUrlFromParts,
fetchChapterLinks,
isChapterPathname,
isLikelyComicLandSite,
isListingPathname,
parseComicLandUrl,
seriesSlugFromListingUrl,
};
+67
View File
@@ -0,0 +1,67 @@
/**
* ComicLand API and CDN constants. The public site is a React SPA; listings and
* pages come from `api.comicland.org`, not from HTML.
* @see https://comicland.org/comic/{slug}
*/
/** Hostnames this adapter is intended for (friendly checks + detectSource). */
const COMICLAND_HOSTS = /** @type {const} */ ([
"comicland.org",
"www.comicland.org",
]);
/** Canonical site origin used as Origin/Referer for API and CDN requests. */
const COMICLAND_ORIGIN = "https://comicland.org/";
/** JSON API origin used by the SPA (`Nt("/comic/detail", …)`). */
const COMICLAND_API_ORIGIN = "https://api.comicland.org";
/** Default sample listing used by the fetch CLI when no URL is given. */
const DEFAULT_LISTING_URL = "https://comicland.org/comic/the-delinquent-girl";
/** Series page: `/comic/{slug}` (trailing slash optional). */
const LISTING_PATHNAME_PATTERN = /^\/comic\/[^/]+$/iu;
/** Reader page: `/comic/{slug}/chapter/{index}` (index may include a decimal). */
const CHAPTER_PATHNAME_PATTERN = /^\/comic\/[^/]+\/chapter\/[^/]+$/iu;
/** Browser-like User-Agent for API + CDN (matches a desktop Chrome client). */
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
/**
* Headers for JSON API GETs (SPA sends Origin/Referer as comicland.org).
* @type {Readonly<Record<string, string>>}
*/
const API_JSON_HEADERS = {
Accept: "application/json,text/plain,*/*",
"Accept-Language": "en-US,en;q=0.9",
Origin: "https://comicland.org",
Referer: COMICLAND_ORIGIN,
"User-Agent": USER_AGENT,
};
/**
* Headers for CDN page image GETs.
* @type {Readonly<Record<string, string>>}
*/
const CDN_IMAGE_HEADERS = {
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
Referer: COMICLAND_ORIGIN,
Origin: "https://comicland.org",
"User-Agent": USER_AGENT,
};
module.exports = {
API_JSON_HEADERS,
CDN_IMAGE_HEADERS,
CHAPTER_PATHNAME_PATTERN,
COMICLAND_API_ORIGIN,
COMICLAND_HOSTS,
COMICLAND_ORIGIN,
DEFAULT_LISTING_URL,
LISTING_PATHNAME_PATTERN,
USER_AGENT,
};
+240
View File
@@ -0,0 +1,240 @@
const fs = require("fs/promises");
const path = require("path");
const os = require("os");
const AdmZip = require("adm-zip");
const { downloadChapterImages } = require("./images.js");
const { fetchChapterLinks, parseComicLandUrl, seriesSlugFromListingUrl } =
require("./chapters.js");
/**
* `/comic/my-series/chapter/12` → `my-series-chapter-12`.
* @param {string} chapterPageUrl - Full chapter URL.
* @returns {string} Suggested `.cbz` stem.
*/
function stemForComicLandChapterCbz(chapterPageUrl) {
try {
const { pathname } = new URL(chapterPageUrl);
const segs = pathname.replace(/\/+$/u, "").split("/").filter(Boolean);
const comicIdx = segs.indexOf("comic");
if (comicIdx !== -1 && segs.length >= comicIdx + 4 && segs[comicIdx + 2] === "chapter") {
return `${segs[comicIdx + 1]}-chapter-${segs[comicIdx + 3]}`;
}
if (segs.length >= 2) {
return segs.slice(-2).join("-");
}
return segs.at(-1) || "chapter";
} catch {
return "chapter";
}
}
/**
* @param {string} stem - File base segment.
* @returns {string} Sanitized stem.
*/
function sanitizeFsStem(stem) {
const cleaned = stem.replace(/[^a-z0-9._-]+/giu, "_").replace(/^_+|_+$/gu, "");
return cleaned.length > 0 ? cleaned.slice(0, 180) : "chapter";
}
/**
* @param {string} name - Human-readable comic title.
* @returns {string} Filesystem-safe directory name.
*/
function sanitizeComicDirName(name) {
const collapsed = name.trim().replace(/\s+/gu, " ");
const cleaned = [...collapsed]
.filter((ch) => {
const code = ch.charCodeAt(0);
if (code < 32 || code === 127) {
return false;
}
return !/[<>:"/\\|?*]/.test(ch);
})
.join("")
.replace(/\.+$/g, "")
.trim();
return cleaned.length > 0 ? cleaned.slice(0, 180) : "comic";
}
/**
* @param {string|undefined} cliOut - Positional output path/dir.
* @param {string} defaultStem - When `cliOut` is omitted or directory-like.
* @param {string} [comicTitle] - When set, default output goes under this subdirectory.
* @returns {string} Absolute `.cbz` path.
*/
function resolveCliOutputPath(cliOut, defaultStem, comicTitle) {
const stem = sanitizeFsStem(defaultStem);
const baseDir =
typeof comicTitle === "string" && comicTitle.trim().length > 0
? path.join(
process.cwd(),
"data",
"comicland",
"comics",
sanitizeComicDirName(comicTitle),
)
: path.join(process.cwd(), "data", "comicland", "comics");
if (cliOut === undefined || cliOut.trim() === "") {
return path.join(baseDir, `${stem}.cbz`);
}
const trimmed = cliOut.trim();
const abs = path.resolve(trimmed);
if (abs.toLowerCase().endsWith(".cbz")) {
return abs;
}
return path.join(abs, `${stem}.cbz`);
}
/**
* Downloads ComicLand chapter pages to temp storage, packs `.cbz`, removes temp folder.
* @param {string} chapterUrl - Chapter reader URL.
* @param {string} outputCbzPath - Destination `.cbz`.
* @returns {Promise<{ imageCount: number, outputPath: string }>} Page count and written archive path.
*/
async function chapterUrlToCbz(chapterUrl, outputCbzPath) {
const absOut = outputCbzPath.toLowerCase().endsWith(".cbz")
? path.resolve(outputCbzPath)
: `${path.resolve(outputCbzPath)}.cbz`;
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cxyz-comicland-chapter-"));
try {
const saved = await downloadChapterImages(chapterUrl, tmpRoot);
if (saved.length === 0) {
throw new Error("No chapter images were found for this ComicLand URL.");
}
await fs.mkdir(path.dirname(absOut), { recursive: true });
const zip = new AdmZip();
for (const { filePath } of saved) {
zip.addLocalFile(filePath, "", path.basename(filePath));
}
zip.writeZip(absOut);
return { imageCount: saved.length, outputPath: absOut };
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Listing URL for the series that owns this chapter.
* @param {string} chapterUrl - Chapter or listing URL.
* @returns {string} `/comic/{slug}/` listing URL.
*/
function listingUrlFromChapterUrl(chapterUrl) {
const { origin, slug } = parseComicLandUrl(chapterUrl);
return `${origin.replace(/\/+$/u, "")}/comic/${slug}/`;
}
/**
* CLI: `node src/comicland/downloadChapterCbz.js <chapterUrl> [.cbz|dir] [--no-db]`
* Upserts the series into SQLite and marks this chapter downloaded unless `--no-db`.
* @returns {Promise<void>}
*/
async function main() {
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const positional = argv.filter((a) => !a.startsWith("-"));
const chapterUrl = positional[0];
if (!chapterUrl) {
console.error(
"Usage: node src/comicland/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]",
);
process.exitCode = 1;
return;
}
const outArg = positional[1];
const stem = stemForComicLandChapterCbz(chapterUrl);
let comicTitle = seriesSlugFromListingUrl(chapterUrl);
try {
const listingUrl = listingUrlFromChapterUrl(chapterUrl);
const { comicTitle: fetchedTitle } = await fetchChapterLinks(listingUrl);
comicTitle = fetchedTitle || comicTitle;
} catch {
/* keep slug fallback */
}
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let chapterRow;
let target;
try {
if (!noDb) {
const cliStore = require("../server/cli-store.js");
const { resolveCbzPath } = require("../server/sources.js");
({ db, dataDir } = cliStore.openCliDatabase());
const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir);
console.log(
`SQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`${String(result.total)} chapter(s)`,
);
chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl);
if (outArg === undefined || outArg.trim() === "") {
target = resolveCbzPath(
"comicland",
result.title.title || comicTitle,
chapterUrl,
dataDir,
);
} else {
target = resolveCliOutputPath(outArg, stem, comicTitle);
}
if (chapterRow) {
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
} else {
target = resolveCliOutputPath(outArg, stem, comicTitle);
}
const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target);
console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
const cliStore = require("../server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(msg);
if (db && chapterRow) {
const cliStore = require("../server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
process.exitCode = 1;
} finally {
if (db) {
db.close();
}
}
}
module.exports = {
chapterUrlToCbz,
resolveCliOutputPath,
sanitizeComicDirName,
sanitizeFsStem,
stemForComicLandChapterCbz,
};
if (require.main === module) {
main();
}
+732
View File
@@ -0,0 +1,732 @@
const path = require("path");
const { fetchChapterLinks, isLikelyComicLandSite } = require("./chapters.js");
const { stemForComicLandChapterCbz, chapterUrlToCbz, sanitizeComicDirName } =
require("./downloadChapterCbz.js");
/**
* @typedef {object} ParsedCli
* @property {boolean} [help] - Print usage and exit when true.
* @property {string|undefined} listingUrl - Series listing URL (required unless help).
* @property {boolean} listOnly - Only print the chapter menu.
* @property {boolean} allChapters - Queue every chapter (API already oldest-first).
* @property {boolean} missingOnly - Queue chapters that need download (SQLite + CBZ on disk).
* @property {string|undefined} selectSpec - `--select` string (1-based menu indices).
* @property {string|undefined} numbersSpec - `--numbers` string (chapter numbers from titles).
* @property {string} outDir - Output directory for `.cbz` files.
* @property {boolean} noDb - Skip SQLite updates.
* @property {number|undefined} titleId - Load listing URL from SQLite title id.
* @property {boolean} outDirExplicit - True when `--out` was passed.
* @property {boolean} verbose - Print informational progress logs.
* @property {number} delaySeconds - Pause between chapter downloads (0 = no delay).
*/
/**
* @param {string[]} argv - `process.argv`.
* @returns {ParsedCli} Parsed flags and positional URL.
*/
function parseCli(argv) {
/** @type {ParsedCli} */
const result = {
help: false,
listingUrl: undefined,
listOnly: false,
allChapters: false,
missingOnly: false,
selectSpec: undefined,
numbersSpec: undefined,
outDir: path.join(process.cwd(), "data", "comicland", "comics"),
noDb: false,
titleId: undefined,
outDirExplicit: false,
verbose: false,
delaySeconds: 0,
};
for (let i = 2; i < argv.length; i += 1) {
const a = argv[i];
if (a === "--help" || a === "-h") {
result.help = true;
} else if (a === "--list-only" || a === "-l") {
result.listOnly = true;
} else if (a === "--all") {
result.allChapters = true;
} else if (a === "--missing") {
result.missingOnly = true;
} else if (a === "--verbose" || a === "-v") {
result.verbose = true;
} else if (a === "--no-db") {
result.noDb = true;
} else if (a === "--delay") {
const next = argv[i + 1];
if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) {
throw new Error("Missing value for --delay");
}
const seconds = Number.parseInt(next, 10);
if (!Number.isFinite(seconds) || seconds < 0) {
throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`);
}
result.delaySeconds = seconds;
i += 1;
} else if (a === "--title-id") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --title-id");
}
const id = Number.parseInt(next, 10);
if (!Number.isFinite(id) || id < 1) {
throw new Error(`Invalid --title-id: ${next}`);
}
result.titleId = id;
i += 1;
} else if (a === "--select" || a === "-s") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --select");
}
result.selectSpec = next;
i += 1;
} else if (a === "--numbers" || a === "-n") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --numbers");
}
result.numbersSpec = next;
i += 1;
} else if (a === "--out" || a === "-o") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --out");
}
result.outDir = path.resolve(next);
result.outDirExplicit = true;
i += 1;
} else if (!a.startsWith("-")) {
if (result.listingUrl) {
throw new Error(`Unexpected extra argument: ${a}`);
}
result.listingUrl = a;
} else {
throw new Error(`Unknown option: ${a}`);
}
}
return result;
}
/**
* @param {boolean} verbose - When false, no-op.
* @returns {(msg: string) => void} Logger.
*/
function makeVlog(verbose) {
return (msg) => {
if (verbose) {
console.log(`info: ${msg}`);
}
};
}
/**
* @param {number} seconds - Seconds to wait.
* @returns {Promise<void>}
*/
function sleepSeconds(seconds) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1000);
});
}
/**
* Build a download queue from SQLite chapters that still need a CBZ.
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first listing.
* @param {object[]} needingRows - Chapter rows from SQLite.
* @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue items.
*/
function queueItemsMissing(chapters, needingRows) {
/** @type {Map<string, object>} */
const byUrl = new Map();
for (const row of needingRows) {
if (typeof row.url === "string" && row.url) {
byUrl.set(row.url, row);
}
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
const items = [];
/** @type {Set<string>} */
const used = new Set();
for (const ch of chapters) {
const row = byUrl.get(ch.url);
if (!row) {
continue;
}
used.add(ch.url);
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: ch.url,
title: ch.title,
pickLabel: `missing:${reason}`,
});
}
for (const row of needingRows) {
if (typeof row.url !== "string" || !row.url || used.has(row.url)) {
continue;
}
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: row.url,
title: row.label || row.chapter_key || row.url,
pickLabel: `missing:${reason}`,
});
}
return items;
}
/**
* Chapters that need a download (status pending/failed, or downloaded CBZ gone).
* @param {import("better-sqlite3").Database} db - Database handle.
* @param {number} titleId - Title id.
* @param {(path: string|null|undefined) => boolean} cbzExistsFn - File check.
* @returns {object[]} Chapter rows.
*/
function listChaptersMissing(db, titleId, cbzExistsFn) {
const dbApi = require("../server/db.js");
const rows = dbApi.listChapters(db, titleId);
return rows.filter((ch) => {
if (ch.status === "pending" || ch.status === "failed") {
return true;
}
if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) {
return true;
}
return false;
});
}
/**
* ComicLand API already returns oldest-first; copy so callers can mutate safely.
* @param {import("./chapters.js").ChapterLink[]} chapters - Site/API order.
* @returns {import("./chapters.js").ChapterLink[]} New array, oldest chapter first.
*/
function chaptersInReadingOrder(chapters) {
return [...chapters];
}
/**
* @param {string} spec - `--select` value.
* @param {number} count - Chapter count.
* @returns {number[]} Sorted unique 1-based row indices.
*/
function parseChapterSelection(spec, count) {
if (count <= 0) {
throw new Error("Chapter list is empty; cannot select indices.");
}
const trimmed = spec.trim();
if (trimmed.length === 0) {
throw new Error("--select value is empty.");
}
/** @type {Set<number>} */
const seen = new Set();
for (const rawPart of trimmed.split(",")) {
const part = rawPart.trim();
if (part.length === 0) {
continue;
}
if (/^\d+$/u.test(part)) {
const n = Number.parseInt(part, 10);
if (n < 1 || n > count) {
throw new Error(
`Index ${String(n)} is out of range (valid: 1–${String(count)}).`,
);
}
seen.add(n);
continue;
}
const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part);
if (rangeMatch) {
const lo = Number.parseInt(rangeMatch[1], 10);
const hi = Number.parseInt(rangeMatch[2], 10);
const a = Math.min(lo, hi);
const b = Math.max(lo, hi);
for (let j = a; j <= b; j += 1) {
if (j < 1 || j > count) {
throw new Error(
`Range ${part} includes invalid index ${String(j)} (valid: 1–${String(count)}).`,
);
}
seen.add(j);
}
continue;
}
throw new Error(`Invalid --select segment: "${part}"`);
}
if (seen.size === 0) {
throw new Error("No indices parsed from --select.");
}
return [...seen].sort((x, y) => x - y);
}
/**
* @param {string} title - e.g. `Chapter 22`.
* @returns {string|null} Chapter number string from title, or null.
*/
function chapterNumberFromListingTitle(title) {
const m = /^Chapter\s+([\d.]+)\s*$/iu.exec(title.trim().replace(/\s+/gu, " "));
return m ? m[1] : null;
}
/**
* @param {string} spec - `--numbers` argument.
* @returns {string[]} Ordered unique chapter number strings as entered.
*/
function parseChapterNumbersSpec(spec) {
const trimmed = spec.trim();
if (trimmed.length === 0) {
throw new Error("--numbers value is empty.");
}
/** @type {string[]} */
const ordered = [];
/** @type {Set<string>} */
const seen = new Set();
for (const rawPart of trimmed.split(",")) {
const part = rawPart.trim();
if (part.length === 0) {
continue;
}
const rangeMatch = /^(\d+)\s*-\s*(\d+)$/u.exec(part);
if (rangeMatch) {
const lo = Number.parseInt(rangeMatch[1], 10);
const hi = Number.parseInt(rangeMatch[2], 10);
const a = Math.min(lo, hi);
const b = Math.max(lo, hi);
for (let n = a; n <= b; n += 1) {
const k = String(n);
if (!seen.has(k)) {
seen.add(k);
ordered.push(k);
}
}
continue;
}
if (/^\d+(\.\d+)?$/u.test(part)) {
if (!seen.has(part)) {
seen.add(part);
ordered.push(part);
}
continue;
}
throw new Error(`Invalid --numbers segment: "${part}"`);
}
if (ordered.length === 0) {
throw new Error("No chapter numbers parsed from --numbers.");
}
return ordered;
}
/**
* @param {string[]} orderedNums - Chapter numbers requested.
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest→newest.
* @returns {Array<{ url: string, title: string, pickLabel: string }>} Rows to download.
*/
function queueItemsByChapterNumbers(orderedNums, chapters) {
/** @type {Map<string, import("./chapters.js").ChapterLink>} */
const first = new Map();
for (const ch of chapters) {
const num = chapterNumberFromListingTitle(ch.title);
if (num !== null && !first.has(num)) {
first.set(num, ch);
}
}
return orderedNums.map((num) => {
const ch = first.get(num);
if (!ch) {
throw new Error(
`No chapter titled "Chapter ${num}" in the ComicLand listing. ` +
"Use --list-only and check titles.",
);
}
return {
url: ch.url,
title: ch.title,
pickLabel: `Ch.${num}`,
};
});
}
/**
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first list.
* @returns {void}
*/
function printChapterMenu(chapters) {
const digits = String(chapters.length).length;
for (let i = 0; i < chapters.length; i += 1) {
const n = i + 1;
const pad = String(n).padStart(digits, " ");
const title = chapters[i].title.replace(/\s+/gu, " ").trim();
console.log(`${pad}. ${title}`);
console.log(` ${chapters[i].url}`);
}
}
/**
* @param {number[]} indices1 - Sorted 1-based indices.
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first chapter list.
* @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue rows.
*/
function chaptersByIndices(indices1, chapters) {
return indices1.map((idx) => ({
url: chapters[idx - 1].url,
title: chapters[idx - 1].title,
pickLabel: `#${String(idx)}`,
}));
}
/**
* CLI entry: fetch listing, optionally build `.cbz` archives.
* Upserts titles/chapters into SQLite unless `--no-db`.
* @returns {Promise<void>}
*/
async function main() {
let parsed;
try {
parsed = parseCli(process.argv);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
if (parsed.help) {
console.log(`ComicLand chapter queue (JSON API; URLs like /comic/{slug}):
Usage:
node src/comicland/downloadChaptersQueue.js <listing-url> [--list-only|-l]
node src/comicland/downloadChaptersQueue.js <listing-url> --numbers|-n <spec> [--out|-o <dir>]
node src/comicland/downloadChaptersQueue.js <listing-url> --select|-s <spec> [--out|-o <dir>]
node src/comicland/downloadChaptersQueue.js <listing-url> --all [--out|-o <dir>]
node src/comicland/downloadChaptersQueue.js <listing-url> --missing [--out|-o <dir>]
node src/comicland/downloadChaptersQueue.js --title-id <id> --missing …
node src/comicland/downloadChaptersQueue.js --title-id <id> --numbers|-n <spec> …
Listing URL: series page https://comicland.org/comic/{slug} (chapter URL is also accepted).
--title-id: load listing URL from SQLite (DATA_DIR/app.db) instead of a URL argument
--missing: download chapters that are pending, failed, or downloaded but CBZ missing (requires SQLite)
--delay N: wait N seconds between chapter downloads (e.g. --delay 300)
--no-db: write CBZ files only; do not update SQLite (incompatible with --missing / --title-id)
--verbose|-v: print informational progress logs
DATA_DIR: data root (default: ./data)
Default --out: data/comicland/comics/<comic title>/
(with DB and no --out: $DATA_DIR/comicland/comics/<comic title>/)
--numbers: titles like "Chapter 22", e.g. 22,23,24 or 22-24
--select: 1-based rows on printed list (line 1 = Chapter 1)
`);
return;
}
const vlog = makeVlog(parsed.verbose);
const cliStore = require("../server/cli-store.js");
const { resolveCbzPath, cbzExists } = require("../server/sources.js");
const dbApi = require("../server/db.js");
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let dbTitle;
try {
if (parsed.missingOnly && parsed.noDb) {
console.error("Cannot combine --missing with --no-db (SQLite is required).");
process.exitCode = 1;
return;
}
if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) {
if (parsed.noDb && parsed.titleId !== undefined) {
console.error("Cannot combine --no-db with --title-id.");
process.exitCode = 1;
return;
}
vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`);
({ db, dataDir } = cliStore.openCliDatabase());
vlog(`database ready: ${path.join(dataDir, "app.db")}`);
}
if (parsed.titleId !== undefined) {
vlog(`looking up --title-id ${String(parsed.titleId)}`);
dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId);
if (!dbTitle) {
console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`);
process.exitCode = 1;
return;
}
if (dbTitle.source !== "comicland") {
console.error(
`Title id ${String(parsed.titleId)} is source "${dbTitle.source}"; use the matching CLI.`,
);
process.exitCode = 1;
return;
}
parsed.listingUrl = dbTitle.url;
vlog(`resolved title "${dbTitle.title}" → ${dbTitle.url}`);
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/comicland/downloadChaptersQueue.js <listing-url> [options]\n" +
"Or: --title-id <id> [options]",
);
process.exitCode = 1;
return;
}
if (!isLikelyComicLandSite(parsed.listingUrl)) {
console.warn(
"Warning: URL hostname does not look like ComicLand; the API client may fail.",
);
}
vlog(`fetching chapter listing: ${parsed.listingUrl}`);
let chapters;
let comicTitle;
try {
({ chapters, comicTitle } = await fetchChapterLinks(parsed.listingUrl));
chapters = chaptersInReadingOrder(chapters);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
console.log(`Found ${String(chapters.length)} ComicLand chapter link(s).`);
printChapterMenu(chapters);
vlog(`comic title from listing: ${comicTitle}`);
if (parsed.listOnly) {
if (db && dataDir) {
vlog("upserting title/chapters into SQLite (--list-only)");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
console.log(
`\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
return;
}
const wantsAll = parsed.allChapters;
const wantsMissing = parsed.missingOnly;
const wantsSelect = Boolean(parsed.selectSpec);
const wantsNumbers = Boolean(parsed.numbersSpec);
const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter(
Boolean,
).length;
if (pickModes === 0) {
console.error("Choose --numbers, --select, --all, or --missing (or --list-only).");
process.exitCode = 1;
return;
}
if (pickModes > 1) {
console.error("Use only one of --all, --missing, --select, or --numbers.");
process.exitCode = 1;
return;
}
if (db && dataDir) {
vlog("upserting title/chapters into SQLite");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
dbTitle = result.title;
console.log(
`SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
let queue;
try {
if (wantsMissing) {
if (!db || !dbTitle) {
console.error("--missing requires SQLite (omit --no-db).");
process.exitCode = 1;
return;
}
vlog("selecting chapters that need download (pending/failed/missing CBZ)");
const needing = listChaptersMissing(db, dbTitle.id, cbzExists);
queue = queueItemsMissing(chapters, needing);
console.log(
`Missing: ${String(queue.length)} chapter(s) need download ` +
`(of ${String(chapters.length)} on listing).`,
);
if (queue.length === 0) {
console.log("Nothing to download.");
return;
}
} else if (wantsAll) {
vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`);
const indices = Array.from({ length: chapters.length }, (_, i) => i + 1);
queue = chaptersByIndices(indices, chapters);
} else if (wantsSelect) {
vlog(`queue mode: --select ${parsed.selectSpec}`);
const indices = parseChapterSelection(
/** @type {string} */ (parsed.selectSpec),
chapters.length,
);
queue = chaptersByIndices(indices, chapters);
} else {
vlog(`queue mode: --numbers ${parsed.numbersSpec}`);
const orderedNums = parseChapterNumbersSpec(
/** @type {string} */ (parsed.numbersSpec),
);
queue = queueItemsByChapterNumbers(orderedNums, chapters);
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
vlog(`download queue size: ${String(queue.length)}`);
const titleForPaths = (dbTitle && dbTitle.title) || comicTitle;
const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit);
const fallbackOutDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle));
const outDir =
useDbPaths && queue.length > 0
? path.dirname(
resolveCbzPath(
"comicland",
titleForPaths,
queue[0].url,
/** @type {string} */ (dataDir),
),
)
: fallbackOutDir;
console.log(`Comic: ${comicTitle}`);
console.log(`Output directory: ${outDir}`);
if (parsed.delaySeconds > 0) {
console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`);
}
let failedCount = 0;
let done = 0;
for (const item of queue) {
done += 1;
const stem = stemForComicLandChapterCbz(item.url);
let targetPath = path.join(fallbackOutDir, `${stem}.cbz`);
/** @type {object|undefined} */
let chapterRow;
if (useDbPaths) {
targetPath = resolveCbzPath(
"comicland",
titleForPaths,
item.url,
/** @type {string} */ (dataDir),
);
chapterRow = cliStore.findChapterByUrl(
/** @type {import("better-sqlite3").Database} */ (db),
dbTitle.id,
item.url,
);
} else if (db && dbTitle) {
chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url);
}
console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`);
console.log(` → ${item.url}`);
vlog(`target CBZ: ${targetPath}`);
try {
if (db && chapterRow) {
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath);
console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ✗ ${msg}`);
if (db && chapterRow) {
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
failedCount += 1;
}
if (parsed.delaySeconds > 0 && done < queue.length) {
console.log(
` … waiting ${String(parsed.delaySeconds)}s before next chapter`,
);
await sleepSeconds(parsed.delaySeconds);
}
}
if (failedCount > 0) {
console.error(
`\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`,
);
process.exitCode = 1;
}
} finally {
if (db) {
db.close();
}
}
}
module.exports = {
chapterNumberFromListingTitle,
chaptersByIndices,
chaptersInReadingOrder,
listChaptersMissing,
parseChapterNumbersSpec,
parseChapterSelection,
parseCli,
printChapterMenu,
queueItemsByChapterNumbers,
queueItemsMissing,
};
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}
+74
View File
@@ -0,0 +1,74 @@
const fs = require("fs/promises");
const path = require("path");
const { DEFAULT_LISTING_URL } = require("./constants.js");
const { fetchChapterLinks } = require("./chapters.js");
const OUTPUT_JSON = path.join(
__dirname,
"..",
"..",
"data",
"comicland",
"debug-listing.json",
);
/**
* CLI: fetch ComicLand `/api/comic/detail` and print chapter JSON.
* Optionally upserts into SQLite (`DATA_DIR/app.db`) unless `--no-db`.
*
* Usage:
* node src/comicland/fetchChapters.cli.js [listing-url] [--no-db]
* @returns {Promise<void>}
*/
async function main() {
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const pageUrl =
argv.find((a) => !a.startsWith("-")) ||
process.env.COMICLAND_LISTING_URL ||
DEFAULT_LISTING_URL;
const { chapters, comicTitle } = await fetchChapterLinks(pageUrl);
await fs.mkdir(path.dirname(OUTPUT_JSON), { recursive: true });
await fs.writeFile(
OUTPUT_JSON,
JSON.stringify({ comicTitle, listingUrl: pageUrl, chapters }, null, 2),
"utf8",
);
console.log(`Source: ${pageUrl}`);
console.log(`Title: ${comicTitle}`);
console.log(`Saved JSON: ${OUTPUT_JSON}`);
console.log(`Found ${String(chapters.length)} chapter link(s) (oldest-first):`);
console.log(JSON.stringify(chapters, null, 2));
if (noDb) {
return;
}
const { openCliDatabase, ensureTitleFromUrl } = require("../server/cli-store.js");
const { db, dataDir } = openCliDatabase();
try {
const result = await ensureTitleFromUrl(db, pageUrl, dataDir);
console.log(
`\nSQLite (${path.join(dataDir, "app.db")}): ` +
`title id=${String(result.title.id)} ` +
`"${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
} finally {
db.close();
}
}
if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
}
module.exports = { main };
+104
View File
@@ -0,0 +1,104 @@
const fs = require("fs/promises");
const path = require("path");
const axios = require("axios");
const { COMICLAND_API_ORIGIN } = require("./constants.js");
const { parseComicLandUrl } = require("./chapters.js");
const {
extensionFromContentType,
extensionFromImageUrl,
fetchComicLandJson,
imageGetConfig,
unwrapApiData,
} = require("./io.js");
/**
* Collect page image URLs from `/api/chapter/pages_by_index`.
* @param {string} chapterPageUrl - Reader URL `/comic/{slug}/chapter/{index}`.
* @returns {Promise<string[]>} Absolute image URLs in reading order.
*/
async function collectChapterImageUrls(chapterPageUrl) {
const { slug, chapterIndex } = parseComicLandUrl(chapterPageUrl);
if (!chapterIndex) {
throw new Error(
`ComicLand chapter URL is missing an index: ${chapterPageUrl}`,
);
}
const apiUrl =
`${COMICLAND_API_ORIGIN}/api/chapter/pages_by_index` +
`?slug=${encodeURIComponent(slug)}&index=${encodeURIComponent(chapterIndex)}`;
const payload = await fetchComicLandJson(apiUrl);
const data = unwrapApiData(payload, "chapter/pages_by_index");
const pages = Array.isArray(data.pages) ? data.pages : [];
/** @type {string[]} */
const urls = [];
for (const raw of pages) {
if (typeof raw !== "string" || !raw.trim()) {
continue;
}
try {
urls.push(new URL(raw.trim()).href);
} catch {
/* skip bad URL */
}
}
return urls;
}
/**
* @typedef {object} SavedChapterImage
* @property {string} imageUrl - Remote URL that was downloaded.
* @property {string} filePath - Path of the saved file.
*/
/**
* Downloads chapter pages into `data/comicland/chapters/{slug}-{index}/` by default.
* @param {string} chapterPageUrl - Full `/comic/{slug}/chapter/{index}` URL.
* @param {string} [outDir] - Override output directory.
* @returns {Promise<SavedChapterImage[]>} One record per saved page file.
*/
async function downloadChapterImages(chapterPageUrl, outDir) {
const urls = await collectChapterImageUrls(chapterPageUrl);
const { slug, chapterIndex } = parseComicLandUrl(chapterPageUrl);
const dirSlug = `${slug}-chapter-${chapterIndex || "unknown"}`;
const targetDir =
typeof outDir === "string" && outDir.trim().length > 0
? path.resolve(outDir.trim())
: path.join(
path.join(__dirname, "..", ".."),
"data",
"comicland",
"chapters",
dirSlug,
);
await fs.mkdir(targetDir, { recursive: true });
const cfg = imageGetConfig();
/** @type {SavedChapterImage[]} */
const saved = [];
let index = 0;
for (const imageUrl of urls) {
index += 1;
const { data, headers } = await axios.get(imageUrl, cfg);
const ct = headers["content-type"];
let ext = extensionFromImageUrl(imageUrl);
if (ext.length === 0) {
ext = extensionFromContentType(typeof ct === "string" ? ct : "") || ".bin";
}
const filename = `page-${String(index).padStart(4, "0")}${ext}`;
const filePath = path.join(targetDir, filename);
await fs.writeFile(filePath, Buffer.from(data));
saved.push({ imageUrl, filePath });
}
return saved;
}
module.exports = {
collectChapterImageUrls,
downloadChapterImages,
};
+61
View File
@@ -0,0 +1,61 @@
/**
* ComicLand adapter: JSON API listing + page URLs, image download, and CBZ CLIs.
* Tweak {@link ./constants.js} if the API origin or routes change.
*/
const {
API_JSON_HEADERS,
CDN_IMAGE_HEADERS,
CHAPTER_PATHNAME_PATTERN,
COMICLAND_API_ORIGIN,
COMICLAND_HOSTS,
COMICLAND_ORIGIN,
DEFAULT_LISTING_URL,
LISTING_PATHNAME_PATTERN,
} = require("./constants.js");
const {
fetchChapterLinks,
isChapterPathname,
isLikelyComicLandSite,
isListingPathname,
parseComicLandUrl,
seriesSlugFromListingUrl,
} = require("./chapters.js");
const {
collectChapterImageUrls,
downloadChapterImages,
} = require("./images.js");
const {
chapterUrlToCbz,
resolveCliOutputPath,
sanitizeComicDirName,
sanitizeFsStem,
stemForComicLandChapterCbz,
} = require("./downloadChapterCbz.js");
module.exports = {
API_JSON_HEADERS,
CDN_IMAGE_HEADERS,
CHAPTER_PATHNAME_PATTERN,
COMICLAND_API_ORIGIN,
COMICLAND_HOSTS,
COMICLAND_ORIGIN,
DEFAULT_LISTING_URL,
LISTING_PATHNAME_PATTERN,
chapterUrlToCbz,
collectChapterImageUrls,
downloadChapterImages,
fetchChapterLinks,
isChapterPathname,
isLikelyComicLandSite,
isListingPathname,
parseComicLandUrl,
resolveCliOutputPath,
sanitizeComicDirName,
sanitizeFsStem,
seriesSlugFromListingUrl,
stemForComicLandChapterCbz,
};
+116
View File
@@ -0,0 +1,116 @@
const path = require("path");
const axios = require("axios");
const { API_JSON_HEADERS, CDN_IMAGE_HEADERS } = require("./constants.js");
/** @type {import("axios").AxiosRequestConfig} */
const DEFAULT_GET_JSON = {
responseType: "json",
timeout: 30_000,
headers: { ...API_JSON_HEADERS },
validateStatus: (s) => s >= 200 && s < 300,
};
/**
* GET a ComicLand API URL and return the parsed JSON body.
* @param {string} url - Absolute `api.comicland.org` URL.
* @returns {Promise<object>} JSON object.
*/
async function fetchComicLandJson(url) {
const { data } = await axios.get(url, DEFAULT_GET_JSON);
if (data && typeof data === "object") {
return data;
}
if (typeof data === "string") {
return JSON.parse(data);
}
throw new Error(`ComicLand API returned unexpected body from ${url}`);
}
/**
* @param {object} payload - API envelope.
* @param {string} label - Endpoint name for errors.
* @returns {object} `payload.data` when `code === 0`.
*/
function unwrapApiData(payload, label) {
if (!payload || typeof payload !== "object") {
throw new Error(`ComicLand ${label}: empty response`);
}
const code = /** @type {{ code?: unknown }} */ (payload).code;
if (code !== 0) {
const msg =
typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: `code ${String(code)}`;
throw new Error(`ComicLand ${label} failed: ${msg}`);
}
const { data } = /** @type {{ data?: object }} */ (payload);
if (!data || typeof data !== "object") {
throw new Error(`ComicLand ${label}: missing data`);
}
return data;
}
/**
* @param {string} contentType - `Content-Type` header value.
* @returns {string} File extension including dot, or empty string.
*/
function extensionFromContentType(contentType) {
if (!contentType || typeof contentType !== "string") {
return "";
}
const ct = contentType.split(";")[0].trim().toLowerCase();
/** @type {Record<string, string>} */
const map = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/pjpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/avif": ".avif",
"image/svg+xml": ".svg",
};
return map[ct] || "";
}
/**
* @param {string} imageUrl - Absolute image URL.
* @returns {string} Lowercase extension with dot, or empty string.
*/
function extensionFromImageUrl(imageUrl) {
try {
const p = new URL(imageUrl).pathname;
const ext = path.extname(p).toLowerCase();
if (ext.length > 0 && ext.length <= 8 && /^\.[a-z0-9]+$/u.test(ext)) {
return ext;
}
} catch {
/* invalid URL */
}
return "";
}
/**
* Axios config for CDN page bytes.
* @returns {import("axios").AxiosRequestConfig} GET config for binary image responses.
*/
function imageGetConfig() {
return {
responseType: "arraybuffer",
timeout: 120_000,
maxContentLength: 50 * 1024 * 1024,
maxBodyLength: 50 * 1024 * 1024,
headers: { ...CDN_IMAGE_HEADERS },
validateStatus: (s) => s >= 200 && s < 300,
};
}
module.exports = {
DEFAULT_GET_JSON,
extensionFromContentType,
extensionFromImageUrl,
fetchComicLandJson,
imageGetConfig,
unwrapApiData,
};
+68 -7
View File
@@ -114,20 +114,24 @@ async function chapterUrlToCbz(chapterUrl, outputCbzPath) {
}
/**
* CLI: `node src/downloadChapterCbz.js <chapterUrl> [path/to/file.cbz|path/to/dir/]`
* CLI: `node src/downloadChapterCbz.js <chapterUrl> [path/to/file.cbz|path/to/dir/] [--no-db]`
* Upserts the series into SQLite and marks this chapter downloaded unless `--no-db`.
* @returns {Promise<void>}
*/
async function main() {
const chapterUrl = process.argv[2];
if (!chapterUrl || chapterUrl.startsWith("-")) {
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const positional = argv.filter((a) => !a.startsWith("-"));
const chapterUrl = positional[0];
if (!chapterUrl) {
console.error(
"Usage: node src/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory]",
"Usage: node src/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]",
);
process.exitCode = 1;
return;
}
const outArg = process.argv[3];
const outArg = positional[1];
const stem = stemForCbzFilename(chapterUrl);
let comicTitle = mangaSlugFromUrl(chapterUrl);
try {
@@ -136,14 +140,71 @@ async function main() {
} catch {
/* keep slug fallback */
}
const target = resolveCliOutputPath(outArg, stem, comicTitle);
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let chapterRow;
let target;
try {
if (!noDb) {
const cliStore = require("./server/cli-store.js");
const { resolveCbzPath } = require("./server/sources.js");
({ db, dataDir } = cliStore.openCliDatabase());
const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir);
console.log(
`SQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`${String(result.total)} chapter(s)`,
);
chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl);
if (outArg === undefined || outArg.trim() === "") {
target = resolveCbzPath(
"mangaread",
result.title.title || comicTitle,
chapterUrl,
dataDir,
);
} else {
target = resolveCliOutputPath(outArg, stem, comicTitle);
}
if (chapterRow) {
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
} else {
target = resolveCliOutputPath(outArg, stem, comicTitle);
}
const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target);
console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
const cliStore = require("./server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
const msg = err instanceof Error ? err.message : String(err);
console.error(msg);
if (db && chapterRow) {
const cliStore = require("./server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
process.exitCode = 1;
} finally {
if (db) {
db.close();
}
}
}
+429 -87
View File
@@ -9,9 +9,15 @@ const { chapterUrlToCbz, sanitizeComicDirName, stemForCbzFilename } = require(".
* @property {string|undefined} listingUrl - Page containing `li.wp-manga-chapter` links.
* @property {boolean} listOnly - Print numbered chapters and exit.
* @property {boolean} allChapters - Queue every chapter in list order.
* @property {boolean} missingOnly - Queue chapters that need download (SQLite + CBZ on disk).
* @property {string|undefined} selectSpec - Comma-separated **list indices** on the printed menu (oldest first: row 1 ≈ Chapter 1), e.g. `1,3,5-7`.
* @property {string|undefined} numbersSpec - Comma-separated **chapter numbers** from titles, e.g. `22,23,24` or `22-24`.
* @property {string} outDir - Directory for `.cbz` files.
* @property {boolean} noDb - Skip SQLite updates.
* @property {number|undefined} titleId - Load listing URL from SQLite title id.
* @property {boolean} outDirExplicit - True when `--out` was passed (custom CBZ root).
* @property {boolean} verbose - Print informational progress logs.
* @property {number} delaySeconds - Pause between chapter downloads (0 = no delay).
*/
/**
@@ -26,9 +32,15 @@ function parseCli(argv) {
listingUrl: undefined,
listOnly: false,
allChapters: false,
missingOnly: false,
selectSpec: undefined,
numbersSpec: undefined,
outDir: path.join(process.cwd(), "data", "comics"),
noDb: false,
titleId: undefined,
outDirExplicit: false,
verbose: false,
delaySeconds: 0,
};
for (let i = 2; i < argv.length; i += 1) {
@@ -39,6 +51,34 @@ function parseCli(argv) {
result.listOnly = true;
} else if (a === "--all") {
result.allChapters = true;
} else if (a === "--missing") {
result.missingOnly = true;
} else if (a === "--verbose" || a === "-v") {
result.verbose = true;
} else if (a === "--no-db") {
result.noDb = true;
} else if (a === "--delay") {
const next = argv[i + 1];
if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) {
throw new Error("Missing value for --delay");
}
const seconds = Number.parseInt(next, 10);
if (!Number.isFinite(seconds) || seconds < 0) {
throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`);
}
result.delaySeconds = seconds;
i += 1;
} else if (a === "--title-id") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --title-id");
}
const id = Number.parseInt(next, 10);
if (!Number.isFinite(id) || id < 1) {
throw new Error(`Invalid --title-id: ${next}`);
}
result.titleId = id;
i += 1;
} else if (a === "--select" || a === "-s") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
@@ -59,6 +99,7 @@ function parseCli(argv) {
throw new Error("Missing value for --out");
}
result.outDir = path.resolve(next);
result.outDirExplicit = true;
i += 1;
} else if (!a.startsWith("-")) {
if (result.listingUrl) {
@@ -73,6 +114,102 @@ function parseCli(argv) {
return result;
}
/**
* @param {boolean} verbose - When false, no-op.
* @returns {(msg: string) => void} Logger.
*/
function makeVlog(verbose) {
return (msg) => {
if (verbose) {
console.log(`info: ${msg}`);
}
};
}
/**
* @param {number} seconds - Seconds to wait.
* @returns {Promise<void>}
*/
function sleepSeconds(seconds) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1000);
});
}
/**
* Build a download queue from SQLite chapters that still need a CBZ
* (pending, failed, or marked downloaded but file missing), in listing order.
* @param {import("./mangareadFetchChapters.js").ChapterLink[]} chapters - Oldest-first listing.
* @param {object[]} needingRows - Chapter rows from SQLite.
* @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue items.
*/
function queueItemsMissing(chapters, needingRows) {
/** @type {Map<string, object>} */
const byUrl = new Map();
for (const row of needingRows) {
if (typeof row.url === "string" && row.url) {
byUrl.set(row.url, row);
}
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
const items = [];
/** @type {Set<string>} */
const used = new Set();
for (let i = 0; i < chapters.length; i += 1) {
const ch = chapters[i];
const row = byUrl.get(ch.url);
if (!row) {
continue;
}
used.add(ch.url);
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: ch.url,
title: ch.title,
pickLabel: `missing:${reason}`,
});
}
for (const row of needingRows) {
if (typeof row.url !== "string" || !row.url || used.has(row.url)) {
continue;
}
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: row.url,
title: row.label || row.chapter_key || row.url,
pickLabel: `missing:${reason}`,
});
}
return items;
}
/**
* Chapters that need a download (status pending/failed, or downloaded CBZ gone).
* @param {import("better-sqlite3").Database} db - Database handle.
* @param {number} titleId - Title id.
* @param {(path: string|null|undefined) => boolean} cbzExistsFn - File check.
* @returns {object[]} Chapter rows.
*/
function listChaptersMissing(db, titleId, cbzExistsFn) {
const dbApi = require("./server/db.js");
const rows = dbApi.listChapters(db, titleId);
return rows.filter((ch) => {
if (ch.status === "pending" || ch.status === "failed") {
return true;
}
if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) {
return true;
}
return false;
});
}
/**
* WP‑Manga lists chapters **newest first** in the HTML. We reverse so the numbered
* menu and `--select` indices match reading order (line 1 ≈ Chapter 1).
@@ -278,6 +415,7 @@ function chaptersByIndices(indices1, chapters) {
/**
* Fetches chapter list, queues selected chapters, downloads one `.cbz` at a time.
* Upserts titles/chapters into SQLite unless `--no-db`.
* @returns {Promise<void>}
*/
async function main() {
@@ -296,114 +434,316 @@ async function main() {
node src/downloadChaptersQueue.js <listing-page-url> --numbers|-n <spec> [--out|-o <dir>]
node src/downloadChaptersQueue.js <listing-page-url> --select|-s <spec> [--out|-o <dir>]
node src/downloadChaptersQueue.js <listing-page-url> --all [--out|-o <dir>]
node src/downloadChaptersQueue.js <listing-page-url> --missing [--out|-o <dir>]
node src/downloadChaptersQueue.js --title-id <id> --missing …
node src/downloadChaptersQueue.js --title-id <id> --numbers|-n <spec> …
Any chapter URL for that manga usually works as <listing-page-url> (same HTML includes the chapter list).
--title-id: load listing URL from SQLite (DATA_DIR/app.db) instead of a URL argument
--missing: download chapters that are pending, failed, or downloaded but CBZ missing (requires SQLite)
--delay N: wait N seconds between chapter downloads (e.g. --delay 300)
--no-db: write CBZ files only; do not update SQLite (incompatible with --missing / --title-id)
--verbose|-v: print informational progress logs
DATA_DIR: data root (default: ./data); CBZ paths match the web app when --out is omitted
--numbers spec: chapter numbers from link titles ("Chapter 22"), e.g. 22,23,24 or 22-24
Decimals (e.g. 160.5) must be listed explicitly; integer ranges expand to whole numbers only.
--select spec: 1-based row numbers on the **printed list** (oldest first; row 1 is Chapter 1),
e.g. 1,3,5-10. This is the menu index, not a substring of the title (use --numbers for that).
Chapters are processed in list order for --select/--all, or in the order given for --numbers.
Default --out: data/comics/<comic title>/ (under current working directory).`);
Chapters are processed in list order for --select/--all/--missing, or in the order given for --numbers.
Default --out: data/comics/<comic title>/ (under current working directory; with DB:
$DATA_DIR/comics/<comic title>/).`);
return;
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/downloadChaptersQueue.js <listing-page-url> [options]\nTry --help for details.",
);
process.exitCode = 1;
return;
}
const vlog = makeVlog(parsed.verbose);
const cliStore = require("./server/cli-store.js");
const { resolveCbzPath, cbzExists } = require("./server/sources.js");
const dbApi = require("./server/db.js");
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let dbTitle;
let chapters;
let comicTitle;
try {
({ chapters, comicTitle } = await fetchWpMangaChapterLinks(parsed.listingUrl));
chapters = chaptersInReadingOrder(chapters);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
console.log(`Found ${String(chapters.length)} chapter link(s) on listing page.`);
printChapterMenu(chapters);
if (parsed.listOnly) {
return;
}
const wantsAll = parsed.allChapters;
const wantsSelect = Boolean(parsed.selectSpec);
const wantsNumbers = Boolean(parsed.numbersSpec);
const pickModes = [wantsAll, wantsSelect, wantsNumbers].filter(Boolean).length;
if (pickModes === 0) {
console.error(
"Choose: --numbers <spec>, --select <spec>, or --all (or use --list-only to exit after listing).",
);
process.exitCode = 1;
return;
}
if (pickModes > 1) {
console.error("Use only one of --all, --select, or --numbers.");
process.exitCode = 1;
return;
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
let queue;
try {
if (wantsAll) {
const indices = Array.from({ length: chapters.length }, (_, i) => i + 1);
queue = chaptersByIndices(indices, chapters);
} else if (wantsSelect) {
const indices = parseChapterSelection(
/** @type {string} */ (parsed.selectSpec),
chapters.length,
);
queue = chaptersByIndices(indices, chapters);
} else {
const orderedNums = parseMangaChapterNumbersSpec(
/** @type {string} */ (parsed.numbersSpec),
);
queue = queueItemsByChapterNumbers(orderedNums, chapters);
if (parsed.missingOnly && parsed.noDb) {
console.error("Cannot combine --missing with --no-db (SQLite is required).");
process.exitCode = 1;
return;
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
const outDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle));
console.log(`Comic: ${comicTitle}`);
console.log(`Output directory: ${outDir}`);
let failedCount = 0;
let done = 0;
for (const item of queue) {
done += 1;
const stem = stemForCbzFilename(item.url);
const targetPath = path.join(outDir, `${stem}.cbz`);
console.log(
`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`,
);
console.log(` → ${item.url}`);
if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) {
if (parsed.noDb && parsed.titleId !== undefined) {
console.error("Cannot combine --no-db with --title-id.");
process.exitCode = 1;
return;
}
vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`);
({ db, dataDir } = cliStore.openCliDatabase());
vlog(`database ready: ${path.join(dataDir, "app.db")}`);
}
if (parsed.titleId !== undefined) {
vlog(`looking up --title-id ${String(parsed.titleId)}`);
dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId);
if (!dbTitle) {
console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`);
process.exitCode = 1;
return;
}
if (dbTitle.source !== "mangaread") {
console.error(
`Title id ${String(parsed.titleId)} is source "${dbTitle.source}"; use the matching CLI.`,
);
process.exitCode = 1;
return;
}
parsed.listingUrl = dbTitle.url;
vlog(`resolved title "${dbTitle.title}" → ${dbTitle.url}`);
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/downloadChaptersQueue.js <listing-page-url> [options]\n" +
"Or: node src/downloadChaptersQueue.js --title-id <id> [options]\n" +
"Try --help for details.",
);
process.exitCode = 1;
return;
}
vlog(`fetching chapter listing: ${parsed.listingUrl}`);
let chapters;
let comicTitle;
try {
const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath);
console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`);
({ chapters, comicTitle } = await fetchWpMangaChapterLinks(parsed.listingUrl));
chapters = chaptersInReadingOrder(chapters);
} catch (err) {
console.error(` ✗ ${err instanceof Error ? err.message : err}`);
failedCount += 1;
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
}
if (failedCount > 0) {
console.error(
`\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`,
console.log(`Found ${String(chapters.length)} chapter link(s) on listing page.`);
printChapterMenu(chapters);
vlog(`comic title from listing: ${comicTitle}`);
if (parsed.listOnly) {
if (db && dataDir) {
vlog("upserting title/chapters into SQLite (--list-only)");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
console.log(
`\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
return;
}
const wantsAll = parsed.allChapters;
const wantsMissing = parsed.missingOnly;
const wantsSelect = Boolean(parsed.selectSpec);
const wantsNumbers = Boolean(parsed.numbersSpec);
const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter(
Boolean,
).length;
if (pickModes === 0) {
console.error(
"Choose: --numbers <spec>, --select <spec>, --all, or --missing (or use --list-only).",
);
process.exitCode = 1;
return;
}
if (pickModes > 1) {
console.error("Use only one of --all, --missing, --select, or --numbers.");
process.exitCode = 1;
return;
}
if (db && dataDir) {
vlog("upserting title/chapters into SQLite");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
dbTitle = result.title;
console.log(
`SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
let queue;
try {
if (wantsMissing) {
if (!db || !dbTitle) {
console.error("--missing requires SQLite (omit --no-db).");
process.exitCode = 1;
return;
}
vlog("selecting chapters that need download (pending/failed/missing CBZ)");
const needing = listChaptersMissing(db, dbTitle.id, cbzExists);
queue = queueItemsMissing(chapters, needing);
console.log(
`Missing: ${String(queue.length)} chapter(s) need download ` +
`(of ${String(chapters.length)} on listing).`,
);
if (parsed.verbose) {
for (const item of queue) {
vlog(` queue ${item.pickLabel} ${item.title} → ${item.url}`);
}
}
if (queue.length === 0) {
console.log("Nothing to download.");
return;
}
} else if (wantsAll) {
vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`);
const indices = Array.from({ length: chapters.length }, (_, i) => i + 1);
queue = chaptersByIndices(indices, chapters);
} else if (wantsSelect) {
vlog(`queue mode: --select ${parsed.selectSpec}`);
const indices = parseChapterSelection(
/** @type {string} */ (parsed.selectSpec),
chapters.length,
);
queue = chaptersByIndices(indices, chapters);
} else {
vlog(`queue mode: --numbers ${parsed.numbersSpec}`);
const orderedNums = parseMangaChapterNumbersSpec(
/** @type {string} */ (parsed.numbersSpec),
);
queue = queueItemsByChapterNumbers(orderedNums, chapters);
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
vlog(`download queue size: ${String(queue.length)}`);
const titleForPaths = (dbTitle && dbTitle.title) || comicTitle;
const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit);
const fallbackOutDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle));
const outDir =
useDbPaths && queue.length > 0
? path.dirname(
resolveCbzPath(
"mangaread",
titleForPaths,
queue[0].url,
/** @type {string} */ (dataDir),
),
)
: fallbackOutDir;
console.log(`Comic: ${comicTitle}`);
console.log(`Output directory: ${outDir}`);
if (parsed.delaySeconds > 0) {
console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`);
}
vlog(
useDbPaths
? "CBZ paths from resolveCbzPath (DATA_DIR layout)"
: `CBZ paths under ${fallbackOutDir}`,
);
process.exitCode = 1;
let failedCount = 0;
let done = 0;
for (const item of queue) {
done += 1;
const stem = stemForCbzFilename(item.url);
let targetPath = path.join(fallbackOutDir, `${stem}.cbz`);
/** @type {object|undefined} */
let chapterRow;
if (useDbPaths) {
targetPath = resolveCbzPath(
"mangaread",
titleForPaths,
item.url,
/** @type {string} */ (dataDir),
);
chapterRow = cliStore.findChapterByUrl(
/** @type {import("better-sqlite3").Database} */ (db),
dbTitle.id,
item.url,
);
} else if (db && dbTitle) {
chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url);
}
console.log(
`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`,
);
console.log(` → ${item.url}`);
vlog(`target CBZ: ${targetPath}`);
if (chapterRow) {
vlog(
`sqlite chapter id=${String(chapterRow.id)} status=${String(chapterRow.status)}`,
);
} else if (db) {
vlog("no matching chapter row in SQLite for this URL");
}
try {
if (db && chapterRow) {
vlog("marking chapter downloading");
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
vlog("starting chapterUrlToCbz");
const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath);
console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
vlog("marking chapter downloaded");
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ✗ ${msg}`);
if (db && chapterRow) {
vlog("marking chapter failed");
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
failedCount += 1;
}
if (parsed.delaySeconds > 0 && done < queue.length) {
console.log(
` … waiting ${String(parsed.delaySeconds)}s before next chapter`,
);
vlog(`delay ${String(parsed.delaySeconds)}s`);
await sleepSeconds(parsed.delaySeconds);
}
}
if (failedCount > 0) {
console.error(
`\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`,
);
process.exitCode = 1;
} else {
vlog(`finished: ${String(queue.length)} chapter(s) succeeded`);
}
} finally {
if (db) {
vlog("closing database");
db.close();
}
}
}
@@ -411,11 +751,13 @@ module.exports = {
chapterNumberFromWpTitle,
chaptersByIndices,
chaptersInReadingOrder,
listChaptersMissing,
parseChapterSelection,
parseCli,
parseMangaChapterNumbersSpec,
printChapterMenu,
queueItemsByChapterNumbers,
queueItemsMissing,
};
if (require.main === module) {
+33 -1
View File
@@ -391,19 +391,51 @@ async function downloadWpMangaChapterImages(chapterPageUrl, outDir) {
/**
* Fetches the page, writes HTML to disk, parses chapter rows.
* Optionally upserts into SQLite (`DATA_DIR/app.db`) unless `--no-db`.
*
* Usage:
* node src/mangareadFetchChapters.js [listing-url] [--no-db]
* @returns {Promise<void>}
*/
async function main() {
const html = await fetchPageHtml(PAGE_URL);
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const pageUrl =
argv.find((a) => !a.startsWith("-")) || PAGE_URL;
const html = await fetchPageHtml(pageUrl);
await fs.mkdir(path.dirname(OUTPUT_HTML), { recursive: true });
await fs.writeFile(OUTPUT_HTML, html, "utf8");
const chapters = extractWpMangaChapters(html);
console.log(`Source: ${pageUrl}`);
console.log(`Saved HTML: ${OUTPUT_HTML}`);
console.log(`Found ${String(chapters.length)} li.wp-manga-chapter link(s):`);
console.log(JSON.stringify(chapters, null, 2));
if (noDb) {
return;
}
const {
openCliDatabase,
ensureTitleFromUrl,
} = require("./server/cli-store.js");
const { db, dataDir } = openCliDatabase();
try {
const result = await ensureTitleFromUrl(db, pageUrl, dataDir);
console.log(
`\nSQLite (${path.join(dataDir, "app.db")}): ` +
`title id=${String(result.title.id)} ` +
`"${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
} finally {
db.close();
}
}
module.exports = {
+68 -6
View File
@@ -124,19 +124,24 @@ async function chapterUrlToCbz(chapterUrl, outputCbzPath) {
}
/**
* CLI: `node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [.cbz|dir]`
* CLI: `node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [.cbz|dir] [--no-db]`
* Upserts the series into SQLite and marks this chapter downloaded unless `--no-db`.
* @returns {Promise<void>}
*/
async function main() {
const chapterUrl = process.argv[2];
if (!chapterUrl || chapterUrl.startsWith("-")) {
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const positional = argv.filter((a) => !a.startsWith("-"));
const chapterUrl = positional[0];
if (!chapterUrl) {
console.error(
"Usage: node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory]",
"Usage: node src/manhwasusu/downloadChapterCbz.js <chapterUrl> [output.cbz|outputDirectory] [--no-db]",
);
process.exitCode = 1;
return;
}
const outArg = positional[1];
const stem = stemForManhwaSusuChapterCbz(chapterUrl);
let comicTitle = seriesSlugFromListingUrl(chapterUrl);
try {
@@ -145,14 +150,71 @@ async function main() {
} catch {
/* keep slug fallback */
}
const target = resolveCliOutputPath(process.argv[3], stem, comicTitle);
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let chapterRow;
let target;
try {
if (!noDb) {
const cliStore = require("../server/cli-store.js");
const { resolveCbzPath } = require("../server/sources.js");
({ db, dataDir } = cliStore.openCliDatabase());
const result = await cliStore.ensureTitleFromUrl(db, chapterUrl, dataDir);
console.log(
`SQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`${String(result.total)} chapter(s)`,
);
chapterRow = cliStore.findChapterByUrl(db, result.title.id, chapterUrl);
if (outArg === undefined || outArg.trim() === "") {
target = resolveCbzPath(
"manhwasusu",
result.title.title || comicTitle,
chapterUrl,
dataDir,
);
} else {
target = resolveCliOutputPath(outArg, stem, comicTitle);
}
if (chapterRow) {
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
} else {
target = resolveCliOutputPath(outArg, stem, comicTitle);
}
const { imageCount, outputPath } = await chapterUrlToCbz(chapterUrl, target);
console.log(`Wrote ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
const cliStore = require("../server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
const msg = err instanceof Error ? err.message : String(err);
console.error(msg);
if (db && chapterRow) {
const cliStore = require("../server/cli-store.js");
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
process.exitCode = 1;
} finally {
if (db) {
db.close();
}
}
}
+425 -86
View File
@@ -10,9 +10,15 @@ const { stemForManhwaSusuChapterCbz, chapterUrlToCbz, sanitizeComicDirName } =
* @property {string|undefined} listingUrl - Series listing URL (required unless help).
* @property {boolean} listOnly - Only print the chapter menu.
* @property {boolean} allChapters - Queue every chapter after reversal to reading order.
* @property {boolean} missingOnly - Queue chapters that need download (SQLite + CBZ on disk).
* @property {string|undefined} selectSpec - `--select` string (1-based menu indices).
* @property {string|undefined} numbersSpec - `--numbers` string (chapter numbers from titles).
* @property {string} outDir - Output directory for `.cbz` files.
* @property {boolean} noDb - Skip SQLite updates.
* @property {number|undefined} titleId - Load listing URL from SQLite title id.
* @property {boolean} outDirExplicit - True when `--out` was passed.
* @property {boolean} verbose - Print informational progress logs.
* @property {number} delaySeconds - Pause between chapter downloads (0 = no delay).
*/
/**
@@ -26,9 +32,15 @@ function parseCli(argv) {
listingUrl: undefined,
listOnly: false,
allChapters: false,
missingOnly: false,
selectSpec: undefined,
numbersSpec: undefined,
outDir: path.join(process.cwd(), "data", "manhwasusu", "comics"),
noDb: false,
titleId: undefined,
outDirExplicit: false,
verbose: false,
delaySeconds: 0,
};
for (let i = 2; i < argv.length; i += 1) {
@@ -39,6 +51,34 @@ function parseCli(argv) {
result.listOnly = true;
} else if (a === "--all") {
result.allChapters = true;
} else if (a === "--missing") {
result.missingOnly = true;
} else if (a === "--verbose" || a === "-v") {
result.verbose = true;
} else if (a === "--no-db") {
result.noDb = true;
} else if (a === "--delay") {
const next = argv[i + 1];
if (next === undefined || (next.startsWith("-") && !/^-?\d+$/u.test(next))) {
throw new Error("Missing value for --delay");
}
const seconds = Number.parseInt(next, 10);
if (!Number.isFinite(seconds) || seconds < 0) {
throw new Error(`Invalid --delay: ${next} (expected non-negative integer seconds)`);
}
result.delaySeconds = seconds;
i += 1;
} else if (a === "--title-id") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
throw new Error("Missing value for --title-id");
}
const id = Number.parseInt(next, 10);
if (!Number.isFinite(id) || id < 1) {
throw new Error(`Invalid --title-id: ${next}`);
}
result.titleId = id;
i += 1;
} else if (a === "--select" || a === "-s") {
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
@@ -59,6 +99,7 @@ function parseCli(argv) {
throw new Error("Missing value for --out");
}
result.outDir = path.resolve(next);
result.outDirExplicit = true;
i += 1;
} else if (!a.startsWith("-")) {
if (result.listingUrl) {
@@ -73,6 +114,101 @@ function parseCli(argv) {
return result;
}
/**
* @param {boolean} verbose - When false, no-op.
* @returns {(msg: string) => void} Logger.
*/
function makeVlog(verbose) {
return (msg) => {
if (verbose) {
console.log(`info: ${msg}`);
}
};
}
/**
* @param {number} seconds - Seconds to wait.
* @returns {Promise<void>}
*/
function sleepSeconds(seconds) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1000);
});
}
/**
* Build a download queue from SQLite chapters that still need a CBZ.
* @param {import("./chapters.js").ChapterLink[]} chapters - Oldest-first listing.
* @param {object[]} needingRows - Chapter rows from SQLite.
* @returns {Array<{ url: string, title: string, pickLabel: string }>} Queue items.
*/
function queueItemsMissing(chapters, needingRows) {
/** @type {Map<string, object>} */
const byUrl = new Map();
for (const row of needingRows) {
if (typeof row.url === "string" && row.url) {
byUrl.set(row.url, row);
}
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
const items = [];
/** @type {Set<string>} */
const used = new Set();
for (let i = 0; i < chapters.length; i += 1) {
const ch = chapters[i];
const row = byUrl.get(ch.url);
if (!row) {
continue;
}
used.add(ch.url);
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: ch.url,
title: ch.title,
pickLabel: `missing:${reason}`,
});
}
for (const row of needingRows) {
if (typeof row.url !== "string" || !row.url || used.has(row.url)) {
continue;
}
const reason =
row.status === "downloaded" ? "file-missing" : String(row.status);
items.push({
url: row.url,
title: row.label || row.chapter_key || row.url,
pickLabel: `missing:${reason}`,
});
}
return items;
}
/**
* Chapters that need a download (status pending/failed, or downloaded CBZ gone).
* @param {import("better-sqlite3").Database} db - Database handle.
* @param {number} titleId - Title id.
* @param {(path: string|null|undefined) => boolean} cbzExistsFn - File check.
* @returns {object[]} Chapter rows.
*/
function listChaptersMissing(db, titleId, cbzExistsFn) {
const dbApi = require("../server/db.js");
const rows = dbApi.listChapters(db, titleId);
return rows.filter((ch) => {
if (ch.status === "pending" || ch.status === "failed") {
return true;
}
if (ch.status === "downloaded" && !cbzExistsFn(ch.cbz_path)) {
return true;
}
return false;
});
}
/**
* Listing HTML is newest-first; reverse so `--select` line 1 is Chapter 1.
* @param {import("./chapters.js").ChapterLink[]} chapters - Site order.
@@ -276,6 +412,7 @@ function chaptersByIndices(indices1, chapters) {
/**
* CLI entry: fetch listing, optionally build `.cbz` archives.
* Upserts titles/chapters into SQLite unless `--no-db`.
* @returns {Promise<void>}
*/
async function main() {
@@ -296,10 +433,21 @@ Usage:
node src/manhwasusu/downloadChaptersQueue.js <listing-url> --numbers|-n <spec> [--out|-o <dir>]
node src/manhwasusu/downloadChaptersQueue.js <listing-url> --select|-s <spec> [--out|-o <dir>]
node src/manhwasusu/downloadChaptersQueue.js <listing-url> --all [--out|-o <dir>]
node src/manhwasusu/downloadChaptersQueue.js <listing-url> --missing [--out|-o <dir>]
node src/manhwasusu/downloadChaptersQueue.js --title-id <id> --missing …
node src/manhwasusu/downloadChaptersQueue.js --title-id <id> --numbers|-n <spec> …
Listing URL: series page ending in /read/{slug}/ (or chapter URL — site embeds chapter list markup).
--title-id: load listing URL from SQLite (DATA_DIR/app.db) instead of a URL argument
--missing: download chapters that are pending, failed, or downloaded but CBZ missing (requires SQLite)
--delay N: wait N seconds between chapter downloads (e.g. --delay 300)
--no-db: write CBZ files only; do not update SQLite (incompatible with --missing / --title-id)
--verbose|-v: print informational progress logs
DATA_DIR: data root (default: ./data)
Default --out: data/manhwasusu/comics/<comic title>/
(with DB and no --out: $DATA_DIR/manhwasusu/comics/<comic title>/)
--numbers: titles like "Chapter 22", e.g. 22,23,24 or 22-24
--select: 1-based rows on printed list after reversal (line 1 = Chapter 1)
@@ -307,103 +455,292 @@ Default --out: data/manhwasusu/comics/<comic title>/
return;
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/manhwasusu/downloadChaptersQueue.js <listing-url> [options]",
);
process.exitCode = 1;
return;
}
const vlog = makeVlog(parsed.verbose);
const cliStore = require("../server/cli-store.js");
const { resolveCbzPath, cbzExists } = require("../server/sources.js");
const dbApi = require("../server/db.js");
if (!isLikelyManhwaSusuSite(parsed.listingUrl)) {
console.warn(
"Warning: URL hostname does not look like ManhwaSusu; parsers may fail if structure differs.",
);
}
/** @type {import("better-sqlite3").Database|null} */
let db = null;
/** @type {string|undefined} */
let dataDir;
/** @type {object|undefined} */
let dbTitle;
let chapters;
let comicTitle;
try {
({ chapters, comicTitle } = await fetchChapterLinks(parsed.listingUrl));
chapters = chaptersInReadingOrder(chapters);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
console.log(`Found ${String(chapters.length)} ManhwaSusu chapter link(s).`);
printChapterMenu(chapters);
if (parsed.listOnly) {
return;
}
const wantsAll = parsed.allChapters;
const wantsSelect = Boolean(parsed.selectSpec);
const wantsNumbers = Boolean(parsed.numbersSpec);
const pickModes = [wantsAll, wantsSelect, wantsNumbers].filter(Boolean).length;
if (pickModes === 0) {
console.error("Choose --numbers, --select, or --all (or --list-only).");
process.exitCode = 1;
return;
}
if (pickModes > 1) {
console.error("Use only one of --all, --select, or --numbers.");
process.exitCode = 1;
return;
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
let queue;
try {
if (wantsAll) {
const indices = Array.from({ length: chapters.length }, (_, i) => i + 1);
queue = chaptersByIndices(indices, chapters);
} else if (wantsSelect) {
const indices = parseChapterSelection(
/** @type {string} */ (parsed.selectSpec),
chapters.length,
);
queue = chaptersByIndices(indices, chapters);
} else {
const orderedNums = parseChapterNumbersSpec(
/** @type {string} */ (parsed.numbersSpec),
);
queue = queueItemsByChapterNumbers(orderedNums, chapters);
if (parsed.missingOnly && parsed.noDb) {
console.error("Cannot combine --missing with --no-db (SQLite is required).");
process.exitCode = 1;
return;
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
const outDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle));
console.log(`Comic: ${comicTitle}`);
console.log(`Output directory: ${outDir}`);
if (!parsed.noDb || parsed.titleId !== undefined || parsed.missingOnly) {
if (parsed.noDb && parsed.titleId !== undefined) {
console.error("Cannot combine --no-db with --title-id.");
process.exitCode = 1;
return;
}
vlog(`opening SQLite under DATA_DIR=${cliStore.resolveDataDir()}`);
({ db, dataDir } = cliStore.openCliDatabase());
vlog(`database ready: ${path.join(dataDir, "app.db")}`);
}
let failedCount = 0;
let done = 0;
for (const item of queue) {
done += 1;
const stem = stemForManhwaSusuChapterCbz(item.url);
const targetPath = path.join(outDir, `${stem}.cbz`);
console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`);
console.log(` → ${item.url}`);
if (parsed.titleId !== undefined) {
vlog(`looking up --title-id ${String(parsed.titleId)}`);
dbTitle = dbApi.getTitle(/** @type {import("better-sqlite3").Database} */ (db), parsed.titleId);
if (!dbTitle) {
console.error(`Title id ${String(parsed.titleId)} not found in SQLite.`);
process.exitCode = 1;
return;
}
if (dbTitle.source !== "manhwasusu") {
console.error(
`Title id ${String(parsed.titleId)} is source "${dbTitle.source}"; use the matching CLI.`,
);
process.exitCode = 1;
return;
}
parsed.listingUrl = dbTitle.url;
vlog(`resolved title "${dbTitle.title}" → ${dbTitle.url}`);
}
if (!parsed.listingUrl) {
console.error(
"Usage: node src/manhwasusu/downloadChaptersQueue.js <listing-url> [options]\n" +
"Or: --title-id <id> [options]",
);
process.exitCode = 1;
return;
}
if (!isLikelyManhwaSusuSite(parsed.listingUrl)) {
console.warn(
"Warning: URL hostname does not look like ManhwaSusu; parsers may fail if structure differs.",
);
}
vlog(`fetching chapter listing: ${parsed.listingUrl}`);
let chapters;
let comicTitle;
try {
const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath);
console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`);
({ chapters, comicTitle } = await fetchChapterLinks(parsed.listingUrl));
chapters = chaptersInReadingOrder(chapters);
} catch (err) {
console.error(` ✗ ${err instanceof Error ? err.message : err}`);
failedCount += 1;
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
}
if (failedCount > 0) {
console.error(
`\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`,
console.log(`Found ${String(chapters.length)} ManhwaSusu chapter link(s).`);
printChapterMenu(chapters);
vlog(`comic title from listing: ${comicTitle}`);
if (parsed.listOnly) {
if (db && dataDir) {
vlog("upserting title/chapters into SQLite (--list-only)");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
console.log(
`\nSQLite: title id=${String(result.title.id)} "${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
return;
}
const wantsAll = parsed.allChapters;
const wantsMissing = parsed.missingOnly;
const wantsSelect = Boolean(parsed.selectSpec);
const wantsNumbers = Boolean(parsed.numbersSpec);
const pickModes = [wantsAll, wantsMissing, wantsSelect, wantsNumbers].filter(
Boolean,
).length;
if (pickModes === 0) {
console.error("Choose --numbers, --select, --all, or --missing (or --list-only).");
process.exitCode = 1;
return;
}
if (pickModes > 1) {
console.error("Use only one of --all, --missing, --select, or --numbers.");
process.exitCode = 1;
return;
}
if (db && dataDir) {
vlog("upserting title/chapters into SQLite");
const result = await cliStore.ensureTitleFromUrl(db, parsed.listingUrl, dataDir);
dbTitle = result.title;
console.log(
`SQLite: title id=${String(dbTitle.id)} "${dbTitle.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
}
/** @type {Array<{ url: string, title: string, pickLabel: string }>} */
let queue;
try {
if (wantsMissing) {
if (!db || !dbTitle) {
console.error("--missing requires SQLite (omit --no-db).");
process.exitCode = 1;
return;
}
vlog("selecting chapters that need download (pending/failed/missing CBZ)");
const needing = listChaptersMissing(db, dbTitle.id, cbzExists);
queue = queueItemsMissing(chapters, needing);
console.log(
`Missing: ${String(queue.length)} chapter(s) need download ` +
`(of ${String(chapters.length)} on listing).`,
);
if (parsed.verbose) {
for (const item of queue) {
vlog(` queue ${item.pickLabel} ${item.title} → ${item.url}`);
}
}
if (queue.length === 0) {
console.log("Nothing to download.");
return;
}
} else if (wantsAll) {
vlog(`queue mode: --all (${String(chapters.length)} chapter(s))`);
const indices = Array.from({ length: chapters.length }, (_, i) => i + 1);
queue = chaptersByIndices(indices, chapters);
} else if (wantsSelect) {
vlog(`queue mode: --select ${parsed.selectSpec}`);
const indices = parseChapterSelection(
/** @type {string} */ (parsed.selectSpec),
chapters.length,
);
queue = chaptersByIndices(indices, chapters);
} else {
vlog(`queue mode: --numbers ${parsed.numbersSpec}`);
const orderedNums = parseChapterNumbersSpec(
/** @type {string} */ (parsed.numbersSpec),
);
queue = queueItemsByChapterNumbers(orderedNums, chapters);
}
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
return;
}
vlog(`download queue size: ${String(queue.length)}`);
const titleForPaths = (dbTitle && dbTitle.title) || comicTitle;
const useDbPaths = Boolean(db && dataDir && dbTitle && !parsed.outDirExplicit);
const fallbackOutDir = path.join(parsed.outDir, sanitizeComicDirName(comicTitle));
const outDir =
useDbPaths && queue.length > 0
? path.dirname(
resolveCbzPath(
"manhwasusu",
titleForPaths,
queue[0].url,
/** @type {string} */ (dataDir),
),
)
: fallbackOutDir;
console.log(`Comic: ${comicTitle}`);
console.log(`Output directory: ${outDir}`);
if (parsed.delaySeconds > 0) {
console.log(`Delay between chapters: ${String(parsed.delaySeconds)}s`);
}
vlog(
useDbPaths
? "CBZ paths from resolveCbzPath (DATA_DIR layout)"
: `CBZ paths under ${fallbackOutDir}`,
);
process.exitCode = 1;
let failedCount = 0;
let done = 0;
for (const item of queue) {
done += 1;
const stem = stemForManhwaSusuChapterCbz(item.url);
let targetPath = path.join(fallbackOutDir, `${stem}.cbz`);
/** @type {object|undefined} */
let chapterRow;
if (useDbPaths) {
targetPath = resolveCbzPath(
"manhwasusu",
titleForPaths,
item.url,
/** @type {string} */ (dataDir),
);
chapterRow = cliStore.findChapterByUrl(
/** @type {import("better-sqlite3").Database} */ (db),
dbTitle.id,
item.url,
);
} else if (db && dbTitle) {
chapterRow = cliStore.findChapterByUrl(db, dbTitle.id, item.url);
}
console.log(`\n[${String(done)}/${String(queue.length)}] ${item.pickLabel} ${item.title}`);
console.log(` → ${item.url}`);
vlog(`target CBZ: ${targetPath}`);
if (chapterRow) {
vlog(
`sqlite chapter id=${String(chapterRow.id)} status=${String(chapterRow.status)}`,
);
} else if (db) {
vlog("no matching chapter row in SQLite for this URL");
}
try {
if (db && chapterRow) {
vlog("marking chapter downloading");
cliStore.markDownload(db, chapterRow, { ok: true, phase: "start" });
}
vlog("starting chapterUrlToCbz");
const { imageCount, outputPath } = await chapterUrlToCbz(item.url, targetPath);
console.log(` ✓ ${String(imageCount)} page(s) → ${outputPath}`);
if (db && chapterRow) {
vlog("marking chapter downloaded");
cliStore.markDownload(db, chapterRow, {
ok: true,
cbzPath: outputPath,
phase: "finish",
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(` ✗ ${msg}`);
if (db && chapterRow) {
vlog("marking chapter failed");
cliStore.markDownload(db, chapterRow, {
ok: false,
error: msg,
phase: "finish",
});
}
failedCount += 1;
}
if (parsed.delaySeconds > 0 && done < queue.length) {
console.log(
` … waiting ${String(parsed.delaySeconds)}s before next chapter`,
);
vlog(`delay ${String(parsed.delaySeconds)}s`);
await sleepSeconds(parsed.delaySeconds);
}
}
if (failedCount > 0) {
console.error(
`\n${String(failedCount)} chapter(s) failed (${String(queue.length - failedCount)} succeeded).`,
);
process.exitCode = 1;
} else {
vlog(`finished: ${String(queue.length)} chapter(s) succeeded`);
}
} finally {
if (db) {
vlog("closing database");
db.close();
}
}
}
@@ -411,11 +748,13 @@ module.exports = {
chapterNumberFromListingTitle,
chaptersByIndices,
chaptersInReadingOrder,
listChaptersMissing,
parseChapterNumbersSpec,
parseChapterSelection,
parseCli,
printChapterMenu,
queueItemsByChapterNumbers,
queueItemsMissing,
};
if (require.main === module) {
+36 -2
View File
@@ -15,11 +15,23 @@ const OUTPUT_HTML = path.join(
);
/**
* CLI: snapshot listing HTML + print chapter JSON. Optional `MANHWASUSU_LISTING_URL` env overrides default.
* CLI: snapshot listing HTML + print chapter JSON.
* Optionally upserts into SQLite (`DATA_DIR/app.db`) unless `--no-db`.
*
* URL resolution order: argv listing URL, then `MANHWASUSU_LISTING_URL`, then default.
*
* Usage:
* node src/manhwasusu/fetchChapters.cli.js [listing-url] [--no-db]
* @returns {Promise<void>}
*/
async function main() {
const pageUrl = process.env.MANHWASUSU_LISTING_URL || DEFAULT_LISTING_URL;
const argv = process.argv.slice(2);
const noDb = argv.includes("--no-db");
const pageUrl =
argv.find((a) => !a.startsWith("-")) ||
process.env.MANHWASUSU_LISTING_URL ||
DEFAULT_LISTING_URL;
const html = await fetchManhwaSusuHtml(pageUrl);
await fs.mkdir(path.dirname(OUTPUT_HTML), { recursive: true });
@@ -33,6 +45,28 @@ async function main() {
`Found ${String(chapters.length)} chapter link(s) (newest-first on page; reverse for reading order):`,
);
console.log(JSON.stringify(chapters, null, 2));
if (noDb) {
return;
}
const {
openCliDatabase,
ensureTitleFromUrl,
} = require("../server/cli-store.js");
const { db, dataDir } = openCliDatabase();
try {
const result = await ensureTitleFromUrl(db, pageUrl, dataDir);
console.log(
`\nSQLite (${path.join(dataDir, "app.db")}): ` +
`title id=${String(result.title.id)} ` +
`"${result.title.title}" ` +
`(${result.created ? "created" : "updated"}), ` +
`+${String(result.added)} chapter(s), total ${String(result.total)}`,
);
} finally {
db.close();
}
}
if (require.main === module) {

Some files were not shown because too many files have changed in this diff Show More