Compare commits
8
Commits
ffeaa412e8
..
v0.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15c05f6bcd
|
||
|
|
9ba7eed81a
|
||
|
|
bbccd48bd6
|
||
|
|
7c63edc664
|
||
|
|
83e7c698e1
|
||
|
|
d5f6d20e1f
|
||
|
|
008379a09a
|
||
|
|
897874169d
|
No files matched your search
+34
-22
@@ -7,6 +7,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".gitea/workflows/build.yml"
|
- ".gitea/workflows/build.yml"
|
||||||
- "scripts/**"
|
- "scripts/**"
|
||||||
|
- "patches/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -22,39 +23,24 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Futon Niadd debug APK
|
name: Build Futon Niadd debug APK
|
||||||
runs-on: ubuntu-latest
|
runs-on: android-futon
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout CI repository
|
- name: Checkout CI repository
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Verify Android build environment
|
||||||
uses: https://github.com/actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: Set up Android SDK
|
|
||||||
uses: https://github.com/android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Install Android SDK 36
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
|
|
||||||
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
sdkmanager \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-${ANDROID_PLATFORM}" \
|
|
||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
|
||||||
|
|
||||||
echo "=== Android SDK ==="
|
echo "=== Android SDK ==="
|
||||||
echo "ANDROID_HOME=${ANDROID_HOME}"
|
echo "ANDROID_HOME=${ANDROID_HOME}"
|
||||||
echo "ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT:-}"
|
echo "ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT:-}"
|
||||||
|
|
||||||
echo "=== Installed build tools ==="
|
echo "=== Installed build tools ==="
|
||||||
ls -la "${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS}"
|
test -x "${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS}/aapt"
|
||||||
|
test -x "${ANDROID_HOME}/build-tools/${ANDROID_BUILD_TOOLS}/aapt2"
|
||||||
|
|
||||||
echo "=== Java ==="
|
echo "=== Java ==="
|
||||||
java -version
|
java -version
|
||||||
@@ -72,6 +58,7 @@ jobs:
|
|||||||
|
|
||||||
test -f scripts/patch_futon_niadd.py
|
test -f scripts/patch_futon_niadd.py
|
||||||
test -f scripts/patch_ninemanga_niadd.py
|
test -f scripts/patch_ninemanga_niadd.py
|
||||||
|
test -f patches/futon-niadd-range-download.patch
|
||||||
|
|
||||||
python3 -m py_compile \
|
python3 -m py_compile \
|
||||||
scripts/patch_futon_niadd.py \
|
scripts/patch_futon_niadd.py \
|
||||||
@@ -163,6 +150,32 @@ jobs:
|
|||||||
|
|
||||||
git -C work/Futon diff --check
|
git -C work/Futon diff --check
|
||||||
|
|
||||||
|
- name: Apply Niadd range-download workaround
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
patch --dry-run -p1 \
|
||||||
|
-d work/Futon \
|
||||||
|
< patches/futon-niadd-range-download.patch
|
||||||
|
|
||||||
|
patch -p1 \
|
||||||
|
-d work/Futon \
|
||||||
|
< patches/futon-niadd-range-download.patch
|
||||||
|
|
||||||
|
PAGE_LOADER="work/Futon/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt"
|
||||||
|
|
||||||
|
grep -F 'NIADD_RANGE_CHUNK_SIZE = 16L * 1024L' \
|
||||||
|
"${PAGE_LOADER}"
|
||||||
|
|
||||||
|
grep -F 'MangaParserSource.NINEMANGA_RU' \
|
||||||
|
"${PAGE_LOADER}"
|
||||||
|
|
||||||
|
grep -F '.header("Range", "bytes=$offset-$requestedEnd")' \
|
||||||
|
"${PAGE_LOADER}"
|
||||||
|
|
||||||
|
git -C work/Futon diff --check
|
||||||
|
|
||||||
- name: Apply Niadd parser modifications
|
- name: Apply Niadd parser modifications
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -246,6 +259,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
working-directory: work/Futon
|
working-directory: work/Futon
|
||||||
env:
|
env:
|
||||||
|
GRADLE_USER_HOME: /root/.gradle
|
||||||
GRADLE_OPTS: >-
|
GRADLE_OPTS: >-
|
||||||
-Dorg.gradle.daemon=false
|
-Dorg.gradle.daemon=false
|
||||||
-Dorg.gradle.jvmargs=-Xmx4g
|
-Dorg.gradle.jvmargs=-Xmx4g
|
||||||
@@ -254,14 +268,12 @@ jobs:
|
|||||||
|
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
|
|
||||||
echo "=== Gradle version ==="
|
|
||||||
./gradlew --version
|
|
||||||
|
|
||||||
echo "=== Build ==="
|
echo "=== Build ==="
|
||||||
|
|
||||||
./gradlew \
|
./gradlew \
|
||||||
--no-daemon \
|
--no-daemon \
|
||||||
--stacktrace \
|
--stacktrace \
|
||||||
|
--max-workers=6 \
|
||||||
:app:assembleDebug
|
:app:assembleDebug
|
||||||
|
|
||||||
- name: Verify APK identity
|
- name: Verify APK identity
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt
|
||||||
|
index ae55a0f..9f4338b 100644
|
||||||
|
--- a/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt
|
||||||
|
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/details/ui/pager/pages/MangaPageFetcher.kt
|
||||||
|
@@ -15,21 +15,26 @@ import coil3.request.Options
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
+import okio.Buffer
|
||||||
|
import okio.FileSystem
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
|
import io.github.landwarderer.futon.core.network.MangaHttpClient
|
||||||
|
import io.github.landwarderer.futon.core.network.imageproxy.ImageProxyInterceptor
|
||||||
|
import io.github.landwarderer.futon.core.parser.MangaRepository
|
||||||
|
import io.github.landwarderer.futon.core.util.MimeTypes
|
||||||
|
+import io.github.landwarderer.futon.core.util.ext.MimeType
|
||||||
|
import io.github.landwarderer.futon.core.util.ext.fetch
|
||||||
|
import io.github.landwarderer.futon.core.util.ext.isNetworkUri
|
||||||
|
import io.github.landwarderer.futon.core.util.ext.toMimeTypeOrNull
|
||||||
|
import io.github.landwarderer.futon.local.data.LocalStorageCache
|
||||||
|
import io.github.landwarderer.futon.local.data.PageCache
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||||
|
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
+import kotlinx.coroutines.sync.Semaphore
|
||||||
|
+import kotlinx.coroutines.sync.withPermit
|
||||||
|
import io.github.landwarderer.futon.reader.domain.PageLoader
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@@ -43,6 +48,11 @@ class MangaPageFetcher(
|
||||||
|
private val imageLoader: ImageLoader,
|
||||||
|
) : Fetcher {
|
||||||
|
|
||||||
|
+ private val niaddOkHttpClient = okHttpClient.newBuilder()
|
||||||
|
+ .followRedirects(false)
|
||||||
|
+ .followSslRedirects(false)
|
||||||
|
+ .build()
|
||||||
|
+
|
||||||
|
override suspend fun fetch(): FetchResult? {
|
||||||
|
if (!page.preview.isNullOrEmpty()) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
@@ -72,6 +82,12 @@ class MangaPageFetcher(
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchPage(pageUrl: String): FetchResult {
|
||||||
|
+ if (page.source == MangaParserSource.NINEMANGA_RU) {
|
||||||
|
+ return niaddSemaphore.withPermit {
|
||||||
|
+ fetchNiaddPageByRanges(pageUrl)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
val request = PageLoader.createPageRequest(pageUrl, page.source)
|
||||||
|
return imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
@@ -89,6 +105,99 @@ class MangaPageFetcher(
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+ private suspend fun fetchNiaddPageByRanges(pageUrl: String): FetchResult {
|
||||||
|
+ val buffer = Buffer()
|
||||||
|
+ var mimeType: MimeType? = null
|
||||||
|
+ var offset = 0L
|
||||||
|
+ var totalBytes = -1L
|
||||||
|
+
|
||||||
|
+ while (totalBytes < 0L || offset < totalBytes) {
|
||||||
|
+ val requestedEnd = if (totalBytes > 0L) {
|
||||||
|
+ minOf(offset + NIADD_RANGE_CHUNK_SIZE - 1L, totalBytes - 1L)
|
||||||
|
+ } else {
|
||||||
|
+ offset + NIADD_RANGE_CHUNK_SIZE - 1L
|
||||||
|
+ }
|
||||||
|
+ val request = PageLoader.createPageRequest(pageUrl, page.source)
|
||||||
|
+ .newBuilder()
|
||||||
|
+ .header("Range", "bytes=$offset-$requestedEnd")
|
||||||
|
+ .header("Connection", "close")
|
||||||
|
+ .build()
|
||||||
|
+
|
||||||
|
+ niaddOkHttpClient.newCall(request).execute().use { response ->
|
||||||
|
+ check(response.code == 206) {
|
||||||
|
+ val location = response.header("Location")
|
||||||
|
+ "Niadd CDN range request returned HTTP ${response.code}" +
|
||||||
|
+ if (location.isNullOrBlank()) {
|
||||||
|
+ " for $pageUrl"
|
||||||
|
+ } else {
|
||||||
|
+ ", Location=$location for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val contentType = response.header("Content-Type")
|
||||||
|
+ ?.substringBefore(';')
|
||||||
|
+ ?.trim()
|
||||||
|
+ .orEmpty()
|
||||||
|
+ check(contentType.startsWith("image/", ignoreCase = true)) {
|
||||||
|
+ "Niadd CDN returned non-image Content-Type '$contentType' for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ if (pageUrl.substringBefore('?').endsWith(".webp", ignoreCase = true)) {
|
||||||
|
+ check(contentType.equals("image/webp", ignoreCase = true)) {
|
||||||
|
+ "Niadd CDN replaced WebP with '$contentType' for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val contentRange = response.header("Content-Range")
|
||||||
|
+ ?: error("Niadd CDN response has no Content-Range for $pageUrl")
|
||||||
|
+ val rangeMatch = NIADD_CONTENT_RANGE_REGEX.matchEntire(contentRange)
|
||||||
|
+ ?: error("Invalid Niadd Content-Range: $contentRange")
|
||||||
|
+ val responseStart = rangeMatch.groupValues[1].toLong()
|
||||||
|
+ val responseEnd = rangeMatch.groupValues[2].toLong()
|
||||||
|
+ val responseTotal = rangeMatch.groupValues[3].toLong()
|
||||||
|
+ check(responseTotal > 0L && responseStart == offset) {
|
||||||
|
+ "Unexpected Niadd Content-Range '$contentRange' for offset $offset ($pageUrl)"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if (totalBytes < 0L) {
|
||||||
|
+ totalBytes = responseTotal
|
||||||
|
+ } else {
|
||||||
|
+ check(totalBytes == responseTotal) {
|
||||||
|
+ "Niadd CDN changed content size from $totalBytes to $responseTotal for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val expectedEnd = minOf(requestedEnd, totalBytes - 1L)
|
||||||
|
+ check(responseEnd == expectedEnd) {
|
||||||
|
+ "Unexpected Niadd range end: expected $expectedEnd, got $responseEnd " +
|
||||||
|
+ "($contentRange, $pageUrl)"
|
||||||
|
+ }
|
||||||
|
+ if (mimeType == null) {
|
||||||
|
+ mimeType = response.mimeType?.toMimeTypeOrNull()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val expectedBytes = expectedEnd - offset + 1L
|
||||||
|
+ val receivedBytes = buffer.writeAll(response.requireBody().source())
|
||||||
|
+ check(receivedBytes == expectedBytes) {
|
||||||
|
+ "Short Niadd range: expected $expectedBytes bytes, got $receivedBytes " +
|
||||||
|
+ "for bytes=$offset-$expectedEnd ($pageUrl)"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ offset += receivedBytes
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ check(totalBytes > 0L && buffer.size == totalBytes) {
|
||||||
|
+ "Incomplete Niadd image: expected $totalBytes bytes, got ${buffer.size} for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val file = pagesCache.set(pageUrl, buffer, mimeType)
|
||||||
|
+ return SourceFetchResult(
|
||||||
|
+ source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
|
||||||
|
+ mimeType = mimeType?.toString(),
|
||||||
|
+ dataSource = DataSource.NETWORK,
|
||||||
|
+ )
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
private fun Response.toNetworkResponse() = NetworkResponse(
|
||||||
|
code = code,
|
||||||
|
requestMillis = sentRequestAtMillis,
|
||||||
|
@@ -106,6 +215,12 @@ class MangaPageFetcher(
|
||||||
|
return headers.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
+ companion object {
|
||||||
|
+ private const val NIADD_RANGE_CHUNK_SIZE = 16L * 1024L
|
||||||
|
+ private val NIADD_CONTENT_RANGE_REGEX = Regex("""bytes (\d+)-(\d+)/(\d+)""")
|
||||||
|
+ private val niaddSemaphore = Semaphore(1)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
class Factory @Inject constructor(
|
||||||
|
@MangaHttpClient private val okHttpClient: OkHttpClient,
|
||||||
|
@PageCache private val pagesCache: LocalStorageCache,
|
||||||
|
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt
|
||||||
|
index 8227b2a..742341e 100644
|
||||||
|
--- a/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt
|
||||||
|
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/download/ui/worker/DownloadWorker.kt
|
||||||
|
@@ -91,6 +91,7 @@ import okio.use
|
||||||
|
import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
@@ -125,6 +126,10 @@ class DownloadWorker @AssistedInject constructor(
|
||||||
|
private val notificationFactory = notificationFactoryFactory.create(uuid = params.id, isSilent = task.isSilent)
|
||||||
|
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
private val bandwidthLimiter = BandwidthLimiter { settings.downloadBandwidthLimit * 1024 }
|
||||||
|
+ private val niaddOkHttp = okHttp.newBuilder()
|
||||||
|
+ .followRedirects(false)
|
||||||
|
+ .followSslRedirects(false)
|
||||||
|
+ .build()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastPublishedState: DownloadState? = null
|
||||||
|
@@ -427,6 +432,13 @@ class DownloadWorker @AssistedInject constructor(
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
+ if (isNiaddRangeDownload(url, source)) {
|
||||||
|
+ slowdownDispatcher.delay(source)
|
||||||
|
+ return NIADD_DOWNLOAD_SEMAPHORE.withPermit {
|
||||||
|
+ downloadNiaddFileByRanges(url, destination, source)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
val request = PageLoader.createPageRequest(url, source)
|
||||||
|
slowdownDispatcher.delay(source)
|
||||||
|
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
|
||||||
|
@@ -450,6 +462,117 @@ class DownloadWorker @AssistedInject constructor(
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+ private fun isNiaddRangeDownload(url: String, source: MangaSource): Boolean {
|
||||||
|
+ return source == MangaParserSource.NINEMANGA_RU &&
|
||||||
|
+ url.substringBefore('?').endsWith(".webp", ignoreCase = true) &&
|
||||||
|
+ url.contains("acc=", ignoreCase = true) &&
|
||||||
|
+ url.contains("exp=", ignoreCase = true)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ private suspend fun downloadNiaddFileByRanges(
|
||||||
|
+ url: String,
|
||||||
|
+ destination: File,
|
||||||
|
+ source: MangaSource,
|
||||||
|
+ ): File {
|
||||||
|
+ Log.d("DownloadWorker", "Using Niadd range download for $url")
|
||||||
|
+ var file: File? = null
|
||||||
|
+ var offset = 0L
|
||||||
|
+ var totalBytes = -1L
|
||||||
|
+
|
||||||
|
+ try {
|
||||||
|
+ while (totalBytes < 0L || offset < totalBytes) {
|
||||||
|
+ val requestedEnd = if (totalBytes > 0L) {
|
||||||
|
+ minOf(offset + NIADD_RANGE_CHUNK_SIZE - 1L, totalBytes - 1L)
|
||||||
|
+ } else {
|
||||||
|
+ offset + NIADD_RANGE_CHUNK_SIZE - 1L
|
||||||
|
+ }
|
||||||
|
+ val request = PageLoader.createPageRequest(url, source)
|
||||||
|
+ .newBuilder()
|
||||||
|
+ .header("Range", "bytes=$offset-$requestedEnd")
|
||||||
|
+ .header("Connection", "close")
|
||||||
|
+ .build()
|
||||||
|
+
|
||||||
|
+ niaddOkHttp.newCall(request).execute().use { response ->
|
||||||
|
+ check(response.code == 206) {
|
||||||
|
+ val location = response.header("Location")
|
||||||
|
+ "Niadd CDN range request returned HTTP ${response.code}" +
|
||||||
|
+ if (location.isNullOrBlank()) {
|
||||||
|
+ " for $url"
|
||||||
|
+ } else {
|
||||||
|
+ ", Location=$location for $url"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val contentType = response.header("Content-Type")
|
||||||
|
+ ?.substringBefore(';')
|
||||||
|
+ ?.trim()
|
||||||
|
+ .orEmpty()
|
||||||
|
+ check(contentType.equals("image/webp", ignoreCase = true)) {
|
||||||
|
+ "Niadd CDN returned '$contentType' instead of image/webp for $url"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val contentRange = response.header("Content-Range")
|
||||||
|
+ ?: error("Niadd CDN response has no Content-Range for $url")
|
||||||
|
+ val rangeMatch = NIADD_CONTENT_RANGE_REGEX.matchEntire(contentRange)
|
||||||
|
+ ?: error("Invalid Niadd Content-Range: $contentRange")
|
||||||
|
+ val responseStart = rangeMatch.groupValues[1].toLong()
|
||||||
|
+ val responseEnd = rangeMatch.groupValues[2].toLong()
|
||||||
|
+ val responseTotal = rangeMatch.groupValues[3].toLong()
|
||||||
|
+ check(responseTotal > 0L && responseStart == offset) {
|
||||||
|
+ "Unexpected Niadd Content-Range '$contentRange' for offset $offset ($url)"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if (totalBytes < 0L) {
|
||||||
|
+ totalBytes = responseTotal
|
||||||
|
+ } else {
|
||||||
|
+ check(totalBytes == responseTotal) {
|
||||||
|
+ "Niadd CDN changed content size from $totalBytes to $responseTotal for $url"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val expectedEnd = minOf(requestedEnd, totalBytes - 1L)
|
||||||
|
+ check(responseEnd == expectedEnd) {
|
||||||
|
+ "Unexpected Niadd range end: expected $expectedEnd, got $responseEnd " +
|
||||||
|
+ "($contentRange, $url)"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val body = response.requireBody()
|
||||||
|
+ if (file == null) {
|
||||||
|
+ file = destination.createTempFile(
|
||||||
|
+ ext = MimeTypes.getExtension(body.contentType()?.toMimeType()),
|
||||||
|
+ )
|
||||||
|
+ }
|
||||||
|
+ val outputFile = checkNotNull(file)
|
||||||
|
+ check(outputFile.length() == offset) {
|
||||||
|
+ "Niadd temporary file offset mismatch: expected $offset, got ${outputFile.length()} for $url"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val expectedBytes = expectedEnd - offset + 1L
|
||||||
|
+ outputFile.sink(append = offset > 0L).buffer().use { sink ->
|
||||||
|
+ sink.writeAllCancellable(body.source(), bandwidthLimiter)
|
||||||
|
+ }
|
||||||
|
+ val newLength = outputFile.length()
|
||||||
|
+ val receivedBytes = newLength - offset
|
||||||
|
+ check(receivedBytes == expectedBytes) {
|
||||||
|
+ "Short Niadd range: expected $expectedBytes bytes, got $receivedBytes " +
|
||||||
|
+ "for bytes=$offset-$expectedEnd ($url)"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ offset = newLength
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val result = checkNotNull(file)
|
||||||
|
+ check(totalBytes > 0L && result.length() == totalBytes) {
|
||||||
|
+ "Incomplete Niadd image: expected $totalBytes bytes, got ${result.length()} for $url"
|
||||||
|
+ }
|
||||||
|
+ return result
|
||||||
|
+ } catch (e: Exception) {
|
||||||
|
+ file?.delete()
|
||||||
|
+ throw e
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
private fun File.createTempFile(ext: String?) = File(
|
||||||
|
this,
|
||||||
|
buildString {
|
||||||
|
@@ -633,6 +756,9 @@ class DownloadWorker @AssistedInject constructor(
|
||||||
|
|
||||||
|
const val MAX_FAILSAFE_ATTEMPTS = 2
|
||||||
|
const val MAX_PAGES_PARALLELISM = 4
|
||||||
|
+ private const val NIADD_RANGE_CHUNK_SIZE = 16L * 1024L
|
||||||
|
+ private val NIADD_CONTENT_RANGE_REGEX = Regex("""bytes (\d+)-(\d+)/(\d+)""")
|
||||||
|
+ private val NIADD_DOWNLOAD_SEMAPHORE = Semaphore(1)
|
||||||
|
const val DOWNLOAD_ERROR_DELAY = 2_000L
|
||||||
|
const val MAX_RETRY_DELAY = 7_200_000L // 2 hours
|
||||||
|
const val TAG = "download"
|
||||||
|
diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt
|
||||||
|
index 18d203d..02b1653 100644
|
||||||
|
--- a/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt
|
||||||
|
+++ b/app/src/main/kotlin/io/github/landwarderer/futon/reader/domain/PageLoader.kt
|
||||||
|
@@ -30,6 +30,7 @@ import io.github.landwarderer.futon.core.prefs.AppSettings
|
||||||
|
import io.github.landwarderer.futon.core.ui.image.TrimTransformation
|
||||||
|
import io.github.landwarderer.futon.core.util.FileSize
|
||||||
|
import io.github.landwarderer.futon.core.util.MimeTypes
|
||||||
|
+import io.github.landwarderer.futon.core.util.ext.MimeType
|
||||||
|
import io.github.landwarderer.futon.core.util.ext.URI_SCHEME_ZIP
|
||||||
|
import io.github.landwarderer.futon.core.util.ext.cancelChildrenAndJoin
|
||||||
|
import io.github.landwarderer.futon.core.util.ext.compressToPNG
|
||||||
|
@@ -52,6 +53,7 @@ import io.github.landwarderer.futon.download.ui.worker.DownloadSlowdownDispatche
|
||||||
|
import io.github.landwarderer.futon.local.data.LocalStorageCache
|
||||||
|
import io.github.landwarderer.futon.local.data.PageCache
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.requireBody
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
@@ -70,6 +72,9 @@ import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
+import okio.buffer
|
||||||
|
+import okio.sink
|
||||||
|
+import okio.source
|
||||||
|
import okio.use
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import java.io.File
|
||||||
|
@@ -97,6 +102,11 @@ class PageLoader @Inject constructor(
|
||||||
|
|
||||||
|
private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
|
||||||
|
private val semaphore = Semaphore(3)
|
||||||
|
+ private val niaddSemaphore = Semaphore(1)
|
||||||
|
+ private val niaddOkHttp = okHttp.newBuilder()
|
||||||
|
+ .followRedirects(false)
|
||||||
|
+ .followSslRedirects(false)
|
||||||
|
+ .build()
|
||||||
|
private val convertLock = Mutex()
|
||||||
|
private val prefetchLock = Mutex()
|
||||||
|
|
||||||
|
@@ -303,13 +313,126 @@ class PageLoader @Inject constructor(
|
||||||
|
if (isPrefetch) {
|
||||||
|
downloadSlowdownDispatcher.delay(page.source)
|
||||||
|
}
|
||||||
|
- val request = createPageRequest(pageUrl, page.source)
|
||||||
|
- imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
|
||||||
|
- response.requireBody().withProgress(progress).use {
|
||||||
|
- cache.set(pageUrl, it.source(), it.contentType()?.toMimeType())
|
||||||
|
+ if (page.source == MangaParserSource.NINEMANGA_RU) {
|
||||||
|
+ niaddSemaphore.withPermit {
|
||||||
|
+ downloadNiaddPageByRanges(pageUrl, page.source, progress)
|
||||||
|
}
|
||||||
|
- }.toUri()
|
||||||
|
+ } else {
|
||||||
|
+ val request = createPageRequest(pageUrl, page.source)
|
||||||
|
+ imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
|
||||||
|
+ response.requireBody().withProgress(progress).use {
|
||||||
|
+ cache.set(pageUrl, it.source(), it.contentType()?.toMimeType())
|
||||||
|
+ }
|
||||||
|
+ }.toUri()
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ private suspend fun downloadNiaddPageByRanges(
|
||||||
|
+ pageUrl: String,
|
||||||
|
+ source: MangaSource,
|
||||||
|
+ progress: MutableStateFlow<Float>,
|
||||||
|
+ ): Uri {
|
||||||
|
+ val tempFile = File.createTempFile(
|
||||||
|
+ "niadd-page-",
|
||||||
|
+ ".part",
|
||||||
|
+ context.cacheDir,
|
||||||
|
+ )
|
||||||
|
+ var mimeType: MimeType? = null
|
||||||
|
+ try {
|
||||||
|
+ var offset = 0L
|
||||||
|
+ var totalBytes = -1L
|
||||||
|
+
|
||||||
|
+ tempFile.sink(append = false).buffer().use { sink ->
|
||||||
|
+ while (totalBytes < 0L || offset < totalBytes) {
|
||||||
|
+ val requestedEnd = if (totalBytes > 0L) {
|
||||||
|
+ minOf(offset + NIADD_RANGE_CHUNK_SIZE - 1L, totalBytes - 1L)
|
||||||
|
+ } else {
|
||||||
|
+ offset + NIADD_RANGE_CHUNK_SIZE - 1L
|
||||||
|
+ }
|
||||||
|
+ val request = createPageRequest(pageUrl, source)
|
||||||
|
+ .newBuilder()
|
||||||
|
+ .header("Range", "bytes=$offset-$requestedEnd")
|
||||||
|
+ .header("Connection", "close")
|
||||||
|
+ .build()
|
||||||
|
+
|
||||||
|
+ niaddOkHttp.newCall(request).execute().use { response ->
|
||||||
|
+ check(response.code == 206) {
|
||||||
|
+ val location = response.header("Location")
|
||||||
|
+ "Niadd CDN range request returned HTTP ${response.code}" +
|
||||||
|
+ if (location.isNullOrBlank()) {
|
||||||
|
+ " for $pageUrl"
|
||||||
|
+ } else {
|
||||||
|
+ ", Location=$location for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val contentType = response.header("Content-Type")
|
||||||
|
+ ?.substringBefore(';')
|
||||||
|
+ ?.trim()
|
||||||
|
+ .orEmpty()
|
||||||
|
+ check(contentType.startsWith("image/", ignoreCase = true)) {
|
||||||
|
+ "Niadd CDN returned non-image Content-Type '$contentType' for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ if (pageUrl.substringBefore('?').endsWith(".webp", ignoreCase = true)) {
|
||||||
|
+ check(contentType.equals("image/webp", ignoreCase = true)) {
|
||||||
|
+ "Niadd CDN replaced WebP with '$contentType' for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val contentRange = response.header("Content-Range")
|
||||||
|
+ ?: error("Niadd CDN response has no Content-Range for $pageUrl")
|
||||||
|
+ val rangeMatch = NIADD_CONTENT_RANGE_REGEX.matchEntire(contentRange)
|
||||||
|
+ ?: error("Invalid Niadd Content-Range: $contentRange")
|
||||||
|
+ val responseStart = rangeMatch.groupValues[1].toLong()
|
||||||
|
+ val responseEnd = rangeMatch.groupValues[2].toLong()
|
||||||
|
+ val responseTotal = rangeMatch.groupValues[3].toLong()
|
||||||
|
+ check(responseTotal > 0L && responseStart == offset) {
|
||||||
|
+ "Unexpected Niadd Content-Range '$contentRange' for offset $offset ($pageUrl)"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if (totalBytes < 0L) {
|
||||||
|
+ totalBytes = responseTotal
|
||||||
|
+ } else {
|
||||||
|
+ check(totalBytes == responseTotal) {
|
||||||
|
+ "Niadd CDN changed content size from $totalBytes to $responseTotal for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val expectedEnd = minOf(requestedEnd, totalBytes - 1L)
|
||||||
|
+ check(responseEnd == expectedEnd) {
|
||||||
|
+ "Unexpected Niadd range end: expected $expectedEnd, got $responseEnd " +
|
||||||
|
+ "($contentRange, $pageUrl)"
|
||||||
|
+ }
|
||||||
|
+ val body = response.requireBody()
|
||||||
|
+ if (mimeType == null) {
|
||||||
|
+ mimeType = body.contentType()?.toMimeType()
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ val expectedBytes = expectedEnd - offset + 1L
|
||||||
|
+ val receivedBytes = sink.writeAll(body.source())
|
||||||
|
+ check(receivedBytes == expectedBytes) {
|
||||||
|
+ "Short Niadd range: expected $expectedBytes bytes, got $receivedBytes " +
|
||||||
|
+ "for bytes=$offset-$expectedEnd ($pageUrl)"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ offset += receivedBytes
|
||||||
|
+ progress.value = (offset.toFloat() / totalBytes.toFloat())
|
||||||
|
+ .coerceIn(0f, 1f)
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ check(totalBytes > 0L && tempFile.length() == totalBytes) {
|
||||||
|
+ "Incomplete Niadd image: expected $totalBytes bytes, got ${tempFile.length()} for $pageUrl"
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ return tempFile.source().use { sourceStream ->
|
||||||
|
+ cache.set(pageUrl, sourceStream, mimeType).toUri()
|
||||||
|
}
|
||||||
|
+ } finally {
|
||||||
|
+ tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -340,6 +463,8 @@ class PageLoader @Inject constructor(
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val PROGRESS_UNDEFINED = -1f
|
||||||
|
+ private const val NIADD_RANGE_CHUNK_SIZE = 16L * 1024L
|
||||||
|
+ private val NIADD_CONTENT_RANGE_REGEX = Regex("""bytes (\d+)-(\d+)/(\d+)""")
|
||||||
|
private const val PREFETCH_LIMIT_DEFAULT = 6
|
||||||
|
private const val PREFETCH_MIN_RAM_MB = 80L
|
||||||
|
|
||||||
@@ -425,38 +425,6 @@ replace_exact(
|
|||||||
|
|
||||||
val dateText = anchorDate ?: rowDate
|
val dateText = anchorDate ?: rowDate
|
||||||
|
|
||||||
val metadataText = when {
|
|
||||||
anchorDate != null -> anchorText
|
|
||||||
rowDate != null -> rowText
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val textBeforeDate = if (dateText != null) {
|
|
||||||
metadataText
|
|
||||||
.substringBefore(dateText)
|
|
||||||
.trim()
|
|
||||||
} else {
|
|
||||||
metadataText
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Niadd exposes the page count immediately before
|
|
||||||
* the chapter date, for example:
|
|
||||||
*
|
|
||||||
* Падение русала 1 - 42 41 Aug 02, 2024
|
|
||||||
* ^^
|
|
||||||
*
|
|
||||||
* Store that number in a URL fragment. Fragments
|
|
||||||
* are local metadata and are not sent to the server.
|
|
||||||
*/
|
|
||||||
val pageCount = Regex(
|
|
||||||
"""\\s+(\\d+)\\s*$"""
|
|
||||||
)
|
|
||||||
.find(textBeforeDate)
|
|
||||||
?.groupValues
|
|
||||||
?.getOrNull(1)
|
|
||||||
?.toIntOrNull()
|
|
||||||
|
|
||||||
val chapterTitle = if (
|
val chapterTitle = if (
|
||||||
dateText != null &&
|
dateText != null &&
|
||||||
rawTitle.contains(dateText)
|
rawTitle.contains(dateText)
|
||||||
@@ -472,15 +440,6 @@ replace_exact(
|
|||||||
rawTitle
|
rawTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
val chapterUrl = if (
|
|
||||||
pageCount != null &&
|
|
||||||
pageCount > 0
|
|
||||||
) {
|
|
||||||
"$href#niadd-pages=$pageCount"
|
|
||||||
} else {
|
|
||||||
href
|
|
||||||
}
|
|
||||||
|
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
title = chapterTitle.takeIf {
|
title = chapterTitle.takeIf {
|
||||||
@@ -488,7 +447,7 @@ replace_exact(
|
|||||||
},
|
},
|
||||||
number = i + 1f,
|
number = i + 1f,
|
||||||
volume = 0,
|
volume = 0,
|
||||||
url = chapterUrl,
|
url = href,
|
||||||
uploadDate = parseChapterDateByLang(
|
uploadDate = parseChapterDateByLang(
|
||||||
dateText.orEmpty()
|
dateText.orEmpty()
|
||||||
),
|
),
|
||||||
@@ -641,42 +600,78 @@ replace_exact(
|
|||||||
''' override suspend fun getPages(
|
''' override suspend fun getPages(
|
||||||
chapter: MangaChapter,
|
chapter: MangaChapter,
|
||||||
): List<MangaPage> {
|
): List<MangaPage> {
|
||||||
if (domain.endsWith("niadd.com")) {
|
val chapterUrl = chapter.url
|
||||||
val originalUrl = chapter.url
|
|
||||||
.substringBefore("#")
|
.substringBefore("#")
|
||||||
.substringBefore("?")
|
|
||||||
|
|
||||||
val totalPages = chapter.url
|
val doc = webClient
|
||||||
.substringAfter(
|
.httpGet(
|
||||||
"#niadd-pages=",
|
chapterUrl.toAbsoluteUrl(domain)
|
||||||
"",
|
|
||||||
)
|
)
|
||||||
.substringBefore("&")
|
.parseHtml()
|
||||||
.toIntOrNull()
|
|
||||||
?.takeIf { it > 0 }
|
if (domain.endsWith("niadd.com")) {
|
||||||
|
val pageLabelRegex = Regex(
|
||||||
|
"""^\\s*(\\d+)\\s*/\\s*(\\d+)\\s*$"""
|
||||||
|
)
|
||||||
|
|
||||||
|
val pageOptions = doc
|
||||||
|
.select("option[value]")
|
||||||
|
.mapNotNull { option ->
|
||||||
|
val match = pageLabelRegex.matchEntire(
|
||||||
|
option.text().trim()
|
||||||
|
) ?: return@mapNotNull null
|
||||||
|
|
||||||
|
val pageNumber = match
|
||||||
|
.groupValues
|
||||||
|
.getOrNull(1)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: return@mapNotNull null
|
||||||
|
|
||||||
|
val totalPages = match
|
||||||
|
.groupValues
|
||||||
|
.getOrNull(2)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: return@mapNotNull null
|
||||||
|
|
||||||
|
val url = option.attr("value").trim()
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
Triple(pageNumber, totalPages, url)
|
||||||
|
}
|
||||||
|
.distinctBy { (pageNumber, _, _) ->
|
||||||
|
pageNumber
|
||||||
|
}
|
||||||
|
.sortedBy { (pageNumber, _, _) ->
|
||||||
|
pageNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
val totalPages = pageOptions
|
||||||
|
.firstOrNull()
|
||||||
|
?.second
|
||||||
?: error(
|
?: error(
|
||||||
"Niadd page count is missing for ${chapter.url}. " +
|
"Cannot find Niadd page selector for $chapterUrl"
|
||||||
"Refresh manga details and try again."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val normalizedUrl = originalUrl
|
check(
|
||||||
.removeSuffix("/")
|
pageOptions.all { (_, total, _) ->
|
||||||
|
total == totalPages
|
||||||
val baseUrl = if (
|
}
|
||||||
normalizedUrl.endsWith(".html")
|
|
||||||
) {
|
) {
|
||||||
normalizedUrl.removeSuffix(".html")
|
"Inconsistent Niadd page count for $chapterUrl"
|
||||||
} else {
|
|
||||||
normalizedUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (1..totalPages).map { pageNumber ->
|
check(
|
||||||
val url = if (pageNumber == 1) {
|
pageOptions.size == totalPages &&
|
||||||
originalUrl
|
pageOptions.map { it.first } ==
|
||||||
} else {
|
(1..totalPages).toList()
|
||||||
"$baseUrl-$pageNumber.html"
|
) {
|
||||||
|
"Incomplete Niadd page selector for $chapterUrl: " +
|
||||||
|
"found ${pageOptions.size} of $totalPages pages"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return pageOptions.map { (_, _, url) ->
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
@@ -686,12 +681,6 @@ replace_exact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val doc = webClient
|
|
||||||
.httpGet(
|
|
||||||
chapter.url.toAbsoluteUrl(domain)
|
|
||||||
)
|
|
||||||
.parseHtml()
|
|
||||||
|
|
||||||
return doc
|
return doc
|
||||||
.body()
|
.body()
|
||||||
.requireElementById("page")
|
.requireElementById("page")
|
||||||
@@ -767,27 +756,9 @@ replace_exact(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
// Do not fall back to an arbitrary <img>. Niadd may return a
|
||||||
* Fallback in case Niadd removes the "N of M"
|
// service/notice page with a perfectly valid static image, which
|
||||||
* image link but leaves the actual image element.
|
// would otherwise be cached as every manga page.
|
||||||
*/
|
|
||||||
val imageUrlRegex = Regex(
|
|
||||||
"""(?i)\\.(?:jpe?g|png|gif|webp|avif)(?:\\?|$)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
val image = root
|
|
||||||
.select("img[src]")
|
|
||||||
.firstOrNull { element ->
|
|
||||||
val src = element.attr("src")
|
|
||||||
|
|
||||||
imageUrlRegex.containsMatchIn(src) &&
|
|
||||||
!src.contains("logo", ignoreCase = true) &&
|
|
||||||
!src.contains("avatar", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (image != null) {
|
|
||||||
return image.attrAsAbsoluteUrl("src")
|
|
||||||
}
|
|
||||||
|
|
||||||
error(
|
error(
|
||||||
"Cannot find Niadd reader image for ${page.url}"
|
"Cannot find Niadd reader image for ${page.url}"
|
||||||
|
|||||||
Reference in new issue
Block a user