re add kogma balls, lanraragi and kavita
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
|
@ -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
|
|
@ -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.
|
|
@ -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
|
|
@ -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:
|
|
@ -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
|
|
@ -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.
|
|
@ -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"
|
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 3.9 KiB |
After Width: | Height: | Size: 6.4 KiB |
After Width: | Height: | Size: 9.0 KiB |
|
@ -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<String>(
|
||||
"Minimum Rating",
|
||||
arrayOf(
|
||||
"Any",
|
||||
"1 star",
|
||||
"2 stars",
|
||||
"3 stars",
|
||||
"4 stars",
|
||||
"5 stars",
|
||||
),
|
||||
)
|
||||
class SmartFiltersFilter(smartFilters: Array<String>) :
|
||||
Filter.Select<String>("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters)
|
||||
class SortFilter(sortables: Array<String>) : 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<StatusFilter>) :
|
||||
Filter.Group<StatusFilter>("Status", filters)
|
||||
|
||||
class ReleaseYearRange(name: String) : Filter.Text(name)
|
||||
class ReleaseYearRangeGroup(filters: List<ReleaseYearRange>) :
|
||||
Filter.Group<ReleaseYearRange>("Release Year", filters)
|
||||
class GenreFilter(name: String) : Filter.TriState(name)
|
||||
class GenreFilterGroup(genres: List<GenreFilter>) :
|
||||
Filter.Group<GenreFilter>("Genres", genres)
|
||||
|
||||
class TagFilter(name: String) : Filter.TriState(name)
|
||||
class TagFilterGroup(tags: List<TagFilter>) : Filter.Group<TagFilter>("Tags", tags)
|
||||
|
||||
class AgeRatingFilter(name: String) : Filter.TriState(name)
|
||||
class AgeRatingFilterGroup(ageRatings: List<AgeRatingFilter>) :
|
||||
Filter.Group<AgeRatingFilter>("Age Rating", ageRatings)
|
||||
|
||||
class FormatFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class FormatsFilterGroup(formats: List<FormatFilter>) :
|
||||
Filter.Group<FormatFilter>("Formats", formats)
|
||||
|
||||
class CollectionFilter(name: String) : Filter.TriState(name)
|
||||
class CollectionFilterGroup(collections: List<CollectionFilter>) :
|
||||
Filter.Group<CollectionFilter>("Collection", collections)
|
||||
|
||||
class LanguageFilter(name: String) : Filter.TriState(name)
|
||||
class LanguageFilterGroup(languages: List<LanguageFilter>) :
|
||||
Filter.Group<LanguageFilter>("Language", languages)
|
||||
|
||||
class LibraryFilter(library: String) : Filter.TriState(library)
|
||||
class LibrariesFilterGroup(libraries: List<LibraryFilter>) :
|
||||
Filter.Group<LibraryFilter>("Libraries", libraries)
|
||||
|
||||
class PubStatusFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class PubStatusFilterGroup(status: List<PubStatusFilter>) :
|
||||
Filter.Group<PubStatusFilter>("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<WriterPeopleFilter>) :
|
||||
Filter.Group<WriterPeopleFilter>("Writer", peoples)
|
||||
|
||||
class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class PencillerPeopleFilterGroup(peoples: List<PencillerPeopleFilter>) :
|
||||
Filter.Group<PencillerPeopleFilter>("Penciller", peoples)
|
||||
|
||||
class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class InkerPeopleFilterGroup(peoples: List<InkerPeopleFilter>) :
|
||||
Filter.Group<InkerPeopleFilter>("Inker", peoples)
|
||||
|
||||
class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class ColoristPeopleFilterGroup(peoples: List<ColoristPeopleFilter>) :
|
||||
Filter.Group<ColoristPeopleFilter>("Colorist", peoples)
|
||||
|
||||
class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class LettererPeopleFilterGroup(peoples: List<LettererPeopleFilter>) :
|
||||
Filter.Group<LettererPeopleFilter>("Letterer", peoples)
|
||||
|
||||
class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class CoverArtistPeopleFilterGroup(peoples: List<CoverArtistPeopleFilter>) :
|
||||
Filter.Group<CoverArtistPeopleFilter>("Cover Artist", peoples)
|
||||
|
||||
class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class EditorPeopleFilterGroup(peoples: List<EditorPeopleFilter>) :
|
||||
Filter.Group<EditorPeopleFilter>("Editor", peoples)
|
||||
|
||||
class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class PublisherPeopleFilterGroup(peoples: List<PublisherPeopleFilter>) :
|
||||
Filter.Group<PublisherPeopleFilter>("Publisher", peoples)
|
||||
|
||||
class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class CharacterPeopleFilterGroup(peoples: List<CharacterPeopleFilter>) :
|
||||
Filter.Group<CharacterPeopleFilter>("Character", peoples)
|
||||
|
||||
class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||
class TranslatorPeopleFilterGroup(peoples: List<TranslatorPeopleFilter>) :
|
||||
Filter.Group<TranslatorPeopleFilter>("Translator", peoples)
|
|
@ -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"
|
||||
}
|
|
@ -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<Source> =
|
||||
listOf(
|
||||
Kavita("1"),
|
||||
Kavita("2"),
|
||||
Kavita("3"),
|
||||
)
|
||||
}
|
|
@ -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 <reified T : Enum<T>> 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<PaginationInfo>(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<SChapter> {
|
||||
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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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<FilterStatementDto> = 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<out Any>) {
|
||||
if (values.isNotEmpty()) {
|
||||
statements.add(FilterStatementDto(comparison.type, field.type, values.joinToString(",")))
|
||||
}
|
||||
}
|
||||
|
||||
fun addContainsNotTriple(list: List<Triple<FilterField, java.util.ArrayList<out Any>, ArrayList<Int>>>) {
|
||||
list.map {
|
||||
addStatement(FilterComparison.Contains, it.first, it.second)
|
||||
addStatement(FilterComparison.NotContains, it.first, it.third)
|
||||
}
|
||||
}
|
||||
fun addPeople(list: List<Pair<FilterField, ArrayList<Int>>>) {
|
||||
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),
|
||||
}
|
|
@ -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<Person> = emptyList(),
|
||||
val coverArtists: List<Person> = emptyList(),
|
||||
val genres: List<Genres> = 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<ChapterDto> = 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,
|
||||
)
|
|
@ -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<String> = arrayListOf<String>(),
|
||||
val readStatusList: List<String> = listOf("notRead", "inProgress", "read"),
|
||||
// _i = included, _e = excluded
|
||||
var genres_i: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var genres_e: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var tags_i: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var tags_e: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var ageRating_i: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var ageRating_e: ArrayList<Int> = arrayListOf<Int>(),
|
||||
|
||||
var formats: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var collections_i: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var collections_e: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var userRating: Int = 0,
|
||||
var people: ArrayList<Int> = arrayListOf<Int>(),
|
||||
// _i = included, _e = excluded
|
||||
var language_i: ArrayList<String> = arrayListOf<String>(),
|
||||
var language_e: ArrayList<String> = arrayListOf<String>(),
|
||||
|
||||
var libraries_i: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var libraries_e: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var pubStatus: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var seriesNameQuery: String = "",
|
||||
var releaseYearRangeMin: Int = 0,
|
||||
var releaseYearRangeMax: Int = 0,
|
||||
|
||||
var peopleWriters: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peoplePenciller: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peopleInker: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peoplePeoplecolorist: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peopleLetterer: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peopleCoverArtist: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peopleEditor: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peoplePublisher: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peopleCharacter: ArrayList<Int> = arrayListOf<Int>(),
|
||||
var peopleTranslator: ArrayList<Int> = arrayListOf<Int>(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SmartFilter(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val filter: String,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
|
@ -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
|
|
@ -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.
|
|
@ -0,0 +1,7 @@
|
|||
ext {
|
||||
extName = 'Komga'
|
||||
extClass = '.KomgaFactory'
|
||||
extVersionCode = 50
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 3.0 KiB |
After Width: | Height: | Size: 8.0 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 22 KiB |
|
@ -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<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
genreToInclude.add(content.name)
|
||||
}
|
||||
}
|
||||
if (genreToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("genre", genreToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is TagGroup -> {
|
||||
val tagToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
tagToInclude.add(content.name)
|
||||
}
|
||||
}
|
||||
if (tagToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("tag", tagToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is PublisherGroup -> {
|
||||
val publisherToInclude = mutableListOf<String>()
|
||||
filter.state.forEach { content ->
|
||||
if (content.state) {
|
||||
publisherToInclude.add(content.name)
|
||||
}
|
||||
}
|
||||
if (publisherToInclude.isNotEmpty()) {
|
||||
url.addQueryParameter("publisher", publisherToInclude.joinToString(","))
|
||||
}
|
||||
}
|
||||
is AuthorGroup -> {
|
||||
val authorToInclude = mutableListOf<AuthorDto>()
|
||||
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<SManga> {
|
||||
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<ReadListDto>(body.string())
|
||||
readList.toSManga()
|
||||
} else {
|
||||
val series = json.decodeFromString<SeriesDto>(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<SChapter> {
|
||||
val responseBody = response.body
|
||||
val page = responseBody.use { json.decodeFromString<PageWrapperDto<BookDto>>(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<Page> {
|
||||
val responseBody = response.body
|
||||
val pages = responseBody.use { json.decodeFromString<List<PageDto>>(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<PageWrapperDto<ReadListDto>>(body.string())) {
|
||||
MangasPage(content.map { it.toSManga() }, !last)
|
||||
}
|
||||
} else {
|
||||
with(json.decodeFromString<PageWrapperDto<SeriesDto>>(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<String>("Search for", arrayOf(TYPE_SERIES, TYPE_READLISTS))
|
||||
private class LibraryFilter(val id: String, name: String) : Filter.CheckBox(name, false)
|
||||
private class LibraryGroup(libraries: List<LibraryFilter>) : Filter.Group<LibraryFilter>("Libraries", libraries)
|
||||
private class CollectionSelect(collections: List<CollectionFilterEntry>) : Filter.Select<CollectionFilterEntry>("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<StatusFilter>) : Filter.Group<StatusFilter>("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<GenreFilter>) : Filter.Group<GenreFilter>("Genres", genres)
|
||||
private class TagFilter(tag: String) : Filter.CheckBox(tag, false)
|
||||
private class TagGroup(tags: List<TagFilter>) : Filter.Group<TagFilter>("Tags", tags)
|
||||
private class PublisherFilter(publisher: String) : Filter.CheckBox(publisher, false)
|
||||
private class PublisherGroup(publishers: List<PublisherFilter>) : Filter.Group<PublisherFilter>("Publishers", publishers)
|
||||
private class AuthorFilter(val author: AuthorDto) : Filter.CheckBox(author.name, false)
|
||||
private class AuthorGroup(role: String, authors: List<AuthorFilter>) : Filter.Group<AuthorFilter>(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<Filter<*>>(
|
||||
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<LibraryDto>()
|
||||
private var collections = emptyList<CollectionDto>()
|
||||
private var genres = emptySet<String>()
|
||||
private var tags = emptySet<String>()
|
||||
private var publishers = emptySet<String>()
|
||||
private var authors = emptyMap<String, List<AuthorDto>>() // 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<Application>().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<Button>(android.R.id.button1)
|
||||
?.isEnabled = editText.error == null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(this.key, newValue as String).commit()
|
||||
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addPreference(preference)
|
||||
}
|
||||
|
||||
private val SharedPreferences.displayName
|
||||
get() = getString(PREF_DISPLAYNAME, "")!!
|
||||
|
||||
private val SharedPreferences.baseUrl
|
||||
get() = getString(PREF_ADDRESS, ADDRESS_DEFAULT)!!.removeSuffix("/")
|
||||
|
||||
private val SharedPreferences.username
|
||||
get() = getString(PREF_USERNAME, USERNAME_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.password
|
||||
get() = getString(PREF_PASSWORD, PASSWORD_DEFAULT)!!
|
||||
|
||||
init {
|
||||
if (baseUrl.isNotBlank()) {
|
||||
Single.fromCallable {
|
||||
try {
|
||||
client.newCall(GET("$baseUrl/api/v1/libraries", headers)).execute().use { response ->
|
||||
libraries = try {
|
||||
val responseBody = response.body
|
||||
responseBody.use { json.decodeFromString(it.string()) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while decoding JSON for libraries filter", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while loading libraries for filters", e)
|
||||
}
|
||||
|
||||
try {
|
||||
client.newCall(GET("$baseUrl/api/v1/collections?unpaged=true", headers)).execute().use { response ->
|
||||
collections = try {
|
||||
val responseBody = response.body
|
||||
responseBody.use { json.decodeFromString<PageWrapperDto<CollectionDto>>(it.string()).content }
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while decoding JSON for collections filter", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while loading collections for filters", e)
|
||||
}
|
||||
|
||||
try {
|
||||
client.newCall(GET("$baseUrl/api/v1/genres", headers)).execute().use { response ->
|
||||
genres = try {
|
||||
val responseBody = response.body
|
||||
responseBody.use { json.decodeFromString(it.string()) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while decoding JSON for genres filter", e)
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while loading genres for filters", e)
|
||||
}
|
||||
|
||||
try {
|
||||
client.newCall(GET("$baseUrl/api/v1/tags", headers)).execute().use { response ->
|
||||
tags = try {
|
||||
response.body.use { json.decodeFromString(it.string()) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while decoding JSON for tags filter", e)
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while loading tags for filters", e)
|
||||
}
|
||||
|
||||
try {
|
||||
client.newCall(GET("$baseUrl/api/v1/publishers", headers)).execute().use { response ->
|
||||
publishers = try {
|
||||
response.body.use { json.decodeFromString(it.string()) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while decoding JSON for publishers filter", e)
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while loading publishers for filters", e)
|
||||
}
|
||||
|
||||
try {
|
||||
client.newCall(GET("$baseUrl/api/v1/authors", headers)).execute().use { response ->
|
||||
authors = try {
|
||||
response.body
|
||||
.use { json.decodeFromString<List<AuthorDto>>(it.string()) }
|
||||
.groupBy { it.role }
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while decoding JSON for authors filter", e)
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "error while loading authors for filters", e)
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{},
|
||||
{ tr ->
|
||||
Log.e(LOG_TAG, "error while doing initial calls", tr)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_DISPLAYNAME = "Source display name"
|
||||
private const val PREF_ADDRESS = "Address"
|
||||
private const val ADDRESS_DEFAULT = ""
|
||||
private const val PREF_USERNAME = "Username"
|
||||
private const val USERNAME_DEFAULT = ""
|
||||
private const val PREF_PASSWORD = "Password"
|
||||
private const val PASSWORD_DEFAULT = ""
|
||||
|
||||
private val supportedImageTypes = listOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/jxl", "image/heif", "image/avif")
|
||||
|
||||
private const val TYPE_SERIES = "Series"
|
||||
private const val TYPE_READLISTS = "Read lists"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package eu.kanade.tachiyomi.extension.all.komga
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class KomgaFactory : SourceFactory {
|
||||
|
||||
override fun createSources(): List<Source> =
|
||||
listOf(
|
||||
Komga(),
|
||||
Komga("2"),
|
||||
Komga("3"),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package eu.kanade.tachiyomi.extension.all.komga
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
object KomgaHelper {
|
||||
val formatterDate = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
val formatterDateTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
val formatterDateTimeMilli = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
package eu.kanade.tachiyomi.extension.all.komga.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LibraryDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeriesDto(
|
||||
val id: String,
|
||||
val libraryId: String,
|
||||
val name: String,
|
||||
val created: String?,
|
||||
val lastModified: String?,
|
||||
val fileLastModified: String,
|
||||
val booksCount: Int,
|
||||
val metadata: SeriesMetadataDto,
|
||||
val booksMetadata: BookMetadataAggregationDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SeriesMetadataDto(
|
||||
val status: String,
|
||||
val created: String?,
|
||||
val lastModified: String?,
|
||||
val title: String,
|
||||
val titleSort: String,
|
||||
val summary: String,
|
||||
val summaryLock: Boolean,
|
||||
val readingDirection: String,
|
||||
val readingDirectionLock: Boolean,
|
||||
val publisher: String,
|
||||
val publisherLock: Boolean,
|
||||
val ageRating: Int?,
|
||||
val ageRatingLock: Boolean,
|
||||
val language: String,
|
||||
val languageLock: Boolean,
|
||||
val genres: Set<String>,
|
||||
val genresLock: Boolean,
|
||||
val tags: Set<String>,
|
||||
val tagsLock: Boolean,
|
||||
val totalBookCount: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BookMetadataAggregationDto(
|
||||
val authors: List<AuthorDto> = emptyList(),
|
||||
val tags: Set<String> = emptySet(),
|
||||
val releaseDate: String?,
|
||||
val summary: String,
|
||||
val summaryNumber: String,
|
||||
|
||||
val created: String,
|
||||
val lastModified: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BookDto(
|
||||
val id: String,
|
||||
val seriesId: String,
|
||||
val seriesTitle: String,
|
||||
val name: String,
|
||||
val number: Float,
|
||||
val created: String?,
|
||||
val lastModified: String?,
|
||||
val fileLastModified: String,
|
||||
val sizeBytes: Long,
|
||||
val size: String,
|
||||
val media: MediaDto,
|
||||
val metadata: BookMetadataDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaDto(
|
||||
val status: String,
|
||||
val mediaType: String,
|
||||
val pagesCount: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PageDto(
|
||||
val number: Int,
|
||||
val fileName: String,
|
||||
val mediaType: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BookMetadataDto(
|
||||
val title: String,
|
||||
val titleLock: Boolean,
|
||||
val summary: String,
|
||||
val summaryLock: Boolean,
|
||||
val number: String,
|
||||
val numberLock: Boolean,
|
||||
val numberSort: Float,
|
||||
val numberSortLock: Boolean,
|
||||
val releaseDate: String?,
|
||||
val releaseDateLock: Boolean,
|
||||
val authors: List<AuthorDto>,
|
||||
val authorsLock: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthorDto(
|
||||
val name: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CollectionDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val ordered: Boolean,
|
||||
val seriesIds: List<String>,
|
||||
val createdDate: String,
|
||||
val lastModifiedDate: String,
|
||||
val filtered: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReadListDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val summary: String,
|
||||
val bookIds: List<String>,
|
||||
val createdDate: String,
|
||||
val lastModifiedDate: String,
|
||||
val filtered: Boolean,
|
||||
)
|
|
@ -0,0 +1,16 @@
|
|||
package eu.kanade.tachiyomi.extension.all.komga.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PageWrapperDto<T>(
|
||||
val content: List<T>,
|
||||
val empty: Boolean,
|
||||
val first: Boolean,
|
||||
val last: Boolean,
|
||||
val number: Long,
|
||||
val numberOfElements: Long,
|
||||
val size: Long,
|
||||
val totalElements: Long,
|
||||
val totalPages: Long,
|
||||
)
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
|
@ -0,0 +1,79 @@
|
|||
## 1.3.12
|
||||
Minimum LANraragi version required: 0.8.2
|
||||
|
||||
### Features
|
||||
|
||||
* Exclude from bulk update warnings
|
||||
|
||||
## 1.2.9
|
||||
Minimum LANraragi version required: 0.8.2
|
||||
|
||||
### Features
|
||||
|
||||
* Add `CHANGELOG.md` & `README.md`
|
||||
|
||||
## 1.2.8
|
||||
Minimum LANraragi version required: 0.8.2
|
||||
|
||||
### Fix
|
||||
|
||||
* Nullpo
|
||||
|
||||
### Refactor
|
||||
|
||||
* replace Gson with kotlinx.serialization
|
||||
|
||||
## 1.2.7
|
||||
Minimum LANraragi version required: 0.8.2
|
||||
|
||||
### Features
|
||||
|
||||
* Update Icon
|
||||
* Search-aware random
|
||||
* API Key warnig
|
||||
* Items marked as `Completed` instead of `Unknown`
|
||||
|
||||
### Fix
|
||||
|
||||
* Tag Separation
|
||||
|
||||
## 1.2.6
|
||||
|
||||
### Fix
|
||||
|
||||
* categories not appearing from large responses
|
||||
* Random item visibility
|
||||
* Ignore DNS over HTTPS
|
||||
|
||||
## 1.2.5
|
||||
|
||||
### Features
|
||||
|
||||
* Add Random entry
|
||||
* Clear new status on the server
|
||||
|
||||
## 1.2.4
|
||||
|
||||
### Features
|
||||
|
||||
* Safer tag parsing
|
||||
|
||||
## 1.2.3
|
||||
|
||||
### Features
|
||||
|
||||
* Filters and pagination manipulation
|
||||
* New preferences
|
||||
* API usage change to use its metadata endpoint and preserve WebView
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Features
|
||||
|
||||
* Update to API 0.7.2
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Features
|
||||
|
||||
* first version
|
|
@ -0,0 +1,35 @@
|
|||
# LANraragi
|
||||
|
||||
Table of Content
|
||||
- [FAQ](#FAQ)
|
||||
- [Why do I see no manga?](#why-do-i-see-no-manga)
|
||||
- [Where can I get more information about LANraragi?](#where-can-i-get-more-information-about-lanraragi)
|
||||
- [The LANraragi extension stopped working?](#the-lanraragi-extension-stopped-working)
|
||||
- [Can I add more than one LANraragi server or user?](#can-i-add-more-than-one-lanraragi-server-or-user)
|
||||
- [Can I test the LANraragi extension before setting up my own server?](#can-i-test-the-lanraragi-extension-before-setting-up-my-own-server)
|
||||
- [Guides](#Guides)
|
||||
- [How do I add my LANraragi server to Tachiyomi?](#how-do-i-add-my-lanraragi-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?
|
||||
LANraragi is a self-hosted comic/manga media server.
|
||||
|
||||
### Where can I get more information about LANraragi?
|
||||
You can visit the [LANraragi](https://github.com/Difegue/LANraragi) github page for for more information.
|
||||
|
||||
### The LANraragi extension stopped working?
|
||||
Make sure that your LANraragi server and extension are on the newest version.
|
||||
|
||||
### Can I add more than one LANraragi server or user?
|
||||
No, currently there is only support for 1 instances in Tachiyomi, if you need more instances please open a feature request on [tachiyomi-extensions](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose) repo.
|
||||
|
||||
### Can I test the LANraragi extension before setting up my own server?
|
||||
Yes, you can try it out with the DEMO server `https://lrr.tvc-16.science`.
|
||||
|
||||
## Guides
|
||||
|
||||
### How do I add my LANraragi server to Tachiyomi?
|
||||
Go into the settings of the LANraragi extension from the Extension tab in Browse and fill in your server address and API key if needed.
|
|
@ -0,0 +1,7 @@
|
|||
ext {
|
||||
extName = 'LANraragi'
|
||||
extClass = '.LANraragiFactory'
|
||||
extVersionCode = 15
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
After Width: | Height: | Size: 4.7 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 7.4 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 23 KiB |
|
@ -0,0 +1,31 @@
|
|||
package eu.kanade.tachiyomi.extension.all.lanraragi
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Archive(
|
||||
val arcid: String,
|
||||
val isnew: String,
|
||||
val tags: String?,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArchivePage(
|
||||
val pages: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArchiveSearchResult(
|
||||
val data: List<Archive>,
|
||||
val recordsFiltered: Int,
|
||||
val recordsTotal: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Category(
|
||||
val id: String,
|
||||
val last_used: String,
|
||||
val name: String,
|
||||
val pinned: String,
|
||||
)
|
|
@ -0,0 +1,472 @@
|
|||
package eu.kanade.tachiyomi.extension.all.lanraragi
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.text.InputType
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
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.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.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Dns
|
||||
import okhttp3.Headers
|
||||
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 java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.max
|
||||
|
||||
open class LANraragi(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() {
|
||||
override val baseUrl by lazy { getPrefBaseUrl() }
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val name by lazy { "LANraragi (${getPrefCustomLabel()})" }
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val apiKey by lazy { getPrefAPIKey() }
|
||||
|
||||
private val latestNamespacePref by lazy { getPrefLatestNS() }
|
||||
|
||||
private val json by lazy { Injekt.get<Json>() }
|
||||
|
||||
private var randomArchiveID: String = ""
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
val id = if (manga.url.startsWith("/api/search/random")) randomArchiveID else getReaderId(manga.url)
|
||||
val uri = getApiUriBuilder("/api/archives/$id/metadata").build()
|
||||
|
||||
if (manga.url.startsWith("/api/search/random")) {
|
||||
val randQuery = Uri.parse(manga.url).encodedQuery.toString()
|
||||
randomArchiveID = getRandomID(randQuery)
|
||||
}
|
||||
|
||||
return client.newCall(GET(uri.toString(), headers))
|
||||
.asObservableSuccess()
|
||||
.map { mangaDetailsParse(it).apply { initialized = true } }
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
// Catch-all that includes random's ID via thumbnail
|
||||
val id = getThumbnailId(manga.thumbnail_url!!)
|
||||
|
||||
return GET("$baseUrl/reader?id=$id", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val archive = json.decodeFromString<Archive>(response.body.string())
|
||||
|
||||
return archiveToSManga(archive)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val id = if (manga.url.startsWith("/api/search/random")) randomArchiveID else getReaderId(manga.url)
|
||||
val uri = getApiUriBuilder("/api/archives/$id/metadata").build()
|
||||
|
||||
return GET(uri.toString(), headers)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val archive = json.decodeFromString<Archive>(response.body.string())
|
||||
val uri = getApiUriBuilder("/api/archives/${archive.arcid}/files")
|
||||
val prefClearNew = preferences.getBoolean(NEW_ONLY_KEY, NEW_ONLY_DEFAULT)
|
||||
|
||||
if (archive.isnew == "true" && prefClearNew) {
|
||||
val clearNew = Request.Builder()
|
||||
.url("$baseUrl/api/archives/${archive.arcid}/isnew")
|
||||
.headers(headers)
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
client.newCall(clearNew).execute()
|
||||
}
|
||||
|
||||
return listOf(
|
||||
SChapter.create().apply {
|
||||
val uriBuild = uri.build()
|
||||
|
||||
url = uriBuild.toString()
|
||||
chapter_number = 1F
|
||||
name = "Chapter"
|
||||
|
||||
getDateAdded(archive.tags).toLongOrNull()?.let {
|
||||
date_upload = it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET(chapter.url, headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val archivePage = json.decodeFromString<ArchivePage>(response.body.string())
|
||||
|
||||
return archivePage.pages.mapIndexed { index, url ->
|
||||
val uri = Uri.parse("${baseUrl}${url.trimStart('.')}")
|
||||
Page(index, uri.toString(), uri.toString(), uri)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("imageUrlParse is unused")
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return searchMangaRequest(page, "", FilterList())
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
return searchMangaParse(response)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val filters = mutableListOf<Filter<*>>()
|
||||
val prefNewOnly = preferences.getBoolean(NEW_ONLY_KEY, NEW_ONLY_DEFAULT)
|
||||
|
||||
if (prefNewOnly) filters.add(NewArchivesOnly(true))
|
||||
|
||||
if (latestNamespacePref.isNotBlank()) {
|
||||
filters.add(SortByNamespace(latestNamespacePref))
|
||||
filters.add(DescendingOrder(true))
|
||||
}
|
||||
|
||||
return searchMangaRequest(page, "", FilterList(filters))
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
return searchMangaParse(response)
|
||||
}
|
||||
|
||||
private var lastResultCount: Int = 100
|
||||
private var lastRecordsFiltered: Int = 0
|
||||
private var maxResultCount: Int = 0
|
||||
private var totalRecords: Int = 0
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val uri = getApiUriBuilder("/api/search")
|
||||
var startPageOffset = 0
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is StartingPage -> {
|
||||
startPageOffset = filter.state.toIntOrNull() ?: 1
|
||||
|
||||
// Exception for API wrapping around and user input of 0
|
||||
if (startPageOffset > 0) {
|
||||
startPageOffset -= 1
|
||||
}
|
||||
}
|
||||
is NewArchivesOnly -> if (filter.state) uri.appendQueryParameter("newonly", "true")
|
||||
is UntaggedArchivesOnly -> if (filter.state) uri.appendQueryParameter("untaggedonly", "true")
|
||||
is DescendingOrder -> if (filter.state) uri.appendQueryParameter("order", "desc")
|
||||
is SortByNamespace -> if (filter.state.isNotEmpty()) uri.appendQueryParameter("sortby", filter.state.trim())
|
||||
is CategorySelect -> if (filter.state > 0) uri.appendQueryParameter("category", filter.toUriPart())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
uri.appendQueryParameter("start", ((page - 1 + startPageOffset) * maxResultCount).toString())
|
||||
|
||||
if (query.isNotEmpty()) {
|
||||
uri.appendQueryParameter("filter", query)
|
||||
}
|
||||
|
||||
return GET(uri.toString(), headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val jsonResult = json.decodeFromString<ArchiveSearchResult>(response.body.string())
|
||||
val currentStart = getStart(response)
|
||||
val archives = arrayListOf<SManga>()
|
||||
|
||||
lastResultCount = jsonResult.data.size
|
||||
maxResultCount = max(lastResultCount, maxResultCount)
|
||||
lastRecordsFiltered = jsonResult.recordsFiltered
|
||||
totalRecords = jsonResult.recordsTotal
|
||||
|
||||
if (lastResultCount > 1 && currentStart == 0) {
|
||||
val randQuery = response.request.url.encodedQuery.toString()
|
||||
randomArchiveID = getRandomID(randQuery)
|
||||
|
||||
archives.add(
|
||||
SManga.create().apply {
|
||||
url = "/api/search/random?count=1&$randQuery"
|
||||
title = "Random"
|
||||
description = "Refresh for a random archive."
|
||||
thumbnail_url = getThumbnailUri("tachiyomi") // noThumb
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
jsonResult.data.map {
|
||||
archives.add(archiveToSManga(it))
|
||||
}
|
||||
|
||||
return MangasPage(archives, currentStart + lastResultCount < lastRecordsFiltered)
|
||||
}
|
||||
|
||||
private fun archiveToSManga(archive: Archive) = SManga.create().apply {
|
||||
url = "/reader?id=${archive.arcid}"
|
||||
title = archive.title
|
||||
description = archive.title
|
||||
thumbnail_url = getThumbnailUri(archive.arcid)
|
||||
genre = archive.tags?.replace(",", ", ")
|
||||
artist = getArtist(archive.tags)
|
||||
author = artist
|
||||
status = SManga.COMPLETED
|
||||
}
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
if (apiKey.isNotEmpty()) {
|
||||
val apiKey64 = Base64.encodeToString(apiKey.toByteArray(), Base64.NO_WRAP)
|
||||
add("Authorization", "Bearer $apiKey64")
|
||||
}
|
||||
}
|
||||
|
||||
private class DescendingOrder(overrideState: Boolean = false) : Filter.CheckBox("Descending Order", overrideState)
|
||||
private class NewArchivesOnly(overrideState: Boolean = false) : Filter.CheckBox("New Archives Only", overrideState)
|
||||
private class UntaggedArchivesOnly : Filter.CheckBox("Untagged Archives Only", false)
|
||||
private class StartingPage(stats: String) : Filter.Text("Starting Page$stats", "")
|
||||
private class SortByNamespace(defaultText: String = "") : Filter.Text("Sort by (namespace)", defaultText)
|
||||
private class CategorySelect(categories: Array<Pair<String?, String>>) : UriPartFilter("Category", categories)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
CategorySelect(getCategoryPairs(categories)),
|
||||
Filter.Separator(),
|
||||
DescendingOrder(),
|
||||
NewArchivesOnly(),
|
||||
UntaggedArchivesOnly(),
|
||||
StartingPage(startingPageStats()),
|
||||
SortByNamespace(),
|
||||
)
|
||||
|
||||
private var categories = emptyList<Category>()
|
||||
|
||||
// Preferences
|
||||
override val id by lazy {
|
||||
// Retain previous ID for first entry
|
||||
val key = "lanraragi" + (if (suffix == "1") "" else "_$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<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
private fun getPrefBaseUrl(): String = preferences.getString(HOSTNAME_KEY, HOSTNAME_DEFAULT)!!
|
||||
private fun getPrefAPIKey(): String = preferences.getString(APIKEY_KEY, "")!!
|
||||
private fun getPrefLatestNS(): String = preferences.getString(SORT_BY_NS_KEY, SORT_BY_NS_DEFAULT)!!
|
||||
private fun getPrefCustomLabel(): String = preferences.getString(CUSTOM_LABEL_KEY, suffix)!!.ifBlank { suffix }
|
||||
|
||||
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
|
||||
screen.addPreference(screen.editTextPreference(HOSTNAME_KEY, "Hostname", HOSTNAME_DEFAULT, baseUrl, refreshSummary = true))
|
||||
screen.addPreference(screen.editTextPreference(APIKEY_KEY, "API Key", "", "Required if No-Fun Mode is enabled.", true))
|
||||
screen.addPreference(screen.editTextPreference(CUSTOM_LABEL_KEY, "Custom Label", "", "Show the given label for the source instead of the default."))
|
||||
screen.addPreference(screen.checkBoxPreference(CLEAR_NEW_KEY, "Clear New status", CLEAR_NEW_DEFAULT, "Clear an entry's New status when its details are viewed."))
|
||||
screen.addPreference(screen.checkBoxPreference(NEW_ONLY_KEY, "Latest - New Only", NEW_ONLY_DEFAULT))
|
||||
screen.addPreference(screen.editTextPreference(SORT_BY_NS_KEY, "Latest - Sort by Namespace", SORT_BY_NS_DEFAULT, "Sort by the given namespace for Latest, such as date_added."))
|
||||
}
|
||||
|
||||
private fun androidx.preference.PreferenceScreen.checkBoxPreference(key: String, title: String, default: Boolean, summary: String = ""): androidx.preference.CheckBoxPreference {
|
||||
return androidx.preference.CheckBoxPreference(context).apply {
|
||||
this.key = key
|
||||
this.title = title
|
||||
this.summary = summary
|
||||
setDefaultValue(default)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(this.key, newValue as Boolean).commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.preference.PreferenceScreen.editTextPreference(key: String, title: String, default: String, summary: String, isPassword: Boolean = false, refreshSummary: Boolean = false): androidx.preference.EditTextPreference {
|
||||
return androidx.preference.EditTextPreference(context).apply {
|
||||
this.key = key
|
||||
this.title = title
|
||||
this.summary = summary
|
||||
this.setDefaultValue(default)
|
||||
|
||||
if (isPassword) {
|
||||
setOnBindEditTextListener {
|
||||
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val newString = newValue.toString()
|
||||
val res = preferences.edit().putString(this.key, newString).commit()
|
||||
|
||||
if (refreshSummary) {
|
||||
this.apply {
|
||||
this.summary = newValue as String
|
||||
}
|
||||
}
|
||||
|
||||
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper
|
||||
private fun getRandomID(query: String): String {
|
||||
val searchRandom = client.newCall(GET("$baseUrl/api/search/random?count=1&$query", headers)).execute()
|
||||
val data = json.parseToJsonElement(searchRandom.body.string()).jsonObject["data"]
|
||||
val archive = data!!.jsonArray.firstOrNull()?.jsonObject
|
||||
|
||||
// 0.8.2~0.8.7 = id, 0.8.8+ = arcid
|
||||
return (archive?.get("arcid") ?: archive?.get("id"))?.jsonPrimitive?.content ?: ""
|
||||
}
|
||||
|
||||
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String?, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].first
|
||||
}
|
||||
|
||||
private fun getCategories() {
|
||||
Single.fromCallable {
|
||||
client.newCall(GET("$baseUrl/api/categories", headers)).execute()
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{
|
||||
categories = try {
|
||||
json.decodeFromString(it.body.string())
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
},
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCategoryPairs(categories: List<Category>): Array<Pair<String?, String>> {
|
||||
// Empty pair to disable. Sort by pinned status then name for convenience.
|
||||
// Web client sort is pinned > last_used but reflects between page changes.
|
||||
|
||||
val pin = "\uD83D\uDCCC "
|
||||
|
||||
// Maintain categories sync for next FilterList reset. If there's demand for it, it's now
|
||||
// possible to sort by last_used similar to the web client. Maybe an option toggle?
|
||||
getCategories()
|
||||
|
||||
return listOf(Pair("", ""))
|
||||
.plus(
|
||||
categories
|
||||
.sortedWith(compareByDescending<Category> { it.pinned }.thenBy { it.name })
|
||||
.map {
|
||||
val pinned = if (it.pinned == "1") pin else ""
|
||||
Pair(it.id, "$pinned${it.name}")
|
||||
},
|
||||
)
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
private fun startingPageStats(): String {
|
||||
return if (maxResultCount > 0 && totalRecords > 0) " ($maxResultCount / $lastRecordsFiltered items)" else ""
|
||||
}
|
||||
|
||||
private fun getApiUriBuilder(path: String): Uri.Builder {
|
||||
return Uri.parse("$baseUrl$path").buildUpon()
|
||||
}
|
||||
|
||||
private fun getThumbnailUri(id: String): String {
|
||||
val uri = getApiUriBuilder("/api/archives/$id/thumbnail")
|
||||
|
||||
return uri.toString()
|
||||
}
|
||||
|
||||
private tailrec fun getTopResponse(response: Response): Response {
|
||||
return if (response.priorResponse == null) response else getTopResponse(response.priorResponse!!)
|
||||
}
|
||||
|
||||
private fun getStart(response: Response): Int {
|
||||
return getTopResponse(response).request.url.queryParameter("start")!!.toInt()
|
||||
}
|
||||
|
||||
private fun getReaderId(url: String): String {
|
||||
return Regex("""/reader\?id=(\w{40})""").find(url)?.groupValues?.get(1) ?: ""
|
||||
}
|
||||
|
||||
private fun getThumbnailId(url: String): String {
|
||||
return Regex("""/(\w{40})/thumbnail""").find(url)?.groupValues?.get(1) ?: ""
|
||||
}
|
||||
|
||||
private fun getNSTag(tags: String?, tag: String): List<String>? {
|
||||
tags?.split(',')?.forEach {
|
||||
if (it.contains(':')) {
|
||||
val temp = it.trim().split(":", limit = 2)
|
||||
if (temp[0].equals(tag, true)) return temp
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getArtist(tags: String?): String = getNSTag(tags, "artist")?.get(1) ?: "N/A"
|
||||
|
||||
private fun getDateAdded(tags: String?): String {
|
||||
// Pad Date Added NS to milliseconds
|
||||
return getNSTag(tags, "date_added")?.get(1)?.padEnd(13, '0') ?: ""
|
||||
}
|
||||
|
||||
// Headers (currently auth) are done in headersBuilder
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.dns(Dns.SYSTEM)
|
||||
.addInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.code == 401) throw IOException("If the server is in No-Fun Mode make sure the extension's API Key is correct.")
|
||||
response
|
||||
}
|
||||
.build()
|
||||
|
||||
init {
|
||||
if (baseUrl.isNotBlank()) {
|
||||
// Save a FilterList reset
|
||||
getCategories()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HOSTNAME_DEFAULT = "http://127.0.0.1:3000"
|
||||
private const val HOSTNAME_KEY = "hostname"
|
||||
private const val APIKEY_KEY = "apiKey"
|
||||
private const val CUSTOM_LABEL_KEY = "customLabel"
|
||||
private const val NEW_ONLY_DEFAULT = true
|
||||
private const val NEW_ONLY_KEY = "latestNewOnly"
|
||||
private const val SORT_BY_NS_DEFAULT = "date_added"
|
||||
private const val SORT_BY_NS_KEY = "latestNamespacePref"
|
||||
private const val CLEAR_NEW_KEY = "clearNew"
|
||||
private const val CLEAR_NEW_DEFAULT = true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package eu.kanade.tachiyomi.extension.all.lanraragi
|
||||
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class LANraragiFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> =
|
||||
listOf(
|
||||
LANraragi("1"),
|
||||
LANraragi("2"),
|
||||
LANraragi("3"),
|
||||
)
|
||||
}
|