re add kogma balls, lanraragi and kavita
CI / Prepare job (push) Successful in 3s Details
CI / Build multisrc modules (push) Failing after 8s Details
CI / Build individual modules (push) Failing after 7s Details
CI / Publish repo (push) Has been skipped Details

This commit is contained in:
Draff 2024-01-22 23:39:22 +00:00
parent 5c8cb75e9a
commit 9c512ea3ac
49 changed files with 4087 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -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

37
src/all/kavita/README.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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.

View File

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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"
}

View File

@ -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"),
)
}

View File

@ -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)
}
},
)
}

View File

@ -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"
}

View File

@ -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),
}

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

383
src/all/komga/CHANGELOG.md Normal file
View File

@ -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

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

@ -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.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -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"
}
}

View File

@ -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"),
)
}

View File

@ -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") }
}

View File

@ -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,
)

View File

@ -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,
)

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -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

View File

@ -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.

View File

@ -0,0 +1,7 @@
ext {
extName = 'LANraragi'
extClass = '.LANraragiFactory'
extVersionCode = 15
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -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,
)

View File

@ -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
}
}

View File

@ -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"),
)
}