Add MangaOwl.To (#1307)

* Add MangaOwl (mangaowl.to)

* Factory class to separate Comics & Mangas along with their respective genres

* Using API to request for manga’s detail

* Using API to request chapters list

* parse JSON for pages

* Add mirrors

* Rename source to MangaOwl.To

* using DTO

* migrate to full API

* update icon

* cleanup

* Fix: allow reset GenresFilter checkbox

* separate Genre & GenreCheckBox

* Update query builder

Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>

* apiUrl -> baseUrl

* unused fields in dtos

* extra newline

---------

Co-authored-by: stevenyomi <95685115+stevenyomi@users.noreply.github.com>
Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
Cuong M. Tran 2024-02-29 21:46:34 +07:00 committed by Draff
parent 24087b9688
commit c81adc7829
10 changed files with 426 additions and 0 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -0,0 +1,168 @@
package eu.kanade.tachiyomi.extension.en.mangaowlto
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
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.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.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class MangaOwlTo(
private val collection: String,
extraName: String,
private val genresList: List<Genre>,
) : ConfigurableSource, HttpSource() {
override val name: String = "MangaOwl.To $extraName"
override val lang = "en"
override val supportsLatest = true
private val preferences: SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
private val defaultDomain: String =
preferences.getString(MIRROR_PREF_KEY, MIRROR_PREF_DEFAULT_VALUE)!!
override val baseUrl = "https://$defaultDomain"
private val apiUrl = "https://api.$defaultDomain/v1"
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val mirrorPref = ListPreference(screen.context).apply {
key = MIRROR_PREF_KEY
title = "Mirror (Requires Restart)"
entries = MIRROR_PREF_ENTRIES
entryValues = MIRROR_PREF_ENTRY_VALUES
setDefaultValue(MIRROR_PREF_DEFAULT_VALUE)
summary = "%s"
}
screen.addPreference(mirrorPref)
}
private val json: Json by injectLazy()
override fun popularMangaRequest(page: Int) =
GET("$apiUrl/stories?type=$collection&ordering=-view_count&page=$page".toHttpUrl(), headers)
override fun popularMangaParse(response: Response) =
json.decodeFromString<MangaOwlToStories>(response.body.string()).toMangasPage()
// Latest
override fun latestUpdatesRequest(page: Int) =
GET("$apiUrl/stories?type=$collection&ordering=-modified_at&page=$page".toHttpUrl(), headers)
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return if (query.isNotEmpty() || filters.isEmpty()) {
// Search won't work together with filter
val url = "$apiUrl/search".toHttpUrl().newBuilder()
.addQueryParameter("q", query)
.addQueryParameter("page", page.toString())
.build()
GET(url, headers)
} else {
val url = "$apiUrl/stories?type=$collection".toHttpUrl().newBuilder()
filters.forEach { filter ->
when (filter) {
is SortFilter -> if (!filter.toUriPart().isNullOrEmpty()) {
url.addQueryParameter("ordering", filter.toUriPart())
}
is StatusFilter -> if (!filter.toUriPart().isNullOrEmpty()) {
url.addQueryParameter("status", filter.toUriPart())
}
is GenresFilter ->
filter.state
.filter { it.state }
.forEach { url.addQueryParameter("genres", it.uriPart) }
else -> {}
}
}
url.addQueryParameter("page", page.toString())
GET(url.build(), headers)
}
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
// Manga summary page
override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$apiUrl/stories/${manga.url}", headers)
}
override fun mangaDetailsParse(response: Response) =
json.decodeFromString<MangaOwlToStory>(response.body.string()).toSManga()
override fun getMangaUrl(manga: SManga): String {
return "$baseUrl/10-comic/${manga.url}"
}
// Chapters
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response) =
json.decodeFromString<MangaOwlToStory>(response.body.string()).chaptersList
override fun getChapterUrl(chapter: SChapter): String {
return "$baseUrl${chapter.url}"
}
// Pages
override fun pageListRequest(chapter: SChapter): Request {
val id = chapter.url.substringAfterLast("/")
return GET("$apiUrl/chapters/$id/images?page_size=1000", headers)
}
override fun pageListParse(response: Response) =
json.decodeFromString<MangaOwlToChapterPages>(response.body.string()).toPages()
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
// Filters
override fun getFilterList() = FilterList(
Filter.Header("Search query won't use filters"),
GenresFilter(genresList),
StatusFilter(),
SortFilter(),
)
companion object {
private const val MIRROR_PREF_KEY = "MIRROR"
private val MIRROR_PREF_ENTRIES get() = arrayOf(
"mangaowl.to",
"mangabuddy.to",
"mangafreak.to",
"toonily.to",
"manganato.so",
"mangakakalot.so", // Redirected from mangago.to
)
private val MIRROR_PREF_ENTRY_VALUES get() = arrayOf(
"mangaowl.to",
"mangabuddy.to",
"mangafreak.to",
"toonily.to",
"manganato.so",
"mangago.to", // API for domain mangakakalot.so
)
private val MIRROR_PREF_DEFAULT_VALUE get() = MIRROR_PREF_ENTRY_VALUES[0]
const val ONGOING = "ongoing"
const val COMPLETED = "completed"
}
}

