Convert :source-api and :source-local to android library module (#3636)

- There's really no plan to support other platforms on this code base
- The "KMP" modules only had android target which is rather pointless
- `i18n` involves more work but given it has no actual code keeping it as is
This commit is contained in:
AntsyLich
2026-07-23 22:47:24 +06:00
committed by GitHub
parent 47d7c0f2ea
commit 9c3cc1ca5f
38 changed files with 54 additions and 128 deletions
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,78 @@
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 tachiyomi.core.common.util.lang.awaitSingle
interface CatalogueSource : Source {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang: String
@Suppress("DEPRECATION")
override suspend fun getPopularManga(page: Int): MangasPage = fetchPopularManga(page).awaitSingle()
@Suppress("DEPRECATION")
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()
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
*/
@Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga"))
fun fetchPopularManga(page: Int): Observable<MangasPage> = throw UnsupportedOperationException()
/**
* 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.
*/
@Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga"))
fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<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 suspend API instead", ReplaceWith("getLatestUpdates"))
fun fetchLatestUpdates(page: Int): Observable<MangasPage> = throw UnsupportedOperationException()
}
@@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.source
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
interface ConfigurableSource : Source {
/**
* Gets instance of [SharedPreferences] scoped to the specific source.
*
* @since extensions-lib 1.5
*/
fun getSourcePreferences(): SharedPreferences =
Injekt.get<Application>().getSharedPreferences(preferenceKey(), Context.MODE_PRIVATE)
fun setupPreferenceScreen(screen: PreferenceScreen)
}
fun ConfigurableSource.preferenceKey(): String = "source_$id"
// TODO: use getSourcePreferences once all extensions are on ext-lib 1.5
fun ConfigurableSource.sourcePreferences(): SharedPreferences =
Injekt.get<Application>().getSharedPreferences(preferenceKey(), Context.MODE_PRIVATE)
fun sourcePreferences(key: String): SharedPreferences =
Injekt.get<Application>().getSharedPreferences(key, Context.MODE_PRIVATE)
@@ -0,0 +1,106 @@
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
/**
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface Source {
/**
* ID for the source. Must be unique.
*/
val id: Long
/**
* Name of the source.
*/
val name: String
val lang: String
get() = ""
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns the list of filters for the source.
*/
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 tachiyomix 1.6
* @param chapter the chapter.
* @return the pages for the chapter.
*/
suspend fun getPageList(chapter: SChapter): List<Page>
@Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate"))
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw UnsupportedOperationException()
@Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate"))
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw UnsupportedOperationException()
@Deprecated("Use the suspend API instead", ReplaceWith("getPageList"))
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = throw UnsupportedOperationException()
}
@@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.source
/**
* A factory for creating sources at runtime.
*/
interface SourceFactory {
/**
* Create a new copy of the sources
* @return The created sources
*/
fun createSources(): List<Source>
}
@@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.source
/**
* A source that explicitly doesn't require traffic considerations.
*
* This typically applies for self-hosted sources.
*/
interface UnmeteredSource
@@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.source.model
sealed class Filter<T>(val name: String, var state: T) {
open class Header(name: String) : Filter<Any>(name, 0)
open class Separator(name: String = "") : Filter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(
name,
state,
)
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
fun isIgnored() = state == STATE_IGNORE
fun isIncluded() = state == STATE_INCLUDE
fun isExcluded() = state == STATE_EXCLUDE
companion object {
const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1
const val STATE_EXCLUDE = 2
}
}
abstract class Group<V>(name: String, state: List<V>) : Filter<List<V>>(name, state)
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
Filter<Sort.Selection?>(name, state) {
data class Selection(val index: Int, val ascending: Boolean)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Filter<*>) return false
return name == other.name && state == other.state
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + (state?.hashCode() ?: 0)
return result
}
}
@@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.source.model
import androidx.compose.runtime.Stable
@Stable
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
override fun equals(other: Any?): Boolean {
return false
}
override fun hashCode(): Int {
return list.hashCode()
}
}
@@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.source.model
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,
)
}
@@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.source.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
open class Page(
val index: Int,
val url: String = "",
var imageUrl: String? = null,
@Transient var uri: Uri? = null, // Deprecated but can't be deleted due to extensions
) : ProgressListener {
val number: Int
get() = index + 1
@Transient
private val _statusFlow = MutableStateFlow<State>(State.Queue)
@Transient
val statusFlow = _statusFlow.asStateFlow()
var status: State
get() = _statusFlow.value
set(value) {
_statusFlow.value = value
}
@Transient
private val _progressFlow = MutableStateFlow(0)
@Transient
val progressFlow = _progressFlow.asStateFlow()
var progress: Int
get() = _progressFlow.value
set(value) {
_progressFlow.value = value
}
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
sealed interface State {
data object Queue : State
data object LoadPage : State
data object DownloadImage : State
data object Ready : State
data class Error(val error: Throwable) : State
}
}
@@ -0,0 +1,47 @@
@file:Suppress("PropertyName")
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import java.io.Serializable
interface SChapter : Serializable {
var url: String
var name: String
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
date_upload = other.date_upload
chapter_number = other.chapter_number
scanlator = other.scanlator
memo = other.memo
}
companion object {
fun create(): SChapter {
return SChapterImpl()
}
}
}
@@ -0,0 +1,21 @@
@file:Suppress("PropertyName")
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import mihon.core.common.extensions.EMPTY
class SChapterImpl : SChapter {
override lateinit var url: String
override lateinit var name: String
override var chapter_number: Float = -1f
override var scanlator: String? = null
override var date_upload: Long = 0
override var memo: JsonObject = JsonObject.EMPTY
}
@@ -0,0 +1,75 @@
@file:Suppress("PropertyName")
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import java.io.Serializable
interface SManga : Serializable {
var url: String
var title: String
var thumbnail_url: String?
var artist: String?
var author: String?
var status: Int
var description: String?
var genre: 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
it.memo = memo
}
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
const val COMPLETED = 2
const val LICENSED = 3
const val PUBLISHING_FINISHED = 4
const val CANCELLED = 5
const val ON_HIATUS = 6
fun create(): SManga {
return SMangaImpl()
}
}
}
@@ -0,0 +1,31 @@
@file:Suppress("PropertyName")
package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject
import mihon.core.common.extensions.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 update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
override var initialized: Boolean = false
override var memo: JsonObject = JsonObject.EMPTY
}
@@ -0,0 +1,4 @@
package eu.kanade.tachiyomi.source.model
@Suppress("UNUSED")
class SMangaUpdate(val manga: SManga, val chapters: List<SChapter>)
@@ -0,0 +1,23 @@
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
*/
@Suppress("UNUSED")
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,
}
@@ -0,0 +1,498 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource
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 okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import tachiyomi.core.common.util.lang.awaitSingle
import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
abstract class HttpSource : CatalogueSource {
/**
* Network service.
*/
protected val network: NetworkHelper by injectLazy()
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
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: Int = 1
/**
* ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string `"${name.lowercase()}/$lang/$versionId"`.
*
* The ID is generated by the [generateId] function, which can be reused if needed
* to generate outdated IDs for cases where the source name or language needs to
* be changed but migrations can be avoided.
*
* Note: the generated ID sets the sign bit to `0`.
*/
override val id: Long by lazy { generateId(name, lang, versionId) }
/**
* Headers used for requests.
*/
val headers: Headers by lazy { headersBuilder().build() }
/**
* Default network client for doing requests.
*/
open val client: OkHttpClient get() = network.client
/**
* Generates a unique ID for the source based on the provided [name], [lang] and
* [versionId]. It will use the first 16 characters (64 bits) of the MD5 of the string
* `"${name.lowercase()}/$lang/$versionId"`.
*
* Note: the generated ID sets the sign bit to `0`.
*
* Can be used to generate outdated IDs, such as when the source name or language
* needs to be changed but migrations can be avoided.
*
* @since extensions-lib 1.5
* @param name [String] the name of the source
* @param lang [String] the language of the source
* @param versionId [Int] the version ID of the source
* @return a unique ID for the source
*/
@Suppress("MemberVisibilityCanBePrivate")
protected fun generateId(name: String, lang: String, versionId: Int): Long {
val key = "${name.lowercase()}/$lang/$versionId"
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
}
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder(): Headers.Builder = Headers.Builder().apply {
add("User-Agent", network.defaultUserAgentProvider())
}
/**
* Visible name of the source.
*/
override fun toString(): String = "$name (${lang.uppercase()})"
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
*/
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga"))
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
.asObservableSuccess()
.map { response ->
popularMangaParse(response)
}
}
/**
* Returns the request for the popular manga given the page.
*
* @param page the page number to retrieve.
*/
@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.
*/
@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
* override this method.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga"))
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchMangaParse(response)
}
}
/**
* Returns the request for the search manga given the page.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
@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 = throw UnsupportedOperationException()
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
@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.
*/
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getLatestUpdates"))
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess()
.map { response ->
latestUpdatesParse(response)
}
}
/**
* Returns the request for latest manga given the page.
*
* @param page the page number to retrieve.
*/
@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.
*/
@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()
/**
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to be updated.
*/
@Suppress("DEPRECATION")
@Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
/**
* Returns the request for the details of a manga. Override only if it's needed to change the
* url, send different headers or request method like POST.
*
* @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 {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
@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()
/**
* 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 look for chapters.
*/
@Suppress("DEPRECATION")
@Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return 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
* the url, send different headers or request method like POST.
*
* @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 {
return GET(baseUrl + manga.url, headers)
}
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
@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()
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
pageListParse(response)
}
}
/**
* Returns the request for getting the page list. Override only if it's needed to override the
* url, send different headers or request method like POST.
*
* @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 {
return GET(baseUrl + chapter.url, headers)
}
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
@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.
*
* @param page the page whose source image has to be fetched.
*/
@Suppress("DEPRECATION")
@Deprecated("Use the suspend API instead", ReplaceWith("getImageUrl"))
open fun fetchImageUrl(page: Page): Observable<String> {
return 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 {
return GET(page.url, headers)
}
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
@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()
suspend fun getImage(page: Page, existingSize: Long = 0L): Response {
return client.newCachelessCallWithProgress(imageRequest(page), page, existingSize)
.awaitSuccess()
}
/**
* Returns the request for getting 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
*/
protected open fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers)
}
/**
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the chapter.
*/
@Suppress("Unused")
fun SChapter.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the manga.
*/
@Suppress("Unused")
fun SManga.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Returns the url of the given string without the scheme and domain.
*
* @param orig the full url.
*/
private fun getUrlWithoutDomain(orig: String): String {
return try {
val uri = URI(orig.replace(" ", "%20"))
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (_: URISyntaxException) {
orig
}
}
/**
* Returns the url of the provided manga
*
* @since extensions-lib 1.4
* @param manga the manga
* @return url of the manga
*/
@Suppress("DEPRECATION")
open fun getMangaUrl(manga: SManga): String {
return mangaDetailsRequest(manga).url.toString()
}
/**
* Returns the url of the provided chapter
*
* @since extensions-lib 1.4
* @param chapter the chapter
* @return url of the chapter
*/
@Suppress("DEPRECATION")
open fun getChapterUrl(chapter: SChapter): String {
return pageListRequest(chapter).url.toString()
}
/**
* Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga].
*
* @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) {}
}
@@ -0,0 +1,225 @@
package eu.kanade.tachiyomi.source.online
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.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
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()
val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
protected abstract fun popularMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [popularMangaSelector].
*/
protected abstract fun popularMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun popularMangaNextPageSelector(): String?
/**
* 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 searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
protected abstract fun searchMangaSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [searchMangaSelector].
*/
protected abstract fun searchMangaFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun searchMangaNextPageSelector(): String?
/**
* 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 latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
protected abstract fun latestUpdatesSelector(): String
/**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [latestUpdatesSelector].
*/
protected abstract fun latestUpdatesFromElement(element: Element): SManga
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun latestUpdatesNextPageSelector(): String?
/**
* Parses the response from the site and returns the details of a manga.
*
* @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 {
return mangaDetailsParse(response.asJsoup())
}
/**
* Returns the details of the manga from the given [document].
*
* @param document the parsed document.
*/
protected abstract fun mangaDetailsParse(document: Document): SManga
/**
* Parses the response from the site and returns a list of chapters.
*
* @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) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
*/
protected abstract fun chapterListSelector(): String
/**
* Returns a chapter from the given element.
*
* @param element an element obtained from [chapterListSelector].
*/
protected abstract fun chapterFromElement(element: Element): SChapter
/**
* Parses the response from the site and returns the page list.
*
* @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> {
return pageListParse(response.asJsoup())
}
/**
* Returns a page list from the given document.
*
* @param document the parsed document.
*/
protected abstract fun pageListParse(document: Document): List<Page>
/**
* Parse the response from the site and returns the absolute url to the source image.
*
* @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 {
return imageUrlParse(response.asJsoup())
}
/**
* Returns the absolute url to the source image from the document.
*
* @param document the parsed document.
*/
protected abstract fun imageUrlParse(document: Document): String
}
@@ -0,0 +1,43 @@
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 or SChapter for a given URI.
*
* @since extensions-lib 1.5
*/
interface ResolvableSource : Source {
/**
* 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 getUriType(uri: String): UriType
/**
* 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
}
@@ -0,0 +1,26 @@
package eu.kanade.tachiyomi.util
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
fun Element.selectText(css: String, defaultValue: String? = null): String? {
return select(css).first()?.text() ?: defaultValue
}
fun Element.selectInt(css: String, defaultValue: Int = 0): Int {
return select(css).first()?.text()?.toInt() ?: defaultValue
}
fun Element.attrOrText(css: String): String {
return if (css != "text") attr(css) else text()
}
/**
* Returns a Jsoup document for this response.
* @param html the body of the response. Use only if the body was read before calling this method.
*/
fun Response.asJsoup(html: String? = null): Document {
return Jsoup.parse(html ?: body.string(), request.url.toString())
}
@@ -0,0 +1,6 @@
package eu.kanade.tachiyomi.util
import rx.Observable
import tachiyomi.core.common.util.lang.awaitSingle
suspend fun <T> Observable<T>.awaitSingle(): T = awaitSingle()