Komga: README, chapter timestamp shenanigans, refactor (#1313)

* Komga: README, chapter timestamp shenanigans, refactor

* Repeating by password length is probably fine

* Reuse the CoroutineScope
This commit is contained in:
beerpsi 2024-02-17 12:31:40 +07:00 committed by GitHub
parent bbeb5e0157
commit ca3589bb80
8 changed files with 347 additions and 269 deletions

41
src/all/komga/README.md Normal file
View File

@ -0,0 +1,41 @@
# Komga
Table of Content
- [FAQ](#FAQ)
- [Why do I see no manga?](#why-do-i-see-no-manga)
- [Where can I get more information about Komga?](#where-can-i-get-more-information-about-komga)
- [The Komga extension stopped working?](#the-komga-extension-stopped-working)
- [Can I add more than one Komga server or user?](#can-i-add-more-than-one-komga-server-or-user)
- [Can I test the Komga extension before setting up my own server?](#can-i-test-the-komga-extension-before-setting-up-my-own-server)
- [Guides](#Guides)
- [How do I add my Komga server to the app?](#how-do-i-add-my-komga-server-to-the-app)
## FAQ
### Why do I see no manga?
Komga is a self-hosted comic/manga media server.
### Where can I get more information about Komga?
You can visit the [Komga](https://komga.org/) website for for more information.
### The Komga extension stopped working?
Make sure that your Komga server and extension are on the newest version.
### Can I add more than one Komga server or user?
Yes, currently you can add up to 11 different Komga instances to the app. The number of instances
available can be customized in extension settings.
### Can I test the Komga extension before setting up my own server?
Yes, you can try it out with the DEMO server `https://demo.komga.org`, username `demo@komga.org` and
password `komga-demo`.
### Why are EPUB chapters/books missing?
Mihon/Tachiyomi does not support reading text. EPUB files containing only images will be available
if your Komga server version is [1.9.0](https://github.com/gotson/komga/releases/tag/1.9.0)
or newer.
## Guides
### How do I add my Komga server to the app?
Go into the settings of the Komga extension (under Browse -> Extensions) and fill in your server
address and login details.

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Komga' extName = 'Komga'
extClass = '.KomgaFactory' extClass = '.KomgaFactory'
extVersionCode = 55 extVersionCode = 56
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -5,15 +5,10 @@ import android.content.SharedPreferences
import android.text.InputType import android.text.InputType
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.AppInfo import eu.kanade.tachiyomi.AppInfo
import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.addEditTextPreference
import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.isFromReadList
import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.parseAs
import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.toSManga
import eu.kanade.tachiyomi.extension.all.komga.dto.AuthorDto import eu.kanade.tachiyomi.extension.all.komga.dto.AuthorDto
import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto
import eu.kanade.tachiyomi.extension.all.komga.dto.CollectionDto import eu.kanade.tachiyomi.extension.all.komga.dto.CollectionDto
@ -23,6 +18,7 @@ import eu.kanade.tachiyomi.extension.all.komga.dto.PageWrapperDto
import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto
import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
@ -32,6 +28,11 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Credentials import okhttp3.Credentials
import okhttp3.Dns import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@ -39,15 +40,12 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Single import org.apache.commons.text.StringSubstitutor
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Locale import java.util.Locale
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
open class Komga(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { open class Komga(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() {
@ -57,7 +55,13 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
private val displayName by lazy { preferences.getString(PREF_DISPLAY_NAME, "")!! } private val displayName by lazy { preferences.getString(PREF_DISPLAY_NAME, "")!! }
override val name by lazy { "Komga${displayName.ifBlank { suffix }.let { if (it.isNotBlank()) " ($it)" else "" }}" } override val name by lazy {
val displayNameSuffix = displayName
.ifBlank { suffix }
.let { if (it.isNotBlank()) " ($it)" else "" }
"Komga$displayNameSuffix"
}
override val lang = "all" override val lang = "all"
@ -79,6 +83,8 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
private val defaultLibraries private val defaultLibraries
get() = preferences.getStringSet(PREF_DEFAULT_LIBRARIES, emptySet())!! get() = preferences.getStringSet(PREF_DEFAULT_LIBRARIES, emptySet())!!
private val json: Json by injectLazy()
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.set("User-Agent", "TachiyomiKomga/${AppInfo.getVersionName()}") .set("User-Agent", "TachiyomiKomga/${AppInfo.getVersionName()}")
@ -106,7 +112,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
) )
override fun popularMangaParse(response: Response): MangasPage = override fun popularMangaParse(response: Response): MangasPage =
KomgaUtils.processSeriesPage(response, baseUrl) processSeriesPage(response, baseUrl)
override fun latestUpdatesRequest(page: Int): Request = override fun latestUpdatesRequest(page: Int): Request =
searchMangaRequest( searchMangaRequest(
@ -118,11 +124,9 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
) )
override fun latestUpdatesParse(response: Response): MangasPage = override fun latestUpdatesParse(response: Response): MangasPage =
KomgaUtils.processSeriesPage(response, baseUrl) processSeriesPage(response, baseUrl)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
runCatching { fetchFilterOptions() }
val collectionId = (filters.find { it is CollectionSelect } as? CollectionSelect)?.let { val collectionId = (filters.find { it is CollectionSelect } as? CollectionSelect)?.let {
it.collections[it.state].id it.collections[it.state].id
} }
@ -164,7 +168,17 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
} }
override fun searchMangaParse(response: Response): MangasPage = override fun searchMangaParse(response: Response): MangasPage =
KomgaUtils.processSeriesPage(response, baseUrl) processSeriesPage(response, baseUrl)
private fun processSeriesPage(response: Response, baseUrl: String): MangasPage {
val data = if (response.isFromReadList()) {
response.parseAs<PageWrapperDto<ReadListDto>>()
} else {
response.parseAs<PageWrapperDto<SeriesDto>>()
}
return MangasPage(data.content.map { it.toSManga(baseUrl) }, !data.last)
}
override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "") override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "")
@ -199,10 +213,19 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
SChapter.create().apply { SChapter.create().apply {
chapter_number = if (!isFromReadList) book.metadata.numberSort else index + 1F chapter_number = if (!isFromReadList) book.metadata.numberSort else index + 1F
url = "$baseUrl/api/v1/books/${book.id}" url = "$baseUrl/api/v1/books/${book.id}"
name = KomgaUtils.formatChapterName(book, chapterNameTemplate, isFromReadList) name = book.getChapterName(chapterNameTemplate, isFromReadList)
scanlator = book.metadata.authors.filter { it.role == "translator" }.joinToString { it.name } scanlator = book.metadata.authors
date_upload = book.metadata.releaseDate?.let { KomgaUtils.parseDate(it) } .filter { it.role == "translator" }
?: KomgaUtils.parseDateTime(book.lastModified) .joinToString { it.name }
date_upload = when {
book.metadata.releaseDate != null -> parseDate(book.metadata.releaseDate)
book.created != null -> parseDateTime(book.created)
// XXX: `Book.fileLastModified` actually uses the server's running timezone,
// not UTC, even if the timestamp ends with a Z! We cannot determine the
// server's timezone, which is why this is a last resort option.
else -> parseDateTime(book.fileLastModified)
}
} }
} }
.sortedByDescending { it.chapter_number } .sortedByDescending { it.chapter_number }
@ -215,7 +238,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
return pages.map { return pages.map {
val url = "${response.request.url}/${it.number}" + val url = "${response.request.url}/${it.number}" +
if (!supportedImageTypes.contains(it.mediaType)) { if (!SUPPORTED_IMAGE_TYPES.contains(it.mediaType)) {
"?convert=png" "?convert=png"
} else { } else {
"" ""
@ -228,6 +251,8 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
override fun getFilterList(): FilterList { override fun getFilterList(): FilterList {
fetchFilterOptions()
val filters = mutableListOf<Filter<*>>( val filters = mutableListOf<Filter<*>>(
UnreadFilter(), UnreadFilter(),
InProgressFilter(), InProgressFilter(),
@ -265,8 +290,14 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
publishers.map { UriMultiSelectOption(it) }, publishers.map { UriMultiSelectOption(it) },
), ),
).apply { ).apply {
if (collections.isEmpty() && libraries.isEmpty() && genres.isEmpty() && tags.isEmpty() && publishers.isEmpty()) { if (fetchFilterStatus != FetchFilterStatus.FETCHED) {
add(0, Filter.Header("Press 'Reset' to show filtering options")) val message = if (fetchFilterStatus == FetchFilterStatus.NOT_FETCHED && fetchFiltersAttempts >= 3) {
"Failed to fetch filtering options from the server"
} else {
"Press 'Reset' to show filtering options"
}
add(0, Filter.Header(message))
add(1, Filter.Separator()) add(1, Filter.Separator())
} }
@ -278,6 +309,8 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
} }
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
fetchFilterOptions()
if (suffix.isEmpty()) { if (suffix.isEmpty()) {
ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = PREF_EXTRA_SOURCES_COUNT key = PREF_EXTRA_SOURCES_COUNT
@ -305,9 +338,10 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
title = "Address", title = "Address",
default = "", default = "",
summary = baseUrl.ifBlank { "The server address" }, summary = baseUrl.ifBlank { "The server address" },
dialogMessage = "The address must not end with a forward slash.",
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI,
validate = { it.toHttpUrlOrNull() != null }, validate = { it.toHttpUrlOrNull() != null && !it.endsWith("/") },
validationMessage = "The URL is invalid or malformed", validationMessage = "The URL is invalid, malformed, or ends with a slash",
key = PREF_ADDRESS, key = PREF_ADDRESS,
restartRequired = true, restartRequired = true,
) )
@ -334,7 +368,7 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
append("Show content from selected libraries by default.") append("Show content from selected libraries by default.")
if (libraries.isEmpty()) { if (libraries.isEmpty()) {
append(" Browse the source to load available options.") append(" Exit and enter the settings menu to load options.")
} }
} }
entries = libraries.map { it.name }.toTypedArray() entries = libraries.map { it.name }.toTypedArray()
@ -342,10 +376,25 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
setDefaultValue(emptySet<String>()) setDefaultValue(emptySet<String>())
}.also(screen::addPreference) }.also(screen::addPreference)
EditTextPreference(screen.context).apply { val values = hashMapOf(
key = PREF_CHAPTER_NAME_TEMPLATE "title" to "",
title = "Chapter title format" "seriesTitle" to "",
summary = "Customize how chapter names appear. Chapters in read lists will always be prefixed by the series' name." "number" to "",
"createdDate" to "",
"releaseDate" to "",
"size" to "",
"sizeBytes" to "",
)
val stringSubstitutor = StringSubstitutor(values, "{", "}").apply {
isEnableUndefinedVariableException = true
}
screen.addEditTextPreference(
key = PREF_CHAPTER_NAME_TEMPLATE,
title = "Chapter title format",
summary = "Customize how chapter names appear. Chapters in read lists will always be prefixed by the series' name.",
inputType = InputType.TYPE_CLASS_TEXT,
default = PREF_CHAPTER_NAME_TEMPLATE_DEFAULT,
dialogMessage = """ dialogMessage = """
|Supported placeholders: |Supported placeholders:
|- {title}: Chapter name |- {title}: Chapter name
@ -355,10 +404,19 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
|- {releaseDate}: Chapter release date |- {releaseDate}: Chapter release date
|- {size}: Chapter file size (formatted) |- {size}: Chapter file size (formatted)
|- {sizeBytes}: Chapter file size (in bytes) |- {sizeBytes}: Chapter file size (in bytes)
""".trimMargin() |If you wish to place some text between curly brackets, place the escape character "$"
|before the opening curly bracket, e.g. ${'$'}{series}.
setDefaultValue(PREF_CHAPTER_NAME_TEMPLATE_DEFAULT) """.trimMargin(),
}.also(screen::addPreference) validate = {
try {
stringSubstitutor.replace(it)
true
} catch (e: IllegalArgumentException) {
false
}
},
validationMessage = "Invalid chapter title format",
)
} }
private var libraries = emptyList<LibraryDto>() private var libraries = emptyList<LibraryDto>()
@ -368,72 +426,72 @@ open class Komga(private val suffix: String = "") : ConfigurableSource, Unmetere
private var publishers = emptySet<String>() private var publishers = emptySet<String>()
private var authors = emptyMap<String, List<AuthorDto>>() // roles to list of authors private var authors = emptyMap<String, List<AuthorDto>>() // roles to list of authors
private var fetchFiltersFailed = false private var fetchFilterStatus = FetchFilterStatus.NOT_FETCHED
private var fetchFiltersAttempts = 0 private var fetchFiltersAttempts = 0
private val scope = CoroutineScope(Dispatchers.IO)
private val fetchFiltersLock = ReentrantReadWriteLock()
private fun fetchFilterOptions() { private fun fetchFilterOptions() {
if (baseUrl.isBlank()) { if (baseUrl.isBlank() || fetchFilterStatus != FetchFilterStatus.NOT_FETCHED || fetchFiltersAttempts >= 3) {
return return
} }
Single.fromCallable { fetchFilterStatus = FetchFilterStatus.FETCHING
fetchFiltersLock.read { fetchFiltersAttempts++
if (fetchFiltersAttempts > 3 || (fetchFiltersAttempts > 0 && !fetchFiltersFailed)) {
return@fromCallable
}
}
fetchFiltersLock.write { scope.launch {
fetchFiltersFailed = try { try {
libraries = client.newCall(GET("$baseUrl/api/v1/libraries")).execute().parseAs() libraries = client.newCall(GET("$baseUrl/api/v1/libraries")).await().parseAs()
collections = client collections = client
.newCall(GET("$baseUrl/api/v1/collections?unpaged=true")) .newCall(GET("$baseUrl/api/v1/collections?unpaged=true"))
.execute() .await()
.parseAs<PageWrapperDto<CollectionDto>>() .parseAs<PageWrapperDto<CollectionDto>>()
.content .content
genres = client.newCall(GET("$baseUrl/api/v1/genres")).execute().parseAs() genres = client.newCall(GET("$baseUrl/api/v1/genres")).await().parseAs()
tags = client.newCall(GET("$baseUrl/api/v1/tags")).execute().parseAs() tags = client.newCall(GET("$baseUrl/api/v1/tags")).await().parseAs()
publishers = client.newCall(GET("$baseUrl/api/v1/publishers")).execute().parseAs() publishers = client.newCall(GET("$baseUrl/api/v1/publishers")).await().parseAs()
authors = client authors = client
.newCall(GET("$baseUrl/api/v1/authors")) .newCall(GET("$baseUrl/api/v1/authors"))
.execute() .await()
.parseAs<List<AuthorDto>>() .parseAs<List<AuthorDto>>()
.groupBy { it.role } .groupBy { it.role }
false fetchFilterStatus = FetchFilterStatus.FETCHED
} catch (e: Exception) { } catch (e: Exception) {
Log.e(logTag, "Could not fetch filter options", e) fetchFilterStatus = FetchFilterStatus.NOT_FETCHED
true Log.e(logTag, "Failed to fetch filtering options", e)
}
fetchFiltersAttempts++
} }
} }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe()
} }
private val logTag = "komga${if (suffix.isNotBlank()) ".$suffix" else ""}" fun Response.isFromReadList() = request.url.toString().contains("/api/v1/readlists")
private inline fun <reified T> Response.parseAs(): T =
json.decodeFromString(body.string())
private val logTag by lazy { "komga${if (suffix.isNotBlank()) ".$suffix" else ""}" }
companion object { companion object {
internal const val PREF_EXTRA_SOURCES_COUNT = "Number of extra sources" internal const val PREF_EXTRA_SOURCES_COUNT = "Number of extra sources"
internal const val PREF_EXTRA_SOURCES_DEFAULT = "2" internal const val PREF_EXTRA_SOURCES_DEFAULT = "2"
private val PREF_EXTRA_SOURCES_ENTRIES = (0..10).map { it.toString() }.toTypedArray()
private const val PREF_DISPLAY_NAME = "Source display name"
private const val PREF_ADDRESS = "Address"
private const val PREF_USERNAME = "Username"
private const val PREF_PASSWORD = "Password"
private const val PREF_DEFAULT_LIBRARIES = "Default libraries"
private const val PREF_CHAPTER_NAME_TEMPLATE = "Chapter name template"
private const val PREF_CHAPTER_NAME_TEMPLATE_DEFAULT = "{number} - {title} ({size})"
private val supportedImageTypes = listOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/jxl", "image/heif", "image/avif")
internal const val TYPE_SERIES = "Series" internal const val TYPE_SERIES = "Series"
internal const val TYPE_READLISTS = "Read lists" internal const val TYPE_READLISTS = "Read lists"
} }
} }
private enum class FetchFilterStatus {
NOT_FETCHED,
FETCHING,
FETCHED,
}
private val PREF_EXTRA_SOURCES_ENTRIES = (0..10).map { it.toString() }.toTypedArray()
private const val PREF_DISPLAY_NAME = "Source display name"
private const val PREF_ADDRESS = "Address"
private const val PREF_USERNAME = "Username"
private const val PREF_PASSWORD = "Password"
private const val PREF_DEFAULT_LIBRARIES = "Default libraries"
private const val PREF_CHAPTER_NAME_TEMPLATE = "Chapter name template"
private const val PREF_CHAPTER_NAME_TEMPLATE_DEFAULT = "{number} - {title} ({size})"
private val SUPPORTED_IMAGE_TYPES = listOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/jxl", "image/heif", "image/avif")

View File

@ -6,9 +6,17 @@ import eu.kanade.tachiyomi.source.SourceFactory
class KomgaFactory : SourceFactory { class KomgaFactory : SourceFactory {
override fun createSources(): List<Source> { override fun createSources(): List<Source> {
val firstKomga = Komga("") val firstKomga = Komga("")
val komgaCount = firstKomga.preferences.getString(Komga.PREF_EXTRA_SOURCES_COUNT, Komga.PREF_EXTRA_SOURCES_DEFAULT)!!.toInt() val komgaCount = firstKomga.preferences
.getString(Komga.PREF_EXTRA_SOURCES_COUNT, Komga.PREF_EXTRA_SOURCES_DEFAULT)!!
.toInt()
// Komga(""), Komga("2"), Komga("3"), ... // Komga(""), Komga("2"), Komga("3"), ...
return listOf(firstKomga) + (0 until komgaCount).map { Komga("${it + 2}") } return buildList(komgaCount) {
add(firstKomga)
for (i in 0 until komgaCount) {
add(Komga("${i + 2}"))
}
}
} }
} }

View File

@ -98,6 +98,8 @@ internal class AuthorGroup(
} }
} }
internal class CollectionSelect(val collections: List<CollectionFilterEntry>) : Filter.Select<String>("Collection", collections.map { it.name }.toTypedArray()) internal class CollectionSelect(
val collections: List<CollectionFilterEntry>,
) : Filter.Select<String>("Collection", collections.map { it.name }.toTypedArray())
internal data class CollectionFilterEntry(val name: String, val id: String? = null) internal data class CollectionFilterEntry(val name: String, val id: String? = null)

