Extension API 1.6 (#2120)

* Non-Extension Index changes for 1.6

* Changelog

* Minor fixes

* Implement extension store

* Test build fix

* Docs

* Simplify fetching manga and chapters

* Use EMPTY JsonObject

* Update docs/Configuring-Suwayomi‐Server.md

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>

* Improve Fetch Extension Store

* Fixes

* Simplify deprecated isNsfw in SourceQuery

* Simplify ContentRating in Source.kt

* Simplify isNsfw in SourceType

* No magic numbers for ContentRating, improves safety for future versions of extension api

* Fix SearchTest

* Lint

* Lint

* Optimize imports and fix unchecked cast warning

* Proper extension store queries

* Optimize import fixes

* Add ContentRatingFilter

* Improve extension store sync

* fix: re-sync (#2121)

* Lint

* Add ExtenionStores to the fetchExtensions result since its possible for the stores to change.

* Use a single version of ContentRating

* Exclude ServerConfig.extensionStores from GraphQL

* Use syncDbToPrefs in ExtensionStoreMutation

* Optimize Imports

* Update server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>

* Remove replaceWith and add specific description for GQL APIs

* Include OkHttp ZSTD

* Update to latest Mihon extension lib

* Fix latest Mihon Extension Lib

* Lint

* Optimize imports

* Lint

* Review fixes

* Add a index to extesnion table store url

* Lint

---------

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>
This commit is contained in:
Mitchell Syer
2026-06-27 13:39:28 -04:00
committed by GitHub
parent c8f5d83e9c
commit 2d535b44d8
84 changed files with 2576 additions and 1007 deletions

View File

@@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.network
import okhttp3.Response
/**
* Exception that handles HTTP codes considered not successful by OkHttp.
* Use it to have a standardized error message in the app across the extensions.
*
* @see Response.isSuccessful
* @since tachiyomix 1.6
* @param code [Int] the HTTP status code
*/
class HttpException(
val code: Int,
) : IllegalStateException("HTTP error $code")

View File

@@ -2,26 +2,25 @@ package eu.kanade.tachiyomi.network
import android.content.Context
import app.cash.quickjs.QuickJs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import eu.kanade.tachiyomi.util.lang.withIOContext
/**
* Util for evaluating JavaScript in sources.
*/
@Suppress("UNUSED", "UNCHECKED_CAST")
class JavaScriptEngine(
@Suppress("UNUSED_PARAMETER") context: Context,
context: Context,
) {
/**
* Evaluate arbitrary JavaScript code and get the result as a primitive type
* (e.g., String, Int).
*
* @since extensions-lib 1.4
* @since tachiyomix 1.4
* @param script JavaScript to execute.
* @return Result of JavaScript code as a primitive type.
*/
@Suppress("UNUSED", "UNCHECKED_CAST")
suspend fun <T> evaluate(script: String): T =
withContext(Dispatchers.IO) {
withIOContext {
QuickJs.create().use {
it.evaluate(script) as T
}

View File

@@ -9,7 +9,6 @@ package eu.kanade.tachiyomi.network
import android.content.Context
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.brotli.BrotliInterceptor
import okhttp3.logging.HttpLoggingInterceptor
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import java.net.CookieHandler
@@ -84,8 +82,6 @@ class NetworkHelper(
),
).addInterceptor(UncaughtExceptionInterceptor())
.addInterceptor(UserAgentInterceptor(::defaultUserAgentProvider))
.addNetworkInterceptor(IgnoreGzipInterceptor())
.addNetworkInterceptor(BrotliInterceptor)
// if (preferences.verboseLogging().get()) {
val httpLoggingInterceptor =
@@ -128,5 +124,7 @@ class NetworkHelper(
// val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
val client by lazy { baseClientBuilder.build() }
@Deprecated("The regular client handles Cloudflare by default")
@Suppress("UNUSED")
val cloudflareClient by lazy { client }
}

View File

@@ -15,11 +15,14 @@ import rx.Observable
import rx.Producer
import rx.Subscription
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.resumeWithException
val jsonMime = "application/json; charset=utf-8".toMediaType()
@OptIn(ExperimentalAtomicApi::class)
@Deprecated("Use suspend APIs instead")
fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber.
@@ -27,9 +30,11 @@ fun Call.asObservable(): Observable<Response> {
// Wrap the call in a helper which handles both unsubscription and backpressure.
val requestArbiter =
object : AtomicBoolean(), Producer, Subscription {
object : Producer, Subscription {
val boolean = AtomicBoolean(false)
override fun request(n: Long) {
if (n == 0L || !compareAndSet(false, true)) return
if (n == 0L || !boolean.compareAndSet(expectedValue = false, newValue = true)) return
try {
val response = call.execute()
@@ -37,15 +42,15 @@ fun Call.asObservable(): Observable<Response> {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
} catch (e: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
subscriber.onError(e)
}
}
}
override fun unsubscribe() {
// call.cancel()
call.cancel()
}
override fun isUnsubscribed(): Boolean = call.isCanceled()
@@ -56,50 +61,50 @@ fun Call.asObservable(): Observable<Response> {
}
}
fun Call.asObservableSuccess(): Observable<Response> =
asObservable()
.doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code)
@Deprecated("Use suspend APIs instead")
fun Call.asObservableSuccess(): Observable<Response> {
@Suppress("DEPRECATION")
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code)
}
}
}
// Based on https://github.com/square/okhttp/blob/master/okhttp-coroutines/src/main/kotlin/okhttp3/coroutines/ExecuteAsync.kt
// and https://github.com/gildor/kotlin-coroutines-okhttp
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
try {
this.cancel()
} catch (_: Throwable) {
// ignore
}
}
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
return suspendCancellableCoroutine { continuation ->
val callback =
this.enqueue(
object : Callback {
override fun onResponse(
call: Call,
response: Response,
) {
continuation.resume(response) { _, resourceToClose, _ ->
response.body.close()
resourceToClose.close()
}
}
override fun onFailure(
call: Call,
e: IOException,
) {
// Don't bother with resuming the continuation if it is already cancelled.
if (continuation.isCancelled) return
val exception = IOException(e.message, e).apply { stackTrace = callStack }
continuation.resumeWithException(exception)
}
}
enqueue(callback)
continuation.invokeOnCancellation {
try {
cancel()
} catch (ex: Throwable) {
// Ignore cancel exception
}
}
override fun onResponse(
call: Call,
response: Response,
) {
continuation.resume(response) { _, value, _ ->
value.close()
}
}
},
)
}
}
@@ -109,7 +114,7 @@ suspend fun Call.await(): Response {
}
/**
* @since extensions-lib 1.5
* Similar to [await] but throws [HttpException] if [Response.isSuccessful] returns false
*/
suspend fun Call.awaitSuccess(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
@@ -150,7 +155,3 @@ fun <T> decodeFromJsonResponse(
response.body.source().use {
json.decodeFromBufferedSource(deserializer, it)
}
class HttpException(
val code: Int,
) : IllegalStateException("HTTP error $code")

View File

@@ -35,7 +35,11 @@ class ProgressResponseBody(
val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
progressListener.update(
totalBytesRead,
responseBody.contentLength(),
bytesRead == -1L,
)
return bytesRead
}
}

View File

@@ -6,6 +6,7 @@ import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.RequestBody
import java.util.concurrent.TimeUnit.MINUTES
@@ -18,13 +19,7 @@ fun GET(
url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request =
Request
.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
): Request = GET(url.toHttpUrl(), headers, cache)
/**
* @since extensions-lib 1.4

View File

@@ -1,21 +0,0 @@
package eu.kanade.tachiyomi.network.interceptor
import okhttp3.Interceptor
import okhttp3.Response
/**
* To use [okhttp3.brotli.BrotliInterceptor] as a network interceptor,
* add [IgnoreGzipInterceptor] right before it.
*
* This nullifies the transparent gzip of [okhttp3.internal.http.BridgeInterceptor]
* so gzip and Brotli are explicitly handled by the [okhttp3.brotli.BrotliInterceptor].
*/
class IgnoreGzipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (request.header("Accept-Encoding") == "gzip") {
request = request.newBuilder().removeHeader("Accept-Encoding").build()
}
return chain.proceed(request)
}
}

