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