Skip to content

Commit

Permalink
feat(src/zh): Add New source Xfani (#114)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dark25 authored Oct 18, 2024
1 parent f4517ef commit 0eb277f
Show file tree
Hide file tree
Showing 10 changed files with 340 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/zh/xfani/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ext {
extName = 'Xfani'
extClass = '.Xfani'
extVersionCode = 1
}

apply from: "$rootDir/common.gradle"
Binary file added src/zh/xfani/res/mipmap-hdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/zh/xfani/res/mipmap-mdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/zh/xfani/res/mipmap-xhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/zh/xfani/res/mipmap-xxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/zh/xfani/res/mipmap-xxxhdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani

import eu.kanade.tachiyomi.animesource.model.AnimeFilter

abstract class SelectFilter(name: String, private val options: Array<Pair<String, String>>) :
AnimeFilter.Select<String>(name, options.map { it.first }.toTypedArray()) {
val selected
get() = options[state].second
}

abstract class TagFilter(name: String, values: Array<String>) :
SelectFilter(
name,
values.mapIndexed { index, s ->
if (index == 0) {
s to ""
} else {
s to s
}
}.toTypedArray(),
)

class TypeFilter(
kv: Array<Pair<String, String>> = arrayOf(
"连载新番" to "1",
"完结旧番" to "2",
"剧场版" to "3",
),
) : SelectFilter("频道", kv)

class ClassFilter(
tags: Array<String> = arrayOf(
"全部",
"搞笑",
"原创",
"轻小说改",
"恋爱",
"百合",
"漫改",
),
) : TagFilter("类型", tags)

class VersionFilter(
tags: Array<String> = arrayOf(
"全部",
"BD",
"OVA",
"SP",
"OAD",
),
) : TagFilter("版本", tags)

class LetterFilter(
tags: Array<String> = "ABCDEFGHIJKLMNOPQRSTUYWXYZ".map { it.toString() }.toMutableList()
.also { it.add("0-9") }.toTypedArray(),
) : TagFilter("字母", tags)

class SortFilter(
kv: Array<Pair<String, String>> = arrayOf(
"按最新" to "time",
"按热门" to "hits",
"按评分" to "score",
),
) : SelectFilter("排序", kv)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani

import java.security.MessageDigest

private const val UID = "DCC147D11943AF75"

internal fun generateKey(time: Long): String {
return "DS${time}$UID".md5()
}

internal fun String.md5(): String {
val md = MessageDigest.getInstance("MD5")
val digest = md.digest(this.toByteArray())
return digest.joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
}
212 changes: 212 additions & 0 deletions src/zh/xfani/src/eu/kanade/tachiyomi/animeextension/zh/xfani/Xfani.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani

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.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy

class Xfani : AnimeHttpSource(), ConfigurableAnimeSource {
override val baseUrl: String
get() = "https://dick.xfani.com"
override val lang: String
get() = "zh"
override val name: String
get() = "稀饭动漫"
override val supportsLatest: Boolean
get() = true

private val json by injectLazy<Json>()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val numberRegex = Regex("\\d+")

private val selectedVideoSource
get() = preferences.getString(PREF_KEY_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE)!!.toInt()

override fun animeDetailsParse(response: Response): SAnime = SAnime.create()

override fun episodeListParse(response: Response): List<SEpisode> {
val jsoup = response.asJsoup()
val result = jsoup.select("ul.anthology-list-play.size")
val episodeList = if (result.size > selectedVideoSource) {
result[selectedVideoSource]
} else {
result[0]
}.select("li > a")
return episodeList.map {
SEpisode.create().apply {
name = it.text()
url = it.attr("href")
episode_number = numberRegex.find(name)?.value?.toFloat() ?: -1F
}
}
}

override fun videoListParse(response: Response): List<Video> {
val script = response.asJsoup().select("script:containsData(player_aaaa)").first()!!.data()
val info = script.substringAfter("player_aaaa=").let { json.parseToJsonElement(it) }
val url = info.jsonObject["url"]!!.jsonPrimitive.content
return listOf(Video(url, "SingleFile", videoUrl = url))
}

override fun latestUpdatesParse(response: Response): AnimesPage {
return vodListToAnimePageList(response)
}

override fun latestUpdatesRequest(page: Int): Request =
searchAnimeRequest(page, "", AnimeFilterList())

override fun popularAnimeParse(response: Response): AnimesPage {
return vodListToAnimePageList(response)
}

override fun popularAnimeRequest(page: Int): Request =
searchAnimeRequest(page, "", AnimeFilterList(SortFilter().apply { state = 1 }))

private fun vodListToAnimePageList(response: Response): AnimesPage {
val vodResponse = json.decodeFromString<VodResponse>(response.body.string())
val animeList = vodResponse.list.map {
SAnime.create().apply {
url = "/bangumi/${it.vodId}.html"
thumbnail_url = it.vodPicThumb.ifEmpty { it.vodPic }
title = it.vodName
author = it.vodActor
description = it.vodBlurb
genre = it.vodClass
}
}
return AnimesPage(
animeList,
animeList.isNotEmpty() && vodResponse.page * vodResponse.limit < vodResponse.total,
)
}

override fun searchAnimeParse(response: Response): AnimesPage {
val jsoup = response.asJsoup()
val items = jsoup.select("div.public-list-box.search-box.flex.rel")
val animeList = items.map { item ->
SAnime.create().apply {
title = item.select(".thumb-txt").text()
url = item.select("div.left.public-list-bj a.public-list-exp").attr("href")
thumbnail_url =
item.select("div.left.public-list-bj img[data-src]").attr("data-src")
author = item.select("div.thumb-actor").text().removeSuffix("/")
artist = item.select("div.thumb-director").text().removeSuffix("/")
description = item.select(".thumb-blurb").text()
genre = item.select("div.thumb-else").text()
val statusString = item.select("div.left.public-list-bj .public-list-prb").text()
status = STATUS_STR_MAPPING.getOrElse(statusString) { SAnime.ONGOING }
}
}
val tip = jsoup.select("div.pages div.page-tip").text()
return AnimesPage(animeList, tip.isNotEmpty() && hasMorePage(tip))
}

private fun hasMorePage(tip: String): Boolean {
val pageIndicator = tip.substringAfter("当前").substringBefore("")
val numbers = pageIndicator.split("/")
return numbers.size == 2 && numbers[0] != numbers[1]
}

override fun getFilterList(): AnimeFilterList {
return AnimeFilterList(
AnimeFilter.Header("设置筛选后搜索关键字搜索会失效"),
TypeFilter(),
ClassFilter(),
VersionFilter(),
LetterFilter(),
SortFilter(),
)
}

private fun doSearch(page: Int, query: String): Request {
val url = baseUrl.toHttpUrl().newBuilder()
if (page <= 1) {
url.addPathSegment("search.html")
.addQueryParameter("wd", query)
} else {
url.addPathSegments("search/wd/")
.addPathSegment(query)
.addPathSegments("page/$page.html")
}
return GET(url.build())
}

override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
if (query.isNotBlank()) {
return doSearch(page, query)
}
val url = baseUrl.toHttpUrl().newBuilder()
.addPathSegments("index.php/api/vod")
.build()
val time = System.currentTimeMillis() / 1000
val formBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("page", "$page")
.addFormDataPart("time", "$time")
.addFormDataPart("key", generateKey(time))
filters.forEach { filter ->
when (filter) {
is TypeFilter -> formBody.addFormDataPart("type", filter.selected)
is ClassFilter -> formBody.addFormDataPart("class", filter.selected)
is VersionFilter -> formBody.addFormDataPart("version", filter.selected)
is LetterFilter -> formBody.addFormDataPart("letter", filter.selected)
is SortFilter -> formBody.addFormDataPart("by", filter.selected)
else -> {}
}
}
if (filters.filterIsInstance<TypeFilter>().isEmpty()) {
formBody.addFormDataPart("type", "1")
}
return POST(url.toString(), body = formBody.build())
}

override fun setupPreferenceScreen(screen: PreferenceScreen) {
screen.addPreference(
ListPreference(screen.context).apply {
key = PREF_KEY_VIDEO_SOURCE
title = "请设置首选视频源线路"
entries = arrayOf("主线-1", "主线-2", "备用-1")
entryValues = arrayOf("0", "1", "2")
setDefaultValue(DEFAULT_VIDEO_SOURCE)
summary = "当前选择:${entries[selectedVideoSource]}"
setOnPreferenceChangeListener { _, newValue ->
summary = "当前选择 ${entries[(newValue as String).toInt()]}"
true
}
},
)
}

companion object {
const val PREF_KEY_VIDEO_SOURCE = "PREF_KEY_VIDEO_SOURCE"

const val DEFAULT_VIDEO_SOURCE = "0"

val STATUS_STR_MAPPING = mapOf(
"已完结" to SAnime.COMPLETED,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.animeextension.zh.xfani

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class VodInfo(
@SerialName("vod_id")
val vodId: Int,
@SerialName("vod_level")
val vodLevel: Int = 0,
@SerialName("vod_name")
val vodName: String,
@SerialName("vod_pic")
val vodPic: String,
@SerialName("vod_pic_thumb")
val vodPicThumb: String = "",
@SerialName("vod_tag")
val vodTag: String = "",
@SerialName("vod_class")
val vodClass: String,
@SerialName("vod_remarks")
val vodRemarks: String,
@SerialName("vod_serial")
val vodSerial: String,
@SerialName("vod_sub")
val vodSub: String,
@SerialName("vod_actor")
val vodActor: String,
@SerialName("vod_blurb")
val vodBlurb: String,
)

@Serializable
data class VodResponse(
val page: Int,
@SerialName("pagecount")
val pageCount: Int,
val limit: Int,
val total: Int,
val list: List<VodInfo>,
)

0 comments on commit 0eb277f

Please sign in to comment.