View File

@@ -2,6 +2,12 @@ package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.SMangaUpdate
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import rx.Observable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
@@ -11,68 +17,62 @@ interface CatalogueSource : Source {
*/
override val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Get a page with a list of manga.
*
* @since extensions-lib 1.5
* @param page the page number to retrieve.
*/
@Suppress("DEPRECATION")
suspend fun getPopularManga(page: Int): MangasPage = fetchPopularManga(page).awaitSingle()
override suspend fun getPopularManga(page: Int): MangasPage = fetchPopularManga(page).awaitSingle()
/**
* Get a page with a list of manga.
*
* @since extensions-lib 1.5
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
@Suppress("DEPRECATION")
suspend fun getSearchManga(
override suspend fun getLatestUpdates(page: Int): MangasPage = fetchLatestUpdates(page).awaitSingle()
@Suppress("DEPRECATION")
override suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): MangasPage = fetchSearchManga(page, query, filters).awaitSingle()
@Suppress("DEPRECATION")
override suspend fun getMangaUpdate(
manga: SManga,
chapters: List<SChapter>,
fetchDetails: Boolean,
fetchChapters: Boolean,
): SMangaUpdate =
supervisorScope {
val asyncManga = if (fetchDetails) async { fetchMangaDetails(manga).awaitSingle() } else null
val asyncChapters = if (fetchChapters) async { fetchChapterList(manga).awaitSingle() } else null
SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters)
}
@Suppress("DEPRECATION")
override suspend fun getPageList(chapter: SChapter): List<Page> = fetchPageList(chapter).awaitSingle()
/**
* Get a page with a list of latest manga updates.
* Returns an observable containing a page with a list of manga.
*
* @since extensions-lib 1.5
* @param page the page number to retrieve.
*/
@Suppress("DEPRECATION")
suspend fun getLatestUpdates(page: Int): MangasPage = fetchLatestUpdates(page).awaitSingle()
@Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga"))
fun fetchPopularManga(page: Int): Observable<MangasPage> = throw UnsupportedOperationException()
/**
* Returns the list of filters for the source.
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun getFilterList(): FilterList
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getPopularManga"),
)
fun fetchPopularManga(page: Int): Observable<MangasPage> = throw IllegalStateException("Not used")
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getSearchManga"),
)
@Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga"))
fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> = throw IllegalStateException("Not used")
): Observable<MangasPage> = throw UnsupportedOperationException()
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getLatestUpdates"),
)
fun fetchLatestUpdates(page: Int): Observable<MangasPage> = throw IllegalStateException("Not used")
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
@Deprecated("Use the suspend API instead", ReplaceWith("getLatestUpdates"))
fun fetchLatestUpdates(page: Int): Observable<MangasPage> = throw UnsupportedOperationException()
}

View File

@@ -0,0 +1,4 @@
package eu.kanade.tachiyomi.source
@Suppress("unused")
typealias PreferenceScreen = androidx.preference.PreferenceScreen

View File

@@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.SMangaUpdate
import rx.Observable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
/**
* A basic interface for creating a source. It could be an online source, a local source, etc.
@@ -24,53 +26,86 @@ interface Source {
get() = ""
/**
* Get the updated details for a manga.
*
* @since extensions-lib 1.5
* @param manga the manga to update.
* @return the updated manga.
* Whether the source has support for latest updates.
*/
@Suppress("DEPRECATION")
suspend fun getMangaDetails(manga: SManga): SManga = fetchMangaDetails(manga).awaitSingle()
val supportsLatest: Boolean
/**
* Get all the available chapters for a manga.
*
* @since extensions-lib 1.5
* @param manga the manga to update.
* @return the chapters for the manga.
* Returns the list of filters for the source.
*/
@Suppress("DEPRECATION")
suspend fun getChapterList(manga: SManga): List<SChapter> = fetchChapterList(manga).awaitSingle()
fun getFilterList(): FilterList = FilterList()
/**
* Get a page with a list of manga.
*
* @since tachiyomix 1.6
* @param page the page number to retrieve.
*/
suspend fun getPopularManga(page: Int): MangasPage
/**
* Get a page with a list of latest manga updates.
*
* @since tachiyomix 1.6
* @param page the page number to retrieve.
*/
suspend fun getLatestUpdates(page: Int): MangasPage
/**
* Get a page with a list of manga.
*
* @since tachiyomix 1.6
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): MangasPage
/**
* Fetches updated information for a manga.
*
* Depending on the provided flags or source availability, this may include
* updated manga metadata, available chapters, or both.
*
* If a value is not requested, the existing provided value can be returned as-is.
* The host app may apply any returned updates regardless of the flags,
* so care should be taken to only return accurate and intentional changes.
*
* @since tachiyomix 1.6
* @param manga The manga to fetch updates for.
* @param chapters Existing chapters of the manga
* @param fetchDetails Whether to fetch updated manga details.
* @param fetchChapters Whether to fetch available chapters.
*/
suspend fun getMangaUpdate(
manga: SManga,
chapters: List<SChapter>,
fetchDetails: Boolean,
fetchChapters: Boolean,
): SMangaUpdate
/**
* Get the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored.
*
* @since extensions-lib 1.5
* @since tachiyomix 1.6
* @param chapter the chapter.
* @return the pages for the chapter.
*/
@Suppress("DEPRECATION")
suspend fun getPageList(chapter: SChapter): List<Page> = fetchPageList(chapter).awaitSingle()
suspend fun getPageList(chapter: SChapter): List<Page>
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getMangaDetails"),
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
@Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate"))
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw UnsupportedOperationException()
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getChapterList"),
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
@Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate"))
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw UnsupportedOperationException()
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getPageList"),
)
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = throw IllegalStateException("Not used")
@Deprecated("Use the suspend API instead", ReplaceWith("getPageList"))
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = throw UnsupportedOperationException()
}
// fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)

