Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

Commit 00aacef

Browse files
authored
[EN] Av1encodes Fix (#1343)
* Add files via upload * Update extVersionCode to 4 in build.gradle * Update build.gradle * Add files via upload
1 parent a1b15e2 commit 00aacef

6 files changed

Lines changed: 619 additions & 316 deletions

File tree

src/en/av1encodes/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
ext {
22
extName = 'AV1Encodes'
33
extClass = '.Av1Encodes'
4-
extVersionCode = 1
4+
extVersionCode = 2
55
isNsfw = false
66
}
77

8-
apply from: "$rootDir/common.gradle"
8+
apply from: "$rootDir/common.gradle"

src/en/av1encodes/src/eu/kanade/tachiyomi/animeextension/en/av1encodes/Av1Encodes.kt

Lines changed: 248 additions & 314 deletions
Large diffs are not rendered by default.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package eu.kanade.tachiyomi.animeextension.en.av1encodes
2+
3+
import org.jsoup.Jsoup
4+
import org.jsoup.nodes.Element
5+
6+
internal fun extractCleanTitle(raw: String): String {
7+
var cleaned = raw.replace(Regex("""\s*·\s*\d+\s*downloads?.*""", RegexOption.IGNORE_CASE), "")
8+
cleaned = cleaned.replace(Regex("""^\[[a-zA-Z0-9_\-]+]\s*"""), "")
9+
cleaned = cleaned.replace(Regex("""\s*\[\d{3,4}p].*""", RegexOption.IGNORE_CASE), "")
10+
cleaned = cleaned.replace(Regex("""\.(mkv|mp4)$""", RegexOption.IGNORE_CASE), "")
11+
return cleaned.trim()
12+
}
13+
14+
internal fun getListImageUrl(anchor: Element, baseUrl: String): String? {
15+
val img = anchor.selectFirst("img")
16+
if (img != null) {
17+
val url = img.attr("abs:data-src").ifBlank { img.attr("abs:data-lazy-src") }
18+
.ifBlank { img.attr("abs:src") }
19+
if (url.isNotBlank()) return url
20+
}
21+
22+
var bg = extractBg(anchor, baseUrl)
23+
if (bg != null) return bg
24+
25+
for (child in anchor.allElements) {
26+
bg = extractBg(child, baseUrl)
27+
if (bg != null) return bg
28+
}
29+
return null
30+
}
31+
32+
internal fun extractBg(el: Element, baseUrl: String): String? {
33+
val style = el.attr("style")
34+
if (style.contains("background", ignoreCase = true)) {
35+
val match = Regex("""url\(['"]?(.*?)['"]?\)""").find(style)
36+
if (match != null && match.groupValues[1].isNotBlank()) {
37+
val url = match.groupValues[1]
38+
return if (url.startsWith("http")) url else "$baseUrl/${url.removePrefix("/")}"
39+
}
40+
}
41+
return null
42+
}
43+
44+
/**
45+
* Extracts the X-DDL-Token from the episode page's inline JavaScript.
46+
*
47+
* The token always appears as one of:
48+
* 'X-DDL-Token': "VALUE" (fetch header object literal)
49+
* ddl_token = "VALUE" (variable assignment)
50+
*
51+
* Broader fallback patterns (data-token, bare "token" key, any *Token variable)
52+
* were removed because pattern 5 in particular matched the ?token= query param
53+
* found in episode download hrefs on the same page, silently returning the
54+
* wrong value and causing /get_ddl/ calls to fail.
55+
*/
56+
internal fun extractDdlToken(html: String): String? {
57+
val patterns = listOf(
58+
// 'X-DDL-Token': "VALUE" or "X-DDL-Token": "VALUE"
59+
Regex("""['"]X-DDL-Token['"]\s*:\s*['"]([A-Za-z0-9+/=_\-]+)['"]"""),
60+
// ddl_token = "VALUE" / ddltoken = "VALUE" / ddl-token: "VALUE"
61+
Regex("""ddl[_\-]?token['"\s]*[=:]\s*['"]([A-Za-z0-9+/=_\-]+)['"]""", RegexOption.IGNORE_CASE),
62+
)
63+
for (pattern in patterns) {
64+
val match = pattern.find(html)
65+
if (match != null) return match.groupValues[1]
66+
}
67+
return null
68+
}
69+
70+
/**
71+
* Parses the /episodes/{slug}/{season}/{quality} HTML response and returns
72+
* a list of [EpisodeItem]s.
73+
*
74+
* Target structure:
75+
* ```html
76+
* <div class="episode-item">
77+
* <a href="/episode/attack-on-titan-s01e01-1080p">
78+
* <span class="episode-label">Episode 1</span>
79+
* <span class="audio-badge">Dual Audio</span>
80+
* </a>
81+
* </div>
82+
* ```
83+
*
84+
* Falls back to any <a> whose href contains "/episode/" if the structured
85+
* class is absent.
86+
*/
87+
internal fun parseEpisodeItems(html: String): List<EpisodeItem> {
88+
val doc = Jsoup.parse(html)
89+
val items = mutableListOf<EpisodeItem>()
90+
91+
// Primary path: strict .episode-item divs
92+
val containers = doc.select("div.episode-item, [class~=episode-item]")
93+
if (containers.isNotEmpty()) {
94+
for (div in containers) {
95+
val a = div.selectFirst("a[href]") ?: continue
96+
val href = a.attr("href").takeIf { it.isNotBlank() } ?: continue
97+
val label = div.selectFirst("span.episode-label, [class~=episode-label]")?.text()?.trim()
98+
?: a.text().trim()
99+
val audio = div.selectFirst("span.audio-badge, [class~=audio-badge]")?.text()?.trim() ?: ""
100+
val num = Regex("""\d+""").find(label)?.value?.toIntOrNull() ?: items.size + 1
101+
items.add(EpisodeItem(num = num, label = label, audio = audio, href = href))
102+
}
103+
return items
104+
}
105+
106+
// Fallback: any anchor pointing to an episode detail page
107+
for (a in doc.select("a[href*='/episode/']")) {
108+
val href = a.attr("href").takeIf { it.isNotBlank() } ?: continue
109+
val label = a.selectFirst("[class~=episode-label]")?.text()?.trim()
110+
?: a.text().trim()
111+
val audio = a.selectFirst("[class~=audio-badge]")?.text()?.trim() ?: ""
112+
val num = Regex("""\d+""").find(label)?.value?.toIntOrNull() ?: items.size + 1
113+
items.add(EpisodeItem(num = num, label = label, audio = audio, href = href))
114+
}
115+
return items
116+
}
117+
118+
/**
119+
* Extracts the decoded .mkv filename from a /download/…/filename.mkv?token= URL.
120+
* Used when episode.url is already a direct download link (Path A in getVideoList).
121+
*
122+
* Example input:
123+
* /download/demon-slayer/movie/1920%20x%201080/%5BS01%5D%20Demon%20Slayer%20%5B1080p%5D.mkv?token=…
124+
* Returns:
125+
* [S01] Demon Slayer [1080p].mkv
126+
*/
127+
internal fun mkvFilenameFromDownloadUrl(downloadUrl: String): String? {
128+
val path = downloadUrl.substringBefore("?").trimEnd('/')
129+
val segment = path.substringAfterLast("/")
130+
if (!segment.endsWith(".mkv", ignoreCase = true)) return null
131+
return try {
132+
java.net.URLDecoder.decode(segment, "UTF-8")
133+
} catch (_: Exception) {
134+
segment
135+
}
136+
}
137+
138+
/**
139+
* Scans the episode page HTML for any anchor whose href contains a /download/ path
140+
* with a .mkv file segment, then returns the URL-decoded .mkv filename.
141+
*
142+
* The site embeds links like:
143+
* /download/{slug}/{season}/{resolution}/[S01-E04] Title [1080p] [Sub].mkv?token=…
144+
*
145+
* We extract the last path segment (before the query string) and decode it,
146+
* giving us exactly the filename that /get_ddl/ expects — regardless of resolution.
147+
* If multiple resolutions are present (1080p, 720p, …) we return the first one found;
148+
* the API response contains all available links anyway.
149+
*/
150+
internal fun extractMkvFilenameFromHtml(html: String): String? {
151+
val doc = Jsoup.parse(html)
152+
// Match any anchor whose href has /download/ and ends with .mkv (before query)
153+
val downloadHref = doc.select("a[href*='/download/']")
154+
.map { it.attr("href") }
155+
.firstOrNull { href ->
156+
val path = href.substringBefore("?")
157+
path.endsWith(".mkv", ignoreCase = true)
158+
}
159+
?: return null
160+
161+
val path = downloadHref.substringBefore("?").trimEnd('/')
162+
val segment = path.substringAfterLast("/")
163+
return try {
164+
java.net.URLDecoder.decode(segment, "UTF-8")
165+
} catch (_: Exception) {
166+
segment
167+
}
168+
}
169+
170+
/**
171+
* Extracts the filename segment that the /get_ddl/ API expects from an
172+
* episode detail page URL.
173+
*
174+
* Algorithm:
175+
* 1. Unescape \? → ? and \& → &
176+
* 2. Drop query string
177+
* 3. Take the last non-empty path segment
178+
* 4. Drop everything from '\' onward (Windows path safety)
179+
* 5. URL-decode the result
180+
*/
181+
internal fun filenameFromPageUrl(pageUrl: String): String {
182+
val unescaped = pageUrl.replace("\\?", "?").replace("\\&", "&")
183+
val path = unescaped.substringBefore("?")
184+
val segment = path.trimEnd('/').substringAfterLast("/")
185+
val noWin = segment.substringBefore("\\")
186+
return try {
187+
java.net.URLDecoder.decode(noWin, "UTF-8")
188+
} catch (_: Exception) {
189+
noWin
190+
}
191+
}
192+
193+
internal fun buildEpisodeLabel(item: EpisodeItem, season: String): String {
194+
val base = if (item.label.isNotBlank()) item.label else "Episode ${item.num}"
195+
val audioTag = item.audio.ifBlank { null }
196+
val seasonPrefix = if (season != "1" && season.isNotBlank()) "Season $season " else ""
197+
return "$seasonPrefix$base${if (audioTag != null) " [$audioTag]" else ""}"
198+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package eu.kanade.tachiyomi.animeextension.en.av1encodes
2+
3+
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
4+
5+
internal val SORT_VALUES = arrayOf("", "a-z", "z-a", "episodes")
6+
internal val TYPE_VALUES = arrayOf("", "sub", "dual")
7+
8+
internal class SortFilter : AnimeFilter.Select<String>(
9+
"Sort By",
10+
arrayOf("Latest Added", "AZ", "ZA", "Episode Count"),
11+
)
12+
13+
internal class TypeFilter : AnimeFilter.Select<String>(
14+
"Audio Type (overrides Sort)",
15+
arrayOf("All", "Sub only (Airing)", "Dual audio (Airing)"),
16+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package eu.kanade.tachiyomi.animeextension.en.av1encodes
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
// CHANGED: Added watch_link (confirmed from live API response).
7+
@Serializable
8+
internal data class DdlResponse(
9+
@SerialName("success") val success: Boolean = false,
10+
@SerialName("stream_link") val streamLink: String? = null,
11+
@SerialName("download_link") val downloadLink: String? = null,
12+
@SerialName("torrent_link") val torrentLink: String? = null,
13+
@SerialName("watch_link") val watchLink: String? = null,
14+
@SerialName("file_name") val fileName: String? = null,
15+
@SerialName("file_size") val fileSize: String? = null,
16+
@SerialName("subtitles") val subtitles: List<SubtitleInfo>? = null,
17+
@SerialName("audio_details") val audioDetails: AudioDetailsWrapper? = null,
18+
@SerialName("video_details") val videoDetails: List<VideoDetailInfo>? = null,
19+
)
20+
21+
@Serializable
22+
internal data class SubtitleInfo(
23+
@SerialName("format") val format: String? = null,
24+
@SerialName("language") val language: String? = null,
25+
)
26+
27+
@Serializable
28+
internal data class AudioDetailsWrapper(
29+
@SerialName("audio") val audio: List<AudioTrackInfo>? = null,
30+
)
31+
32+
@Serializable
33+
internal data class AudioTrackInfo(
34+
@SerialName("language") val language: String? = null,
35+
@SerialName("format") val format: String? = null,
36+
@SerialName("bit_rate") val bitRate: String? = null,
37+
)
38+
39+
// ADDED: New model for the video_details array returned by the updated API.
40+
// NOTE: width/height come as strings like "1 920 pixels" / "1 080 pixels" from the API,
41+
// so we store them as String and parse digits-only in Av1Encodes.kt.
42+
@Serializable
43+
internal data class VideoDetailInfo(
44+
@SerialName("width") val width: String? = null,
45+
@SerialName("height") val height: String? = null,
46+
@SerialName("frame_rate") val frameRate: String? = null,
47+
@SerialName("format") val format: String? = null,
48+
@SerialName("bit_depth") val bitDepth: String? = null,
49+
@SerialName("duration") val duration: String? = null,
50+
)
51+
52+
// ADDED: Model for a single item in the /episodes/ JSON array response.
53+
// The API now returns a JSON array where each item includes the episode href
54+
// (with the ?token= query param already embedded) and optionally the full DDL
55+
// block, so we can skip the /get_ddl/ round-trip when the data is already present.
56+
@Serializable
57+
internal data class EpisodeItem(
58+
@SerialName("num") val num: Int = 0,
59+
@SerialName("label") val label: String = "",
60+
@SerialName("audio") val audio: String = "",
61+
// Full relative path, e.g. "/download/slug/1/1920%20x%201080/file.mkv?token=..."
62+
@SerialName("href") val href: String = "",
63+
// May already contain the resolved DDL links – use as a fast path in getVideoList.
64+
@SerialName("ddl") val ddl: DdlResponse? = null,
65+
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package eu.kanade.tachiyomi.animeextension.en.av1encodes
2+
3+
import android.content.SharedPreferences
4+
import androidx.preference.ListPreference
5+
import androidx.preference.PreferenceScreen
6+
import androidx.preference.SwitchPreferenceCompat
7+
import eu.kanade.tachiyomi.animesource.model.Video
8+
9+
internal const val PREF_DOMAIN_KEY = "preferred_domain"
10+
internal const val PREF_DOMAIN_DEFAULT = "https://av1encodes.com"
11+
internal val DOMAIN_ENTRIES = arrayOf("av1encodes.com (default)", "av1please.com (mirror)")
12+
internal val DOMAIN_VALUES = arrayOf("https://av1encodes.com", "https://av1please.com")
13+
14+
internal const val PREF_QUALITY_KEY = "preferred_quality"
15+
internal const val PREF_QUALITY_DEFAULT = "1920 x 1080"
16+
internal val QUALITY_ENTRIES = arrayOf("1080p", "720p", "480p", "360p")
17+
internal val QUALITY_VALUES = arrayOf("1920 x 1080", "1280 x 720", "854 x 480", "640 x 360")
18+
19+
internal const val PREF_LINK_TYPE_KEY = "preferred_link_type"
20+
internal const val PREF_LINK_TYPE_DEFAULT = "Stream"
21+
internal val LINK_TYPE_ENTRIES = arrayOf("Watch", "Stream", "Download", "Torrent")
22+
23+
internal const val PREF_SHOW_TORRENT_KEY = "show_torrent"
24+
internal const val PREF_SHOW_TORRENT_DEFAULT = true
25+
26+
internal fun buildPreferenceScreen(screen: PreferenceScreen, preferences: SharedPreferences) {
27+
// ── Domain ────────────────────────────────────────────────────────────────
28+
ListPreference(screen.context).apply {
29+
key = PREF_DOMAIN_KEY
30+
title = "Domain"
31+
summary = "%s\n\nSwitch to the mirror if the default domain is unreachable. Restart the app after changing."
32+
entries = DOMAIN_ENTRIES
33+
entryValues = DOMAIN_VALUES
34+
setDefaultValue(PREF_DOMAIN_DEFAULT)
35+
setOnPreferenceChangeListener { _, newValue ->
36+
preferences.edit().putString(PREF_DOMAIN_KEY, newValue as String).apply()
37+
true
38+
}
39+
}.also(screen::addPreference)
40+
41+
// ── Preferred Resolution ──────────────────────────────────────────────────
42+
ListPreference(screen.context).apply {
43+
key = PREF_QUALITY_KEY
44+
title = "Preferred Resolution"
45+
summary = "%s\n\nIf a season shows no episodes, try a lower resolution."
46+
entries = QUALITY_ENTRIES
47+
entryValues = QUALITY_VALUES
48+
setDefaultValue(PREF_QUALITY_DEFAULT)
49+
setOnPreferenceChangeListener { _, newValue ->
50+
preferences.edit().putString(PREF_QUALITY_KEY, newValue as String).apply()
51+
true
52+
}
53+
}.also(screen::addPreference)
54+
55+
// ── Preferred Link Type ───────────────────────────────────────────────────
56+
ListPreference(screen.context).apply {
57+
key = PREF_LINK_TYPE_KEY
58+
title = "Preferred Link Type"
59+
summary = "%s — this link type will appear first in the video list."
60+
entries = LINK_TYPE_ENTRIES
61+
entryValues = LINK_TYPE_ENTRIES
62+
setDefaultValue(PREF_LINK_TYPE_DEFAULT)
63+
setOnPreferenceChangeListener { _, newValue ->
64+
preferences.edit().putString(PREF_LINK_TYPE_KEY, newValue as String).apply()
65+
true
66+
}
67+
}.also(screen::addPreference)
68+
69+
// ── Show Torrent Link ─────────────────────────────────────────────────────
70+
SwitchPreferenceCompat(screen.context).apply {
71+
key = PREF_SHOW_TORRENT_KEY
72+
title = "Show Torrent Link"
73+
summary = "Include the torrent link as a video option."
74+
setDefaultValue(PREF_SHOW_TORRENT_DEFAULT)
75+
setOnPreferenceChangeListener { _, newValue ->
76+
preferences.edit().putBoolean(PREF_SHOW_TORRENT_KEY, newValue as Boolean).apply()
77+
true
78+
}
79+
}.also(screen::addPreference)
80+
}
81+
82+
internal fun List<Video>.sortByPreferredQuality(preferences: SharedPreferences): List<Video> {
83+
val q = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
84+
val linkType = preferences.getString(PREF_LINK_TYPE_KEY, PREF_LINK_TYPE_DEFAULT)!!
85+
return sortedWith(
86+
compareByDescending<Video> { it.quality.contains(linkType, ignoreCase = true) }
87+
.thenByDescending { it.quality.contains(q, ignoreCase = true) }
88+
.thenByDescending { it.quality.replace("p", "").toIntOrNull() ?: 0 },
89+
)
90+
}

0 commit comments

Comments
 (0)