|
| 1 | +package com.animeinweb |
| 2 | + |
| 3 | +import com.lagradost.cloudstream3.* |
| 4 | +import com.lagradost.cloudstream3.utils.* |
| 5 | + |
| 6 | +class AnimeInWebProvider : MainAPI() { |
| 7 | + override var mainUrl = "https://animeinweb.com" |
| 8 | + override var name = "AnimeInWeb" |
| 9 | + override val hasMainPage = true |
| 10 | + override var lang = "id" |
| 11 | + override val hasDownloadSupport = true |
| 12 | + override val supportedTypes = setOf( |
| 13 | + TvType.Anime, |
| 14 | + TvType.AnimeMovie, |
| 15 | + TvType.OVA, |
| 16 | + ) |
| 17 | + |
| 18 | + // ─── API ────────────────────────────────────────────────────────────────── |
| 19 | + private val apiBase = "https://xyz-api.animein.net/3/2" |
| 20 | + private val cdnBase = "https://xyz-api.animein.net" |
| 21 | + private val idUser = "388448" |
| 22 | + private val keyClient = "WOcU74yO3OznevT4fju3CS2ovlff9vVFt788u6tZNUjxyrWlqQ" |
| 23 | + private val apkVer = "5.0.2" |
| 24 | + |
| 25 | + private val defaultHeaders = mapOf( |
| 26 | + "Accept" to "application/json", |
| 27 | + "User-Agent" to "AnimeInWeb/$apkVer Android", |
| 28 | + ) |
| 29 | + private val baseParams get() = mapOf( |
| 30 | + "id_user" to idUser, |
| 31 | + "key_client" to keyClient, |
| 32 | + "apk_ver" to apkVer, |
| 33 | + ) |
| 34 | + |
| 35 | + private suspend fun apiGet(path: String, extra: Map<String, String> = emptyMap()): NiceResponse { |
| 36 | + val params = (baseParams + extra).entries.joinToString("&") { "${it.key}=${it.value}" } |
| 37 | + return app.get("$apiBase/$path?$params", referer = mainUrl, headers = defaultHeaders) |
| 38 | + } |
| 39 | + |
| 40 | + /** Fix relative image path → absolute URL */ |
| 41 | + private fun String?.fixImage(): String? { |
| 42 | + if (this.isNullOrEmpty()) return null |
| 43 | + return if (startsWith("http")) this else "$cdnBase$this" |
| 44 | + } |
| 45 | + |
| 46 | + // ─── Type / Status ──────────────────────────────────────────────────────── |
| 47 | + companion object { |
| 48 | + fun getType(t: String): TvType = when { |
| 49 | + t.equals("OVA", ignoreCase = true) -> TvType.OVA |
| 50 | + t.equals("MOVIE", ignoreCase = true) -> TvType.AnimeMovie |
| 51 | + else -> TvType.Anime |
| 52 | + } |
| 53 | + fun getStatus(s: String): ShowStatus = when { |
| 54 | + s.equals("ONGOING", ignoreCase = true) -> ShowStatus.Ongoing |
| 55 | + s.equals("FINISHED", ignoreCase = true) -> ShowStatus.Completed |
| 56 | + else -> ShowStatus.Completed |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + // ─── Main Page ──────────────────────────────────────────────────────────── |
| 61 | + override val mainPage = mainPageOf( |
| 62 | + "home/hot" to "🔥 Populer", |
| 63 | + "home/update" to "🆕 Terbaru Update", |
| 64 | + "home/ongoing" to "📺 Sedang Tayang", |
| 65 | + "home/finished" to "✅ Selesai", |
| 66 | + "home/movie" to "🎬 Anime Movie", |
| 67 | + "home/recommended" to "⭐ Rekomendasi", |
| 68 | + ) |
| 69 | + |
| 70 | + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { |
| 71 | + val resp = apiGet(request.data, extra = mapOf("limit" to "24", "page" to "$page", "genre_in" to "")) |
| 72 | + val items = resp.parsedSafe<ApiListResponse>()?.data?.allItems() ?: emptyList() |
| 73 | + return newHomePageResponse(request.name, items.map { it.toSearchResponse() }) |
| 74 | + } |
| 75 | + |
| 76 | + // ─── Search ─────────────────────────────────────────────────────────────── |
| 77 | + override suspend fun search(query: String): List<SearchResponse> { |
| 78 | + val resp = apiGet("search", extra = mapOf("q" to query, "limit" to "30")) |
| 79 | + return resp.parsedSafe<ApiListResponse>()?.data?.allItems() |
| 80 | + ?.map { it.toSearchResponse() } ?: emptyList() |
| 81 | + } |
| 82 | + |
| 83 | + // ─── Load Detail ────────────────────────────────────────────────────────── |
| 84 | + // Endpoint: GET /3/2/movie/detail/{id} |
| 85 | + // Response: { data: { movie, episode (latest), season[] } } |
| 86 | + override suspend fun load(url: String): LoadResponse? { |
| 87 | + val movieId = url.substringAfterLast("/") |
| 88 | + |
| 89 | + val detail = apiGet("movie/detail/$movieId") |
| 90 | + .parsedSafe<ApiDetailResponse>()?.data ?: return null |
| 91 | + val movie = detail.movie ?: return null |
| 92 | + val seasons = detail.season ?: emptyList() |
| 93 | + |
| 94 | + val episodes = buildEpisodeList(movieId) |
| 95 | + |
| 96 | + val tags = movie.genre |
| 97 | + ?.split(",") |
| 98 | + ?.map { it.trim() } |
| 99 | + ?.filter { it.isNotEmpty() && it != "-" } |
| 100 | + |
| 101 | + return newAnimeLoadResponse( |
| 102 | + name = movie.title ?: "Unknown", |
| 103 | + url = url, |
| 104 | + type = getType(movie.type ?: "SERIES") |
| 105 | + ) { |
| 106 | + posterUrl = movie.image_poster.fixImage() |
| 107 | + backgroundPosterUrl = movie.image_cover.fixImage() |
| 108 | + plot = movie.synopsis?.replace("\r\n", "\n") |
| 109 | + this.tags = tags |
| 110 | + showStatus = getStatus(movie.status ?: "") |
| 111 | + year = movie.year?.toIntOrNull() |
| 112 | + duration = movie.duration?.toIntOrNull() |
| 113 | + |
| 114 | + movie.studio?.takeIf { it.isNotEmpty() }?.let { |
| 115 | + this.actors = listOf(ActorData(Actor(it))) |
| 116 | + } |
| 117 | + |
| 118 | + if (seasons.size > 1) { |
| 119 | + this.recommendations = seasons |
| 120 | + .filter { it.id != movieId } |
| 121 | + .map { it.toSearchResponse() } |
| 122 | + } |
| 123 | + |
| 124 | + addEpisodes(DubStatus.Subbed, episodes) |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + private suspend fun buildEpisodeList(movieId: String): List<Episode> { |
| 129 | + val resp = apiGet( |
| 130 | + "movie/$movieId/episode", |
| 131 | + extra = mapOf("limit" to "999", "sort" to "asc") |
| 132 | + ) |
| 133 | + val list = resp.parsedSafe<ApiEpisodeListResponse>()?.data?.allItems() |
| 134 | + ?: return emptyList() |
| 135 | + |
| 136 | + return list.mapIndexed { idx, ep -> |
| 137 | + val epNum = ep.index?.toIntOrNull() ?: ep.number ?: (idx + 1) |
| 138 | + // data = episodeId (that's all we need for streamnew) |
| 139 | + newEpisode(ep.id ?: "") { |
| 140 | + this.name = ep.title?.takeIf { it.isNotBlank() } ?: "Episode $epNum" |
| 141 | + this.episode = epNum |
| 142 | + this.posterUrl = ep.image.fixImage() |
| 143 | + this.addDate(ep.key_time ?: ep.aired) |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + // ─── Load Links ─────────────────────────────────────────────────────────── |
| 149 | + // Confirmed endpoint: GET /3/2/episode/streamnew/{episodeId} |
| 150 | + // Response: { data: { episode, episode_next, server: [ {link, quality, type, name} ] } } |
| 151 | + override suspend fun loadLinks( |
| 152 | + data: String, // = episodeId e.g. "313448" |
| 153 | + isCasting: Boolean, |
| 154 | + subtitleCallback: (SubtitleFile) -> Unit, |
| 155 | + callback: (ExtractorLink) -> Unit |
| 156 | + ): Boolean { |
| 157 | + val episodeId = data.ifEmpty { return false } |
| 158 | + |
| 159 | + val resp = apiGet("episode/streamnew/$episodeId") |
| 160 | + val servers = resp.parsedSafe<ApiStreamResponse>()?.data?.server |
| 161 | + ?: return false |
| 162 | + |
| 163 | + servers.forEach { server -> |
| 164 | + val link = server.link?.ifEmpty { null } ?: return@forEach |
| 165 | + val quality = server.quality ?: "" |
| 166 | + val label = buildString { |
| 167 | + append(server.name ?: "Server") |
| 168 | + if (quality.isNotEmpty()) append(" $quality") |
| 169 | + } |
| 170 | + |
| 171 | + when (server.type?.lowercase()) { |
| 172 | + "direct" -> { |
| 173 | + // Direct MP4 from storages.animein.net — confirmed |
| 174 | + callback( |
| 175 | + newExtractorLink( |
| 176 | + source = name, |
| 177 | + name = label, |
| 178 | + url = link, |
| 179 | + type = ExtractorLinkType.VIDEO, |
| 180 | + ) { |
| 181 | + this.quality = when (quality) { |
| 182 | + "1080p" -> Qualities.P1080.value |
| 183 | + "720p" -> Qualities.P720.value |
| 184 | + "480p" -> Qualities.P480.value |
| 185 | + "360p" -> Qualities.P360.value |
| 186 | + else -> Qualities.Unknown.value |
| 187 | + } |
| 188 | + this.isM3u8 = false |
| 189 | + } |
| 190 | + ) |
| 191 | + } |
| 192 | + else -> { |
| 193 | + // Embed / iframe server |
| 194 | + loadExtractor(link, mainUrl, subtitleCallback, callback) |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + return true |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +// ─── Data Classes ───────────────────────────────────────────────────────────── |
| 204 | + |
| 205 | +// LIST (home/search) |
| 206 | +data class ApiListResponse( |
| 207 | + val status: Int? = null, |
| 208 | + val error: Boolean? = null, |
| 209 | + val data: ListData? = null, |
| 210 | +) |
| 211 | +data class ListData( |
| 212 | + val movie: List<AnimeItem>? = null, |
| 213 | + val anime: List<AnimeItem>? = null, |
| 214 | + val list: List<AnimeItem>? = null, |
| 215 | +) { fun allItems() = movie ?: anime ?: list ?: emptyList() } |
| 216 | + |
| 217 | +data class AnimeItem( |
| 218 | + val id: String? = null, |
| 219 | + val title: String? = null, |
| 220 | + val synopsis: String? = null, |
| 221 | + val synonyms: String? = null, |
| 222 | + val image_poster: String? = null, |
| 223 | + val image_cover: String? = null, |
| 224 | + val type: String? = null, |
| 225 | + val year: String? = null, |
| 226 | + val day: String? = null, |
| 227 | + val status: String? = null, |
| 228 | + val views: String? = null, |
| 229 | + val favorites: String? = null, |
| 230 | + val studio: String? = null, |
| 231 | + val aired_start: String? = null, |
| 232 | + val aired_end: String? = null, |
| 233 | + val genre: String? = null, |
| 234 | + val score: String? = null, |
| 235 | + val duration: String? = null, |
| 236 | + val season: String? = null, |
| 237 | + val is_fav: Any? = null, |
| 238 | + val total_episode: Int? = null, |
| 239 | + val latest_episode: Int? = null, |
| 240 | +) { |
| 241 | + fun toSearchResponse() = newAnimeSearchResponse( |
| 242 | + name = title ?: "Unknown", |
| 243 | + url = "https://animeinweb.com/anime/$id", |
| 244 | + type = AnimeInWebProvider.getType(type ?: "SERIES") |
| 245 | + ) { |
| 246 | + posterUrl = if (image_poster?.startsWith("http") == true) image_poster |
| 247 | + else "https://xyz-api.animein.net$image_poster" |
| 248 | + addSub(latest_episode ?: total_episode) |
| 249 | + } |
| 250 | +} |
| 251 | + |
| 252 | +// DETAIL → { data: { movie, episode, season[] } } |
| 253 | +data class ApiDetailResponse( |
| 254 | + val status: Int? = null, |
| 255 | + val error: Boolean? = null, |
| 256 | + val data: DetailData? = null, |
| 257 | +) |
| 258 | +data class DetailData( |
| 259 | + val movie: AnimeItem? = null, |
| 260 | + val episode: EpisodeItem? = null, |
| 261 | + val season: List<AnimeItem>? = null, |
| 262 | +) |
| 263 | + |
| 264 | +// EPISODE LIST → { data: { episode[] } } |
| 265 | +data class ApiEpisodeListResponse( |
| 266 | + val status: Int? = null, |
| 267 | + val data: EpisodeListData? = null, |
| 268 | +) |
| 269 | +data class EpisodeListData( |
| 270 | + val episode: List<EpisodeItem>? = null, |
| 271 | + val list: List<EpisodeItem>? = null, |
| 272 | +) { fun allItems() = episode ?: list ?: emptyList() } |
| 273 | + |
| 274 | +data class EpisodeItem( |
| 275 | + val id: String? = null, |
| 276 | + val title: String? = null, |
| 277 | + val index: String? = null, // "1", "2", ... |
| 278 | + val number: Int? = null, |
| 279 | + val episode_number: Int? = null, |
| 280 | + val views: String? = null, |
| 281 | + val id_movie: String? = null, |
| 282 | + val key_time: String? = null, // "12 Jan 2026" |
| 283 | + val aired: String? = null, |
| 284 | + val image: String? = null, // may be relative |
| 285 | + val thumbnail: String? = null, |
| 286 | +) |
| 287 | + |
| 288 | +// STREAM → GET /episode/streamnew/{id} |
| 289 | +// { data: { episode, episode_next, server: [{id, link, quality, type, name, ...}] } } |
| 290 | +data class ApiStreamResponse( |
| 291 | + val status: Int? = null, |
| 292 | + val error: Boolean? = null, |
| 293 | + val data: StreamData? = null, |
| 294 | +) |
| 295 | +data class StreamData( |
| 296 | + val episode: EpisodeItem? = null, |
| 297 | + val episode_next: EpisodeItem? = null, |
| 298 | + val server: List<StreamServer>? = null, |
| 299 | +) |
| 300 | +data class StreamServer( |
| 301 | + val id: String? = null, |
| 302 | + val link: String? = null, // confirmed: direct MP4 URL |
| 303 | + val quality: String? = null, // "360p" | "480p" | "720p" | "1080p" |
| 304 | + val key_file_size: String? = null, |
| 305 | + val name: String? = null, // e.g. "RAPSODI" |
| 306 | + val type: String? = null, // "direct" | "embed" |
| 307 | + val domain: String? = null, |
| 308 | + val username: String? = null, |
| 309 | + val server_id: String? = null, |
| 310 | +) |
0 commit comments