View File

@@ -23,12 +23,15 @@ import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.SMangaUpdate
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.EpubFile
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
@@ -167,8 +170,20 @@ class LocalSource(
return MangasPage(mangas.toList(), false)
}
override suspend fun getMangaUpdate(
manga: SManga,
chapters: List<SChapter>,
fetchDetails: Boolean,
fetchChapters: Boolean,
): SMangaUpdate =
supervisorScope {
val asyncManga = if (fetchDetails) async { getMangaDetails(manga) } else null
val asyncChapters = if (fetchChapters) async { getChapterList(manga) } else null
SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters)
}
// Manga details related
override suspend fun getMangaDetails(manga: SManga): SManga =
private suspend fun getMangaDetails(manga: SManga): SManga =
withContext(Dispatchers.IO) {
coverManager.find(manga.url)?.let {
manga.thumbnail_url = it.absolutePath
@@ -289,7 +304,7 @@ class LocalSource(
}
// Chapters
override suspend fun getChapterList(manga: SManga): List<SChapter> =
private suspend fun getChapterList(manga: SManga): List<SChapter> =
fileSystem
.getFilesInMangaDirectory(manga.url)
// Only keep supported formats
@@ -467,7 +482,8 @@ class LocalSource(
it[versionName] = "1.2"
it[versionCode] = 0
it[lang] = LANG
it[isNsfw] = false
it[extensionLib] = "1.2"
it[contentWarning] = 0
it[isInstalled] = true
}
@@ -476,7 +492,6 @@ class LocalSource(
it[name] = NAME
it[lang] = LANG
it[extension] = extensionId
it[isNsfw] = false
}
}
}

