Add GANMA! (#12206)
This commit is contained in:
parent
b34822ade3
commit
a9bd85d728
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="eu.kanade.tachiyomi.extension" />
|
|
@ -0,0 +1,12 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'GANMA!'
|
||||||
|
pkgNameSuffix = 'ja.ganma'
|
||||||
|
extClass = '.GanmaFactory'
|
||||||
|
extVersionCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.0 KiB |
|
@ -0,0 +1,126 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ja.ganma
|
||||||
|
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservable
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
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.json.decodeFromStream
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
|
||||||
|
open class Ganma : HttpSource(), ConfigurableSource {
|
||||||
|
override val id = sourceId
|
||||||
|
override val name = sourceName
|
||||||
|
override val lang = sourceLang
|
||||||
|
override val versionId = sourceVersionId
|
||||||
|
override val baseUrl = "https://ganma.jp"
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override fun headersBuilder() = super.headersBuilder().add("X-From", baseUrl)
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) =
|
||||||
|
when (page) {
|
||||||
|
1 -> GET("$baseUrl/api/1.0/ranking", headers)
|
||||||
|
else -> GET("$baseUrl/api/1.1/ranking?flag=Finish", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val list: List<Magazine> = response.parseAs()
|
||||||
|
return MangasPage(list.map { it.toSManga() }, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/2.2/top", headers)
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
val list = response.parseAs<Top>().boxes.flatMap { it.panels }
|
||||||
|
.filter { it.newestStoryItem != null }
|
||||||
|
.sortedByDescending { it.newestStoryItem!!.release }
|
||||||
|
return MangasPage(list.map { it.toSManga() }, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
|
val pageNumber = when (filters.size) {
|
||||||
|
0 -> 1
|
||||||
|
else -> (filters[0] as TypeFilter).state + 1
|
||||||
|
}
|
||||||
|
return fetchPopularManga(pageNumber).map { mangasPage ->
|
||||||
|
MangasPage(mangasPage.mangas.filter { it.title.contains(query) }, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
// navigate Webview to web page
|
||||||
|
override fun mangaDetailsRequest(manga: SManga) =
|
||||||
|
GET("$baseUrl/${manga.url.alias()}", headers)
|
||||||
|
|
||||||
|
protected open fun realMangaDetailsRequest(manga: SManga) =
|
||||||
|
GET("$baseUrl/api/1.0/magazines/web/${manga.url.alias()}", headers)
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga) = realMangaDetailsRequest(manga)
|
||||||
|
|
||||||
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
||||||
|
client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess()
|
||||||
|
.map { mangaDetailsParse(it).apply { initialized = true } }
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga =
|
||||||
|
response.parseAs<Magazine>().toSMangaDetails()
|
||||||
|
|
||||||
|
protected open fun List<SChapter>.sortedDescending() = this.asReversed()
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> =
|
||||||
|
response.parseAs<Magazine>().getSChapterList().sortedDescending()
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
||||||
|
client.newCall(pageListRequest(chapter)).asObservable()
|
||||||
|
.map { pageListParse(chapter, it) }
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter) =
|
||||||
|
GET("$baseUrl/api/1.0/magazines/web/${chapter.url.alias()}", headers)
|
||||||
|
|
||||||
|
protected open fun pageListParse(chapter: SChapter, response: Response): List<Page> {
|
||||||
|
val manga: Magazine = response.parseAs()
|
||||||
|
val chapterId = chapter.url.substringAfter('/')
|
||||||
|
return manga.items.find { it.id == chapterId }!!.toPageList()
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun pageListParse(response: Response): List<Page> =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String =
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
|
||||||
|
protected open class TypeFilter : Filter.Select<String>("Type", arrayOf("Popular", "Completed"))
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(TypeFilter())
|
||||||
|
|
||||||
|
protected inline fun <reified T> Response.parseAs(): T = use {
|
||||||
|
json.decodeFromStream<Result<T>>(it.body!!.byteStream()).root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
EditTextPreference(screen.context).apply {
|
||||||
|
key = METADATA_PREF
|
||||||
|
title = "Metadata (Debug)"
|
||||||
|
setDefaultValue("")
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
preferences.edit().putString(METADATA_PREF, newValue as String).apply()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}.let { screen.addPreference(it) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ja.ganma
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class GanmaApp(private val metadata: Metadata) : Ganma() {
|
||||||
|
|
||||||
|
override val client = network.client.newBuilder()
|
||||||
|
.cookieJar(Cookies(metadata.baseUrl.toHttpUrl().host, metadata.cookieName))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val appHeaders: Headers = Headers.Builder().apply {
|
||||||
|
add("User-Agent", metadata.userAgent)
|
||||||
|
add("X-From", metadata.baseUrl)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request {
|
||||||
|
checkSession()
|
||||||
|
return GET(metadata.baseUrl + String.format(metadata.magazineUrl, manga.url.mangaId()), appHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun List<SChapter>.sortedDescending() = this
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
checkSession()
|
||||||
|
val (mangaId, chapterId) = chapter.url.chapterDir()
|
||||||
|
return GET(metadata.baseUrl + String.format(metadata.storyUrl, mangaId, chapterId), appHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(chapter: SChapter, response: Response): List<Page> =
|
||||||
|
try {
|
||||||
|
response.parseAs<AppStory>().toPageList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Chapter not available!")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkSession() {
|
||||||
|
val expiration = preferences.getLong(SESSION_EXPIRATION_PREF, 0)
|
||||||
|
if (System.currentTimeMillis() + 60 * 1000 <= expiration) return // at least 1 minute
|
||||||
|
var field1 = preferences.getString(TOKEN_FIELD1_PREF, "")!!
|
||||||
|
var field2 = preferences.getString(TOKEN_FIELD2_PREF, "")!!
|
||||||
|
if (field1.isEmpty() || field2.isEmpty()) {
|
||||||
|
val response = client.newCall(POST(metadata.baseUrl + metadata.tokenUrl, appHeaders)).execute()
|
||||||
|
val token: JsonObject = response.parseAs()
|
||||||
|
field1 = token[metadata.tokenField1]!!.jsonPrimitive.content
|
||||||
|
field2 = token[metadata.tokenField2]!!.jsonPrimitive.content
|
||||||
|
}
|
||||||
|
val requestBody = FormBody.Builder().apply {
|
||||||
|
add(metadata.tokenField1, field1)
|
||||||
|
add(metadata.tokenField2, field2)
|
||||||
|
}.build()
|
||||||
|
val response = client.newCall(POST(metadata.baseUrl + metadata.sessionUrl, appHeaders, requestBody)).execute()
|
||||||
|
val session: Session = response.parseAs()
|
||||||
|
preferences.edit().apply {
|
||||||
|
putString(TOKEN_FIELD1_PREF, field1)
|
||||||
|
putString(TOKEN_FIELD2_PREF, field2)
|
||||||
|
putLong(SESSION_EXPIRATION_PREF, session.expire)
|
||||||
|
}.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearSession(clearToken: Boolean) {
|
||||||
|
preferences.edit().apply {
|
||||||
|
putString(SESSION_PREF, "")
|
||||||
|
putLong(SESSION_EXPIRATION_PREF, 0)
|
||||||
|
if (clearToken) {
|
||||||
|
putString(TOKEN_FIELD1_PREF, "")
|
||||||
|
putString(TOKEN_FIELD2_PREF, "")
|
||||||
|
}
|
||||||
|
}.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
super.setupPreferenceScreen(screen)
|
||||||
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
title = "Clear session"
|
||||||
|
setOnPreferenceClickListener {
|
||||||
|
clearSession(clearToken = false)
|
||||||
|
Toast.makeText(screen.context, "Session cleared", Toast.LENGTH_SHORT).show()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}.let { screen.addPreference(it) }
|
||||||
|
SwitchPreferenceCompat(screen.context).apply {
|
||||||
|
title = "Clear token"
|
||||||
|
setOnPreferenceClickListener {
|
||||||
|
clearSession(clearToken = true)
|
||||||
|
Toast.makeText(screen.context, "Token cleared", Toast.LENGTH_SHORT).show()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}.let { screen.addPreference(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Cookies(private val host: String, private val name: String) : CookieJar {
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||||
|
if (url.host != host) return emptyList()
|
||||||
|
val cookie = Cookie.Builder().apply {
|
||||||
|
name(name)
|
||||||
|
value(preferences.getString(SESSION_PREF, "")!!)
|
||||||
|
domain(host)
|
||||||
|
}.build()
|
||||||
|
return listOf(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||||
|
if (url.host != host) return
|
||||||
|
for (cookie in cookies) {
|
||||||
|
if (cookie.name == name) {
|
||||||
|
preferences.edit().putString(SESSION_PREF, cookie.value).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TOKEN_FIELD1_PREF = "TOKEN_FIELD1"
|
||||||
|
private const val TOKEN_FIELD2_PREF = "TOKEN_FIELD2"
|
||||||
|
private const val SESSION_PREF = "SESSION"
|
||||||
|
private const val SESSION_EXPIRATION_PREF = "SESSION_EXPIRATION"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,195 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ja.ganma
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Result<T>(val root: T)
|
||||||
|
|
||||||
|
// Manga
|
||||||
|
@Serializable
|
||||||
|
data class Magazine(
|
||||||
|
val id: String,
|
||||||
|
val alias: String? = null,
|
||||||
|
val title: String,
|
||||||
|
val description: String? = null,
|
||||||
|
val squareImage: File? = null,
|
||||||
|
// val squareWithLogoImage: File? = null,
|
||||||
|
val author: Author? = null,
|
||||||
|
val newestStoryItem: Story? = null,
|
||||||
|
val flags: Flags? = null,
|
||||||
|
val announcement: Announcement? = null,
|
||||||
|
val items: List<Story> = emptyList(),
|
||||||
|
) {
|
||||||
|
fun toSManga() = SManga.create().apply {
|
||||||
|
url = "${alias!!}#$id"
|
||||||
|
title = this@Magazine.title
|
||||||
|
thumbnail_url = squareImage!!.url
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toSMangaDetails() = toSManga().apply {
|
||||||
|
author = this@Magazine.author?.penName
|
||||||
|
val flagsText = flags?.toText()
|
||||||
|
description = generateDescription(flagsText)
|
||||||
|
status = when {
|
||||||
|
flags?.isFinish == true -> SManga.COMPLETED
|
||||||
|
!flagsText.isNullOrEmpty() -> SManga.ONGOING
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateDescription(flagsText: String?): String {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
if (!flagsText.isNullOrEmpty()) result.add("Updates: $flagsText")
|
||||||
|
if (announcement != null) result.add("Announcement: ${announcement.text}")
|
||||||
|
if (description != null) result.add(description)
|
||||||
|
return result.joinToString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSChapterList() = items.map {
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = "${alias!!}#$id/${it.id ?: it.storyId}"
|
||||||
|
val prefix = if (it.kind == "free") "" else "🔒 "
|
||||||
|
name = if (it.subtitle != null) "$prefix${it.title} ${it.subtitle}" else "$prefix${it.title}"
|
||||||
|
date_upload = it.releaseStart ?: -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.alias() = this.substringBefore('#')
|
||||||
|
fun String.mangaId() = this.substringAfter('#')
|
||||||
|
fun String.chapterDir(): Pair<String, String> =
|
||||||
|
with(this.substringAfter('#')) {
|
||||||
|
// this == [mangaId-UUID]/[chapterId-UUID]
|
||||||
|
Pair(substring(0, 36), substring(37, 37 + 36))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapter
|
||||||
|
@Serializable
|
||||||
|
data class Story(
|
||||||
|
val id: String? = null,
|
||||||
|
val storyId: String? = null,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String? = null,
|
||||||
|
val release: Long = 0,
|
||||||
|
val releaseStart: Long? = null,
|
||||||
|
val page: Directory? = null,
|
||||||
|
val afterwordImage: File? = null,
|
||||||
|
val kind: String? = null,
|
||||||
|
) {
|
||||||
|
fun toPageList(): List<Page> {
|
||||||
|
val result = page!!.toPageList()
|
||||||
|
if (afterwordImage != null) {
|
||||||
|
result.add(Page(result.size, imageUrl = afterwordImage.url))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class File(val url: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Author(val penName: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Top(val boxes: List<Box>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Box(val panels: List<Magazine>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Flags(
|
||||||
|
val isMonday: Boolean = false,
|
||||||
|
val isTuesday: Boolean = false,
|
||||||
|
val isWednesday: Boolean = false,
|
||||||
|
val isThursday: Boolean = false,
|
||||||
|
val isFriday: Boolean = false,
|
||||||
|
val isSaturday: Boolean = false,
|
||||||
|
val isSunday: Boolean = false,
|
||||||
|
|
||||||
|
val isWeekly: Boolean = false,
|
||||||
|
val isEveryOtherWeek: Boolean = false,
|
||||||
|
val isThreeConsecutiveWeeks: Boolean = false,
|
||||||
|
val isMonthly: Boolean = false,
|
||||||
|
|
||||||
|
val isFinish: Boolean = false,
|
||||||
|
// val isMGAward: Boolean = false,
|
||||||
|
// val isNew: Boolean = false,
|
||||||
|
) {
|
||||||
|
fun toText(): String {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
val days = mutableListOf<String>()
|
||||||
|
arrayOf(isWeekly, isEveryOtherWeek, isThreeConsecutiveWeeks, isMonthly)
|
||||||
|
.forEachIndexed { i, value -> if (value) result.add(weekText[i]) }
|
||||||
|
arrayOf(isMonday, isTuesday, isWednesday, isThursday, isFriday, isSaturday, isSunday)
|
||||||
|
.forEachIndexed { i, value -> if (value) days.add(dayText[i] + "s") }
|
||||||
|
if (days.size == 7) {
|
||||||
|
result.add("every day")
|
||||||
|
} else if (days.size != 0) {
|
||||||
|
days[0] = "on " + days[0]
|
||||||
|
result += days
|
||||||
|
}
|
||||||
|
return result.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val weekText = arrayOf("every week", "every other week", "three weeks in a row", "every month")
|
||||||
|
private val dayText = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Announcement(val text: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Directory(
|
||||||
|
val baseUrl: String,
|
||||||
|
val token: String,
|
||||||
|
val files: List<String>,
|
||||||
|
) {
|
||||||
|
fun toPageList(): MutableList<Page> =
|
||||||
|
files.mapIndexedTo(ArrayList(files.size + 1)) { i, file ->
|
||||||
|
Page(i, imageUrl = "$baseUrl$file?$token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppStory(val pages: List<AppPage>) {
|
||||||
|
fun toPageList(): List<Page> {
|
||||||
|
val result = ArrayList<Page>(pages.size)
|
||||||
|
pages.forEach {
|
||||||
|
if (it.imageURL != null)
|
||||||
|
result.add(Page(result.size, imageUrl = it.imageURL.url))
|
||||||
|
else if (it.afterwordImageURL != null)
|
||||||
|
result.add(Page(result.size, imageUrl = it.afterwordImageURL.url))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppPage(
|
||||||
|
val imageURL: File? = null,
|
||||||
|
val afterwordImageURL: File? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Please keep the data private to support the site,
|
||||||
|
// otherwise they might change their APIs.
|
||||||
|
@Serializable
|
||||||
|
data class Metadata(
|
||||||
|
val userAgent: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
val tokenUrl: String,
|
||||||
|
val tokenField1: String,
|
||||||
|
val tokenField2: String,
|
||||||
|
val sessionUrl: String,
|
||||||
|
val cookieName: String,
|
||||||
|
val magazineUrl: String,
|
||||||
|
val storyUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Session(val expire: Long)
|
|
@ -0,0 +1,48 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ja.ganma
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
// source ID needed before class construction
|
||||||
|
// generated by running main() below
|
||||||
|
const val sourceId = 8045942616403978870
|
||||||
|
const val sourceName = "GANMA!"
|
||||||
|
const val sourceLang = "ja"
|
||||||
|
const val sourceVersionId = 1 // != extension version code
|
||||||
|
const val METADATA_PREF = "METADATA"
|
||||||
|
|
||||||
|
val json: Json = Injekt.get()
|
||||||
|
val preferences: SharedPreferences =
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$sourceId", 0x0000)
|
||||||
|
|
||||||
|
class GanmaFactory : SourceFactory {
|
||||||
|
override fun createSources(): List<Source> {
|
||||||
|
val source = try {
|
||||||
|
val metadata = preferences.getString(METADATA_PREF, "")!!
|
||||||
|
.also { if (it.isEmpty()) throw Exception() }
|
||||||
|
.let { Base64.decode(it.toByteArray(), Base64.DEFAULT) }
|
||||||
|
GanmaApp(json.decodeFromString(String(metadata)))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Ganma()
|
||||||
|
}
|
||||||
|
return listOf(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
println(getSourceId()) // unfortunately there's no constexpr in Kotlin
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSourceId() = run { // copied from HttpSource
|
||||||
|
val key = "${sourceName.lowercase()}/$sourceLang/$sourceVersionId"
|
||||||
|
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
|
||||||
|
}
|
Loading…
Reference in New Issue