diff --git a/src/all/mangahosted/AndroidManifest.xml b/src/all/mangahosted/AndroidManifest.xml new file mode 100644 index 000000000..89ee9a464 --- /dev/null +++ b/src/all/mangahosted/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/all/mangahosted/build.gradle b/src/all/mangahosted/build.gradle new file mode 100644 index 000000000..f2fb40324 --- /dev/null +++ b/src/all/mangahosted/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Manga Hosted' + extClass = '.MangaHostedFactory' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/mangahosted/res/mipmap-hdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..c67ab461b Binary files /dev/null and b/src/all/mangahosted/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/mangahosted/res/mipmap-mdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ae0fd67b4 Binary files /dev/null and b/src/all/mangahosted/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/mangahosted/res/mipmap-xhdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f1059244d Binary files /dev/null and b/src/all/mangahosted/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/mangahosted/res/mipmap-xxhdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..167181495 Binary files /dev/null and b/src/all/mangahosted/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/mangahosted/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/mangahosted/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..fe00773e6 Binary files /dev/null and b/src/all/mangahosted/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHosted.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHosted.kt new file mode 100644 index 000000000..3c6bd55f1 --- /dev/null +++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHosted.kt @@ -0,0 +1,202 @@ +package eu.kanade.tachiyomi.extension.all.mangahosted + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale + +class MangaHosted(private val langOption: LanguageOption) : HttpSource() { + + override val lang = langOption.lang + + override val name: String = "Manga Hosted${langOption.nameSuffix}" + + override val baseUrl: String = "https://mangahosted.org" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + override val client = network.client.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder(): Headers.Builder = super.headersBuilder() + .set("Referer", "$baseUrl/") + + // ================================= Popular ========================================== + + override fun popularMangaRequest(page: Int): Request { + val maxResult = 24 + return GET("$apiUrl/${langOption.infix}/HomeTopFllow/$maxResult/${page - 1}") + } + + override fun popularMangaParse(response: Response): MangasPage { + val dto = response.parseAs>() + val mangas = dto.data.map(::mangaParse) + return MangasPage( + mangas = mangas, + hasNextPage = dto.hasNextPage(), + ) + } + + // ================================= Latest =========================================== + + override fun latestUpdatesRequest(page: Int): Request { + val maxResult = 24 + val url = "$apiUrl/${langOption.infix}/HomeLastUpdate".toHttpUrl().newBuilder() + .addPathSegment("$maxResult") + .addPathSegment("${page - 1}") + .build() + return GET(url, headers) + } + + override fun latestUpdatesParse(response: Response) = popularMangaParse(response) + + // ================================= Search =========================================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val maxResult = 20 + val url = "$apiUrl/${langOption.infix}/SeachPage/$maxResult/${page - 1}".toHttpUrl().newBuilder() + .addPathSegment(query) + .build() + return GET(url, headers) + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (query.startsWith(SEARCH_PREFIX)) { + val url = "$baseUrl/${langOption.infix}/${query.substringAfter(SEARCH_PREFIX)}" + return client.newCall(GET(url, headers)) + .asObservableSuccess().map { response -> + val mangas = try { listOf(mangaDetailsParse(response)) } catch (_: Exception) { emptyList() } + MangasPage(mangas, false) + } + } + return super.fetchSearchManga(page, query, filters) + } + + override fun searchMangaParse(response: Response): MangasPage { + val dto = response.parseAs() + return MangasPage( + dto.mangas.map(::mangaParse), + false, + ) + } + + // ================================= Details ========================================== + + override fun mangaDetailsRequest(manga: SManga): Request { + val url = "$apiUrl/${langOption.infix}/getInfoManga".toHttpUrl().newBuilder() + .addPathSegment(manga.slug()) + .build() + return GET(url, headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val dto = response.parseAs() + return mangaParse(dto.details) + } + + override fun getMangaUrl(manga: SManga): String { + return baseUrl + manga.url.replace(langOption.infix, langOption.mangaSubstring) + } + + // ================================= Chapter ========================================== + + override fun fetchChapterList(manga: SManga): Observable> { + val chapters = mutableListOf() + var currentPage = 0 + do { + val chaptersDto = fetchChapterListPageable(manga, currentPage++) + chapters += chaptersDto.data.map { chapter -> + SChapter.create().apply { + name = chapter.name + date_upload = chapter.date.toDate() + url = chapter.toChapterUrl(langOption.infix) + } + } + } while (chaptersDto.hasNextPage()) + return Observable.just(chapters) + } + + private fun fetchChapterListPageable(manga: SManga, page: Int): Pageable { + val maxResult = 100 + val url = "$apiUrl/${langOption.infix}/GetChapterListFilter/${manga.slug()}/$maxResult/$page/all/${langOption.orderBy}" + return client.newCall(GET(url, headers)).execute() + .parseAs>() + } + + override fun chapterListParse(response: Response) = throw UnsupportedOperationException() + + // ================================= Pages ============================================ + + override fun pageListRequest(chapter: SChapter): Request { + val chapterSlug = chapter.url.substringAfter(langOption.infix) + val url = "$apiUrl/${langOption.infix}/GetImageChapter$chapterSlug" + return GET(url, headers) + } + + override fun imageRequest(page: Page): Request { + val imageHeaders = headers.newBuilder() + .set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + .removeAll("Referer") + .build() + return super.imageRequest(page).newBuilder() + .headers(imageHeaders) + .build() + } + + override fun pageListParse(response: Response): List { + val location = response.request.url.toString() + val dto = response.parseAs() + return dto.pages.mapIndexed { index, url -> + Page(index, location, imageUrl = url) + } + } + + override fun imageUrlParse(response: Response): String = "" + + // ================================= Utilities ======================================= + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } + + private fun SManga.slug() = this.url.split("/").last() + + private fun mangaParse(dto: MangaDto): SManga { + return SManga.create().apply { + title = dto.title + thumbnail_url = dto.thumbnailUrl + status = dto.status + url = "/${langOption.infix}/${dto.slug}" + genre = dto.genres + initialized = true + } + } + + private fun String.toDate(): Long = + try { dateFormat.parse(trim())!!.time } catch (_: Exception) { 0L } + + companion object { + const val SEARCH_PREFIX = "slug:" + val baseApiUrl = "https://api.novelfull.us" + val apiUrl = "$baseApiUrl/api" + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH) + } +} diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedDto.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedDto.kt new file mode 100644 index 000000000..4ef2d8c42 --- /dev/null +++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedDto.kt @@ -0,0 +1,68 @@ +package eu.kanade.tachiyomi.extension.all.mangahosted + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class MangaDetailsDto(private val data: Props) { + val details: MangaDto get() = data.details + + @Serializable + class Props( + @SerialName("infoDoc") val details: MangaDto, + ) +} + +@Serializable +open class Pageable( + var currentPage: Int, + var totalPage: Int, + val data: List, +) { + fun hasNextPage() = (currentPage + 1) <= totalPage +} + +@Serializable +class ChapterDto( + val date: String, + @SerialName("idDoc") val slugManga: String, + @SerialName("idDetail") val id: String, + @SerialName("nameChapter") val name: String, +) { + fun toChapterUrl(lang: String) = "/$lang/${this.slugManga}/$id" +} + +@Serializable +class MangaDto( + @SerialName("name") val title: String, + @SerialName("image") private val _thumbnailUrl: String, + @SerialName("idDoc") val slug: String, + @SerialName("genresName") val genres: String, + @SerialName("status") val _status: String, +) { + val thumbnailUrl get() = "${MangaHosted.baseApiUrl}$_thumbnailUrl" + + val status get() = when (_status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } +} + +@Serializable +class SearchDto( + @SerialName("data") + val mangas: List, +) + +@Serializable +class PageDto(val `data`: Data) { + val pages: List get() = `data`.detailDocuments.source.split("#") + + @Serializable + class Data(@SerialName("detail_documents") val detailDocuments: DetailDocuments) + + @Serializable + class DetailDocuments(val source: String) +} diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedFactory.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedFactory.kt new file mode 100644 index 000000000..5cd1d05b2 --- /dev/null +++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedFactory.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.extension.all.mangahosted + +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class MangaHostedFactory : SourceFactory { + override fun createSources(): List = languages.map { MangaHosted(it) } +} + +class LanguageOption( + val lang: String, + val infix: String = lang, + val mangaSubstring: String = infix, + val nameSuffix: String = "", + val orderBy: String = "DESC", +) + +val languages = listOf( + LanguageOption("en", "manga", "scan"), + LanguageOption("en", "manga-v2", "kaka", " v2"), + LanguageOption("en", "comic", "comic-dc", " Comics"), + LanguageOption("es", "manga-spanish", "manga-es"), + LanguageOption("id", "manga-indo", "id"), + LanguageOption("it", "manga-italia", "manga-it"), + LanguageOption("ja", "mangaraw", "raw"), + LanguageOption("pt-BR", "manga-br", orderBy = "ASC"), + LanguageOption("ru", "manga-ru", "mangaru"), + LanguageOption("ru", "manga-ru-hentai", "hentai", " +18"), + LanguageOption("ru", "manga-ru-yaoi", "yaoi", " +18 Yaoi"), +) diff --git a/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedUrlActivity.kt b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedUrlActivity.kt new file mode 100644 index 000000000..98c94933e --- /dev/null +++ b/src/all/mangahosted/src/eu/kanade/tachiyomi/extension/all/mangahosted/MangaHostedUrlActivity.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.extension.all.mangahosted + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class MangaHostedUrlActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + + if (pathSegments != null && pathSegments.size > 1) { + val intent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", slug(pathSegments)) + putExtra("filter", packageName) + } + + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e("UnionMangasUrlActivity", e.toString()) + } + } + + finish() + exitProcess(0) + } + + private fun slug(pathSegments: List) = + "${MangaHosted.SEARCH_PREFIX}${pathSegments[1]}" +}