View File

@@ -1,6 +1,22 @@
package eu.kanade.tachiyomi.source.model
data class MangasPage(
class MangasPage(
val mangas: List<SManga>,
val hasNextPage: Boolean,
)
) {
@Deprecated("MangasPage is now a regular class")
operator fun component1(): List<SManga> = mangas
@Deprecated("MangasPage is now a regular class")
operator fun component2(): Boolean = hasNextPage
@Deprecated("MangasPage is now a regular class")
fun copy(
mangas: List<SManga> = this.mangas,
hasNextPage: Boolean = this.hasNextPage,
): MangasPage =
MangasPage(
mangas = mangas,
hasNextPage = hasNextPage,
)
}

View File

@@ -27,12 +27,4 @@ open class Page(
-1
}
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}

View File

@@ -2,6 +2,7 @@
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import java.io.Serializable
interface SChapter : Serializable {
@@ -9,12 +10,25 @@ interface SChapter : Serializable {
var name: String
var date_upload: Long
var chapter_number: Float
var scanlator: String?
var date_upload: Long
/**
* Extra metadata associated with the chapter.
*
* The JSON object is not visible to users and intended for internal or source-specific
* purposes. Apps may define their own namespaced keys (e.g., `"mihon.*"`) for sources to populate.
*
* This allows apps to attach and ask for custom information without affecting the visible
* chapter data.
*
* @since tachiyomix 1.6
*/
var memo: JsonObject
fun copyFrom(other: SChapter) {
name = other.name
url = other.url

View File

@@ -2,14 +2,19 @@
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
class SChapterImpl : SChapter {
override lateinit var url: String
override lateinit var name: String
override var date_upload: Long = 0
override var chapter_number: Float = -1f
override var scanlator: String? = null
override var date_upload: Long = 0
override var memo: JsonObject = JsonObject.EMPTY
}

View File

@@ -2,6 +2,7 @@
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import java.io.Serializable
interface SManga : Serializable {
@@ -9,22 +10,58 @@ interface SManga : Serializable {
var title: String
var thumbnail_url: String?
var artist: String?
var author: String?
var status: Int
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var update_strategy: UpdateStrategy
var initialized: Boolean
/**
* Extra metadata associated with the manga.
*
* The JSON object is not visible to users and intended for internal or source-specific
* purposes. Apps may define their own namespaced keys (e.g., `"mihon.*"`) for sources to populate.
*
* This allows apps to attach and ask for custom information without affecting the visible
* manga data.
*
* @since tachiyomix 1.6
*/
var memo: JsonObject
fun getGenres(): List<String>? {
if (genre.isNullOrBlank()) return null
return genre
?.split(", ")
?.map { it.trim() }
?.filterNot { it.isBlank() }
?.distinct()
}
fun copy() =
create().also {
it.url = url
it.title = title
it.artist = artist
it.author = author
it.description = description
it.genre = genre
it.status = status
it.thumbnail_url = thumbnail_url
it.update_strategy = update_strategy
it.initialized = initialized
}
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
@@ -37,30 +74,3 @@ interface SManga : Serializable {
fun create(): SManga = SMangaImpl()
}
}
// fun SManga.toMangaInfo(): MangaInfo {
// return MangaInfo(
// key = this.url,
// title = this.title,
// artist = this.artist ?: "",
// author = this.author ?: "",
// description = this.description ?: "",
// genres = this.genre?.split(", ") ?: emptyList(),
// status = this.status,
// cover = this.thumbnail_url ?: ""
// )
// }
//
// fun MangaInfo.toSManga(): SManga {
// val mangaInfo = this
// return SManga.create().apply {
// url = mangaInfo.key
// title = mangaInfo.title
// artist = mangaInfo.artist
// author = mangaInfo.author
// description = mangaInfo.description
// genre = mangaInfo.genres.joinToString(", ")
// status = mangaInfo.status
// thumbnail_url = mangaInfo.cover
// }
// }

View File

@@ -2,24 +2,29 @@
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
class SMangaImpl : SManga {
override lateinit var url: String
override lateinit var title: String
override var thumbnail_url: String? = null
override var artist: String? = null
override var author: String? = null
override var status: Int = 0
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
override var initialized: Boolean = false
override var memo: JsonObject = JsonObject.EMPTY
}

View File

@@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.source.model
@Suppress("UNUSED")
class SMangaUpdate(
val manga: SManga,
val chapters: List<SChapter>,
)

View File

@@ -1,6 +1,22 @@
package eu.kanade.tachiyomi.source.model
/**
* Define the update strategy for a single [SManga].
* The strategy used will only take effect on the library update.
*
* @since extensions-lib 1.4
*/
enum class UpdateStrategy {
/**
* Series marked as always update will be included in the library
* update if they aren't excluded by additional restrictions.
*/
ALWAYS_UPDATE,
/**
* Series marked as only fetch once will be automatically skipped
* during library updates. Useful for cases where the series is previously
* known to be finished and have only a single chapter, for example.
*/
ONLY_FETCH_ONCE,
}

View File

@@ -25,7 +25,6 @@ import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
@Suppress("unused")
abstract class HttpSource : CatalogueSource {
/**
* Network service.
@@ -37,11 +36,24 @@ abstract class HttpSource : CatalogueSource {
*/
abstract val baseUrl: String
/**
* Returns the base (home) URL of the website as a string.
*
* This is typically the root address that serves as the main entry point
* to the site's content, such as "https://mihon.tech".
*
* This method is used in the browse screen to determine the URL
* opened when tapping "Open in WebView".
*
* @return The websites home page URL. Defaults to [baseUrl].
*/
open fun getHomeUrl(): String = baseUrl
/**
* Version id used to generate the source id. If the site completely changes and urls are
* incompatible, you may increase this value and it'll be considered as a new source.
*/
open val versionId = 1
open val versionId: Int = 1
/**
* ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
@@ -53,7 +65,7 @@ abstract class HttpSource : CatalogueSource {
*
* Note: the generated ID sets the sign bit to `0`.
*/
override val id by lazy { generateId() }
override val id: Long by lazy { generateId(name, lang, versionId) }
/**
* Headers used for requests.
@@ -63,10 +75,7 @@ abstract class HttpSource : CatalogueSource {
/**
* Default network client for doing requests.
*/
open val client: OkHttpClient
get() = network.client
private fun generateId(): Long = generateId("${name.lowercase()}/$lang/$versionId")
open val client: OkHttpClient get() = network.client
/**
* Generates a unique ID for the source based on the provided [name], [lang] and
@@ -91,10 +100,6 @@ abstract class HttpSource : CatalogueSource {
versionId: Int,
): Long {
val key = "${name.lowercase()}/$lang/$versionId"
return generateId(key)
}
private fun generateId(key: String): Long {
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
@@ -102,7 +107,7 @@ abstract class HttpSource : CatalogueSource {
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() =
protected open fun headersBuilder(): Headers.Builder =
Headers.Builder().apply {
add("User-Agent", network.defaultUserAgentProvider())
}
@@ -110,7 +115,7 @@ abstract class HttpSource : CatalogueSource {
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.uppercase()})"
override fun toString(): String = "$name (${lang.uppercase()})"
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
@@ -118,7 +123,8 @@ abstract class HttpSource : CatalogueSource {
*
* @param page the page number to retrieve.
*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga"))
override fun fetchPopularManga(page: Int): Observable<MangasPage> =
client
.newCall(popularMangaRequest(page))
@@ -132,14 +138,24 @@ abstract class HttpSource : CatalogueSource {
*
* @param page the page number to retrieve.
*/
protected abstract fun popularMangaRequest(page: Int): Request
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun popularMangaRequest(page: Int): Request = throw UnsupportedOperationException()
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
protected abstract fun popularMangaParse(response: Response): MangasPage
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun popularMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
@@ -149,22 +165,17 @@ abstract class HttpSource : CatalogueSource {
* @param query the search query.
* @param filters the list of filters to apply.
*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga"))
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> =
Observable
.defer {
try {
client.newCall(searchMangaRequest(page, query, filters)).asObservableSuccess()
} catch (e: NoClassDefFoundError) {
// RxJava doesn't handle Errors, which tends to happen during global searches
// if an old extension using non-existent classes is still around
throw RuntimeException(e)
}
}.map { response ->
client
.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchMangaParse(response)
}
@@ -175,25 +186,36 @@ abstract class HttpSource : CatalogueSource {
* @param query the search query.
* @param filters the list of filters to apply.
*/
protected abstract fun searchMangaRequest(
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun searchMangaRequest(
page: Int,
query: String,
filters: FilterList,
): Request
): Request = throw UnsupportedOperationException()
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
protected abstract fun searchMangaParse(response: Response): MangasPage
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates"))
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getLatestUpdates"))
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> =
client
.newCall(latestUpdatesRequest(page))
@@ -207,26 +229,33 @@ abstract class HttpSource : CatalogueSource {
*
* @param page the page number to retrieve.
*/
protected abstract fun latestUpdatesRequest(page: Int): Request
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
protected abstract fun latestUpdatesParse(response: Response): MangasPage
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
/**
* Get the updated details for a manga.
* Normally it's not needed to override this method.
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to update.
* @return the updated manga.
* @param manga the manga to be updated.
*/
@Suppress("DEPRECATION")
override suspend fun getMangaDetails(manga: SManga): SManga = fetchMangaDetails(manga).awaitSingle()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
@Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
client
.newCall(mangaDetailsRequest(manga))
@@ -241,6 +270,11 @@ abstract class HttpSource : CatalogueSource {
*
* @param manga the manga to be updated.
*/
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
open fun mangaDetailsRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers)
/**
@@ -248,37 +282,28 @@ abstract class HttpSource : CatalogueSource {
*
* @param response the response from the site.
*/
protected abstract fun mangaDetailsParse(response: Response): SManga
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException()
/**
* Get all the available chapters for a manga.
* Normally it's not needed to override this method.
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to update.
* @return the chapters for the manga.
* @throws LicensedMangaChaptersException if a manga is licensed and therefore no chapters are available.
* @param manga the manga to look for chapters.
*/
@Suppress("DEPRECATION")
override suspend fun getChapterList(manga: SManga): List<SChapter> {
if (manga.status == SManga.LICENSED) {
throw LicensedMangaChaptersException()
}
return fetchChapterList(manga).awaitSingle()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
@Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
if (manga.status != SManga.LICENSED) {
client
.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
chapterListParse(response)
}
} else {
Observable.error(LicensedMangaChaptersException())
}
client
.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
chapterListParse(response)
}
/**
* Returns the request for updating the chapter list. Override only if it's needed to override
@@ -286,6 +311,11 @@ abstract class HttpSource : CatalogueSource {
*
* @param manga the manga to look for chapters.
*/
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun chapterListRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers)
/**
@@ -293,19 +323,20 @@ abstract class HttpSource : CatalogueSource {
*
* @param response the response from the site.
*/
protected abstract fun chapterListParse(response: Response): List<SChapter>
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun chapterListParse(response: Response): List<SChapter> = throw UnsupportedOperationException()
/**
* Get the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored.
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter.
* @return the pages for the chapter.
* @param chapter the chapter whose page list has to be fetched.
*/
@Suppress("DEPRECATION")
override suspend fun getPageList(chapter: SChapter): List<Page> = fetchPageList(chapter).awaitSingle()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
@Deprecated("Use the suspend API instead", ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
client
.newCall(pageListRequest(chapter))
@@ -320,6 +351,11 @@ abstract class HttpSource : CatalogueSource {
*
* @param chapter the chapter whose page list has to be fetched.
*/
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun pageListRequest(chapter: SChapter): Request = GET(baseUrl + chapter.url, headers)
/**
@@ -327,31 +363,47 @@ abstract class HttpSource : CatalogueSource {
*
* @param response the response from the site.
*/
protected abstract fun pageListParse(response: Response): List<Page>
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun pageListParse(response: Response): List<Page> = throw UnsupportedOperationException()
/**
* Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception.
*
* @since extensions-lib 1.5
* @param page the page whose source image has to be fetched.
*/
@Suppress("DEPRECATION")
open suspend fun getImageUrl(page: Page): String = fetchImageUrl(page).awaitSingle()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
@Deprecated("Use the suspend API instead", ReplaceWith("getImageUrl"))
open fun fetchImageUrl(page: Page): Observable<String> =
client
.newCall(imageUrlRequest(page))
.asObservableSuccess()
.map { imageUrlParse(it) }
/**
* Returns the image url for the provided [page]. The function is only called if [Page.imageUrl] is null.
*
* @since tachiyomix 1.6
* @param page the page whose source image has to be fetched.
*/
@Suppress("DEPRECATION")
open suspend fun getImageUrl(page: Page): String = fetchImageUrl(page).awaitSingle()
/**
* Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun imageUrlRequest(page: Page): Request = GET(page.url, headers)
/**
@@ -359,16 +411,14 @@ abstract class HttpSource : CatalogueSource {
*
* @param response the response from the site.
*/
protected abstract fun imageUrlParse(response: Response): String
@Deprecated(
message =
"The helper functions are inherently limiting and hides the underlying implementation. " +
"Source developers should make their own implementation according to their needs.",
)
protected open fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
/**
* Returns the response of the source image.
* Typically does not need to be overridden.
*
* @since extensions-lib 1.5
* @param page the page whose source image has to be downloaded.
*/
open suspend fun getImage(page: Page): Response =
suspend fun getImage(page: Page): Response =
client
.newCachelessCallWithProgress(imageRequest(page), page)
.awaitSuccess()
@@ -387,6 +437,7 @@ abstract class HttpSource : CatalogueSource {
*
* @param url the full url to the chapter.
*/
@Suppress("Unused")
fun SChapter.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
@@ -397,6 +448,7 @@ abstract class HttpSource : CatalogueSource {
*
* @param url the full url to the manga.
*/
@Suppress("Unused")
fun SManga.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
@@ -417,7 +469,7 @@ abstract class HttpSource : CatalogueSource {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
} catch (_: URISyntaxException) {
orig
}
@@ -428,6 +480,7 @@ abstract class HttpSource : CatalogueSource {
* @param manga the manga
* @return url of the manga
*/
@Suppress("DEPRECATION")
open fun getMangaUrl(manga: SManga): String = mangaDetailsRequest(manga).url.toString()
/**
@@ -437,6 +490,7 @@ abstract class HttpSource : CatalogueSource {
* @param chapter the chapter
* @return url of the chapter
*/
@Suppress("DEPRECATION")
open fun getChapterUrl(chapter: SChapter): String = pageListRequest(chapter).url.toString()
/**
@@ -446,15 +500,9 @@ abstract class HttpSource : CatalogueSource {
* @param chapter the chapter to be added.
* @param manga the manga of the chapter.
*/
@Deprecated("All modifications should be done when constructing the chapter")
open fun prepareNewChapter(
chapter: SChapter,
manga: SManga,
) {}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
}
class LicensedMangaChaptersException : Exception("Licensed - No chapters to show")

View File

@@ -12,12 +12,20 @@ import org.jsoup.nodes.Element
/**
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
@Deprecated(
message =
"In most cases sources only require a subset of the methods from this class. " +
"Source developers should make their own implementation according to their needs.",
)
abstract class ParsedHttpSource : HttpSource() {
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
@Deprecated(
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
)
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
@@ -58,6 +66,9 @@ abstract class ParsedHttpSource : HttpSource() {
*
* @param response the response from the site.
*/
@Deprecated(
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
)
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
@@ -98,6 +109,9 @@ abstract class ParsedHttpSource : HttpSource() {
*
* @param response the response from the site.
*/
@Deprecated(
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
)
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
@@ -138,6 +152,9 @@ abstract class ParsedHttpSource : HttpSource() {
*
* @param response the response from the site.
*/
@Deprecated(
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
)
override fun mangaDetailsParse(response: Response): SManga = mangaDetailsParse(response.asJsoup())
/**
@@ -152,6 +169,9 @@ abstract class ParsedHttpSource : HttpSource() {
*
* @param response the response from the site.
*/
@Deprecated(
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
)
override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup()
return document.select(chapterListSelector()).map { chapterFromElement(it) }
@@ -174,6 +194,9 @@ abstract class ParsedHttpSource : HttpSource() {
*
* @param response the response from the site.
*/
@Deprecated(
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
)
override fun pageListParse(response: Response): List<Page> = pageListParse(response.asJsoup())
/**
@@ -188,6 +211,9 @@ abstract class ParsedHttpSource : HttpSource() {
*
* @param response the response from the site.
*/
@Deprecated(
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
)
override fun imageUrlParse(response: Response): String = imageUrlParse(response.asJsoup())
/**

View File

@@ -1,26 +1,44 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
/**
* A source that may handle opening an SManga for a given URI.
* A source that may handle opening an SManga or SChapter for a given URI.
*
* @since extensions-lib 1.5
*/
@Suppress("unused")
interface ResolvableSource : Source {
/**
* Whether this source may potentially handle the given URI.
* Returns what the given URI may open.
* Returns [UriType.Unknown] if the source is not able to resolve the URI.
*
* @since extensions-lib 1.5
*/
fun canResolveUri(uri: String): Boolean
fun getUriType(uri: String): UriType
/**
* Called if canHandleUri is true. Returns the corresponding SManga, if possible.
* Called if [getUriType] is [UriType.Manga].
* Returns the corresponding SManga, if possible.
*
* @since extensions-lib 1.5
*/
suspend fun getManga(uri: String): SManga?
/**
* Called if [getUriType] is [UriType.Chapter].
* Returns the corresponding SChapter, if possible.
*
* @since extensions-lib 1.5
*/
suspend fun getChapter(uri: String): SChapter?
}
sealed interface UriType {
data object Manga : UriType
data object Chapter : UriType
data object Unknown : UriType
}