mirror of
https://github.com/yuzono/anime-extensions.git
synced 2026-10-20 10:51:36 +00:00
Fixes/miruro: Squash Merge (#629)
* Fix miruro issues
* Revert version
* Fix null titles (again)
* Update src/en/miruro/src/eu/kanade/tachiyomi/animeextension/en/miruro/Miruro.kt
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* Fix review issues
* Add provider toggle
* Improve Miruro provider robustness with retry, fallback, and graceful error handling
- Make decryptResponse graceful (Log.e + return empty instead of throw)
- Eliminate volatile mutable state (currentEpisodeData, currentAnilistId)
- Embed episode data and anilistId in pipe URL JSON for thread-safety
- Derive subTypes dynamically from episodesObj.keys()
- Add safePipeApiCall with retry for 444/5xx errors (2 retries, 500ms delay)
- Build cross-provider episode map at episodeListParse time
- Embed fallback provider data in episode URLs for zero-cost fallback
- Add video-level provider fallback in videoListParse
- Remove dead parseEpisodesFromProvider code
* Bump Miruro extVersionCode to 3
* Refactor Miruro: eliminate volatile state, deduplicate episode paths, refine retry logic
- Replace cachedAnimeMeta @Volatile var with thread-safe LRU LinkedHashMap
cache (max 16 entries, access-order eviction) via getOrCreateMeta/getMeta
helpers - eliminates race conditions from shared mutable state
- Unify three nearly-identical episode building blocks (primary, merge
fallback, non-merge fallback) into a single provider loop with
merge/non-merge guards
- Extract stripHtml() private function from inline HTML stripping chain
- Refine safePipeApiCall to only retry on 429/444/502/503/504 instead
of all 5xx codes
- Bump extVersionCode 3 -> 4
* Fix Miruro: cascade fallback on provider errors, skip 444 retries
- 444 is a permanent 'content not available' from provider, not transient
- Remove 444 from retryable codes in safePipeApiCall
- Return immediately on 444 instead of wasting time retrying
- Primary provider failure now triggers fallback chain immediately
- Track primaryFailed flag from 444 or parse exceptions
- Fallback runs on primaryFailed OR empty videos
- Exponential backoff for transient errors (429/502/503/504)
- 1s, 2s, 3s delays instead of flat 500ms
- maxRetries bumped from 2 to 3
- Fallback tries all sub-types per provider, not just preferred
- Log empty/null streams arrays for easier debugging
- Bump extVersionCode 4 -> 5
* Fix Miruro: handle unreleased series and provider 444 errors gracefully
- Add validateResponse() to catch 500 (unreleased series) and 444
(provider doesn't have content) before decrypting
- animeDetailsParse + episodeListParse now show clear error messages
instead of crashing on garbage response bodies
- IOException messages are user-facing in the host app
- Bump extVersionCode 5 -> 6
* Refactor Miruro: extract DTO and extractor into separate files, remove duplicate code
Split monolithic Miruro.kt into MiruroDto.kt (serialization DTOs and Json parser) and
MiruroExtractor.kt (video extraction, circuit breaker, decryption, fallback traversal).
Remove duplicate code block (second copies of buildPipeRequest, buildPipeQuery,
safePipeApiCall, decryptResponse, etc.), fix nested lambda ambiguity in subtitle
mapping, add missing resolveTitleFromDto method, fix kotlinx.serialization.Json
import path, add jsonObject import, remove unused imports.
* Fix Miruro: use dynamic year generation in filters
Replace hardcoded 2026 year with Calendar.getInstance().get(Calendar.YEAR) so
filter year options stay current without manual updates.
* Bump Miruro extVersionCode to 7
Version bump for refactor and filter improvements.
* Align Miruro with live site: dynamic config, fix provider order, remove kuz
* Fix version
* Fix Miruro: extract media object from /info response to restore descriptions
* Fix Miruro: wire dub filter, make cache thread-safe, fix resource leaks, improve robustness
- Wire DubLanguageFilter into searchAnimeRequest (dub query param)
- Make animeMetaCache thread-safe (Collections.synchronizedMap + synchronized)
- Convert AnimeMeta to regular class with @Volatile on mutable fields
- Fix resource leak in videoListParse (nullable primaryResponse, finally close, 444 early exit)
- Add toIntOrNull guard in episodeListRequest with descriptive IOException
- Add synchronized(this) double-check locking in fetchConfig
- Pre-compile regex patterns as companion constants (BR_REGEX, CLOSE_P_REGEX, HTML_TAG_REGEX, QUALITY_REGEX)
- Extract truncateDescription() and formatSubTypeLabel() helpers (dedup)
- Add TRANSIENT_RETRY_CODES companion constant
- Add explicit JSONArray/JSONObject branches in buildPipeQuery
- Add logging to 5 swallowed exception catch blocks
* Fix Miruro: remove unused code, fix extractor leak, harden types and null safety
- Remove unused baseUrl from MiruroExtractor constructor
- Fix safePipeApiCall resource leak (close before new call, null guard on return)
- Add h-sub/embed cases to extractor subType labels
- Add TRANSIENT_RETRY_CODES companion constant in extractor
- Remove unused DTOs (PipePayload, AnimeBrowseResponse, PageInfoDto, EpisodesResponseDto, ProviderEpisodeDto)
- Remove unused JsonElement import from MiruroDto
- Convert AnilistMalIdResponse and JikanEpisodesDto nested classes to data class
- Make jsonParser internal visibility
- Replace !! with ?. null-safe handling in MiruroFilters.parseCheckbox
- Remove exitProcess(0) from MiruroUrlActivity (was killing host app process)
* Fix Miruro: add genre/tag exclusion filters, remove invalid Hentai genre
Convert GenreFilter and TagsFilter from CheckBox to TriState to support include/exclude/ignore. Wire excludedGenre and excludedTag API params in searchAnimeRequest. Remove Hentai from genre list (not valid per AniList). Bump extVersionCode to 4.
* Fix Miruro: remove unused JsonObject import
* fix(en/miruro): replace provider aliases with real display names
Replace coded aliases (kiwi, bee, hop, etc.) with actual streaming service names (AnimePahe, Anikoto, Zoro, etc.) in preferences and episode scanlator labels. Add PROVIDER_DISPLAY_NAMES map and providerDisplayName() function. Sort videos by preferred provider first. Bump extVersionCode to 3.
* refactor(en/miruro): add provider names to video quality labels and improve extractor logging
Prefix Video.quality with provider display name (e.g. 'AnimePahe - 1080p Sub'). Add providerDisplayName() and PROVIDER_DISPLAY_NAMES to extractor companion object. Improve circuit breaker log messages to show both display name and alias key.
* fix(en/miruro): show display names in preference summary and all providers in scanlator
* fix(en/miruro): default show provider names in scanlator to false and improve title
* fix(en/miruro): aggregate sub-type labels across all providers in scanlator
* fix(en/miruro): filter scanlator to sub/dub only and clean up serialization roundtrip
- Add SCANLATOR_SUB_TYPES whitelist (sub, dub) to filter out non-display
sub-type keys like 'es', 'ssub', 'h-sub' from scanlator label
- Add SUB_TYPE_DISPLAY_ORDER for consistent scanlator label ordering
- Replace org.json ↔ kotlinx.serialization double-serialization with
direct buildFallbackProvidersJson/buildFallbackSubTypesJson helpers
- Fix MutableMap type (Map→MutableMap) resolving 'No set operator' error
- Remove duplicate PROVIDER_DISPLAY_NAMES from MiruroExtractor, delegate
to Miruro.providerDisplayName()
- Bump extVersionCode 2 → 3
* Fix version
* feat(en/miruro): add stream format preference and label HLS/MP4 in video quality
Add Preferred Stream Format preference (HLS/MP4/All, default HLS) that sorts preferred format first as a fallback - non-preferred formats are never discarded, just ranked lower. Include all stream types instead of silently skipping non-HLS. Label stream type (HLS/MP4) in quality string so users can distinguish them. Bump extVersionCode to 3.
* fix(en/miruro): rename MP4 stream preference to Embed to match Miruro terminology
* fix(en/miruro): improve stream format preference labels
* refactor(en/miruro): remove embed providers and stream format preference
- Remove 5 embed providers (bun, nun, twin, cog, telli) from defaultConfig
- Update provider list to HLS-only: kiwi, bee, bonk, ally, moo, hop, dune
- Remove stream format preference (HLS/Embed/First available) — now always HLS
- Remove embed from SUB_TYPE_DISPLAY_ORDER and formatSubTypeLabel
- Remove TRANSIENT_RETRY_CODES and preferredStreamFormat delegate
- Simplify videoListParse — direct parseStreamsFromResponse call, no retry/fallback
- Remove buildFallbackProvidersJson and buildFallbackSubTypesJson helpers
- Bump extVersionCode 3 → 4
* refactor(en/miruro): remove proxy, retry, and fallback logic from extractor and DTOs
- Remove buildProxiedUrl, obfuscateUrl, fnv1a32, PROXY_* constants
- Remove circuit breaker (providerFailureState, FailureState, isProviderCircuitOpen)
- Remove recordProviderFailure/recordProviderSuccess
- Remove safePipeApiCall (retry with exponential backoff)
- Remove fetchFallbackVideos and buildPipeQueryMap
- Simplify parseStreamsFromResponse — remove proxyConfig, cors, episodeId params
- Remove ProxyConfigDto from DTOs
- Simplify ProviderConfigDto — remove parent, relationship, visible, player, proxy, cors, fallback
- Fix referer check: != null → isNotEmpty() (field has default value, never null)
* feat(en/miruro): add AniTaku (twin) and AnimeGG (cog) providers
* feat(en/miruro): dynamically fetch providers from config API
Fetch available providers from the site's config API on extension load
instead of hardcoding them. Filters out embed providers (identified by
relationship=embed + player=iframe in config) so only native providers
appear in preferences and episode resolution.
- Add parent, relationship, visible, player fields to ProviderConfigDto
- Replace static PROVIDER_DISPLAY_NAMES/PREF_PROVIDER_ENTRIES/VALUES
with mutable instance vars populated from config API
- MiruroExtractor now takes resolveDisplayName lambda instead of
calling static companion method
- Auto-reset stale preferred provider when config changes
- Episode fallback respects config provider priority order
- findMaxEpisodeNumber scans all providers when preferred is missing
- Remove twin/cog from defaults (they are embeds of bonk/moo)
- Bump extVersionCode to 6
* fix(en/miruro): remove duplicate providerOrder local variable
The same val providerOrder was declared twice in the same function
scope (line 549 and 592), causing a compile error. Removed the second
declaration since the first is already in scope.
* Spotless
* fix(en/miruro): extract m3u8 from kwik embed pages via JsUnpacker
Embed streams returned kwik.cx HTML page URLs as playable video URLs, causing
unsupported file format errors. Now fetches the kwik /e/ page, unpacks the packed
JavaScript, and extracts the actual .m3u8 URL. Also adds isActive field to StreamDto.
* feat(en/miruro): add stream type preference, highest available quality, and more truncation options
- Add Preferred Stream Type preference (HLS/Embed/All, default HLS) that filters
videos in sort with fallback to full list if filter yields empty
- Add Highest Available as default quality option (sorts by actual resolution
descending instead of pinning a specific quality)
- Add 750 and 75 character description truncation options
* feat(en/miruro): async config fetch, dynamic provider mapping, and debug logging
Migrate synchronous blocking config fetch to cached async system with
SharedPreferences persistence. Config is fetched on extension load and
re-fetched on mirror change; cold starts use cached prefs until fresh
data arrives.
Add dynamic provider alias-to-display-name mapping using config API data:
- Parent-aware naming for embed providers (e.g. 'Bun (via Bee)')
- Respect visible flag to filter out hidden providers
- Append capability labels to preference entries (e.g. 'AnimePahe (Sub, Download)')
Fix ProxyConfigDto deserialization — API returns boolean for embed
providers but object for native providers. Add ProxyConfigSerializer to
handle both forms gracefully.
Add comprehensive Log.d debug logging throughout Miruro.kt and
MiruroExtractor.kt. Use TAG constant instead of hardcoded strings.
Bump extVersionCode 4 → 6.
* feat(en/miruro): add MAL episode title support via Jikan API
Fetch episode titles from Jikan alongside filler data in a single pagination
pass. Cache titles in AnimeMeta, resolve via new resolveEpisodeTitles()
method with cache-first logic that shares Jikan calls with filler resolution.
Add Episode Title Source preference (MAL/Provider) defaulting to MAL.
Provider titles serve as fallback when no MAL title is available for a
given episode number.
* chore(en/miruro): bump extVersionCode for MAL episode titles
* fix(en/miruro): use lenient Json parser for proxy config deserialization
The ProxyConfigSerializer used the default Json instance which does not
ignore unknown keys, causing a warning when the API returns new fields
like 'segments' in the proxy config object. Switch to jsonParser which
has ignoreUnknownKeys = true.
* feat(en/miruro): add dynamic mirror fetching from status page API
Fetch available mirrors from status.miruro.com API, filter by heartbeat
health status, persist to SharedPreferences, and populate mirror
preference dynamically. Migrate rateLimitHost calls to kotlin.time
Duration API.
* chore(en/miruro): bump extVersionCode for dynamic mirrors
* fix(en/miruro): place episode 0 before episode 1 in all sort orders
Separate episode 0 entries from the main list and prepend them
to the front, so descending sort yields 0, 12, 11, ..., 1 instead
of 12, 11, ..., 1, 0. Ascending order was already correct but is
now explicit about the split.
* miruro: add missing providers (moo, cog) and improve embed extraction
- Add moo (Moon) and cog (Cog) to provider defaults and display names
- Add dune and kuz to known display names
- Replace custom kwik embed extraction with OmniEmbedExtractor
- Add fallback provider support when primary provider returns no videos
- Bump extVersionCode to 10
* lib: add omniembedextractor and kwikextractor libraries
* Integrate all video extractor libs into OmniEmbedExtractor
Add 26 extractors beyond the original 8 (34 total):
- Tier 1 (simple): Voe, Streamlare, StreamHub, VidGuard, Sendvid, StreamDav, StreamSilk, Vido, Vudeo, Upstream, Sibnet, Rumble, Amazon, Fusevideo, Lulu, Buzzheavier, Fastream, VidBom, PixelDrain, MixDrop
- Tier 2 (suspend): VidMoly, VidHide, StreamPlay, StreamUp, Uqload, Blogger
Domain mappings cover 50+ embed domains used across extensions.
* miruro: address code review feedback
- Remove unused companion providerDisplayName (instance method handles fallback)
- Remove unused referer param from OmniEmbedExtractor.extractVideos
- Derive Jikan maxPages from maxEpisode instead of hard-coding 10
- Gate Log.d calls behind BuildConfig.DEBUG via logD helper
- Track mirror fetch job and add close() to cancel CoroutineScope
* Revert unnecessary changes
* Revert "Revert unnecessary changes"
This reverts commit c05093891f.
* Revert UrlActivitiy
* Cleanup
* Suggestion
* refactor
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* PR changes
* Fix okhttp
* lib: add anilib library for AniList GraphQL API
* refactor(en/miruro): replace Jikan and inline AniList code with anilib
* Init
* fix(cloudscraper): address code review compilation and runtime issues
- Evaluate URL_HELPER before shims that reference __cf_parseUrl (BrowserEnvironment)
- Add missing kotlinx.serialization.Serializable import for CvParams (JsdSolver)
- Fix isLenientOnJson -> isLenient in Json builder (JsdSolver)
- Fix key-quoting regex to produce valid JSON with opening quotes (JsdSolver)
- Replace non-existent Cookie.EXPIRES_AT_OTHER with direct expiresAt comparison (CookieCache)
- Remove unused defaultTtlMillis constructor parameter (CookieCache)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(en/miruro): add "Include all provider streams" preference
Adds a settings toggle (default off) that controls whether video streams
from all available providers are included in the player, or only the
preferred provider's streams.
When off (default): only the preferred provider's streams are shown
(fallbacks still used when primary is empty).
When on: streams from all providers are fetched and shown.
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(lib/anilib): expand AniLib with rate-limit handling, caching, paginated queries, ani.zip, and description builder
- Add rate-limit handling: throttle() enforces min interval (667ms normal,
2000ms degraded), handle429() reads Retry-After, handleRateLimitHeaders()
reads X-RateLimit-Remaining, state persisted to SharedPreferences
- Add inline SharedPreferences caching with configurable TTL (default 15min)
- Add fetchSearch, fetchTrending, fetchSeasonal, fetchRelatedMedia queries
- Add fetchEpisodeTitles() for ani.zip integration returning EpisodeTitlesResult
- Expand MediaSnapshot with format, episodes, duration, season, seasonYear,
startDate, endDate, synonyms, averageScore, popularity, tags, relations,
characters, trailer, countryOfOrigin, source, siteUrl
- Add PageData, PageInfo, FuzzyDate, MediaTag, MediaConnection, MediaEdge,
RelatedMediaNode, CharacterNode, CharacterName, CharacterImage, Trailer,
AnizipResponse, AnizipEpisode, EpisodeTitlesResult DTOs
- Migrate executeQuery() to graphQLPost()/parseGraphQLAs() from core
- Add buildDescription() extension on MediaSnapshot with DescriptionOptions
* refactor(en/miruro): prioritize AniLib data over pipe API in anime details, enrich episodes with ani.zip titles
- animeDetailsParse now tries AniLib.fetchMediaDetails() first for richer
AniList GraphQL data, falling back to pipe API DTO then raw JSONObject
- Add buildFromSnapshot() using AniLib.resolveTitle/resolveCoverUrl/
resolveMainStudio and buildDescription()
- Add buildFromDto() using same AniLib helpers via MediaTitle/MediaCoverImage/
MediaStudios construction
- Extract mapStatus() as shared helper for all three parse paths
- Remove resolveTitleFromDto() (fully replaced by AniLib.resolveTitle())
- Replace FQCN aniyomi.lib.anilib.X with short-form imports
- Enrich episode names from ani.zip when only "Episode X" is available
* fix(en/miruro): bump extVersionCode to 6
* feat(lib/anilib): add AniFiller integration with episode classification types and cached dataset fetch
* feat(en/miruro): add filler episode handling preferences and integrate AniFiller data in episode list
* fix(lib/anilib): fix GraphQL deserialization, add unified filter/browse method, migrate Miruro browse to AniLib
Replace AniListResponse<T> with correct DTO types for parseGraphQLAs.
parseGraphQLAs already unwraps the GraphQL {data} envelope, so
AniListResponse<T> (which has its own data field) caused a
mismatch — after unwrapping, JSON is {Page:{...}} not
{data:{Page:{...}}}.
- Add PageDataWrapper with @SerialName("Page") property matching
post-unwrapping JSON shape (same pattern as MediaData with
@SerialName("Media"))
- Change all executeQuery calls: Page queries use PageDataWrapper,
Media queries use MediaData directly
- Remove AniListResponse<T> (no longer needed)
- Add MediaFilter data class with toVariables() for parameterized
GraphQL queries
- Add fetchFilteredMedia with FILTERED_MEDIA_QUERY supporting all
AniList filter parameters
- Refactor fetchSearchAnime/fetchTrending/fetchSeasonal to delegate
to fetchFilteredMedia
- Add convenience wrappers: fetchPopular, fetchUpcoming,
fetchRecentlyUpdated
- Migrate Miruro browse/search from pipe API to AniLib directly
- Add toMediaFilter() extension on FilterSearchParams
- Add animeFromMediaSnapshot() converter and applyDubPostFilter()
- Bump extVersionCode to 8
* refactor(lib/anilib): restyle description builder with markdown formatting and star ratings
Use markdown bold labels, star ratings, and structured line-per-field
layout instead of plain Unicode symbols (⊛⊞│) and cramped single-line
info. This matches the styling patterns used by Animetsu, Cineby, and
AnikotoTheme in the repo.
- Add 5-star score visualization (★★★★☆ 85%) replacing plain ★ 85%
- Replace Unicode bullets with **Bold:** labels for genres/tags/studio
- Replace │ inline separator with line-per-field layout for readability
- Use [linked titles] for relations when AniList siteUrl is available
- Use [▶ Watch Trailer](url) instead of raw Trailer: URL
- Use *italic* for synonyms section
- Use --- horizontal rule before synopsis instead of bare em-dash
- Use Airing instead of Releasing for RELEASING status
- Use ordinal episodes (12 episodes) instead of abbrev (12 eps)
- DescriptionOptions API unchanged — fully backward-compatible
* Fix title pref
* fix(lib/anilib): fix AnizipEpisode deserialization, add airing dates and filler episode data
- AnizipEpisode.title was String? but API returns JSON object (en/x-jat/ja), causing silent null. Add AnizipTitle DTO and resolvedTitle computed property.
- AnizipEpisode had no @SerialName for aired field; add airDate/airdate with correct SerialNames and resolvedAirDate computed property.
- Make EpisodeTitlesResult @Serializable with pre-parsed airDates map.
- Add FillerEpisodeData(type, airDate, title) DTO with parsed epoch millis.
- Add @Serializable to FillerType enum (was missing, blocked compilation of FillerEpisodeData).
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(lib/anilib): add caching and date parsing to fetchAiringSchedule, fetchEpisodeTitles, fetchFillerData
- Add SharedPreferences caching (15-min TTL) to fetchAiringSchedule via executeQuery with cacheKey.
- Extract extractAiringSchedule(snapshot) and buildScheduleMap(media) as public helpers to reuse schedule data from already-fetched fetchMediaDetails cache.
- Add SharedPreferences caching (15-min TTL) to fetchEpisodeTitles; parse resolvedAirDate via anizipDateFormat.tryParse() into airDates map.
- Rewrite fetchFillerData to return FillerEpisodeData with parsed airDate (epoch millis via anifillerDateFormat.tryParse) and title from AniFillerEpisode.
- Move anizipDateFormat and anifillerDateFormat lazy vals above KDoc blocks for correct doc-comment association.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(en/miruro): fix episode airing dates, titles, and filler marks with enriched fallback chains
- Rewrite episodeListParse enrichment: try fetchMediaDetails cache first for schedule data (reuses 15-min cache), fall back to standalone fetchAiringSchedule only on failure.
- Date fallback chain: AniList airingSchedule → ani.zip airDates → AniFiller airDate.
- Title enrichment: try ani.zip resolvedTitle first, then AniFiller title as last resort.
- Filler marking via FillerEpisodeData with [Filler], [Mixed Canon], [Anime Canon] scanlator tags.
- Use Math.round() instead of .toInt() for Int map lookups to fix Float truncation bug on fractional episodes.
- Bump extVersionCode 8 → 10.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* https://developer.android.com/studio/known-issues#android-studio-quail-1-parallel-sync-execution-issue
* fix(lib/anilib): make AniFillerMappings.malId nullable to handle null JSON values
AniFiller dataset can contain null mal_id for anime without a MyAnimeList entry. The non-nullable Int type caused kotlinx.serialization to throw 'Unexpected symbol n in numeric literal' at path $[175].mappings.mal_id.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(en/miruro): remove brackets from filler episode scanlator marks
Change filler mark labels from [Filler], [Mixed Canon], [Anime Canon] to Filler, Mixed Canon, Anime Canon in episode scanlator field.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(en/miruro): add CloudScraperInterceptor and zstd decompression for pipe API requests
Miruro.tv pipe API requests were failing with HTTP 403 due to Cloudflare
TLS fingerprinting blocking OkHttp's non-browser TLS stack. Additionally,
the server now responds with content-encoding: zstd instead of gzip.
Changes:
- Add CloudScraperInterceptor (QuickJS-based headless CF bypass) to OkHttp client
- Add ZstdDecompressInterceptor as a network interceptor for zstd decompression
- Bump extVersionCode 10 -> 11
- Add dependencies: lib:cloudscraper, com.github.luben:zstd-jni:1.5.6-9@aar
The CloudScraperInterceptor solves JSD, legacy IUAM, and managed CF challenges
without a WebView, and caches cf_clearance cookies across requests. The
ZstdDecompressInterceptor strips the Content-Encoding header and decompresses
the response body before OkHttp's BridgeInterceptor processes it.
* fix(cloudscraper): resolve compilation errors in cloudscraper library
Cloudscraper library from feature/cflib merge had several compilation issues:
- BrowserEnvironment: const val with trimMargin() requires runtime
evaluation; changed URL_HELPER to @JvmField val
- ChallengeDetector/JsdSolver: $cv and $params interpreted as Kotlin
string templates; escaped as ${'$'}cv and ${'$'}params
- JsdSolver: extractCookies returns immutable Map<String,String>;
changed to toMutableMap() for putAll support
- LegacyIuamSolver/ManagedChallengeSolver: non-local return statements
inside withQuickJsTimeout lambda (not inline); restructured to use
captured result vars returned after the lambda completes
Also fixes in Miruro.kt:
- Buffer.writeTo(zstd) corrected to Buffer.readFrom(zstd) (InputStream vs OutputStream)
- Removed broken .addHeader() call on OkHttpClient.Builder (not a valid API)
* fix(cloudscraper): resolve concurrent QuickJS stack overflow
- Replace per-call Executors.newSingleThreadExecutor() with shared
ThreadPoolExecutor(4, 4, 60s) daemon threads in QuickJsExt
- Add explicit StackOverflowError catch mapped to CloudscraperException
- Ensure engine is always closed in finally block
- Remove @Synchronized from CloudScraperInterceptor.intercept() which
was serializing all HTTP requests unnecessarily
- Per-host locking via CookieCache.lockForHost() already handles
concurrent access correctly
* fix(cloudscraper): harden browser environment and JS solvers
BrowserEnvironment:
- Add JS-level recursion guard (RECURSION_GUARD_SHIM) with
__cf_callDepth counter and __cf_maxCallDepth=256 limit
- Timer callbacks reset call depth before execution for fresh budget
- Add WebGLRenderingContext and WebGL2RenderingContext stubs with
full extension object hierarchy
- Add AudioContext and OfflineAudioContext stubs with realistic
method signatures (createOscillator, createAnalyser, createGain)
- Add Intl.DateTimeFormat, Intl.NumberFormat, and Intl.Collator stubs
with functional format/compare methods
Security hardening:
- Fix JS injection in ManagedChallengeSolver and LegacyIuamSolver:
escape backslashes and double quotes in domain string interpolation
- Add protocol whitelist (javascript:/data:) to resolveUrl() in all
three solvers (JsdSolver, ManagedChallengeSolver, LegacyIuamSolver)
to prevent SSRF via crafted challenge URLs
* feat(cloudscraper): add TLS/JA3 fingerprint spoofing via impersonator-bctls
Integrate zhkl0228/impersonator (BouncyCastle-based TLS fingerprint
spoofing) to make CloudScraper's HTTP requests appear as real Chrome
browser TLS handshakes to Cloudflare.
- Add impersonator-bctls 1.0.13 dependency (pure Java, no native code,
no OkHttp classpath conflicts)
- Create impersonated OkHttpClient internally using ImpersonatorFactory
.chrome() with spoofed SSLContext for Chrome's JA3 fingerprint
- All challenge-solving requests and cookie-carrying retries now use the
impersonated client, bypassing Cloudflare's TLS fingerprint checks
- Add impersonatedClient constructor parameter for users who want to
provide their own pre-configured client
- Use solveClient.newCall() directly for retry instead of chain.proceed()
to ensure the TLS fingerprint is applied to the final request
* fix(cloudscraper): use impersonator-bctls 1.0.12 (latest on JitPack)
* fix(cloudscraper): use ImpersonatorFactory.android() (chrome() doesn't exist)
* feat(en/miruro): shape requests with browser-fingerprint interceptor to bypass WAF block; drop zstd-jni native dep
Miruro's Cloudflare edge enforces a WAF custom-rule block (not a solvable
challenge — the body is the 'Sorry, you have been blocked' page with
cf-error-details and no cf-mitigated header) on pipe-API requests whose
header set does not match a real Chrome browser's fetch(). Live curl
against /api/secure/pipe?e=<base64url> with only UA+Accept+Referer
returns 403; the same probe with the full browser-shape set
(sec-fetch-*, sec-ch-ua-*, Origin, Accept-Language q=0.9, zstd opt-in)
returns 200 + x-obfuscated: 2.
CloudScraperInterceptor cannot fix custom-rule blocks — its isBlockPage
path passes through unresolved by design (no challenge to solve). Rewiring
miruro through the lib was a dead end.
Fix: add MiruroBrowserFingerprintInterceptor, an internal network
interceptor that browser-shapes every miruro request with a Chrome 148
desktop fingerprint. Classifies pipe-context (path /api/*: Sec-Fetch-Dest
empty, Sec-Fetch-Mode cors, Sec-Fetch-Site same-origin, Origin set) vs
navigate-context (warmup GET / and /watch/<id>: Sec-Fetch-Dest document,
Sec-Fetch-Mode navigate, Sec-Fetch-Site none on cold, no Origin).
Overwrites WAF-fingerprint headers (Accept-Encoding/Accept-Language/
User-Agent/Sec-Ch-Ua/Sec-Ch-Ua-Mobile/Sec-Ch-Ua-Platform/Sec-Fetch-*/
Origin) so caller intent cannot leak a bot signal; fill-if-absent on
body-shape headers (Accept, Referer).
Stripped the now-redundant manual UA/Accept/Accept-Language/Referer
from ensureBaseVisit, ensureWatchPageVisited, and buildPipeRequest
(now bare GET(url) + bare Request.Builder().url(...).build()) — the new
interceptor is the single source of truth for fingerprint shaping.
Bumped USER_AGENT from private to internal so the same-package
interceptor can read it.
Second prod crash unmasked by the WAF fix: java.lang.NoClassDefFoundError
clinit failed for com.github.luben.zstd.ZstdInputStreamNoFinalizer. The
zstd-jni native library (libzstd-jni-*.so) wasn't being packaged into the
extension APK under kei.plugins.extension.legacy — the static initializer
that calls System.loadLibrary fails on real Android devices (confirmed on
Samsung SM-S928U, Android 16). Before this fingerprint fix, the WAF
returned 403 HTML (Content-Encoding: identity), so ZstdDecompressInterceptor
short-circuited and never instantiated ZstdInputStream; now that the WAF
passes us through, server honored our zstd advertisement and the broken
native-lib path executed for the first time in prod.
Fix for the crash: drop the zstd token from Accept-Encoding (advertise
only gzip, deflate, br), remove the ZstdDecompressInterceptor class
entirely, and drop the zstd-jni AAR dependency. The server responds with
content-encoding: br instead, which aniyomi's host okhttp3.
CompressionInterceptor handles transparently (no native lib). Verified
via live curl + full decode walk: brotli-decompress → base64url-decode →
XOR with PIPE_KEY → gzip-decompress (XOR'd payload has gzip magic
1f 8b 08 00) → valid UTF-8 JSON {streaming:{bonk:{...},...}}.
MiruroExtractor.decryptResponse already does base64url→XOR→GZIPInputStream,
so it needs zero changes.
Also dropped the now-unused project(':lib:cloudscraper') dep from
miruro gradle — the lib itself is untouched and still available for
other extensions that need challenge-solving.
Build verification: cleaned and assembled successfully on Windows.
Real-device test on Samsung SM-S928U / Android 16 / WebView 149.0.7827.163
passed (no NoClassDefFoundError, pipe API returns real JSON)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* refactor(lib/cloudscraper): remove obsolete LegacyIuam and ManagedChallenge solvers
These solver paths produced zero production wins on modern Cloudflare edge
configs and were already documented as removed in the parent interceptor's
own KDoc. Modern CF managed v2/v3 challenges ('Just a moment…') require
fingerprint signals (real canvas/WebGL rendering, pointer events, focus
state) that are not reproducible in a headless QuickJS DOM shim — the
math-based JSchl computation that LegacyIuamSolver/ManagedChallengeSolver
performed is no longer sufficient to earn cf_clearance on edges that have
escalated to challenge v2/v3.
The CloudScraperInterceptor now relies on its WebView fallback path
(aniyomi.lib.cloudflareinterceptor.CloudflareInterceptor) for unclassifiable
or managed challenges; the JSD fast-path (JsdSolver) handles the simpler
window.__CF$cv$params variants that DO solve in headless environments.
Files removed:
* ManagedChallengeSolver.kt — QuickJS-DOM-shim attempt at managed v2/v3
challenges (the approach pioneered by go-cfscraper). Failed on modern
edges for the canvas/WebGL/pointer/focus reasons cited above.
* LegacyIuamSolver.kt — Cloudflare IUAM (I'm Under Attack Mode) and V1
classic JS challenges (jschl_vc / jschl_answer / pass). Both mostly
extinct on modern CF; legacy edges that still serve them are typically
solvable by JsdSolver's pre-challenge detection path instead.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* refactor(lib/cloudscraper): rewrite JSD solver, browser environment, and QuickJS worker config
Rewrites the three coupled JSD-solver pieces as one unit so each one
compiles against a consistent sibling API. This commit would not build
if split (each file references members the others expose in their new
shape), so all three land together.
* BrowserEnvironment.kt — Refactored from one monolithic shim block into
modular constant blocks (URL_HELPER, RECURSION_GUARD_SHIM, CONSOLE_SHIM,
TIMERS_SHIM, ATOB_BTOA_SHIM, TEXT_CODEC_SHIM, windowShim, NAVIGATOR_SHIM,
SCREEN_SHIM, documentShim, PERFORMANCE_SHIM, CRYPTO_SHIM, MISC_SHIM).
Each block is evaluated in its own QuickJs.evaluate call so a syntax
error in one block surfaces with a clear boundary instead of aborting
the entire install. Fingerprint values (navigator.userAgentData.brands,
navigator.platform, sec-ch-ua, screen dimensions, language) are now
derived from the supplied userAgent string so the navigator/brands/
platform all agree — Cloudflare flags mismatches between reported UA
and navigator.userAgentData.brands / navigator.platform.
* JsdSolver.kt — Adds initialCookies passthrough so cookies warmed via
the host client's jar reach the QuickJS script execution; tracks the
sensor POST URL separately (SensorResult.challengeUrl) for telemetry;
tightens retry to RETRYABLE_ERRORS only (script-content errors like
NO_SENSOR_PAYLOAD / NO_CLEARANCE_COOKIE are deterministic for a fixed
script+env so retrying them only burns another scriptTimeoutMs);
drops scriptTimeoutMs default from 10s to 8s for faster fallback to
WebView.
* QuickJsExt.kt — Worker thread stack size raised to 16 MB (private
WORKER_STACK_SIZE) so both QuickJs.create() and evaluate() run on the
same 16 MB-stack thread. Without this, the CashApp binding sizes
QuickJS's internal 1 MB C-stack limit from the creating thread, so
creating the engine on a small-stack thread and evaluating on a
large-stack worker causes an instant stack overflow on the very first
evaluate call (related to the prior 'resolve concurrent QuickJS stack
overflow' commit but stabilizing the worker thread itself too).
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* refactor(lib/cloudscraper): harden interceptor + detector + cookie cache and add diagnostics
Pairs together the runtime-side hardening flow + a new debug helper.
* CloudScraperInterceptor.kt — Removed the broken impersonator-bctls
SSLContext path (BCJSSE raised TlsFatalAlert during TLS 1.3 handshake
on every modern CF edge — see upstream issues zhkl0228/impersonator#8/#9)
and now layers keiyoushi.lib.tlsspoof.SpoofedTlsSupport on the host
client builder instead. Restated the KDoc as a hybrid solver: headless
JSD fast path + WebView fallback for unclassifiable/managed challenges.
The X-Cloudscraper-Skip marker header on internal retry leg is preserved
for downstream consumer diagnostics.
* ChallengeDetector.kt — Reduced the challenge taxonomy to binary
JSD / UNSOLVABLE. The old multi-type taxonomy (MANAGED / LEGACY_IUAM /
TURNSTILE) carried metadata only the dead solvers consumed; modernized
to match the 'no managed/legacy solver' reality.
* CookieCache.kt — make lockForHost non-reentrant (a second attempt by
the same thread returns false so the lock now guards against duplicate
solve attempts within the same call stack). unlockForHost remove the
ReentrantLock from the hostLocks map when its queue is empty, so the
map no longer grows one entry per host ever visited.
* CloudScraperDebug.kt (NEW) — Runtime-flag-gated diagnostic tiers
(always / verbose / trace) for log-noisy JSD sensor debugging. Flags
are runtime not compile-time, so a developer can flip them on without
rebuilding cloudscraper itself.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* chore(lib/cloudscraper): wire test sourceset and add JSD solver unit + integration tests
Splits build config + tests + fixtures together because the test
sourceset needs the new build.gradle.kts deps to compile against
cloudscraper's main sourceset; splitting would leave the new test
sourceset non-compiling and the build config change meaningless
without tests.
build.gradle.kts:
* Adds the 'test' android sourceSet wired to test/java + test/kotlin
so JVM unit tests can run against this android library module.
* testOptions: isIncludeAndroidResources=false, isReturnDefaultValues=true
so Log.w/Log.e calls in the unit under test don't try to load the
Android framework (we are testing parsing/cache/regex logic, not
real-device logging).
* Replaces implementation(libs.impersonator) with
implementation(project(":lib:cloudflareinterceptor")) and
implementation(project(":lib:tlsspoof")) — the runtime path now uses
keiyoushi.lib.tlsspoof.SpoofedTlsSupport instead of the broken
impersonator-bctls SSLContext path (BouncyCastle JSSE raised
TlsFatalAlert during TLS 1.3 handshake on every modern CF edge —
see upstream issues zhkl0228/impersonator#8/#9).
* Adds testImplementation deps for kotlin-test-junit, okhttp,
kotlinx.serializaton.json, and quickjs so tests can construct
HttpUrl/Cookie instances, parse JSON, and run the real sensor
script under QuickJS for integration coverage.
Tests (test/kotlin/keiyoushi/lib/cloudscraper/, 6 files):
* ChallengeDetectorTest.kt — verifies JSD detection, UNSOLVABLE
classification of managed/legacy/turnstile/403/503 variants, and
regex-pattern extraction of __CF$cv$params metadata.
* CookieCacheTest.kt — covers cache hit/miss/stale/expiry, per-host
lock non-reentrancy (the new lockForHost behavior from the prior
commit), and hostLocks-map cleanup when the queue is empty.
* JsdSolverExtractCookiesTest.kt — Set-Cookie parsing from sensor
response, including the cf_clearance cookie and any sibling cookies
the challenge endpoint emits.
* JsdSolverExtractDeobfuscationPiecesTest.kt — verifies the
deobfuscation-piece extraction (D-array entries, handler chains)
from a real captured cf_jsd_main.js sensor script.
* JsdSolverParseCvParamsTest.kt — parses a real __CF$cv$params JSON
blob into the typed struct the solver consumes.
* JsdSolverRealScriptTest.kt — end-to-end integration: loads the
real captured cf_jsd_main.js fixture + cf_jsd_sensor_payload.txt,
runs the full QuickJS install-evaluate-sensor-POST path, asserts
a non-empty sensor payload is produced. Marked as the slowest test
in the suite but kept in the regular unit run for regression
coverage on the JSD fast path.
Fixtures (test/fixtures/, 4 files):
* cf_challenge_403.html — a captured managed-challenge 403 page used
by ChallengeDetectorTest to verify UNSOLVABLE classification of
non-JSD challenges.
* cf_jsd_main.js — the real captured JSD sensor script used by
JsdSolverExtractDeobfuscationPiecesTest and JsdSolverRealScriptTest.
Single-line file (minified source) so line is long; fixture kept
as one file rather than reformatted so byte-for-byte matches what
Cloudflare serves.
* cf_jsd_sensor_payload.txt — the request body the sensor script
POSTs to /__cf_chl_rcv, used by JsdSolverRealScriptTest as the
expected output shape for the replicated sensor generation.
* cf_jsd_sensor_response.txt — empty placeholder documenting the
response shape from the challenge endpoint (real responses are
server-generated and short-lived; left empty so we don't pin to
one CF variant's content).
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(lib:tlsspoof,lib:cloudflareinterceptor,gradle): add TLS spoofing support and update CloudflareInterceptor fallback
Adds the new :lib:tlsspoof module + wires it into both the Cloudflare
fallback path and the version catalog as one atomic unit. The five
files land together because each pair would otherwise be broken:
- tlsspoof's build.gradle.kts consumes the conscrypt dep that the
decorator-domain KDoc in TlsSpoof.kt references
- SpoofedTlsSupport.applyTo() calls TlsSpoof.install()
- CloudflareInterceptor now imports SpoofedTlsSupport to install
the spoofed stack on the WebView-bypass client; references the
new keiyoushi.lib.tlsspoof package
- libs.versions.toml drops the failed impersonator-bctls entry
(now superseded by :lib:tlsspoof via Conscrypt)
lib/tlsspoof (NEW):
* build.gradle.kts — aliases kei.plugins.library; depends on
org.conscrypt:conscrypt-android:2.5.2 (the Maven-published
Conscrypt build that ships GREASE / ALPS / X25519MLKEM768,
giving a Chromium-adjacent JA3 instead of Android's default).
* TlsSpoof.kt — pure-JVM Android TLS spoofing library. Installs the
upstream Conscrypt JSSE provider at JCA position 1 (idempotent —
Security.insertProviderElement returns the prior position if
already installed). Every SSLContext.getInstance("TLS") OkHttp
then creates internally uses Conscrypt instead of Android's bundled
variant. Background KDoc documents why this exists: prior
impersonator-bctls attempt failed (BCJSSE TLS 1.3 TlsFatalAlert on
modern CF edges — upstream issues zhkl0228/impersonator#8/#9), and
modern CF edge configs (e.g. Miruro.tv) reject Android-default JA3
at the TLS handshake layer before challenge JS even runs. Includes
the design-tradeoff honestly: pure-JVM cannot reproduce a per-byte
Chrome JA3 (Chrome's ClientHello byte ordering is owned by
Conscrypt's internal ClientHello builder, not by OkHttp or by the
JCA caller), but the Chromium-adjacent JA3 is enough to pass CF's
fingerprint gate for the sites tested.
* SpoofedTlsSupport.kt — applyTo(OkHttpClient.Builder) entry point.
Calls TlsSpoof.install() then filters the builder's connectionSpecs
down to OkHttp's MODERN_TLS profile (TLS 1.2/1.3 only, AEAD cipher
suites, same set Chrome 131 offers). CLEARTEXT preserved so plain
HTTP calls still work. Mutation is idempotent. Does NOT disable
cert pinning or trust verification — the spoofed handshake still
validates against the system trust store.
lib/cloudflareinterceptor (MODIFIED):
* CloudflareInterceptor.kt — rewrote KDoc to be honest about the
hybrid reality (headless cannot solve modern managed/Turnstile).
New per-host locks (hostLocks ConcurrentHashMap<String, ReentrantLock>)
deduplicate concurrent solves for the same host. In-memory
CookieCache with 25-minute TTL avoids burning a fresh WebView for
every request; cf_clearance is bound to TLS/JA3+UA+IP and is
intentionally NOT persisted across process death. Caller-UA
passthrough — uses request UA if present, falls back to current
Chrome 131 / Windows UA, because CF re-challenges sessions that
flip UA between the original request and the WebView. Detects
cf-mitigated: challenge header alongside the legacy
Server: cloudflare + 403/503 signaling. Cookie sync to OkHttp
client uses the REAL request URL (scheme + host + path), not a
fake http:// URL — the old code's cookies were silently discarded.
Caller-contract note: must be added as a network interceptor
(not app interceptor) so the WebView solve and post-solve retry
both flow through the rest of the chain (decompression, rate
limiting, etc.).
gradle/libs.versions.toml (MODIFIED):
* Drops the impersonator entry (com.github.zhkl0228:impersonator-bctls)
now that nothing in the tree depends on it. :lib:tlsspoof is the
replacement.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(lib:m3u8server): add full-buffer AutoDetector, AES-128 HLS, header threading, fallback retry, and UpstreamStatusException
AutoDetector: detectInterleavedSkips() full-buffer scan for JPEG/PNG/GIF junk at any offset (not just prefix). M3u8HttpServer: full-buffer download + non-junk chunk assembly, processSegmentUrl takes keyUrl+iv for AES-128-CBC decryption, modifyM3u8Content parses #EXT-X-KEY and injects key/iv on segment URLs, createLocalUrl accepts referer+userAgent encoded as URL params, handleM3u8Request/handleSegmentRequest read from session.parameters, fetchM3u8Content catches IOException and retries via fallbackClient, UpstreamStatusException maps upstream codes to NanoHTTPD Status via passThroughStatus. M3u8Integration: processM3u8Video extracts Referer+UA from video.headers, constructor accepts fallbackClient. M3u8ServerManager: processM3u8Url threads referer+userAgent, constructor accepts fallbackClient.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(lib:megacloudextractor,lib:rapidcloudextractor,lib:dopeflixextractor): graceful 410 handling via requestNewKeyOrNull
Replace hard-fail requestNewKey() with null-tolerant requestNewKeyOrNull() across MegaCloud, RapidCloud, and DopeFlix extractors. When the keys.json GitHub raw endpoint returns 410 ( Gone) or fails, sources are silently skipped via mapNotNull instead of crashing the entire extraction path with IllegalStateException.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(en/miruro): route HLS streams through ultracloud.cc proxy with XOR obfuscation
Add buildProxiedUrl() that wraps raw owocdn.top stream URLs through vault01/02.ultracloud.cc using XOR obfuscation with VITE_PROXY_OBF_KEY and base64url encoding. Proxy server selection via FNV-1a hash of episodeId|anilistId mod 2, matching the frontend's Xb() function. The proxy URL encodes the raw stream.referer from the pipe API (e.g. https://kwik.cx/) — not the resolveHlsReferer output — because the proxy uses the encoded referer to fetch upstream. Video headers carry Referer+Origin set to mirrorBaseUrl so the m3u8 server sends browser-like CORS headers to the proxy. parseStreamsFromResponse accepts episodeId+anilistId params for the hash seed. build.gradle wires m3u8server, cloudflareinterceptor, megacloudextractor, and rapidcloudextractor dependencies.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(en/miruro): thread episodeId/anilistId and PROXY_KEY from Miruro.kt into extractor
Add PROXY_KEY constant (hex decode of a54d389c18527d9fd3e7f0643e27edbe) alongside existing PIPE_KEY. Pass PROXY_KEY as new constructor param to MiruroExtractor. In videoListParse, extract episodeId and anilistId from the episodeData JSON and thread them through all three parseStreamsFromResponse call sites (primary, sub-types, and fallback providers) so the extractor can select the correct proxy server via FNV-1a hash.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* docs(en/miruro): add frontend reverse-engineering research artifacts
Bundle analysis from miruro.tv WatchRoute JS: proxy URL construction (ix/ax XOR+base64url), FNV-1a proxy selection (Xb), deobfuscation (ox/dx), recursive m3u8 fetch (px), and URL verification (gx). Secrets dump from env2.js with both XOR keys and proxy URLs. Discovered endpoints, security report, and README with critical findings.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* refactor(en/miruro): remove dead code
Remove unused parseAnimeListResponse (deprecated, never called),
activeMirrorReferer (only consumer was resolveHlsReferer), and
resolveHlsReferer (private, never called).
* feat(lib:anilib): extend with includeNSFW filter, new query methods, and utility extensions
- Add includeNSFW parameter to all fetching methods (fetchFilteredMedia, fetchSearchAnime, fetchTrending, fetchSeasonal, fetchPopular, fetchUpcoming, fetchRecentlyUpdated)
- Add fetchTopAnime, fetchAiringThisSeason, fetchGenres, fetchTags, fetchStudioProductions, fetchMediaTrends methods
- Add MediaSnapshot extension properties (isAdultContent, formattedStatus, streamingLinks, externalLinks, rankings, stats, etc.)
- Add MediaFilter.Builder for fluent filter construction
- Add new DTOs: MediaStreamingEpisode, MediaExternalLink, MediaRank, MediaStats, MediaTrend, Studio, GenreCollectionData
- Extend MediaSnapshot with meanScore, trending, favourites, isLicensed, hashtag, streamingEpisodes, externalLinks, rankings, stats fields
- Update fetchMediaDetails query to include new fields
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(en/miruro): add Show NSFW preference toggle
Adds a toggle to include adult/NSFW anime in search and browse results. Defaults to off. Includes disclaimer that Miruro providers seldom have fully NSFW anime (hentai) available regardless.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* docs(miruro): Remove falsely committed research artifacts
* fix(lib:anilib): pass isAdult=true instead of null to include adult content
AniList API defaults to excluding adult content when the isAdult variable is omitted. Setting isAdult=null (which caused omission) was therefore equivalent to having the NSFW toggle off. Now explicitly sends isAdult=true when the user enables the toggle.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* refactor(lib:anilib): move MediaFilter.Builder into MediaFilter companion class
Relocates the Builder class from file-level to inside the MediaFilter data class body, improving discoverability and encapsulation.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat(en/miruro): wire includeNSFW to all AniLib fetch calls and parallelize episode metadata
Threads the includeNSFW preference into fetchRecentlyUpdated, fetchSearchAnime, and fetchFilteredMedia so the NSFW toggle takes effect across latest, text search, and filter search. Also parallelizes three independent AniLib network calls (airing schedule, episode titles, filler data) via runBlocking/coroutineScope/async, switches animeMetaCache from synchronized LinkedHashMap to ConcurrentHashMap, uses parallelCatchingFlatMapBlocking for fallback provider requests, and fixes label variable ordering in the debug interceptor.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(en/miruro): optimize XOR decryption in-place and disable m3u8 server for multi streams
Replaces mapIndexed + toByteArray with an in-place XOR loop to avoid allocating an intermediate array. Also sets withM3u8Server=false for MegaCloud multi-type streams so extraction uses the direct video URL instead of routing through the m3u8 server proxy.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* refactor(lib:anilib): relocate to lib-multisrc with multisrc plugin and improve error handling
Move anilib from lib/ to lib-multisrc/ using the multisrc plugin format with baseVersionCode. Package changed from aniyomi.lib.anilib to eu.kanade.tachiyomi.multisrc.anilib. Added proper non-2xx HTTP status code handling: executeQuery now reads response bodies on failure, extracts GraphQL error messages, and logs them with status codes. Similar improvements applied to fetchEpisodeTitles and loadAniFillerIndex.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* refactor(en/miruro): update anilib dependency and imports for new multisrc location
Update build.gradle dependency from lib:anilib to lib-multisrc:anilib. Update all imports from aniyomi.lib.anilib to eu.kanade.tachiyomi.multisrc.anilib in Miruro.kt and MiruroFilters.kt.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* fix(lib:anilib): show all content when NSFW toggle is enabled
When includeNSFW is true, omit isAdult parameter to show all anime
(adult + non-adult). Previously set isAdult=true which showed only
adult content.
* anilib: improve rate limit handling
- Fix throttle() race condition with synchronized block
- Only backoff when X-RateLimit-Remaining == 0 (was <= 1)
- Add degraded mode recovery after 5-minute cooldown
- Persist degraded state to SharedPreferences
* Delete cf_challenge_403.html
* Delete cf_jsd_main.js
* Delete cf_jsd_sensor_payload.txt
* Delete cf_jsd_sensor_response.txt
* Delete ChallengeDetectorTest.kt
* Delete CookieCacheTest.kt
* Delete JsdSolverExtractCookiesTest.kt
* Delete JsdSolverExtractDeobfuscationPiecesTest.kt
* Delete JsdSolverParseCvParamsTest.kt
* Delete JsdSolverRealScriptTest.kt
* Remove tests
* refactor(anilib): Move anilib again
* refactor(anilib): Move anilib again again
* feat(en/miruro): route browse/search through Miruro pipe API with AniLib fallback
getPopularAnime, getLatestUpdates, and getSearchAnime (both text-search and filter-browse paths) now hit Miruro's own pipe 'search' and 'search/browse' endpoints first, keeping AniList GraphQL (AniLib) as a synchronous fallback so a Miruro backend hiccup never blanks the page. The filter UI now drives the pipe's native genre/tag/format/status/season/year/dubLanguage query params — dub filtering happens at the source instead of as a client-side post-filter on the pipe path. Pipe responses are decoded with the existing XOR+gzip path and parsed via the same parseAnimeFromMediaObj used by the info endpoint; media shape is identical.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* Remove broken libraries and fix miruro build chores
* Remove bad miruro lib
* Remove bad miruro lib (again)
---------
Co-authored-by: Amnesia <amnesia@example.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Amnesia <anon@example.com>
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
committed by
GitHub
parent
582c064d97
commit
51fa7e2b07
@@ -5,3 +5,4 @@ org.gradle.jvmargs=-Xmx6144m
|
||||
org.gradle.parallel=true
|
||||
|
||||
kotlin.stdlib.default.dependency=false
|
||||
org.gradle.tooling.parallel=true
|
||||
|
||||
7
lib/anilib/build.gradle.kts
Normal file
7
lib/anilib/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
import keiyoushi.gradle.extensions.baseVersionCode
|
||||
|
||||
plugins {
|
||||
alias(kei.plugins.multisrc)
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
||||
1090
lib/anilib/src/aniyomi/lib/anilib/AniLib.kt
Normal file
1090
lib/anilib/src/aniyomi/lib/anilib/AniLib.kt
Normal file
File diff suppressed because it is too large
Load Diff
198
lib/anilib/src/aniyomi/lib/anilib/AniLibDescription.kt
Normal file
198
lib/anilib/src/aniyomi/lib/anilib/AniLibDescription.kt
Normal file
@@ -0,0 +1,198 @@
|
||||
package aniyomi.lib.anilib
|
||||
|
||||
/**
|
||||
* Controls which sections are included in the built description string.
|
||||
*
|
||||
* All sections default to `true`. Set to `false` to exclude a section.
|
||||
*/
|
||||
data class DescriptionOptions(
|
||||
val genres: Boolean = true,
|
||||
val tags: Boolean = true,
|
||||
val score: Boolean = true,
|
||||
val status: Boolean = true,
|
||||
val format: Boolean = true,
|
||||
val episodes: Boolean = true,
|
||||
val duration: Boolean = true,
|
||||
val season: Boolean = true,
|
||||
val studio: Boolean = true,
|
||||
val synonyms: Boolean = true,
|
||||
val relations: Boolean = true,
|
||||
val trailer: Boolean = true,
|
||||
val stripHtml: Boolean = true,
|
||||
val maxTagCount: Int = 10,
|
||||
val truncateAt: Int = 0,
|
||||
val titleStyle: String = "userPreferred",
|
||||
)
|
||||
|
||||
private val BR_REGEX = Regex("<br\\s*/?>", RegexOption.IGNORE_CASE)
|
||||
private val CLOSE_P_REGEX = Regex("</p>", RegexOption.IGNORE_CASE)
|
||||
private val HTML_TAG_REGEX = Regex("<[^>]+>")
|
||||
|
||||
/**
|
||||
* Build a rich, formatted description string from this [MediaSnapshot].
|
||||
*
|
||||
* Produces a markdown-formatted description with metadata sections above
|
||||
* the AniList synopsis. The host app renders markdown in `SAnime.description`,
|
||||
* so bold labels, star ratings, and links display with proper styling.
|
||||
*
|
||||
* Sections are controlled via [options] — disable any you don't want.
|
||||
*/
|
||||
fun MediaSnapshot.buildDescription(options: DescriptionOptions = DescriptionOptions()): String {
|
||||
val sections = mutableListOf<String>()
|
||||
|
||||
// ---- Score ----
|
||||
if (options.score && averageScore != null && averageScore > 0) {
|
||||
sections.add(fancyScore(averageScore))
|
||||
}
|
||||
|
||||
// ---- Quick-info line ----
|
||||
val infoParts = mutableListOf<String>()
|
||||
if (options.status && !status.isNullOrBlank()) {
|
||||
infoParts.add("**${labelStatus(status)}**")
|
||||
}
|
||||
if (options.format && !format.isNullOrBlank()) {
|
||||
infoParts.add(labelFormat(format))
|
||||
}
|
||||
if (options.episodes && episodes != null && episodes > 0) {
|
||||
val epLabel = if (episodes == 1) "1 episode" else "$episodes episodes"
|
||||
val durPart = if (options.duration && duration != null && duration > 0) ", $duration min" else ""
|
||||
infoParts.add("$epLabel$durPart")
|
||||
} else if (options.duration && duration != null && duration > 0) {
|
||||
infoParts.add("$duration min")
|
||||
}
|
||||
if (options.season && !season.isNullOrBlank() && seasonYear != null) {
|
||||
infoParts.add("${labelSeason(season)} $seasonYear")
|
||||
}
|
||||
if (infoParts.isNotEmpty()) {
|
||||
sections.add(infoParts.joinToString(" • "))
|
||||
}
|
||||
|
||||
// ---- Studio ----
|
||||
if (options.studio) {
|
||||
val studio = AniLib.resolveMainStudio(studios)
|
||||
if (studio.isNotBlank()) {
|
||||
sections.add("**Studio:** $studio")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Genres ----
|
||||
if (options.genres && genres.isNotEmpty()) {
|
||||
sections.add("**Genres:** ${genres.joinToString()}")
|
||||
}
|
||||
|
||||
// ---- Tags ----
|
||||
if (options.tags && !tags.isNullOrEmpty()) {
|
||||
val displayTags = tags
|
||||
.filter { !it.isGeneralSpoiler && !it.isMediaSpoiler }
|
||||
.sortedByDescending { it.rank }
|
||||
.take(options.maxTagCount)
|
||||
.mapNotNull { it.name }
|
||||
if (displayTags.isNotEmpty()) {
|
||||
sections.add("**Tags:** ${displayTags.joinToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Synonyms ----
|
||||
if (options.synonyms && synonyms.isNotEmpty()) {
|
||||
val primaryTitle = AniLib.resolveTitle(title, options.titleStyle).orEmpty()
|
||||
val allTitleVariants = listOfNotNull(title?.userPreferred, title?.romaji, title?.english, title?.native)
|
||||
val filtered = synonyms.filter { !it.isNullOrBlank() && it != primaryTitle && it !in allTitleVariants }
|
||||
if (filtered.isNotEmpty()) {
|
||||
sections.add("*Also known as: ${filtered.joinToString()}*")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Relations ----
|
||||
if (options.relations && relations?.edges?.isNotEmpty() == true) {
|
||||
val relationLines = relations.edges.mapNotNull { edge ->
|
||||
val type = edge.relationType ?: return@mapNotNull null
|
||||
val node = edge.node ?: return@mapNotNull null
|
||||
val name = AniLib.resolveTitle(node.title, options.titleStyle) ?: return@mapNotNull null
|
||||
val fmt = node.format?.let { labelFormat(it) } ?: ""
|
||||
val eps = node.episodes?.takeIf { it > 0 }?.let { "$it episodes" }
|
||||
val details = listOfNotNull(fmt, eps).joinToString(", ")
|
||||
val namePart = if (node.siteUrl.isNullOrBlank()) {
|
||||
name
|
||||
} else {
|
||||
"[$name](${node.siteUrl})"
|
||||
}
|
||||
"**$type:** $namePart" + if (details.isNotBlank()) " — $details" else ""
|
||||
}
|
||||
if (relationLines.isNotEmpty()) {
|
||||
sections.add(relationLines.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Trailer ----
|
||||
if (options.trailer && trailer?.url() != null) {
|
||||
sections.add("[▶ Watch Trailer](${trailer.url()})")
|
||||
}
|
||||
|
||||
// ---- Synopsis ----
|
||||
val synopsis = if (!description.isNullOrBlank()) {
|
||||
val cleaned = if (options.stripHtml) stripHtml(description) else description
|
||||
cleaned.trim()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val result = buildString {
|
||||
if (sections.isNotEmpty()) {
|
||||
append(sections.joinToString("\n\n"))
|
||||
}
|
||||
if (!synopsis.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n\n---\n\n")
|
||||
append(synopsis)
|
||||
}
|
||||
}
|
||||
|
||||
return if (options.truncateAt > 0 && result.length > options.truncateAt) {
|
||||
val cutIndex = result.lastIndexOf(' ', options.truncateAt)
|
||||
if (cutIndex > options.truncateAt * 2 / 3) {
|
||||
result.substring(0, cutIndex) + "\u2026"
|
||||
} else {
|
||||
result.substring(0, options.truncateAt) + "\u2026"
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun fancyScore(score: Int): String {
|
||||
val stars = (score / 20.0).toInt().coerceIn(1, 5)
|
||||
return "${"★".repeat(stars)}${"☆".repeat(5 - stars)} $score%"
|
||||
}
|
||||
|
||||
private fun stripHtml(input: String): String = input
|
||||
.replace(BR_REGEX, "\n")
|
||||
.replace(CLOSE_P_REGEX, "\n")
|
||||
.replace(HTML_TAG_REGEX, "")
|
||||
.trim()
|
||||
|
||||
private fun labelStatus(status: String): String = when (status.uppercase()) {
|
||||
"RELEASING" -> "Airing"
|
||||
"FINISHED" -> "Finished"
|
||||
"CANCELLED" -> "Cancelled"
|
||||
"HIATUS" -> "Hiatus"
|
||||
"NOT_YET_RELEASED" -> "Unreleased"
|
||||
else -> status.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
private fun labelFormat(format: String): String = when (format.uppercase()) {
|
||||
"TV" -> "TV"
|
||||
"TV_SHORT" -> "TV Short"
|
||||
"MOVIE" -> "Movie"
|
||||
"SPECIAL" -> "Special"
|
||||
"OVA" -> "OVA"
|
||||
"ONA" -> "ONA"
|
||||
"MUSIC" -> "Music"
|
||||
else -> format.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
private fun labelSeason(season: String): String = when (season.uppercase()) {
|
||||
"WINTER" -> "Winter"
|
||||
"SPRING" -> "Spring"
|
||||
"SUMMER" -> "Summer"
|
||||
"FALL" -> "Fall"
|
||||
else -> season.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
795
lib/anilib/src/aniyomi/lib/anilib/AniLibDto.kt
Normal file
795
lib/anilib/src/aniyomi/lib/anilib/AniLibDto.kt
Normal file
@@ -0,0 +1,795 @@
|
||||
package aniyomi.lib.anilib
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
// ========================== Response Wrappers ==========================
|
||||
// After parseGraphQLAs unwraps the {"data": ..., "errors": ...} envelope,
|
||||
// the remaining JSON has the GraphQL root operation type as a key.
|
||||
// These wrappers map that key to a property via @SerialName.
|
||||
|
||||
@Serializable
|
||||
data class PageDataWrapper(
|
||||
@SerialName("Page") val page: PageData = PageData(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PageData(
|
||||
val pageInfo: PageInfo? = null,
|
||||
val media: List<MediaSnapshot> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PageInfo(
|
||||
val total: Int = 0,
|
||||
val currentPage: Int = 0,
|
||||
val lastPage: Int = 0,
|
||||
val hasNextPage: Boolean = false,
|
||||
val perPage: Int = 0,
|
||||
)
|
||||
|
||||
// ========================== Media Data ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaData(
|
||||
@SerialName("Media") val media: MediaSnapshot? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaSnapshot(
|
||||
val id: Int = 0,
|
||||
@SerialName("idMal") val malId: Int? = null,
|
||||
val title: MediaTitle? = null,
|
||||
val coverImage: MediaCoverImage? = null,
|
||||
val bannerImage: String? = null,
|
||||
val description: String? = null,
|
||||
val status: String? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val studios: MediaStudios? = null,
|
||||
val nextAiringEpisode: AiringEpisode? = null,
|
||||
val airingSchedule: AiringScheduleConnection? = null,
|
||||
// --- New fields ---
|
||||
val format: String? = null,
|
||||
val episodes: Int? = null,
|
||||
val duration: Int? = null,
|
||||
val season: String? = null,
|
||||
val seasonYear: Int? = null,
|
||||
val startDate: FuzzyDate? = null,
|
||||
val endDate: FuzzyDate? = null,
|
||||
val synonyms: List<String> = emptyList(),
|
||||
val averageScore: Int? = null,
|
||||
val popularity: Int? = null,
|
||||
val tags: List<MediaTag>? = null,
|
||||
val relations: MediaConnection? = null,
|
||||
val characters: MediaConnection? = null,
|
||||
val trailer: Trailer? = null,
|
||||
val countryOfOrigin: String? = null,
|
||||
val source: String? = null,
|
||||
val siteUrl: String? = null,
|
||||
// --- Extended fields ---
|
||||
val meanScore: Int? = null,
|
||||
val trending: Int? = null,
|
||||
val favourites: Int? = null,
|
||||
val isLicensed: Boolean? = null,
|
||||
val hashtag: String? = null,
|
||||
val updatedAt: Int? = null,
|
||||
val seasonInt: Int? = null,
|
||||
val streamingEpisodes: List<MediaStreamingEpisode>? = null,
|
||||
val externalLinks: List<MediaExternalLink>? = null,
|
||||
val rankings: List<MediaRank>? = null,
|
||||
val stats: MediaStats? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTitle(
|
||||
val userPreferred: String? = null,
|
||||
val romaji: String? = null,
|
||||
val english: String? = null,
|
||||
val native: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaCoverImage(
|
||||
val extraLarge: String? = null,
|
||||
val large: String? = null,
|
||||
val medium: String? = null,
|
||||
)
|
||||
|
||||
// ========================== Studios ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaStudios(
|
||||
val edges: List<StudioEdge>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudioEdge(
|
||||
val isMain: Boolean = false,
|
||||
val node: StudioNode? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudioNode(
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
// ========================== Airing Schedule ==========================
|
||||
|
||||
@Serializable
|
||||
data class AiringEpisode(
|
||||
val episode: Int = 0,
|
||||
val airingAt: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AiringScheduleConnection(
|
||||
val nodes: List<AiringScheduleNode>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AiringScheduleNode(
|
||||
val episode: Int = 0,
|
||||
val airingAt: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of fetching airing schedule from AniList.
|
||||
* Maps episode number → air date in milliseconds since epoch.
|
||||
*/
|
||||
data class AiringScheduleResult(
|
||||
val schedule: Map<Float, Long> = emptyMap(),
|
||||
)
|
||||
|
||||
// ========================== Date ==========================
|
||||
|
||||
@Serializable
|
||||
data class FuzzyDate(
|
||||
val year: Int? = null,
|
||||
val month: Int? = null,
|
||||
val day: Int? = null,
|
||||
) {
|
||||
/** Formats the date as "YYYY-MM-DD" with null parts replaced by "?". */
|
||||
fun format(): String = buildString {
|
||||
append(year?.toString() ?: "?")
|
||||
append('-')
|
||||
append(month?.toString()?.padStart(2, '0') ?: "??")
|
||||
append('-')
|
||||
append(day?.toString()?.padStart(2, '0') ?: "??")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================== Tags ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaTag(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val rank: Int? = null,
|
||||
val isGeneralSpoiler: Boolean = false,
|
||||
val isMediaSpoiler: Boolean = false,
|
||||
)
|
||||
|
||||
// ========================== Relations ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaConnection(
|
||||
val edges: List<MediaEdge>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaEdge(
|
||||
val relationType: String? = null,
|
||||
val node: RelatedMediaNode? = null,
|
||||
@SerialName("characterName") val characterName: String? = null,
|
||||
val role: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RelatedMediaNode(
|
||||
val id: Int = 0,
|
||||
val type: String? = null,
|
||||
val format: String? = null,
|
||||
val title: MediaTitle? = null,
|
||||
val coverImage: MediaCoverImage? = null,
|
||||
val status: String? = null,
|
||||
val episodes: Int? = null,
|
||||
val siteUrl: String? = null,
|
||||
)
|
||||
|
||||
// ========================== Characters ==========================
|
||||
|
||||
@Serializable
|
||||
data class CharacterNode(
|
||||
val id: Int = 0,
|
||||
val name: CharacterName? = null,
|
||||
val image: CharacterImage? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharacterName(
|
||||
val full: String? = null,
|
||||
val native: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharacterImage(
|
||||
val large: String? = null,
|
||||
val medium: String? = null,
|
||||
)
|
||||
|
||||
// ========================== Trailer ==========================
|
||||
|
||||
@Serializable
|
||||
data class Trailer(
|
||||
val id: String? = null,
|
||||
val site: String? = null,
|
||||
val thumbnail: String? = null,
|
||||
) {
|
||||
/** Returns a watchable URL if the trailer is from YouTube. */
|
||||
fun url(): String? {
|
||||
if (site != "youtube" || id == null) return null
|
||||
return "https://www.youtube.com/watch?v=$id"
|
||||
}
|
||||
}
|
||||
|
||||
// ========================== Streaming Episodes ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaStreamingEpisode(
|
||||
val title: String? = null,
|
||||
val thumbnail: String? = null,
|
||||
val url: String? = null,
|
||||
val site: String? = null,
|
||||
)
|
||||
|
||||
// ========================== External Links ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaExternalLink(
|
||||
val id: Int = 0,
|
||||
val url: String? = null,
|
||||
val site: String? = null,
|
||||
val siteId: Int? = null,
|
||||
val type: String? = null,
|
||||
val language: String? = null,
|
||||
val color: String? = null,
|
||||
val icon: String? = null,
|
||||
val isDisabled: Boolean = false,
|
||||
)
|
||||
|
||||
// ========================== Rankings ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaRank(
|
||||
val id: Int = 0,
|
||||
val rank: Int = 0,
|
||||
val type: String? = null,
|
||||
val format: String? = null,
|
||||
val year: Int? = null,
|
||||
val season: String? = null,
|
||||
val allTime: Boolean = false,
|
||||
val context: String? = null,
|
||||
)
|
||||
|
||||
// ========================== Media Stats ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaStats(
|
||||
val scoreDistribution: List<ScoreDistribution>? = null,
|
||||
val statusDistribution: List<StatusDistribution>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScoreDistribution(
|
||||
val score: Int = 0,
|
||||
val amount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatusDistribution(
|
||||
val status: String? = null,
|
||||
val amount: Int = 0,
|
||||
)
|
||||
|
||||
// ========================== Media Trend ==========================
|
||||
|
||||
@Serializable
|
||||
data class MediaTrend(
|
||||
val mediaId: Int = 0,
|
||||
val date: Int = 0,
|
||||
val trending: Int = 0,
|
||||
val averageScore: Int? = null,
|
||||
val popularity: Int? = null,
|
||||
val inProgress: Int? = null,
|
||||
val releasing: Boolean = false,
|
||||
val episode: Int? = null,
|
||||
val media: MediaSnapshot? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTrendConnection(
|
||||
val edges: List<MediaTrendEdge>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTrendEdge(
|
||||
val node: MediaTrend? = null,
|
||||
)
|
||||
|
||||
// ========================== Studio Details ==========================
|
||||
|
||||
@Serializable
|
||||
data class StudioData(
|
||||
@SerialName("Studio") val studio: Studio? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Studio(
|
||||
val id: Int = 0,
|
||||
val name: String? = null,
|
||||
val isAnimationStudio: Boolean = false,
|
||||
val favourites: Int = 0,
|
||||
val siteUrl: String? = null,
|
||||
val media: StudioMediaConnection? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudioMediaConnection(
|
||||
val edges: List<StudioMediaEdge>? = null,
|
||||
val pageInfo: PageInfo? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudioMediaEdge(
|
||||
val node: MediaSnapshot? = null,
|
||||
val isMain: Boolean = false,
|
||||
)
|
||||
|
||||
// ========================== Genre/Tag Collections ==========================
|
||||
|
||||
@Serializable
|
||||
data class GenreCollectionData(
|
||||
@SerialName("GenreCollection") val genres: List<String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTagCollectionData(
|
||||
@SerialName("MediaTagCollection") val tags: List<MediaTag>? = null,
|
||||
)
|
||||
|
||||
// ========================== Ani.zip Episode Data ==========================
|
||||
|
||||
@Serializable
|
||||
data class AnizipResponse(
|
||||
val episodes: Map<String, AnizipEpisode>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AnizipTitle(
|
||||
val en: String? = null,
|
||||
@SerialName("x-jat") val xJat: String? = null,
|
||||
val ja: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AnizipEpisode(
|
||||
val title: AnizipTitle? = null,
|
||||
val airDate: String? = null,
|
||||
val airdate: String? = null,
|
||||
val runtime: Int? = null,
|
||||
) {
|
||||
val resolvedTitle: String?
|
||||
get() = title?.en?.ifEmpty { null }
|
||||
?: title?.xJat?.ifEmpty { null }
|
||||
?: title?.ja?.ifEmpty { null }
|
||||
|
||||
val resolvedAirDate: String?
|
||||
get() = airDate?.ifEmpty { null }
|
||||
?: airdate?.ifEmpty { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of fetching episode titles from ani.zip.
|
||||
* Maps episode number → title/air date/runtime data.
|
||||
*
|
||||
* [airDates] contains pre-parsed epoch milliseconds for episode air dates,
|
||||
* resolved from ani.zip `airDate`/`airdate` fields.
|
||||
*/
|
||||
@Serializable
|
||||
data class EpisodeTitlesResult(
|
||||
val episodes: Map<Int, AnizipEpisode> = emptyMap(),
|
||||
val airDates: Map<Int, Long> = emptyMap(),
|
||||
)
|
||||
|
||||
// ========================== AniFiller Episode Data ==========================
|
||||
|
||||
/** Episode classification types from AniFiller. */
|
||||
@Serializable
|
||||
enum class FillerType(val label: String) {
|
||||
MANGA_CANON("manga-canon"),
|
||||
FILLER("filler"),
|
||||
MIXED_MANGA("mixed-manga"),
|
||||
ANIME_CANON("anime-canon"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Parse from the AniFiller JSON string value, defaults to null if unrecognized. */
|
||||
fun fromValue(value: String): FillerType? = entries.find { it.label == value }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AniFillerShow(
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val mappings: AniFillerMappings = AniFillerMappings(),
|
||||
val episodes: List<AniFillerEpisode> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniFillerMappings(
|
||||
@SerialName("anilist_id") val anilistId: Int = 0,
|
||||
@SerialName("mal_id") val malId: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AniFillerEpisode(
|
||||
val episode: Int = 0,
|
||||
val title: String = "",
|
||||
val type: String = "",
|
||||
@SerialName("aired_date") val airedDate: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FillerEpisodeData(
|
||||
val type: FillerType,
|
||||
val airDate: Long = 0L,
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of fetching filler classification from AniFiller.
|
||||
* Maps episode number → [FillerEpisodeData] containing type, air date, and title.
|
||||
*/
|
||||
@Serializable
|
||||
data class FillerDataResult(
|
||||
val episodes: Map<Int, FillerEpisodeData> = emptyMap(),
|
||||
)
|
||||
|
||||
// ========================== MediaSnapshot Extensions ==========================
|
||||
|
||||
/**
|
||||
* Whether this media is classified as adult/NSFW content.
|
||||
* Checks both the `isAdult` GraphQL field (via tags) and genre-based heuristics.
|
||||
*/
|
||||
val MediaSnapshot.isAdultContent: Boolean
|
||||
get() = genres.any { it.equals("Ecchi", ignoreCase = true) } ||
|
||||
tags?.any { it.name.equals("Nudity", ignoreCase = true) || it.name.equals("Ero Guro", ignoreCase = true) } == true
|
||||
|
||||
/**
|
||||
* Human-readable status label derived from the AniList status string.
|
||||
*/
|
||||
val MediaSnapshot.formattedStatus: String
|
||||
get() = when (status?.uppercase()) {
|
||||
"RELEASING" -> "Airing"
|
||||
"FINISHED" -> "Finished"
|
||||
"CANCELLED" -> "Cancelled"
|
||||
"HIATUS" -> "Hiatus"
|
||||
"NOT_YET_RELEASED" -> "Not Yet Aired"
|
||||
else -> status?.replaceFirstChar { it.uppercase() } ?: "Unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* All available title variants (userPreferred, romaji, english, native) excluding blanks.
|
||||
*/
|
||||
val MediaSnapshot.allTitleVariants: List<String>
|
||||
get() = listOfNotNull(
|
||||
title?.userPreferred?.ifBlank { null },
|
||||
title?.romaji?.ifBlank { null },
|
||||
title?.english?.ifBlank { null },
|
||||
title?.native?.ifBlank { null },
|
||||
).distinct()
|
||||
|
||||
/**
|
||||
* Human-readable format label (e.g. "TV Short", "OVA", "Movie").
|
||||
*/
|
||||
val MediaSnapshot.formattedFormat: String
|
||||
get() = when (format?.uppercase()) {
|
||||
"TV" -> "TV"
|
||||
"TV_SHORT" -> "TV Short"
|
||||
"MOVIE" -> "Movie"
|
||||
"SPECIAL" -> "Special"
|
||||
"OVA" -> "OVA"
|
||||
"ONA" -> "ONA"
|
||||
"MUSIC" -> "Music"
|
||||
else -> format?.replaceFirstChar { it.uppercase() } ?: "Unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
* Season label with year (e.g. "Spring 2024"), or null if unavailable.
|
||||
*/
|
||||
val MediaSnapshot.seasonLabel: String?
|
||||
get() {
|
||||
if (season.isNullOrBlank() || seasonYear == null) return null
|
||||
val seasonName = when (season.uppercase()) {
|
||||
"WINTER" -> "Winter"
|
||||
"SPRING" -> "Spring"
|
||||
"SUMMER" -> "Summer"
|
||||
"FALL" -> "Fall"
|
||||
else -> season.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
return "$seasonName $seasonYear"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted episode count string (e.g. "12 episodes" or "1 episode").
|
||||
*/
|
||||
val MediaSnapshot.episodeLabel: String?
|
||||
get() {
|
||||
if (episodes == null || episodes <= 0) return null
|
||||
return if (episodes == 1) "1 episode" else "$episodes episodes"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted duration string (e.g. "24 min/ep").
|
||||
*/
|
||||
val MediaSnapshot.durationLabel: String?
|
||||
get() {
|
||||
if (duration == null || duration <= 0) return null
|
||||
return "$duration min/ep"
|
||||
}
|
||||
|
||||
/**
|
||||
* Average score as a display string (e.g. "85%" or "N/A").
|
||||
*/
|
||||
val MediaSnapshot.scoreLabel: String
|
||||
get() = if (averageScore != null && averageScore > 0) "$averageScore%" else "N/A"
|
||||
|
||||
/**
|
||||
* AniList site URL for this media, or null if not available.
|
||||
*/
|
||||
val MediaSnapshot.anilistUrl: String?
|
||||
get() = siteUrl?.ifBlank { null }
|
||||
|
||||
val MediaSnapshot.meanScoreLabel: String
|
||||
get() = if (meanScore != null && meanScore > 0) "$meanScore%" else "N/A"
|
||||
|
||||
val MediaSnapshot.favouritesLabel: String
|
||||
get() = when {
|
||||
favourites == null || favourites <= 0 -> "0"
|
||||
favourites >= 1000 -> "${"%.1f".format(favourites / 1000.0)}k"
|
||||
else -> favourites.toString()
|
||||
}
|
||||
|
||||
val MediaSnapshot.sourceLabel: String?
|
||||
get() = when (source?.uppercase()) {
|
||||
"ORIGINAL" -> "Original"
|
||||
"MANGA" -> "Manga"
|
||||
"LIGHT_NOVEL" -> "Light Novel"
|
||||
"VISUAL_NOVEL" -> "Visual Novel"
|
||||
"GAME" -> "Game"
|
||||
"OTHER" -> "Other"
|
||||
"NOVEL" -> "Novel"
|
||||
"DOUJINSHI" -> "Doujinshi"
|
||||
"ANIME" -> "Anime"
|
||||
else -> source?.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
val MediaSnapshot.streamingLinks: List<MediaStreamingEpisode>
|
||||
get() = streamingEpisodes?.filter { !it.url.isNullOrBlank() } ?: emptyList()
|
||||
|
||||
val MediaSnapshot.streamingSites: List<String>
|
||||
get() = streamingEpisodes?.mapNotNull { it.site?.ifBlank { null } }?.distinct() ?: emptyList()
|
||||
|
||||
val MediaSnapshot.externalStreamingLinks: List<MediaExternalLink>
|
||||
get() = externalLinks?.filter { it.type == "STREAMING" && !it.isDisabled } ?: emptyList()
|
||||
|
||||
val MediaSnapshot.externalInfoLinks: List<MediaExternalLink>
|
||||
get() = externalLinks?.filter { it.type == "INFO" && !it.isDisabled } ?: emptyList()
|
||||
|
||||
val MediaSnapshot.topRanking: MediaRank?
|
||||
get() = rankings?.filter { it.type == "RATED" }?.minByOrNull { it.rank }
|
||||
|
||||
val MediaSnapshot.popularityRanking: MediaRank?
|
||||
get() = rankings?.filter { it.type == "POPULAR" }?.minByOrNull { it.rank }
|
||||
|
||||
val MediaSnapshot.scoreDistributionMap: Map<Int, Int>
|
||||
get() = stats?.scoreDistribution?.associate { it.score to it.amount } ?: emptyMap()
|
||||
|
||||
val MediaSnapshot.watchingCount: Int
|
||||
get() = stats?.statusDistribution?.firstOrNull { it.status == "CURRENT" }?.amount ?: 0
|
||||
|
||||
val MediaSnapshot.completedCount: Int
|
||||
get() = stats?.statusDistribution?.firstOrNull { it.status == "COMPLETED" }?.amount ?: 0
|
||||
|
||||
// ========================== Media Filter ==========================
|
||||
|
||||
/**
|
||||
* Universal filter parameters for AniList media queries.
|
||||
*
|
||||
* All fields are nullable strings (or lists of strings) to allow flexible
|
||||
* composition without requiring enum conversions at the boundary.
|
||||
* The [toVariables] method builds a GraphQL variables JSON object containing
|
||||
* only the non-null fields, so the fixed query template can include all
|
||||
* parameters as optional variables.
|
||||
*
|
||||
* @property search Text search query.
|
||||
* @property sort Comma-separated sort criteria (e.g. "TRENDING_DESC", "POPULARITY_DESC").
|
||||
* AniList accepts `[MediaSort]` as a JSON array — pass multiple values in [sortList].
|
||||
* @property sortList Explicit list of sort values; takes precedence over [sort] if both set.
|
||||
* @property format Media format (e.g. "TV", "MOVIE", "OVA").
|
||||
* @property status Media status (e.g. "RELEASING", "FINISHED", "NOT_YET_RELEASED").
|
||||
* @property season Media season (e.g. "WINTER", "SPRING", "SUMMER", "FALL").
|
||||
* @property seasonYear Year of the season.
|
||||
* @property genres Included genres list.
|
||||
* @property excludedGenres Excluded genres list.
|
||||
* @property tags Included tags list.
|
||||
* @property excludedTags Excluded tags list.
|
||||
* @property minimumTagRank Minimum rank for tags (0–100).
|
||||
* @property yearGreater Start year for FuzzyDateInt range (inclusive).
|
||||
* @property yearLesser End year for FuzzyDateInt range (inclusive).
|
||||
* @property countryOfOrigin Country code (e.g. "JP", "KR", "CN").
|
||||
* @property isAdult Whether to filter adult content.
|
||||
* @property onList Whether the media is on the user's list.
|
||||
* @property page Page number (1-indexed).
|
||||
* @property perPage Results per page (max 50).
|
||||
*/
|
||||
data class MediaFilter(
|
||||
val search: String? = null,
|
||||
val sort: String? = null,
|
||||
val sortList: List<String>? = null,
|
||||
val format: String? = null,
|
||||
val formatList: List<String>? = null,
|
||||
val status: String? = null,
|
||||
val season: String? = null,
|
||||
val seasonYear: Int? = null,
|
||||
val genres: List<String>? = null,
|
||||
val excludedGenres: List<String>? = null,
|
||||
val tags: List<String>? = null,
|
||||
val excludedTags: List<String>? = null,
|
||||
val minimumTagRank: Int? = null,
|
||||
val yearGreater: Int? = null,
|
||||
val yearLesser: Int? = null,
|
||||
val countryOfOrigin: String? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val onList: Boolean? = null,
|
||||
val page: Int? = null,
|
||||
val perPage: Int? = null,
|
||||
) {
|
||||
/**
|
||||
* Build a GraphQL variables JSON object from the non-null filter fields.
|
||||
* Only includes fields that have a value, so the fixed query template can
|
||||
* safely declare all parameters as optional GraphQL variables.
|
||||
*/
|
||||
fun toVariables(): JsonObject = buildJsonObject {
|
||||
search?.let { put("search", it) }
|
||||
// sortList takes precedence; fallback: split single sort string
|
||||
val effectiveSort = sortList ?: sort?.let { listOf(it) }
|
||||
effectiveSort?.let {
|
||||
put("sort", JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) }))
|
||||
}
|
||||
// formatList takes precedence; fallback: single format string
|
||||
val effectiveFormat = formatList ?: format?.let { listOf(it) }
|
||||
effectiveFormat?.let {
|
||||
put("format", JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) }))
|
||||
}
|
||||
status?.let { put("status", it) }
|
||||
season?.let { put("season", it) }
|
||||
seasonYear?.let { put("seasonYear", it) }
|
||||
genres?.let {
|
||||
put("genres", JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) }))
|
||||
}
|
||||
excludedGenres?.let {
|
||||
put("excludedGenres", JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) }))
|
||||
}
|
||||
tags?.let {
|
||||
put("tags", JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) }))
|
||||
}
|
||||
excludedTags?.let {
|
||||
put("excludedTags", JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) }))
|
||||
}
|
||||
minimumTagRank?.let { put("minimumTagRank", it) }
|
||||
yearGreater?.let { put("yearGreater", it) }
|
||||
yearLesser?.let { put("yearLesser", it) }
|
||||
countryOfOrigin?.let { put("countryOfOrigin", it) }
|
||||
isAdult?.let { put("isAdult", it) }
|
||||
onList?.let { put("onList", it) }
|
||||
page?.let { put("page", it) }
|
||||
perPage?.let { put("perPage", it) }
|
||||
// type is always ANIME for this library
|
||||
put("type", "ANIME")
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for constructing [MediaFilter] instances with a fluent API.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val filter = MediaFilter.Builder()
|
||||
* .search("Attack on Titan")
|
||||
* .sort("POPULARITY_DESC")
|
||||
* .genre("Action", "Drama")
|
||||
* .excludeGenre("Comedy")
|
||||
* .status("FINISHED")
|
||||
* .season("SPRING", 2023)
|
||||
* .format("TV")
|
||||
* .page(1)
|
||||
* .perPage(20)
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
class Builder {
|
||||
private var search: String? = null
|
||||
private var sort: String? = null
|
||||
private var sortList: List<String>? = null
|
||||
private var format: String? = null
|
||||
private var formatList: List<String>? = null
|
||||
private var status: String? = null
|
||||
private var season: String? = null
|
||||
private var seasonYear: Int? = null
|
||||
private val genres = mutableListOf<String>()
|
||||
private val excludedGenres = mutableListOf<String>()
|
||||
private val tags = mutableListOf<String>()
|
||||
private val excludedTags = mutableListOf<String>()
|
||||
private var minimumTagRank: Int? = null
|
||||
private var yearGreater: Int? = null
|
||||
private var yearLesser: Int? = null
|
||||
private var countryOfOrigin: String? = null
|
||||
private var isAdult: Boolean? = null
|
||||
private var onList: Boolean? = null
|
||||
private var page: Int? = null
|
||||
private var perPage: Int? = null
|
||||
|
||||
fun search(query: String) = apply { this.search = query }
|
||||
fun sort(vararg criteria: String) = apply { this.sortList = criteria.toList() }
|
||||
fun sort(criteria: String) = apply { this.sort = criteria }
|
||||
fun format(vararg formats: String) = apply { this.formatList = formats.toList() }
|
||||
fun format(fmt: String) = apply { this.format = fmt }
|
||||
fun status(status: String) = apply { this.status = status }
|
||||
fun season(season: String, year: Int? = null) = apply {
|
||||
this.season = season.uppercase()
|
||||
this.seasonYear = year
|
||||
}
|
||||
fun genre(vararg genres: String) = apply { this.genres.addAll(genres) }
|
||||
fun excludeGenre(vararg genres: String) = apply { this.excludedGenres.addAll(genres) }
|
||||
fun tag(vararg tags: String) = apply { this.tags.addAll(tags) }
|
||||
fun excludeTag(vararg tags: String) = apply { this.excludedTags.addAll(tags) }
|
||||
fun minimumTagRank(rank: Int) = apply { this.minimumTagRank = rank }
|
||||
fun yearRange(from: Int? = null, to: Int? = null) = apply {
|
||||
this.yearGreater = from
|
||||
this.yearLesser = to
|
||||
}
|
||||
fun countryOfOrigin(code: String) = apply { this.countryOfOrigin = code }
|
||||
fun isAdult(adult: Boolean?) = apply { this.isAdult = adult }
|
||||
fun onList(onList: Boolean?) = apply { this.onList = onList }
|
||||
fun page(page: Int) = apply { this.page = page }
|
||||
fun perPage(perPage: Int) = apply { this.perPage = perPage }
|
||||
|
||||
fun build() = MediaFilter(
|
||||
search = search,
|
||||
sort = sort,
|
||||
sortList = sortList,
|
||||
format = format,
|
||||
formatList = formatList,
|
||||
status = status,
|
||||
season = season,
|
||||
seasonYear = seasonYear,
|
||||
genres = genres.takeIf { it.isNotEmpty() },
|
||||
excludedGenres = excludedGenres.takeIf { it.isNotEmpty() },
|
||||
tags = tags.takeIf { it.isNotEmpty() },
|
||||
excludedTags = excludedTags.takeIf { it.isNotEmpty() },
|
||||
minimumTagRank = minimumTagRank,
|
||||
yearGreater = yearGreater,
|
||||
yearLesser = yearLesser,
|
||||
countryOfOrigin = countryOfOrigin,
|
||||
isAdult = isAdult,
|
||||
onList = onList,
|
||||
page = page,
|
||||
perPage = perPage,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
@@ -17,149 +18,434 @@ import okhttp3.Response
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
|
||||
/**
|
||||
* WebView-based Cloudflare bypass interceptor.
|
||||
*
|
||||
* Launches an Android [WebView] on the main thread, loads the challenged URL,
|
||||
* and waits for Cloudflare to issue `cf_clearance` (and `__cf_bm`) cookies
|
||||
* before retrying the original request with those cookies attached.
|
||||
*
|
||||
* This is the only reliable way to bypass modern Cloudflare managed challenges
|
||||
* and Turnstile widgets on Android — headless solvers cannot reproduce the
|
||||
* browser fingerprint signals Cloudflare now collects (real canvas rendering,
|
||||
* WebGL viewport, pointer events, focus state, etc.).
|
||||
*
|
||||
* **Design choices:**
|
||||
* - **Per-host locks** ([hostLocks]) deduplicate concurrent solves for the same
|
||||
* host. Non-contending hosts proceed in parallel; contending callers wait
|
||||
* for the winner and reuse its cookies.
|
||||
* - **In-memory cookie cache** ([CookieCache]) with 25-minute TTL avoids
|
||||
* burning a fresh WebView for every request during a single user session.
|
||||
* `cf_clearance` is bound to TLS/JA3+UA+IP, so it is intentionally **not**
|
||||
* persisted across process death — see plan §final-confirmation #2.
|
||||
* - **Caller UA passthrough** — uses `request.header("User-Agent")` if present
|
||||
* (falling back to a current Chrome 131 / Windows UA). Cloudflare re-challenges
|
||||
* sessions that flip UA between the original request and the WebView, so this
|
||||
* is critical.
|
||||
* - **`cf-mitigated: challenge` header detection** alongside the legacy
|
||||
* `Server: cloudflare` + 403/503 signaling, for modern CF responses.
|
||||
* - **Cookie sync to the OkHttp client** uses the **real** request URL
|
||||
* (scheme + host + path), not a fake `http://` URL — the old code's cookies
|
||||
* were silently discarded.
|
||||
*
|
||||
* **Caller contract:** add this as a network interceptor so that the WebView
|
||||
* solve and the post-solve retry both flow through the rest of the chain
|
||||
* (zstd decompression, rate limiting, etc.). Adding as an app interceptor
|
||||
* causes the retry to bypass downstream network interceptors.
|
||||
*
|
||||
* @param client the OkHttpClient whose [cookieJar][OkHttpClient.cookieJar]
|
||||
* will receive the WebView-acquired cookies so they persist for the session
|
||||
* within OkHttp's own cookie storage (not just this interceptor's cache)
|
||||
* @param userAgent canonical User-Agent string to use when the request itself
|
||||
* does not carry one. Must be a real, current Chrome version to keep the
|
||||
* TLS/JA3 fingerprint and `sec-ch-ua` brand list coherent with the rest of
|
||||
* the session.
|
||||
*/
|
||||
class CloudflareInterceptor(
|
||||
private val client: OkHttpClient,
|
||||
private val userAgent: String = DEFAULT_USER_AGENT,
|
||||
) : Interceptor {
|
||||
|
||||
class CloudflareInterceptor(private val client: OkHttpClient) : Interceptor {
|
||||
private val context: Application by injectLazy()
|
||||
private val handler by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
@Synchronized
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val originalResponse = chain.proceed(originalRequest)
|
||||
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
|
||||
// if Cloudflare anti-bot didn't block it, then do nothing and return it
|
||||
if (!(originalResponse.code in ERROR_CODES && originalResponse.header("Server") in SERVER_CHECK)) {
|
||||
if (!isCloudflareChallenge(originalResponse)) {
|
||||
return originalResponse
|
||||
}
|
||||
|
||||
return try {
|
||||
originalResponse.close()
|
||||
val request = resolveWithWebView(originalRequest, client)
|
||||
val url = originalRequest.url
|
||||
val host = url.host
|
||||
|
||||
chain.proceed(request)
|
||||
} catch (e: Exception) {
|
||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||
// we don't crash the entire app
|
||||
throw IOException(e)
|
||||
// Try the cookie cache first — another request may have just solved
|
||||
// for this host. If still valid, retry without burning a WebView.
|
||||
val cachedCookies = cookieCache.getMatching(url)
|
||||
if (cachedCookies != null) {
|
||||
originalResponse.close()
|
||||
val retryRequest = attachCookies(originalRequest, cachedCookies)
|
||||
val retryResponse = chain.proceed(retryRequest)
|
||||
if (!isCloudflareChallenge(retryResponse)) {
|
||||
return retryResponse
|
||||
}
|
||||
retryResponse.close()
|
||||
cookieCache.remove(url)
|
||||
Log.d(TAG, "Cached cf_clearance for $host was stale, re-solving via WebView")
|
||||
}
|
||||
|
||||
// Per-host lock so concurrent requests for the same host wait for a
|
||||
// single WebView solve instead of each launching their own. Hosts that
|
||||
// don't contend proceed immediately.
|
||||
val lock = hostLocks.getOrPut(host) { ReentrantLock() }
|
||||
val acquired = lock.tryLock()
|
||||
if (!acquired) {
|
||||
// Another thread is solving for this host. Wait for it, polling
|
||||
// the cache. This blocks the OkHttp dispatcher thread, but only
|
||||
// while a solve is in flight — better than queuing serially via
|
||||
// @Synchronized (which serialized ALL hosts).
|
||||
originalResponse.close()
|
||||
Log.d(TAG, "Waiting for in-flight WebView solve for $host")
|
||||
val deadline = System.currentTimeMillis() + SOLVE_WAIT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val retry = cookieCache.getMatching(url)
|
||||
if (retry != null) {
|
||||
return chain.proceed(attachCookies(originalRequest, retry))
|
||||
}
|
||||
try {
|
||||
Thread.sleep(POLL_INTERVAL_MS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
// Still no cookies — retry the original; CF will likely re-challenge
|
||||
// and we'll go through the solve path ourselves next time.
|
||||
return chain.proceed(originalRequest)
|
||||
}
|
||||
|
||||
try {
|
||||
originalResponse.close()
|
||||
// Solve with WebView. resolveWithWebView returns a request
|
||||
// decorated with the freshly-acquired cookies.
|
||||
val solvedRequest = resolveWithWebView(originalRequest, url)
|
||||
// Retry through the full chain so downstream interceptors
|
||||
// (zstd decompression, rate limit, etc.) still run on the response.
|
||||
val retryResponse = chain.proceed(solvedRequest)
|
||||
if (isCloudflareChallenge(retryResponse)) {
|
||||
retryResponse.close()
|
||||
throw IOException("Cloudflare WebView solve did not clear challenge for $host")
|
||||
}
|
||||
return retryResponse
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
class CloudflareJSI(private val latch: CountDownLatch) {
|
||||
/**
|
||||
* Launches a WebView on the main thread, loads the challenged URL, polls
|
||||
* for `cf_clearance` in the WebView's cookie jar, and returns a request
|
||||
* decorated with the freshly-acquired cookies.
|
||||
*
|
||||
* **Threading:** the calling OkHttp dispatcher thread blocks on
|
||||
* [completionLatch] while the WebView runs on the main thread. The WebView
|
||||
* instance is created, used, and destroyed entirely on the main thread
|
||||
* (with a [destroyLatch] ack to ensure no use-after-free from the caller).
|
||||
*
|
||||
* **Timeout:** [SOLVE_TIMEOUT_MS] per attempt. The polling script exits
|
||||
* early as soon as `cf_clearance` appears, so most solves complete in ~5s
|
||||
* for managed challenges and ~8-15s for Turnstile.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun resolveWithWebView(originalRequest: Request, url: HttpUrl): Request {
|
||||
val completionLatch = CountDownLatch(1)
|
||||
val jsInterface = CloudflareJSInterface(completionLatch)
|
||||
|
||||
// AtomicReference so the calling thread sees the main-thread assignment
|
||||
// (the annotation @Volatile is not applicable to local variables).
|
||||
// The destroy latch guarantees the WebView is fully gone before we
|
||||
// proceed, fixing the happens-before tear in the old code.
|
||||
val webViewRef = AtomicReference<WebView?>(null)
|
||||
val destroyLatch = CountDownLatch(1)
|
||||
|
||||
val requestUrlString = url.toString()
|
||||
val requestHeaders = originalRequest.headers.toMultimap()
|
||||
.mapValues { it.value.joinToString("; ") }
|
||||
.toMutableMap()
|
||||
// Force the canonical UA into the WebView if the request didn't carry one.
|
||||
requestHeaders.putIfAbsent("User-Agent", userAgent)
|
||||
|
||||
handler.post {
|
||||
val webView = WebView(context)
|
||||
webViewRef.set(webView)
|
||||
try {
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = false
|
||||
userAgentString = requestHeaders["User-Agent"] ?: userAgent
|
||||
}
|
||||
|
||||
// Accept cookies so cf_clearance lands in CookieManager. These
|
||||
// are CookieManager APIs, not WebSettings — calling them on
|
||||
// webView.settings (as the old code did) is an unresolved reference.
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
// Allow third-party cookies — Cloudflare sets the cookie
|
||||
// on its own domain while the page is the target host, but
|
||||
// some CF configurations redirect through challenges.cloudflare.com.
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
|
||||
|
||||
webView.addJavascriptInterface(jsInterface, "CloudflareJSI")
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, pageUrl: String?) {
|
||||
view?.evaluateJavascript(CHECK_SCRIPT) {}
|
||||
}
|
||||
}
|
||||
|
||||
webView.loadUrl(requestUrlString, requestHeaders)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "WebView setup failed for $requestUrlString", e)
|
||||
completionLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the JS interface to signal cf_clearance presence (or timeout).
|
||||
try {
|
||||
completionLatch.await(SOLVE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
|
||||
// Tear down the WebView on the main thread and wait for the ack so
|
||||
// the calling thread never reads a torn-down reference.
|
||||
handler.post {
|
||||
try {
|
||||
webViewRef.get()?.stopLoading()
|
||||
webViewRef.get()?.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "WebView teardown error", e)
|
||||
} finally {
|
||||
webViewRef.set(null)
|
||||
destroyLatch.countDown()
|
||||
}
|
||||
}
|
||||
destroyLatch.await(TEARDOWN_ACK_MS, TimeUnit.MILLISECONDS)
|
||||
|
||||
// Extract cookies from CookieManager and sync into OkHttp + cache.
|
||||
val cookieHeader = CookieManager.getInstance().getCookie(requestUrlString) ?: ""
|
||||
val cookies = cookieHeader.split(";")
|
||||
.mapNotNull { raw ->
|
||||
val parsed = Cookie.parse(url, raw.trim())
|
||||
parsed?.let { it.name to it }
|
||||
}
|
||||
.toMap()
|
||||
|
||||
if (cookies.isEmpty()) {
|
||||
throw IOException("Cloudflare WebView solve produced no cookies for $url")
|
||||
}
|
||||
|
||||
// Sync to the OkHttp client's cookie jar using the REAL url.
|
||||
// The old code used a fake http:// URL which silently discarded cookies.
|
||||
try {
|
||||
client.cookieJar.saveFromResponse(
|
||||
url = url,
|
||||
cookies = cookies.values.toList(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to sync WebView cookies to OkHttp cookie jar", e)
|
||||
}
|
||||
|
||||
// Cache cf_clearance + __cf_bm for cross-request reuse in this session.
|
||||
val cacheableCookies = cookies.entries
|
||||
.filter { (name, _) -> name == "cf_clearance" || name.startsWith("__cf_") || name.startsWith("cf_") }
|
||||
.map { it.value }
|
||||
if (cacheableCookies.isNotEmpty()) {
|
||||
cookieCache.put(url, cacheableCookies)
|
||||
Log.d(TAG, "Cached ${cacheableCookies.size} CF cookies for $url.host (cf_clearance=${cookies.containsKey("cf_clearance")})")
|
||||
}
|
||||
|
||||
if (!cookies.containsKey("cf_clearance")) {
|
||||
throw IOException("Cloudflare WebView solve did not produce cf_clearance for $url (cookies: ${cookies.keys})")
|
||||
}
|
||||
|
||||
return attachCookies(originalRequest, cookies.values.toList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the given cookies to the original request, preserving any
|
||||
* existing request cookies not overridden by the new ones.
|
||||
*/
|
||||
private fun attachCookies(request: Request, cookies: List<Cookie>): Request {
|
||||
val matching = cookies.filter { it.matches(request.url) }
|
||||
val existing = Cookie.parseAll(request.url, request.headers)
|
||||
val merged = buildList {
|
||||
// Keep existing cookies whose names are not overridden by cached ones.
|
||||
addAll(
|
||||
existing.filter { existingCookie ->
|
||||
matching.none { it.name == existingCookie.name }
|
||||
},
|
||||
)
|
||||
addAll(matching)
|
||||
}
|
||||
return request.newBuilder()
|
||||
.header("Cookie", merged.joinToString("; ") { "${it.name}=${it.value}" })
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun isCloudflareChallenge(response: Response): Boolean {
|
||||
val code = response.code
|
||||
val server = response.header("Server")
|
||||
val mitigated = response.header("cf-mitigated")
|
||||
val isError = code in ERROR_CODES
|
||||
val isCfServer = server in SERVER_CHECK
|
||||
// Legacy / nginx marker: code in {403,503} AND Server: cloudflare[-nginx]
|
||||
if (isError && isCfServer) return true
|
||||
// Modern CF managed challenge: returns 403 with `cf-mitigated: challenge`
|
||||
// even when Server isn't exactly "cloudflare-nginx"
|
||||
if (isError && mitigated.equals("challenge", ignoreCase = true)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* JS interface that the [CHECK_SCRIPT] polls to signal challenge completion.
|
||||
*/
|
||||
class CloudflareJSInterface(private val latch: CountDownLatch) {
|
||||
@JavascriptInterface
|
||||
fun leave() = latch.countDown()
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun resolveWithWebView(request: Request, client: OkHttpClient): Request {
|
||||
// We need to lock this thread until the WebView finds the challenge solution url, because
|
||||
// OkHttp doesn't support asynchronous interceptors.
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val jsinterface = CloudflareJSI(latch)
|
||||
|
||||
var webView: WebView? = null
|
||||
|
||||
val origRequestUrl = request.url.toString()
|
||||
val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap()
|
||||
|
||||
handler.post {
|
||||
val webview = WebView(context)
|
||||
webView = webview
|
||||
with(webview.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = false
|
||||
userAgentString = request.header("User-Agent")
|
||||
?: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
webview.addJavascriptInterface(jsinterface, "CloudflareJSI")
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view?.evaluateJavascript(CHECK_SCRIPT) {}
|
||||
}
|
||||
}
|
||||
|
||||
webview.loadUrl(origRequestUrl, headers)
|
||||
}
|
||||
|
||||
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
|
||||
// around 4 seconds but it can take more due to slow networks or server issues.
|
||||
latch.await(30, TimeUnit.SECONDS)
|
||||
|
||||
handler.post {
|
||||
webView?.stopLoading()
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
}
|
||||
|
||||
val cookies = CookieManager.getInstance()
|
||||
?.getCookie(origRequestUrl)
|
||||
?.split(";")
|
||||
?.mapNotNull { Cookie.parse(request.url, it) }
|
||||
?: emptyList<Cookie>()
|
||||
|
||||
// Copy webview cookies to OkHTTP cookie storage
|
||||
cookies.forEach {
|
||||
client.cookieJar.saveFromResponse(
|
||||
url = HttpUrl.Builder()
|
||||
.scheme("http")
|
||||
.host(it.domain)
|
||||
.build(),
|
||||
cookies = cookies,
|
||||
)
|
||||
}
|
||||
|
||||
return createRequestWithCookies(request, cookies)
|
||||
}
|
||||
|
||||
private fun createRequestWithCookies(request: Request, cookies: List<Cookie>): Request {
|
||||
val convertedForThisRequest = cookies.filter {
|
||||
it.matches(request.url)
|
||||
}
|
||||
val existingCookies = Cookie.parseAll(
|
||||
request.url,
|
||||
request.headers,
|
||||
)
|
||||
val filteredExisting = existingCookies.filter { existing ->
|
||||
convertedForThisRequest.none { converted -> converted.name == existing.name }
|
||||
}
|
||||
|
||||
val newCookies = filteredExisting + convertedForThisRequest
|
||||
return request.newBuilder()
|
||||
.header("Cookie", newCookies.joinToString("; ") { "${it.name}=${it.value}" })
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CloudflareInterceptor"
|
||||
|
||||
private val ERROR_CODES = listOf(403, 503)
|
||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||
|
||||
// ref: https://github.com/vvanglro/cf-clearance/blob/0d3455b5b4f299b131f357dd6e0a27316cf26f9a/cf_clearance/retry.py#L15
|
||||
/** Canonical User-Agent: Chrome 131 / Windows — matches the
|
||||
* WebView Chromium engine (the TLS stack carrying cf_clearance
|
||||
* solve) when a Windows-shaped UA override is in effect. */
|
||||
internal const val DEFAULT_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
/** Per-attempt WebView solve timeout. CF managed ~5s, Turnstile ~8-15s. */
|
||||
private const val SOLVE_TIMEOUT_MS = 30_000L
|
||||
|
||||
/** How long to wait for another thread's in-flight solve. */
|
||||
private const val SOLVE_WAIT_MS = 30_000L
|
||||
|
||||
/** Cache poll interval while waiting for another thread's solve. */
|
||||
private const val POLL_INTERVAL_MS = 100L
|
||||
|
||||
/** Ack timeout for WebView teardown on main thread. */
|
||||
private const val TEARDOWN_ACK_MS = 2_000L
|
||||
|
||||
/** Per-host solve locks. */
|
||||
private val hostLocks = ConcurrentHashMap<String, ReentrantLock>()
|
||||
|
||||
/** In-session cookie cache shared across all CloudflareInterceptor instances. */
|
||||
internal val cookieCache = CloudflareCookieCache()
|
||||
|
||||
/**
|
||||
* JS injected on every `onPageFinished`. Polls for `cf_clearance` cookie
|
||||
* presence in `document.cookie` — this works *across origins* because
|
||||
* `document.cookie` is same-origin to the loaded page (miruro.tv), unlike
|
||||
* the old code which reached into the cross-origin Turnstile iframe
|
||||
* (challenges.cloudflare.com) and tripped a SecurityError.
|
||||
*
|
||||
* When either (a) `cf_clearance` appears, or (b) the challenge form has
|
||||
* been removed from the DOM (managed-challenge success redirects away),
|
||||
* we signal completion via `CloudflareJSI.leave()`.
|
||||
*
|
||||
* We do NOT attempt to programmatically click the Turnstile checkbox —
|
||||
* that lives in a cross-origin iframe and the click can't be trucked
|
||||
* from JS. CF's own challenge script handles interaction (or auto-passes
|
||||
* after the bot-detection signals clear). The WebView just needs to load
|
||||
* and let CF's JS run; for many sites the managed challenge auto-passes
|
||||
* once CF's fingerprinting completes in a real browser engine.
|
||||
*/
|
||||
private val CHECK_SCRIPT by lazy {
|
||||
"""
|
||||
setInterval(() => {
|
||||
if (document.querySelector("#challenge-form") != null) {
|
||||
// still havent passed, lets try to click in some challenges
|
||||
const simpleChallenge = document.querySelector("#challenge-stage > div > input[type='button']")
|
||||
if (simpleChallenge != null) simpleChallenge.click()
|
||||
|
||||
const turnstile = document.querySelector("div.hcaptcha-box > iframe")
|
||||
if (turnstile != null) {
|
||||
const button = turnstile.contentWindow.document.querySelector("input[type='checkbox']")
|
||||
if (button != null) button.click()
|
||||
(function() {
|
||||
var attempts = 0;
|
||||
var maxAttempts = ${SOLVE_TIMEOUT_MS} / 500;
|
||||
var timer = setInterval(function() {
|
||||
attempts++;
|
||||
try {
|
||||
var cookie = document.cookie || '';
|
||||
var hasClearance = cookie.indexOf('cf_clearance=') !== -1;
|
||||
var noChallengeForm = !document.querySelector('#challenge-form')
|
||||
&& !document.querySelector('#challenge-stage')
|
||||
&& !document.querySelector('.cf-turnstile');
|
||||
if (hasClearance || (noChallengeForm && attempts > 2)) {
|
||||
clearInterval(timer);
|
||||
try { CloudflareJSI.leave(); } catch (e) {}
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(timer);
|
||||
try { CloudflareJSI.leave(); } catch (e) {}
|
||||
}
|
||||
} catch (e) {
|
||||
// Swallow — keep polling. A SecurityError on one tick
|
||||
// doesn't mean the next tick will also fail.
|
||||
}
|
||||
} else {
|
||||
// passed
|
||||
CloudflareJSI.leave()
|
||||
}
|
||||
}, 2500)
|
||||
}, 500);
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory Cloudflare cookie cache keyed by host, with TTL derived from
|
||||
* each cookie's own `expiresAt` (capped to [DEFAULT_TTL_MILLIS]).
|
||||
*
|
||||
* Cookies are intentionally **not** persisted to SharedPreferences: `cf_clearance`
|
||||
* is bound to the TLS session's JA3 + UA + client IP, and on a fresh process
|
||||
* those may differ (the WebView Chromium TLS may have rotated, the device may
|
||||
* have roamed networks), so a stale-but-not-expired `cf_clearance` causes CF to
|
||||
* issue a *harder* challenge on retry. Keeping cookies in-process only means
|
||||
* each new process solves once and then reuses for the session.
|
||||
*/
|
||||
internal class CloudflareCookieCache {
|
||||
|
||||
private data class HostEntry(
|
||||
val cookies: List<Cookie>,
|
||||
val storedAt: Long,
|
||||
)
|
||||
|
||||
private val store = ConcurrentHashMap<String, HostEntry>()
|
||||
|
||||
fun put(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val nonSession = cookies.filter { it.persistent }
|
||||
if (nonSession.isEmpty()) return
|
||||
store[url.host] = HostEntry(nonSession, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
fun getMatching(url: HttpUrl): List<Cookie>? {
|
||||
val entry = store[url.host] ?: return null
|
||||
val now = System.currentTimeMillis()
|
||||
val valid = entry.cookies.filter { it.expiresAt > now }
|
||||
if (valid.isEmpty()) {
|
||||
store.remove(url.host)
|
||||
return null
|
||||
}
|
||||
val matching = valid.filter { it.matches(url) }
|
||||
return matching.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun remove(url: HttpUrl) {
|
||||
store.remove(url.host)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Default TTL cap: 25 minutes (cf_clearance is ~30 min from CF). */
|
||||
internal const val DEFAULT_TTL_MILLIS = 25L * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,14 +77,16 @@ class DopeFlixExtractor(
|
||||
.execute().use { it.body.string() }
|
||||
val data = json.decodeFromString<SourceResponseDto>(srcRes)
|
||||
|
||||
val key by lazy { requestNewKey() }
|
||||
|
||||
return data.sources.map { source ->
|
||||
return data.sources.mapNotNull { source ->
|
||||
val encoded = source.file
|
||||
|
||||
val m3u8: String = if (!data.encrypted || ".m3u8" in encoded) {
|
||||
encoded
|
||||
} else {
|
||||
val key = requestNewKeyOrNull() ?: run {
|
||||
Log.w("MegaCloudExtractor", "Skipping encrypted source — decryption keys unavailable")
|
||||
return@mapNotNull null
|
||||
}
|
||||
val fullUrl = buildString {
|
||||
append(megaCloudAPI)
|
||||
append("?encrypted_data=").append(URLEncoder.encode(encoded, "UTF-8"))
|
||||
@@ -104,17 +106,27 @@ class DopeFlixExtractor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNewKey(): String = client.newCall(GET("https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json"))
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch keys.json")
|
||||
val jsonStr = response.body.string()
|
||||
if (jsonStr.isEmpty()) throw IllegalStateException("keys.json is empty")
|
||||
val key = json.decodeFromString<Map<String, String>>(jsonStr)["mega"]
|
||||
?: throw IllegalStateException("Mega key not found in keys.json")
|
||||
Log.d("MegaCloudExtractor", "Using Mega Key: $key")
|
||||
key
|
||||
}
|
||||
private fun requestNewKeyOrNull(): String? = try {
|
||||
client.newCall(GET("https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json"))
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
Log.w("MegaCloudExtractor", "keys.json fetch failed: HTTP ${response.code}")
|
||||
return@use null
|
||||
}
|
||||
val jsonStr = response.body.string()
|
||||
if (jsonStr.isEmpty()) {
|
||||
Log.w("MegaCloudExtractor", "keys.json is empty")
|
||||
return@use null
|
||||
}
|
||||
json.decodeFromString<Map<String, String>>(jsonStr)["mega"]?.also {
|
||||
Log.d("MegaCloudExtractor", "Using Mega Key: $it")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("MegaCloudExtractor", "requestNewKey failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class VideoDto(
|
||||
|
||||
9
lib/kwikextractor/build.gradle.kts
Normal file
9
lib/kwikextractor/build.gradle.kts
Normal file
@@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
alias(kei.plugins.library)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:cloudflareinterceptor"))
|
||||
implementation(project(":lib:unpacker"))
|
||||
implementation(project(":lib:playlistutils"))
|
||||
}
|
||||
172
lib/kwikextractor/src/aniyomi/lib/kwikextractor/KwikExtractor.kt
Normal file
172
lib/kwikextractor/src/aniyomi/lib/kwikextractor/KwikExtractor.kt
Normal file
@@ -0,0 +1,172 @@
|
||||
package aniyomi.lib.kwikextractor
|
||||
|
||||
import android.util.Log
|
||||
import aniyomi.lib.cloudflareinterceptor.CloudflareInterceptor
|
||||
import aniyomi.lib.playlistutils.PlaylistUtils
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import keiyoushi.lib.jsunpacker.JsUnpacker
|
||||
import keiyoushi.utils.bodyString
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
/**
|
||||
* Extracts video URLs from kwik.cx embed pages.
|
||||
*
|
||||
* kwik.cx is behind Cloudflare WAF. This extractor uses OkHttp (with
|
||||
* [CloudflareInterceptor] wired in) to fetch the embed page, unpack the
|
||||
* Dean Edwards packed JavaScript, and extract the m3u8 URL. If Cloudflare
|
||||
* blocks the initial request, the interceptor transparently solves the
|
||||
* challenge via WebView and retries with clearance cookies.
|
||||
*/
|
||||
class KwikExtractor(
|
||||
client: OkHttpClient,
|
||||
private val headers: Headers,
|
||||
) {
|
||||
|
||||
private val kwikClient: OkHttpClient = client.newBuilder()
|
||||
.addInterceptor(CloudflareInterceptor(client))
|
||||
.build()
|
||||
|
||||
private val playlistUtils by lazy { PlaylistUtils(kwikClient, headers) }
|
||||
|
||||
fun videosFromUrl(
|
||||
url: String,
|
||||
prefix: String = "",
|
||||
subtitleList: List<Track> = emptyList(),
|
||||
): List<Video> {
|
||||
Log.d(TAG, "videosFromUrl: starting extraction for ${url.take(120)}")
|
||||
|
||||
val qualityLabel = prefix.takeIf(String::isNotBlank)?.let { "$it " } ?: ""
|
||||
|
||||
return try {
|
||||
val videoUrl = getHlsStreamUrl(url)
|
||||
val videoHeaders = Headers.Builder()
|
||||
.set("Referer", "https://kwik.cx/")
|
||||
.set("Origin", "https://kwik.cx")
|
||||
.build()
|
||||
|
||||
when {
|
||||
"m3u8" in videoUrl -> {
|
||||
Log.d(TAG, "Extracting HLS from ${videoUrl.take(100)}")
|
||||
playlistUtils.extractFromHls(
|
||||
playlistUrl = videoUrl,
|
||||
referer = "https://kwik.cx/",
|
||||
masterHeaders = videoHeaders,
|
||||
videoHeaders = videoHeaders,
|
||||
videoNameGen = { "${qualityLabel}Kwik - $it" },
|
||||
subtitleList = subtitleList,
|
||||
)
|
||||
}
|
||||
else -> listOf(
|
||||
Video(videoUrl, "${qualityLabel}Kwik", videoUrl, videoHeaders, subtitleTracks = subtitleList),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Extraction failed: ${e.message}", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// ========================= HLS Extraction =========================
|
||||
|
||||
private fun getHlsStreamUrl(kwikUrl: String): String {
|
||||
Log.d(TAG, "getHlsStreamUrl: $kwikUrl")
|
||||
|
||||
// Step 1: Fetch the /e/ page via OkHttp (CF interceptor handles challenges)
|
||||
val html = fetchEPage(kwikUrl)
|
||||
|
||||
// Step 2: Parse packed JavaScript and extract m3u8 URL
|
||||
return parseHlsFromHtml(html, kwikUrl)
|
||||
}
|
||||
|
||||
private fun fetchEPage(kwikUrl: String): String {
|
||||
kwikClient.newCall(GET(kwikUrl, buildEPageHeaders())).execute().use { response ->
|
||||
val body = response.bodyString()
|
||||
if (body.contains("eval(function(")) {
|
||||
Log.d(TAG, "Fetch succeeded (${body.length} bytes)")
|
||||
return body
|
||||
}
|
||||
throw ExtractionException("kwik.cx returned page without packed scripts (${body.length} bytes, HTTP ${response.code})")
|
||||
}
|
||||
}
|
||||
|
||||
// ========================= HTML Parsing =========================
|
||||
|
||||
private fun parseHlsFromHtml(html: String, baseUrl: String): String {
|
||||
val document = Jsoup.parse(html, baseUrl)
|
||||
val packedScripts = document.select("script:containsData(eval\\(function)")
|
||||
|
||||
if (packedScripts.isEmpty()) {
|
||||
throw ExtractionException("No packed scripts found (${html.length} bytes)")
|
||||
}
|
||||
|
||||
for (script in packedScripts) {
|
||||
val scriptData = script.data()
|
||||
val evalPositions = mutableListOf<Int>()
|
||||
var searchFrom = 0
|
||||
while (true) {
|
||||
val idx = scriptData.indexOf("eval(function(", searchFrom)
|
||||
if (idx == -1) break
|
||||
evalPositions.add(idx)
|
||||
searchFrom = idx + 1
|
||||
}
|
||||
|
||||
for (pos in evalPositions.reversed()) {
|
||||
val packedContent = scriptData.substring(pos + "eval(".length)
|
||||
if (packedContent.isBlank()) continue
|
||||
try {
|
||||
JsUnpacker.unpackAndCombine("eval($packedContent")?.let { unpacked ->
|
||||
extractM3u8FromUnpacked(unpacked)?.let { return it }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unpack failed at pos $pos: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw ExtractionException("Could not extract m3u8 from kwik.cx page")
|
||||
}
|
||||
|
||||
private fun extractM3u8FromUnpacked(unpacked: String): String? {
|
||||
val patterns = listOf(
|
||||
"const source=\\'" to "\\';",
|
||||
"var source=\\'" to "\\';",
|
||||
"const source='" to "';",
|
||||
"var source='" to "';",
|
||||
"const source=\"" to "\";",
|
||||
"var source=\"" to "\";",
|
||||
)
|
||||
for ((prefix, suffix) in patterns) {
|
||||
unpacked.substringAfter(prefix).substringBefore(suffix).let { url ->
|
||||
if (url.startsWith("http") && ".m3u8" in url) return url
|
||||
}
|
||||
}
|
||||
return M3U8_REGEX.find(unpacked)?.groupValues?.get(1)
|
||||
}
|
||||
|
||||
// ========================= Headers =========================
|
||||
|
||||
private fun buildEPageHeaders(): Headers = Headers.Builder()
|
||||
.set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
.set("Accept-Language", "en-US,en;q=0.9")
|
||||
.set("Sec-Fetch-Dest", "iframe")
|
||||
.set("Sec-Fetch-Mode", "navigate")
|
||||
.set("Sec-Fetch-Site", "cross-site")
|
||||
.set("Sec-Fetch-User", "?1")
|
||||
.set("Upgrade-Insecure-Requests", "1")
|
||||
.build()
|
||||
|
||||
// ========================= Types =========================
|
||||
|
||||
private class ExtractionException(message: String) : Exception(message)
|
||||
|
||||
// ========================= Companion =========================
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KwikExtractor"
|
||||
private val M3U8_REGEX = Regex("""(https?://[^\s\\'"]+\.m3u8[^\s\\'"]*?)""")
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,32 @@ object AutoDetector {
|
||||
private const val MPEG_TS_PACKET_SIZE = 188
|
||||
|
||||
/**
|
||||
* Automatically detects how many bytes to skip at the beginning of the file
|
||||
* Standard length of a single fake-header junk block. The obfuscators that
|
||||
* inject fake image magic bytes (ibyteimg / tiktokcdn variants observed in
|
||||
* ChillX-style streams) prepend a fixed 252-byte disguise chunk before
|
||||
* resuming the real format. This value is matched by [JunkBytesInterceptor]
|
||||
* in lib-multisrc/anikototheme and serves as the default junk-block length
|
||||
* when we can't positively identify where the junk ends.
|
||||
*/
|
||||
private const val DEFAULT_JUNK_BLOCK_SIZE = 252
|
||||
|
||||
/**
|
||||
* Maximum distance from a junk-magic header to search for the next valid
|
||||
* video format boundary when determining the end of the junk block. Capped
|
||||
* at 8 KB so a stray magic header embedded inside a long real segment does
|
||||
* not blow up the scan into a buffer-size chase.
|
||||
*/
|
||||
private const val JUNK_END_SEARCH_LIMIT = 8 * 1024
|
||||
|
||||
/**
|
||||
* Automatically detects how many bytes to skip at the beginning of the file.
|
||||
*
|
||||
* NOTE: This is the legacy prefix-only entry point — it inspects only the
|
||||
* leading portion of the buffer and returns a single offset. For segments
|
||||
* potentially containing junk blocks interleaved past the first 4 KB (the
|
||||
* ChillX-style obfuscation pattern), use [detectInterleavedSkips], which
|
||||
* scans the entire buffer and returns a list of regions to strip.
|
||||
*
|
||||
* @param data File data (first 4KB is sufficient)
|
||||
* @return Number of bytes to skip
|
||||
*/
|
||||
@@ -38,6 +63,180 @@ object AutoDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the ENTIRE buffer for fake-image junk blocks (JPEG / PNG / GIF magic
|
||||
* bytes) inserted at any offset to disguise the underlying video stream, and
|
||||
* returns the sorted, non-overlapping list of byte ranges that should be
|
||||
* stripped from the buffer before the result is served to the player.
|
||||
*
|
||||
* Each returned [IntRange] is an inclusive-inclusive `[start, end]` pair
|
||||
* (Kotlin's default `IntRange` semantics) covering bytes that are垃圾 data
|
||||
* and must NOT be forwarded. Callers re-assemble the segment by keeping
|
||||
* every byte NOT covered by any returned range, in order.
|
||||
*
|
||||
* ### Detection algorithm
|
||||
*
|
||||
* For every offset `i` in the buffer whose 3-4 byte prefix matches JPEG /
|
||||
* PNG / GIF magic, the detector walks forward up to [JUNK_END_SEARCH_LIMIT]
|
||||
* bytes looking for a valid **video format boundary**:
|
||||
* * MPEG-TS:a 0x47 sync byte followed by at least 2 more 0x47 bytes at
|
||||
* 188-byte strides (validated by [isMpegTsValidAt])
|
||||
* * MP4:a 4-byte big-endian size followed by "ftyp"
|
||||
* * AVI:a "RIFF" marker immediately followed by "AVI "
|
||||
*
|
||||
* If a boundary is found within the search window, the junk block ends
|
||||
* just before that boundary. If no boundary is found, the junk block is
|
||||
* assumed to be a fixed [DEFAULT_JUNK_BLOCK_SIZE] bytes long (matching the
|
||||
* pattern produced by the most-common ibyteimg/tiktokcdn injectors).
|
||||
*
|
||||
* Junk blocks that overlap (or that fall entirely inside another junk
|
||||
* region) are merged. Ranges starting past the buffer end are dropped.
|
||||
*
|
||||
* ### Why full-buffer scan
|
||||
*
|
||||
* The previous [detectSkipBytes] path only inspected the first 4 KB of a
|
||||
* segment (the size of the single read performed by the legacy
|
||||
* fetchSegmentWithAutoDetection path). ChillX-style obfuscators
|
||||
* insert additional junk blocks deeper into the segment — past the 4 KB
|
||||
* prefix — so the legacy path silently missed them. This entry point
|
||||
* operates on the full downloaded segment buffer.
|
||||
*
|
||||
* @param data Full segment buffer (NOT truncated to 4 KB)
|
||||
* @return Sorted, non-overlapping [IntRange]s of junk bytes to strip.
|
||||
* Empty list if no junk is detected.
|
||||
*/
|
||||
fun detectInterleavedSkips(data: ByteArray): List<IntRange> {
|
||||
if (data.isEmpty()) return emptyList()
|
||||
|
||||
val regions = mutableListOf<IntRange>()
|
||||
|
||||
var i = 0
|
||||
while (i < data.size - 2) {
|
||||
val junkEnd = detectJunkBlockEnd(data, i)
|
||||
if (junkEnd >= 0) {
|
||||
if (junkEnd > i) {
|
||||
regions.add(i until junkEnd)
|
||||
}
|
||||
i = junkEnd
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return mergeRegions(regions)
|
||||
}
|
||||
|
||||
/**
|
||||
* If [offset] looks like the start of a fake-image junk block, returns
|
||||
* the EXCLUSIVE end offset (i.e. the index of the first byte AFTER the
|
||||
* junk block). Returns -1 if [offset] is not a junk-magic header.
|
||||
*
|
||||
* The returned end is always `> offset` for a real junk block, and is
|
||||
* clamped to [data.size].
|
||||
*/
|
||||
private fun detectJunkBlockEnd(data: ByteArray, offset: Int): Int {
|
||||
val isJpeg = offset + 2 < data.size &&
|
||||
data[offset] == JPEG_HEADER[0] &&
|
||||
data[offset + 1] == JPEG_HEADER[1] &&
|
||||
data[offset + 2] == JPEG_HEADER[2]
|
||||
val isPng = offset + 3 < data.size &&
|
||||
data[offset] == PNG_HEADER[0] &&
|
||||
data[offset + 1] == PNG_HEADER[1] &&
|
||||
data[offset + 2] == PNG_HEADER[2] &&
|
||||
data[offset + 3] == PNG_HEADER[3]
|
||||
val isGif = offset + 2 < data.size &&
|
||||
data[offset] == GIF_HEADER[0] &&
|
||||
data[offset + 1] == GIF_HEADER[1] &&
|
||||
data[offset + 2] == GIF_HEADER[2]
|
||||
|
||||
if (!isJpeg && !isPng && !isGif) return -1
|
||||
|
||||
val searchEnd = minOf(data.size, offset + JUNK_END_SEARCH_LIMIT)
|
||||
|
||||
var i = offset + 1
|
||||
while (i < searchEnd) {
|
||||
if (i + 8 <= searchEnd && isFtypAt(data, i)) {
|
||||
return i
|
||||
}
|
||||
if (i + 12 <= searchEnd && isRiffAviAt(data, i)) {
|
||||
return i
|
||||
}
|
||||
if (data[i] == MPEG_TS_SYNC && isMpegTsValidAt(data, i)) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return minOf(data.size, offset + DEFAULT_JUNK_BLOCK_SIZE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an MP4 "ftyp" atom begins at [offset]. The 4-byte size at
|
||||
* [offset, offset+3] is intentionally NOT validated. Caller MUST ensure
|
||||
* `offset + 8 <= data.size` before invoking.
|
||||
*/
|
||||
private fun isFtypAt(data: ByteArray, offset: Int): Boolean = data[offset + 4] == MP4_FTYP[0] &&
|
||||
data[offset + 5] == MP4_FTYP[1] &&
|
||||
data[offset + 6] == MP4_FTYP[2] &&
|
||||
data[offset + 7] == MP4_FTYP[3]
|
||||
|
||||
/**
|
||||
* Returns true if a RIFF/AVI magic block begins at [offset]. Caller MUST
|
||||
* ensure `offset + 12 <= data.size` before invoking.
|
||||
*/
|
||||
private fun isRiffAviAt(data: ByteArray, offset: Int): Boolean = data[offset] == AVI_RIFF[0] &&
|
||||
data[offset + 1] == AVI_RIFF[1] &&
|
||||
data[offset + 2] == AVI_RIFF[2] &&
|
||||
data[offset + 3] == AVI_RIFF[3] &&
|
||||
data[offset + 8] == AVI_AVI[0] &&
|
||||
data[offset + 9] == AVI_AVI[1] &&
|
||||
data[offset + 10] == AVI_AVI[2] &&
|
||||
data[offset + 11] == AVI_AVI[3]
|
||||
|
||||
/**
|
||||
* Validates a MPEG-TS sync grid starting at [start]: checks for at least
|
||||
* 2 consecutive 188-stride 0x47 sync bytes within the next 1 KB, without
|
||||
* requiring the entire buffer to be MPEG-TS-aligned. Used to confirm a
|
||||
* video format boundary after a junk block.
|
||||
*/
|
||||
private fun isMpegTsValidAt(data: ByteArray, start: Int): Boolean {
|
||||
if (start + MPEG_TS_PACKET_SIZE > data.size) return false
|
||||
if (data[start] != MPEG_TS_SYNC) return false
|
||||
var valid = 0
|
||||
var j = start
|
||||
val ceiling = minOf(data.size, start + 1024)
|
||||
while (j < ceiling) {
|
||||
if (j + MPEG_TS_PACKET_SIZE <= data.size && data[j] == MPEG_TS_SYNC) {
|
||||
valid++
|
||||
}
|
||||
j += MPEG_TS_PACKET_SIZE
|
||||
}
|
||||
return valid >= 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges overlapping or adjacent junk regions into a sorted, non-overlapping
|
||||
* list. Adjacent regions (the end of one is the start of the next) are
|
||||
* merged too — strips two back-to-back junk blocks as one continuous gap.
|
||||
*/
|
||||
private fun mergeRegions(regions: List<IntRange>): List<IntRange> {
|
||||
if (regions.isEmpty()) return emptyList()
|
||||
val sorted = regions.sortedBy { it.first }
|
||||
val merged = mutableListOf<IntRange>()
|
||||
var current = sorted.first()
|
||||
for (i in 1 until sorted.size) {
|
||||
val next = sorted[i]
|
||||
if (next.first <= current.last + 1) {
|
||||
current = current.first..maxOf(current.last, next.last)
|
||||
} else {
|
||||
merged.add(current)
|
||||
current = next
|
||||
}
|
||||
}
|
||||
merged.add(current)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if it's a valid MPEG-TS stream
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -17,14 +18,31 @@ import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Real HTTP server for M3U8 processing using NanoHTTPD
|
||||
* Compatible with Android and provides actual HTTP endpoints
|
||||
*
|
||||
* @param client the OkHttpClient used for all upstream fetches. May carry a
|
||||
* Cloudflare-solve interceptor; if so, supply [fallbackClient] so a failed
|
||||
* WebView solve does not hard-crash the m3u8 path.
|
||||
* @param fallbackClient optional secondary client consulted when [client]
|
||||
* throws an IOException that looks like a Cloudflare-solve failure (the
|
||||
* canonical case is `lib/cloudflareinterceptor` raising "Cloudflare
|
||||
* WebView solve produced no cookies"). The fallback is expected to have
|
||||
* no CF interceptor and to use HTTP/1.1; it lets the request flow
|
||||
* through with whatever browser-fingerprint headers the caller attached
|
||||
* so a header-gated CDN can still be served. May be null — when null,
|
||||
* solve failures bubble up as `UpstreamStatusException(503, …)`.
|
||||
*/
|
||||
class M3u8HttpServer(
|
||||
private val client: OkHttpClient,
|
||||
port: Int = 0, // 0 means random port
|
||||
private val fallbackClient: OkHttpClient? = null,
|
||||
) : NanoHTTPD(port) {
|
||||
|
||||
val port: Int
|
||||
@@ -76,7 +94,9 @@ class M3u8HttpServer(
|
||||
|
||||
private fun handleM3u8Request(session: IHTTPSession): Response {
|
||||
val url = session.parameters["url"]?.first()
|
||||
val headers = extractHeadersFromSession(session)
|
||||
val fallbackReferer = session.parameters["referer"]?.first()
|
||||
val fallbackUserAgent = session.parameters["useragent"]?.first()
|
||||
val headers = extractHeadersFromSession(session, fallbackReferer, fallbackUserAgent)
|
||||
|
||||
Log.d(tag, "Processing M3U8 request for URL: $url")
|
||||
Log.d(tag, "Headers: $headers")
|
||||
@@ -91,6 +111,9 @@ class M3u8HttpServer(
|
||||
val processedContent = runBlocking { processM3u8Content(url, headers) }
|
||||
Log.d(tag, "M3U8 processing completed successfully, content length: ${processedContent.length}")
|
||||
newFixedLengthResponse(Status.OK, "application/vnd.apple.mpegurl", processedContent)
|
||||
} catch (e: UpstreamStatusException) {
|
||||
Log.w(tag, "Upstream HTTP ${e.code} for $url: ${e.message}")
|
||||
passThroughStatus(e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error processing M3U8: ${e.message}", e)
|
||||
newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Error: ${e.message}")
|
||||
@@ -99,9 +122,13 @@ class M3u8HttpServer(
|
||||
|
||||
private fun handleSegmentRequest(session: IHTTPSession): Response {
|
||||
val url = session.parameters["url"]?.first()
|
||||
val headers = extractHeadersFromSession(session)
|
||||
val keyUrl = session.parameters["key"]?.first()
|
||||
val iv = session.parameters["iv"]?.first()
|
||||
val fallbackReferer = session.parameters["referer"]?.first()
|
||||
val fallbackUserAgent = session.parameters["useragent"]?.first()
|
||||
val headers = extractHeadersFromSession(session, fallbackReferer, fallbackUserAgent)
|
||||
|
||||
Log.d(tag, "Processing segment request for URL: $url")
|
||||
Log.d(tag, "Processing segment request for URL: $url (key=${keyUrl != null}, iv=${iv != null})")
|
||||
Log.d(tag, "Headers: $headers")
|
||||
|
||||
if (url.isNullOrBlank()) {
|
||||
@@ -109,18 +136,46 @@ class M3u8HttpServer(
|
||||
return newFixedLengthResponse(Status.BAD_REQUEST, MIME_PLAINTEXT, "Missing url parameter")
|
||||
}
|
||||
|
||||
val hasAes = keyUrl != null && iv != null
|
||||
return try {
|
||||
Log.d(tag, "Starting segment processing for: $url")
|
||||
val segmentData = runBlocking { processSegmentUrl(url, headers) }
|
||||
Log.d(tag, "Starting segment processing for: $url (aes=$hasAes)")
|
||||
val segmentData = runBlocking {
|
||||
processSegmentUrl(url, headers, keyUrl, iv)
|
||||
}
|
||||
Log.d(tag, "Segment processing completed successfully, data size: ${segmentData.size} bytes")
|
||||
val inputStream = ByteArrayInputStream(segmentData)
|
||||
newChunkedResponse(Status.OK, "video/mp2t", inputStream)
|
||||
} catch (e: UpstreamStatusException) {
|
||||
Log.w(tag, "Upstream segment HTTP ${e.code} for $url: ${e.message}")
|
||||
passThroughStatus(e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error processing segment: ${e.message}", e)
|
||||
newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the [UpstreamStatusException] code in an HTTP response that
|
||||
* surfaces the upstream's status (403, 503, …) to the player instead of
|
||||
* collapsing every failure into [Status.INTERNAL_ERROR]. The body carries
|
||||
* the upstream URL + code so logcat / mpv diagnostics can pin the cause
|
||||
* without re-reading the chain.
|
||||
*/
|
||||
private fun passThroughStatus(e: UpstreamStatusException): Response {
|
||||
val nanoStatus = when (e.code) {
|
||||
401 -> Status.UNAUTHORIZED
|
||||
403 -> Status.FORBIDDEN
|
||||
404 -> Status.NOT_FOUND
|
||||
429 -> Status.TOO_MANY_REQUESTS
|
||||
in 400..499 -> Status.BAD_REQUEST
|
||||
500 -> Status.INTERNAL_ERROR
|
||||
503 -> Status.SERVICE_UNAVAILABLE
|
||||
else -> Status.INTERNAL_ERROR
|
||||
}
|
||||
val body = "Upstream ${e.code} for ${e.url}\n${e.message}"
|
||||
return newFixedLengthResponse(nanoStatus, MIME_PLAINTEXT, body)
|
||||
}
|
||||
|
||||
private fun handleHealthRequest(): Response {
|
||||
Log.d(tag, "Health check requested")
|
||||
val status = getHealthStatus()
|
||||
@@ -129,26 +184,70 @@ class M3u8HttpServer(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers from the HTTP session
|
||||
* Build the upstream-fetch header map. Session headers (forwarded by the
|
||||
* media player when it opens `http://localhost:…/m3u8?url=…`) seed the map;
|
||||
* per-URL attachments from [fallbackReferer]/[fallbackUserAgent] then
|
||||
* OVERRIDE / add their values, so the original extension's `video.headers`
|
||||
* (encoded into the proxied URL by [createLocalUrl]) always win over
|
||||
* whatever ExoPlayer/mpv copied to the localhost request.
|
||||
*
|
||||
* This prioritisation is the root-cause fix for `vault-99.owocdn.top` and
|
||||
* similar Miruro-CDN hosts: the m3u8 server was previously fetching the
|
||||
* upstream with no Referer (mpv does not carry the original `Referer`
|
||||
* through to localhost), and the CDN returned a Cloudflare-signed 403 —
|
||||
* the CloudflareInterceptor then kicked in, attempted a WebView solve, and
|
||||
* when it failed (CDN sets no `cf_clearance` for null-Referer requests),
|
||||
* the m3u8 path crashed with HTTP 500. Encoding the extension's Referer
|
||||
* directly in the proxied URL cuts that whole branch off.
|
||||
*/
|
||||
private fun extractHeadersFromSession(session: IHTTPSession): Map<String, String> {
|
||||
private fun extractHeadersFromSession(
|
||||
session: IHTTPSession,
|
||||
fallbackReferer: String? = null,
|
||||
fallbackUserAgent: String? = null,
|
||||
): Map<String, String> {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
|
||||
// Extract common headers that might be needed for video requests
|
||||
session.headers.forEach { (key, value) ->
|
||||
when (key.lowercase()) {
|
||||
"user-agent", "referer", "origin", "accept", "accept-language",
|
||||
"accept-encoding", "connection", "cache-control", "pragma",
|
||||
-> {
|
||||
headers[key] = value
|
||||
headers[key.lowercase()] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Extracted headers: $headers")
|
||||
if (!fallbackUserAgent.isNullOrBlank()) {
|
||||
headers["user-agent"] = fallbackUserAgent
|
||||
}
|
||||
if (!fallbackReferer.isNullOrBlank()) {
|
||||
headers["referer"] = fallbackReferer
|
||||
}
|
||||
|
||||
Log.d(tag, "Extracted headers (referer=${headers["referer"]?.take(80) ?: "none"}, ua=${headers["user-agent"]?.take(40) ?: "none"})")
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `fetchM3u8Content` / `fetchSegmentBytes` when the upstream
|
||||
* returns a non-2xx HTTP code, OR when both the primary [client] and the
|
||||
* secondary [fallbackClient] (CF-stripped) fail to retrieve the resource.
|
||||
*
|
||||
* The [code] carries the upstream's HTTP status when known; for
|
||||
* transform-layer failures (e.g., a Cloudflare-solve crash with no
|
||||
* response yet observed) it defaults to 503 so callers see a
|
||||
* service-unavailable NanoHTTPD response instead of opaque INTERNAL_ERROR.
|
||||
*
|
||||
* Caught by [handleM3u8Request]/[handleSegmentRequest] → [passThroughStatus]
|
||||
* so mpv receives a meaningful 403/503 (not a `500 Error: …` wrapper).
|
||||
*/
|
||||
private class UpstreamStatusException(
|
||||
val code: Int,
|
||||
val url: String,
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : IOException("$code for $url: $message", cause)
|
||||
|
||||
/**
|
||||
* Process M3U8 content through the server
|
||||
*/
|
||||
@@ -158,32 +257,129 @@ class M3u8HttpServer(
|
||||
val m3u8Content = fetchM3u8Content(url, headers)
|
||||
Log.d(tag, "Original M3U8 content length: ${m3u8Content.length}")
|
||||
|
||||
val modifiedContent = modifyM3u8Content(m3u8Content, url, port)
|
||||
val referer = headers["referer"]
|
||||
val userAgent = headers["user-agent"]
|
||||
val modifiedContent = modifyM3u8Content(m3u8Content, url, port, referer, userAgent)
|
||||
Log.d(tag, "Modified M3U8 content length: ${modifiedContent.length}")
|
||||
Log.d(tag, "M3U8 processing completed successfully")
|
||||
|
||||
modifiedContent
|
||||
} catch (e: UpstreamStatusException) {
|
||||
Log.w(tag, "Upstream ${e.code} propagating from processM3u8Content for $url")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error processing M3U8 URL: ${e.message}", e)
|
||||
throw IOException("Error processing m3u8: ${e.message}")
|
||||
throw UpstreamStatusException(503, url, "Error processing m3u8: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process segment with automatic detection
|
||||
* Process segment with automatic detection. When [keyUrl] and [iv] are
|
||||
* both supplied, the segment is treated as AES-128-CBC encrypted (the
|
||||
* `#EXT-X-KEY:METHOD=AES-128` playlist path) and the bytes are decrypted
|
||||
* BEFORE junk-byte interleaving is stripped (ChillX-style obfuscation
|
||||
* may stack on top of AES-128; decryption first produces the raw TS that
|
||||
* AutoDetector then scans for image-magic-junk blocks).
|
||||
*/
|
||||
suspend fun processSegmentUrl(url: String, headers: Map<String, String> = emptyMap()): ByteArray = withContext(Dispatchers.IO) {
|
||||
suspend fun processSegmentUrl(
|
||||
url: String,
|
||||
headers: Map<String, String> = emptyMap(),
|
||||
keyUrl: String? = null,
|
||||
iv: String? = null,
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(tag, "Fetching segment from: $url with headers: $headers")
|
||||
val segmentData = fetchSegmentWithAutoDetection(url, headers)
|
||||
Log.d(tag, "Segment processing completed, final size: ${segmentData.size} bytes")
|
||||
segmentData
|
||||
Log.d(tag, "Fetching segment from: $url with headers: $headers (aes=${keyUrl != null})")
|
||||
val rawSegment = fetchSegmentBytes(url, headers)
|
||||
val plaintext = if (keyUrl != null && iv != null) {
|
||||
val keyBytes = fetchSegmentBytes(keyUrl, headers)
|
||||
decryptAes128Cbc(rawSegment, keyBytes, iv).also {
|
||||
Log.d(tag, "AES-128 decrypted segment: ${rawSegment.size} → ${it.size} bytes")
|
||||
}
|
||||
} else {
|
||||
rawSegment
|
||||
}
|
||||
val stripped = stripInterleavedJunk(plaintext)
|
||||
Log.d(tag, "Segment processing completed, final size: ${stripped.size} bytes")
|
||||
stripped
|
||||
} catch (e: UpstreamStatusException) {
|
||||
Log.w(tag, "Segment fetch upstream ${e.code} for $url")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error processing segment URL: ${e.message}", e)
|
||||
throw IOException("Error processing segment: ${e.message}")
|
||||
throw UpstreamStatusException(503, url, "Error processing segment: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSegmentBytes(url: String, headers: Map<String, String>): ByteArray = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "Making HTTP request to fetch segment with headers: $headers")
|
||||
|
||||
val requestBuilder = Request.Builder().url(url)
|
||||
headers.forEach { (key, value) ->
|
||||
requestBuilder.addHeader(key, value)
|
||||
}
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
Log.d(tag, "Segment HTTP response code: ${response.code}")
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(tag, "Failed to fetch segment, HTTP code: ${response.code}")
|
||||
throw UpstreamStatusException(response.code, url, "Failed to fetch segment")
|
||||
}
|
||||
response.body.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripInterleavedJunk(fullData: ByteArray): ByteArray {
|
||||
val skipRanges = AutoDetector.detectInterleavedSkips(fullData)
|
||||
if (skipRanges.isEmpty()) return fullData
|
||||
val strippedBytes = skipRanges.sumOf { it.last - it.first + 1 }
|
||||
val finalSize = fullData.size - strippedBytes
|
||||
Log.d(tag, "Stripping ${skipRanges.size} interleaved junk block(s) ($strippedBytes bytes total), final size: $finalSize bytes")
|
||||
val stripped = ByteArrayOutputStream(finalSize.coerceAtLeast(0))
|
||||
var cursor = 0
|
||||
for (range in skipRanges) {
|
||||
if (range.first > cursor) {
|
||||
stripped.write(copyRegion(fullData, cursor, range.first - cursor))
|
||||
}
|
||||
cursor = range.last + 1
|
||||
}
|
||||
if (cursor < fullData.size) {
|
||||
stripped.write(copyRegion(fullData, cursor, fullData.size - cursor))
|
||||
}
|
||||
return stripped.toByteArray()
|
||||
}
|
||||
|
||||
private fun decryptAes128Cbc(data: ByteArray, key: ByteArray, iv: String): ByteArray {
|
||||
if (key.size != 16) {
|
||||
throw IOException("Invalid AES-128 key length: ${key.size}")
|
||||
}
|
||||
val normalizedIv = iv.normalizeHlsIv()
|
||||
if (normalizedIv.length != 32) {
|
||||
throw IOException("Invalid AES-128 IV length: ${normalizedIv.length}")
|
||||
}
|
||||
return try {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(key, "AES"),
|
||||
IvParameterSpec(normalizedIv.hexToByteArray()),
|
||||
)
|
||||
cipher.doFinal(data)
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw IOException("Failed to decrypt AES-128 segment", e)
|
||||
} catch (e: NumberFormatException) {
|
||||
throw IOException("Invalid AES-128 IV", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toHlsIv(): String = toString(16).padStart(32, '0')
|
||||
|
||||
private fun String.normalizeHlsIv(): String = removePrefix("0x").removePrefix("0X").padStart(32, '0')
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray = ByteArray(length / 2) { index ->
|
||||
substring(index * 2, index * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
@@ -202,112 +398,220 @@ class M3u8HttpServer(
|
||||
}
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
Log.d(tag, "M3U8 HTTP response code: ${response.code}")
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
Log.d(tag, "M3U8 HTTP response code: ${response.code}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(tag, "Failed to fetch M3U8 content, HTTP code: ${response.code}")
|
||||
throw IOException("Failed to fetch m3u8: ${response.code}")
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(tag, "Failed to fetch M3U8 content, HTTP code: ${response.code}")
|
||||
throw UpstreamStatusException(response.code, url, "Failed to fetch m3u8")
|
||||
}
|
||||
|
||||
val content = response.body.string()
|
||||
if (content.isBlank()) {
|
||||
Log.e(tag, "Empty M3U8 response body")
|
||||
throw UpstreamStatusException(502, url, "Empty response body")
|
||||
}
|
||||
|
||||
Log.d(tag, "Successfully fetched M3U8 content")
|
||||
content
|
||||
}
|
||||
} catch (primary: UpstreamStatusException) {
|
||||
throw primary
|
||||
} catch (primary: Exception) {
|
||||
// Primary client threw; if it was a CF-solve failure we are not
|
||||
// a typed exception here (lib stays agnostic of cloudflareinterceptor).
|
||||
// Use the fallback client when supplied — header-gated CDNs may
|
||||
// return 200 once the WebView detour is bypassed.
|
||||
Log.w(tag, "Primary client failed for $url: ${primary.javaClass.simpleName}: ${primary.message}; ${if (fallbackClient != null) "attempting fallback" else "no fallback configured"}")
|
||||
val fb = fallbackClient ?: throw UpstreamStatusException(503, url, "Primary client failed: ${primary.message}", primary)
|
||||
|
||||
val content = response.body.string()
|
||||
if (content.isBlank()) {
|
||||
Log.e(tag, "Empty M3U8 response body")
|
||||
throw IOException("Empty response body")
|
||||
try {
|
||||
fb.newCall(request).execute().use { response ->
|
||||
Log.d(tag, "M3U8 fallback HTTP response code: ${response.code}")
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(tag, "Fallback also failed for M3U8, HTTP code: ${response.code}")
|
||||
throw UpstreamStatusException(response.code, url, "Fallback failed: ${primary.message}", primary)
|
||||
}
|
||||
val content = response.body.string()
|
||||
if (content.isBlank()) {
|
||||
throw UpstreamStatusException(502, url, "Empty fallback body", primary)
|
||||
}
|
||||
Log.d(tag, "Fallback fetch succeeded, content length: ${content.length}")
|
||||
content
|
||||
}
|
||||
} catch (fb: UpstreamStatusException) {
|
||||
throw fb
|
||||
} catch (fb: Exception) {
|
||||
Log.e(tag, "Fallback client threw: ${fb.javaClass.simpleName}: ${fb.message}", fb)
|
||||
throw UpstreamStatusException(503, url, "Both primary and fallback failed (primary=${primary.message}; fallback=${fb.message})", primary)
|
||||
}
|
||||
|
||||
Log.d(tag, "Successfully fetched M3U8 content")
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSegmentWithAutoDetection(url: String, headers: Map<String, String> = emptyMap()): ByteArray = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "Making HTTP request to fetch segment with headers: $headers")
|
||||
|
||||
val requestBuilder = Request.Builder().url(url)
|
||||
headers.forEach { (key, value) ->
|
||||
requestBuilder.addHeader(key, value)
|
||||
}
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
Log.d(tag, "Segment HTTP response code: ${response.code}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(tag, "Failed to fetch segment, HTTP code: ${response.code}")
|
||||
throw IOException("Failed to fetch segment: ${response.code}")
|
||||
}
|
||||
|
||||
val inputStream = response.body.byteStream()
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
// Read first 4KB to detect format
|
||||
val buffer = ByteArray(4096)
|
||||
val bytesRead = inputStream.read(buffer)
|
||||
Log.d(tag, "Read $bytesRead bytes from segment for format detection")
|
||||
|
||||
if (bytesRead > 0) {
|
||||
val skipBytes = AutoDetector.detectSkipBytes(buffer.copyOf(bytesRead))
|
||||
Log.d(tag, "AutoDetector determined skip bytes: $skipBytes")
|
||||
|
||||
// Write data from detected offset
|
||||
val validBytes = bytesRead - skipBytes
|
||||
outputStream.write(buffer, skipBytes, validBytes)
|
||||
Log.d(tag, "Wrote $validBytes bytes from detected offset")
|
||||
|
||||
// Copy remaining data
|
||||
val remainingBytes = inputStream.copyTo(outputStream)
|
||||
Log.d(tag, "Copied $remainingBytes remaining bytes")
|
||||
}
|
||||
|
||||
inputStream.close()
|
||||
val finalData = outputStream.toByteArray()
|
||||
outputStream.close()
|
||||
Log.d(tag, "Final segment data size: ${finalData.size} bytes")
|
||||
finalData
|
||||
}
|
||||
private fun copyRegion(src: ByteArray, off: Int, len: Int): ByteArray {
|
||||
if (off == 0 && len == src.size) return src
|
||||
if (len == 0) return ByteArray(0)
|
||||
val out = ByteArray(len)
|
||||
System.arraycopy(src, off, out, 0, len)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a local M3U8 URL by encoding the original URL and redirecting to the local server.
|
||||
* It can either be segment URL or a direct M3U8 URL (not a playlist).
|
||||
* Creates a local M3U8 URL that re-enters this server at `/m3u8`. The
|
||||
* caller can attach the original extension's [referer] and [userAgent]
|
||||
* so the upstream-fetch path can re-issue them even when the media
|
||||
* player (mpv / ExoPlayer) does not carry them through to localhost.
|
||||
*
|
||||
* This is the root-cause fix for Cloudflare-fronted CDNs that reject
|
||||
* null-Referer (or wrong-Referer) requests with a 403 — the m3u8 server
|
||||
* would otherwise hit the upstream with whatever headers the player
|
||||
* copied to localhost, which on most MPV integrations is "no Referer".
|
||||
*/
|
||||
fun createLocalUrl(m3u8Url: String): String {
|
||||
val encodedUrl = URLEncoder.encode(m3u8Url, Charsets.UTF_8.name())
|
||||
return "http://localhost:$port/m3u8?url=$encodedUrl"
|
||||
fun createLocalUrl(
|
||||
m3u8Url: String,
|
||||
referer: String? = null,
|
||||
userAgent: String? = null,
|
||||
): String {
|
||||
val sb = StringBuilder("http://localhost:$port/m3u8?url=")
|
||||
.append(URLEncoder.encode(m3u8Url, Charsets.UTF_8.name()))
|
||||
if (!referer.isNullOrBlank()) {
|
||||
sb.append("&referer=").append(URLEncoder.encode(referer, Charsets.UTF_8.name()))
|
||||
}
|
||||
if (!userAgent.isNullOrBlank()) {
|
||||
sb.append("&useragent=").append(URLEncoder.encode(userAgent, Charsets.UTF_8.name()))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun modifyM3u8Content(content: String, originalUrl: String, serverPort: Int): String {
|
||||
Log.d(tag, "Modifying M3U8 content for server port: $serverPort")
|
||||
val lines = content.lines().toMutableList()
|
||||
private fun modifyM3u8Content(
|
||||
content: String,
|
||||
originalUrl: String,
|
||||
serverPort: Int,
|
||||
referer: String? = null,
|
||||
userAgent: String? = null,
|
||||
): String {
|
||||
Log.d(tag, "Modifying M3U8 content for server port: $serverPort (referer=${referer?.take(80) ?: "none"})")
|
||||
val lines = content.lines()
|
||||
val modifiedLines = mutableListOf<String>()
|
||||
var segmentCount = 0
|
||||
var mediaSequence = 0L
|
||||
var segmentSequence = mediaSequence
|
||||
var currentKey: HlsKey? = null
|
||||
|
||||
// Determine base URL from the original URL
|
||||
val baseHttpUrl = originalUrl.toHttpUrlOrNull()
|
||||
|
||||
for (line in lines) {
|
||||
when {
|
||||
line.startsWith("#") -> {
|
||||
// Keep comments and headers
|
||||
line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> {
|
||||
mediaSequence = line.substringAfter(":").trim().toLongOrNull() ?: mediaSequence
|
||||
segmentSequence = mediaSequence
|
||||
modifiedLines.add(line)
|
||||
}
|
||||
line.isNotBlank() && !line.startsWith("#") -> {
|
||||
// This is a segment URL, resolve against base URL
|
||||
val resolvedUrl = baseHttpUrl?.resolve(line)?.toString() ?: line
|
||||
val encodedUrl = URLEncoder.encode(resolvedUrl, Charsets.UTF_8.name())
|
||||
val localUrl = "http://localhost:$serverPort/segment?url=$encodedUrl"
|
||||
modifiedLines.add(localUrl)
|
||||
segmentCount++
|
||||
line.startsWith("#EXT-X-KEY:") -> {
|
||||
val attributes = parseHlsAttributes(line)
|
||||
when (attributes["METHOD"]?.uppercase()) {
|
||||
"AES-128" -> {
|
||||
val keyUri = attributes["URI"]
|
||||
if (keyUri.isNullOrBlank()) {
|
||||
currentKey = null
|
||||
modifiedLines.add(line)
|
||||
} else {
|
||||
val resolvedKeyUrl = resolveHlsUrl(baseHttpUrl, keyUri)
|
||||
val iv = attributes["IV"]
|
||||
currentKey = HlsKey(
|
||||
url = resolvedKeyUrl,
|
||||
iv = iv?.let { it.normalizeHlsIv() } ?: segmentSequence.toHlsIv(),
|
||||
)
|
||||
Log.d(tag, "AES-128 detected, intercepting key at proxy: $resolvedKeyUrl (iv=${currentKey!!.iv})")
|
||||
}
|
||||
}
|
||||
"NONE" -> {
|
||||
currentKey = null
|
||||
modifiedLines.add(line)
|
||||
}
|
||||
else -> {
|
||||
currentKey = null
|
||||
modifiedLines.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
line.startsWith("#") || line.isBlank() -> {
|
||||
modifiedLines.add(line)
|
||||
}
|
||||
else -> {
|
||||
// Keep empty lines
|
||||
modifiedLines.add(line)
|
||||
val resolvedUrl = resolveHlsUrl(baseHttpUrl, line)
|
||||
if (resolvedUrl.contains(".m3u8", ignoreCase = true)) {
|
||||
val encodedUrl = URLEncoder.encode(resolvedUrl, Charsets.UTF_8.name())
|
||||
// Forward the caller's referer / UA to sub-playlists so
|
||||
// the redirect chain keeps browser-fingerprint headers.
|
||||
modifiedLines.add(buildChildM3u8Url(serverPort, resolvedUrl, referer, userAgent))
|
||||
} else {
|
||||
modifiedLines.add(
|
||||
createLocalSegmentUrl(serverPort, resolvedUrl, currentKey, referer, userAgent),
|
||||
)
|
||||
segmentCount++
|
||||
}
|
||||
segmentSequence++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Modified M3U8 content: $segmentCount segments redirected to local server")
|
||||
Log.d(tag, "Modified M3U8 content: $segmentCount segments redirected, ${if (currentKey != null) "AES-128 keys proxied" else "no AES keys"}")
|
||||
return modifiedLines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun buildChildM3u8Url(
|
||||
serverPort: Int,
|
||||
m3u8Url: String,
|
||||
referer: String?,
|
||||
userAgent: String?,
|
||||
): String {
|
||||
val sb = StringBuilder("http://localhost:$serverPort/m3u8?url=")
|
||||
.append(URLEncoder.encode(m3u8Url, Charsets.UTF_8.name()))
|
||||
if (!referer.isNullOrBlank()) {
|
||||
sb.append("&referer=").append(URLEncoder.encode(referer, Charsets.UTF_8.name()))
|
||||
}
|
||||
if (!userAgent.isNullOrBlank()) {
|
||||
sb.append("&useragent=").append(URLEncoder.encode(userAgent, Charsets.UTF_8.name()))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private data class HlsKey(val url: String, val iv: String)
|
||||
|
||||
private val hlsAttributeRegex = Regex("""([A-Z0-9-]+)=("[^"]*"|[^,]*)""")
|
||||
|
||||
private fun parseHlsAttributes(line: String): Map<String, String> = hlsAttributeRegex.findAll(line.substringAfter(":")).associate {
|
||||
it.groupValues[1] to it.groupValues[2].trim('"')
|
||||
}
|
||||
|
||||
private fun resolveHlsUrl(baseHttpUrl: okhttp3.HttpUrl?, uri: String): String = baseHttpUrl?.resolve(uri)?.toString() ?: uri
|
||||
|
||||
private fun createLocalSegmentUrl(
|
||||
serverPort: Int,
|
||||
segmentUrl: String,
|
||||
key: HlsKey?,
|
||||
referer: String? = null,
|
||||
userAgent: String? = null,
|
||||
): String {
|
||||
val encodedUrl = URLEncoder.encode(segmentUrl, Charsets.UTF_8.name())
|
||||
return buildString {
|
||||
append("http://localhost:$serverPort/segment?url=$encodedUrl")
|
||||
if (key != null) {
|
||||
append("&key=")
|
||||
append(URLEncoder.encode(key.url, Charsets.UTF_8.name()))
|
||||
append("&iv=")
|
||||
append(URLEncoder.encode(key.iv, Charsets.UTF_8.name()))
|
||||
}
|
||||
if (!referer.isNullOrBlank()) {
|
||||
append("&referer=")
|
||||
append(URLEncoder.encode(referer, Charsets.UTF_8.name()))
|
||||
}
|
||||
if (!userAgent.isNullOrBlank()) {
|
||||
append("&useragent=")
|
||||
append(URLEncoder.encode(userAgent, Charsets.UTF_8.name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,17 @@ import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* M3U8 Server integration with Q1N extension
|
||||
*
|
||||
* @param client the primary OkHttp client used for upstream fetches; may
|
||||
* carry a Cloudflare-solve interceptor.
|
||||
* @param fallbackClient optional secondary OkHttp client consulted when
|
||||
* the primary client throws on a Cloudflare-solve failure. Should be
|
||||
* CF-interceptor-free; supplies the header-gated retry leg.
|
||||
*/
|
||||
class M3u8Integration(
|
||||
client: OkHttpClient,
|
||||
private val serverManager: M3u8ServerManager = M3u8ServerManager(client),
|
||||
fallbackClient: OkHttpClient? = null,
|
||||
private val serverManager: M3u8ServerManager = M3u8ServerManager(client, fallbackClient),
|
||||
) {
|
||||
|
||||
private val tag by lazy { javaClass.simpleName }
|
||||
@@ -27,12 +34,16 @@ class M3u8Integration(
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an M3U8 video through the local server
|
||||
* @param originalVideo Original video with M3U8 URL
|
||||
* @return Processed video with local URL
|
||||
* Processes an M3U8 video through the local server. The original
|
||||
* [Video.headers] is consulted to derive `Referer` and `User-Agent`,
|
||||
* which are then re-encoded into the proxied URL so the m3u8 server
|
||||
* can re-issue them on the upstream fetch even if the media player
|
||||
* (mpv / ExoPlayer) does not carry them through to localhost.
|
||||
*/
|
||||
private fun processM3u8Video(originalVideo: Video): Video {
|
||||
val processedUrl = serverManager.processM3u8Url(originalVideo.url)
|
||||
val referer = originalVideo.headers?.get("Referer")
|
||||
val userAgent = originalVideo.headers?.get("User-Agent")
|
||||
val processedUrl = serverManager.processM3u8Url(originalVideo.url, referer, userAgent)
|
||||
return Video(
|
||||
videoUrl = processedUrl ?: originalVideo.url,
|
||||
url = originalVideo.url,
|
||||
|
||||
@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
|
||||
*/
|
||||
class M3u8ServerManager(
|
||||
private val client: OkHttpClient,
|
||||
private val fallbackClient: OkHttpClient? = null,
|
||||
) {
|
||||
private val tag by lazy { javaClass.simpleName }
|
||||
private var server: M3u8HttpServer? = null
|
||||
@@ -24,7 +25,7 @@ class M3u8ServerManager(
|
||||
}
|
||||
|
||||
try {
|
||||
server = M3u8HttpServer(client, port)
|
||||
server = M3u8HttpServer(client, port, fallbackClient)
|
||||
server?.start()
|
||||
Log.d(tag, "Server started on port: ${server?.port}")
|
||||
} catch (e: Exception) {
|
||||
@@ -55,11 +56,16 @@ class M3u8ServerManager(
|
||||
fun getServerUrl(): String? = server?.let { "http://localhost:${it.port}" }
|
||||
|
||||
/**
|
||||
* Processes an M3U8 file through the server
|
||||
* Processes an M3U8 file through the server.
|
||||
*
|
||||
* @param m3u8Url Original M3U8 file URL
|
||||
* @return Processed M3U8 content
|
||||
* @param referer optional Referer to encode into the proxied URL — see
|
||||
* [M3u8HttpServer.createLocalUrl] for why this is the root-cause fix
|
||||
* for Cloudflare-fronted CDNs that 403 on null-Referer requests.
|
||||
* @param userAgent optional User-Agent to encode alongside the referer.
|
||||
* @return Processed M3U8 content as a local URL string
|
||||
*/
|
||||
fun processM3u8Url(m3u8Url: String): String? = server?.createLocalUrl(m3u8Url)
|
||||
fun processM3u8Url(m3u8Url: String, referer: String? = null, userAgent: String? = null): String? = server?.createLocalUrl(m3u8Url, referer, userAgent)
|
||||
|
||||
/**
|
||||
* Processes a segment through the server
|
||||
|
||||
@@ -93,14 +93,16 @@ class MegaCloudExtractor(
|
||||
.execute().use { it.body.string() }
|
||||
val data = json.decodeFromString<SourceResponseDto>(srcRes)
|
||||
|
||||
val key by lazy { requestNewKey() }
|
||||
|
||||
return data.sources.map { source ->
|
||||
return data.sources.mapNotNull { source ->
|
||||
val encoded = source.file
|
||||
|
||||
val m3u8: String = if (!data.encrypted || ".m3u8" in encoded) {
|
||||
encoded
|
||||
} else {
|
||||
val key = requestNewKeyOrNull() ?: run {
|
||||
Log.w("MegaCloudExtractor", "Skipping encrypted source — decryption keys unavailable")
|
||||
return@mapNotNull null
|
||||
}
|
||||
val fullUrl = buildString {
|
||||
append(megaCloudAPI)
|
||||
append("?encrypted_data=").append(URLEncoder.encode(encoded, "UTF-8"))
|
||||
@@ -120,17 +122,27 @@ class MegaCloudExtractor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNewKey(): String = client.newCall(GET("https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json"))
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch keys.json")
|
||||
val jsonStr = response.body.string()
|
||||
if (jsonStr.isEmpty()) throw IllegalStateException("keys.json is empty")
|
||||
val key = json.decodeFromString<Map<String, String>>(jsonStr)["mega"]
|
||||
?: throw IllegalStateException("Mega key not found in keys.json")
|
||||
Log.i("MegaCloudExtractor", "Using Mega Key: $key")
|
||||
key
|
||||
}
|
||||
private fun requestNewKeyOrNull(): String? = try {
|
||||
client.newCall(GET("https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json"))
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
Log.w("MegaCloudExtractor", "keys.json fetch failed: HTTP ${response.code}")
|
||||
return@use null
|
||||
}
|
||||
val jsonStr = response.body.string()
|
||||
if (jsonStr.isEmpty()) {
|
||||
Log.w("MegaCloudExtractor", "keys.json is empty")
|
||||
return@use null
|
||||
}
|
||||
json.decodeFromString<Map<String, String>>(jsonStr)["mega"]?.also {
|
||||
Log.i("MegaCloudExtractor", "Using Mega Key: $it")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("MegaCloudExtractor", "requestNewKey failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class VideoDto(
|
||||
|
||||
45
lib/omniembedextractor/build.gradle.kts
Normal file
45
lib/omniembedextractor/build.gradle.kts
Normal file
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
alias(kei.plugins.library)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Original extractors
|
||||
implementation(project(":lib:okruextractor"))
|
||||
implementation(project(":lib:vkextractor"))
|
||||
implementation(project(":lib:doodextractor"))
|
||||
implementation(project(":lib:streamtapeextractor"))
|
||||
implementation(project(":lib:mp4uploadextractor"))
|
||||
implementation(project(":lib:streamwishextractor"))
|
||||
implementation(project(":lib:filemoonextractor"))
|
||||
implementation(project(":lib:kwikextractor"))
|
||||
|
||||
// New extractors - Tier 1: Simple (client, headers) + videosFromUrl(url, prefix)
|
||||
implementation(project(":lib:voeextractor"))
|
||||
implementation(project(":lib:streamlareextractor"))
|
||||
implementation(project(":lib:streamhubextractor"))
|
||||
implementation(project(":lib:vidguardextractor"))
|
||||
implementation(project(":lib:sendvidextractor"))
|
||||
implementation(project(":lib:streamdavextractor"))
|
||||
implementation(project(":lib:streamsilkextractor"))
|
||||
implementation(project(":lib:vidoextractor"))
|
||||
implementation(project(":lib:vudeoextractor"))
|
||||
implementation(project(":lib:upstreamextractor"))
|
||||
implementation(project(":lib:sibnetextractor"))
|
||||
implementation(project(":lib:rumbleextractor"))
|
||||
implementation(project(":lib:amazonextractor"))
|
||||
implementation(project(":lib:fusevideoextractor"))
|
||||
implementation(project(":lib:luluextractor"))
|
||||
implementation(project(":lib:buzzheavierextractor"))
|
||||
implementation(project(":lib:fastreamextractor"))
|
||||
implementation(project(":lib:vidbomextractor"))
|
||||
implementation(project(":lib:pixeldrainextractor"))
|
||||
implementation(project(":lib:mixdropextractor"))
|
||||
|
||||
// New extractors - Tier 2: Suspend functions (need runBlocking)
|
||||
implementation(project(":lib:vidmolyextractor"))
|
||||
implementation(project(":lib:vidhideextractor"))
|
||||
implementation(project(":lib:streamplayextractor"))
|
||||
implementation(project(":lib:streamupextractor"))
|
||||
implementation(project(":lib:uqloadextractor"))
|
||||
implementation(project(":lib:bloggerextractor"))
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package aniyomi.lib.omniembedextractor
|
||||
|
||||
import android.util.Log
|
||||
import aniyomi.lib.amazonextractor.AmazonExtractor
|
||||
import aniyomi.lib.bloggerextractor.BloggerExtractor
|
||||
import aniyomi.lib.buzzheavierextractor.BuzzheavierExtractor
|
||||
import aniyomi.lib.doodextractor.DoodExtractor
|
||||
import aniyomi.lib.fastreamextractor.FastreamExtractor
|
||||
import aniyomi.lib.filemoonextractor.FilemoonExtractor
|
||||
import aniyomi.lib.fusevideoextractor.FusevideoExtractor
|
||||
import aniyomi.lib.kwikextractor.KwikExtractor
|
||||
import aniyomi.lib.luluextractor.LuluExtractor
|
||||
import aniyomi.lib.mixdropextractor.MixDropExtractor
|
||||
import aniyomi.lib.mp4uploadextractor.Mp4uploadExtractor
|
||||
import aniyomi.lib.okruextractor.OkruExtractor
|
||||
import aniyomi.lib.pixeldrainextractor.PixelDrainExtractor
|
||||
import aniyomi.lib.rumbleextractor.RumbleExtractor
|
||||
import aniyomi.lib.sendvidextractor.SendvidExtractor
|
||||
import aniyomi.lib.sibnetextractor.SibnetExtractor
|
||||
import aniyomi.lib.streamdavextractor.StreamDavExtractor
|
||||
import aniyomi.lib.streamhubextractor.StreamHubExtractor
|
||||
import aniyomi.lib.streamlareextractor.StreamlareExtractor
|
||||
import aniyomi.lib.streamplayextractor.StreamPlayExtractor
|
||||
import aniyomi.lib.streamsilkextractor.StreamSilkExtractor
|
||||
import aniyomi.lib.streamtapeextractor.StreamTapeExtractor
|
||||
import aniyomi.lib.streamupextractor.StreamupExtractor
|
||||
import aniyomi.lib.streamwishextractor.StreamWishExtractor
|
||||
import aniyomi.lib.upstreamextractor.UpstreamExtractor
|
||||
import aniyomi.lib.uqloadextractor.UqloadExtractor
|
||||
import aniyomi.lib.vidbomextractor.VidBomExtractor
|
||||
import aniyomi.lib.vidguardextractor.VidGuardExtractor
|
||||
import aniyomi.lib.vidhideextractor.VidHideExtractor
|
||||
import aniyomi.lib.vidmolyextractor.VidMolyExtractor
|
||||
import aniyomi.lib.vidoextractor.VidoExtractor
|
||||
import aniyomi.lib.vkextractor.VkExtractor
|
||||
import aniyomi.lib.voeextractor.VoeExtractor
|
||||
import aniyomi.lib.vudeoextractor.VudeoExtractor
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
private const val TAG = "OmniEmbedExtractor"
|
||||
private const val USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||
|
||||
private enum class EmbedType {
|
||||
OKRU,
|
||||
VK,
|
||||
DOOD,
|
||||
STREAMTAPE,
|
||||
MP4UPLOAD,
|
||||
STREAMWISH,
|
||||
FILEMOON,
|
||||
KWIK,
|
||||
|
||||
// Tier 1: Simple extractors
|
||||
VOE,
|
||||
STREAMLARE,
|
||||
STREAMHUB,
|
||||
VIDGUARD,
|
||||
SENDVID,
|
||||
STREAMDAV,
|
||||
STREAMSILK,
|
||||
VIDO,
|
||||
VUDEO,
|
||||
UPSTREAM,
|
||||
SIBNET,
|
||||
RUMBLE,
|
||||
AMAZON,
|
||||
FUSEVIDEO,
|
||||
LULU,
|
||||
BUZZHEAVIER,
|
||||
FASTREAM,
|
||||
VIDBOM,
|
||||
PIXELDRAIN,
|
||||
MIXDROP,
|
||||
|
||||
// Tier 2: Suspend extractors
|
||||
VIDMOLY,
|
||||
VIDHIDE,
|
||||
STREAMPLAY,
|
||||
STREAMUP,
|
||||
UQLOAD,
|
||||
BLOGGER,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
private val EMBED_DOMAIN_MAP = mapOf(
|
||||
// VK-family
|
||||
"ok.ru" to EmbedType.OKRU,
|
||||
"vk.com" to EmbedType.VK,
|
||||
// Doodstream
|
||||
"doodstream.com" to EmbedType.DOOD,
|
||||
"dood.wf" to EmbedType.DOOD,
|
||||
"doodstream" to EmbedType.DOOD,
|
||||
// Streamtape
|
||||
"streamtape.com" to EmbedType.STREAMTAPE,
|
||||
"streamtape.net" to EmbedType.STREAMTAPE,
|
||||
// Mp4Upload
|
||||
"mp4upload.com" to EmbedType.MP4UPLOAD,
|
||||
// StreamWish (+ mirrors)
|
||||
"streamwish.com" to EmbedType.STREAMWISH,
|
||||
"niramirus.com" to EmbedType.STREAMWISH,
|
||||
"medixiru.com" to EmbedType.STREAMWISH,
|
||||
// Filemoon
|
||||
"filemoon.sx" to EmbedType.FILEMOON,
|
||||
"filemoon.to" to EmbedType.FILEMOON,
|
||||
// Kwik
|
||||
"kwik.cx" to EmbedType.KWIK,
|
||||
// Tier 1: Simple extractors
|
||||
"voe.sx" to EmbedType.VOE,
|
||||
"voe.to" to EmbedType.VOE,
|
||||
"voe.sg" to EmbedType.VOE,
|
||||
"voe.pm" to EmbedType.VOE,
|
||||
"voe.sh" to EmbedType.VOE,
|
||||
"voe.st" to EmbedType.VOE,
|
||||
"voe.cloud" to EmbedType.VOE,
|
||||
"streamlare.com" to EmbedType.STREAMLARE,
|
||||
"slwatch.co" to EmbedType.STREAMLARE,
|
||||
"streamhub.gg" to EmbedType.STREAMHUB,
|
||||
"vidguard.app" to EmbedType.VIDGUARD,
|
||||
"vidguard.io" to EmbedType.VIDGUARD,
|
||||
"vgf.play" to EmbedType.VIDGUARD,
|
||||
"sendvid.com" to EmbedType.SENDVID,
|
||||
"streamdav.com" to EmbedType.STREAMDAV,
|
||||
"streamsilk.com" to EmbedType.STREAMSILK,
|
||||
"vido.lol" to EmbedType.VIDO,
|
||||
"vidoza.net" to EmbedType.VIDO,
|
||||
"vudeo.co" to EmbedType.VUDEO,
|
||||
"upstream.to" to EmbedType.UPSTREAM,
|
||||
"sibnet.ru" to EmbedType.SIBNET,
|
||||
"rumble.com" to EmbedType.RUMBLE,
|
||||
"amazon.com" to EmbedType.AMAZON,
|
||||
"fusevideo.com" to EmbedType.FUSEVIDEO,
|
||||
"luluvdo.com" to EmbedType.LULU,
|
||||
"buzzheavier.com" to EmbedType.BUZZHEAVIER,
|
||||
"fastream.to" to EmbedType.FASTREAM,
|
||||
"vidbom.com" to EmbedType.VIDBOM,
|
||||
"vidbem.com" to EmbedType.VIDBOM,
|
||||
"vidbm.com" to EmbedType.VIDBOM,
|
||||
"vedpom.com" to EmbedType.VIDBOM,
|
||||
"pixeldrain.com" to EmbedType.PIXELDRAIN,
|
||||
"mixdrop.ag" to EmbedType.MIXDROP,
|
||||
"mixdrop.co" to EmbedType.MIXDROP,
|
||||
"mixdrop.to" to EmbedType.MIXDROP,
|
||||
// Tier 2: Suspend extractors
|
||||
"vidmoly.to" to EmbedType.VIDMOLY,
|
||||
"vidmoly.biz" to EmbedType.VIDMOLY,
|
||||
"vidhide.com" to EmbedType.VIDHIDE,
|
||||
"streamplay.co.in" to EmbedType.STREAMPLAY,
|
||||
"streamup.cc" to EmbedType.STREAMUP,
|
||||
"uqload.co" to EmbedType.UQLOAD,
|
||||
"uqload.is" to EmbedType.UQLOAD,
|
||||
"blogger.com" to EmbedType.BLOGGER,
|
||||
"bp.blogspot.com" to EmbedType.BLOGGER,
|
||||
)
|
||||
|
||||
/**
|
||||
* Universal embed video extractor that detects the embed domain from a URL and
|
||||
* delegates to the appropriate per-host extractor.
|
||||
*
|
||||
* Supported embed domains:
|
||||
* - ok.ru, vk.com → OkruExtractor / VkExtractor
|
||||
* - doodstream.*, dood.wf → DoodExtractor
|
||||
* - streamtape.com, streamtape.net → StreamTapeExtractor
|
||||
* - mp4upload.com → Mp4uploadExtractor
|
||||
* - streamwish.com, niramirus.com, medixiru.com → StreamWishExtractor
|
||||
* - filemoon.sx, filemoon.to → FilemoonExtractor
|
||||
* - kwik.cx → KwikExtractor
|
||||
* - voe.sx, voe.to, etc. → VoeExtractor
|
||||
* - streamlare.com, slwatch.co → StreamlareExtractor
|
||||
* - streamhub.gg → StreamHubExtractor
|
||||
* - vidguard.app, vidguard.io → VidGuardExtractor
|
||||
* - sendvid.com → SendvidExtractor
|
||||
* - streamdav.com → StreamDavExtractor
|
||||
* - streamsilk.com → StreamSilkExtractor
|
||||
* - vido.lol, vidoza.net → VidoExtractor
|
||||
* - vudeo.co → VudeoExtractor
|
||||
* - upstream.to → UpstreamExtractor
|
||||
* - sibnet.ru → SibnetExtractor
|
||||
* - rumble.com → RumbleExtractor
|
||||
* - amazon.com → AmazonExtractor
|
||||
* - fusevideo.com → FusevideoExtractor
|
||||
* - luluvdo.com → LuluExtractor
|
||||
* - buzzheavier.com → BuzzheavierExtractor
|
||||
* - fastream.to → FastreamExtractor
|
||||
* - vidbom.com, vidbem.com, vidbm.com, vedpom.com → VidBomExtractor
|
||||
* - pixeldrain.com → PixelDrainExtractor
|
||||
* - mixdrop.ag, mixdrop.co, mixdrop.to → MixDropExtractor
|
||||
* - vidmoly.to, vidmoly.biz → VidMolyExtractor
|
||||
* - vidhide.com → VidHideExtractor
|
||||
* - streamplay.co.in → StreamPlayExtractor
|
||||
* - streamup.cc → StreamupExtractor
|
||||
* - uqload.co, uqload.is → UqloadExtractor
|
||||
* - blogger.com, bp.blogspot.com → BloggerExtractor
|
||||
*/
|
||||
class OmniEmbedExtractor(
|
||||
private val client: OkHttpClient,
|
||||
private val headers: Headers,
|
||||
) {
|
||||
|
||||
// Lazy-initialized per-host extractors
|
||||
// Original extractors
|
||||
private val okruExtractor by lazy { OkruExtractor(client, headers) }
|
||||
private val vkExtractor by lazy { VkExtractor(client, headers) }
|
||||
private val doodExtractor by lazy { DoodExtractor(client) }
|
||||
private val streamtapeExtractor by lazy { StreamTapeExtractor(client) }
|
||||
private val mp4uploadExtractor by lazy { Mp4uploadExtractor(client) }
|
||||
private val streamwishExtractor by lazy { StreamWishExtractor(client, headers) }
|
||||
private val filemoonExtractor by lazy { FilemoonExtractor(client) }
|
||||
private val kwikExtractor by lazy { KwikExtractor(client, headers) }
|
||||
|
||||
// Tier 1: Simple extractors
|
||||
private val voeExtractor by lazy { VoeExtractor(client, headers) }
|
||||
private val streamlareExtractor by lazy { StreamlareExtractor(client) }
|
||||
private val streamHubExtractor by lazy { StreamHubExtractor(client) }
|
||||
private val vidGuardExtractor by lazy { VidGuardExtractor(client) }
|
||||
private val sendvidExtractor by lazy { SendvidExtractor(client, headers) }
|
||||
private val streamDavExtractor by lazy { StreamDavExtractor(client) }
|
||||
private val streamSilkExtractor by lazy { StreamSilkExtractor(client, headers) }
|
||||
private val vidoExtractor by lazy { VidoExtractor(client) }
|
||||
private val vudeoExtractor by lazy { VudeoExtractor(client) }
|
||||
private val upstreamExtractor by lazy { UpstreamExtractor(client) }
|
||||
private val sibnetExtractor by lazy { SibnetExtractor(client) }
|
||||
private val rumbleExtractor by lazy { RumbleExtractor(client, headers) }
|
||||
private val amazonExtractor by lazy { AmazonExtractor(client) }
|
||||
private val fusevideoExtractor by lazy { FusevideoExtractor(client, headers) }
|
||||
private val luluExtractor by lazy { LuluExtractor(client, headers) }
|
||||
private val buzzheavierExtractor by lazy { BuzzheavierExtractor(client, headers) }
|
||||
private val fastreamExtractor by lazy { FastreamExtractor(client, headers) }
|
||||
private val vidBomExtractor by lazy { VidBomExtractor(client) }
|
||||
private val pixelDrainExtractor by lazy { PixelDrainExtractor() }
|
||||
private val mixDropExtractor by lazy { MixDropExtractor(client) }
|
||||
|
||||
// Tier 2: Suspend extractors (use runBlocking)
|
||||
private val vidMolyExtractor by lazy { VidMolyExtractor(client, headers) }
|
||||
private val vidHideExtractor by lazy { VidHideExtractor(client, headers) }
|
||||
private val streamPlayExtractor by lazy { StreamPlayExtractor(client, headers) }
|
||||
private val streamupExtractor by lazy { StreamupExtractor(client) }
|
||||
private val uqloadExtractor by lazy { UqloadExtractor(client) }
|
||||
private val bloggerExtractor by lazy { BloggerExtractor(client) }
|
||||
|
||||
/**
|
||||
* Detect the embed type from a URL and extract videos.
|
||||
*
|
||||
* @param embedUrl the third-party embed URL
|
||||
* @param qualityLabel the quality label prefix for the extracted videos
|
||||
* @param subtitles subtitle tracks to attach to the extracted videos
|
||||
* @return a list of [Video] objects, or empty if extraction fails
|
||||
*/
|
||||
fun extractVideos(
|
||||
embedUrl: String,
|
||||
qualityLabel: String,
|
||||
subtitles: List<Track>,
|
||||
): List<Video> {
|
||||
val embedType = detectEmbedType(embedUrl)
|
||||
Log.d(TAG, "extractVideos: type=$embedType url=${embedUrl.take(100)}")
|
||||
|
||||
return try {
|
||||
when (embedType) {
|
||||
// Original extractors
|
||||
EmbedType.OKRU -> extractFromOkru(embedUrl, qualityLabel)
|
||||
EmbedType.VK -> extractFromVk(embedUrl, qualityLabel)
|
||||
EmbedType.DOOD -> extractFromDood(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMTAPE -> extractFromStreamtape(embedUrl, qualityLabel)
|
||||
EmbedType.MP4UPLOAD -> extractFromMp4upload(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMWISH -> extractFromStreamwish(embedUrl, qualityLabel)
|
||||
EmbedType.FILEMOON -> extractFromFilemoon(embedUrl, qualityLabel)
|
||||
EmbedType.KWIK -> extractFromKwik(embedUrl, qualityLabel, subtitles)
|
||||
// Tier 1: Simple extractors
|
||||
EmbedType.VOE -> extractFromVoe(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMLARE -> extractFromStreamlare(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMHUB -> extractFromStreamhub(embedUrl, qualityLabel)
|
||||
EmbedType.VIDGUARD -> extractFromVidguard(embedUrl, qualityLabel)
|
||||
EmbedType.SENDVID -> extractFromSendvid(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMDAV -> extractFromStreamdav(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMSILK -> extractFromStreamsilk(embedUrl, qualityLabel)
|
||||
EmbedType.VIDO -> extractFromVido(embedUrl, qualityLabel)
|
||||
EmbedType.VUDEO -> extractFromVudeo(embedUrl, qualityLabel)
|
||||
EmbedType.UPSTREAM -> extractFromUpstream(embedUrl, qualityLabel)
|
||||
EmbedType.SIBNET -> extractFromSibnet(embedUrl, qualityLabel)
|
||||
EmbedType.RUMBLE -> extractFromRumble(embedUrl, qualityLabel)
|
||||
EmbedType.AMAZON -> extractFromAmazon(embedUrl, qualityLabel)
|
||||
EmbedType.FUSEVIDEO -> extractFromFusevideo(embedUrl, qualityLabel)
|
||||
EmbedType.LULU -> extractFromLulu(embedUrl, qualityLabel)
|
||||
EmbedType.BUZZHEAVIER -> extractFromBuzzheavier(embedUrl, qualityLabel)
|
||||
EmbedType.FASTREAM -> extractFromFastream(embedUrl, qualityLabel)
|
||||
EmbedType.VIDBOM -> extractFromVidbom(embedUrl)
|
||||
EmbedType.PIXELDRAIN -> extractFromPixeldrain(embedUrl, qualityLabel)
|
||||
EmbedType.MIXDROP -> extractFromMixdrop(embedUrl, qualityLabel)
|
||||
// Tier 2: Suspend extractors
|
||||
EmbedType.VIDMOLY -> extractFromVidmoly(embedUrl, qualityLabel)
|
||||
EmbedType.VIDHIDE -> extractFromVidhide(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMPLAY -> extractFromStreamplay(embedUrl, qualityLabel)
|
||||
EmbedType.STREAMUP -> extractFromStreamup(embedUrl, qualityLabel)
|
||||
EmbedType.UQLOAD -> extractFromUqload(embedUrl, qualityLabel)
|
||||
EmbedType.BLOGGER -> extractFromBlogger(embedUrl, qualityLabel, subtitles)
|
||||
EmbedType.UNKNOWN -> {
|
||||
Log.w(TAG, "Unknown embed domain: ${embedUrl.take(100)}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to extract from ${embedType.name}: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectEmbedType(url: String): EmbedType {
|
||||
val host = try {
|
||||
java.net.URI(url).host?.lowercase() ?: ""
|
||||
} catch (_: Exception) {
|
||||
url.substringAfter("://").substringBefore("/").substringBefore("?").lowercase()
|
||||
}
|
||||
|
||||
for ((domain, type) in EMBED_DOMAIN_MAP) {
|
||||
if (host == domain || host.endsWith(".$domain")) {
|
||||
return type
|
||||
}
|
||||
}
|
||||
return EmbedType.UNKNOWN
|
||||
}
|
||||
|
||||
// ============================== Original extractors ==============================
|
||||
|
||||
// ============================== OKRU ==============================
|
||||
|
||||
private fun extractFromOkru(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
okruExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== VK ==============================
|
||||
|
||||
private fun extractFromVk(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
vkExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== DOODSTREAM ==============================
|
||||
|
||||
private fun extractFromDood(embedUrl: String, qualityLabel: String): List<Video> = doodExtractor.videosFromUrl(embedUrl, quality = qualityLabel)
|
||||
|
||||
// ============================== STREAMTAPE ==============================
|
||||
|
||||
private fun extractFromStreamtape(embedUrl: String, qualityLabel: String): List<Video> = streamtapeExtractor.videosFromUrl(embedUrl, quality = qualityLabel)
|
||||
|
||||
// ============================== MP4UPLOAD ==============================
|
||||
|
||||
private fun extractFromMp4upload(embedUrl: String, qualityLabel: String): List<Video> {
|
||||
val mp4Headers = Headers.Builder()
|
||||
.set("Referer", "https://mp4upload.com/")
|
||||
.build()
|
||||
return mp4uploadExtractor.videosFromUrl(embedUrl, mp4Headers, prefix = qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== STREAMWISH ==============================
|
||||
|
||||
private fun extractFromStreamwish(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
streamwishExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== FILEMOON ==============================
|
||||
|
||||
private fun extractFromFilemoon(embedUrl: String, qualityLabel: String): List<Video> {
|
||||
val fmHeaders = Headers.Builder()
|
||||
.set("Referer", embedUrl)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.build()
|
||||
return filemoonExtractor.videosFromUrl(embedUrl, prefix = "$qualityLabel ", headers = fmHeaders)
|
||||
}
|
||||
|
||||
// ============================== KWIK ==============================
|
||||
|
||||
private fun extractFromKwik(
|
||||
embedUrl: String,
|
||||
qualityLabel: String,
|
||||
subtitles: List<Track>,
|
||||
): List<Video> = kwikExtractor.videosFromUrl(
|
||||
url = embedUrl,
|
||||
prefix = qualityLabel,
|
||||
subtitleList = subtitles,
|
||||
)
|
||||
|
||||
// ============================== Tier 1: Simple extractors ==============================
|
||||
|
||||
// ============================== VOE ==============================
|
||||
|
||||
private fun extractFromVoe(embedUrl: String, qualityLabel: String): List<Video> = voeExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== STREAMLARE ==============================
|
||||
|
||||
private fun extractFromStreamlare(embedUrl: String, qualityLabel: String): List<Video> = streamlareExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== STREAMHUB ==============================
|
||||
|
||||
private fun extractFromStreamhub(embedUrl: String, qualityLabel: String): List<Video> = streamHubExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== VIDGUARD ==============================
|
||||
|
||||
private fun extractFromVidguard(embedUrl: String, qualityLabel: String): List<Video> = vidGuardExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== SENDVID ==============================
|
||||
|
||||
private fun extractFromSendvid(embedUrl: String, qualityLabel: String): List<Video> = sendvidExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== STREAMDAV ==============================
|
||||
|
||||
private fun extractFromStreamdav(embedUrl: String, qualityLabel: String): List<Video> = streamDavExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== STREAMSILK ==============================
|
||||
|
||||
private fun extractFromStreamsilk(embedUrl: String, qualityLabel: String): List<Video> = streamSilkExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== VIDO ==============================
|
||||
|
||||
private fun extractFromVido(embedUrl: String, qualityLabel: String): List<Video> = vidoExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== VUDEO ==============================
|
||||
|
||||
private fun extractFromVudeo(embedUrl: String, qualityLabel: String): List<Video> = vudeoExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== UPSTREAM ==============================
|
||||
|
||||
private fun extractFromUpstream(embedUrl: String, qualityLabel: String): List<Video> = upstreamExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== SIBNET ==============================
|
||||
|
||||
private fun extractFromSibnet(embedUrl: String, qualityLabel: String): List<Video> = sibnetExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== RUMBLE ==============================
|
||||
|
||||
private fun extractFromRumble(embedUrl: String, qualityLabel: String): List<Video> = rumbleExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== AMAZON ==============================
|
||||
|
||||
private fun extractFromAmazon(embedUrl: String, qualityLabel: String): List<Video> = amazonExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== FUSEVIDEO ==============================
|
||||
|
||||
private fun extractFromFusevideo(embedUrl: String, qualityLabel: String): List<Video> = fusevideoExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== LULU ==============================
|
||||
|
||||
private fun extractFromLulu(embedUrl: String, qualityLabel: String): List<Video> = luluExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== BUZZHEAVIER ==============================
|
||||
|
||||
private fun extractFromBuzzheavier(embedUrl: String, qualityLabel: String): List<Video> = buzzheavierExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== FASTREAM ==============================
|
||||
|
||||
private fun extractFromFastream(embedUrl: String, qualityLabel: String): List<Video> = fastreamExtractor.videosFromUrl(embedUrl, prefix = "$qualityLabel ")
|
||||
|
||||
// ============================== VIDBOM ==============================
|
||||
|
||||
private fun extractFromVidbom(embedUrl: String): List<Video> = vidBomExtractor.videosFromUrl(embedUrl)
|
||||
|
||||
// ============================== PIXELDRAIN ==============================
|
||||
|
||||
private fun extractFromPixeldrain(embedUrl: String, qualityLabel: String): List<Video> = pixelDrainExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== MIXDROP ==============================
|
||||
|
||||
private fun extractFromMixdrop(embedUrl: String, qualityLabel: String): List<Video> = mixDropExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
|
||||
// ============================== Tier 2: Suspend extractors ==============================
|
||||
|
||||
// ============================== VIDMOLY ==============================
|
||||
|
||||
private fun extractFromVidmoly(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
vidMolyExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== VIDHIDE ==============================
|
||||
|
||||
private fun extractFromVidhide(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
vidHideExtractor.videosFromUrl(embedUrl, videoNameGen = { quality -> "$qualityLabel VidHide - $quality" })
|
||||
}
|
||||
|
||||
// ============================== STREAMPLAY ==============================
|
||||
|
||||
private fun extractFromStreamplay(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
streamPlayExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== STREAMUP ==============================
|
||||
|
||||
private fun extractFromStreamup(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
streamupExtractor.getVideosFromUrl(embedUrl, headers, qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== UQLOAD ==============================
|
||||
|
||||
private fun extractFromUqload(embedUrl: String, qualityLabel: String): List<Video> = runBlocking {
|
||||
uqloadExtractor.videosFromUrl(embedUrl, prefix = qualityLabel)
|
||||
}
|
||||
|
||||
// ============================== BLOGGER ==============================
|
||||
|
||||
private fun extractFromBlogger(
|
||||
embedUrl: String,
|
||||
qualityLabel: String,
|
||||
subtitles: List<Track>,
|
||||
): List<Video> = runBlocking {
|
||||
bloggerExtractor.videosFromUrl(embedUrl, headers, suffix = qualityLabel)
|
||||
}
|
||||
}
|
||||
@@ -168,38 +168,50 @@ class RapidCloudExtractor(
|
||||
|
||||
val data = json.decodeFromString<SourceResponseDto>(srcRes)
|
||||
|
||||
return data.sources.map { source ->
|
||||
return data.sources.mapNotNull { source ->
|
||||
val encoded = source.file
|
||||
|
||||
val m3u8: String = if (!data.encrypted || ".m3u8" in encoded) {
|
||||
encoded
|
||||
} else {
|
||||
// tryDecrypting(ciphered, keyType)
|
||||
decryptWithNewKey(encoded)
|
||||
decryptWithNewKey(encoded) ?: run {
|
||||
Log.w("RapidCloudExtractor", "Skipping encrypted source — decryption keys unavailable")
|
||||
return@mapNotNull null
|
||||
}
|
||||
}
|
||||
|
||||
VideoDto(m3u8, data.tracks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptWithNewKey(ciphered: String): String {
|
||||
val newKey = requestNewKey()
|
||||
private fun decryptWithNewKey(ciphered: String): String? {
|
||||
val newKey = requestNewKeyOrNull() ?: return null
|
||||
return decryptOpenSSL(ciphered, newKey).also {
|
||||
Log.i("RapidCloudExtractor", "Decrypted URL with new key: $it")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNewKey(): String = client.newCall(GET("https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json"))
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch keys.json")
|
||||
val jsonStr = response.body.string()
|
||||
if (jsonStr.isEmpty()) throw IllegalStateException("keys.json is empty")
|
||||
val key = json.decodeFromString<Map<String, String>>(jsonStr)["mega"]
|
||||
?: throw IllegalStateException("Rapid key not found in keys.json")
|
||||
Log.i("RapidCloudExtractor", "Using Rapid Key: $key")
|
||||
key
|
||||
}
|
||||
private fun requestNewKeyOrNull(): String? = try {
|
||||
client.newCall(GET("https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json"))
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
Log.w("RapidCloudExtractor", "keys.json fetch failed: HTTP ${response.code}")
|
||||
return@use null
|
||||
}
|
||||
val jsonStr = response.body.string()
|
||||
if (jsonStr.isEmpty()) {
|
||||
Log.w("RapidCloudExtractor", "keys.json is empty")
|
||||
return@use null
|
||||
}
|
||||
json.decodeFromString<Map<String, String>>(jsonStr)["mega"]?.also {
|
||||
Log.i("RapidCloudExtractor", "Using Rapid Key: $it")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("RapidCloudExtractor", "requestNewKey failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
private fun decryptOpenSSL(encBase64: String, password: String): String {
|
||||
try {
|
||||
|
||||
@@ -6,3 +6,12 @@ ext {
|
||||
}
|
||||
|
||||
apply plugin: "kei.plugins.extension.legacy"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib:omniembedextractor'))
|
||||
implementation(project(':lib:anilib'))
|
||||
implementation(project(':lib:m3u8server'))
|
||||
implementation(project(':lib:cloudflareinterceptor'))
|
||||
implementation(project(':lib:megacloudextractor'))
|
||||
implementation(project(':lib:rapidcloudextractor'))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.miruro
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Browser-fingerprint request-shaping interceptor for the miruro.tv edge.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* Miruro's Cloudflare edge enforces a **WAF custom-rule block** (not a
|
||||
* managed challenge — `cf-mitigated: challenge` is not set; the body is the
|
||||
* "Sorry, you have been blocked" page with `cf-error-details` and no
|
||||
* `cf-mitigated` header). Live curl against `https://www.miruro.tv/` with a
|
||||
* bare Chrome `User-Agent` returns 200 OK; the *same* UA + `Accept` + a
|
||||
* `Referer` against `/api/secure/pipe?e=<base64url>` returns 403. The WAF
|
||||
* rule is rejecting pipe-API requests whose header set does not match a real
|
||||
* Chrome browser's `fetch()`. The miruro.tv frontend itself sends **no**
|
||||
* custom headers on its GET pipe fetch — every browser-shape header
|
||||
* (`sec-fetch-*`, `sec-ch-ua*`, `Origin`, `Accept-Language`,
|
||||
* `Accept-Encoding` advertising gzip/deflate/br/zstd) is provided for free by
|
||||
* the browser. An OkHttp client does not send any of those by default, so the
|
||||
* WAF flags it as a bot. (This interceptor advertises only `gzip, deflate, br`
|
||||
* — see [header-contract] below for why `zstd` is intentionally omitted.)
|
||||
*
|
||||
* The previous attempt routed every request through `CloudScraperInterceptor`
|
||||
* (the in-repo lib `keiyoushi.lib.cloudscraper`), but that interceptor's
|
||||
* `isBlockPage` path passes through block pages **unresolved by design** —
|
||||
* there is no challenge to solve. Routing through it cannot fix a custom-rule
|
||||
* block. The only fix is to send the headers a real Chrome browser would
|
||||
* send, so the WAF rule does not trip in the first place.
|
||||
*
|
||||
* ## What it does
|
||||
*
|
||||
* For every request that flows through the [client][Miruro.client] network
|
||||
* interceptor chain, this interceptor classifies the request by URL path:
|
||||
*
|
||||
* - **Pipe context** — path begins with `/api/` (the `/api/secure/pipe`
|
||||
* call). Shaped as a same-origin CORS XHR-style fetch: `Sec-Fetch-Dest: empty`,
|
||||
* `Sec-Fetch-Mode: cors`, `Sec-Fetch-Site: same-origin`, `Origin` set.
|
||||
* - **Navigate context** — anything else (the warm-up `GET /` and
|
||||
* `GET /watch/<id>` calls). Shaped as a toplevel navigation:
|
||||
* `Sec-Fetch-Dest: document`, `Sec-Fetch-Mode: navigate`, no `Origin`.
|
||||
*
|
||||
* In both contexts the interceptor **overwrites** the WAF-fingerprint
|
||||
* headers (Accept-Encoding / Accept-Language / User-Agent / Sec-Ch-Ua /
|
||||
* Sec-Ch-Ua-Mobile / Sec-Ch-Ua-Platform / Sec-Fetch-Dest / Sec-Fetch-Mode /
|
||||
* Sec-Fetch-Site / Origin) and **fills-if-absent** the body-shape headers
|
||||
* (Accept / Referer).
|
||||
*
|
||||
* The split is deliberate:
|
||||
* - WAF-fingerprint headers must always be the Chrome 148 desktop values so
|
||||
* the WAF rule never trips, regardless of what the caller passes in.
|
||||
* - Body-shape headers should reflect caller intent where possible (e.g. a
|
||||
* POST body upload should be allowed to set its own `Content-Type`), but
|
||||
* sane browser defaults should be provided when the caller omits them.
|
||||
*
|
||||
* ## Design decisions (user-approved)
|
||||
*
|
||||
* 1. **Owns User-Agent.** Always overwrites `User-Agent` with
|
||||
* [Miruro.USER_AGENT] (Chrome 148 on Windows). The sec-ch-ua brand claim
|
||||
* below matches that UA string.
|
||||
* 2. **Overwrites Accept-Encoding with `gzip, deflate, br`.** The
|
||||
* OkHttp BridgeInterceptor only fills `gzip` if absent and never advertises
|
||||
* `br`; miruro's WAF/edge responds with `content-encoding: br` on pipe
|
||||
* responses, which is then transparently decompressed by the host app's
|
||||
* `okhttp3.CompressionInterceptor` (brotli-aware) before any downstream
|
||||
* interceptor or the body extractor sees the bytes. An earlier revision
|
||||
* advertised `zstd` here and relied on a per-extension
|
||||
* `ZstdDecompressInterceptor` backed by `zstd-jni`, but the `zstd-jni`
|
||||
* native library (`libzstd-jni-*.so`) proved not to be packaged into the
|
||||
* extension APK — `ZstdInputStream`'s static initializer raised
|
||||
* `NoClassDefFoundError` on real Android devices. Dropping `zstd` from the
|
||||
* advertised list sidesteps the native-lib packaging issue entirely while
|
||||
* still advertising the same browser-shape encoding list a real Chrome
|
||||
* desktop browser would send (Chrome advertises `gzip, deflate, br, zstd`;
|
||||
* `zstd` is opt-in only and its absence is not a WAF bot signal — verified
|
||||
* via live curl against the pipe endpoint, which returns 200 +
|
||||
* `x-obfuscated: 2` regardless of whether `zstd` is included in the list).
|
||||
* 3. **Accept-Language `en-US,en;q=0.9`.** The previous warmup code used
|
||||
* `q=0.5`; the browser default is `q=0.9`. The `0.5` value is itself a
|
||||
* bot signal.
|
||||
* 4. **Origin only on pipe context.** Real Chrome does not send `Origin` on
|
||||
* a toplevel `GET` (cold navigation); it does send it on CORS fetches.
|
||||
* Sending `Origin` on a warmup GET would itself be a bot signal.
|
||||
* 5. **Sec-Fetch-Site: `none` on cold navigate.** A warmup GET with no
|
||||
* caller-supplied `Referer` mirrors a fresh browser tab. Setting `none`
|
||||
* here matches what real Chrome sends.
|
||||
*
|
||||
* ## What it does NOT do
|
||||
*
|
||||
* - It does **not** touch `Cookie` — the cookie jar (`JavaNetCookieJar` on
|
||||
* `super.client`, applied by BridgeInterceptor) handles cookie
|
||||
* attach/persist. The warm-up visits performed by [Miruro.ensureBaseVisit]
|
||||
* and [Miruro.ensureWatchPageVisited] populate the jar, and this interceptor
|
||||
* passes cookies through untouched.
|
||||
* - It does **not** mutate the `Response`. Pure request shaping.
|
||||
* - It does **not** log — [Miruro.MiruroDebugInterceptor] logs every miruro
|
||||
* request/response already.
|
||||
*
|
||||
* ## Ordering in the network interceptor chain
|
||||
*
|
||||
* Wired first in [Miruro.client]'s builder (followed only by
|
||||
* [Miruro.MiruroDebugInterceptor]); on the request leg it runs before the
|
||||
* debug interceptor, on the response leg it sees the response *last* — which
|
||||
* is fine because this interceptor does not read the response body at all.
|
||||
* Brotli/gzip response decompression is handled by the host app's
|
||||
* `okhttp3.CompressionInterceptor`, which sits even further towards the
|
||||
* connection, transparently decoding any `content-encoding: br` or
|
||||
* `content-encoding: gzip` body before it reaches our extractor or the debug
|
||||
* interceptor.
|
||||
*
|
||||
* This runs as a **network** interceptor, placing it after `BridgeInterceptor`
|
||||
* — that's required because Bridge interceptors run before network
|
||||
* interceptors, and Bridge pre-fills `Accept-Encoding: gzip` if the header is
|
||||
* absent. To advertise `gzip, deflate, br` instead, this interceptor must
|
||||
* **overwrite** that value (not merely fill-if-absent), which is only possible
|
||||
* on the network-interceptor side of the chain (Bridge's headers have already
|
||||
* been attached and are visible to `request.header(...)` here).
|
||||
*
|
||||
* ## Class visibility
|
||||
*
|
||||
* `internal` — visible only inside the `:src:en:miruro` module. Same package
|
||||
* as [Miruro], so it can read [Miruro.USER_AGENT] (an `internal const` in the
|
||||
* Miruro companion object) without any import.
|
||||
*/
|
||||
internal class MiruroBrowserFingerprintInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val isPipeContext = original.url.encodedPath.removePrefix("/").startsWith("api/")
|
||||
val refererPresent = original.header("Referer") != null
|
||||
|
||||
val builder = original.newBuilder()
|
||||
|
||||
// ── OVERWRITE: WAF-fingerprint headers (both contexts) ──────────────
|
||||
// These MUST be the Chrome 148 desktop values so the WAF custom-rule
|
||||
// block does not trip, regardless of what the caller passed in.
|
||||
builder.header("Accept-Encoding", "gzip, deflate, br")
|
||||
builder.header("Accept-Language", "en-US,en;q=0.9")
|
||||
builder.header("User-Agent", Miruro.USER_AGENT)
|
||||
builder.header("Sec-Ch-Ua", SEC_CH_UA)
|
||||
builder.header("Sec-Ch-Ua-Mobile", SEC_CH_UA_MOBILE)
|
||||
builder.header("Sec-Ch-Ua-Platform", SEC_CH_UA_PLATFORM)
|
||||
|
||||
if (isPipeContext) {
|
||||
// Pipe = same-origin CORS XHR-style fetch.
|
||||
builder.header("Sec-Fetch-Dest", "empty")
|
||||
builder.header("Sec-Fetch-Mode", "cors")
|
||||
builder.header("Sec-Fetch-Site", "same-origin")
|
||||
builder.header("Origin", "https://www.miruro.tv")
|
||||
|
||||
// Fill-if-absent: body-shape defaults for an API fetch.
|
||||
if (original.header("Accept") == null) {
|
||||
builder.header("Accept", "*/*")
|
||||
}
|
||||
if (original.header("Referer") == null) {
|
||||
builder.header("Referer", "https://www.miruro.tv/")
|
||||
}
|
||||
} else {
|
||||
// Navigate context = toplevel GET (warm-up visit).
|
||||
builder.header("Sec-Fetch-Dest", "document")
|
||||
builder.header("Sec-Fetch-Mode", "navigate")
|
||||
// A cold warmup with no caller-supplied Referer mirrors a fresh
|
||||
// browser tab → real Chrome sends `Sec-Fetch-Site: none`. If a
|
||||
// Referer somehow IS present (it shouldn't be after the buildPipe
|
||||
// / warmup edits, but be correct anyway), use `same-origin`.
|
||||
builder.header("Sec-Fetch-Site", if (refererPresent) "same-origin" else "none")
|
||||
// Do NOT set Origin — real browsers omit it on toplevel GET.
|
||||
// Do NOT force a Referer — keep Sec-Fetch-Site: none correct.
|
||||
|
||||
// Fill-if-absent: body-shape defaults for a toplevel navigation.
|
||||
if (original.header("Accept") == null) {
|
||||
builder.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
}
|
||||
}
|
||||
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Brand claim exactly matching Miruro.USER_AGENT (Chrome 148 on Windows).
|
||||
// Real Chrome sends `"Not_A Brand";v="24"` (the underscore-A is literal,
|
||||
// not a typo — Chrome uses the brand-list rotation to detect header
|
||||
// tampering; the underscore variant is one of the rotated brand strings).
|
||||
private const val SEC_CH_UA = "\"Chromium\";v=\"148\", \"Not_A Brand\";v=\"24\", \"Google Chrome\";v=\"148\""
|
||||
private const val SEC_CH_UA_MOBILE = "?0"
|
||||
private const val SEC_CH_UA_PLATFORM = "\"Windows\""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.miruro
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
internal val jsonParser = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AnimeMediaDto(
|
||||
val id: Int = 0,
|
||||
@SerialName("idMal") val malId: Int? = null,
|
||||
val title: TitleDto? = null,
|
||||
val coverImage: CoverImageDto? = null,
|
||||
val bannerImage: String? = null,
|
||||
val description: String? = null,
|
||||
val status: String? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val studios: StudiosDto? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class TitleDto(
|
||||
val userPreferred: String? = null,
|
||||
val romaji: String? = null,
|
||||
val english: String? = null,
|
||||
val native: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CoverImageDto(
|
||||
val extraLarge: String? = null,
|
||||
val large: String? = null,
|
||||
val medium: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudiosDto(
|
||||
val edges: List<StudioEdgeDto>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudioEdgeDto(
|
||||
val isMain: Boolean = false,
|
||||
val node: StudioNodeDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudioNodeDto(
|
||||
val name: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SourcesResponseDto(
|
||||
val streams: List<StreamDto> = emptyList(),
|
||||
val subtitles: List<SubtitleDto> = emptyList(),
|
||||
) {
|
||||
@Serializable
|
||||
data class NestedWrapper(
|
||||
val streams: List<StreamDto> = emptyList(),
|
||||
val subtitles: List<SubtitleDto> = emptyList(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun parse(json: String): SourcesResponseDto {
|
||||
val element = Json.parseToJsonElement(json).jsonObject
|
||||
|
||||
val directStreams = element["streams"]?.jsonArray
|
||||
if (directStreams != null) {
|
||||
return jsonParser.decodeFromString<SourcesResponseDto>(json)
|
||||
}
|
||||
|
||||
for (entry in element.entries) {
|
||||
val value = entry.value
|
||||
if (value is JsonObject && value.containsKey("streams")) {
|
||||
return jsonParser.decodeFromString<NestedWrapper>(value.toString()).let {
|
||||
SourcesResponseDto(streams = it.streams, subtitles = it.subtitles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SourcesResponseDto()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StreamDto(
|
||||
val type: String = "",
|
||||
val url: String = "",
|
||||
val quality: String = "",
|
||||
val resolution: ResolutionDto? = null,
|
||||
val codec: String = "",
|
||||
val audio: String = "",
|
||||
val fansub: String = "",
|
||||
val referer: String = "https://kwik.cx/",
|
||||
val isActive: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResolutionDto(
|
||||
val width: Int = 0,
|
||||
val height: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubtitleDto(
|
||||
val url: String = "",
|
||||
val label: String = "",
|
||||
val language: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConfigResponseDto(
|
||||
val streaming: Map<String, ProviderConfigDto> = emptyMap(),
|
||||
val providerOrder: List<String> = emptyList(),
|
||||
val meta: MetaConfigDto? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class ProviderConfigDto(
|
||||
val capabilities: ProviderCapabilitiesDto = ProviderCapabilitiesDto(),
|
||||
val parent: String? = null,
|
||||
val relationship: String? = null,
|
||||
val visible: Boolean = true,
|
||||
val player: String = "native",
|
||||
val fallback: Int? = null,
|
||||
@Serializable(with = ProxyConfigSerializer::class)
|
||||
val proxy: ProxyConfigDto = ProxyConfigDto(),
|
||||
val cors: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProxyConfigDto(
|
||||
val rotate: Boolean = false,
|
||||
)
|
||||
|
||||
internal object ProxyConfigSerializer : KSerializer<ProxyConfigDto> {
|
||||
override val descriptor: SerialDescriptor = ProxyConfigDto.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ProxyConfigDto) {
|
||||
ProxyConfigDto.serializer().serialize(encoder, value)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): ProxyConfigDto = when (val json = decoder.decodeSerializableValue(JsonElement.serializer())) {
|
||||
is JsonPrimitive if json.booleanOrNull != null -> ProxyConfigDto(rotate = false)
|
||||
is JsonObject -> jsonParser.decodeFromJsonElement(ProxyConfigDto.serializer(), json)
|
||||
else -> ProxyConfigDto()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProviderCapabilitiesDto(
|
||||
val sub: Boolean = false,
|
||||
val dub: Boolean = false,
|
||||
val ssub: Boolean = false,
|
||||
val download: Boolean = false,
|
||||
@SerialName("skip_times")
|
||||
val skipTimes: Boolean = false,
|
||||
val thumbnails: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MetaConfigDto(
|
||||
val anilist: AnilistConfigDto? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class AnilistConfigDto(
|
||||
val graphql: String = "https://graphql.anilist.co",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StatusPageDto(
|
||||
val publicGroupList: List<StatusPageGroupDto> = emptyList(),
|
||||
) {
|
||||
@Serializable
|
||||
data class StatusPageGroupDto(
|
||||
val name: String = "",
|
||||
val monitorList: List<StatusMonitorDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatusMonitorDto(
|
||||
val id: Int = 0,
|
||||
val name: String = "",
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StatusHeartbeatDto(
|
||||
val heartbeatList: Map<String, List<StatusHeartbeatEntryDto>> = emptyMap(),
|
||||
) {
|
||||
@Serializable
|
||||
data class StatusHeartbeatEntryDto(
|
||||
val status: Int = 0,
|
||||
val ping: Int = 0,
|
||||
val time: String = "",
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CachedMirrorsDto(
|
||||
val entries: List<String> = emptyList(),
|
||||
val values: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,379 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.miruro
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import aniyomi.lib.cloudflareinterceptor.CloudflareInterceptor
|
||||
import aniyomi.lib.m3u8server.M3u8Integration
|
||||
import aniyomi.lib.megacloudextractor.MegaCloudExtractor
|
||||
import aniyomi.lib.omniembedextractor.OmniEmbedExtractor
|
||||
import aniyomi.lib.rapidcloudextractor.RapidCloudExtractor
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
class MiruroExtractor(
|
||||
private val client: OkHttpClient,
|
||||
private val pipeKey: ByteArray,
|
||||
private val proxyKey: ByteArray,
|
||||
private val headers: Headers,
|
||||
private val preferences: android.content.SharedPreferences,
|
||||
private val mirrorBaseUrl: String,
|
||||
private val resolveDisplayName: (String) -> String,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MiruroExtractor"
|
||||
|
||||
/**
|
||||
* Host patterns that signal a Zoro-style embed (MegaCloud / RapidCloud
|
||||
* / ChillX family). Pre-routed to their dedicated extractors rather
|
||||
* than handed to OmniEmbedExtractor, which has no entry for these
|
||||
* domains and would silently return an empty list.
|
||||
*/
|
||||
private val MEGACLOUD_HOSTS = listOf("megacloud.tv", "megacloud.club")
|
||||
private val RAPID_CLOUD_HOSTS = listOf("rapid-cloud.co", "scloud")
|
||||
|
||||
/**
|
||||
* Placeholder for MegaCloud's external decryption endpoint; only used
|
||||
* by the encrypted-code path. The vast majority of current streams
|
||||
* return `.m3u8` directly (`data.encrypted == false` OR `.m3u8 in
|
||||
* encoded`), short-circuiting any key fetch. No prior call site ever
|
||||
* instantiated these extractors, so no production value exists to
|
||||
* mirror (confirmed by grep).
|
||||
*/
|
||||
private const val MEGACLOUD_API_PLACEHOLDER = "https://megacloud.example/decrypt/"
|
||||
|
||||
/**
|
||||
* Referer that StreamDto defaults to in [MiruroDto.StreamDto]. The
|
||||
* pipe API populates this as `https://kwik.cx/` for kwik-served HLS
|
||||
* streams (AnimePahe) but leaves the kwik default for many other
|
||||
* providers, including Miruro's own `vault-*.owocdn.top` CDN. Using
|
||||
* the kwik referer to fetch owocdn m3u8 is wrong — that host expects
|
||||
* a Miruro referer (the active mirror baseUrl) and 403s otherwise,
|
||||
* which previously triggered [CloudflareInterceptor] and the crash
|
||||
* chain documented in [MiruroExtractor]'s m3u8 path.
|
||||
*/
|
||||
internal const val KWIK_DEFAULT_REFERER = "https://kwik.cx/"
|
||||
|
||||
/**
|
||||
* Miruro frontend proxy servers (from `VITE_PROXY_A` / `VITE_PROXY_B`
|
||||
* in `env2.js`). The frontend wraps every provider stream URL through
|
||||
* one of these proxies: the proxy fetches the upstream m3u8/segment
|
||||
* and relays it back, bypassing CORS and header-gating that would
|
||||
* 403 a direct fetch from outside the browser.
|
||||
*/
|
||||
private const val PROXY_A = "https://vault01.ultracloud.cc/"
|
||||
private const val PROXY_B = "https://vault02.ultracloud.cc/"
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit hash constants (IETF RFC 7020).
|
||||
* Used by the frontend to deterministically select between
|
||||
* [PROXY_A] and [PROXY_B] based on episode/anilist IDs.
|
||||
*/
|
||||
private const val FNV_OFFSET_BASIS: Int = 2166136261.toInt()
|
||||
private const val FNV_PRIME: Int = 16777619
|
||||
|
||||
/**
|
||||
* Encode a [ByteArray] to base64url without padding, matching the
|
||||
* frontend's `ix()` / `ax()` obfuscation step (`btoa` + replace
|
||||
* `+`→`-`, `/`→`_`, strip `=`).
|
||||
*/
|
||||
private fun base64UrlNoPad(data: ByteArray): String = Base64.encodeToString(data, Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING)
|
||||
|
||||
/**
|
||||
* XOR-obfuscate a UTF-8 string with [key] bytes (cycled) and
|
||||
* base64url-encode the result. Mirrors the frontend's `ix()`
|
||||
* function from `WatchRoute-B2vRFobK.js`.
|
||||
*/
|
||||
private fun xorEncode(input: String, key: ByteArray): String {
|
||||
val bytes = input.toByteArray(Charsets.UTF_8)
|
||||
val out = ByteArray(bytes.size)
|
||||
for (i in bytes.indices) {
|
||||
out[i] = (bytes[i].toInt() xor key[i % key.size].toInt()).toByte()
|
||||
}
|
||||
return base64UrlNoPad(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit hash of a string, returning the hash mod 2 to
|
||||
* deterministically select between [PROXY_A] (even) and [PROXY_B]
|
||||
* (odd). Mirrors the frontend's `Xb()` function.
|
||||
*
|
||||
* If [seed] is blank, defaults to 0 (→ PROXY_A).
|
||||
*/
|
||||
private fun fnv1aMod2(seed: String): Int {
|
||||
if (seed.isEmpty()) return 0
|
||||
var hash = FNV_OFFSET_BASIS
|
||||
for (b in seed.toByteArray(Charsets.UTF_8)) {
|
||||
hash = hash xor (b.toInt() and 0xFF)
|
||||
hash *= FNV_PRIME
|
||||
}
|
||||
return hash and 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Miruro proxy URL wrapping [streamUrl] and [referer]
|
||||
* through `vault01/02.ultracloud.cc`. The proxy fetches the upstream
|
||||
* content and relays it, bypassing CORS/403s from direct fetches.
|
||||
*
|
||||
* URL format (from frontend `cx()` / `lx()`):
|
||||
* `{proxyBase}{xorEncode(streamUrl)}~{xorEncode(referer)}/pl.m3u8`
|
||||
*
|
||||
* If [proxyKey] is empty, returns the original [streamUrl] unchanged
|
||||
* (no proxy wrapping possible).
|
||||
*/
|
||||
fun buildProxiedUrl(
|
||||
streamUrl: String,
|
||||
referer: String,
|
||||
proxyKey: ByteArray,
|
||||
proxySeed: String,
|
||||
): String {
|
||||
if (proxyKey.isEmpty()) return streamUrl
|
||||
val proxyBase = if (fnv1aMod2(proxySeed) == 0) PROXY_A else PROXY_B
|
||||
val obfUrl = xorEncode(streamUrl, proxyKey)
|
||||
val obfReferer = xorEncode(referer, proxyKey)
|
||||
return "${proxyBase}$obfUrl~$obfReferer/pl.m3u8"
|
||||
}
|
||||
}
|
||||
|
||||
private val embedExtractor by lazy { OmniEmbedExtractor(client, headers) }
|
||||
|
||||
/**
|
||||
* Dedicated HTTP/1.1 client for media / m3u8 fetches. Some provider CDNs
|
||||
* (notably Zoro's edge) reject HTTP/2 connections with the host app's
|
||||
* default OkHttp fingerprint by stamping a 444 status and closing the
|
||||
* socket. Forcing HTTP/1.1 + a 30 s read timeout (matching the proven
|
||||
* AnikotoTheme `m3u8Client` shape) avoids that fingerprint check.
|
||||
*
|
||||
* A [CloudflareInterceptor] is wired in to transparently solve Cloudflare
|
||||
* challenges for upstreams that WAF the m3u8 / segment URL (AnimePahe →
|
||||
* kwik.cx CDN is the canonical case — see [lib/kwikextractor] for the
|
||||
* precedent). The interceptor caches `cf_clearance` per host, so after
|
||||
* the first WebView solve the cached cookies clear subsequent requests
|
||||
* without burning another solve.
|
||||
*/
|
||||
private val mediaClient by lazy {
|
||||
client.newBuilder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
.addInterceptor(CloudflareInterceptor(client))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Header-gated fallback leg: identical to [mediaClient] minus the
|
||||
* [CloudflareInterceptor]. Supplied to [M3u8Integration] / [m3u8Integration]
|
||||
* so that when the primary client's WebView solve fails (the canonical
|
||||
* crash chain: `vault-99.owocdn.top` returns 403 → CloudflareInterceptor
|
||||
* WebView solve produces no cookies → IOException propagates as 500 to
|
||||
* mpv), the m3u8 server retries through this client with whatever browser
|
||||
* headers the caller threaded via the proxied URL. Many header-gated
|
||||
* CDNs serve 200 to a plain HTTP/1.1 request with the correct Referer
|
||||
* once the WebView detour is bypassed.
|
||||
*/
|
||||
private val mediaClientFallback by lazy {
|
||||
client.newBuilder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
.build()
|
||||
}
|
||||
|
||||
private val m3u8Integration by lazy { M3u8Integration(mediaClient, mediaClientFallback) }
|
||||
|
||||
private val megaCloudExtractor by lazy {
|
||||
MegaCloudExtractor(mediaClient, headers, MEGACLOUD_API_PLACEHOLDER)
|
||||
}
|
||||
|
||||
private val rapidCloudExtractor by lazy {
|
||||
RapidCloudExtractor(mediaClient, headers, preferences)
|
||||
}
|
||||
|
||||
fun providerDisplayName(key: String): String = resolveDisplayName(key)
|
||||
|
||||
fun decryptResponse(response: Response): String {
|
||||
val obfuscated = response.header("x-obfuscated") ?: "1"
|
||||
val bodyStr = response.body?.string()?.trim() ?: ""
|
||||
|
||||
if (obfuscated != "2") {
|
||||
Log.d(TAG, "decryptResponse: not obfuscated (header=$obfuscated), ${bodyStr.length} chars")
|
||||
return bodyStr
|
||||
}
|
||||
|
||||
if (bodyStr.isEmpty()) {
|
||||
Log.e(TAG, "Empty response body from server")
|
||||
return ""
|
||||
}
|
||||
|
||||
return try {
|
||||
val decoded = Base64.decode(bodyStr, Base64.URL_SAFE)
|
||||
val data = decoded
|
||||
for (i in data.indices) {
|
||||
data[i] = (data[i].toInt() xor pipeKey[i % pipeKey.size].toInt()).toByte()
|
||||
}
|
||||
|
||||
val result = GZIPInputStream(java.io.ByteArrayInputStream(data)).use { gzipStream ->
|
||||
gzipStream.bufferedReader(Charsets.UTF_8).readText()
|
||||
}
|
||||
Log.d(TAG, "decryptResponse: decrypted ${bodyStr.length} → ${result.length} chars")
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decrypt response from server: ${e.message}")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun parseStreamsFromResponse(
|
||||
response: Response,
|
||||
subType: String?,
|
||||
providerKey: String = "",
|
||||
episodeId: String = "",
|
||||
anilistId: String = "",
|
||||
): List<Video> {
|
||||
val json = try {
|
||||
response.use(::decryptResponse)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decrypt stream response: ${e.message}")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val sourcesDto = try {
|
||||
SourcesResponseDto.parse(json)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse sources response: ${e.message}")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (sourcesDto.streams.isEmpty()) {
|
||||
Log.w(TAG, "Empty streams array in response (subType=$subType, provider=$providerKey)")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
Log.d(TAG, "parseStreamsFromResponse: ${sourcesDto.streams.size} streams, ${sourcesDto.subtitles.size} subtitles (subType=$subType, provider=$providerKey)")
|
||||
|
||||
val subTypeLabel = when (subType) {
|
||||
"sub" -> "Sub"
|
||||
"dub" -> "Dub"
|
||||
"ssub" -> "Soft Sub"
|
||||
"h-sub" -> "Hard Sub"
|
||||
null -> null
|
||||
else -> subType.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
val subtitles = sourcesDto.subtitles
|
||||
.filter { it.url.isNotEmpty() }
|
||||
.map { sub ->
|
||||
Track(sub.url, sub.label.ifEmpty { sub.language })
|
||||
}
|
||||
|
||||
val videos = mutableListOf<Video>()
|
||||
|
||||
// FNV hash seed for proxy server selection: `${episodeId}|${anilistId}`,
|
||||
// matching the frontend's `Xb(episodeId, anilistId)` logic.
|
||||
val proxySeed = "$episodeId|$anilistId"
|
||||
|
||||
for (stream in sourcesDto.streams) {
|
||||
if (stream.url.isEmpty()) continue
|
||||
|
||||
val qualityInt = stream.quality.toIntOrNull() ?: 0
|
||||
val width = stream.resolution?.width ?: 0
|
||||
val height = stream.resolution?.height ?: 0
|
||||
|
||||
val streamTypeLabel = stream.type.uppercase()
|
||||
|
||||
val qualityLabel = buildString {
|
||||
if (providerKey.isNotEmpty()) append("${providerDisplayName(providerKey)} - ")
|
||||
append("${qualityInt}p")
|
||||
if (subTypeLabel != null) append(" $subTypeLabel")
|
||||
if (width > 0 && height > 0) append(" - ${width}x$height")
|
||||
if (stream.codec.isNotEmpty()) append(" ${stream.codec}")
|
||||
if (stream.audio.isNotEmpty()) append(" ${stream.audio}")
|
||||
if (stream.fansub.isNotEmpty()) append(" ${stream.fansub}")
|
||||
append(" $streamTypeLabel")
|
||||
}
|
||||
|
||||
when (stream.type.lowercase()) {
|
||||
"hls" -> {
|
||||
val proxyReferer = stream.referer.trim().ifEmpty { KWIK_DEFAULT_REFERER }
|
||||
val proxiedUrl = buildProxiedUrl(
|
||||
streamUrl = stream.url,
|
||||
referer = proxyReferer,
|
||||
proxyKey = proxyKey,
|
||||
proxySeed = proxySeed,
|
||||
)
|
||||
if (proxiedUrl != stream.url) {
|
||||
Log.d(TAG, "HLS proxy-wrapped: ${stream.url.take(60)} → ${proxiedUrl.take(60)}")
|
||||
}
|
||||
val proxyHeaders = headers.newBuilder()
|
||||
.set("Referer", "${mirrorBaseUrl.trimEnd('/')}/")
|
||||
.set("Origin", mirrorBaseUrl.trimEnd('/'))
|
||||
.build()
|
||||
videos.add(
|
||||
Video(proxiedUrl, qualityLabel, proxiedUrl, proxyHeaders, subtitleTracks = subtitles),
|
||||
)
|
||||
}
|
||||
"embed" -> {
|
||||
Log.d(TAG, "parseStreams: extracting embed: ${stream.url.take(80)}")
|
||||
val embedVideos = extractPreRoutedEmbed(
|
||||
embedUrl = stream.url,
|
||||
qualityLabel = qualityLabel,
|
||||
subtitles = subtitles,
|
||||
)
|
||||
if (embedVideos.isNotEmpty()) {
|
||||
videos.addAll(embedVideos)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to extract from embed: ${stream.url.take(80)}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown stream type '${stream.type}', skipping: ${stream.url.take(80)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val proxied = m3u8Integration.processVideoList(videos)
|
||||
Log.d(TAG, "parseStreamsFromResponse: built ${videos.size} videos from ${sourcesDto.streams.size} streams, ${proxied.size} proxied via m3u8server")
|
||||
return proxied
|
||||
}
|
||||
|
||||
private fun extractPreRoutedEmbed(
|
||||
embedUrl: String,
|
||||
qualityLabel: String,
|
||||
subtitles: List<Track>,
|
||||
): List<Video> {
|
||||
val host = runCatching { embedUrl.toHttpUrlOrNull()?.host }.getOrNull()
|
||||
val lowerHost = host?.lowercase() ?: ""
|
||||
|
||||
return when {
|
||||
MEGACLOUD_HOSTS.any { lowerHost.contains(it) } -> runCatching {
|
||||
megaCloudExtractor.getVideosFromUrl(
|
||||
url = embedUrl,
|
||||
type = "Multi",
|
||||
name = qualityLabel,
|
||||
withM3u8Server = false,
|
||||
)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "MegaCloud extraction failed: ${it.message}")
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
RAPID_CLOUD_HOSTS.any { lowerHost.contains(it) } -> runCatching {
|
||||
rapidCloudExtractor.getVideosFromUrl(embedUrl, type = "Multi", name = qualityLabel)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "RapidCloud extraction failed: ${it.message}")
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
else -> {
|
||||
embedExtractor.extractVideos(
|
||||
embedUrl = embedUrl,
|
||||
qualityLabel = qualityLabel,
|
||||
subtitles = subtitles,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package eu.kanade.tachiyomi.animeextension.en.miruro
|
||||
|
||||
import aniyomi.lib.anilib.MediaFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter.TriState
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import java.util.Calendar
|
||||
|
||||
object MiruroFilters {
|
||||
|
||||
@@ -15,6 +18,9 @@ object MiruroFilters {
|
||||
fun toQueryPart() = vals[state].second
|
||||
}
|
||||
|
||||
open class TriStateFilterList(name: String, values: List<TriFilterVal>) : AnimeFilter.Group<TriState>(name, values)
|
||||
class TriFilterVal(name: String) : TriState(name)
|
||||
|
||||
open class CheckBoxFilterList(name: String, values: List<CheckBox>) : AnimeFilter.Group<AnimeFilter.CheckBox>(name, values)
|
||||
|
||||
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
|
||||
@@ -28,18 +34,35 @@ object MiruroFilters {
|
||||
): List<String> = (this.getFirst<R>() as CheckBoxFilterList).state
|
||||
.mapNotNull { checkbox ->
|
||||
if (checkbox.state) {
|
||||
options.find { it.first == checkbox.name }!!.second
|
||||
options.find { it.first == checkbox.name }?.second
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified R> AnimeFilterList.parseTriState(
|
||||
options: Array<Pair<String, String>>,
|
||||
): Pair<List<String>, List<String>> {
|
||||
val state = (this.getFirst<R>() as TriStateFilterList).state
|
||||
val included = mutableListOf<String>()
|
||||
val excluded = mutableListOf<String>()
|
||||
for (filter in state) {
|
||||
if (filter.isIgnored()) continue
|
||||
val value = options.find { it.first == filter.name }?.second ?: continue
|
||||
when (filter.state) {
|
||||
TriState.STATE_INCLUDE -> included.add(value)
|
||||
TriState.STATE_EXCLUDE -> excluded.add(value)
|
||||
}
|
||||
}
|
||||
return included to excluded
|
||||
}
|
||||
|
||||
class SortFilter : QueryPartFilter("Sort", MiruroFiltersData.SORT)
|
||||
|
||||
class GenreFilter :
|
||||
CheckBoxFilterList(
|
||||
TriStateFilterList(
|
||||
"Genres",
|
||||
MiruroFiltersData.GENRES.map { CheckBoxVal(it.first, false) },
|
||||
MiruroFiltersData.GENRES.map { TriFilterVal(it.first) },
|
||||
)
|
||||
|
||||
class YearFilter : QueryPartFilter("Year", MiruroFiltersData.YEARS)
|
||||
@@ -55,11 +78,13 @@ object MiruroFilters {
|
||||
)
|
||||
|
||||
class TagsFilter :
|
||||
CheckBoxFilterList(
|
||||
TriStateFilterList(
|
||||
"Tags",
|
||||
MiruroFiltersData.TAGS.map { CheckBoxVal(it.first, false) },
|
||||
MiruroFiltersData.TAGS.map { TriFilterVal(it.first) },
|
||||
)
|
||||
|
||||
class DubLanguageFilter : QueryPartFilter("Dub Language", MiruroFiltersData.DUB_LANGUAGES)
|
||||
|
||||
val FILTER_LIST get() = AnimeFilterList(
|
||||
SortFilter(),
|
||||
AnimeFilter.Separator(),
|
||||
@@ -70,28 +95,37 @@ object MiruroFilters {
|
||||
YearFilter(),
|
||||
SeasonFilter(),
|
||||
StatusFilter(),
|
||||
DubLanguageFilter(),
|
||||
)
|
||||
|
||||
data class FilterSearchParams(
|
||||
val sort: String = "all",
|
||||
val genres: List<String> = emptyList(),
|
||||
val excludedGenres: List<String> = emptyList(),
|
||||
val tags: List<String> = emptyList(),
|
||||
val excludedTags: List<String> = emptyList(),
|
||||
val year: String = "all",
|
||||
val season: String = "all",
|
||||
val status: String = "all",
|
||||
val formats: List<String> = emptyList(),
|
||||
val dubLanguage: String = "all",
|
||||
)
|
||||
|
||||
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
|
||||
if (filters.isEmpty()) return FilterSearchParams()
|
||||
val (includedGenres, excludedGenres) = filters.parseTriState<GenreFilter>(MiruroFiltersData.GENRES)
|
||||
val (includedTags, excludedTags) = filters.parseTriState<TagsFilter>(MiruroFiltersData.TAGS)
|
||||
return FilterSearchParams(
|
||||
sort = filters.asQueryPart<SortFilter>(),
|
||||
genres = filters.parseCheckbox<GenreFilter>(MiruroFiltersData.GENRES),
|
||||
tags = filters.parseCheckbox<TagsFilter>(MiruroFiltersData.TAGS),
|
||||
genres = includedGenres,
|
||||
excludedGenres = excludedGenres,
|
||||
tags = includedTags,
|
||||
excludedTags = excludedTags,
|
||||
year = filters.asQueryPart<YearFilter>(),
|
||||
season = filters.asQueryPart<SeasonFilter>(),
|
||||
status = filters.asQueryPart<StatusFilter>(),
|
||||
formats = filters.parseCheckbox<FormatFilter>(MiruroFiltersData.FORMATS),
|
||||
dubLanguage = filters.asQueryPart<DubLanguageFilter>(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +151,6 @@ object MiruroFilters {
|
||||
Pair("Drama", "Drama"),
|
||||
Pair("Ecchi", "Ecchi"),
|
||||
Pair("Fantasy", "Fantasy"),
|
||||
Pair("Hentai", "Hentai"),
|
||||
Pair("Horror", "Horror"),
|
||||
Pair("Mahou Shoujo", "Mahou Shoujo"),
|
||||
Pair("Mecha", "Mecha"),
|
||||
@@ -132,7 +165,7 @@ object MiruroFilters {
|
||||
Pair("Thriller", "Thriller"),
|
||||
)
|
||||
|
||||
val YEARS = arrayOf(ALL) + (2026 downTo 1940).map {
|
||||
val YEARS = arrayOf(ALL) + (Calendar.getInstance().get(Calendar.YEAR) downTo 1940).map {
|
||||
Pair(it.toString(), it.toString())
|
||||
}.toTypedArray()
|
||||
|
||||
@@ -163,6 +196,27 @@ object MiruroFilters {
|
||||
Pair("Music", "MUSIC"),
|
||||
)
|
||||
|
||||
val DUB_LANGUAGES = arrayOf(
|
||||
ALL,
|
||||
Pair("English", "English"),
|
||||
Pair("Japanese", "Japanese"),
|
||||
Pair("Spanish", "Español"),
|
||||
Pair("Portuguese", "Português"),
|
||||
Pair("French", "Français"),
|
||||
Pair("German", "Deutsch"),
|
||||
Pair("Italian", "Italiano"),
|
||||
Pair("Korean", "한국어"),
|
||||
Pair("Chinese", "中文"),
|
||||
Pair("Arabic", "العربية"),
|
||||
Pair("Hindi", "हिन्दी"),
|
||||
Pair("Russian", "Русский"),
|
||||
Pair("Turkish", "Türkçe"),
|
||||
Pair("Thai", "ไทย"),
|
||||
Pair("Polish", "Polski"),
|
||||
Pair("Tagalog", "Tagalog"),
|
||||
Pair("Ukrainian", "Українська"),
|
||||
)
|
||||
|
||||
val TAGS = arrayOf(
|
||||
Pair("Achromatic", "Achromatic"),
|
||||
Pair("Achronological Order", "Achronological Order"),
|
||||
@@ -489,3 +543,17 @@ object MiruroFilters {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun MiruroFilters.FilterSearchParams.toMediaFilter(page: Int = 1, perPage: Int = 20): MediaFilter = MediaFilter(
|
||||
sort = sort.takeIf { it != "all" },
|
||||
genres = genres.takeIf { it.isNotEmpty() },
|
||||
excludedGenres = excludedGenres.takeIf { it.isNotEmpty() },
|
||||
tags = tags.takeIf { it.isNotEmpty() },
|
||||
excludedTags = excludedTags.takeIf { it.isNotEmpty() },
|
||||
seasonYear = year.toIntOrNull(),
|
||||
season = season.takeIf { it != "all" },
|
||||
status = status.takeIf { it != "all" },
|
||||
formatList = formats.takeIf { it.isNotEmpty() },
|
||||
page = page,
|
||||
perPage = perPage,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user