View File

@ -6,176 +6,89 @@ import android.widget.Button
import android.widget.Toast import android.widget.Toast
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.all.komga.KomgaUtils.toSManga import java.text.ParseException
import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto
import eu.kanade.tachiyomi.extension.all.komga.dto.PageWrapperDto
import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto
import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Response
import org.apache.commons.text.StringSubstitutor
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
internal object KomgaUtils { val formatterDate = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val json: Json by injectLazy() .apply { timeZone = TimeZone.getTimeZone("UTC") }
val formatterDateTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
val formatterDate = SimpleDateFormat("yyyy-MM-dd", Locale.US) fun parseDate(date: String): Long = try {
.apply { timeZone = TimeZone.getTimeZone("UTC") } formatterDate.parse(date)!!.time
val formatterDateTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) } catch (_: ParseException) {
.apply { timeZone = TimeZone.getTimeZone("UTC") } 0L
val formatterDateTimeMilli = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.US) }
.apply { timeZone = TimeZone.getTimeZone("UTC") }
fun parseDateTime(date: String) = try {
fun parseDate(date: String?): Long = runCatching { formatterDateTime.parse(date)!!.time
formatterDate.parse(date!!)!!.time } catch (_: ParseException) {
}.getOrDefault(0L) 0L
}
fun parseDateTime(date: String?) = if (date == null) {
0L fun PreferenceScreen.addEditTextPreference(
} else { title: String,
runCatching { default: String,
formatterDateTime.parse(date)!!.time summary: String,
} dialogMessage: String? = null,
.getOrElse { inputType: Int? = null,
formatterDateTimeMilli.parse(date)?.time ?: 0L validate: ((String) -> Boolean)? = null,
} validationMessage: String? = null,
} key: String = title,
restartRequired: Boolean = false,
fun Response.isFromReadList() = request.url.toString().contains("/api/v1/readlists") ) {
EditTextPreference(context).apply {
fun processSeriesPage(response: Response, baseUrl: String): MangasPage { this.key = key
return if (response.isFromReadList()) { this.title = title
val data = response.parseAs<PageWrapperDto<ReadListDto>>() this.summary = summary
this.setDefaultValue(default)
MangasPage(data.content.map { it.toSManga(baseUrl) }, !data.last) dialogTitle = title
} else { this.dialogMessage = dialogMessage
val data = response.parseAs<PageWrapperDto<SeriesDto>>()
setOnBindEditTextListener { editText ->
MangasPage(data.content.map { it.toSManga(baseUrl) }, !data.last) if (inputType != null) {
} editText.inputType = inputType
} }
fun formatChapterName(book: BookDto, chapterNameTemplate: String, isFromReadList: Boolean): String { if (validate != null) {
val values = hashMapOf( editText.addTextChangedListener(
"title" to book.metadata.title, object : TextWatcher {
"seriesTitle" to book.seriesTitle, override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
"number" to book.metadata.number,
"createdDate" to book.created, override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
"releaseDate" to book.metadata.releaseDate,
"size" to book.size, override fun afterTextChanged(editable: Editable?) {
"sizeBytes" to book.sizeBytes.toString(), requireNotNull(editable)
)
val sub = StringSubstitutor(values, "{", "}") val text = editable.toString()
return buildString { val isValid = text.isBlank() || validate(text)
if (isFromReadList) {
append(book.seriesTitle) editText.error = if (!isValid) validationMessage else null
append(" ") editText.rootView.findViewById<Button>(android.R.id.button1)
} ?.isEnabled = editText.error == null
}
append(sub.replace(chapterNameTemplate)) },
} )
} }
}
fun SeriesDto.toSManga(baseUrl: String): SManga =
SManga.create().apply { setOnPreferenceChangeListener { _, newValue ->
title = metadata.title try {
url = "$baseUrl/api/v1/series/$id" val text = newValue as String
thumbnail_url = "$url/thumbnail" val result = text.isBlank() || validate?.invoke(text) ?: true
status = when {
metadata.status == "ENDED" && metadata.totalBookCount != null && booksCount < metadata.totalBookCount -> SManga.PUBLISHING_FINISHED if (restartRequired && result) {
metadata.status == "ENDED" -> SManga.COMPLETED Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
metadata.status == "ONGOING" -> SManga.ONGOING }
metadata.status == "ABANDONED" -> SManga.CANCELLED
metadata.status == "HIATUS" -> SManga.ON_HIATUS result
else -> SManga.UNKNOWN } catch (e: Exception) {
} e.printStackTrace()
genre = (metadata.genres + metadata.tags + booksMetadata.tags).distinct().joinToString(", ") false
description = metadata.summary.ifBlank { booksMetadata.summary } }
booksMetadata.authors.groupBy({ it.role }, { it.name }).let { map -> }
author = map["writer"]?.distinct()?.joinToString() }.also(::addPreference)
artist = map["penciller"]?.distinct()?.joinToString()
}
}
fun ReadListDto.toSManga(baseUrl: String): SManga =
SManga.create().apply {
title = name
description = summary
url = "$baseUrl/api/v1/readlists/$id"
thumbnail_url = "$url/thumbnail"
status = SManga.UNKNOWN
}
fun PreferenceScreen.addEditTextPreference(
title: String,
default: String,
summary: String,
inputType: Int? = null,
validate: ((String) -> Boolean)? = null,
validationMessage: String? = null,
key: String = title,
restartRequired: Boolean = false,
) {
EditTextPreference(context).apply {
this.key = key
this.title = title
this.summary = summary
this.setDefaultValue(default)
dialogTitle = title
setOnBindEditTextListener { editText ->
if (inputType != null) {
editText.inputType = inputType
}
if (validate != null) {
editText.addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(editable: Editable?) {
requireNotNull(editable)
val text = editable.toString()
val isValid = text.isBlank() || validate(text)
editText.error = if (!isValid) validationMessage else null
editText.rootView.findViewById<Button>(android.R.id.button1)
?.isEnabled = editText.error == null
}
},
)
}
}
setOnPreferenceChangeListener { _, _ ->
try {
val result = text.isBlank() || validate?.invoke(text) ?: true
if (restartRequired && result) {
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
}
result
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}.also(::addPreference)
}
inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
} }

