Initial commit

This commit is contained in:
almightyhak 2024-06-20 11:54:12 +07:00
commit 98ed7e8839
2263 changed files with 108711 additions and 0 deletions

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.sudatchi.SudatchiUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="sudatchi.com"
android:pathPattern="/anime/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,11 @@
ext {
extName = 'Sudatchi'
extClass = '.Sudatchi'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:playlist-utils"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,292 @@
package eu.kanade.tachiyomi.animeextension.all.sudatchi
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.DirectoryDto
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.HomeListDto
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.LongAnimeDto
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.ShortAnimeDto
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.SubtitleDto
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.WatchDto
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
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.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parseAs
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 Sudatchi : AnimeHttpSource(), ConfigurableAnimeSource {
override val name = "Sudatchi"
override val baseUrl = "https://sudatchi.com"
override val lang = "all"
override val supportsLatest = true
private val codeRegex by lazy { Regex("""\((.*)\)""") }
private val json: Json by injectLazy()
private val sudatchiFilters: SudatchiFilters by lazy { SudatchiFilters(baseUrl, client) }
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/api/home-list", headers)
private fun Int.parseStatus() = when (this) {
1 -> SAnime.UNKNOWN // Not Yet Released
2 -> SAnime.ONGOING
3 -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
}
private fun ShortAnimeDto.toSAnime(titleLang: String) = SAnime.create().apply {
url = "/anime/$slug"
title = when (titleLang) {
"romaji" -> titleRomanji
"japanese" -> titleJapanese
else -> titleEnglish
} ?: arrayOf(titleEnglish, titleRomanji, titleJapanese, "").firstNotNullOf { it }
description = synopsis
status = statusId.parseStatus()
thumbnail_url = "$baseUrl$imgUrl"
genre = animeGenres?.joinToString { it.genre.name }
}
override fun popularAnimeParse(response: Response): AnimesPage {
sudatchiFilters.fetchFilters()
val titleLang = preferences.title
return AnimesPage(response.parseAs<HomeListDto>().animeSpotlight.map { it.toSAnime(titleLang) }, false)
}
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/directory?page=$page&genres=&status=2,3", headers)
override fun latestUpdatesParse(response: Response): AnimesPage {
sudatchiFilters.fetchFilters()
val titleLang = preferences.title
return response.parseAs<DirectoryDto>().let {
AnimesPage(it.animes.map { it.toSAnime(titleLang) }, it.page != it.pages)
}
}
// =============================== Search ===============================
override fun getFilterList() = sudatchiFilters.getFilterList()
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val id = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/api/anime/$id", headers))
.awaitSuccess()
.use(::searchAnimeByIdParse)
} else {
super.getSearchAnime(page, query, filters)
}
}
private fun searchAnimeByIdParse(response: Response) = AnimesPage(listOf(animeDetailsParse(response)), false)
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val url = "$baseUrl/api/directory".toHttpUrl().newBuilder()
url.addQueryParameter("page", page.toString())
url.addQueryParameter("title", query)
filters.filterIsInstance<SudatchiFilters.QueryParameterFilter>().forEach {
val (name, value) = it.toQueryParameter()
if (value != null) url.addQueryParameter(name, value)
}
return GET(url.build(), headers)
}
override fun searchAnimeParse(response: Response) = latestUpdatesParse(response)
// =========================== Anime Details ============================
override fun getAnimeUrl(anime: SAnime) = "$baseUrl${anime.url}"
override fun animeDetailsRequest(anime: SAnime) = GET("$baseUrl/api${anime.url}", headers)
override fun animeDetailsParse(response: Response) = response.parseAs<ShortAnimeDto>().toSAnime(preferences.title)
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime) = animeDetailsRequest(anime)
override fun episodeListParse(response: Response): List<SEpisode> {
val anime = response.parseAs<LongAnimeDto>()
return anime.episodes.map {
SEpisode.create().apply {
name = it.title
episode_number = it.number.toFloat()
url = "/watch/${anime.slug}/${it.number}"
}
}.reversed()
}
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode) = GET("$baseUrl${episode.url}", headers)
private val playlistUtils: PlaylistUtils by lazy { PlaylistUtils(client, headers) }
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val jsonString = document.selectFirst("script#__NEXT_DATA__")?.data() ?: return emptyList()
val data = json.decodeFromString<WatchDto>(jsonString).props.pageProps.episodeData
val subtitles = json.decodeFromString<List<SubtitleDto>>(data.subtitlesJson)
// val videoUrl = client.newCall(GET("$baseUrl/api/streams?episodeId=${data.episode.id}", headers)).execute().parseAs<StreamsDto>().url
// keeping it in case the simpler solution breaks, can be hardcoded to this for now :
val videoUrl = "$baseUrl/videos/m3u8/episode-${data.episode.id}.m3u8"
return playlistUtils.extractFromHls(
videoUrl,
videoNameGen = { "Sudatchi (Private IPFS Gateway) - $it" },
subtitleList = subtitles.map {
Track("$baseUrl${it.url}", "${it.subtitlesName.name} (${it.subtitlesName.language})")
}.sort(),
)
}
@JvmName("trackSort")
private fun List<Track>.sort(): List<Track> {
val subtitles = preferences.subtitles
return sortedWith(
compareBy(
{ codeRegex.find(it.lang)!!.groupValues[1] != subtitles },
{ codeRegex.find(it.lang)!!.groupValues[1] != PREF_SUBTITLES_DEFAULT },
{ it.lang },
),
)
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.quality
return sortedWith(
compareBy { it.quality.contains(quality) },
).reversed()
}
// ============================ Preferences =============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES.map { it.first }.toTypedArray()
entryValues = PREF_QUALITY_ENTRIES.map { it.second }.toTypedArray()
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, new ->
val index = findIndexOfValue(new as String)
preferences.edit().putString(key, entryValues[index] as String).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SUBTITLES_KEY
title = PREF_SUBTITLES_TITLE
entries = PREF_SUBTITLES_ENTRIES.map { it.first }.toTypedArray()
entryValues = PREF_SUBTITLES_ENTRIES.map { it.second }.toTypedArray()
setDefaultValue(PREF_SUBTITLES_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, new ->
val index = findIndexOfValue(new as String)
preferences.edit().putString(key, entryValues[index] as String).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_TITLE_KEY
title = PREF_TITLE_TITLE
entries = PREF_TITLE_ENTRIES.map { it.first }.toTypedArray()
entryValues = PREF_TITLE_ENTRIES.map { it.second }.toTypedArray()
setDefaultValue(PREF_TITLE_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, new ->
val index = findIndexOfValue(new as String)
preferences.edit().putString(key, entryValues[index] as String).commit()
}
}.also(screen::addPreference)
}
// ============================= Utilities ==============================
private val SharedPreferences.quality get() = getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
private val SharedPreferences.subtitles get() = getString(PREF_SUBTITLES_KEY, PREF_SUBTITLES_DEFAULT)!!
private val SharedPreferences.title get() = getString(PREF_TITLE_KEY, PREF_TITLE_DEFAULT)!!
companion object {
const val PREFIX_SEARCH = "id:"
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "1080"
private val PREF_QUALITY_ENTRIES = arrayOf(
Pair("1080p", "1080"),
Pair("720p", "720"),
Pair("480p", "480"),
)
private const val PREF_SUBTITLES_KEY = "preferred_subtitles"
private const val PREF_SUBTITLES_TITLE = "Preferred subtitles"
private const val PREF_SUBTITLES_DEFAULT = "eng"
private val PREF_SUBTITLES_ENTRIES = arrayOf(
Pair("Arabic (Saudi Arabia)", "ara"),
Pair("Brazilian Portuguese", "por"),
Pair("Chinese", "chi"),
Pair("Croatian", "hrv"),
Pair("Czech", "cze"),
Pair("Danish", "dan"),
Pair("Dutch", "dut"),
Pair("English", "eng"),
Pair("European Spanish", "spa-es"),
Pair("Filipino", "fil"),
Pair("Finnish", "fin"),
Pair("French", "fra"),
Pair("German", "deu"),
Pair("Greek", "gre"),
Pair("Hebrew", "heb"),
Pair("Hindi", "hin"),
Pair("Hungarian", "hun"),
Pair("Indonesian", "ind"),
Pair("Italian", "ita"),
Pair("Japanese", "jpn"),
Pair("Korean", "kor"),
Pair("Latin American Spanish", "spa-419"),
Pair("Malay", "may"),
Pair("Norwegian Bokmål", "nob"),
Pair("Polish", "pol"),
Pair("Romanian", "rum"),
Pair("Russian", "rus"),
Pair("Swedish", "swe"),
Pair("Thai", "tha"),
Pair("Turkish", "tur"),
Pair("Ukrainian", "ukr"),
Pair("Vietnamese", "vie"),
)
private const val PREF_TITLE_KEY = "preferred_title"
private const val PREF_TITLE_TITLE = "Preferred title"
private const val PREF_TITLE_DEFAULT = "english"
private val PREF_TITLE_ENTRIES = arrayOf(
Pair("English", "english"),
Pair("Romaji", "romaji"),
Pair("Japanese", "japanese"),
)
}
}