View File

@ -0,0 +1,113 @@
package eu.kanade.tachiyomi.extension.en.mangaowlto
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 kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
class MangaOwlToStories(
private val next: String?,
private val results: List<MangaOwlToStory>,
) {
fun toMangasPage() = MangasPage(
mangas = results.map { it.toSManga() },
hasNextPage = !next.isNullOrEmpty(),
)
}
@Serializable
class MangaOwlToStory(
private val name: String,
private val slug: String,
@SerialName("status") private val titleStatus: String?, // ongoing & completed
@SerialName("thumbnail") private val thumbnailUrl: String,
@SerialName("al_name") private val altName: String?,
private val rating: Float?,
@SerialName("view_count") private val views: Int,
private val description: String?,
private val genres: List<MangaOwlToGenre> = emptyList(),
private val authors: List<MangaOwlToAuthor> = emptyList(),
private val chapters: List<MangaOwlToChapter> = emptyList(),
) {
private val fullDescription: String
get() = buildString {
append(description)
altName?.let { append("\n\n $it") }
append("\n\nRating: $rating")
append("\nViews: $views")
}
val chaptersList: List<SChapter>
get() = chapters.reversed().map { it.toSChapter(slug) }
fun toSManga(): SManga = SManga.create().apply {
title = name
author = authors.joinToString { it.name }
description = fullDescription.trim()
genre = genres.joinToString { it.name }
status = when (titleStatus) {
MangaOwlTo.ONGOING -> SManga.ONGOING
MangaOwlTo.COMPLETED -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
thumbnail_url = thumbnailUrl
url = slug
}
}
@Serializable
class MangaOwlToGenre(
val name: String,
)
@Serializable
class MangaOwlToAuthor(
val name: String,
)
@Serializable
class MangaOwlToChapter(
private val id: Int,
@SerialName("name") private val title: String,
@SerialName("created_at") private val createdAt: String,
) {
fun toSChapter(slug: String): SChapter = SChapter.create().apply {
name = title
date_upload = parseDate()
url = "/reading/$slug/$id"
}
companion object {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.US)
}
private fun parseDate(): Long = try {
dateFormat.parse(createdAt)!!.time
} catch (_: ParseException) {
0L
}
}
@Serializable
class MangaOwlToChapterPages(
@SerialName("results") private val pages: List<MangaOwlToPage> = emptyList(),
) {
fun toPages() =
pages.mapIndexed { idx, page ->
Page(
index = idx,
imageUrl = page.imageUrl,
)
}
}
@Serializable
class MangaOwlToPage(
@SerialName("image") val imageUrl: String,
)

View File