View File

@ -1,15 +1,21 @@
package eu.kanade.tachiyomi.extension.all.komga.dto package eu.kanade.tachiyomi.extension.all.komga.dto
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.apache.commons.text.StringSubstitutor
interface ConvertibleToSManga {
fun toSManga(baseUrl: String): SManga
}
@Serializable @Serializable
data class LibraryDto( class LibraryDto(
val id: String, val id: String,
val name: String, val name: String,
) )
@Serializable @Serializable
data class SeriesDto( class SeriesDto(
val id: String, val id: String,
val libraryId: String, val libraryId: String,
val name: String, val name: String,
@ -19,10 +25,30 @@ data class SeriesDto(
val booksCount: Int, val booksCount: Int,
val metadata: SeriesMetadataDto, val metadata: SeriesMetadataDto,
val booksMetadata: BookMetadataAggregationDto, val booksMetadata: BookMetadataAggregationDto,
) ) : ConvertibleToSManga {
override fun toSManga(baseUrl: String) = SManga.create().apply {
title = metadata.title
url = "$baseUrl/api/v1/series/$id"
thumbnail_url = "$url/thumbnail"
status = when {
metadata.status == "ENDED" && metadata.totalBookCount != null && booksCount < metadata.totalBookCount -> SManga.PUBLISHING_FINISHED
metadata.status == "ENDED" -> SManga.COMPLETED
metadata.status == "ONGOING" -> SManga.ONGOING
metadata.status == "ABANDONED" -> SManga.CANCELLED
metadata.status == "HIATUS" -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
genre = (metadata.genres + metadata.tags + booksMetadata.tags).distinct().joinToString(", ")
description = metadata.summary.ifBlank { booksMetadata.summary }
booksMetadata.authors.groupBy({ it.role }, { it.name }).let { map ->
author = map["writer"]?.distinct()?.joinToString()
artist = map["penciller"]?.distinct()?.joinToString()
}
}
}
@Serializable @Serializable
data class SeriesMetadataDto( class SeriesMetadataDto(
val status: String, val status: String,
val created: String?, val created: String?,
val lastModified: String?, val lastModified: String?,
@ -46,7 +72,7 @@ data class SeriesMetadataDto(
) )
@Serializable @Serializable
data class BookMetadataAggregationDto( class BookMetadataAggregationDto(
val authors: List<AuthorDto> = emptyList(), val authors: List<AuthorDto> = emptyList(),
val tags: Set<String> = emptySet(), val tags: Set<String> = emptySet(),
val releaseDate: String?, val releaseDate: String?,
@ -58,7 +84,7 @@ data class BookMetadataAggregationDto(
) )
@Serializable @Serializable
data class BookDto( class BookDto(
val id: String, val id: String,
val seriesId: String, val seriesId: String,
val seriesTitle: String, val seriesTitle: String,
@ -71,10 +97,32 @@ data class BookDto(
val size: String, val size: String,
val media: MediaDto, val media: MediaDto,
val metadata: BookMetadataDto, val metadata: BookMetadataDto,
) ) {
fun getChapterName(template: String, isFromReadList: Boolean): String {
val values = hashMapOf(
"title" to metadata.title,
"seriesTitle" to seriesTitle,
"number" to metadata.number,
"createdDate" to created,
"releaseDate" to metadata.releaseDate,
"size" to size,
"sizeBytes" to sizeBytes.toString(),
)
val sub = StringSubstitutor(values, "{", "}")
return buildString {
if (isFromReadList) {
append(seriesTitle)
append(" ")
}
append(sub.replace(template))
}
}
}
@Serializable @Serializable
data class MediaDto( class MediaDto(
val status: String, val status: String,
val mediaType: String, val mediaType: String,
val pagesCount: Int, val pagesCount: Int,
@ -83,14 +131,14 @@ data class MediaDto(
) )
@Serializable @Serializable
data class PageDto( class PageDto(
val number: Int, val number: Int,
val fileName: String, val fileName: String,
val mediaType: String, val mediaType: String,
) )
@Serializable @Serializable
data class BookMetadataDto( class BookMetadataDto(
val title: String, val title: String,
val titleLock: Boolean, val titleLock: Boolean,
val summary: String, val summary: String,
@ -106,13 +154,13 @@ data class BookMetadataDto(
) )
@Serializable @Serializable
data class AuthorDto( class AuthorDto(
val name: String, val name: String,
val role: String, val role: String,
) )
@Serializable @Serializable
data class CollectionDto( class CollectionDto(
val id: String, val id: String,
val name: String, val name: String,
val ordered: Boolean, val ordered: Boolean,
@ -123,7 +171,7 @@ data class CollectionDto(
) )
@Serializable @Serializable
data class ReadListDto( class ReadListDto(
val id: String, val id: String,
val name: String, val name: String,
val summary: String, val summary: String,
@ -131,4 +179,12 @@ data class ReadListDto(
val createdDate: String, val createdDate: String,
val lastModifiedDate: String, val lastModifiedDate: String,
val filtered: Boolean, val filtered: Boolean,
) ) : ConvertibleToSManga {
override fun toSManga(baseUrl: String) = SManga.create().apply {
title = name
description = summary
url = "$baseUrl/api/v1/readlists/$id"
thumbnail_url = "$url/thumbnail"
status = SManga.UNKNOWN
}
}

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.extension.all.komga.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class PageWrapperDto<T>( class PageWrapperDto<T>(
val content: List<T>, val content: List<T>,
val empty: Boolean, val empty: Boolean,
val first: Boolean, val first: Boolean,