View file

@ -0,0 +1,72 @@
package eu.kanade.tachiyomi.animeextension.all.sudatchi
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.DirectoryFiltersDto
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.FilterItemDto
import eu.kanade.tachiyomi.animeextension.all.sudatchi.dto.FilterYearDto
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.parseAs
import okhttp3.OkHttpClient
class SudatchiFilters(
private val baseUrl: String,
private val client: OkHttpClient,
) {
private var error = false
private lateinit var filterList: AnimeFilterList
interface QueryParameterFilter { fun toQueryParameter(): Pair<String, String?> }
private class Checkbox(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
private class CheckboxList(name: String, private val paramName: String, private val pairs: List<Pair<String, String>>) :
AnimeFilter.Group<AnimeFilter.CheckBox>(name, pairs.map { Checkbox(it.first) }), QueryParameterFilter {
override fun toQueryParameter() = Pair(
paramName,
state.asSequence()
.filter { it.state }
.map { checkbox -> pairs.find { it.first == checkbox.name }!!.second }
.filter(String::isNotBlank)
.joinToString(","),
)
}
fun getFilterList(): AnimeFilterList {
return if (error) {
AnimeFilterList(AnimeFilter.Header("Error fetching the filters."))
} else if (this::filterList.isInitialized) {
filterList
} else {
AnimeFilterList(AnimeFilter.Header("Use 'Reset' to load the filters."))
}
}
fun fetchFilters() {
if (!this::filterList.isInitialized) {
runCatching {
error = false
filterList = client.newCall(GET("$baseUrl/api/directory"))
.execute()
.parseAs<DirectoryFiltersDto>()
.let(::filtersParse)
}.onFailure { error = true }
}
}
private fun List<FilterItemDto>.toPairList() = map { Pair(it.name, it.id.toString()) }
@JvmName("toPairList2")
private fun List<FilterYearDto>.toPairList() = map { Pair(it.year.toString(), it.year.toString()) }
private fun filtersParse(directoryFiltersDto: DirectoryFiltersDto): AnimeFilterList {
return AnimeFilterList(
CheckboxList("Genres", "genres", directoryFiltersDto.genres.toPairList()),
CheckboxList("Years", "years", directoryFiltersDto.years.toPairList()),
CheckboxList("Types", "types", directoryFiltersDto.types.toPairList()),
CheckboxList("Status", "status", directoryFiltersDto.status.toPairList()),
)
}
}

View file

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.animeextension.all.sudatchi
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://sudatchi.com/anime/<item> intents
* and redirects them to the main Aniyomi process.
*/
class SudatchiUrlActivity : Activity() {
private val tag = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val item = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${Sudatchi.PREFIX_SEARCH}$item")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(tag, e.toString())
}
} else {
Log.e(tag, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View file

@ -0,0 +1,111 @@
package eu.kanade.tachiyomi.animeextension.all.sudatchi.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Genre(val name: String)
@Serializable
data class AnimeGenreRelation(
@SerialName("Genre")
val genre: Genre,
)
@Serializable
data class ShortAnimeDto(
val titleRomanji: String?,
val titleEnglish: String?,
val titleJapanese: String?,
val synopsis: String,
val slug: String,
val statusId: Int,
val imgUrl: String,
@SerialName("AnimeGenres")
val animeGenres: List<AnimeGenreRelation>?,
)
@Serializable
data class HomeListDto(
@SerialName("AnimeSpotlight")
val animeSpotlight: List<ShortAnimeDto>,
)
@Serializable
data class DirectoryDto(
val animes: List<ShortAnimeDto>,
val page: Int,
val pages: Int,
)
@Serializable
data class Episode(
val title: String,
val id: Int,
val number: Int,
)
@Serializable
data class LongAnimeDto(
val slug: String,
@SerialName("Episodes")
val episodes: List<Episode>,
)
@Serializable
data class SubtitleLangDto(
val name: String,
val language: String,
)
@Serializable
data class SubtitleDto(
val url: String,
@SerialName("SubtitlesName")
val subtitlesName: SubtitleLangDto,
)
@Serializable
data class EpisodeDataDto(
val episode: Episode,
val subtitlesJson: String,
)
@Serializable
data class PagePropsDto(
val episodeData: EpisodeDataDto,
)
@Serializable
data class DataWatchDto(
val pageProps: PagePropsDto,
)
@Serializable
data class WatchDto(
val props: DataWatchDto,
)
@Serializable
data class FilterItemDto(
val id: Int,
val name: String,
)
@Serializable
data class FilterYearDto(
val year: Int,
)
@Serializable
data class DirectoryFiltersDto(
val genres: List<FilterItemDto>,
val years: List<FilterYearDto>,
val types: List<FilterItemDto>,
val status: List<FilterItemDto>,
)
@Serializable
data class StreamsDto(
val url: String,
)