forked from aniyomiorg/aniyomi-extensions
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat(src/zh): Add new source Hanime1
- Loading branch information
Showing
8 changed files
with
338 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
ext { | ||
extName = 'Hanime1' | ||
extClass = '.Hanime1' | ||
extVersionCode = 1 | ||
isNsfw = true | ||
} | ||
|
||
apply from: "$rootDir/common.gradle" |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions
49
src/zh/hanime1/src/eu/kanade/tachiyomi/animeextension/zh/hanime1/Filters.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
package eu.kanade.tachiyomi.animeextension.zh.hanime1 | ||
|
||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter | ||
|
||
open class QueryFilter(name: String, val key: String, values: Array<String>) : | ||
AnimeFilter.Select<String>(name, values) { | ||
val selected: String | ||
get() = if (state == 0) { | ||
"" | ||
} else { | ||
values[state] | ||
} | ||
} | ||
|
||
open class TagFilter(val key: String, name: String, state: Boolean = false) : | ||
AnimeFilter.CheckBox(name, state) | ||
|
||
class GenreFilter(values: Array<String>) : | ||
QueryFilter( | ||
"影片類型", | ||
"genre", | ||
values.ifEmpty { arrayOf("全部", "裏番", "泡面番", "Motion Anime") }, | ||
) | ||
|
||
class SortFilter(values: Array<String>) : | ||
QueryFilter( | ||
"排序方式", | ||
"sort", | ||
values.ifEmpty { arrayOf("最新上市", "最新上傳", "本日排行", "本週排行", "本月排行") }, | ||
) | ||
|
||
object HotFilter : TagFilter("sort", "本周排行", true) | ||
|
||
class YearFilter(values: Array<String>) : | ||
QueryFilter("發佈年份", "year", values.ifEmpty { arrayOf("全部年份") }) | ||
|
||
class MonthFilter(values: Array<String>) : | ||
QueryFilter("發佈月份", "month", values.ifEmpty { arrayOf("全部月份") }) | ||
|
||
class DateFilter(yearFilter: YearFilter, monthFilter: MonthFilter) : | ||
AnimeFilter.Group<QueryFilter>("發佈日期", listOf(yearFilter, monthFilter)) | ||
|
||
class CategoryFilter(name: String, filters: List<TagFilter>) : | ||
AnimeFilter.Group<TagFilter>(name, filters) | ||
|
||
class BroadMatchFilter : TagFilter("broad", "廣泛配對") | ||
|
||
class TagsFilter(filters: List<AnimeFilter<out Any>>) : | ||
AnimeFilter.Group<AnimeFilter<out Any>>("標籤", filters) |
281 changes: 281 additions & 0 deletions
281
src/zh/hanime1/src/eu/kanade/tachiyomi/animeextension/zh/hanime1/Hanime1.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,281 @@ | ||
package eu.kanade.tachiyomi.animeextension.zh.hanime1 | ||
|
||
import android.app.Application | ||
import android.content.SharedPreferences | ||
import androidx.preference.ListPreference | ||
import androidx.preference.PreferenceScreen | ||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource | ||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter | ||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList | ||
import eu.kanade.tachiyomi.animesource.model.AnimesPage | ||
import eu.kanade.tachiyomi.animesource.model.SAnime | ||
import eu.kanade.tachiyomi.animesource.model.SEpisode | ||
import eu.kanade.tachiyomi.animesource.model.Video | ||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource | ||
import eu.kanade.tachiyomi.network.GET | ||
import eu.kanade.tachiyomi.network.awaitSuccess | ||
import eu.kanade.tachiyomi.util.asJsoup | ||
import kotlinx.coroutines.CoroutineExceptionHandler | ||
import kotlinx.coroutines.CoroutineScope | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.launch | ||
import kotlinx.serialization.encodeToString | ||
import kotlinx.serialization.json.Json | ||
import kotlinx.serialization.json.JsonElement | ||
import kotlinx.serialization.json.jsonObject | ||
import kotlinx.serialization.json.jsonPrimitive | ||
import okhttp3.HttpUrl.Companion.toHttpUrl | ||
import okhttp3.Interceptor | ||
import okhttp3.Request | ||
import okhttp3.Response | ||
import uy.kohesive.injekt.Injekt | ||
import uy.kohesive.injekt.api.get | ||
import uy.kohesive.injekt.injectLazy | ||
import java.text.SimpleDateFormat | ||
import java.util.Locale | ||
|
||
enum class FilterUpdateState { | ||
NONE, | ||
UPDATING, | ||
COMPLETED, | ||
FAILED, | ||
} | ||
|
||
class Hanime1 : AnimeHttpSource(), ConfigurableAnimeSource { | ||
override val baseUrl: String | ||
get() = "https://hanime1.me" | ||
override val lang: String | ||
get() = "zh" | ||
override val name: String | ||
get() = "Hanime1.me" | ||
override val supportsLatest: Boolean | ||
get() = true | ||
|
||
override val client = | ||
network.client.newBuilder().addInterceptor(::checkFiltersInterceptor).build() | ||
|
||
private val preferences: SharedPreferences by lazy { | ||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) | ||
} | ||
private val json by injectLazy<Json>() | ||
private var filterUpdateState = FilterUpdateState.NONE | ||
private val uploadDateFormat: SimpleDateFormat by lazy { | ||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) | ||
} | ||
|
||
override fun animeDetailsParse(response: Response): SAnime { | ||
val jsoup = response.asJsoup() | ||
return SAnime.create().apply { | ||
genre = jsoup.select(".single-video-tag").not("[data-toggle]").eachText().joinToString() | ||
author = jsoup.select("#video-artist-name").text() | ||
jsoup.select("script[type=application/ld+json]").first()?.data()?.let { | ||
val info = json.decodeFromString<JsonElement>(it).jsonObject | ||
title = info["name"]!!.jsonPrimitive.content | ||
description = info["description"]!!.jsonPrimitive.content | ||
} | ||
} | ||
} | ||
|
||
override fun episodeListParse(response: Response): List<SEpisode> { | ||
val jsoup = response.asJsoup() | ||
val nodes = jsoup.select("#playlist-scroll").first()!!.select(">div") | ||
return nodes.mapIndexed { index, element -> | ||
SEpisode.create().apply { | ||
val href = element.select("a.overlay").attr("href") | ||
setUrlWithoutDomain(href) | ||
episode_number = (nodes.size - index).toFloat() | ||
name = element.select("div.card-mobile-title").text() | ||
if (href == response.request.url.toString()) { | ||
// current video | ||
jsoup.select("script[type=application/ld+json]").first()?.data()?.let { | ||
val info = json.decodeFromString<JsonElement>(it).jsonObject | ||
info["uploadDate"]?.jsonPrimitive?.content?.let { date -> | ||
date_upload = | ||
runCatching { uploadDateFormat.parse(date)?.time }.getOrNull() ?: 0L | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
override fun videoListParse(response: Response): List<Video> { | ||
val sourceList = response.asJsoup().select("video source") | ||
val preferQuality = preferences.getString(PREF_KEY_VIDEO_QUALITY, DEFAULT_QUALITY) | ||
return sourceList.map { | ||
val quality = it.attr("size") | ||
val url = it.attr("src") | ||
Video(url, "${quality}P", videoUrl = url) | ||
}.sortedByDescending { preferQuality == it.quality } | ||
} | ||
|
||
override fun latestUpdatesParse(response: Response): AnimesPage = searchAnimeParse(response) | ||
|
||
override fun latestUpdatesRequest(page: Int) = searchAnimeRequest(page, "", AnimeFilterList()) | ||
|
||
override fun popularAnimeParse(response: Response): AnimesPage = searchAnimeParse(response) | ||
|
||
override fun popularAnimeRequest(page: Int) = | ||
searchAnimeRequest(page, "", AnimeFilterList(HotFilter)) | ||
|
||
private fun String.appendInvisibleChar(): String { | ||
// The search result title will be same as one episode name of anime. | ||
// Adding extra char makes them has different title | ||
return "${this}\u200B" | ||
} | ||
|
||
override fun searchAnimeParse(response: Response): AnimesPage { | ||
val jsoup = response.asJsoup() | ||
val nodes = jsoup.select("div.search-doujin-videos.hidden-xs") | ||
val list = if (nodes.isNotEmpty()) { | ||
nodes.map { | ||
SAnime.create().apply { | ||
setUrlWithoutDomain(it.select("a[class=overlay]").attr("href")) | ||
thumbnail_url = it.select("img + img").attr("src") | ||
title = it.select("div.card-mobile-title").text().appendInvisibleChar() | ||
author = it.select(".card-mobile-user").text() | ||
} | ||
} | ||
} else { | ||
jsoup.select(".search-videos").map { | ||
SAnime.create().apply { | ||
setUrlWithoutDomain(it.parent()!!.attr("href")) | ||
thumbnail_url = it.select("img").attr("src") | ||
title = it.select(".home-rows-videos-title").text().appendInvisibleChar() | ||
} | ||
} | ||
} | ||
val nextPage = jsoup.select("li.page-item a.page-link[rel=next]") | ||
return AnimesPage(list, nextPage.isNotEmpty()) | ||
} | ||
|
||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { | ||
val searchUrl = baseUrl.toHttpUrl().newBuilder().addPathSegment("search") | ||
if (query.isNotEmpty()) { | ||
searchUrl.addQueryParameter("query", query) | ||
} | ||
filters.list.forEach { | ||
when (it) { | ||
is QueryFilter -> { | ||
if (it.selected.isNotEmpty()) { | ||
searchUrl.addQueryParameter(it.key, it.selected) | ||
} | ||
} | ||
|
||
is TagFilter -> searchUrl.addQueryParameter(it.key, it.name) | ||
else -> {} | ||
} | ||
} | ||
if (page > 1) { | ||
searchUrl.addQueryParameter("page", "$page") | ||
} | ||
return GET(searchUrl.build()) | ||
} | ||
|
||
private fun checkFiltersInterceptor(chain: Interceptor.Chain): Response { | ||
if (filterUpdateState == FilterUpdateState.NONE) { | ||
updateFilters() | ||
} | ||
return chain.proceed(chain.request()) | ||
} | ||
|
||
private fun updateFilters() { | ||
filterUpdateState = FilterUpdateState.UPDATING | ||
val exceptionHandler = | ||
CoroutineExceptionHandler { _, _ -> filterUpdateState = FilterUpdateState.FAILED } | ||
CoroutineScope(Dispatchers.IO + exceptionHandler).launch { | ||
val jsoup = client.newCall(GET("$baseUrl/search")).awaitSuccess().asJsoup() | ||
val genreList = jsoup.select("div.genre-option div.hentai-sort-options").eachText() | ||
val sortList = | ||
jsoup.select("div.hentai-sort-options-wrapper div.hentai-sort-options").eachText() | ||
val yearList = jsoup.select("select#year option").eachAttr("value") | ||
.map { it.ifEmpty { "全部年份" } } | ||
val monthList = jsoup.select("select#month option").eachAttr("value") | ||
.map { it.ifEmpty { "全部月份" } } | ||
val categoryDict = mutableMapOf<String, MutableList<String>>() | ||
var currentKey = "" | ||
jsoup.select("div#tags div.modal-body").first()?.children()?.forEach { | ||
if (it.tagName() == "h5") { | ||
currentKey = it.text() | ||
} | ||
if (it.tagName() == "label") { | ||
if (currentKey in categoryDict) { | ||
categoryDict[currentKey] | ||
} else { | ||
categoryDict[currentKey] = mutableListOf() | ||
categoryDict[currentKey] | ||
}!!.add(it.select("input[name]").attr("value")) | ||
} | ||
} | ||
preferences.edit().putString(PREF_KEY_GENRE_LIST, genreList.joinToString()) | ||
.putString(PREF_KEY_SORT_LIST, sortList.joinToString()) | ||
.putString(PREF_KEY_YEAR_LIST, yearList.joinToString()) | ||
.putString(PREF_KEY_MONTH_LIST, monthList.joinToString()) | ||
.putString(PREF_KEY_CATEGORY_LIST, json.encodeToString(categoryDict)).apply() | ||
filterUpdateState = FilterUpdateState.COMPLETED | ||
} | ||
} | ||
|
||
private fun <T : QueryFilter> createFilter(prefKey: String, block: (Array<String>) -> T): T { | ||
val savedOptions = preferences.getString(prefKey, "") | ||
if (savedOptions.isNullOrEmpty()) { | ||
return block(emptyArray()) | ||
} | ||
return block(savedOptions.split(", ").toTypedArray()) | ||
} | ||
|
||
private fun createCategoryFilters(): List<AnimeFilter<out Any>> { | ||
val result = mutableListOf<AnimeFilter<out Any>>( | ||
BroadMatchFilter(), | ||
) | ||
val savedCategories = preferences.getString(PREF_KEY_CATEGORY_LIST, "") | ||
if (savedCategories.isNullOrEmpty()) { | ||
return result | ||
} | ||
json.decodeFromString<Map<String, List<String>>>(savedCategories).forEach { | ||
result.add(CategoryFilter(it.key, it.value.map { value -> TagFilter("tags[]", value) })) | ||
} | ||
return result | ||
} | ||
|
||
override fun getFilterList(): AnimeFilterList { | ||
return AnimeFilterList( | ||
createFilter(PREF_KEY_GENRE_LIST) { GenreFilter(it) }, | ||
createFilter(PREF_KEY_SORT_LIST) { SortFilter(it) }, | ||
DateFilter( | ||
createFilter(PREF_KEY_YEAR_LIST) { YearFilter(it) }, | ||
createFilter(PREF_KEY_MONTH_LIST) { MonthFilter(it) }, | ||
), | ||
TagsFilter(createCategoryFilters()), | ||
) | ||
} | ||
|
||
override fun setupPreferenceScreen(screen: PreferenceScreen) { | ||
screen.addPreference( | ||
ListPreference(screen.context).apply { | ||
key = PREF_KEY_VIDEO_QUALITY | ||
title = "設置首選畫質" | ||
entries = arrayOf("1080P", "720P", "480P") | ||
entryValues = entries | ||
setDefaultValue(DEFAULT_QUALITY) | ||
summary = "當前選擇:${preferences.getString(PREF_KEY_VIDEO_QUALITY, DEFAULT_QUALITY)}" | ||
setOnPreferenceChangeListener { _, newValue -> | ||
summary = "當前選擇:${newValue as String}" | ||
true | ||
} | ||
}, | ||
) | ||
} | ||
|
||
companion object { | ||
const val PREF_KEY_VIDEO_QUALITY = "PREF_KEY_VIDEO_QUALITY" | ||
|
||
const val PREF_KEY_GENRE_LIST = "PREF_KEY_GENRE_LIST" | ||
const val PREF_KEY_SORT_LIST = "PREF_KEY_SORT_LIST" | ||
const val PREF_KEY_YEAR_LIST = "PREF_KEY_YEAR_LIST" | ||
const val PREF_KEY_MONTH_LIST = "PREF_KEY_MONTH_LIST" | ||
const val PREF_KEY_CATEGORY_LIST = "PREF_KEY_CATEGORY_LIST" | ||
|
||
const val DEFAULT_QUALITY = "1080P" | ||
} | ||
} |