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