diff --git a/src/all/kavita/AndroidManifest.xml b/src/all/kavita/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/all/kavita/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/kavita/CHANGELOG.md b/src/all/kavita/CHANGELOG.md new file mode 100644 index 000000000..2843891e9 --- /dev/null +++ b/src/all/kavita/CHANGELOG.md @@ -0,0 +1,93 @@ +## 1.3.13 + +### Fixed + + * Fixed 'null cannot be cast to non-null type' exception + +## 1.3.12 + +### Features + +* Migrate filters to v2 +* Implemented smartFilters +* Added localization support + +### Fixed + +* Fixed publication status not showing + +## 1.3.10 + +### Features + +* API Change for Kavita v0.7.2 + +## 1.3.9 + +### Features + +* Added pdf support + +## 1.3.8 + +### Fix + +* Fixed `Expected URL scheme 'http' or 'https` when downloading + +## 1.3.7 + +### Features + +* New Sort filter: Time to read +* New Filter: Year release filter + +### Fix + +* Filters can now be used together with search +* Epub and pdfs no longer show in format filter (currently not supported) + +## 1.3.6 + +### Fix + +* Fixed "lateinit property title not initialized" + +## 1.3.5 + +### Features + +* Ignore DOH +* Added sort option `Item Added` +* Latest button now shows latest `Item Added` + +## 1.3.4 + +### Features + +* Exclude from bulk update warnings + +## 1.2.3 + +### Fix + +* Fixed Rating filter +* Fixed Chapter list not sorting correctly +* Fixed search +* Fixed manga details not showing correctly +* Fixed filters not populating if account was not admin + +### Features +* The extension is now ready to implement tracking. +* Min required version for the extension to work properly: `v0.5.1.1` + +## 1.2.2 + +### Features + +* Add `CHANGELOG.md` & `README.md` + +## 1.2.1 + +### Features + +* first version diff --git a/src/all/kavita/README.md b/src/all/kavita/README.md new file mode 100644 index 000000000..79f467edd --- /dev/null +++ b/src/all/kavita/README.md @@ -0,0 +1,37 @@ +# Kavita + +Table of Content +- [FAQ](#FAQ) + - [Why do I see no manga?](#why-do-i-see-no-manga) + - [Where can I get more information about Kavita?](#where-can-i-get-more-information-about-kavita) + - [The Kavita extension stopped working?](#the-kavita-extension-stopped-working) + - [Can I add more than one Kavita server or user?](#can-i-add-more-than-one-kavita-server-or-user) + - [Can I test the Kavita extension before setting up my own server?](#can-i-test-the-kavita-extension-before-setting-up-my-own-server) +- [Guides](#Guides) + - [How do I add my Kavita server to Tachiyomi?](#how-do-i-add-my-kavita-server-to-tachiyomi) + +Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation) + +Kavita also has a documentation about the Tachiyomi Kavita extension at the [Kavita wiki](https://wiki.kavitareader.com/en/guides/misc/tachiyomi). + +## FAQ + +### Why do I see no manga? +Kavita is a self-hosted comic/manga media server. + +### Where can I get more information about Kavita? +You can visit the [Kavita](https://www.kavitareader.com/) website for for more information. + +### The Kavita extension stopped working? +Make sure that your Kavita server and extension are on the newest version. + +### Can I add more than one Kavita server or user? +Yes, currently you can add up to 3 different Kavita instances to Tachiyomi. + +### Can I test the Kavita extension before setting up my own server? +Yes, you can try it out with the DEMO servers OPDS url `https://demo.kavitareader.com/api/opds/aca1c50d-7e08-4f37-b356-aecd6bf69b72`. + +## Guides + +### How do I add my Kavita server to Tachiyomi? +Go into the settings of the Kavita extension from the Extension tab in Browse and fill in your OPDS url. diff --git a/src/all/kavita/assets/i18n/messages_en.properties b/src/all/kavita/assets/i18n/messages_en.properties new file mode 100644 index 000000000..52f341d65 --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_en.properties @@ -0,0 +1,18 @@ +login_errors_failed_login=Login failed. Something went wrong +login_errors_header_token_empty="Error: The JSON Web Token is empty.\nTry opening the extension first." +login_errors_invalid_url=Invalid URL: +login_errors_parse_tokendto=There was an error parsing the auth token +pref_customsource_title=Displayed name for source +pref_edit_customsource_summary=Here you can change this source name.\nYou can write a descriptive name to identify this OPDS URL. +pref_filters_summary=Show these filters in the filter list +pref_filters_title=Default filters shown +pref_opds_badformed_url=Incorrect OPDS address. Please copy it from User settings \u2192 3rd party apps \u2192 OPDS URL +pref_opds_duplicated_source_url=The URL is configured in a different source -> +pref_opds_must_setup_address=You must set up the address to communicate with Kavita +pref_opds_summary=The OPDS URL copied from User Settings. This should include address and end with the API key. +restartapp_settings=Restart Tachiyomi to apply new setting. +version_exceptions_chapters_parse=Unhandled exception parsing chapters. Send your logs to the Kavita devs. +check_version=Ensure you have the newest version of the extension and Kavita. (0.7.8 or newer.)\nIf the issue persists, report it to the Kavita developers with the accompanying logs. +version_exceptions_smart_filter=Could not decode SmartFilter. Ensure you are using Kavita version 0.7.11 or later. +http_errors_500=Something went wrong +http_errors_401=There was an error logging in. Try again or reload the app diff --git a/src/all/kavita/assets/i18n/messages_es_es.properties b/src/all/kavita/assets/i18n/messages_es_es.properties new file mode 100644 index 000000000..8f6d3058c --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_es_es.properties @@ -0,0 +1,18 @@ +pref_customsource_title=Nombre de la instancia +pref_edit_customsource_summary=Aqui puedes cambiar el nombre de la instancia.\nPuedes escribir un nombre descriptivo que identifique esta url/instancia +restartapp_settings=Reinicia la aplicación para aplicar los cambios +version_exceptions_chapters_parse=Algo ha ido mal al procesar los capitulos. Envia los registros de fallo a los desarrolladores de Kavita +check_version=Comprueba que tienes tanto Kavita como la extension actualizada. (Version minima: 0.7.8)\nSi el problema persiste, reportalo a los desarrolladores de Kavita aportando los registros de fallo. +version_exceptions_smart_filter=Fallo al decodificar los filtros inteligentes. Aseg\u00FArate que estas al menos en la version 0.7.11 de Kavita +http_errors_500=Algo ha ido mal +http_errors_401=Ha habido un error al iniciar sesi\u00F3n. Prueba otra vez o reinicia la aplicaci\u00F3n +pref_opds_summary=La url del OPDS copiada de la configuraci\u00F3n del usuario. Debe incluir la direcci\u00F3n y la clave api al final. +pref_filters_summary=Mostrar estos filtros en la lista de filtros +pref_filters_title=Filtros por defecto +pref_opds_badformed_url=La direcci\u00F3n OPDS no es correcta. Por favor, c\u00F3piela desde la Configuraci\u00F3n de usuario-> aplicaciones de terceros -> url de OPDS +login_errors_parse_tokendto=Se ha producido un error al procesar el token de autenticaci\u00F3n +pref_opds_duplicated_source_url=Url est\u00E1 configurado en una fuente diferente -> +pref_opds_must_setup_address=Debe configurar la direcci\u00F3n para comunicarse con Kavita +login_errors_failed_login=Error en el inicio de sesi\u00F3n. Algo ha ido mal +login_errors_header_token_empty="Error: el token jwt est\u00E1 vac\u00EDo.\nIntente abrir primero la extensi\u00F3n" +login_errors_invalid_url=URL no v\u00E1lida: diff --git a/src/all/kavita/assets/i18n/messages_fr_fr.properties b/src/all/kavita/assets/i18n/messages_fr_fr.properties new file mode 100644 index 000000000..bd29b962a --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_fr_fr.properties @@ -0,0 +1,20 @@ + + +version_exceptions_chapters_parse=Exception non trait\u00E9e durant l'analyse des chapitres. Envoyez les journaux aux d\u00E9velopeurs de Kavita +pref_customsource_title=Nom d'affichage pour la source +version_exceptions_smart_filter=\u00C9chec du d\u00E9codage de SmartFilter. Assurez-vous que vous utilisez au moins Kavita version 0.7.11 +pref_opds_summary=L'URL OPDS a \u00E9t\u00E9 copi\u00E9e \u00E0 partir des param\u00E8tres de l'utilisateur. Ceci devrait inclure l'adresse et la cl\u00E9 API. +pref_filters_summary=Afficher ces filtres dans la liste des filtres +check_version=Assurez-vous que vous avez l'extension et Kavita mises \u00E0 jour. (version Mini\u202F: 0.7.8)\nSi le probl\u00E8me persiste, signalez-le aux d\u00E9veloppeurs de Kavita en fournissant des journaux +pref_filters_title=Filtres par d\u00E9faut affich\u00E9s +pref_edit_customsource_summary=Ici vous pouvez changer ce nom source.\nVous pouvez \u00E9crire un nom descriptif pour identifier cette URL opds +pref_opds_badformed_url=L'adresse OPDS n'est pas correcte. Veuillez la copiez \u00E0 partir des param\u00E8tres de l'utilisateur - > Applis tierces -> URL OPDS +login_errors_parse_tokendto=Il y a eu une erreur pendant l'analyse du jeton d'authentification +restartapp_settings=Red\u00E9marrez Tachiyomi pour appliquer le nouveau r\u00E9glage. +pref_opds_duplicated_source_url=L'URL est configur\u00E9e dans une autre source -> +pref_opds_must_setup_address=Vous devez configurer l'adresse pour communiquer avec Kavita +login_errors_failed_login=\u00C9chec de la connexion. Quelque chose s'est mal pass\u00E9 +http_errors_500=Quelque chose s'est mal pass\u00E9 +login_errors_header_token_empty=\u00AB\u00A0Erreur\u202F: le jeton jwt est vide.\nEssayez d'abord d'ouvrir l'extension\u00A0\u00BB +login_errors_invalid_url=URL invalide\u202F: +http_errors_401=Il y a eu une erreur. Essayez de nouveau ou rechargez l'application diff --git a/src/all/kavita/assets/i18n/messages_nb_no.properties b/src/all/kavita/assets/i18n/messages_nb_no.properties new file mode 100644 index 000000000..c2e0a530d --- /dev/null +++ b/src/all/kavita/assets/i18n/messages_nb_no.properties @@ -0,0 +1,21 @@ + + +pref_customsource_title=Vist kildenavn +pref_edit_customsource_summary=Her kan du endre dette kildenavnet.\nDu kan skrive et beskrivende navn for \u00E5 identifisere denne OPDS-nettadressen. +restartapp_settings=Ny innstilling trer i kraft n\u00E5r du starter Tachiyomi p\u00E5 ny. +duplicated_source_url=Nettadressen er satt opp i en annen Kavita-instans +pref_filters_summary=Vis disse filterne i filterlisten +pref_filters_title=Forvalgte filtre valgt +login_errors_parse_tokendto=Kunne ikke tolke identifiseringssymbolet +login_errors_failed_login=Innlogging mislyktes. Noe gikk galt. +http_errors_500=Noe gikk galt +login_errors_header_token_empty="Feil: JSON-nettsymbol er tomt.\nPr\u00F8v \u00E5 \u00E5pne utvidelsen f\u00F8rst." +login_errors_invalid_url=Ugyldig nettadresse: +version_exceptions_chapters_parse=Uh\u00E5ndtert unntak i tolking av kapitler. Send loggene dine til Kavita-utviklerne. +version_exceptions_smart_filter=Kunne ikke dekode smartfilter. Forsikre deg om at du bruker Kavita versjon 0.7.11 eller nyere. +pref_opds_summary=OPDS-nettadressen kopiert fra brukerinnstillingene. Denne skal inkludere med adressen og slutte med API-n\u00F8kkelen. +check_version=Forsikre deg om at b\u00E5de utvidelsen og Kavita er av nyeste versjon. (Ihvertfall 0.7.8)\nHvis problemet vedvarer kan du rapportere det til Kavita-utviklerne med tilh\u00F8rende loggf\u00F8ring. +pref_opds_badformed_url=OPDS-adressen er ikke riktig. Kopier den fra brukerinnstillinger -> tredjepartsprogrammer -> OPDS-nettadresse +pref_opds_duplicated_source_url=Nettadressen er satt opp i en annen instans -> +pref_opds_must_setup_address=Du m\u00E5 sette opp adressen som skal kommunisere med Kavita +http_errors_401=Feil med innlogging. Pr\u00F8v \u00E5 laste inn p\u00E5 ny, eller start programmet p\u00E5 ny. diff --git a/src/all/kavita/build.gradle b/src/all/kavita/build.gradle new file mode 100644 index 000000000..bb2376766 --- /dev/null +++ b/src/all/kavita/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'Kavita' + extClass = '.KavitaFactory' + extVersionCode = 13 +} + +dependencies { + implementation 'info.debatty:java-string-similarity:2.0.0' + implementation(project(':lib:i18n')) +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/kavita/res/mipmap-hdpi/ic_launcher.png b/src/all/kavita/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..16cee6c3e Binary files /dev/null and b/src/all/kavita/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/kavita/res/mipmap-mdpi/ic_launcher.png b/src/all/kavita/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ebcbc75a2 Binary files /dev/null and b/src/all/kavita/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/kavita/res/mipmap-xhdpi/ic_launcher.png b/src/all/kavita/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..39ca4ccaa Binary files /dev/null and b/src/all/kavita/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/kavita/res/mipmap-xxhdpi/ic_launcher.png b/src/all/kavita/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..98f3ea1ff Binary files /dev/null and b/src/all/kavita/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/kavita/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/kavita/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..dc7d8341f Binary files /dev/null and b/src/all/kavita/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt new file mode 100644 index 000000000..80d0e4075 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt @@ -0,0 +1,112 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants.noSmartFilterSelected +import eu.kanade.tachiyomi.source.model.Filter + +class UserRating : + Filter.Select( + "Minimum Rating", + arrayOf( + "Any", + "1 star", + "2 stars", + "3 stars", + "4 stars", + "5 stars", + ), + ) +class SmartFiltersFilter(smartFilters: Array) : + Filter.Select("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters) +class SortFilter(sortables: Array) : Filter.Sort("Sort by", sortables, Selection(0, true)) + +val sortableList = listOf( + Pair("Sort name", 1), + Pair("Created", 2), + Pair("Last modified", 3), + Pair("Item added", 4), + Pair("Time to Read", 5), + Pair("Release year", 6), +) + +class StatusFilter(name: String) : Filter.CheckBox(name, false) +class StatusFilterGroup(filters: List) : + Filter.Group("Status", filters) + +class ReleaseYearRange(name: String) : Filter.Text(name) +class ReleaseYearRangeGroup(filters: List) : + Filter.Group("Release Year", filters) +class GenreFilter(name: String) : Filter.TriState(name) +class GenreFilterGroup(genres: List) : + Filter.Group("Genres", genres) + +class TagFilter(name: String) : Filter.TriState(name) +class TagFilterGroup(tags: List) : Filter.Group("Tags", tags) + +class AgeRatingFilter(name: String) : Filter.TriState(name) +class AgeRatingFilterGroup(ageRatings: List) : + Filter.Group("Age Rating", ageRatings) + +class FormatFilter(name: String) : Filter.CheckBox(name, false) +class FormatsFilterGroup(formats: List) : + Filter.Group("Formats", formats) + +class CollectionFilter(name: String) : Filter.TriState(name) +class CollectionFilterGroup(collections: List) : + Filter.Group("Collection", collections) + +class LanguageFilter(name: String) : Filter.TriState(name) +class LanguageFilterGroup(languages: List) : + Filter.Group("Language", languages) + +class LibraryFilter(library: String) : Filter.TriState(library) +class LibrariesFilterGroup(libraries: List) : + Filter.Group("Libraries", libraries) + +class PubStatusFilter(name: String) : Filter.CheckBox(name, false) +class PubStatusFilterGroup(status: List) : + Filter.Group("Publication Status", status) + +class PeopleHeaderFilter(name: String) : + Filter.Header(name) +class PeopleSeparatorFilter : + Filter.Separator() + +class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false) +class WriterPeopleFilterGroup(peoples: List) : + Filter.Group("Writer", peoples) + +class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false) +class PencillerPeopleFilterGroup(peoples: List) : + Filter.Group("Penciller", peoples) + +class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false) +class InkerPeopleFilterGroup(peoples: List) : + Filter.Group("Inker", peoples) + +class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false) +class ColoristPeopleFilterGroup(peoples: List) : + Filter.Group("Colorist", peoples) + +class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false) +class LettererPeopleFilterGroup(peoples: List) : + Filter.Group("Letterer", peoples) + +class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false) +class CoverArtistPeopleFilterGroup(peoples: List) : + Filter.Group("Cover Artist", peoples) + +class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false) +class EditorPeopleFilterGroup(peoples: List) : + Filter.Group("Editor", peoples) + +class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false) +class PublisherPeopleFilterGroup(peoples: List) : + Filter.Group("Publisher", peoples) + +class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false) +class CharacterPeopleFilterGroup(peoples: List) : + Filter.Group("Character", peoples) + +class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false) +class TranslatorPeopleFilterGroup(peoples: List) : + Filter.Group("Translator", peoples) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt new file mode 100644 index 000000000..079e457a9 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt @@ -0,0 +1,1264 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import android.app.Application +import android.content.SharedPreferences +import android.text.InputType +import android.util.Log +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.MultiSelectListPreference +import eu.kanade.tachiyomi.AppInfo +import eu.kanade.tachiyomi.extension.all.kavita.dto.AuthenticationDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterComparison +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterField +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterStatementDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.FilterV2Dto +import eu.kanade.tachiyomi.extension.all.kavita.dto.MangaFormat +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataAgeRatings +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataCollections +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataGenres +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataLanguages +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataLibrary +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPayload +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPeople +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPubStatus +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataTag +import eu.kanade.tachiyomi.extension.all.kavita.dto.PersonRole +import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesMetadataDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.ServerInfoDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.SmartFilter +import eu.kanade.tachiyomi.extension.all.kavita.dto.SortFieldEnum +import eu.kanade.tachiyomi.extension.all.kavita.dto.SortOptions +import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.UnmeteredSource +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.Filter.TriState.Companion.STATE_EXCLUDE +import eu.kanade.tachiyomi.source.model.Filter.TriState.Companion.STATE_INCLUDE +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import okhttp3.Dns +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import rx.Observable +import rx.Single +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.io.IOException +import java.net.ConnectException +import java.security.MessageDigest +import java.util.Locale + +class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { + private val helper = KavitaHelper() + override val client: OkHttpClient = + network.client.newBuilder() + .dns(Dns.SYSTEM) + .build() + override val id by lazy { + val key = "${"kavita_$suffix"}/all/$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 + } + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})" + override val lang = "all" + override val supportsLatest = true + private val apiUrl by lazy { getPrefApiUrl() } + override val baseUrl by lazy { getPrefBaseUrl() } + private val address by lazy { getPrefAddress() } // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key + private val apiKey by lazy { getPrefApiKey() } + private var jwtToken = "" // * JWT Token for authentication with the server. Stored in memory. + private val LOG_TAG = """Kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref, "[$suffix]")!!.replace(' ', '_')}""" + private var isLogged = false // Used to know if login was correct and not send login requests anymore + private val json: Json by injectLazy() + + private var series = emptyList() // Acts as a cache + + private inline fun Response.parseAs(): T = + use { + if (it.code == 401) { + Log.e(LOG_TAG, "Http error 401 - Not authorized: ${it.request.url}") + Throwable("Http error 401 - Not authorized: ${it.request.url}") + } + + if (it.peekBody(Long.MAX_VALUE).string().isEmpty()) { + Log.e(LOG_TAG, "Empty body String for request url: ${it.request.url}") + throw EmptyRequestBody( + "Body of the response is empty. RequestUrl=${it.request.url}\nPlease check your kavita instance is up to date", + Throwable("Error. Request body is empty"), + ) + } + json.decodeFromString(it.body.string()) + } + + /** + * Custom implementation for fetch popular, latest and search + * Handles and logs errors to provide a more detailed exception to the users. + */ + private fun fetch(request: Request): Observable { + return client.newCall(request) + .asObservableSuccess() + .onErrorResumeNext { throwable -> + // Get Http code + val field = throwable.javaClass.getDeclaredField("code") + field.isAccessible = true // Make the field accessible + try { + var code = field.get(throwable) // Get the value of the code property + Log.e(LOG_TAG, "Error fetching manga: ${throwable.message}", throwable) + if (code as Int !in intArrayOf(401, 201, 500)) { + code = 500 + } + return@onErrorResumeNext Observable.error(IOException("Http Error: $code\n ${helper.intl["http_errors_$code"]}\n${helper.intl["check_version"]}")) + } catch (e: Exception) { + Log.e(LOG_TAG, e.toString(), e) + return@onErrorResumeNext Observable.error(e) + } + } + .map { response -> + popularMangaParse(response) + } + } + + override fun fetchPopularManga(page: Int) = + fetch(popularMangaRequest(page)) + + override fun fetchLatestUpdates(page: Int) = + fetch(latestUpdatesRequest(page)) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable = + fetch(searchMangaRequest(page, query, filters)) + + override fun popularMangaParse(response: Response): MangasPage { + try { + val result = response.parseAs>() + series = result + val mangaList = result.map { item -> helper.createSeriesDto(item, apiUrl, apiKey) } + return MangasPage(mangaList, helper.hasNextPage(response)) + } catch (e: Exception) { + Log.e(LOG_TAG, "Unhandled exception", e) + throw IOException(helper.intl["check_version"]) + } + } + + override fun popularMangaRequest(page: Int): Request { + if (!isLogged) { + doLogin() + } + val payload = buildFilterBody(currentFilter) + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + payload.toRequestBody(JSON_MEDIA_TYPE), + ) + } + + override fun latestUpdatesRequest(page: Int): Request { + if (!isLogged) { + doLogin() + } + // Hardcode exclude epubs + val filter = FilterV2Dto(sortOptions = SortOptions(SortFieldEnum.LastChapterAdded.type, false)) + filter.statements.add(FilterStatementDto(FilterComparison.NotContains.type, FilterField.Formats.type, "3")) + val payload = json.encodeToJsonElement(filter).toString() + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + payload.toRequestBody(JSON_MEDIA_TYPE), + ) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val newFilter = MetadataPayload() // need to reset it or will double + val smartFilterFilter = filters.find { it is SmartFiltersFilter } + // If a SmartFilter selected, apply its filter and return that + if (smartFilterFilter?.state != 0 && smartFilterFilter != null) { + val index = try { + smartFilterFilter?.state as Int - 1 + } catch (e: Exception) { + Log.e(LOG_TAG, e.toString(), e) + 0 + } + + val filter: SmartFilter = smartFilters[index] + val payload = buildJsonObject { + put("EncodedFilter", filter.filter) + } + // Decode selected filters + val request = POST( + "$apiUrl/filter/decode", + headersBuilder().build(), + payload.toString().toRequestBody(JSON_MEDIA_TYPE), + ) + client.newCall(request).execute().use { + if (it.code == 200) { + // Hardcode exclude epub + val decoded_filter = json.decodeFromString(it.body.string()) + decoded_filter.statements.add(FilterStatementDto(FilterComparison.NotContains.type, FilterField.Formats.type, "3")) + + // Make request with selected filters + return POST( + "$apiUrl/series/all-v2?pageNumber=$page&pageSize=20", + headersBuilder().build(), + json.encodeToJsonElement(decoded_filter).toString().toRequestBody(JSON_MEDIA_TYPE), + ) + } else { + Log.e(LOG_TAG, "Failed to decode SmartFilter: ${it.code}\n" + it.message) + throw IOException(helper.intl["version_exceptions_smart_filter"]) + } + } + } + // Else apply user filters + + filters.forEach { filter -> + when (filter) { + is SortFilter -> { + if (filter.state != null) { + newFilter.sorting = filter.state!!.index + 1 + newFilter.sorting_asc = filter.state!!.ascending + } + } + + is StatusFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.readStatus.add(content.name) + } + } + } + + is ReleaseYearRangeGroup -> { + filter.state.forEach { content -> + if (content.state.isNotEmpty()) { + if (content.name == "Min") { + newFilter.releaseYearRangeMin = content.state.toInt() + } + if (content.name == "Max") { + newFilter.releaseYearRangeMax = content.state.toInt() + } + } + } + } + + is GenreFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.genres_i.add(genresListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.genres_e.add(genresListMeta.find { it.title == content.name }!!.id) + } + } + } + + is UserRating -> { + newFilter.userRating = filter.state + } + + is TagFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.tags_i.add(tagsListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.tags_e.add(tagsListMeta.find { it.title == content.name }!!.id) + } + } + } + + is AgeRatingFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.ageRating_i.add(ageRatingsListMeta.find { it.title == content.name }!!.value) + } else if (content.state == STATE_EXCLUDE) { + newFilter.ageRating_e.add(ageRatingsListMeta.find { it.title == content.name }!!.value) + } + } + } + + is FormatsFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.formats.add(MangaFormat.valueOf(content.name).ordinal) + } + } + } + + is CollectionFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.collections_i.add(collectionsListMeta.find { it.title == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.collections_e.add(collectionsListMeta.find { it.title == content.name }!!.id) + } + } + } + + is LanguageFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.language_i.add(languagesListMeta.find { it.title == content.name }!!.isoCode) + } else if (content.state == STATE_EXCLUDE) { + newFilter.language_e.add(languagesListMeta.find { it.title == content.name }!!.isoCode) + } + } + } + + is LibrariesFilterGroup -> { + filter.state.forEach { content -> + if (content.state == STATE_INCLUDE) { + newFilter.libraries_i.add(libraryListMeta.find { it.name == content.name }!!.id) + } else if (content.state == STATE_EXCLUDE) { + newFilter.libraries_e.add(libraryListMeta.find { it.name == content.name }!!.id) + } + } + } + + is PubStatusFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.pubStatus.add(pubStatusListMeta.find { it.title == content.name }!!.value) + } + } + } + + is WriterPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleWriters.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is PencillerPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peoplePenciller.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is InkerPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleInker.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is ColoristPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peoplePeoplecolorist.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is LettererPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleLetterer.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is CoverArtistPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleCoverArtist.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is EditorPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleEditor.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is PublisherPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peoplePublisher.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is CharacterPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleCharacter.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + is TranslatorPeopleFilterGroup -> { + filter.state.forEach { content -> + if (content.state) { + newFilter.peopleTranslator.add(peopleListMeta.find { it.name == content.name }!!.id) + } + } + } + + else -> {} + } + } + newFilter.seriesNameQuery = query + currentFilter = newFilter + return popularMangaRequest(page) + } + + /* + * MANGA DETAILS (metadata about series) + * **/ + + override fun fetchMangaDetails(manga: SManga): Observable { + val serieId = helper.getIdFromUrl(manga.url) + return client.newCall(GET("$apiUrl/series/metadata?seriesId=$serieId", headersBuilder().build())) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val serieId = helper.getIdFromUrl(manga.url) + val foundSerie = series.find { dto -> dto.id == serieId } + return GET( + "$baseUrl/library/${foundSerie!!.libraryId}/series/$serieId", + headersBuilder().build(), + ) + } + + override fun mangaDetailsParse(response: Response): SManga { + val result = response.parseAs() + + val existingSeries = series.find { dto -> dto.id == result.seriesId } + if (existingSeries != null) { + val manga = helper.createSeriesDto(existingSeries, apiUrl, apiKey) + manga.url = "$apiUrl/Series/${result.seriesId}" + manga.artist = result.coverArtists.joinToString { it.name } + manga.description = result.summary + manga.author = result.writers.joinToString { it.name } + manga.genre = result.genres.joinToString { it.title } + manga.thumbnail_url = "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" + + return manga + } + val serieDto = client.newCall(GET("$apiUrl/Series/${result.seriesId}", headersBuilder().build())) + .execute() + .parseAs() + + return SManga.create().apply { + url = "$apiUrl/Series/${result.seriesId}" + artist = result.coverArtists.joinToString { it.name } + description = result.summary + author = result.writers.joinToString { it.name } + genre = result.genres.joinToString { it.title } + title = serieDto.name + thumbnail_url = "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" + status = when (result.publicationStatus) { + 4 -> SManga.PUBLISHING_FINISHED + 2 -> SManga.COMPLETED + 0 -> SManga.ONGOING + 3 -> SManga.CANCELLED + 1 -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + } + + /* + * CHAPTER LIST + * **/ + override fun chapterListRequest(manga: SManga): Request { + val url = "$apiUrl/Series/volumes?seriesId=${helper.getIdFromUrl(manga.url)}" + return GET(url, headersBuilder().build()) + } + + override fun chapterListParse(response: Response): List { + try { + val volumes = response.parseAs>() + val allChapterList = mutableListOf() + volumes.forEach { volume -> + run { + if (volume.number == 0) { + // Regular chapters + volume.chapters.map { + allChapterList.add(helper.chapterFromObject(it)) + } + } else { + // Volume chapter + volume.chapters.map { + allChapterList.add(helper.chapterFromVolume(it, volume)) + } + } + } + } + + allChapterList.sortWith(KavitaHelper.CompareChapters) + return allChapterList + } catch (e: Exception) { + Log.e(LOG_TAG, "Unhandled exception parsing chapters. Send logs to kavita devs", e) + throw IOException(helper.intl["version_exceptions_chapters_parse"]) + } + } + + /** + * Fetches the "url" of each page from the chapter + * **/ + override fun pageListRequest(chapter: SChapter): Request { + return GET("$apiUrl/${chapter.url}", headersBuilder().build()) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val chapterId = chapter.url + val numPages = chapter.scanlator?.replace(" pages", "")?.toInt() + val numPages2 = "$numPages".toInt() - 1 + val pages = mutableListOf() + for (i in 0..numPages2) { + pages.add( + Page( + index = i, + imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey", + ), + ) + } + return Observable.just(pages) + } + + override fun latestUpdatesParse(response: Response): MangasPage = + throw UnsupportedOperationException("Not used") + + override fun pageListParse(response: Response): List = + throw UnsupportedOperationException("Not used") + + override fun searchMangaParse(response: Response): MangasPage = + throw UnsupportedOperationException("Not used") + + override fun imageUrlParse(response: Response): String = "" + + /* + * FILTERING + **/ + + private var currentFilter: MetadataPayload = MetadataPayload() + + /** Some variable names already exist. im not good at naming add Meta suffix */ + private var genresListMeta = emptyList() + private var tagsListMeta = emptyList() + private var ageRatingsListMeta = emptyList() + private var peopleListMeta = emptyList() + private var pubStatusListMeta = emptyList() + private var languagesListMeta = emptyList() + private var libraryListMeta = emptyList() + private var collectionsListMeta = emptyList() + private var smartFilters = emptyList() + private val personRoles = listOf( + "Writer", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Publisher", + "Character", + "Translator", + ) + + /** + * Loads the enabled filters if they are not empty so tachiyomi can show them to the user + */ + override fun getFilterList(): FilterList { + val toggledFilters = getToggledFilters() + + val filters = try { + val peopleInRoles = mutableListOf>() + personRoles.map { role -> + val peoplesWithRole = mutableListOf() + peopleListMeta.map { + if (it.role == helper.safeValueOf(role).role) { + peoplesWithRole.add(it) + } + } + peopleInRoles.add(peoplesWithRole) + } + + val filtersLoaded = mutableListOf>() + + if (sortableList.isNotEmpty() and toggledFilters.contains("Sort Options")) { + filtersLoaded.add( + SortFilter(sortableList.map { it.first }.toTypedArray()), + ) + if (smartFilters.isNotEmpty()) { + filtersLoaded.add( + SmartFiltersFilter(smartFilters.map { it.name }.toTypedArray()), + + ) + } + } + if (toggledFilters.contains("Read Status")) { + filtersLoaded.add( + StatusFilterGroup( + listOf( + "notRead", + "inProgress", + "read", + ).map { StatusFilter(it) }, + ), + ) + } + if (toggledFilters.contains("ReleaseYearRange")) { + filtersLoaded.add( + ReleaseYearRangeGroup( + listOf("Min", "Max").map { ReleaseYearRange(it) }, + ), + ) + } + + if (genresListMeta.isNotEmpty() and toggledFilters.contains("Genres")) { + filtersLoaded.add( + GenreFilterGroup(genresListMeta.map { GenreFilter(it.title) }), + ) + } + if (tagsListMeta.isNotEmpty() and toggledFilters.contains("Tags")) { + filtersLoaded.add( + TagFilterGroup(tagsListMeta.map { TagFilter(it.title) }), + ) + } + if (ageRatingsListMeta.isNotEmpty() and toggledFilters.contains("Age Rating")) { + filtersLoaded.add( + AgeRatingFilterGroup(ageRatingsListMeta.map { AgeRatingFilter(it.title) }), + ) + } + if (toggledFilters.contains("Format")) { + filtersLoaded.add( + FormatsFilterGroup( + listOf( + "Image", + "Archive", + "Pdf", + "Unknown", + ).map { FormatFilter(it) }, + ), + ) + } + if (collectionsListMeta.isNotEmpty() and toggledFilters.contains("Collections")) { + filtersLoaded.add( + CollectionFilterGroup(collectionsListMeta.map { CollectionFilter(it.title) }), + ) + } + if (languagesListMeta.isNotEmpty() and toggledFilters.contains("Languages")) { + filtersLoaded.add( + LanguageFilterGroup(languagesListMeta.map { LanguageFilter(it.title) }), + ) + } + if (libraryListMeta.isNotEmpty() and toggledFilters.contains("Libraries")) { + filtersLoaded.add( + LibrariesFilterGroup(libraryListMeta.map { LibraryFilter(it.name) }), + ) + } + if (pubStatusListMeta.isNotEmpty() and toggledFilters.contains("Publication Status")) { + filtersLoaded.add( + PubStatusFilterGroup(pubStatusListMeta.map { PubStatusFilter(it.title) }), + ) + } + if (pubStatusListMeta.isNotEmpty() and toggledFilters.contains("Rating")) { + filtersLoaded.add( + UserRating(), + ) + } + + // People Metadata: + if (personRoles.isNotEmpty() and toggledFilters.any { personRoles.contains(it) }) { + filtersLoaded.addAll( + listOf>( + PeopleHeaderFilter(""), + PeopleSeparatorFilter(), + PeopleHeaderFilter("PEOPLE"), + ), + ) + if (peopleInRoles[0].isNotEmpty() and toggledFilters.contains("Writer")) { + filtersLoaded.add( + WriterPeopleFilterGroup( + peopleInRoles[0].map { WriterPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[1].isNotEmpty() and toggledFilters.contains("Penciller")) { + filtersLoaded.add( + PencillerPeopleFilterGroup( + peopleInRoles[1].map { PencillerPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[2].isNotEmpty() and toggledFilters.contains("Inker")) { + filtersLoaded.add( + InkerPeopleFilterGroup( + peopleInRoles[2].map { InkerPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[3].isNotEmpty() and toggledFilters.contains("Colorist")) { + filtersLoaded.add( + ColoristPeopleFilterGroup( + peopleInRoles[3].map { ColoristPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[4].isNotEmpty() and toggledFilters.contains("Letterer")) { + filtersLoaded.add( + LettererPeopleFilterGroup( + peopleInRoles[4].map { LettererPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[5].isNotEmpty() and toggledFilters.contains("CoverArtist")) { + filtersLoaded.add( + CoverArtistPeopleFilterGroup( + peopleInRoles[5].map { CoverArtistPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[6].isNotEmpty() and toggledFilters.contains("Editor")) { + filtersLoaded.add( + EditorPeopleFilterGroup( + peopleInRoles[6].map { EditorPeopleFilter(it.name) }, + ), + ) + } + + if (peopleInRoles[7].isNotEmpty() and toggledFilters.contains("Publisher")) { + filtersLoaded.add( + PublisherPeopleFilterGroup( + peopleInRoles[7].map { PublisherPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[8].isNotEmpty() and toggledFilters.contains("Character")) { + filtersLoaded.add( + CharacterPeopleFilterGroup( + peopleInRoles[8].map { CharacterPeopleFilter(it.name) }, + ), + ) + } + if (peopleInRoles[9].isNotEmpty() and toggledFilters.contains("Translator")) { + filtersLoaded.add( + TranslatorPeopleFilterGroup( + peopleInRoles[9].map { TranslatorPeopleFilter(it.name) }, + ), + ) + filtersLoaded + } else { + filtersLoaded + } + } else { + filtersLoaded + } + } catch (e: Exception) { + Log.e(LOG_TAG, "[FILTERS] Error while creating filter list", e) + emptyList() + } + return FilterList(filters) + } + + /** + * Returns a FilterV2Dto encoded as a json string with values taken from filter + */ + private fun buildFilterBody(filter: MetadataPayload): String { + val filter_dto = FilterV2Dto() + filter_dto.sortOptions.sortField = filter.sorting + filter_dto.sortOptions.isAscending = filter.sorting_asc + + // Fields that support contains and not contains statements + val containsAndNotTriplets = listOf( + Triple(FilterField.Libraries, filter.libraries_i, filter.libraries_e), + Triple(FilterField.Tags, filter.tags_i, filter.tags_e), + Triple(FilterField.Languages, filter.language_i, filter.genres_e), + Triple(FilterField.AgeRating, filter.ageRating_i, filter.ageRating_e), + Triple(FilterField.Genres, filter.genres_i, filter.genres_e), + Triple(FilterField.CollectionTags, filter.collections_i, filter.collections_e), + ) + filter_dto.addContainsNotTriple(containsAndNotTriplets) + // Fields that have must contains statements + val peoplePairs = listOf( + + Pair(FilterField.Writers, filter.peopleWriters), + Pair(FilterField.Penciller, filter.peoplePenciller), + Pair(FilterField.Inker, filter.peopleInker), + Pair(FilterField.Colorist, filter.peopleCharacter), + Pair(FilterField.Letterer, filter.peopleLetterer), + Pair(FilterField.CoverArtist, filter.peopleCoverArtist), + Pair(FilterField.Editor, filter.peopleEditor), + Pair(FilterField.Publisher, filter.peoplePublisher), + Pair(FilterField.Characters, filter.peopleCharacter), + Pair(FilterField.Translators, filter.peopleTranslator), + + Pair(FilterField.PublicationStatus, filter.pubStatus), + ) + filter_dto.addPeople(peoplePairs) + + // Customized statements + filter_dto.addStatement(FilterComparison.Contains, FilterField.Formats, filter.formats) + filter_dto.addStatement(FilterComparison.Matches, FilterField.SeriesName, filter.seriesNameQuery) + // Hardcoded statement to filter out epubs: + filter_dto.addStatement(FilterComparison.NotContains, FilterField.Formats, "3") + if (filter.readStatus.isNotEmpty()) { + filter.readStatus.forEach { + if (it == "notRead") { + filter_dto.addStatement(FilterComparison.Equal, FilterField.ReadProgress, "0") + } else if (it == "inProgress") { + filter_dto.addStatement(FilterComparison.GreaterThan, FilterField.ReadProgress, "0") + filter_dto.addStatement(FilterComparison.LessThan, FilterField.ReadProgress, "100") + } else if (it == "read") { + filter_dto.addStatement(FilterComparison.Equal, FilterField.ReadProgress, "100") + } + } + } + // todo: check statement + // filter_dto.addStatement(FilterComparison.GreaterThanEqual, FilterField.UserRating, filter.userRating.toString()) + if (filter.releaseYearRangeMin != 0) { + filter_dto.addStatement(FilterComparison.GreaterThan, FilterField.ReleaseYear, filter.releaseYearRangeMin.toString()) + } + + if (filter.releaseYearRangeMax != 0) { + filter_dto.addStatement(FilterComparison.LessThan, FilterField.ReleaseYear, filter.releaseYearRangeMax.toString()) + } + return json.encodeToJsonElement(filter_dto).toString() + } + + class LoginErrorException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + class OpdsurlExistsInPref(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + class EmptyRequestBody(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + class LoadingFilterFailed(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + constructor(cause: Throwable) : this(null, cause) + } + + override fun headersBuilder(): Headers.Builder { + if (jwtToken.isEmpty()) { + doLogin() + if (jwtToken.isEmpty()) throw LoginErrorException(helper.intl["login_errors_header_token_empty"]) + } + return Headers.Builder() + .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") + .add("Content-Type", "application/json") + .add("Authorization", "Bearer $jwtToken") + } + + private fun setupLoginHeaders(): Headers.Builder { + return Headers.Builder() + .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") + .add("Content-Type", "application/json") + .add("Authorization", "Bearer $jwtToken") + } + + override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { + val opdsAddressPref = screen.editTextPreference( + ADDRESS_TITLE, + "OPDS url", + "", + helper.intl["pref_opds_summary"], + ) + val enabledFiltersPref = MultiSelectListPreference(screen.context).apply { + key = KavitaConstants.toggledFiltersPref + title = helper.intl["pref_filters_title"] + summary = helper.intl["pref_filters_summary"] + entries = KavitaConstants.filterPrefEntries + entryValues = KavitaConstants.filterPrefEntriesValue + setDefaultValue(KavitaConstants.defaultFilterPrefEntries) + setOnPreferenceChangeListener { _, newValue -> + @Suppress("UNCHECKED_CAST") + val checkValue = newValue as Set + preferences.edit() + .putStringSet(KavitaConstants.toggledFiltersPref, checkValue) + .commit() + } + } + val customSourceNamePref = EditTextPreference(screen.context).apply { + key = KavitaConstants.customSourceNamePref + title = helper.intl["pref_customsource_title"] + summary = helper.intl["pref_edit_customsource_summary"] + setOnPreferenceChangeListener { _, newValue -> + val res = preferences.edit() + .putString(KavitaConstants.customSourceNamePref, newValue.toString()) + .commit() + Toast.makeText( + screen.context, + helper.intl["restartapp_settings"], + Toast.LENGTH_LONG, + ).show() + Log.v(LOG_TAG, "[Preferences] Successfully modified custom source name: $newValue") + res + } + } + screen.addPreference(customSourceNamePref) + screen.addPreference(opdsAddressPref) + screen.addPreference(enabledFiltersPref) + } + + private fun androidx.preference.PreferenceScreen.editTextPreference( + preKey: String, + title: String, + default: String, + summary: String, + isPassword: Boolean = false, + ): EditTextPreference { + return EditTextPreference(context).apply { + key = preKey + this.title = title + val input = preferences.getString(title, null) + this.summary = if (input == null || input.isEmpty()) summary else input + this.setDefaultValue(default) + dialogTitle = title + + if (isPassword) { + setOnBindEditTextListener { + it.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + setOnPreferenceChangeListener { _, newValue -> + try { + val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain + if (opdsUrlInPref.isNotEmpty()) { + // TODO("Add option to allow multiple sources with same url at the cost of tracking") + preferences.edit().putString(title, "").apply() + + Toast.makeText( + context, + helper.intl["pref_opds_duplicated_source_url"] + ": " + opdsUrlInPref, + Toast.LENGTH_LONG, + ).show() + throw OpdsurlExistsInPref(helper.intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) + } + + val res = preferences.edit().putString(title, newValue as String).commit() + Toast.makeText( + context, + helper.intl["restartapp_settings"], + Toast.LENGTH_LONG, + ).show() + setupLogin(newValue) + Log.v(LOG_TAG, "[Preferences] Successfully modified OPDS URL") + res + } catch (e: OpdsurlExistsInPref) { + Log.e(LOG_TAG, "Url exists in a different sourcce") + false + } catch (e: Exception) { + Log.e(LOG_TAG, "Unrecognised error", e) + false + } + } + } + } + + private fun getPrefBaseUrl(): String = preferences.getString("BASEURL", "")!! + private fun getPrefApiUrl(): String = preferences.getString("APIURL", "")!! + private fun getPrefKey(): String = preferences.getString("APIKEY", "")!! + private fun getToggledFilters() = preferences.getStringSet(KavitaConstants.toggledFiltersPref, KavitaConstants.defaultFilterPrefEntries)!! + + // We strip the last slash since we will append it above + private fun getPrefAddress(): String { + var path = preferences.getString(ADDRESS_TITLE, "")!! + if (path.isNotEmpty() && path.last() == '/') { + path = path.substring(0, path.length - 1) + } + return path + } + + private fun getPrefApiKey(): String { + // http(s)://host:(port)/api/opds/api-key + val existingKey = preferences.getString("APIKEY", "") + return existingKey!!.ifEmpty { preferences.getString(ADDRESS_TITLE, "")!!.split("/opds/")[1] } + } + + companion object { + private const val ADDRESS_TITLE = "Address" + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull() + } + + /* + * LOGIN + **/ + /** + * Used to check if a url is configured already in any of the sources + * This is a limitation needed for tracking. + * **/ + private fun opdsUrlInPreferences(url: String): String { + fun getCleanedApiUrl(url: String): String = "${url.split("/api/").first()}/api" + + for (sourceId in 1..3) { // There's 3 sources so 3 preferences to check + val sourceSuffixID by lazy { + val key = "${"kavita_$sourceId"}/all/1" // Hardcoded versionID to 1 + 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 + } + val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$sourceSuffixID", 0x0000) + } + val prefApiUrl = preferences.getString("APIURL", "")!! + + if (prefApiUrl.isNotEmpty()) { + if (prefApiUrl == getCleanedApiUrl(url)) { + if (sourceId.toString() != suffix) { + return preferences.getString(KavitaConstants.customSourceNamePref, sourceId.toString())!! + } + } + } + } + return "" + } + + private fun setupLogin(addressFromPreference: String = "") { + Log.v(LOG_TAG, "[Setup Login] Starting setup") + val validAddress = address.ifEmpty { addressFromPreference } + val tokens = validAddress.split("/api/opds/") + val apiKey = tokens[1] + val baseUrlSetup = tokens[0].replace("\n", "\\n") + + if (baseUrlSetup.toHttpUrlOrNull() == null) { + Log.e(LOG_TAG, "Invalid URL $baseUrlSetup") + throw Exception("""${helper.intl["login_errors_invalid_url"]}: $baseUrlSetup""") + } + preferences.edit().putString("BASEURL", baseUrlSetup).apply() + preferences.edit().putString("APIKEY", apiKey).apply() + preferences.edit().putString("APIURL", "$baseUrlSetup/api").apply() + Log.v(LOG_TAG, "[Setup Login] Setup successful") + } + + private fun doLogin() { + if (address.isEmpty()) { + Log.e(LOG_TAG, "OPDS URL is empty or null") + throw IOException(helper.intl["pref_opds_must_setup_address"]) + } + if (address.split("/opds/").size != 2) { + throw IOException(helper.intl["pref_opds_badformed_url"]) + } + if (jwtToken.isEmpty()) setupLogin() + Log.v(LOG_TAG, "[Login] Starting login") + val request = POST( + "$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita", + setupLoginHeaders().build(), + "{}".toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()), + ) + client.newCall(request).execute().use { + val peekbody = it.peekBody(Long.MAX_VALUE).toString() + + if (it.code == 200) { + try { + jwtToken = it.parseAs().token + isLogged = true + } catch (e: Exception) { + Log.e(LOG_TAG, "Possible outdated kavita", e) + throw IOException(helper.intl["login_errors_parse_tokendto"]) + } + } else { + if (it.code == 500) { + Log.e(LOG_TAG, "[LOGIN] login failed. There was some error -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") + throw LoginErrorException(helper.intl["login_errors_failed_login"]) + } else { + Log.e(LOG_TAG, "[LOGIN] login failed. Authentication was not successful -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") + throw LoginErrorException(helper.intl["login_errors_failed_login"]) + } + } + } + Log.v(LOG_TAG, "[Login] Login successful") + } + + init { + if (apiUrl.isNotBlank()) { + Single.fromCallable { + // Login + doLogin() + try { // Get current version + val requestUrl = "$apiUrl/Server/server-info" + val serverInfoDto = client.newCall(GET(requestUrl, headersBuilder().build())) + .execute() + .parseAs() + Log.e( + LOG_TAG, + "Extension version: code=${AppInfo.getVersionCode()} name=${AppInfo.getVersionName()}" + + " - - Kavita version: ${serverInfoDto.kavitaVersion} - - Lang:${Locale.getDefault()}", + ) // this is not a real error. Using this so it gets printed in dump logs if there's any error + } catch (e: EmptyRequestBody) { + Log.e(LOG_TAG, "Extension version: code=${AppInfo.getVersionCode()} - name=${AppInfo.getVersionName()}") + } catch (e: Exception) { + Log.e(LOG_TAG, "Tachiyomi version: code=${AppInfo.getVersionCode()} - name=${AppInfo.getVersionName()}", e) + } + try { // Load Filters + // Genres + Log.v(LOG_TAG, "[Filter] Fetching filters ") + client.newCall(GET("$apiUrl/Metadata/genres", headersBuilder().build())) + .execute().use { response -> + + genresListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e(LOG_TAG, "[Filter] Error decoding JSON for genres filter -> ${response.body}", e) + emptyList() + } + } + // tagsListMeta + client.newCall(GET("$apiUrl/Metadata/tags", headersBuilder().build())) + .execute().use { response -> + tagsListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e(LOG_TAG, "[Filter] Error decoding JSON for tagsList filter", e) + emptyList() + } + } + // age-ratings + client.newCall(GET("$apiUrl/Metadata/age-ratings", headersBuilder().build())) + .execute().use { response -> + ageRatingsListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for age-ratings filter", + e, + ) + emptyList() + } + } + // collectionsListMeta + client.newCall(GET("$apiUrl/Collection", headersBuilder().build())) + .execute().use { response -> + collectionsListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for collectionsListMeta filter", + e, + ) + emptyList() + } + } + // languagesListMeta + client.newCall(GET("$apiUrl/Metadata/languages", headersBuilder().build())) + .execute().use { response -> + languagesListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for languagesListMeta filter", + e, + ) + emptyList() + } + } + // libraries + client.newCall(GET("$apiUrl/Library", headersBuilder().build())) + .execute().use { response -> + libraryListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "[Filter] Error decoding JSON for libraries filter", + e, + ) + emptyList() + } + } + // peopleListMeta + client.newCall(GET("$apiUrl/Metadata/people", headersBuilder().build())) + .execute().use { response -> + peopleListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "error while decoding JSON for peopleListMeta filter", + e, + ) + emptyList() + } + } + client.newCall(GET("$apiUrl/Metadata/publication-status", headersBuilder().build())) + .execute().use { response -> + pubStatusListMeta = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "error while decoding JSON for publicationStatusListMeta filter", + e, + ) + emptyList() + } + } + client.newCall(GET("$apiUrl/filter", headersBuilder().build())) + .execute().use { response -> + smartFilters = try { + response.body.use { json.decodeFromString(it.string()) } + } catch (e: Exception) { + Log.e( + LOG_TAG, + "error while decoding JSON for smartfilters", + e, + ) + emptyList() + } + } + Log.v(LOG_TAG, "[Filter] Successfully loaded metadata tags from server") + } catch (e: Exception) { + throw LoadingFilterFailed("Failed Loading Filters", e.cause) + } + } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe( + {}, + { tr -> + // Avoid polluting logs with traces of exception + if (tr is EmptyRequestBody || tr is LoginErrorException) { + Log.e(LOG_TAG, "error while doing initial calls\n${tr.cause}") + return@subscribe + } + if (tr is ConnectException) { // avoid polluting logs with traces of exception + Log.e(LOG_TAG, "Error while doing initial calls\n${tr.cause}") + return@subscribe + } + Log.e(LOG_TAG, "error while doing initial calls", tr) + }, + ) + } + } +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt new file mode 100644 index 000000000..24f284410 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt @@ -0,0 +1,81 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +object KavitaConstants { + // toggle filters + const val toggledFiltersPref = "toggledFilters" + val filterPrefEntries = arrayOf( + "Sort Options", + "Format", + "Libraries", + "Read Status", + "Genres", + "Tags", + "Collections", + "Languages", + "Publication Status", + "Rating", + "Age Rating", + "Writers", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "Cover Artist", + "Editor", + "Publisher", + "Character", + "Translators", + "ReleaseYearRange", + ) + val filterPrefEntriesValue = arrayOf( + "Sort Options", + "Format", + "Libraries", + "Read Status", + "Genres", + "Tags", + "Collections", + "Languages", + "Publication Status", + "Rating", + "Age Rating", + "Writers", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Publisher", + "Character", + "Translators", + "ReleaseYearRange", + ) + val defaultFilterPrefEntries = setOf( + "Sort Options", + "Format", + "Libraries", + "Read Status", + "Genres", + "Tags", + "Collections", + "Languages", + "Publication Status", + "Rating", + "Age Rating", + "Writers", + "Penciller", + "Inker", + "Colorist", + "Letterer", + "CoverArtist", + "Editor", + "Publisher", + "Character", + "Translators", + "ReleaseYearRange", + ) + + const val customSourceNamePref = "customSourceName" + const val noSmartFilterSelected = "No smart filter loaded" +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaFactory.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaFactory.kt new file mode 100644 index 000000000..7639b6e53 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaFactory.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class KavitaFactory : SourceFactory { + override fun createSources(): List = + listOf( + Kavita("1"), + Kavita("2"), + Kavita("3"), + ) +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt new file mode 100644 index 000000000..f588f684b --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt @@ -0,0 +1,141 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.PaginationInfo +import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto +import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto +import eu.kanade.tachiyomi.lib.i18n.Intl +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Response +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class KavitaHelper { + val json = Json { + isLenient = true + ignoreUnknownKeys = true + allowSpecialFloatingPointValues = true + useArrayPolymorphism = true + prettyPrint = true + } + inline fun > safeValueOf(type: String): T { + return java.lang.Enum.valueOf(T::class.java, type) + } + val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + fun parseDate(dateAsString: String): Long = + dateFormatter.parse(dateAsString)?.time ?: 0 + + fun hasNextPage(response: Response): Boolean { + val paginationHeader = response.header("Pagination") + var hasNextPage = false + if (!paginationHeader.isNullOrEmpty()) { + val paginationInfo = json.decodeFromString(paginationHeader) + hasNextPage = paginationInfo.currentPage + 1 > paginationInfo.totalPages + } + return !hasNextPage + } + + fun getIdFromUrl(url: String): Int { + return url.split("/").last().toInt() + } + + fun createSeriesDto(obj: SeriesDto, baseUrl: String, apiKey: String): SManga = + SManga.create().apply { + url = "$baseUrl/Series/${obj.id}" + title = obj.name + // Deprecated: description = obj.summary + thumbnail_url = "$baseUrl/image/series-cover?seriesId=${obj.id}&apiKey=$apiKey" + } + class CompareChapters { + companion object : Comparator { + override fun compare(a: SChapter, b: SChapter): Int { + if (a.chapter_number < 1.0 && b.chapter_number < 1.0) { + // Both are volumes, multiply by 100 and do normal sort + return if ((a.chapter_number * 100) < (b.chapter_number * 100)) { + 1 + } else { + -1 + } + } else { + if (a.chapter_number < 1.0 && b.chapter_number >= 1.0) { + // A is volume, b is not. A should sort first + return 1 + } else if (a.chapter_number >= 1.0 && b.chapter_number < 1.0) { + return -1 + } + } + if (a.chapter_number < b.chapter_number) return 1 + if (a.chapter_number > b.chapter_number) return -1 + return 0 + } + } + } + fun chapterFromObject(obj: ChapterDto): SChapter = SChapter.create().apply { + url = obj.id.toString() + name = if (obj.number == "0" && obj.isSpecial) { + // This is a special. Chapter name is special name + obj.range + } else { + val cleanedName = obj.title.replaceFirst("^0+(?!$)".toRegex(), "") + "Chapter $cleanedName" + } + date_upload = parseDate(obj.created) + chapter_number = obj.number.toFloat() + scanlator = "${obj.pages} pages" + } + + fun chapterFromVolume(obj: ChapterDto, volume: VolumeDto): SChapter = + SChapter.create().apply { + // If there are multiple chapters to this volume, then prefix with Volume number + if (volume.chapters.isNotEmpty() && obj.number != "0") { + // This volume is not volume 0, hence they are not loose chapters + // We just add a nice Volume X to the chapter title + // Chapter-based Volume + name = "Volume ${volume.number} Chapter ${obj.number}" + chapter_number = obj.number.toFloat() + } else if (obj.number == "0") { + // Both specials and volume has chapter number 0 + if (volume.number == 0) { + // Treat as special + // Special is not in a volume + if (obj.range == "") { + // Special does not have any Title + name = "Chapter 0" + chapter_number = obj.number.toFloat() + } else { + // We use it's own special tile + name = obj.range + chapter_number = obj.number.toFloat() + } + } else { + // Is a single-file volume + // We encode the chapter number to support tracking + name = "Volume ${volume.number}" + chapter_number = volume.number.toFloat() / 10000 + } + } else { + name = "Unhandled Else Volume ${volume.number}" + } + url = obj.id.toString() + date_upload = parseDate(obj.created) + + scanlator = "${obj.pages} pages" + } + val intl = Intl( + language = Locale.getDefault().toString(), + baseLanguage = "en", + availableLanguages = KavitaInt.AVAILABLE_LANGS, + classLoader = this::class.java.classLoader!!, + createMessageFileName = { lang -> + when (lang) { + KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH) + else -> Intl.createDefaultMessageFileName(lang) + } + }, + ) +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt new file mode 100644 index 000000000..fbaf93af0 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaInt.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.extension.all.kavita + +object KavitaInt { + const val ENGLISH = "en" + const val SPANISH = "es_ES" + const val SPANISH_LATAM = "es-419" + const val FRENCH = "fr_FR" + const val NORWEGIAN = "nb_NO" + val AVAILABLE_LANGS = setOf( + ENGLISH, + SPANISH, + SPANISH_LATAM, + NORWEGIAN, + FRENCH, + ) + const val KAVITA_NAME = "Kavita" +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt new file mode 100644 index 000000000..77b669e51 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt @@ -0,0 +1,124 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable +import kotlin.Triple + +@Serializable +data class FilterV2Dto( + val id: Int? = null, + val name: String? = null, + val statements: MutableList = mutableListOf(), + val combination: Int = 0, // FilterCombination = FilterCombination.And, + val sortOptions: SortOptions = SortOptions(), + val limitTo: Int = 0, +) { + fun addStatement(comparison: FilterComparison, field: FilterField, value: String) { + if (value.isNotBlank()) { + statements.add(FilterStatementDto(comparison.type, field.type, value)) + } + } + fun addStatement(comparison: FilterComparison, field: FilterField, values: java.util.ArrayList) { + if (values.isNotEmpty()) { + statements.add(FilterStatementDto(comparison.type, field.type, values.joinToString(","))) + } + } + + fun addContainsNotTriple(list: List, ArrayList>>) { + list.map { + addStatement(FilterComparison.Contains, it.first, it.second) + addStatement(FilterComparison.NotContains, it.first, it.third) + } + } + fun addPeople(list: List>>) { + list.map { + addStatement(FilterComparison.MustContains, it.first, it.second) + } + } +} + +@Serializable +data class FilterStatementDto( + // todo: Create custom serializator for comparison and field and remove .type extension in Kavita.kt + val comparison: Int, + val field: Int, + val value: String, + +) + +@Serializable +enum class SortFieldEnum(val type: Int) { + SortName(1), + CreatedDate(2), + LastModifiedDate(3), + LastChapterAdded(4), + TimeToRead(5), + ReleaseYear(6), + ; + + companion object { + private val map = SortFieldEnum.values().associateBy(SortFieldEnum::type) + fun fromInt(type: Int) = map[type] + } +} + +@Serializable +data class SortOptions( + var sortField: Int = SortFieldEnum.SortName.type, + var isAscending: Boolean = true, +) + +@Serializable +enum class FilterCombination { + Or, + And, +} + +@Serializable +enum class FilterField(val type: Int) { + Summary(0), + SeriesName(1), + PublicationStatus(2), + Languages(3), + AgeRating(4), + UserRating(5), + Tags(6), + CollectionTags(7), + Translators(8), + Characters(9), + Publisher(10), + Editor(11), + CoverArtist(12), + Letterer(13), + Colorist(14), + Inker(15), + Penciller(16), + Writers(17), + Genres(18), + Libraries(19), + ReadProgress(20), + Formats(21), + ReleaseYear(22), + ReadTime(23), + Path(24), + FilePath(25), +} + +@Serializable +enum class FilterComparison(val type: Int) { + Equal(0), + GreaterThan(1), + GreaterThanEqual(2), + LessThan(3), + LessThanEqual(4), + Contains(5), + MustContains(6), + Matches(7), + NotContains(8), + NotEqual(9), + BeginsWith(10), + EndsWith(11), + IsBefore(12), + IsAfter(13), + IsInLast(14), + IsNotInLast(15), +} diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt new file mode 100644 index 000000000..1c09db11d --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt @@ -0,0 +1,103 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable + +@Serializable +enum class MangaFormat(val format: Int) { + Image(0), + Archive(1), + Unknown(2), + Epub(3), + Pdf(4), + ; + companion object { + private val map = PersonRole.values().associateBy(PersonRole::role) + fun fromInt(type: Int) = map[type] + } +} +enum class PersonRole(val role: Int) { + Other(1), + Writer(3), + Penciller(4), + Inker(5), + Colorist(6), + Letterer(7), + CoverArtist(8), + Editor(9), + Publisher(10), + Character(11), + Translator(12), + ; + companion object { + private val map = PersonRole.values().associateBy(PersonRole::role) + fun fromInt(type: Int) = map[type] + } +} + +@Serializable +data class SeriesDto( + val id: Int, + val name: String, + val originalName: String = "", + val thumbnail_url: String? = "", + val localizedName: String? = "", + val sortName: String? = "", + val pages: Int, + val coverImageLocked: Boolean = true, + val pagesRead: Int, + val userRating: Float, + val userReview: String? = "", + val format: Int, + val created: String? = "", + val libraryId: Int, + val libraryName: String? = "", +) + +@Serializable +data class SeriesMetadataDto( + val id: Int, + val summary: String? = "", + val writers: List = emptyList(), + val coverArtists: List = emptyList(), + val genres: List = emptyList(), + val seriesId: Int, + val ageRating: Int, + val publicationStatus: Int, +) + +@Serializable +data class Genres( + val title: String, +) + +@Serializable +data class Person( + val name: String, +) + +@Serializable +data class VolumeDto( + val id: Int, + val number: Int, + val name: String, + val pages: Int, + val pagesRead: Int, + val lastModified: String, + val created: String, + val seriesId: Int, + val chapters: List = emptyList(), +) + +@Serializable +data class ChapterDto( + val id: Int, + val range: String, + val number: String, + val pages: Int, + val isSpecial: Boolean, + val title: String, + val pagesRead: Int, + val coverImageLocked: Boolean, + val volumeId: Int, + val created: String, +) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt new file mode 100644 index 000000000..f14256719 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt @@ -0,0 +1,104 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable +/** +* This file contains all class for filtering +* */ +@Serializable +data class MetadataGenres( + val id: Int, + val title: String, +) + +@Serializable +data class MetadataPeople( + val id: Int, + val name: String, + val role: Int, +) + +@Serializable +data class MetadataPubStatus( + val value: Int, + val title: String, +) + +@Serializable +data class MetadataTag( + val id: Int, + val title: String, +) + +@Serializable +data class MetadataAgeRatings( + val value: Int, + val title: String, +) + +@Serializable +data class MetadataLanguages( + val isoCode: String, + val title: String, +) + +@Serializable +data class MetadataLibrary( + val id: Int, + val name: String, + val type: Int, +) + +@Serializable +data class MetadataCollections( + val id: Int, + val title: String, +) + +data class MetadataPayload( + val forceUseMetadataPayload: Boolean = true, + var sorting: Int = 1, + var sorting_asc: Boolean = true, + var readStatus: ArrayList = arrayListOf(), + val readStatusList: List = listOf("notRead", "inProgress", "read"), + // _i = included, _e = excluded + var genres_i: ArrayList = arrayListOf(), + var genres_e: ArrayList = arrayListOf(), + var tags_i: ArrayList = arrayListOf(), + var tags_e: ArrayList = arrayListOf(), + var ageRating_i: ArrayList = arrayListOf(), + var ageRating_e: ArrayList = arrayListOf(), + + var formats: ArrayList = arrayListOf(), + var collections_i: ArrayList = arrayListOf(), + var collections_e: ArrayList = arrayListOf(), + var userRating: Int = 0, + var people: ArrayList = arrayListOf(), + // _i = included, _e = excluded + var language_i: ArrayList = arrayListOf(), + var language_e: ArrayList = arrayListOf(), + + var libraries_i: ArrayList = arrayListOf(), + var libraries_e: ArrayList = arrayListOf(), + var pubStatus: ArrayList = arrayListOf(), + var seriesNameQuery: String = "", + var releaseYearRangeMin: Int = 0, + var releaseYearRangeMax: Int = 0, + + var peopleWriters: ArrayList = arrayListOf(), + var peoplePenciller: ArrayList = arrayListOf(), + var peopleInker: ArrayList = arrayListOf(), + var peoplePeoplecolorist: ArrayList = arrayListOf(), + var peopleLetterer: ArrayList = arrayListOf(), + var peopleCoverArtist: ArrayList = arrayListOf(), + var peopleEditor: ArrayList = arrayListOf(), + var peoplePublisher: ArrayList = arrayListOf(), + var peopleCharacter: ArrayList = arrayListOf(), + var peopleTranslator: ArrayList = arrayListOf(), +) + +@Serializable +data class SmartFilter( + val id: Int, + val name: String, + val filter: String, +) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/Responses.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/Responses.kt new file mode 100644 index 000000000..424cc1233 --- /dev/null +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/Responses.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.extension.all.kavita.dto + +import kotlinx.serialization.Serializable + +@Serializable // Used to process login +data class AuthenticationDto( + val username: String, + val token: String, + val apiKey: String, +) + +@Serializable +data class PaginationInfo( + val currentPage: Int, + val itemsPerPage: Int, + val totalItems: Int, + val totalPages: Int, +) + +@Serializable +data class ServerInfoDto( + val installId: String, + val os: String, + val isDocker: Boolean, + val dotnetVersion: String, + val kavitaVersion: String, + val numOfCores: Int, +) diff --git a/src/all/komga/AndroidManifest.xml b/src/all/komga/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/all/komga/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/komga/CHANGELOG.md b/src/all/komga/CHANGELOG.md new file mode 100644 index 000000000..759286187 --- /dev/null +++ b/src/all/komga/CHANGELOG.md @@ -0,0 +1,383 @@ +## 1.4.47 + +Minimum Komga version required: `0.151.0` + +### Feat + +* add support for AVIF and HEIF image types + +## 1.4.46 + +Minimum Komga version required: `0.151.0` + +### Feat + +* Update to extension-lib 1.4 + - Clicking on chapter WebView should now open the chapter/book page. + +## 1.3.45 + +Minimum Komga version required: `0.151.0` + +### Feat + +* Edit source display name + +## 1.3.44 + +Minimum Komga version required: `0.151.0` + +### Fix + +* Better date/time parsing + +## 1.3.43 + +Minimum Komga version required: `0.151.0` + +### Fix + +* Requests failing if address preference is saved with a trailing slash + +### Features + +* Add URL validation in the address preferences +* Use a URL-focused keyboard when available while editing the address preferences + +## 1.3.42 + +Minimum Komga version required: `0.151.0` + +### Fix + +* default sort broken since Komga 0.155.1 +* proper sort criteria for readlists + +## 1.3.41 + +Minimum Komga version required: `0.151.0` + +### Features + +* Improve how the status is displayed + +## 1.3.40 + +Minimum Komga version required: `0.151.0` + +### Features + +* Exclude from bulk update warnings + +## 1.2.39 + +Minimum Komga version required: `0.151.0` + +### Features + +* Prepend series name in front of books within readlists + +## 1.2.38 + +Minimum Komga version required: `0.113.0` + +### Features + +* Add `README.md` + +## 1.2.37 + +Minimum Komga version required: `0.113.0` + +### Features + +* In app link to `CHANGELOG.md` + +## 1.2.36 + +Minimum Komga version required: `0.113.0` + +### Features + +* Don't request conversion for JPEG XL images + +## 1.2.35 + +Minimum Komga version required: `0.113.0` + +### Features + +* Display the Translators of a book in the scanlator chapter field + +## 1.2.34 + +Minimum Komga version required: `0.113.0` + +### Fix + +* Loading of filter values could fail in some cases + +## 1.2.33 + +Minimum Komga version required: `0.113.0` + +### Fix + +* Open in WebView and Share options now open regular browser link instead of showing JSON +* Note that Komga cannot be viewed using System WebView since there is no login prompt + However, opening in a regular browser works. + +## 1.2.32 + +Minimum Komga version required: `0.113.0` + +### Fix + +* Source language, conventionally set to "en", is now changed to "all" +* Downloaded files, if any, will have to be moved to new location + - `Komga (EN)` to `Komga (ALL)` + - `Komga (3) (EN)` to `Komga (3) (ALL)` + +## 1.2.31 + +Minimum Komga version required: `0.113.0` + +### Refactor + +* replace Gson with kotlinx.serialization + +## 1.2.30 + +Minimum Komga version required: `0.113.0` + +### Features + +* display read list summary +* display aggregated tags on series +* search series by book tags + +## 1.2.29 + +Minimum Komga version required: `0.97.0` + +### Features + +* filter deleted series and books + +## 1.2.28 + +Minimum Komga version required: `0.97.0` + +### Fix + +* incorrect User Agent + +## 1.2.27 + +Minimum Komga version required: `0.97.0` + +### Fix + +* filter series by read or in progress + +## 1.2.26 + +Minimum Komga version required: `0.87.4` + +### Fix + +* show series with only in progress books when searching for unread only + +## 1.2.25 + +Minimum Komga version required: `0.87.4` + +### Fix + +* sort order for read list books + +## 1.2.24 + +Minimum Komga version required: `0.87.4` + +### Fix + +* only show series tags in the filter panel +* set URL properly on series and read lists, so restoring from a backup can work properly + + +## 1.2.23 + +Minimum Komga version required: `0.75.0` + +### Features + +* ignore DNS over HTTPS so it can reach IP addresses + +## 1.2.22 + +Minimum Komga version required: `0.75.0` + +### Features + +* add error logs and better catch exceptions + +## 1.2.21 + +Minimum Komga version required: `0.75.0` + +### Features + +* browse read lists (from the filter menu) +* filter by collection, respecting the collection's ordering + +## 1.2.20 + +Minimum Komga version required: `0.75.0` + +### Features + +* filter by authors, grouped by role + +## 1.2.19 + +Minimum Komga version required: `0.68.0` + +### Features + +* display Series authors +* display Series summary from books if no summary exists for Series + +## 1.2.18 + +Minimum Komga version required: `0.63.2` + +### Fix + +* use metadata.releaseDate or fileLastModified for chapter date + +## 1.2.17 + +Minimum Komga version required: `0.63.2` + +### Fix + +* list of collections for filtering could be empty in some conditions + +## 1.2.16 + +Minimum Komga version required: `0.59.0` + +### Features + +* filter by genres, tags and publishers + +## 1.2.15 + +Minimum Komga version required: `0.56.0` + +### Features + +* remove the 1000 chapters limit +* display series description and tags (genres + tags) + +## 1.2.14 + +Minimum Komga version required: `0.41.0` + +### Features + +* change chapter display name to use the display number instead of the sort number + +## 1.2.13 + +Minimum Komga version required: `0.41.0` + +### Features + +* compatibility for the upcoming version of Komga which have changes in the API (IDs are String instead of Long) + +## 1.2.12 + +Minimum Komga version required: `0.41.0` + +### Features + +* filter by collection + +## 1.2.11 + +Minimum Komga version required: `0.35.2` + +### Features + +* Set password preferences inputTypes + +## 1.2.10 + +Minimum Komga version required: `0.35.2` + +### Features + +* unread only filter (closes gotson/komga#180) +* prefix book titles with number (closes gotson/komga#169) + +## 1.2.9 + +Minimum Komga version required: `0.22.0` + +### Features + +* use SourceFactory to have multiple Komga servers (3 for the moment) + +## 1.2.8 + +Minimum Komga version required: `0.22.0` + +### Features + +* use book metadata title for chapter display name +* use book metadata sort number for chapter number + +## 1.2.7 + +### Features + +* use series metadata title for display name +* filter on series status + +## 1.2.6 + +### Features + +* Add support for AndroidX preferences + +## 1.2.5 + +### Features + +* add sort options in filter + +## 1.2.4 + +### Features + +* better handling of authentication + +## 1.2.3 + +### Features + +* filters by library + +## 1.2.2 + +### Features + +* request converted image from server if format is not supported + +## 1.2.1 + +### Features + +* first version diff --git a/src/all/komga/README.md b/src/all/komga/README.md new file mode 100644 index 000000000..fa7ae9fc7 --- /dev/null +++ b/src/all/komga/README.md @@ -0,0 +1,35 @@ +# 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 Tachiyomi?](#how-do-i-add-my-komga-server-to-tachiyomi) + +Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation) + +## 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 3 different Komga instances to Tachiyomi. + +### 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`. + +## Guides + +### How do I add my Komga server to Tachiyomi? +Go into the settings of the Komga extension from the Extension tab in Browse and fill in your server address and login details. diff --git a/src/all/komga/build.gradle b/src/all/komga/build.gradle new file mode 100644 index 000000000..42e1b089d --- /dev/null +++ b/src/all/komga/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Komga' + extClass = '.KomgaFactory' + extVersionCode = 50 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/komga/res/mipmap-hdpi/ic_launcher.png b/src/all/komga/res/mipmap-hdpi/ic_launcher.png new file mode 100755 index 000000000..4a5514218 Binary files /dev/null and b/src/all/komga/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/komga/res/mipmap-mdpi/ic_launcher.png b/src/all/komga/res/mipmap-mdpi/ic_launcher.png new file mode 100755 index 000000000..af95fb6ca Binary files /dev/null and b/src/all/komga/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/komga/res/mipmap-xhdpi/ic_launcher.png b/src/all/komga/res/mipmap-xhdpi/ic_launcher.png new file mode 100755 index 000000000..76c5112ce Binary files /dev/null and b/src/all/komga/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/komga/res/mipmap-xxhdpi/ic_launcher.png b/src/all/komga/res/mipmap-xxhdpi/ic_launcher.png new file mode 100755 index 000000000..80894b006 Binary files /dev/null and b/src/all/komga/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/komga/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/komga/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100755 index 000000000..798b30128 Binary files /dev/null and b/src/all/komga/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt new file mode 100644 index 000000000..4f0adb59f --- /dev/null +++ b/src/all/komga/src/eu/kanade/tachiyomi/extension/all/komga/Komga.kt @@ -0,0 +1,637 @@ +package eu.kanade.tachiyomi.extension.all.komga + +import android.app.Application +import android.content.SharedPreferences +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.util.Log +import android.widget.Button +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.AppInfo +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.CollectionDto +import eu.kanade.tachiyomi.extension.all.komga.dto.LibraryDto +import eu.kanade.tachiyomi.extension.all.komga.dto.PageDto +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.network.GET +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.ConfigurableSource +import eu.kanade.tachiyomi.source.UnmeteredSource +import eu.kanade.tachiyomi.source.model.Filter +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Credentials +import okhttp3.Dns +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import rx.Single +import rx.schedulers.Schedulers +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.security.MessageDigest +import java.util.Locale + +open class Komga(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { + override fun popularMangaRequest(page: Int): Request = + GET("$baseUrl/api/v1/series?page=${page - 1}&deleted=false&sort=metadata.titleSort,asc", headers) + + override fun popularMangaParse(response: Response): MangasPage = + processSeriesPage(response) + + override fun latestUpdatesRequest(page: Int): Request = + GET("$baseUrl/api/v1/series/latest?page=${page - 1}&deleted=false", headers) + + override fun latestUpdatesParse(response: Response): MangasPage = + processSeriesPage(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val collectionId = (filters.find { it is CollectionSelect } as? CollectionSelect)?.let { + it.values[it.state].id + } + + val type = when { + collectionId != null -> "collections/$collectionId/series" + filters.find { it is TypeSelect }?.state == 1 -> "readlists" + else -> "series" + } + + val url = "$baseUrl/api/v1/$type?search=$query&page=${page - 1}&deleted=false".toHttpUrlOrNull()!!.newBuilder() + + filters.forEach { filter -> + when (filter) { + is UnreadFilter -> { + if (filter.state) { + url.addQueryParameter("read_status", "UNREAD") + url.addQueryParameter("read_status", "IN_PROGRESS") + } + } + is InProgressFilter -> { + if (filter.state) { + url.addQueryParameter("read_status", "IN_PROGRESS") + } + } + is ReadFilter -> { + if (filter.state) { + url.addQueryParameter("read_status", "READ") + } + } + is LibraryGroup -> { + val libraryToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + libraryToInclude.add(content.id) + } + } + if (libraryToInclude.isNotEmpty()) { + url.addQueryParameter("library_id", libraryToInclude.joinToString(",")) + } + } + is StatusGroup -> { + val statusToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + statusToInclude.add(content.name.uppercase(Locale.ROOT)) + } + } + if (statusToInclude.isNotEmpty()) { + url.addQueryParameter("status", statusToInclude.joinToString(",")) + } + } + is GenreGroup -> { + val genreToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + genreToInclude.add(content.name) + } + } + if (genreToInclude.isNotEmpty()) { + url.addQueryParameter("genre", genreToInclude.joinToString(",")) + } + } + is TagGroup -> { + val tagToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + tagToInclude.add(content.name) + } + } + if (tagToInclude.isNotEmpty()) { + url.addQueryParameter("tag", tagToInclude.joinToString(",")) + } + } + is PublisherGroup -> { + val publisherToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + publisherToInclude.add(content.name) + } + } + if (publisherToInclude.isNotEmpty()) { + url.addQueryParameter("publisher", publisherToInclude.joinToString(",")) + } + } + is AuthorGroup -> { + val authorToInclude = mutableListOf() + filter.state.forEach { content -> + if (content.state) { + authorToInclude.add(content.author) + } + } + authorToInclude.forEach { + url.addQueryParameter("author", "${it.name},${it.role}") + } + } + is Filter.Sort -> { + var sortCriteria = when (filter.state?.index) { + 0 -> if (type == "series") "metadata.titleSort" else "name" + 1 -> "createdDate" + 2 -> "lastModifiedDate" + else -> "" + } + if (sortCriteria.isNotEmpty()) { + sortCriteria += "," + if (filter.state?.ascending!!) "asc" else "desc" + url.addQueryParameter("sort", sortCriteria) + } + } + else -> {} + } + } + + return GET(url.toString(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage = + processSeriesPage(response) + + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(GET(manga.url, headers)) + .asObservable() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + override fun mangaDetailsRequest(manga: SManga): Request = + GET(manga.url.replaceFirst("api/v1/", "", ignoreCase = true), headers) + + override fun mangaDetailsParse(response: Response): SManga { + return response.body.use { body -> + if (response.fromReadList()) { + val readList = json.decodeFromString(body.string()) + readList.toSManga() + } else { + val series = json.decodeFromString(body.string()) + series.toSManga() + } + } + } + + override fun chapterListRequest(manga: SManga): Request = + GET("${manga.url}/books?unpaged=true&media_status=READY&deleted=false", headers) + + override fun chapterListParse(response: Response): List { + val responseBody = response.body + val page = responseBody.use { json.decodeFromString>(it.string()).content } + + val r = page.mapIndexed { index, book -> + SChapter.create().apply { + chapter_number = if (!response.fromReadList()) book.metadata.numberSort else index + 1F + name = "${if (!response.fromReadList()) "${book.metadata.number} - " else "${book.seriesTitle} ${book.metadata.number}: "}${book.metadata.title} (${book.size})" + url = "$baseUrl/api/v1/books/${book.id}" + scanlator = book.metadata.authors.groupBy({ it.role }, { it.name })["translator"]?.joinToString() + date_upload = book.metadata.releaseDate?.let { parseDate(it) } + ?: parseDateTime(book.fileLastModified) + } + } + return r.sortedByDescending { it.chapter_number } + } + + override fun pageListRequest(chapter: SChapter): Request = + GET("${chapter.url}/pages") + + override fun pageListParse(response: Response): List { + val responseBody = response.body + val pages = responseBody.use { json.decodeFromString>(it.string()) } + return pages.map { + val url = "${response.request.url}/${it.number}" + + if (!supportedImageTypes.contains(it.mediaType)) { + "?convert=png" + } else { + "" + } + Page( + index = it.number - 1, + imageUrl = url, + ) + } + } + + override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "") + + override fun getChapterUrl(chapter: SChapter) = chapter.url.replace("/api/v1/books", "/book") + + private fun processSeriesPage(response: Response): MangasPage { + val responseBody = response.body + return responseBody.use { body -> + if (response.fromReadList()) { + with(json.decodeFromString>(body.string())) { + MangasPage(content.map { it.toSManga() }, !last) + } + } else { + with(json.decodeFromString>(body.string())) { + MangasPage(content.map { it.toSManga() }, !last) + } + } + } + } + + private fun SeriesDto.toSManga(): SManga = + 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 }.let { map -> + author = map["writer"]?.map { it.name }?.distinct()?.joinToString() + artist = map["penciller"]?.map { it.name }?.distinct()?.joinToString() + } + } + + private fun ReadListDto.toSManga(): SManga = + SManga.create().apply { + title = name + description = summary + url = "$baseUrl/api/v1/readlists/$id" + thumbnail_url = "$url/thumbnail" + status = SManga.UNKNOWN + } + + private fun Response.fromReadList() = request.url.toString().contains("/api/v1/readlists") + + private fun parseDate(date: String?): Long = + if (date == null) { + 0 + } else { + try { + KomgaHelper.formatterDate.parse(date)?.time ?: 0 + } catch (ex: Exception) { + 0 + } + } + + private fun parseDateTime(date: String?): Long = + if (date == null) { + 0 + } else { + try { + KomgaHelper.formatterDateTime.parse(date)?.time ?: 0 + } catch (ex: Exception) { + try { + KomgaHelper.formatterDateTimeMilli.parse(date)?.time ?: 0 + } catch (ex: Exception) { + 0 + } + } + } + + override fun imageUrlParse(response: Response): String = "" + + private class TypeSelect : Filter.Select("Search for", arrayOf(TYPE_SERIES, TYPE_READLISTS)) + private class LibraryFilter(val id: String, name: String) : Filter.CheckBox(name, false) + private class LibraryGroup(libraries: List) : Filter.Group("Libraries", libraries) + private class CollectionSelect(collections: List) : Filter.Select("Collection", collections.toTypedArray()) + private class SeriesSort : Filter.Sort("Sort", arrayOf("Alphabetically", "Date added", "Date updated"), Selection(0, true)) + private class StatusFilter(name: String) : Filter.CheckBox(name, false) + private class StatusGroup(filters: List) : Filter.Group("Status", filters) + private class UnreadFilter : Filter.CheckBox("Unread", false) + private class InProgressFilter : Filter.CheckBox("In Progress", false) + private class ReadFilter : Filter.CheckBox("Read", false) + private class GenreFilter(genre: String) : Filter.CheckBox(genre, false) + private class GenreGroup(genres: List) : Filter.Group("Genres", genres) + private class TagFilter(tag: String) : Filter.CheckBox(tag, false) + private class TagGroup(tags: List) : Filter.Group("Tags", tags) + private class PublisherFilter(publisher: String) : Filter.CheckBox(publisher, false) + private class PublisherGroup(publishers: List) : Filter.Group("Publishers", publishers) + private class AuthorFilter(val author: AuthorDto) : Filter.CheckBox(author.name, false) + private class AuthorGroup(role: String, authors: List) : Filter.Group(role, authors) + + private data class CollectionFilterEntry( + val name: String, + val id: String? = null, + ) { + override fun toString() = name + } + + override fun getFilterList(): FilterList { + val filters = try { + mutableListOf>( + UnreadFilter(), + InProgressFilter(), + ReadFilter(), + TypeSelect(), + CollectionSelect(listOf(CollectionFilterEntry("None")) + collections.map { CollectionFilterEntry(it.name, it.id) }), + LibraryGroup(libraries.map { LibraryFilter(it.id, it.name) }.sortedBy { it.name.lowercase(Locale.ROOT) }), + StatusGroup(listOf("Ongoing", "Ended", "Abandoned", "Hiatus").map { StatusFilter(it) }), + GenreGroup(genres.map { GenreFilter(it) }), + TagGroup(tags.map { TagFilter(it) }), + PublisherGroup(publishers.map { PublisherFilter(it) }), + ).also { list -> + list.addAll(authors.map { (role, authors) -> AuthorGroup(role, authors.map { AuthorFilter(it) }) }) + list.add(SeriesSort()) + } + } catch (e: Exception) { + Log.e(LOG_TAG, "error while creating filter list", e) + emptyList() + } + + return FilterList(filters) + } + + private var libraries = emptyList() + private var collections = emptyList() + private var genres = emptySet() + private var tags = emptySet() + private var publishers = emptySet() + private var authors = emptyMap>() // roles to list of authors + + // keep the previous ID when lang was "en", so that preferences and manga bindings are not lost + override val id by lazy { + val key = "komga${if (suffix.isNotBlank()) " ($suffix)" else ""}/en/$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 + } + + private val displayName by lazy { preferences.displayName } + final override val baseUrl by lazy { preferences.baseUrl } + private val username by lazy { preferences.username } + private val password by lazy { preferences.password } + private val json: Json by injectLazy() + + override fun headersBuilder(): Headers.Builder = + Headers.Builder() + .add("User-Agent", "TachiyomiKomga/${AppInfo.getVersionName()}") + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + override val name = "Komga${displayName.ifBlank { suffix }.let { if (it.isNotBlank()) " ($it)" else "" }}" + override val lang = "all" + override val supportsLatest = true + private val LOG_TAG = "extension.all.komga${if (suffix.isNotBlank()) ".$suffix" else ""}" + + override val client: OkHttpClient = + network.client.newBuilder() + .authenticator { _, response -> + if (response.request.header("Authorization") != null) { + null // Give up, we've already failed to authenticate. + } else { + response.request.newBuilder() + .addHeader("Authorization", Credentials.basic(username, password)) + .build() + } + } + .dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing + .build() + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + screen.addEditTextPreference( + title = "Source display name", + default = suffix, + summary = displayName.ifBlank { "Here you can change the source displayed suffix" }, + key = PREF_DISPLAYNAME, + ) + screen.addEditTextPreference( + title = "Address", + default = ADDRESS_DEFAULT, + summary = baseUrl.ifBlank { "The server address" }, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, + validate = { it.toHttpUrlOrNull() != null }, + validationMessage = "The URL is invalid or malformed", + key = PREF_ADDRESS, + ) + screen.addEditTextPreference( + title = "Username", + default = USERNAME_DEFAULT, + summary = username.ifBlank { "The user account email" }, + key = PREF_USERNAME, + ) + screen.addEditTextPreference( + title = "Password", + default = PASSWORD_DEFAULT, + summary = if (password.isBlank()) "The user account password" else "*".repeat(password.length), + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + key = PREF_PASSWORD, + ) + } + + private fun PreferenceScreen.addEditTextPreference( + title: String, + default: String, + summary: String, + inputType: Int? = null, + validate: ((String) -> Boolean)? = null, + validationMessage: String? = null, + key: String = title, + ) { + val preference = 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