Extract source api from app module (#8014)

* Extract source api from app module

* Extract source online api from app module

(cherry picked from commit 86fe850794)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt
#	core/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt
#	source-api/src/main/java/eu/kanade/tachiyomi/source/Source.kt
#	source-api/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt
This commit is contained in:
Andreas
2022-09-16 00:12:27 +02:00
committed by Jobobby04
parent b975b9b86f
commit 8a322ea28e
117 changed files with 547 additions and 422 deletions
@@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import rx.Observable
interface CatalogueSource : Source {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns an observable containing a page with a list of manga.
*
* @param page the page number to retrieve.
*/
fun fetchPopularManga(page: Int): Observable<MangasPage>
/**
* 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 fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
/**
* Returns the list of filters for the source.
*/
fun getFilterList(): FilterList
}
@@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.source
import androidx.preference.PreferenceScreen
interface ConfigurableSource : Source {
fun setupPreferenceScreen(screen: PreferenceScreen)
}
@@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import okhttp3.CacheControl
import okhttp3.Response
interface PagePreviewSource : Source {
suspend fun getPagePreviewList(manga: SManga, page: Int): PagePreviewPage
suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl? = null): Response
}
@Serializable
data class PagePreviewPage(
val page: Int,
val pagePreviews: List<PagePreviewInfo>,
val hasNextPage: Boolean,
val pagePreviewPages: Int?,
)
@Serializable
data class PagePreviewInfo(
val index: Int,
val imageUrl: String,
@Transient
private val _progress: MutableStateFlow<Int> = MutableStateFlow(-1),
) : ProgressListener {
@Transient
val progress = _progress.asStateFlow()
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
_progress.value = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
}
@@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.source
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.lang.awaitSingle
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() = ""
/**
* Returns an observable with the updated details for a manga.
*
* @param manga the manga to update.
*/
@Deprecated(
"Use the 1.x API instead",
ReplaceWith("getMangaDetails"),
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
/**
* Returns an observable with all the available chapters for a manga.
*
* @param manga the manga to update.
*/
@Deprecated(
"Use the 1.x API instead",
ReplaceWith("getChapterList"),
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
// TODO: remove direct usages on this method
/**
* Returns an observable with the list of pages a chapter has.
*
* @param chapter the chapter.
*/
@Deprecated(
"Use the 1.x API instead",
ReplaceWith("getPageList"),
)
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
/**
* [1.x API] Get the updated details for a manga.
*/
@Suppress("DEPRECATION")
suspend fun getMangaDetails(manga: SManga): SManga {
return fetchMangaDetails(manga).awaitSingle()
}
/**
* [1.x API] Get all the available chapters for a manga.
*/
@Suppress("DEPRECATION")
suspend fun getChapterList(manga: SManga): List<SChapter> {
return fetchChapterList(manga).awaitSingle()
}
/**
* [1.x API] Get the list of pages a chapter has.
*/
@Suppress("DEPRECATION")
suspend fun getPageList(chapter: SChapter): List<Page> {
return fetchPageList(chapter).awaitSingle()
}
}
@@ -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,51 @@
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)
}
// SY -->
abstract class AutoComplete(
name: String,
val hint: String,
val values: List<String>,
val skipAutoFillTags: List<String> = emptyList(),
val excludePrefix: String? = null,
state: List<String>,
) : Filter<List<String>>(name, state)
// SY <--
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,14 @@
package eu.kanade.tachiyomi.source.model
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,32 @@
package eu.kanade.tachiyomi.source.model
import exh.metadata.metadata.base.RaisedSearchMetadata
/* SY --> */
open /* SY <-- */ class MangasPage(open val mangas: List<SManga>, open val hasNextPage: Boolean) {
// SY -->
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MangasPage) return false
if (mangas != other.mangas) return false
if (hasNextPage != other.hasNextPage) return false
return true
}
override fun hashCode(): Int {
var result = mangas.hashCode()
result = 31 * result + hasNextPage.hashCode()
return result
}
// SY <--
fun copy(mangas: List<SManga> = this.mangas, hasNextPage: Boolean = this.hasNextPage): MangasPage {
return MangasPage(mangas, hasNextPage)
}
}
// SY -->
data class MetadataMangasPage(override val mangas: List<SManga>, override val hasNextPage: Boolean, val mangasMetadata: List<RaisedSearchMetadata>) : MangasPage(mangas, hasNextPage)
// SY <--
@@ -0,0 +1,67 @@
package eu.kanade.tachiyomi.source.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import rx.subjects.Subject
@Serializable
open class Page(
val index: Int,
/* SY --> */
var /* SY <-- */ 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
@Volatile
var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
statusCallback?.invoke(this)
}
@Transient
@Volatile
var progress: Int = 0
set(value) {
field = value
statusCallback?.invoke(this)
}
@Transient
private var statusSubject: Subject<Int, Int>? = null
@Transient
private var statusCallback: ((Page) -> Unit)? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
fun setStatusCallback(f: ((Page) -> Unit)?) {
statusCallback = f
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}
@@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SChapter : Serializable {
var url: String
var name: String
var date_upload: Long
var chapter_number: Float
var scanlator: String?
fun copyFrom(other: SChapter) {
name = other.name
url = other.url
date_upload = other.date_upload
chapter_number = other.chapter_number
scanlator = other.scanlator
}
companion object {
fun create(): SChapter {
return SChapterImpl()
}
// SY -->
operator fun invoke(
name: String,
url: String,
date_upload: Long = 0,
chapter_number: Float = -1F,
scanlator: String? = null,
): SChapter {
return create().apply {
this.name = name
this.url = url
this.date_upload = date_upload
this.chapter_number = chapter_number
this.scanlator = scanlator
}
}
// SY <--
}
}
@@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.source.model
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
}
@@ -0,0 +1,150 @@
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SManga : Serializable {
var url: String
var title: String
var artist: String?
var author: String?
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var initialized: Boolean
fun getGenres(): List<String>? {
if (genre.isNullOrBlank()) return null
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
}
// SY -->
val originalTitle: String
val originalAuthor: String?
val originalArtist: String?
val originalDescription: String?
val originalGenre: String?
val originalStatus: Int
// SY <--
fun copyFrom(other: SManga) {
// EXH -->
if (other.title.isNotBlank() && originalTitle != other.title) {
title = other.originalTitle
}
// EXH <--
if (other.author != null) {
author = /* SY --> */ other.originalAuthor // SY <--
}
if (other.artist != null) {
artist = /* SY --> */ other.originalArtist // SY <--
}
if (other.description != null) {
description = /* SY --> */ other.originalDescription // SY <--
}
if (other.genre != null) {
genre = /* SY --> */ other.originalGenre // SY <--
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.status
if (!initialized) {
initialized = other.initialized
}
}
fun copy() = create().also {
it.url = url
// SY -->
it.title = originalTitle
it.artist = originalArtist
it.author = originalAuthor
it.description = originalDescription
it.genre = originalGenre
it.status = originalStatus
// SY <--
it.thumbnail_url = thumbnail_url
it.initialized = initialized
}
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()
}
// SY -->
operator fun invoke(
url: String,
title: String,
artist: String? = null,
author: String? = null,
description: String? = null,
genre: String? = null,
status: Int = 0,
thumbnail_url: String? = null,
initialized: Boolean = false,
): SManga {
return 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.initialized = initialized
}
}
// SY <--
}
}
// SY -->
fun SManga.copy(
url: String = this.url,
title: String = this.originalTitle,
artist: String? = this.originalArtist,
author: String? = this.originalAuthor,
description: String? = this.originalDescription,
genre: String? = this.originalGenre,
status: Int = this.status,
thumbnail_url: String? = this.thumbnail_url,
initialized: Boolean = this.initialized,
) = SManga.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.initialized = initialized
}
// SY <--
@@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.source.model
class SMangaImpl : SManga {
override lateinit var url: String
// SY -->
override var title: String = ""
// SY <--
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var initialized: Boolean = false
// SY -->
override val originalTitle: String
get() = title
override val originalAuthor: String?
get() = author
override val originalArtist: String?
get() = artist
override val originalDescription: String?
get() = description
override val originalGenre: String?
get() = genre
override val originalStatus: Int
get() = status
// SY <--
}
@@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import exh.metadata.metadata.base.RaisedSearchMetadata
interface FollowsSource : CatalogueSource {
suspend fun fetchFollows(page: Int): MangasPage
/**
* Returns a list of all Follows retrieved by Coroutines
*
* @param SManga all smanga found for user
*/
suspend fun fetchAllFollows(): List<Pair<SManga, RaisedSearchMetadata>>
}
@@ -0,0 +1,422 @@
package eu.kanade.tachiyomi.source.online
import android.app.Application
import eu.kanade.tachiyomi.network.AndroidCookieJar
import eu.kanade.tachiyomi.network.CACHE_CONTROL_NO_STORE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
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 exh.log.maybeInjectEHLogger
import exh.source.DelegatedHttpSource
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
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.
*/
// SY -->
protected val network: NetworkHelper by lazy {
val network = Injekt.get<NetworkHelper>()
object : NetworkHelper(Injekt.get<Application>()) {
override val client: OkHttpClient
get() = delegate?.networkHttpClient ?: network.client
.newBuilder()
//.injectPatches { id } todo
.maybeInjectEHLogger()
.build()
override val cloudflareClient: OkHttpClient
get() = delegate?.networkCloudflareClient ?: network.cloudflareClient
.newBuilder()
//.injectPatches { id } todo
.maybeInjectEHLogger()
.build()
override val cookieManager: AndroidCookieJar
get() = network.cookieManager
}
}
// SY <--
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
abstract val baseUrl: String
/**
* 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
/**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id by lazy {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
/**
* Headers used for requests.
*/
/* SY --> */
open /* SY <-- */ val headers: Headers by lazy { headersBuilder().build() }
/**
* Default network client for doing requests.
*/
open val client: OkHttpClient
// SY -->
get() = delegate?.baseHttpClient ?: network.client
// SY <--
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", network.defaultUserAgent)
}
/**
* Visible name of the source.
*/
override fun toString() = "$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.
*/
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.
*/
protected abstract fun popularMangaRequest(page: Int): Request
/**
* 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
/**
* 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.
*/
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return 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 ->
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.
*/
protected abstract fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
/**
* 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
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
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.
*/
/* SY --> protected <-- SY */
abstract fun latestUpdatesRequest(page: Int): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
/* SY --> protected <-- SY */
abstract fun latestUpdatesParse(response: Response): MangasPage
/**
* 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.
*/
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.
*/
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.
*/
protected abstract fun mangaDetailsParse(response: Response): SManga
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned
*
* @param manga the manga to look for chapters.
*/
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return if (manga.status != SManga.LICENSED) {
client.newCall(chapterListRequest(manga))
.asObservableSuccess()
.map { response ->
chapterListParse(response)
}
} else {
Observable.error(Exception("Licensed - No chapters to show"))
}
}
/**
* 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.
*/
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.
*/
protected abstract fun chapterListParse(response: Response): List<SChapter>
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
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.
*/
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.
*/
protected abstract fun pageListParse(response: Response): List<Page>
/**
* 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.
*/
open fun fetchImageUrl(page: Page): Observable<String> {
return client.newCall(imageUrlRequest(page))
.asObservableSuccess()
.map { imageUrlParse(it) }
}
/**
* 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
*/
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.
*/
protected abstract fun imageUrlParse(response: Response): String
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
/* SY --> */
open /* SY <-- */ fun fetchImage(page: Page): Observable<Response> {
val request = imageRequest(page).newBuilder()
// images will be cached or saved manually, so don't take up network cache
.cacheControl(CACHE_CONTROL_NO_STORE)
.build()
return client.newCallWithProgress(request, page)
.asObservableSuccess()
}
/**
* 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.
*/
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.
*/
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 (e: URISyntaxException) {
orig
}
}
/**
* 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.
*/
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = FilterList()
// EXH -->
private var delegate: DelegatedHttpSource? = null
get() = if (Injekt.get<NetworkHelper>().preferences.getBoolean("eh_delegate_sources", true)) { // todo
field
} else {
null
}
fun bindDelegate(delegate: DelegatedHttpSource) {
this.delegate = delegate
}
// EXH <--
}
@@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages)
.filter { !it.imageUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
}
fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() }
.concatMap { getImageUrl(it) }
}
@@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
interface LoginSource : Source {
val requiresLogin: Boolean
val twoFactorAuth: AuthSupport
fun isLogged(): Boolean
fun getUsername(): String
fun getPassword(): String
suspend fun login(username: String, password: String, twoFactorCode: String?): Boolean
suspend fun logout(): Boolean
enum class AuthSupport {
NOT_SUPPORTED,
SUPPORTED,
REQUIRED,
}
}
@@ -0,0 +1,120 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.awaitSingle
import eu.kanade.tachiyomi.util.lang.runAsObservable
import exh.metadata.metadata.base.FlatMetadata
import exh.metadata.metadata.base.RaisedSearchMetadata
import rx.Completable
import rx.Single
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.reflect.KClass
/**
* LEWD!
*/
interface MetadataSource<M : RaisedSearchMetadata, I> : CatalogueSource {
interface GetMangaId {
suspend fun awaitId(url: String, sourceId: Long): Long?
}
interface InsertFlatMetadata {
suspend fun await(metadata: RaisedSearchMetadata)
}
interface GetFlatMetadataById {
suspend fun await(id: Long): FlatMetadata?
}
val getMangaId: GetMangaId get() = Injekt.get()
val insertFlatMetadata: InsertFlatMetadata get() = Injekt.get()
val getFlatMetadataById: GetFlatMetadataById get() = Injekt.get()
/**
* The class of the metadata used by this source
*/
val metaClass: KClass<M>
/**
* Parse the supplied input into the supplied metadata object
*/
suspend fun parseIntoMetadata(metadata: M, input: I)
/**
* Use reflection to create a new instance of metadata
*/
private fun newMetaInstance() = metaClass.constructors.find {
it.parameters.isEmpty()
}?.call()
?: error("Could not find no-args constructor for meta class: ${metaClass.qualifiedName}!")
/**
* Parses metadata from the input and then copies it into the manga
*
* Will also save the metadata to the DB if possible
*/
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("Use the MangaInfo variant")
fun parseToMangaCompletable(manga: SManga, input: I): Completable = runAsObservable {
parseToManga(manga, input)
}.toCompletable()
suspend fun parseToManga(manga: SManga, input: I): SManga {
val mangaId = manga.id()
val metadata = if (mangaId != null) {
val flatMetadata = getFlatMetadataById.await(mangaId)
flatMetadata?.raise(metaClass) ?: newMetaInstance()
} else {
newMetaInstance()
}
parseIntoMetadata(metadata, input)
if (mangaId != null) {
metadata.mangaId = mangaId
insertFlatMetadata.await(metadata)
}
return metadata.createMangaInfo(manga)
}
/**
* Try to first get the metadata from the DB. If the metadata is not in the DB, calls the input
* producer and parses the metadata from the input
*
* If the metadata needs to be parsed from the input producer, the resulting parsed metadata will
* also be saved to the DB.
*/
@Suppress("DeprecatedCallableAddReplaceWith")
@Deprecated("use fetchOrLoadMetadata made for MangaInfo")
fun getOrLoadMetadata(mangaId: Long?, inputProducer: () -> Single<I>): Single<M> =
runAsObservable {
fetchOrLoadMetadata(mangaId) { inputProducer().toObservable().awaitSingle() }
}.toSingle()
/**
* Try to first get the metadata from the DB. If the metadata is not in the DB, calls the input
* producer and parses the metadata from the input
*
* If the metadata needs to be parsed from the input producer, the resulting parsed metadata will
* also be saved to the DB.
*/
suspend fun fetchOrLoadMetadata(mangaId: Long?, inputProducer: suspend () -> I): M {
val meta = if (mangaId != null) {
val flatMetadata = getFlatMetadataById.await(mangaId)
flatMetadata?.raise(metaClass)
} else {
null
}
return meta ?: inputProducer().let { input ->
val newMeta = newMetaInstance()
parseIntoMetadata(newMeta, input)
if (mangaId != null) {
newMeta.mangaId = mangaId
insertFlatMetadata.await(newMeta)
}
newMeta
}
}
suspend fun SManga.id() = getMangaId.awaitId(url, id)
}
@@ -0,0 +1,5 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
interface NamespaceSource : Source
@@ -0,0 +1,201 @@
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.
*/
@Suppress("unused")
abstract class ParsedHttpSource : HttpSource() {
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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.
*/
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,7 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source
interface RandomMangaSource : Source {
suspend fun fetchRandomMangaUrl(): String
}
@@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.source.online
import android.net.Uri
import eu.kanade.tachiyomi.source.Source
import java.net.URI
import java.net.URISyntaxException
interface UrlImportableSource : Source {
val matchingHosts: List<String>
fun matchesUri(uri: Uri): Boolean {
return uri.host.orEmpty().lowercase() in matchingHosts
}
fun mapUrlToChapterUrl(uri: Uri): String? = null
suspend fun mapChapterUrlToMangaUrl(uri: Uri): String? = null
// This method is allowed to block for IO if necessary
suspend fun mapUrlToMangaUrl(uri: Uri): String?
fun cleanMangaUrl(url: String): String {
return try {
val uri = URI(url)
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
url
}
}
fun cleanChapterUrl(url: String): String {
return try {
val uri = URI(url)
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
url
}
}
}
@@ -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,29 @@
package exh.md.utils
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.source.R
enum class MangaDexRelation(@StringRes val resId: Int, val mdString: String?) {
SIMILAR(R.string.relation_similar, null),
MONOCHROME(R.string.relation_monochrome, "monochrome"),
MAIN_STORY(R.string.relation_main_story, "main_story"),
ADAPTED_FROM(R.string.relation_adapted_from, "adapted_from"),
BASED_ON(R.string.relation_based_on, "based_on"),
PREQUEL(R.string.relation_prequel, "prequel"),
SIDE_STORY(R.string.relation_side_story, "side_story"),
DOUJINSHI(R.string.relation_doujinshi, "doujinshi"),
SAME_FRANCHISE(R.string.relation_same_franchise, "same_franchise"),
SHARED_UNIVERSE(R.string.relation_shared_universe, "shared_universe"),
SEQUEL(R.string.relation_sequel, "sequel"),
SPIN_OFF(R.string.relation_spin_off, "spin_off"),
ALTERNATE_STORY(R.string.relation_alternate_story, "alternate_story"),
PRESERIALIZATION(R.string.relation_preserialization, "preserialization"),
COLORED(R.string.relation_colored, "colored"),
SERIALIZATION(R.string.relation_serialization, "serialization"),
ALTERNATE_VERSION(R.string.relation_alternate_version, "alternate_version"),
;
companion object {
fun fromDex(mdString: String) = values().find { it.mdString == mdString }
}
}
@@ -0,0 +1,60 @@
package exh.metadata
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.ln
import kotlin.math.pow
/**
* Metadata utils
*/
object MetadataUtil {
fun humanReadableByteCount(bytes: Long, si: Boolean): String {
val unit = if (si) 1000 else 1024
if (bytes < unit) return "$bytes B"
val exp = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt()
val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i"
return String.format("%.1f %sB", bytes / unit.toDouble().pow(exp.toDouble()), pre)
}
private const val KB_FACTOR: Long = 1000
private const val KIB_FACTOR: Long = 1024
private const val MB_FACTOR = 1000 * KB_FACTOR
private const val MIB_FACTOR = 1024 * KIB_FACTOR
private const val GB_FACTOR = 1000 * MB_FACTOR
private const val GIB_FACTOR = 1024 * MIB_FACTOR
fun parseHumanReadableByteCount(bytes: String): Double? {
val ret = bytes.substringBefore(' ').toDouble()
return when (bytes.substringAfter(' ')) {
"GB" -> ret * GB_FACTOR
"GiB" -> ret * GIB_FACTOR
"MB" -> ret * MB_FACTOR
"MiB" -> ret * MIB_FACTOR
"KB" -> ret * KB_FACTOR
"KiB" -> ret * KIB_FACTOR
else -> null
}
}
val ONGOING_SUFFIX = arrayOf(
"[ongoing]",
"(ongoing)",
"{ongoing}",
"<ongoing>",
"ongoing",
"[incomplete]",
"(incomplete)",
"{incomplete}",
"<incomplete>",
"incomplete",
"[wip]",
"(wip)",
"{wip}",
"<wip>",
"wip",
)
val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
}
@@ -0,0 +1,154 @@
package exh.metadata.metadata
import android.content.Context
import androidx.core.net.toUri
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.MetadataUtil
import exh.metadata.metadata.base.RaisedSearchMetadata
import kotlinx.serialization.Serializable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
@Serializable
class EHentaiSearchMetadata : RaisedSearchMetadata() {
var gId: String?
get() = indexedExtra
set(value) { indexedExtra = value }
var gToken: String? = null
var exh: Boolean? = null
var thumbnailUrl: String? = null
var title by titleDelegate(TITLE_TYPE_TITLE)
var altTitle by titleDelegate(TITLE_TYPE_ALT_TITLE)
var genre: String? = null
var datePosted: Long? = null
var parent: String? = null
var visible: String? = null // Not a boolean
var language: String? = null
var translated: Boolean? = null
var size: Long? = null
var length: Int? = null
var favorites: Int? = null
var ratingCount: Int? = null
var averageRating: Double? = null
var aged: Boolean = false
var lastUpdateCheck: Long = 0
override fun createMangaInfo(manga: SManga): SManga {
val key = gId?.let { gId ->
gToken?.let { gToken ->
idAndTokenToUrl(gId, gToken)
}
}
val cover = thumbnailUrl
// No title bug?
val title = altTitle
?.takeIf { Injekt.get<NetworkHelper>().preferences.getBoolean("use_jp_title", false) } // todo
?: title
// Set artist (if we can find one)
val artist = tags.ofNamespace(EH_ARTIST_NAMESPACE)
.ifEmpty { null }
?.joinToString { it.name }
// Copy tags -> genres
val genres = tagsToGenreString()
// Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
// We default to completed
var status = SManga.COMPLETED
title?.let { t ->
MetadataUtil.ONGOING_SUFFIX.find {
t.endsWith(it, ignoreCase = true)
}?.let {
status = SManga.ONGOING
}
}
val description = "meta"
return manga.copy(
url = key ?: manga.url,
title = title ?: manga.title,
artist = artist ?: manga.artist,
description = description,
genre = genres,
status = status,
thumbnail_url = cover ?: manga.thumbnail_url,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(gId) { getString(R.string.id) },
getItem(gToken) { getString(R.string.token) },
getItem(exh) { getString(R.string.is_exhentai_gallery) },
getItem(thumbnailUrl) { getString(R.string.thumbnail_url) },
getItem(title) { getString(R.string.title) },
getItem(altTitle) { getString(R.string.alt_title) },
getItem(genre) { getString(R.string.genre) },
getItem(datePosted, { MetadataUtil.EX_DATE_FORMAT.format(Date(it)) }) { getString(R.string.date_posted) },
getItem(parent) { getString(R.string.parent) },
getItem(visible) { getString(R.string.visible) },
getItem(language) { getString(R.string.language) },
getItem(translated) { getString(R.string.translated) },
getItem(size, { MetadataUtil.humanReadableByteCount(it, true) }) { getString(R.string.gallery_size) },
getItem(length) { getString(R.string.page_count) },
getItem(favorites) { getString(R.string.total_favorites) },
getItem(ratingCount) { getString(R.string.total_ratings) },
getItem(averageRating) { getString(R.string.average_rating) },
getItem(aged) { getString(R.string.aged) },
getItem(lastUpdateCheck, { MetadataUtil.EX_DATE_FORMAT.format(Date(it)) }) { getString(R.string.last_update_check) },
)
}
}
companion object {
private const val TITLE_TYPE_TITLE = 0
private const val TITLE_TYPE_ALT_TITLE = 1
const val TAG_TYPE_NORMAL = 0
const val TAG_TYPE_LIGHT = 1
const val TAG_TYPE_WEAK = 2
const val EH_GENRE_NAMESPACE = "genre"
private const val EH_ARTIST_NAMESPACE = "artist"
const val EH_LANGUAGE_NAMESPACE = "language"
const val EH_META_NAMESPACE = "meta"
const val EH_UPLOADER_NAMESPACE = "uploader"
const val EH_VISIBILITY_NAMESPACE = "visibility"
private fun splitGalleryUrl(url: String) =
url.let {
// Only parse URL if is full URL
val pathSegments = if (it.startsWith("http")) {
it.toUri().pathSegments
} else {
it.split('/')
}
pathSegments.filterNot(String::isNullOrBlank)
}
fun galleryId(url: String): String = splitGalleryUrl(url)[1]
fun galleryToken(url: String): String =
splitGalleryUrl(url)[2]
fun normalizeUrl(url: String) =
idAndTokenToUrl(galleryId(url), galleryToken(url))
fun idAndTokenToUrl(id: String, token: String) =
"/g/$id/$token/?nw=always"
}
}
@@ -0,0 +1,60 @@
package exh.metadata.metadata
import android.content.Context
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.util.nullIfEmpty
import kotlinx.serialization.Serializable
@Serializable
class EightMusesSearchMetadata : RaisedSearchMetadata() {
var path: List<String> = emptyList()
var title by titleDelegate(TITLE_TYPE_MAIN)
var thumbnailUrl: String? = null
override fun createMangaInfo(manga: SManga): SManga {
val key = path.joinToString("/", prefix = "/")
val title = title
val cover = thumbnailUrl
val artist = tags.ofNamespace(ARTIST_NAMESPACE).joinToString { it.name }
val genres = tagsToGenreString()
val description = "meta"
return manga.copy(
url = key,
title = title ?: manga.title,
thumbnail_url = cover ?: manga.thumbnail_url,
artist = artist,
genre = genres,
description = description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(title) { getString(R.string.title) },
getItem(path.nullIfEmpty(), { it.joinToString("/", prefix = "/") }) { getString(R.string.path) },
getItem(thumbnailUrl) { getString(R.string.thumbnail_url) },
)
}
}
companion object {
private const val TITLE_TYPE_MAIN = 0
const val TAG_TYPE_DEFAULT = 0
const val TAGS_NAMESPACE = "tags"
const val ARTIST_NAMESPACE = "artist"
}
}
@@ -0,0 +1,75 @@
package exh.metadata.metadata
import android.content.Context
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.metadata.base.RaisedSearchMetadata
import kotlinx.serialization.Serializable
@Serializable
class HBrowseSearchMetadata : RaisedSearchMetadata() {
var hbId: Long? = null
var hbUrl: String? = null
var thumbnail: String? = null
var title: String? by titleDelegate(TITLE_TYPE_MAIN)
// Length in pages
var length: Int? = null
override fun createMangaInfo(manga: SManga): SManga {
val key = hbUrl
val title = title
// Guess thumbnail URL if manga does not have thumbnail URL
val cover = if (manga.thumbnail_url.isNullOrBlank()) {
guessThumbnailUrl(hbId.toString())
} else {
null
}
val artist = tags.ofNamespace(ARTIST_NAMESPACE).joinToString { it.name }
val genres = tagsToGenreString()
val description = "meta"
return manga.copy(
url = key ?: manga.url,
title = title ?: manga.title,
thumbnail_url = cover ?: manga.thumbnail_url,
artist = artist,
genre = genres,
description = description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(hbId) { getString(R.string.id) },
getItem(hbUrl) { getString(R.string.url) },
getItem(thumbnail) { getString(R.string.thumbnail_url) },
getItem(title) { getString(R.string.title) },
getItem(length) { getString(R.string.page_count) },
)
}
}
companion object {
const val BASE_URL = "https://www.hbrowse.com"
private const val TITLE_TYPE_MAIN = 0
const val TAG_TYPE_DEFAULT = 0
const val ARTIST_NAMESPACE = "artist"
fun guessThumbnailUrl(hbid: String): String {
return "$BASE_URL/thumbnails/${hbid}_1.jpg#guessed"
}
}
}
@@ -0,0 +1,87 @@
package exh.metadata.metadata
import android.content.Context
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.MetadataUtil
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.util.nullIfEmpty
import kotlinx.serialization.Serializable
import java.util.Date
@Serializable
class HitomiSearchMetadata : RaisedSearchMetadata() {
var url get() = hlId?.let { urlFromHlId(it) }
set(a) {
a?.let {
hlId = hlIdFromUrl(a)
}
}
var hlId: String? = null
var title by titleDelegate(TITLE_TYPE_MAIN)
var thumbnailUrl: String? = null
var artists: List<String> = emptyList()
var genre: String? = null
var language: String? = null
var uploadDate: Long? = null
override fun createMangaInfo(manga: SManga): SManga {
val cover = thumbnailUrl
val title = title
// Copy tags -> genres
val genres = tagsToGenreString()
val artist = artists.joinToString()
val status = SManga.UNKNOWN
val description = "meta"
return manga.copy(
thumbnail_url = cover ?: manga.thumbnail_url,
title = title ?: manga.title,
genre = genres,
artist = artist,
status = status,
description = description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(hlId) { getString(R.string.id) },
getItem(title) { getString(R.string.title) },
getItem(thumbnailUrl) { getString(R.string.thumbnail_url) },
getItem(artists.nullIfEmpty(), { it.joinToString() }) { getString(R.string.artist) },
getItem(genre) { getString(R.string.genre) },
getItem(language) { getString(R.string.language) },
getItem(uploadDate, { MetadataUtil.EX_DATE_FORMAT.format(Date(it)) }) { getString(R.string.date_posted) },
)
}
}
companion object {
private const val TITLE_TYPE_MAIN = 0
const val TAG_TYPE_DEFAULT = 0
const val BASE_URL = "https://hitomi.la"
fun hlIdFromUrl(url: String) =
url.split('/').last().split('-').last().substringBeforeLast('.')
fun urlFromHlId(id: String) =
"$BASE_URL/galleries/$id.html"
}
}
@@ -0,0 +1,107 @@
package exh.metadata.metadata
import android.content.Context
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.md.utils.MangaDexRelation
import exh.metadata.metadata.base.RaisedSearchMetadata
import kotlinx.serialization.Serializable
@Serializable
class MangaDexSearchMetadata : RaisedSearchMetadata() {
var mdUuid: String? = null
// var mdUrl: String? = null
var cover: String? = null
var title: String? by titleDelegate(TITLE_TYPE_MAIN)
var altTitles: List<String>? = null
var description: String? = null
var authors: List<String>? = null
var artists: List<String>? = null
var langFlag: String? = null
var lastChapterNumber: Int? = null
var rating: Float? = null
// var users: String? = null
var anilistId: String? = null
var kitsuId: String? = null
var myAnimeListId: String? = null
var mangaUpdatesId: String? = null
var animePlanetId: String? = null
var status: Int? = null
// var missing_chapters: String? = null
var followStatus: Int? = null
var relation: MangaDexRelation? = null
// var maxChapterNumber: Int? = null
override fun createMangaInfo(manga: SManga): SManga {
val key = mdUuid?.let { "/manga/$it" }
val title = title
val cover = cover
val author = authors?.joinToString()
val artist = artists?.joinToString()
val status = status
val genres = tagsToGenreString()
val description = description
return manga.copy(
url = key ?: manga.url,
title = title ?: manga.title,
thumbnail_url = cover ?: manga.thumbnail_url,
author = author ?: manga.author,
artist = artist ?: manga.artist,
status = status ?: manga.status,
genre = genres,
description = description ?: manga.description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(mdUuid) { getString(R.string.id) },
// getItem(mdUrl) { getString(R.string.url) },
getItem(cover) { getString(R.string.thumbnail_url) },
getItem(title) { getString(R.string.title) },
getItem(authors, { it.joinToString() }) { getString(R.string.author) },
getItem(artists, { it.joinToString() }) { getString(R.string.artist) },
getItem(langFlag) { getString(R.string.language) },
getItem(lastChapterNumber) { getString(R.string.last_chapter_number) },
getItem(rating) { getString(R.string.average_rating) },
// getItem(users) { getString(R.string.total_ratings) },
getItem(status) { getString(R.string.status) },
// getItem(missing_chapters) { getString(R.string.missing_chapters) },
getItem(followStatus) { getString(R.string.follow_status) },
getItem(anilistId) { getString(R.string.anilist_id) },
getItem(kitsuId) { getString(R.string.kitsu_id) },
getItem(myAnimeListId) { getString(R.string.mal_id) },
getItem(mangaUpdatesId) { getString(R.string.manga_updates_id) },
getItem(animePlanetId) { getString(R.string.anime_planet_id) },
)
}
}
companion object {
private const val TITLE_TYPE_MAIN = 0
const val TAG_TYPE_DEFAULT = 0
}
}
@@ -0,0 +1,133 @@
package exh.metadata.metadata
import android.content.Context
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.MetadataUtil
import exh.metadata.metadata.base.RaisedSearchMetadata
import kotlinx.serialization.Serializable
import java.util.Date
@Serializable
class NHentaiSearchMetadata : RaisedSearchMetadata() {
var url get() = nhId?.let { BASE_URL + nhIdToPath(it) }
set(a) {
a?.let {
nhId = nhUrlToId(a)
}
}
var nhId: Long? = null
var uploadDate: Long? = null
var favoritesCount: Long? = null
var mediaId: String? = null
var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE)
var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH)
var shortTitle by titleDelegate(TITLE_TYPE_SHORT)
var coverImageType: String? = null
var pageImageTypes: List<String> = emptyList()
var thumbnailImageType: String? = null
var scanlator: String? = null
var preferredTitle: Int? = null
override fun createMangaInfo(manga: SManga): SManga {
val key = nhId?.let { nhIdToPath(it) }
val cover = if (mediaId != null) {
typeToExtension(coverImageType)?.let {
"https://t.nhentai.net/galleries/$mediaId/cover.$it"
}
} else {
null
}
val title = when (preferredTitle) {
TITLE_TYPE_SHORT -> shortTitle ?: englishTitle ?: japaneseTitle ?: manga.title
0, TITLE_TYPE_ENGLISH -> englishTitle ?: japaneseTitle ?: shortTitle ?: manga.title
else -> englishTitle ?: japaneseTitle ?: shortTitle ?: manga.title
}
// Set artist (if we can find one)
val artist = tags.ofNamespace(NHENTAI_ARTIST_NAMESPACE).let { tags ->
if (tags.isNotEmpty()) tags.joinToString(transform = { it.name }) else null
}
// Copy tags -> genres
val genres = tagsToGenreString()
// Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
// We default to completed
var status = SManga.COMPLETED
englishTitle?.let { t ->
MetadataUtil.ONGOING_SUFFIX.find {
t.endsWith(it, ignoreCase = true)
}?.let {
status = SManga.ONGOING
}
}
val description = "meta"
return manga.copy(
url = key ?: manga.url,
thumbnail_url = cover ?: manga.thumbnail_url,
title = title,
artist = artist ?: manga.artist,
genre = genres,
status = status,
description = description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(nhId) { getString(R.string.id) },
getItem(uploadDate, { MetadataUtil.EX_DATE_FORMAT.format(Date(it * 1000)) }) { getString(R.string.date_posted) },
getItem(favoritesCount) { getString(R.string.total_favorites) },
getItem(mediaId) { getString(R.string.media_id) },
getItem(japaneseTitle) { getString(R.string.japanese_title) },
getItem(englishTitle) { getString(R.string.english_title) },
getItem(shortTitle) { getString(R.string.short_title) },
getItem(coverImageType) { getString(R.string.cover_image_file_type) },
getItem(pageImageTypes.size) { getString(R.string.page_count) },
getItem(thumbnailImageType) { getString(R.string.thumbnail_image_file_type) },
getItem(scanlator) { getString(R.string.scanlator) },
)
}
}
companion object {
private const val TITLE_TYPE_JAPANESE = 0
const val TITLE_TYPE_ENGLISH = 1
const val TITLE_TYPE_SHORT = 2
const val TAG_TYPE_DEFAULT = 0
const val BASE_URL = "https://nhentai.net"
private const val NHENTAI_ARTIST_NAMESPACE = "artist"
const val NHENTAI_CATEGORIES_NAMESPACE = "category"
fun typeToExtension(t: String?) =
when (t) {
"p" -> "png"
"j" -> "jpg"
"g" -> "gif"
else -> null
}
fun nhUrlToId(url: String) =
url.split("/").last { it.isNotBlank() }.toLong()
fun nhIdToPath(id: Long) = "/g/$id/"
}
}
@@ -0,0 +1,96 @@
package exh.metadata.metadata
import android.content.Context
import androidx.core.net.toUri
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTitle
import exh.util.nullIfEmpty
import kotlinx.serialization.Serializable
@Serializable
class PervEdenSearchMetadata : RaisedSearchMetadata() {
var pvId: String? = null
var url: String? = null
var thumbnailUrl: String? = null
var title by titleDelegate(TITLE_TYPE_MAIN)
var altTitles
get() = titles.filter { it.type == TITLE_TYPE_ALT }.map { it.title }
set(value) {
titles.removeAll { it.type == TITLE_TYPE_ALT }
titles += value.map { RaisedTitle(it, TITLE_TYPE_ALT) }
}
var artist: String? = null
var genre: String? = null
var rating: Float? = null
var status: String? = null
var lang: String? = null
override fun createMangaInfo(manga: SManga): SManga {
val key = url
val cover = thumbnailUrl
val title = title
val artist = artist
val status = when (status) {
"Ongoing" -> SManga.ONGOING
"Completed", "Suspended" -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
// Copy tags -> genres
val genres = tagsToGenreString()
val description = "meta"
return manga.copy(
url = key ?: manga.url,
thumbnail_url = cover ?: manga.thumbnail_url,
title = title ?: manga.title,
artist = artist ?: manga.artist,
status = status,
genre = genres,
description = description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(pvId) { getString(R.string.id) },
getItem(url) { getString(R.string.url) },
getItem(thumbnailUrl) { getString(R.string.thumbnail_url) },
getItem(title) { getString(R.string.title) },
getItem(altTitles.nullIfEmpty(), { it.joinToString() }) { getString(R.string.alt_titles) },
getItem(artist) { getString(R.string.artist) },
getItem(genre) { getString(R.string.genre) },
getItem(rating) { getString(R.string.average_rating) },
getItem(status) { getString(R.string.status) },
getItem(lang) { getString(R.string.language) },
)
}
}
companion object {
private const val TITLE_TYPE_MAIN = 0
private const val TITLE_TYPE_ALT = 1
const val TAG_TYPE_DEFAULT = 0
private fun splitGalleryUrl(url: String) =
url.toUri().pathSegments.filterNot(String::isNullOrBlank)
fun pvIdFromUrl(url: String): String = splitGalleryUrl(url).last()
}
}
@@ -0,0 +1,85 @@
package exh.metadata.metadata
import android.content.Context
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.metadata.base.RaisedSearchMetadata
import kotlinx.serialization.Serializable
@Serializable
class PururinSearchMetadata : RaisedSearchMetadata() {
var prId: Int? = null
var prShortLink: String? = null
var title by titleDelegate(TITLE_TYPE_TITLE)
var altTitle by titleDelegate(TITLE_TYPE_ALT_TITLE)
var thumbnailUrl: String? = null
var uploaderDisp: String? = null
var pages: Int? = null
var fileSize: String? = null
var ratingCount: Int? = null
var averageRating: Double? = null
override fun createMangaInfo(manga: SManga): SManga {
val key = prId?.let { prId ->
prShortLink?.let { prShortLink ->
"/gallery/$prId/$prShortLink"
}
}
val title = title ?: altTitle
val cover = thumbnailUrl
val artist = tags.ofNamespace(TAG_NAMESPACE_ARTIST).joinToString { it.name }
val genres = tagsToGenreString()
val description = "meta"
return manga.copy(
url = key ?: manga.url,
title = title ?: manga.title,
thumbnail_url = cover ?: manga.thumbnail_url,
artist = artist,
genre = genres,
description = description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(prId) { getString(R.string.id) },
getItem(title) { getString(R.string.title) },
getItem(altTitle) { getString(R.string.alt_title) },
getItem(thumbnailUrl) { getString(R.string.thumbnail_url) },
getItem(uploaderDisp) { getString(R.string.uploader_capital) },
getItem(uploader) { getString(R.string.uploader) },
getItem(pages) { getString(R.string.page_count) },
getItem(fileSize) { getString(R.string.gallery_size) },
getItem(ratingCount) { getString(R.string.total_ratings) },
getItem(averageRating) { getString(R.string.average_rating) },
)
}
}
companion object {
private const val TITLE_TYPE_TITLE = 0
private const val TITLE_TYPE_ALT_TITLE = 1
const val TAG_TYPE_DEFAULT = 0
private const val TAG_NAMESPACE_ARTIST = "artist"
const val TAG_NAMESPACE_CATEGORY = "category"
const val BASE_URL = "https://pururin.io"
}
}
@@ -0,0 +1,103 @@
package exh.metadata.metadata
import android.content.Context
import androidx.core.net.toUri
import eu.kanade.tachiyomi.source.R
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.metadata.MetadataUtil
import exh.metadata.metadata.base.RaisedSearchMetadata
import exh.util.nullIfEmpty
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Serializable
class TsuminoSearchMetadata : RaisedSearchMetadata() {
var tmId: Int? = null
var title by titleDelegate(TITLE_TYPE_MAIN)
var artist: String? = null
var uploadDate: Long? = null
var length: Int? = null
var ratingString: String? = null
var averageRating: Float? = null
var userRatings: Long? = null
var favorites: Long? = null
var category: String? = null
var collection: String? = null
var group: String? = null
var parody: List<String> = emptyList()
var character: List<String> = emptyList()
override fun createMangaInfo(manga: SManga): SManga {
val title = title
val cover = tmId?.let { BASE_URL.replace("www", "content") + thumbUrlFromId(it.toString()) }
val artist = artist
val status = SManga.UNKNOWN
// Copy tags -> genres
val genres = tagsToGenreString()
val description = "meta"
return manga.copy(
title = title ?: manga.title,
thumbnail_url = cover ?: manga.thumbnail_url,
artist = artist ?: manga.artist,
status = status,
genre = genres,
description = description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(tmId) { getString(R.string.id) },
getItem(title) { getString(R.string.title) },
getItem(uploader) { getString(R.string.uploader) },
getItem(uploadDate, { MetadataUtil.EX_DATE_FORMAT.format(Date(it)) }) { getString(R.string.date_posted) },
getItem(length) { getString(R.string.page_count) },
getItem(ratingString) { getString(R.string.rating_string) },
getItem(averageRating) { getString(R.string.average_rating) },
getItem(userRatings) { getString(R.string.total_ratings) },
getItem(favorites) { getString(R.string.total_favorites) },
getItem(category) { getString(R.string.genre) },
getItem(collection) { getString(R.string.collection) },
getItem(group) { getString(R.string.group) },
getItem(parody.nullIfEmpty(), { it.joinToString() }) { getString(R.string.parodies) },
getItem(character.nullIfEmpty(), { it.joinToString() }) { getString(R.string.characters) },
)
}
}
companion object {
private const val TITLE_TYPE_MAIN = 0
const val TAG_TYPE_DEFAULT = 0
val BASE_URL = "https://www.tsumino.com"
val TSUMINO_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
fun tmIdFromUrl(url: String) = url.toUri().lastPathSegment
fun thumbUrlFromId(id: String) = "/thumbs/$id/1"
}
}
@@ -0,0 +1,25 @@
package exh.metadata.metadata.base
import exh.metadata.sql.models.SearchMetadata
import exh.metadata.sql.models.SearchTag
import exh.metadata.sql.models.SearchTitle
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.serializer
import kotlin.reflect.KClass
@Serializable
data class FlatMetadata(
val metadata: SearchMetadata,
val tags: List<SearchTag>,
val titles: List<SearchTitle>,
) {
inline fun <reified T : RaisedSearchMetadata> raise(): T = raise(T::class)
@OptIn(InternalSerializationApi::class)
fun <T : RaisedSearchMetadata> raise(clazz: KClass<T>): T =
RaisedSearchMetadata.raiseFlattenJson
.decodeFromString(clazz.serializer(), metadata.extra).apply {
fillBaseFields(this@FlatMetadata)
}
}
@@ -0,0 +1,200 @@
package exh.metadata.metadata.base
import android.content.Context
import eu.kanade.tachiyomi.source.model.SManga
import exh.metadata.metadata.EHentaiSearchMetadata
import exh.metadata.metadata.EightMusesSearchMetadata
import exh.metadata.metadata.HBrowseSearchMetadata
import exh.metadata.metadata.HitomiSearchMetadata
import exh.metadata.metadata.MangaDexSearchMetadata
import exh.metadata.metadata.NHentaiSearchMetadata
import exh.metadata.metadata.PervEdenSearchMetadata
import exh.metadata.metadata.PururinSearchMetadata
import exh.metadata.metadata.TsuminoSearchMetadata
import exh.metadata.sql.models.SearchMetadata
import exh.metadata.sql.models.SearchTag
import exh.metadata.sql.models.SearchTitle
import exh.util.plusAssign
import kotlinx.serialization.Polymorphic
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@Polymorphic
@Serializable
abstract class RaisedSearchMetadata {
@Transient
var mangaId: Long = -1
@Transient
var uploader: String? = null
@Transient
protected open var indexedExtra: String? = null
@Transient
val tags = mutableListOf<RaisedTag>()
@Transient
val titles = mutableListOf<RaisedTitle>()
fun getTitleOfType(type: Int): String? = titles.find { it.type == type }?.title
fun replaceTitleOfType(type: Int, newTitle: String?) {
titles.removeAll { it.type == type }
if (newTitle != null) titles += RaisedTitle(newTitle, type)
}
fun <T : Any> getItem(
item: T?,
toString: (T) -> String = Any::toString,
block: (T) -> String,
): Pair<String, String>? {
item ?: return null
return block(item) to toString(item)
}
/*open fun copyTo(manga: SManga) {
val infoManga = createMangaInfo(manga.copy())
manga.copyFrom(infoManga)
}*/
abstract fun createMangaInfo(manga: SManga): SManga
fun tagsToGenreString() = tags.toGenreString()
fun tagsToGenreList() = tags.toGenreList()
fun tagsToDescription() =
StringBuilder("Tags:\n").apply {
// BiConsumer only available in Java 8, don't bother calling forEach directly on 'tags'
val groupedTags = tags.filter { it.type != TAG_TYPE_VIRTUAL }.groupBy {
it.namespace
}.entries
groupedTags.forEach { (namespace, tags) ->
if (tags.isNotEmpty()) {
val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
if (namespace != null) {
this += ""
this += namespace
this += ": "
}
this += joinedTags
this += "\n"
}
}
}
fun List<RaisedTag>.ofNamespace(ns: String): List<RaisedTag> {
return filter { it.namespace == ns }
}
fun flatten(): FlatMetadata {
require(mangaId != -1L)
val extra = raiseFlattenJson.encodeToString(this)
return FlatMetadata(
SearchMetadata(
mangaId,
uploader,
extra,
indexedExtra,
0,
),
tags.map {
SearchTag(
null,
mangaId,
it.namespace,
it.name,
it.type,
)
},
titles.map {
SearchTitle(
null,
mangaId,
it.title,
it.type,
)
},
)
}
fun fillBaseFields(metadata: FlatMetadata) {
mangaId = metadata.metadata.mangaId
uploader = metadata.metadata.uploader
indexedExtra = metadata.metadata.indexedExtra
this.tags.clear()
this.tags += metadata.tags.map {
RaisedTag(it.namespace, it.name, it.type)
}
this.titles.clear()
this.titles += metadata.titles.map {
RaisedTitle(it.title, it.type)
}
}
abstract fun getExtraInfoPairs(context: Context): List<Pair<String, String>>
companion object {
// Virtual tags allow searching of otherwise unindexed fields
const val TAG_TYPE_VIRTUAL = -2
fun MutableList<RaisedTag>.toGenreString() =
this.filter { it.type != TAG_TYPE_VIRTUAL }
.joinToString { (if (it.namespace != null) "${it.namespace}: " else "") + it.name }
fun MutableList<RaisedTag>.toGenreList() =
this.filter { it.type != TAG_TYPE_VIRTUAL }
.map { (if (it.namespace != null) "${it.namespace}: " else "") + it.name }
private val module = SerializersModule {
polymorphic(RaisedSearchMetadata::class) {
subclass(EHentaiSearchMetadata::class)
subclass(EightMusesSearchMetadata::class)
subclass(HBrowseSearchMetadata::class)
subclass(HitomiSearchMetadata::class)
subclass(MangaDexSearchMetadata::class)
subclass(NHentaiSearchMetadata::class)
subclass(PervEdenSearchMetadata::class)
subclass(PururinSearchMetadata::class)
subclass(TsuminoSearchMetadata::class)
}
}
val raiseFlattenJson = Json {
ignoreUnknownKeys = true
serializersModule = module
}
fun titleDelegate(type: Int) = object : ReadWriteProperty<RaisedSearchMetadata, String?> {
/**
* Returns the value of the property for the given object.
* @param thisRef the object for which the value is requested.
* @param property the metadata for the property.
* @return the property value.
*/
override fun getValue(thisRef: RaisedSearchMetadata, property: KProperty<*>) =
thisRef.getTitleOfType(type)
/**
* Sets the value of the property for the given object.
* @param thisRef the object for which the value is requested.
* @param property the metadata for the property.
* @param value the value to set.
*/
override fun setValue(thisRef: RaisedSearchMetadata, property: KProperty<*>, value: String?) =
thisRef.replaceTitleOfType(type, value)
}
}
}
@@ -0,0 +1,10 @@
package exh.metadata.metadata.base
import kotlinx.serialization.Serializable
@Serializable
data class RaisedTag(
val namespace: String?,
val name: String,
val type: Int,
)
@@ -0,0 +1,9 @@
package exh.metadata.metadata.base
import kotlinx.serialization.Serializable
@Serializable
data class RaisedTitle(
val title: String,
val type: Int = 0,
)
@@ -0,0 +1,26 @@
package exh.metadata.sql.models
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
@Serializable
data class SearchMetadata(
// Manga ID this gallery is linked to
val mangaId: Long,
// Gallery uploader
val uploader: String?,
// Extra data attached to this metadata, in JSON format
val extra: String,
// Indexed extra data attached to this metadata
val indexedExtra: String?,
// The version of this metadata's extra. Used to track changes to the 'extra' field's schema
val extraVersion: Int,
) {
// Transient information attached to this piece of metadata, useful for caching
var transientCache: Map<String, @Contextual Any>? = null
}
@@ -0,0 +1,21 @@
package exh.metadata.sql.models
import kotlinx.serialization.Serializable
@Serializable
data class SearchTag(
// Tag identifier, unique
val id: Long?,
// Metadata this tag is attached to
val mangaId: Long,
// Tag namespace
val namespace: String?,
// Tag name
val name: String,
// Tag type
val type: Int,
)
@@ -0,0 +1,18 @@
package exh.metadata.sql.models
import kotlinx.serialization.Serializable
@Serializable
data class SearchTitle(
// Title identifier, unique
val id: Long?,
// Metadata this title is attached to
val mangaId: Long,
// Title
val title: String,
// Title type, useful for distinguishing between main/alt titles
val type: Int,
)
@@ -0,0 +1,297 @@
package exh.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.online.HttpSource
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
@Suppress("OverridingDeprecatedMember", "DEPRECATION")
abstract class DelegatedHttpSource(val delegate: HttpSource) : HttpSource() {
/**
* Returns the request for the popular manga given the page.
*
* @param page the page number to retrieve.
*/
override fun popularMangaRequest(page: Int) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun popularMangaParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* 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.
*/
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun searchMangaParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Returns the request for latest manga given the page.
*
* @param page the page number to retrieve.
*/
override fun latestUpdatesRequest(page: Int) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun latestUpdatesParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
override fun mangaDetailsParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
override fun chapterListParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
override fun pageListParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
override val baseUrl get() = delegate.baseUrl
/**
* Headers used for requests.
*/
override val headers get() = delegate.headers
/**
* Whether the source has support for latest updates.
*/
override val supportsLatest get() = delegate.supportsLatest
/**
* Name of the source.
*/
final override val name get() = delegate.name
// ===> OPTIONAL FIELDS
/**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id get() = delegate.id
/**
* Default network client for doing requests.
*/
final override val client get() = delegate.client
/**
* You must NEVER call super.client if you override this!
*/
open val baseHttpClient: OkHttpClient? = null
open val networkHttpClient: OkHttpClient get() = network.client
open val networkCloudflareClient: OkHttpClient get() = network.cloudflareClient
/**
* Visible name of the source.
*/
override fun toString() = delegate.toString()
/**
* 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.
*/
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
ensureDelegateCompatible()
return delegate.fetchPopularManga(page)
}
/**
* 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.
*/
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
ensureDelegateCompatible()
return delegate.fetchSearchManga(page, query, filters)
}
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
ensureDelegateCompatible()
return delegate.fetchLatestUpdates(page)
}
/**
* 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.
*/
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
ensureDelegateCompatible()
return delegate.fetchMangaDetails(manga)
}
/**
* [1.x API] Get the updated details for a manga.
*/
override suspend fun getMangaDetails(manga: SManga): SManga {
ensureDelegateCompatible()
return delegate.getMangaDetails(manga)
}
/**
* 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.
*/
override fun mangaDetailsRequest(manga: SManga): Request {
ensureDelegateCompatible()
return delegate.mangaDetailsRequest(manga)
}
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned
*
* @param manga the manga to look for chapters.
*/
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
ensureDelegateCompatible()
return delegate.fetchChapterList(manga)
}
/**
* [1.x API] Get all the available chapters for a manga.
*/
override suspend fun getChapterList(manga: SManga): List<SChapter> {
ensureDelegateCompatible()
return delegate.getChapterList(manga)
}
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
ensureDelegateCompatible()
return delegate.fetchPageList(chapter)
}
/**
* [1.x API] Get the list of pages a chapter has.
*/
override suspend fun getPageList(chapter: SChapter): List<Page> {
ensureDelegateCompatible()
return delegate.getPageList(chapter)
}
/**
* 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.
*/
override fun fetchImageUrl(page: Page): Observable<String> {
ensureDelegateCompatible()
return delegate.fetchImageUrl(page)
}
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
override fun fetchImage(page: Page): Observable<Response> {
ensureDelegateCompatible()
return delegate.fetchImage(page)
}
/**
* 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.
*/
override fun prepareNewChapter(chapter: SChapter, manga: SManga) {
ensureDelegateCompatible()
return delegate.prepareNewChapter(chapter, manga)
}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = delegate.getFilterList()
protected open fun ensureDelegateCompatible() {
if (versionId != delegate.versionId || lang != delegate.lang) {
throw IncompatibleDelegateException("Delegate source is not compatible (versionId: $versionId <=> ${delegate.versionId}, lang: $lang <=> ${delegate.lang})!")
}
}
class IncompatibleDelegateException(message: String) : RuntimeException(message)
init {
delegate.bindDelegate(this)
}
}
@@ -0,0 +1,256 @@
package exh.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.online.HttpSource
import okhttp3.Response
@Suppress("OverridingDeprecatedMember", "DEPRECATION")
class EnhancedHttpSource(
val originalSource: HttpSource,
val enhancedSource: HttpSource,
val delegateSources:() -> Boolean
) : HttpSource() {
/**
* Returns the request for the popular manga given the page.
*
* @param page the page number to retrieve.
*/
override fun popularMangaRequest(page: Int) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun popularMangaParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* 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.
*/
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun searchMangaParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Returns the request for latest manga given the page.
*
* @param page the page number to retrieve.
*/
override fun latestUpdatesRequest(page: Int) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a [MangasPage] object.
*
* @param response the response from the site.
*/
override fun latestUpdatesParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns the details of a manga.
*
* @param response the response from the site.
*/
override fun mangaDetailsParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a list of chapters.
*
* @param response the response from the site.
*/
override fun chapterListParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
override fun pageListParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException("Should never be called!")
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
override val baseUrl get() = source().baseUrl
/**
* Headers used for requests.
*/
override val headers get() = source().headers
/**
* Whether the source has support for latest updates.
*/
override val supportsLatest get() = source().supportsLatest
/**
* Name of the source.
*/
override val name get() = source().name
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang get() = source().lang
// ===> OPTIONAL FIELDS
/**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id get() = source().id
/**
* Default network client for doing requests.
*/
override val client get() = originalSource.client // source().client
/**
* Visible name of the source.
*/
override fun toString() = source().toString()
/**
* 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.
*/
override fun fetchPopularManga(page: Int) = source().fetchPopularManga(page)
/**
* 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.
*/
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
source().fetchSearchManga(page, query, filters)
/**
* Returns an observable containing a page with a list of latest manga updates.
*
* @param page the page number to retrieve.
*/
override fun fetchLatestUpdates(page: Int) = source().fetchLatestUpdates(page)
/**
* 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.
*/
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga) = source().fetchMangaDetails(manga)
/**
* [1.x API] Get the updated details for a manga.
*/
override suspend fun getMangaDetails(manga: SManga): SManga = source().getMangaDetails(manga)
/**
* 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.
*/
override fun mangaDetailsRequest(manga: SManga) = source().mangaDetailsRequest(manga)
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned
*
* @param manga the manga to look for chapters.
*/
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getChapterList"))
override fun fetchChapterList(manga: SManga) = source().fetchChapterList(manga)
/**
* [1.x API] Get all the available chapters for a manga.
*/
override suspend fun getChapterList(manga: SManga): List<SChapter> = source().getChapterList(manga)
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
@Deprecated("Use the 1.x API instead", replaceWith = ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter) = source().fetchPageList(chapter)
/**
* [1.x API] Get the list of pages a chapter has.
*/
override suspend fun getPageList(chapter: SChapter): List<Page> = source().getPageList(chapter)
/**
* 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.
*/
override fun fetchImageUrl(page: Page) = source().fetchImageUrl(page)
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
override fun fetchImage(page: Page) = source().fetchImage(page)
/**
* 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.
*/
override fun prepareNewChapter(chapter: SChapter, manga: SManga) =
source().prepareNewChapter(chapter, manga)
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = source().getFilterList()
fun source(): HttpSource {
return if (delegateSources()) {
enhancedSource
} else {
originalSource
}
}
}