Switch to a new Ktlint Formatter (#705)

* Switch to new Ktlint plugin

* Add ktlintCheck to PR builds

* Run formatter

* Put ktlint version in libs toml

* Fix lint

* Use Zip4Java from libs.toml
This commit is contained in:
Mitchell Syer
2023-10-06 23:38:39 -04:00
committed by GitHub
parent 3cd3cb0186
commit 849acfca3d
277 changed files with 6709 additions and 5090 deletions

View File

@@ -9,15 +9,11 @@ package eu.kanade.tachiyomi
import android.app.Application
import android.content.Context
// import android.content.res.Configuration
// import android.support.multidex.MultiDex
// import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektScope
import uy.kohesive.injekt.registry.default.DefaultRegistrar
open class App : Application() {
override fun onCreate() {
super.onCreate()
Injekt = InjektScope(DefaultRegistrar())

View File

@@ -1,6 +1,7 @@
package eu.kanade.tachiyomi
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.server.generated.BuildConfig
/**
* Used by extensions.
@@ -14,14 +15,14 @@ object AppInfo {
*
* @since extension-lib 1.3
*/
fun getVersionCode() = suwayomi.tachidesk.server.BuildConfig.REVISION.substring(1).toInt()
fun getVersionCode() = BuildConfig.REVISION.substring(1).toInt()
/**
* should be something like "0.13.1"
*
* @since extension-lib 1.3
*/
fun getVersionName() = suwayomi.tachidesk.server.BuildConfig.VERSION.substring(1)
fun getVersionName() = BuildConfig.VERSION.substring(1)
/**
* A list of supported image MIME types by the reader.

View File

@@ -28,7 +28,6 @@ import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get
class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingleton(app)

View File

@@ -9,7 +9,6 @@ import kotlinx.coroutines.withContext
* Util for evaluating JavaScript in sources.
*/
class JavaScriptEngine(context: Context) {
/**
* Evaluate arbitrary JavaScript code and get the result as a primitive type
* (e.g., String, Int).
@@ -19,9 +18,10 @@ class JavaScriptEngine(context: Context) {
* @return Result of JavaScript code as a primitive type.
*/
@Suppress("UNUSED", "UNCHECKED_CAST")
suspend fun <T> evaluate(script: String): T = withContext(Dispatchers.IO) {
QuickJs.create().use {
it.evaluate(script) as T
suspend fun <T> evaluate(script: String): T =
withContext(Dispatchers.IO) {
QuickJs.create().use {
it.evaluate(script) as T
}
}
}
}

View File

@@ -33,7 +33,10 @@ class MemoryCookieJar : CookieJar {
}
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
override fun saveFromResponse(
url: HttpUrl,
cookies: List<Cookie>,
) {
val cookiesToAdd = cookies.map { WrappedCookie.wrap(it) }
cache.removeAll(cookiesToAdd)

View File

@@ -27,8 +27,7 @@ import java.util.concurrent.TimeUnit
@Suppress("UNUSED_PARAMETER")
class NetworkHelper(context: Context) {
// private val preferences: PreferencesHelper by injectLazy()
// private val preferences: PreferencesHelper by injectLazy()
// private val cacheDir = File(context.cacheDir, "network_cache")
@@ -36,31 +35,36 @@ class NetworkHelper(context: Context) {
// Tachidesk -->
val cookieStore = PersistentCookieStore(context)
init {
CookieHandler.setDefault(
CookieManager(cookieStore, CookiePolicy.ACCEPT_ALL)
CookieManager(cookieStore, CookiePolicy.ACCEPT_ALL),
)
}
// Tachidesk <--
private val baseClientBuilder: OkHttpClient.Builder
get() {
val builder = OkHttpClient.Builder()
.cookieJar(PersistentCookieJar(cookieStore))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.addInterceptor(UserAgentInterceptor())
val builder =
OkHttpClient.Builder()
.cookieJar(PersistentCookieJar(cookieStore))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.addInterceptor(UserAgentInterceptor())
val httpLoggingInterceptor = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger {
val logger = KotlinLogging.logger { }
val httpLoggingInterceptor =
HttpLoggingInterceptor(
object : HttpLoggingInterceptor.Logger {
val logger = KotlinLogging.logger { }
override fun log(message: String) {
logger.debug { message }
override fun log(message: String) {
logger.debug { message }
}
},
).apply {
level = HttpLoggingInterceptor.Level.BASIC
}
}).apply {
level = HttpLoggingInterceptor.Level.BASIC
}
builder.addInterceptor(httpLoggingInterceptor)
// when (preferences.dohProvider()) {

View File

@@ -25,31 +25,32 @@ fun Call.asObservable(): Observable<Response> {
val call = clone()
// Wrap the call in a helper which handles both unsubscription and backpressure.
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
override fun request(n: Long) {
if (n == 0L || !compareAndSet(false, true)) return
val requestArbiter =
object : AtomicBoolean(), Producer, Subscription {
override fun request(n: Long) {
if (n == 0L || !compareAndSet(false, true)) return
try {
val response = call.execute()
if (!subscriber.isUnsubscribed) {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
try {
val response = call.execute()
if (!subscriber.isUnsubscribed) {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
}
}
}
}
override fun unsubscribe() {
// call.cancel()
}
override fun unsubscribe() {
// call.cancel()
}
override fun isUnsubscribed(): Boolean {
return call.isCanceled()
override fun isUnsubscribed(): Boolean {
return call.isCanceled()
}
}
}
subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter)
@@ -72,13 +73,19 @@ private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
return suspendCancellableCoroutine { continuation ->
val callback =
object : Callback {
override fun onResponse(call: Call, response: Response) {
override fun onResponse(
call: Call,
response: Response,
) {
continuation.resume(response) {
response.body.close()
}
}
override fun onFailure(call: Call, e: IOException) {
override fun onFailure(
call: Call,
e: IOException,
) {
// Don't bother with resuming the continuation if it is already cancelled.
if (continuation.isCancelled) return
val exception = IOException(e.message, e).apply { stackTrace = callStack }
@@ -116,16 +123,20 @@ suspend fun Call.awaitSuccess(): Response {
return response
}
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body, listener))
.build()
}
.build()
fun OkHttpClient.newCachelessCallWithProgress(
request: Request,
listener: ProgressListener,
): Call {
val progressClient =
newBuilder()
.cache(null)
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body, listener))
.build()
}
.build()
return progressClient.newCall(request)
}
@@ -139,7 +150,7 @@ context(Json)
@OptIn(ExperimentalSerializationApi::class)
fun <T> decodeFromJsonResponse(
deserializer: DeserializationStrategy<T>,
response: Response
response: Response,
): T {
return response.body.source().use {
decodeFromBufferedSource(deserializer, it)

View File

@@ -6,8 +6,10 @@ import okhttp3.HttpUrl
// from TachiWeb-Server
class PersistentCookieJar(private val store: PersistentCookieStore) : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
override fun saveFromResponse(
url: HttpUrl,
cookies: List<Cookie>,
) {
store.addAll(url, cookies)
}

View File

@@ -15,7 +15,6 @@ import kotlin.time.Duration.Companion.seconds
// from TachiWeb-Server
class PersistentCookieStore(context: Context) : CookieStore {
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
@@ -28,8 +27,9 @@ class PersistentCookieStore(context: Context) : CookieStore {
if (cookies != null) {
try {
val url = "http://$key".toHttpUrlOrNull() ?: continue
val nonExpiredCookies = cookies.mapNotNull { Cookie.parse(url, it) }
.filter { !it.hasExpired() }
val nonExpiredCookies =
cookies.mapNotNull { Cookie.parse(url, it) }
.filter { !it.hasExpired() }
cookieMap.put(key, nonExpiredCookies)
} catch (e: Exception) {
// Ignore
@@ -38,7 +38,10 @@ class PersistentCookieStore(context: Context) : CookieStore {
}
}
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
fun addAll(
url: HttpUrl,
cookies: List<Cookie>,
) {
lock.withLock {
val uri = url.toUri()
@@ -75,13 +78,17 @@ class PersistentCookieStore(context: Context) : CookieStore {
}
}
override fun get(uri: URI): List<HttpCookie> = get(uri.host).map {
it.toHttpCookie()
}
override fun get(uri: URI): List<HttpCookie> =
get(uri.host).map {
it.toHttpCookie()
}
fun get(url: HttpUrl) = get(url.toUri().host)
override fun add(uri: URI?, cookie: HttpCookie) {
override fun add(
uri: URI?,
cookie: HttpCookie,
) {
@Suppress("NAME_SHADOWING")
val uri = uri ?: URI("http://" + cookie.domain.removePrefix("."))
lock.withLock {
@@ -105,15 +112,19 @@ class PersistentCookieStore(context: Context) : CookieStore {
}
}
override fun remove(uri: URI?, cookie: HttpCookie): Boolean {
override fun remove(
uri: URI?,
cookie: HttpCookie,
): Boolean {
@Suppress("NAME_SHADOWING")
val uri = uri ?: URI("http://" + cookie.domain.removePrefix("."))
return lock.withLock {
val cookies = cookieMap[uri.host].orEmpty()
val index = cookies.indexOfFirst {
it.name == cookie.name &&
it.path == cookie.path
}
val index =
cookies.indexOfFirst {
it.name == cookie.name &&
it.path == cookie.path
}
if (index >= 0) {
val newList = cookies.toMutableList()
newList.removeAt(index)
@@ -132,45 +143,47 @@ class PersistentCookieStore(context: Context) : CookieStore {
private fun saveToDisk(uri: URI) {
// Get cookies to be stored in disk
val newValues = cookieMap[uri.host]
.orEmpty()
.asSequence()
.filter { it.persistent && !it.hasExpired() }
.map(Cookie::toString)
.toSet()
val newValues =
cookieMap[uri.host]
.orEmpty()
.asSequence()
.filter { it.persistent && !it.hasExpired() }
.map(Cookie::toString)
.toSet()
prefs.edit().putStringSet(uri.host, newValues).apply()
}
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt
private fun HttpCookie.toCookie(uri: URI) = Cookie.Builder()
.name(name)
.value(value)
.domain(uri.host)
.path(path ?: "/")
.let {
if (maxAge != -1L) {
it.expiresAt(System.currentTimeMillis() + maxAge.seconds.inWholeMilliseconds)
} else {
it.expiresAt(Long.MAX_VALUE)
private fun HttpCookie.toCookie(uri: URI) =
Cookie.Builder()
.name(name)
.value(value)
.domain(uri.host)
.path(path ?: "/")
.let {
if (maxAge != -1L) {
it.expiresAt(System.currentTimeMillis() + maxAge.seconds.inWholeMilliseconds)
} else {
it.expiresAt(Long.MAX_VALUE)
}
}
}
.let {
if (secure) {
it.secure()
} else {
it
.let {
if (secure) {
it.secure()
} else {
it
}
}
}
.let {
if (isHttpOnly) {
it.httpOnly()
} else {
it
.let {
if (isHttpOnly) {
it.httpOnly()
} else {
it
}
}
}
.build()
.build()
private fun Cookie.toHttpCookie(): HttpCookie {
val it = this
@@ -178,11 +191,12 @@ class PersistentCookieStore(context: Context) : CookieStore {
domain = it.domain
path = it.path
secure = it.secure
maxAge = if (it.persistent) {
-1
} else {
(it.expiresAt.milliseconds - System.currentTimeMillis().milliseconds).inWholeSeconds
}
maxAge =
if (it.persistent) {
-1
} else {
(it.expiresAt.milliseconds - System.currentTimeMillis().milliseconds).inWholeSeconds
}
isHttpOnly = it.httpOnly
}

View File

@@ -1,5 +1,9 @@
package eu.kanade.tachiyomi.network
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
fun update(
bytesRead: Long,
contentLength: Long,
done: Boolean,
)
}

View File

@@ -10,7 +10,6 @@ import okio.buffer
import java.io.IOException
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
source(responseBody.source()).buffer()
}
@@ -32,7 +31,10 @@ class ProgressResponseBody(private val responseBody: ResponseBody, private val p
var totalBytesRead = 0L
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
override fun read(
sink: Buffer,
byteCount: Long,
): Long {
val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0

View File

@@ -1,3 +1,5 @@
@file:Suppress("ktlint:standard:function-naming")
package eu.kanade.tachiyomi.network
import okhttp3.CacheControl
@@ -15,7 +17,7 @@ private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
fun GET(
url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
@@ -30,7 +32,7 @@ fun GET(
fun GET(
url: HttpUrl,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
@@ -43,7 +45,7 @@ fun POST(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)

View File

@@ -81,51 +81,56 @@ object CFClearance {
logger.debug { "resolveWithWebView($url)" }
val cookies = Playwright.create().use { playwright ->
playwright.chromium().launch(
LaunchOptions()
.setHeadless(false)
.apply {
if (serverConfig.socksProxyEnabled.value) {
setProxy("socks5://${serverConfig.socksProxyHost.value}:${serverConfig.socksProxyPort.value}")
val cookies =
Playwright.create().use { playwright ->
playwright.chromium().launch(
LaunchOptions()
.setHeadless(false)
.apply {
if (serverConfig.socksProxyEnabled.value) {
setProxy("socks5://${serverConfig.socksProxyHost.value}:${serverConfig.socksProxyPort.value}")
}
},
).use { browser ->
val userAgent = originalRequest.header("User-Agent")
if (userAgent != null) {
browser.newContext(Browser.NewContextOptions().setUserAgent(userAgent)).use { browserContext ->
browserContext.newPage().use { getCookies(it, url) }
}
} else {
browser.newPage().use { getCookies(it, url) }
}
).use { browser ->
val userAgent = originalRequest.header("User-Agent")
if (userAgent != null) {
browser.newContext(Browser.NewContextOptions().setUserAgent(userAgent)).use { browserContext ->
browserContext.newPage().use { getCookies(it, url) }
}
} else {
browser.newPage().use { getCookies(it, url) }
}
}
}
// Copy cookies to cookie store
cookies.groupBy { it.domain }.forEach { (domain, cookies) ->
network.cookieStore.addAll(
url = HttpUrl.Builder()
.scheme("http")
.host(domain)
.build(),
cookies = cookies
url =
HttpUrl.Builder()
.scheme("http")
.host(domain)
.build(),
cookies = cookies,
)
}
// Merge new and existing cookies for this request
// Find the cookies that we need to merge into this request
val convertedForThisRequest = cookies.filter {
it.matches(originalRequest.url)
}
val convertedForThisRequest =
cookies.filter {
it.matches(originalRequest.url)
}
// Extract cookies from current request
val existingCookies = Cookie.parseAll(
originalRequest.url,
originalRequest.headers
)
val existingCookies =
Cookie.parseAll(
originalRequest.url,
originalRequest.headers,
)
// Filter out existing values of cookies that we are about to merge in
val filteredExisting = existingCookies.filter { existing ->
convertedForThisRequest.none { converted -> converted.name == existing.name }
}
val filteredExisting =
existingCookies.filter { existing ->
convertedForThisRequest.none { converted -> converted.name == existing.name }
}
logger.trace { "Existing cookies" }
logger.trace { existingCookies.joinToString("; ") }
val newCookies = filteredExisting + convertedForThisRequest
@@ -143,7 +148,7 @@ object CFClearance {
Playwright.create().use { playwright ->
playwright.chromium().launch(
LaunchOptions()
.setHeadless(true)
.setHeadless(true),
).use { browser ->
browser.newPage().use { page ->
val userAgent = page.evaluate("() => {return navigator.userAgent}") as String
@@ -158,7 +163,10 @@ object CFClearance {
}
}
private fun getCookies(page: Page, url: String): List<Cookie> {
private fun getCookies(
page: Page,
url: String,
): List<Cookie> {
applyStealthInitScripts(page)
page.navigate(url)
val challengeResolved = waitForChallengeResolve(page)
@@ -198,7 +206,7 @@ object CFClearance {
ServerConfig::class.java.getResource("/cloudflare-js/navigator.permissions.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/navigator.webdriver.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.runtime.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.plugin.js")!!.readText()
ServerConfig::class.java.getResource("/cloudflare-js/chrome.plugin.js")!!.readText(),
)
}
@@ -215,12 +223,13 @@ object CFClearance {
val timeoutSeconds = 120
repeat(timeoutSeconds) {
page.waitForTimeout(1.seconds.toDouble(DurationUnit.MILLISECONDS))
val success = try {
page.querySelector("#challenge-form") == null
} catch (e: Exception) {
logger.debug(e) { "query Error" }
false
}
val success =
try {
page.querySelector("#challenge-form") == null
} catch (e: Exception) {
logger.debug(e) { "query Error" }
false
}
if (success) return true
}
return false

View File

@@ -34,7 +34,7 @@ import kotlin.time.toDurationUnit
fun OkHttpClient.Builder.rateLimit(
permits: Int,
period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS
unit: TimeUnit = TimeUnit.SECONDS,
) = addInterceptor(RateLimitInterceptor(null, permits, period.toDuration(unit.toDurationUnit())))
/**
@@ -50,17 +50,18 @@ fun OkHttpClient.Builder.rateLimit(
* @param permits [Int] Number of requests allowed within a period of units.
* @param period [Duration] The limiting duration. Defaults to 1.seconds.
*/
fun OkHttpClient.Builder.rateLimit(permits: Int, period: Duration = 1.seconds) =
addInterceptor(RateLimitInterceptor(null, permits, period))
fun OkHttpClient.Builder.rateLimit(
permits: Int,
period: Duration = 1.seconds,
) = addInterceptor(RateLimitInterceptor(null, permits, period))
/** We can probably accept domains or wildcards by comparing with [endsWith], etc. */
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
internal class RateLimitInterceptor(
private val host: String?,
private val permits: Int,
period: Duration
period: Duration,
) : Interceptor {
private val requestQueue = ArrayDeque<Long>(permits)
private val rateLimitMillis = period.inWholeMilliseconds
private val fairLock = Semaphore(1, true)
@@ -98,7 +99,8 @@ internal class RateLimitInterceptor(
} else if (hasRemovedExpired) {
break
} else {
try { // wait for the first entry to expire, or notified by cached response
try {
// wait for the first entry to expire, or notified by cached response
(requestQueue as Object).wait(requestQueue.first - periodStart)
} catch (_: InterruptedException) {
continue

View File

@@ -29,7 +29,7 @@ fun OkHttpClient.Builder.rateLimitHost(
httpUrl: HttpUrl,
permits: Int,
period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS
unit: TimeUnit = TimeUnit.SECONDS,
) = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period.toDuration(unit.toDurationUnit())))
/**
@@ -49,7 +49,7 @@ fun OkHttpClient.Builder.rateLimitHost(
fun OkHttpClient.Builder.rateLimitHost(
httpUrl: HttpUrl,
permits: Int,
period: Duration = 1.seconds
period: Duration = 1.seconds,
): OkHttpClient.Builder = addInterceptor(RateLimitInterceptor(httpUrl.host, permits, period))
/**
@@ -69,5 +69,5 @@ fun OkHttpClient.Builder.rateLimitHost(
fun OkHttpClient.Builder.rateLimitHost(
url: String,
permits: Int,
period: Duration = 1.seconds
period: Duration = 1.seconds,
): OkHttpClient.Builder = addInterceptor(RateLimitInterceptor(url.toHttpUrlOrNull()?.host, permits, period))

View File

@@ -9,11 +9,12 @@ class UserAgentInterceptor : Interceptor {
val originalRequest = chain.request()
return if (originalRequest.header("User-Agent").isNullOrEmpty()) {
val newRequest = originalRequest
.newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent", HttpSource.DEFAULT_USER_AGENT)
.build()
val newRequest =
originalRequest
.newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent", HttpSource.DEFAULT_USER_AGENT)
.build()
chain.proceed(newRequest)
} else {
chain.proceed(originalRequest)

View File

@@ -6,7 +6,6 @@ import rx.Observable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
interface CatalogueSource : Source {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
@@ -37,7 +36,11 @@ interface CatalogueSource : Source {
* @param filters the list of filters to apply.
*/
@Suppress("DEPRECATION")
suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): MangasPage {
return fetchSearchManga(page, query, filters).awaitSingle()
}
@@ -59,22 +62,23 @@ interface CatalogueSource : Source {
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getPopularManga")
ReplaceWith("getPopularManga"),
)
fun fetchPopularManga(page: Int): Observable<MangasPage> =
throw IllegalStateException("Not used")
fun fetchPopularManga(page: Int): Observable<MangasPage> = throw IllegalStateException("Not used")
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getSearchManga")
ReplaceWith("getSearchManga"),
)
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
throw IllegalStateException("Not used")
fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> = throw IllegalStateException("Not used")
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getLatestUpdates")
ReplaceWith("getLatestUpdates"),
)
fun fetchLatestUpdates(page: Int): Observable<MangasPage> =
throw IllegalStateException("Not used")
fun fetchLatestUpdates(page: Int): Observable<MangasPage> = throw IllegalStateException("Not used")
}

View File

@@ -8,14 +8,12 @@ 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 getSourcePreferences(): SharedPreferences = Injekt.get<Application>().getSharedPreferences(preferenceKey(), Context.MODE_PRIVATE)
fun setupPreferenceScreen(screen: PreferenceScreen)
}

View File

@@ -10,7 +10,6 @@ import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface Source {
/**
* Id for the source. Must be unique.
*/
@@ -60,19 +59,19 @@ interface Source {
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getMangaDetails")
ReplaceWith("getMangaDetails"),
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getChapterList")
ReplaceWith("getChapterList"),
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getPageList")
ReplaceWith("getPageList"),
)
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
}

View File

@@ -1,3 +1,5 @@
@file:Suppress("ktlint:standard:property-naming")
package eu.kanade.tachiyomi.source.local
import eu.kanade.tachiyomi.source.CatalogueSource
@@ -55,9 +57,8 @@ import com.github.junrar.Archive as JunrarArchive
class LocalSource(
private val fileSystem: LocalSourceFileSystem,
private val coverManager: LocalCoverManager
private val coverManager: LocalCoverManager,
) : CatalogueSource, UnmeteredSource {
private val json: Json by injectLazy()
private val xml: XML by injectLazy()
@@ -79,56 +80,64 @@ class LocalSource(
override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LATEST_FILTERS)
override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
override suspend fun getSearchManga(
page: Int,
query: String,
filters: FilterList,
): MangasPage {
val baseDirsFiles = fileSystem.getFilesInBaseDirectories()
val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L }
var mangaDirs = baseDirsFiles
// Filter out files that are hidden and is not a folder
.filter { it.isDirectory && !it.name.startsWith('.') }
.distinctBy { it.name }
.filter { // Filter by query or last modified
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
it.lastModified() >= lastModifiedLimit
var mangaDirs =
baseDirsFiles
// Filter out files that are hidden and is not a folder
.filter { it.isDirectory && !it.name.startsWith('.') }
.distinctBy { it.name }
.filter { // Filter by query or last modified
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
it.lastModified() >= lastModifiedLimit
}
}
}
filters.forEach { filter ->
when (filter) {
is OrderBy.Popular -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
}
mangaDirs =
if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
is OrderBy.Latest -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
mangaDirs =
if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
else -> {
/* Do nothing */
// Do nothing
}
}
}
// Transform mangaDirs to list of SManga
val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
val mangas =
mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
// Try to find the cover
coverManager.find(mangaDir.name)
?.takeIf(File::exists)
?.let { thumbnail_url = it.absolutePath }
// Try to find the cover
coverManager.find(mangaDir.name)
?.takeIf(File::exists)
?.let { thumbnail_url = it.absolutePath }
}
}
}
// Fetch chapters of all the manga
mangas.forEach { manga ->
@@ -156,67 +165,75 @@ class LocalSource(
}
// Manga details related
override suspend fun getMangaDetails(manga: SManga): SManga = withContext(Dispatchers.IO) {
coverManager.find(manga.url)?.let {
manga.thumbnail_url = it.absolutePath
}
// Augment manga details based on metadata files
try {
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url).toList()
val comicInfoFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE }
val noXmlFile = mangaDirFiles
.firstOrNull { it.name == ".noxml" }
val legacyJsonDetailsFile = mangaDirFiles
.firstOrNull { it.extension == "json" }
when {
// Top level ComicInfo.xml
comicInfoFile != null -> {
noXmlFile?.delete()
setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga)
}
// TODO: automatically convert these to ComicInfo.xml
legacyJsonDetailsFile != null -> {
json.decodeFromStream<MangaDetails>(legacyJsonDetailsFile.inputStream()).run {
title?.let { manga.title = it }
author?.let { manga.author = it }
artist?.let { manga.artist = it }
description?.let { manga.description = it }
genre?.let { manga.genre = it.joinToString() }
status?.let { manga.status = it }
}
}
// Copy ComicInfo.xml from chapter archive to top level if found
noXmlFile == null -> {
val chapterArchives = mangaDirFiles
.filter(Archive::isSupported)
.toList()
val mangaDir = fileSystem.getMangaDirectory(manga.url)
val folderPath = mangaDir?.absolutePath
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
if (copiedFile != null) {
setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga)
} else {
// Avoid re-scanning
File("$folderPath/.noxml").createNewFile()
}
}
override suspend fun getMangaDetails(manga: SManga): SManga =
withContext(Dispatchers.IO) {
coverManager.find(manga.url)?.let {
manga.thumbnail_url = it.absolutePath
}
} catch (e: Throwable) {
logger.error(e) { "Error setting manga details from local metadata for ${manga.title}" }
// Augment manga details based on metadata files
try {
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url).toList()
val comicInfoFile =
mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE }
val noXmlFile =
mangaDirFiles
.firstOrNull { it.name == ".noxml" }
val legacyJsonDetailsFile =
mangaDirFiles
.firstOrNull { it.extension == "json" }
when {
// Top level ComicInfo.xml
comicInfoFile != null -> {
noXmlFile?.delete()
setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga)
}
// TODO: automatically convert these to ComicInfo.xml
legacyJsonDetailsFile != null -> {
json.decodeFromStream<MangaDetails>(legacyJsonDetailsFile.inputStream()).run {
title?.let { manga.title = it }
author?.let { manga.author = it }
artist?.let { manga.artist = it }
description?.let { manga.description = it }
genre?.let { manga.genre = it.joinToString() }
status?.let { manga.status = it }
}
}
// Copy ComicInfo.xml from chapter archive to top level if found
noXmlFile == null -> {
val chapterArchives =
mangaDirFiles
.filter(Archive::isSupported)
.toList()
val mangaDir = fileSystem.getMangaDirectory(manga.url)
val folderPath = mangaDir?.absolutePath
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
if (copiedFile != null) {
setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga)
} else {
// Avoid re-scanning
File("$folderPath/.noxml").createNewFile()
}
}
}
} catch (e: Throwable) {
logger.error(e) { "Error setting manga details from local metadata for ${manga.title}" }
}
return@withContext manga
}
return@withContext manga
}
private fun copyComicInfoFileFromArchive(chapterArchives: List<File>, folderPath: String?): File? {
private fun copyComicInfoFileFromArchive(
chapterArchives: List<File>,
folderPath: String?,
): File? {
for (chapter in chapterArchives) {
when (Format.valueOf(chapter)) {
is Format.Zip -> {
@@ -243,7 +260,10 @@ class LocalSource(
return null
}
private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File {
private fun copyComicInfoFile(
comicInfoFileStream: InputStream,
folderPath: String?,
): File {
return File("$folderPath/$COMIC_INFO_FILE").apply {
outputStream().use { outputStream ->
comicInfoFileStream.use { it.copyTo(outputStream) }
@@ -251,10 +271,14 @@ class LocalSource(
}
}
private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) {
val comicInfo = KtXmlReader(stream, StandardCharsets.UTF_8.name()).use {
xml.decodeFromReader<ComicInfo>(it)
}
private fun setMangaDetailsFromComicInfoFile(
stream: InputStream,
manga: SManga,
) {
val comicInfo =
KtXmlReader(stream, StandardCharsets.UTF_8.name()).use {
xml.decodeFromReader<ComicInfo>(it)
}
manga.copyFromComicInfo(comicInfo)
}
@@ -267,15 +291,17 @@ class LocalSource(
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
name =
if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
date_upload = chapterFile.lastModified()
chapter_number = ChapterRecognition
.parseChapterNumber(manga.title, this.name, this.chapter_number.toDouble())
.toFloat()
chapter_number =
ChapterRecognition
.parseChapterNumber(manga.title, this.name, this.chapter_number.toDouble())
.toFloat()
val format = Format.valueOf(chapterFile)
if (format is Format.Epub) {
@@ -305,7 +331,7 @@ class LocalSource(
.mapIndexed { index, page ->
Page(
index,
imageUrl = applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name
imageUrl = applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name,
)
}
}
@@ -347,39 +373,46 @@ class LocalSource(
}
}
private fun updateCover(chapter: SChapter, manga: SManga): File? {
private fun updateCover(
chapter: SChapter,
manga: SManga,
): File? {
return try {
when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
val entry =
format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { coverManager.update(manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries.toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
val entry =
zip.entries.toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { coverManager.update(manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
JunrarArchive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
val entry =
archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { coverManager.update(manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
val entry =
epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { coverManager.update(manga, epub.getInputStream(it)) }
}
@@ -412,16 +445,17 @@ class LocalSource(
if (sourceRecord == null) {
// must do this to avoid database integrity errors
val extensionId = ExtensionTable.insertAndGetId {
it[apkName] = "localSource"
it[name] = EXTENSION_NAME
it[pkgName] = LocalSource::class.java.`package`.name
it[versionName] = "1.2"
it[versionCode] = 0
it[lang] = LANG
it[isNsfw] = false
it[isInstalled] = true
}
val extensionId =
ExtensionTable.insertAndGetId {
it[apkName] = "localSource"
it[name] = EXTENSION_NAME
it[pkgName] = LocalSource::class.java.`package`.name
it[versionName] = "1.2"
it[versionCode] = 0
it[lang] = LANG
it[isNsfw] = false
it[isInstalled] = true
}
SourceTable.insert {
it[id] = ID

View File

@@ -5,8 +5,9 @@ import eu.kanade.tachiyomi.source.model.Filter
sealed class OrderBy(selection: Selection) : Filter.Sort(
"Order by",
arrayOf("Title", "Date"),
selection
selection,
) {
class Popular() : OrderBy(Selection(0, true))
class Latest() : OrderBy(Selection(1, false))
}

View File

@@ -9,9 +9,8 @@ import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg"
class LocalCoverManager(
private val fileSystem: LocalSourceFileSystem
private val fileSystem: LocalSourceFileSystem,
) {
fun find(mangaUrl: String): File? {
return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with 'cover'
@@ -24,7 +23,7 @@ class LocalCoverManager(
fun update(
manga: SManga,
inputStream: InputStream
inputStream: InputStream,
): File? {
val directory = fileSystem.getMangaDirectory(manga.url)
if (directory == null) {

View File

@@ -3,10 +3,10 @@ package eu.kanade.tachiyomi.source.local.io
import java.io.File
object Archive {
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")
fun isSupported(file: File): Boolean = with(file) {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
fun isSupported(file: File): Boolean =
with(file) {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
}

View File

@@ -4,22 +4,25 @@ import java.io.File
sealed interface Format {
data class Directory(val file: File) : Format
data class Zip(val file: File) : Format
data class Rar(val file: File) : Format
data class Epub(val file: File) : Format
class UnknownFormatException : Exception()
companion object {
fun valueOf(file: File) = with(file) {
when {
isDirectory -> Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Rar(this)
extension.equals("epub", true) -> Epub(this)
else -> throw UnknownFormatException()
fun valueOf(file: File) =
with(file) {
when {
isDirectory -> Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Rar(this)
extension.equals("epub", true) -> Epub(this)
else -> throw UnknownFormatException()
}
}
}
}
}

View File

@@ -4,9 +4,8 @@ import suwayomi.tachidesk.server.ApplicationDirs
import java.io.File
class LocalSourceFileSystem(
private val applicationDirs: ApplicationDirs
private val applicationDirs: ApplicationDirs,
) {
fun getBaseDirectories(): Sequence<File> {
return sequenceOf(File(applicationDirs.localMangaRoot))
}

View File

@@ -7,7 +7,6 @@ import java.io.File
* Loader used to load a chapter from a .epub file.
*/
class EpubPageLoader(file: File) : PageLoader {
private val epub = EpubFile(file)
override suspend fun getPages(): List<ReaderPage> {

View File

@@ -13,7 +13,6 @@ import java.io.PipedOutputStream
* Loader used to load a chapter from a .rar or .cbr file.
*/
class RarPageLoader(file: File) : PageLoader {
private val rar = Archive(file)
override suspend fun getPages(): List<ReaderPage> {
@@ -35,7 +34,10 @@ class RarPageLoader(file: File) : PageLoader {
/**
* Returns an input stream for the given [header].
*/
private fun getStream(rar: Archive, header: FileHeader): InputStream {
private fun getStream(
rar: Archive,
header: FileHeader,
): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
synchronized(this) {

View File

@@ -7,5 +7,5 @@ class ReaderPage(
index: Int,
url: String = "",
imageUrl: String? = null,
var stream: (() -> InputStream)? = null
var stream: (() -> InputStream)? = null,
) : Page(index, url, imageUrl, null)

View File

@@ -9,7 +9,6 @@ import java.io.File
* Loader used to load a chapter from a .zip or .cbz file.
*/
class ZipPageLoader(file: File) : PageLoader {
private val zip = ZipFile(file)
override suspend fun getPages(): List<ReaderPage> {

View File

@@ -16,7 +16,7 @@ fun SManga.copyFromComicInfo(comicInfo: ComicInfo) {
listOfNotNull(
comicInfo.genre?.value,
comicInfo.tags?.value,
comicInfo.categories?.value
comicInfo.categories?.value,
)
.distinct()
.joinToString(", ") { it.trim() }
@@ -28,7 +28,7 @@ fun SManga.copyFromComicInfo(comicInfo: ComicInfo) {
comicInfo.inker?.value,
comicInfo.colorist?.value,
comicInfo.letterer?.value,
comicInfo.coverArtist?.value
comicInfo.coverArtist?.value,
)
.flatMap { it.split(", ") }
.distinct()
@@ -57,7 +57,7 @@ data class ComicInfo(
val tags: Tags?,
val web: Web?,
val publishingStatus: PublishingStatusTachiyomi?,
val categories: CategoriesTachiyomi?
val categories: CategoriesTachiyomi?,
) {
@Suppress("UNUSED")
@XmlElement(false)
@@ -71,73 +71,105 @@ data class ComicInfo(
@Serializable
@XmlSerialName("Title", "", "")
data class Title(@XmlValue(true) val value: String = "")
data class Title(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Series", "", "")
data class Series(@XmlValue(true) val value: String = "")
data class Series(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Number", "", "")
data class Number(@XmlValue(true) val value: String = "")
data class Number(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Summary", "", "")
data class Summary(@XmlValue(true) val value: String = "")
data class Summary(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Writer", "", "")
data class Writer(@XmlValue(true) val value: String = "")
data class Writer(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Penciller", "", "")
data class Penciller(@XmlValue(true) val value: String = "")
data class Penciller(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Inker", "", "")
data class Inker(@XmlValue(true) val value: String = "")
data class Inker(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Colorist", "", "")
data class Colorist(@XmlValue(true) val value: String = "")
data class Colorist(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Letterer", "", "")
data class Letterer(@XmlValue(true) val value: String = "")
data class Letterer(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("CoverArtist", "", "")
data class CoverArtist(@XmlValue(true) val value: String = "")
data class CoverArtist(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Translator", "", "")
data class Translator(@XmlValue(true) val value: String = "")
data class Translator(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Genre", "", "")
data class Genre(@XmlValue(true) val value: String = "")
data class Genre(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Tags", "", "")
data class Tags(@XmlValue(true) val value: String = "")
data class Tags(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Web", "", "")
data class Web(@XmlValue(true) val value: String = "")
data class Web(
@XmlValue(true) val value: String = "",
)
// The spec doesn't have a good field for this
@Serializable
@XmlSerialName("PublishingStatusTachiyomi", "http://www.w3.org/2001/XMLSchema", "ty")
data class PublishingStatusTachiyomi(@XmlValue(true) val value: String = "")
data class PublishingStatusTachiyomi(
@XmlValue(true) val value: String = "",
)
@Serializable
@XmlSerialName("Categories", "http://www.w3.org/2001/XMLSchema", "ty")
data class CategoriesTachiyomi(@XmlValue(true) val value: String = "")
data class CategoriesTachiyomi(
@XmlValue(true) val value: String = "",
)
}
enum class ComicInfoPublishingStatus(
val comicInfoValue: String,
val sMangaModelValue: Int
val sMangaModelValue: Int,
) {
ONGOING("Ongoing", SManga.ONGOING),
COMPLETED("Completed", SManga.COMPLETED),
@@ -145,7 +177,7 @@ enum class ComicInfoPublishingStatus(
PUBLISHING_FINISHED("Publishing finished", SManga.PUBLISHING_FINISHED),
CANCELLED("Cancelled", SManga.CANCELLED),
ON_HIATUS("On hiatus", SManga.ON_HIATUS),
UNKNOWN("Unknown", SManga.UNKNOWN)
UNKNOWN("Unknown", SManga.UNKNOWN),
;
companion object {

View File

@@ -9,5 +9,5 @@ class MangaDetails(
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: Int? = null
val status: Int? = null,
)

View File

@@ -4,15 +4,22 @@ package eu.kanade.tachiyomi.source.model
// sealed class Filter<T>(val name: String, var state: T) {
open 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) {
val displayValues get() = values.map { it.toString() }
}
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 {

View File

@@ -1,6 +1,5 @@
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())
}

View File

@@ -9,18 +9,23 @@ 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
// Deprecated but can't be deleted due to extensions
@Transient var uri: Uri? = null,
) : ProgressListener {
private val _progress = MutableStateFlow(0)
val progress = _progress.asStateFlow()
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
_progress.value = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
override fun update(
bytesRead: Long,
contentLength: Long,
done: Boolean,
) {
_progress.value =
if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
companion object {

View File

@@ -1,9 +1,10 @@
@file:Suppress("ktlint:standard:property-naming")
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SChapter : Serializable {
var url: String
var name: String

View File

@@ -1,7 +1,8 @@
@file:Suppress("ktlint:standard:property-naming")
package eu.kanade.tachiyomi.source.model
class SChapterImpl : SChapter {
override lateinit var url: String
override lateinit var name: String

View File

@@ -1,9 +1,10 @@
@file:Suppress("ktlint:standard:property-naming")
package eu.kanade.tachiyomi.source.model
import java.io.Serializable
interface SManga : Serializable {
var url: String
var title: String

View File

@@ -1,7 +1,8 @@
@file:Suppress("ktlint:standard:property-naming")
package eu.kanade.tachiyomi.source.model
class SMangaImpl : SManga {
override lateinit var url: String
override lateinit var title: String

View File

@@ -2,5 +2,5 @@ package eu.kanade.tachiyomi.source.model
enum class UpdateStrategy {
ALWAYS_UPDATE,
ONLY_FETCH_ONCE
ONLY_FETCH_ONCE,
}

View File

@@ -19,7 +19,6 @@ import okhttp3.Response
import rx.Observable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import uy.kohesive.injekt.injectLazy
// import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
@@ -28,7 +27,6 @@ import java.security.MessageDigest
* A simple implementation for sources from a website.
*/
abstract class HttpSource : CatalogueSource {
/**
* Network service.
*/
@@ -91,7 +89,11 @@ abstract class HttpSource : CatalogueSource {
* @param versionId [Int] the version ID of the source
* @return a unique ID for the source
*/
protected fun generateId(name: String, lang: String, versionId: Int): Long {
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
@@ -100,9 +102,10 @@ abstract class HttpSource : CatalogueSource {
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", DEFAULT_USER_AGENT)
}
protected open fun headersBuilder() =
Headers.Builder().apply {
add("User-Agent", DEFAULT_USER_AGENT)
}
/**
* Visible name of the source.
@@ -147,7 +150,11 @@ abstract class HttpSource : CatalogueSource {
* @param filters the list of filters to apply.
*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
@@ -162,7 +169,11 @@ abstract class HttpSource : CatalogueSource {
* @param query the search query.
* @param filters the list of filters to apply.
*/
protected abstract fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
protected abstract fun searchMangaRequest(
page: Int,
query: String,
filters: FilterList,
): Request
/**
* Parses the response from the site and returns a [MangasPage] object.
@@ -450,7 +461,10 @@ abstract class HttpSource : CatalogueSource {
* @param chapter the chapter to be added.
* @param manga the manga of the chapter.
*/
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {}
open fun prepareNewChapter(
chapter: SChapter,
manga: SManga,
) {}
/**
* Returns the list of filters for the source.

View File

@@ -13,7 +13,6 @@ import org.jsoup.nodes.Element
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
abstract class ParsedHttpSource : HttpSource() {
/**
* Parses the response from the site and returns a [MangasPage] object.
*
@@ -22,13 +21,15 @@ abstract class ParsedHttpSource : HttpSource() {
override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
val mangas =
document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element)
}
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
val hasNextPage =
popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
@@ -60,13 +61,15 @@ abstract class ParsedHttpSource : HttpSource() {
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
val mangas =
document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element)
}
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
val hasNextPage =
searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}
@@ -98,13 +101,15 @@ abstract class ParsedHttpSource : HttpSource() {
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val mangas =
document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
val hasNextPage =
latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return MangasPage(mangas, hasNextPage)
}

View File

@@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.source.model.SManga
*/
@Suppress("unused")
interface ResolvableSource : Source {
/**
* Whether this source may potentially handle the given URI.
*

View File

@@ -5,11 +5,17 @@ import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
fun Element.selectText(css: String, defaultValue: String? = null): String? {
fun Element.selectText(
css: String,
defaultValue: String? = null,
): String? {
return select(css).first()?.text() ?: defaultValue
}
fun Element.selectInt(css: String, defaultValue: Int = 0): Int {
fun Element.selectInt(
css: String,
defaultValue: Int = 0,
): Int {
return select(css).first()?.text()?.toInt() ?: defaultValue
}

View File

@@ -4,7 +4,6 @@ package eu.kanade.tachiyomi.util.chapter
* -R> = regex conversion.
*/
object ChapterRecognition {
private const val NUMBER_PATTERN = """([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?"""
/**
@@ -30,7 +29,11 @@ object ChapterRecognition {
*/
private val unwantedWhiteSpace = Regex("""\s(?=extra|special|omake)""")
fun parseChapterNumber(mangaTitle: String, chapterName: String, chapterNumber: Double? = null): Double {
fun parseChapterNumber(
mangaTitle: String,
chapterName: String,
chapterNumber: Double? = null,
): Double {
// If chapter number is known return.
if (chapterNumber != null && (chapterNumber == -2.0 || chapterNumber > -1.0)) {
return chapterNumber
@@ -81,7 +84,10 @@ object ChapterRecognition {
* @param alpha alpha value of regex
* @return decimal/alpha float value
*/
private fun checkForDecimal(decimal: String?, alpha: String?): Double {
private fun checkForDecimal(
decimal: String?,
alpha: String?,
): Double {
if (!decimal.isNullOrEmpty()) {
return decimal.toDouble()
}

View File

@@ -3,11 +3,11 @@ package eu.kanade.tachiyomi.util.lang
import java.security.MessageDigest
object Hash {
private val chars = charArrayOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f'
)
private val chars =
charArrayOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f',
)
private val MD5 get() = MessageDigest.getInstance("MD5")

View File

@@ -7,7 +7,10 @@ import kotlin.math.floor
* Replaces the given string to have at most [count] characters using [replacement] at its end.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.chop(count: Int, replacement: String = ""): String {
fun String.chop(
count: Int,
replacement: String = "",
): String {
return if (length > count) {
take(count - replacement.length) + replacement
} else {
@@ -19,7 +22,10 @@ fun String.chop(count: Int, replacement: String = "…"): String {
* Replaces the given string to have at most [count] characters using [replacement] near the center.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
fun String.truncateCenter(
count: Int,
replacement: String = "...",
): String {
if (length <= count) {
return this
}

View File

@@ -12,7 +12,6 @@ import java.io.InputStream
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
/**
* Zip file of this epub.
*/
@@ -81,9 +80,10 @@ class EpubFile(file: File) : Closeable {
* Returns all the pages from the epub.
*/
fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { node -> "application/xhtml+xml" == node.attr("media-type") }
.associateBy { it.attr("id") }
val pages =
document.select("manifest > item")
.filter { node -> "application/xhtml+xml" == node.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
@@ -92,7 +92,10 @@ class EpubFile(file: File) : Closeable {
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> {
private fun getImagesFromPages(
pages: List<String>,
packageHref: String,
): List<String> {
val result = mutableListOf<String>()
val basePath = getParentDirectory(packageHref)
pages.forEach { page ->
@@ -128,7 +131,10 @@ class EpubFile(file: File) : Closeable {
/**
* Resolves a zip path from base and relative components and a path separator.
*/
private fun resolveZipPath(basePath: String, relativePath: String): String {
private fun resolveZipPath(
basePath: String,
relativePath: String,
): String {
if (relativePath.startsWith(pathSeparator)) {
// Path is absolute, so return as-is.
return relativePath