@ -0,0 +1,103 @@
package eu.kanade.tachiyomi.extension.en.mangaowlto
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class MangaOwlToFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
MangaOwlTo(
collection = "manga",
extraName = "Manga",
genresList = listOf(
Genre("Action", "13"),
Genre("Adult", "29"),
Genre("Adventure", "31"),
Genre("Comedy", "14"),
Genre("Cooking", "37"),
Genre("Doujinshi", "39"),
Genre("Drama", "17"),
Genre("Ecchi", "15"),
Genre("Fantasy", "16"),
Genre("Gender bender", "35"),
Genre("Harem", "27"),
Genre("Historical", "18"),
Genre("Horror", "28"),
Genre("Isekai", "40"),
Genre("Josei", "23"),
Genre("Manhua", "38"),
Genre("Manhwa", "36"),
Genre("Martial arts", "30"),
Genre("Mature", "20"),
Genre("Mecha", "33"),
Genre("Medical", "41"),
Genre("Mystery", "7"),
Genre("One shot", "1"),
Genre("Psychological", "8"),
Genre("Romance", "10"),
Genre("School life", "3"),
Genre("Sci fi", "19"),
Genre("Seinen", "25"),
Genre("Shoujo ai", "5"),
Genre("Shoujo", "12"),
Genre("Shounen ai", "4"),
Genre("Shounen", "9"),
Genre("Slice of life", "26"),
Genre("Smut", "32"),
Genre("Sports", "21"),
Genre("Supernatural", "24"),
Genre("Tragedy", "22"),
Genre("Webtoons", "34"),
Genre("Yaoi", "2"),
Genre("Yuri", "6"),
),
),
MangaOwlTo(
collection = "comic",
extraName = "Comic",
genresList = listOf(
Genre("215 Ink", "189"),
Genre("Ablaze", "98"),
Genre("Action Lab", "204"),
Genre("Aftershock Comics", "68"),
Genre("American Mythology", "130"),
Genre("Antartic Press", "261"),
Genre("Archie", "178"),
Genre("Aspen", "487"),
Genre("Avatar Press", "177"),
Genre("Black Mask", "107"),
Genre("Boom Studios", "65"),
Genre("Comics Experience", "159"),
Genre("Dark Horse", "92"),
Genre("DC Comics", "133"),
Genre("Devils Due", "290"),
Genre("Dynamite", "173"),
Genre("Europe Comics", "67"),
Genre("Heavy Metal", "55"),
Genre("Humanoids", "85"),
Genre("IDW", "110"),
Genre("Image Comics", "60"),
Genre("Inverse", "384"),
Genre("Lion Forge", "162"),
Genre("Mad Cave", "96"),
Genre("MAD", "485"),
Genre("Magnetic Press", "114"),
Genre("Marvel Comics", "45"),
Genre("One Shots &amp; TPBs", "136"),
Genre("Oni Press", "338"),
Genre("Rebellion", "50"),
Genre("Red 5", "88"),
Genre("SAF Comics", "378"),
Genre("Soleil", "156"),
Genre("Source Point Press", "57"),
Genre("Space Goat Productions", "421"),
Genre("Top Cow", "138"),
Genre("Top Shelf", "101"),
Genre("Upshot", "396"),
Genre("Valiant", "87"),
Genre("Vault", "360"),
Genre("Vertigo", "457"),
Genre("Zenescope", "119"),
),
),
)
}

View File

@ -0,0 +1,35 @@
package eu.kanade.tachiyomi.extension.en.mangaowlto
import eu.kanade.tachiyomi.source.model.Filter
class Genre(val name: String, val uriPart: String)
class GenreCheckBox(name: String, val uriPart: String) : Filter.CheckBox(name)
class GenresFilter(genres: List<Genre>) :
Filter.Group<GenreCheckBox>("Genres", genres.map { GenreCheckBox(it.name, it.uriPart) })
class SortFilter : UriPartFilter(
"Sort by",
arrayOf(
Pair("Default", null),
Pair("Most view", "-view_count"),
Pair("Added", "created_at"),
Pair("Last update", "-modified_at"),
Pair("High rating", "rating"),
),
)
class StatusFilter : UriPartFilter(
"Status",
arrayOf(
Pair("Any", null),
Pair("Completed", MangaOwlTo.COMPLETED),
Pair("Ongoing", MangaOwlTo.ONGOING),
),
)
open class UriPartFilter(displayName: String, private val pairs: Array<Pair<String, String?>>) :
Filter.Select<String>(displayName, pairs.map { it.first }.toTypedArray()) {
fun toUriPart() = pairs[state].second
}