From 4e0d221696079d6de969406b8c93cd1aa6ac4184 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 17 May 2026 20:43:24 -0600 Subject: [PATCH] Rewrite TmdbProvider to remove external lib dependency As a side-effect, this also removes all gson usage from extractors and uses AppUtils methods, because gson was only an implicit dependency from the external tmdb library. --- gradle/libs.versions.toml | 2 - library/build.gradle.kts | 1 - .../cloudstream3/extractors/Dailymotion.kt | 13 +- .../cloudstream3/extractors/GDMirrorbot.kt | 78 ++- .../lagradost/cloudstream3/extractors/Voe.kt | 28 +- .../metaproviders/TmdbProvider.kt | 572 ++++++++++-------- 6 files changed, 389 insertions(+), 305 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97145c3f81..0f25009861c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,6 @@ qrcodeKotlin = "4.5.0" rhino = { strictly = "1.8.1" } # Requires minSdk 26 or later beginning at version 1.9.0 safefile = "0.0.8" shimmer = "0.5.0" -tmdbJava = "2.13.0" torrentserver = "7861970" tvprovider = "1.1.0" video = "1.0.0" @@ -110,7 +109,6 @@ qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrco rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } safefile = { module = "com.github.LagradOst:SafeFile", version.ref = "safefile" } shimmer = { module = "com.facebook.shimmer:shimmer", version.ref = "shimmer" } -tmdb-java = { module = "com.uwetrottmann.tmdb2:tmdb-java", version.ref = "tmdbJava" } torrentserver = { module = "com.github.recloudstream:torrentserver", version.ref = "torrentserver" } tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } video = { module = "com.google.android.mediahome:video", version.ref = "video" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 073e49e6483..b4395d4756d 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -61,7 +61,6 @@ kotlin { implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript implementation(libs.newpipeextractor) - implementation(libs.tmdb.java) // TMDB API v3 Wrapper Made with RetroFit } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt index db6db39d588..3ad942aaac6 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt @@ -1,16 +1,14 @@ package com.lagradost.cloudstream3.extractors -import com.google.gson.Gson import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 import java.net.URI - - class Geodailymotion : Dailymotion() { override val name = "GeoDailymotion" override val mainUrl = "https://geo.dailymotion.com" @@ -35,8 +33,7 @@ open class Dailymotion : ExtractorApi() { val metaDataUrl = "$baseUrl/player/metadata/video/$id" val response = app.get(metaDataUrl, referer = embedUrl).text - val gson = Gson() - val meta = gson.fromJson(response, MetaData::class.java) + val meta = parseJson(response) meta.qualities?.get("auto")?.forEach { quality -> val videoUrl = quality.url @@ -57,7 +54,6 @@ open class Dailymotion : ExtractorApi() { } } - private fun getEmbedUrl(url: String): String? { if (url.contains("/embed/") || url.contains("/video/")) return url if (url.contains("geo.dailymotion.com")) { @@ -67,7 +63,6 @@ open class Dailymotion : ExtractorApi() { return null } - private fun getVideoId(url: String): String? { val path = URI(url).path val id = path.substringAfter("/video/") @@ -82,7 +77,6 @@ open class Dailymotion : ExtractorApi() { return generateM3u8(name, streamLink, "").forEach(callback) } - data class MetaData( val qualities: Map>?, val subtitles: SubtitlesWrapper? @@ -102,5 +96,4 @@ open class Dailymotion : ExtractorApi() { val label: String, val urls: List ) - -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt index e0fefe8aae8..9adfacfc112 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt @@ -1,16 +1,17 @@ package com.lagradost.cloudstream3.extractors -import com.google.gson.JsonParser +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.base64Decode +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.loadExtractor import java.net.URI -class Techinmind: GDMirrorbot() { +class Techinmind : GDMirrorbot() { override var name = "Techinmind Cloud AIO" override var mainUrl = "https://stream.techinmind.space" override var requiresReferer = true @@ -21,6 +22,20 @@ open class GDMirrorbot : ExtractorApi() { override var mainUrl = "https://gdmirrorbot.nl" override val requiresReferer = true + private data class EmbedData( + @JsonProperty("data") val data: List? = null, + ) + + private data class FileSlug( + @JsonProperty("fileslug") val fileslug: String? = null, + ) + + private data class EmbedHelper( + @JsonProperty("siteUrls") val siteUrls: Map? = null, + @JsonProperty("siteFriendlyNames") val siteFriendlyNames: Map? = null, + @JsonProperty("mresult") val mresult: Any? = null, + ) + override suspend fun getUrl( url: String, referer: String?, @@ -48,15 +63,9 @@ open class GDMirrorbot : ExtractorApi() { pageText = app.get(apiUrl).text } - val jsonElement = JsonParser.parseString(pageText) - if (!jsonElement.isJsonObject) return - val jsonObject = jsonElement.asJsonObject - + val embedData = tryParseJson(pageText) val embedId = url.substringAfterLast("/") - val sidValue = jsonObject["data"]?.asJsonArray - ?.takeIf { it.size() > 0 } - ?.get(0)?.asJsonObject - ?.get("fileslug")?.asString + val sidValue = embedData?.data?.firstOrNull()?.fileslug ?.takeIf { it.isNotBlank() } ?: embedId Pair(sidValue, hostUrl) @@ -65,34 +74,40 @@ open class GDMirrorbot : ExtractorApi() { val postData = mapOf("sid" to sid) val responseText = app.post("$host/embedhelper.php", data = postData).text - val rootElement = JsonParser.parseString(responseText) - if (!rootElement.isJsonObject) return - val root = rootElement.asJsonObject - - val siteUrls = root["siteUrls"]?.asJsonObject ?: return - val siteFriendlyNames = root["siteFriendlyNames"]?.asJsonObject - - val decodedMresult = when { - root["mresult"]?.isJsonObject == true -> root["mresult"]!!.asJsonObject - root["mresult"]?.isJsonPrimitive == true -> try { - base64Decode(root["mresult"]!!.asString) - .let { JsonParser.parseString(it).asJsonObject } - } catch (e: Exception) { - Log.e("GDMirrorbot", "Failed to decode mresult: $e") - return + val root = tryParseJson(responseText) ?: return + val siteUrls = root.siteUrls ?: return + val siteFriendlyNames = root.siteFriendlyNames + + // mresult can arrive as a JSON object or a base64-encoded string + val mresult: Map? = run { + val raw = responseText + .substringAfter("\"mresult\":") + .trimStart() + when { + raw.startsWith("\"") -> { + // base64-encoded string + tryParseJson>( + try { base64Decode(raw.trim('"')) } catch (_: Exception) { return } + ) + } + raw.startsWith("{") -> tryParseJson>( + raw.substringBefore("\n}").substringBefore(",\n\"").let { "{$it}" } + .let { responseText.substringAfter("\"mresult\":").trimStart() } + ) + else -> null } - else -> return } + if (mresult == null) return - siteUrls.keySet().intersect(decodedMresult.keySet()).forEach { key -> - val base = siteUrls[key]?.asString?.trimEnd('/') ?: return@forEach - val path = decodedMresult[key]?.asString?.trimStart('/') ?: return@forEach + siteUrls.keys.intersect(mresult.keys).forEach { key -> + val base = siteUrls[key]?.trimEnd('/') ?: return@forEach + val path = mresult[key]?.trimStart('/') ?: return@forEach val fullUrl = "$base/$path" - val friendlyName = siteFriendlyNames?.get(key)?.asString ?: key + val friendlyName = siteFriendlyNames?.get(key) ?: key try { when (friendlyName) { - "StreamHG","EarnVids" -> VidHidePro().getUrl(fullUrl, referer, subtitleCallback, callback) + "StreamHG", "EarnVids" -> VidHidePro().getUrl(fullUrl, referer, subtitleCallback, callback) "RpmShare", "UpnShare", "StreamP2p" -> VidStack().getUrl(fullUrl, referer, subtitleCallback, callback) else -> loadExtractor(fullUrl, referer ?: mainUrl, subtitleCallback, callback) } @@ -106,4 +121,3 @@ open class GDMirrorbot : ExtractorApi() { return URI(url).let { "${it.scheme}://${it.host}" } } } - diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt index 67eb49c9a55..ef67c1fe130 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt @@ -1,10 +1,9 @@ package com.lagradost.cloudstream3.extractors -import com.google.gson.JsonObject -import com.google.gson.JsonParser import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.base64Decode +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.INFER_TYPE @@ -66,14 +65,17 @@ open class Voe : ExtractorApi() { if (redirectUrl != null) { res = app.get(redirectUrl, referer = referer) } - val encodedString = res.document.selectFirst("script[type=application/json]")?.data()?.trim()?.substringAfter("[\"")?.substringBeforeLast("\"]") + val encodedString = res.document.selectFirst("script[type=application/json]") + ?.data()?.trim() + ?.substringAfter("[\"") + ?.substringBeforeLast("\"]") if (encodedString == null) { println("encoded string not found.") return } val decryptedJson = decryptF7(encodedString) - val m3u8 = decryptedJson.get("source")?.asString - val mp4 = decryptedJson.get("direct_access_url")?.asString + val m3u8 = decryptedJson?.source + val mp4 = decryptedJson?.directAccessUrl if (m3u8 != null) { M3u8Helper.generateM3u8( @@ -83,8 +85,7 @@ open class Voe : ExtractorApi() { headers = mapOf("Origin" to "$mainUrl/") ).forEach(callback) } - if (mp4!=null) - { + if (mp4 != null) { callback.invoke( newExtractorLink( source = "$name MP4", @@ -99,7 +100,12 @@ open class Voe : ExtractorApi() { } } - private fun decryptF7(p8: String): JsonObject { + private data class VoeDecrypted( + val source: String? = null, + val directAccessUrl: String? = null, + ) + + private fun decryptF7(p8: String): VoeDecrypted? { return try { val vF = rot13(p8) val vF2 = replacePatterns(vF) @@ -108,11 +114,10 @@ open class Voe : ExtractorApi() { val vF5 = charShift(vF4, 3) val vF6 = reverse(vF5) val vAtob = base64Decode(vF6) - - JsonParser.parseString(vAtob).asJsonObject + tryParseJson(vAtob) } catch (e: Exception) { println("Decryption error: ${e.message}") - JsonObject() + null } } @@ -140,5 +145,4 @@ open class Voe : ExtractorApi() { } private fun reverse(input: String): String = input.reversed() - } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt index 89f935da327..5a74c3b058e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.metaproviders import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.Actor +import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.Episode import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.HomePageList @@ -20,6 +21,8 @@ import com.lagradost.cloudstream3.SearchResponseList import com.lagradost.cloudstream3.TvSeriesLoadResponse import com.lagradost.cloudstream3.TvSeriesSearchResponse import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.addDate +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.newEpisode import com.lagradost.cloudstream3.newHomePageResponse import com.lagradost.cloudstream3.newMovieLoadResponse @@ -28,29 +31,13 @@ import com.lagradost.cloudstream3.newTvSeriesLoadResponse import com.lagradost.cloudstream3.newTvSeriesSearchResponse import com.lagradost.cloudstream3.runAllAsync import com.lagradost.cloudstream3.toNewSearchResponseList +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.uwetrottmann.tmdb2.Tmdb -import com.uwetrottmann.tmdb2.entities.AppendToResponse -import com.uwetrottmann.tmdb2.entities.BaseMovie -import com.uwetrottmann.tmdb2.entities.BaseTvShow -import com.uwetrottmann.tmdb2.entities.CastMember -import com.uwetrottmann.tmdb2.entities.ContentRating -import com.uwetrottmann.tmdb2.entities.Movie -import com.uwetrottmann.tmdb2.entities.ReleaseDate -import com.uwetrottmann.tmdb2.entities.ReleaseDatesResult -import com.uwetrottmann.tmdb2.entities.TvSeason -import com.uwetrottmann.tmdb2.entities.TvShow -import com.uwetrottmann.tmdb2.entities.Videos -import com.uwetrottmann.tmdb2.enumerations.AppendToResponseItem -import com.uwetrottmann.tmdb2.enumerations.VideoType -import retrofit2.awaitResponse -import retrofit2.Response -import java.util.Calendar /** * episode and season starting from 1 * they are null if movie - * */ + */ data class TmdbLink( @JsonProperty("imdbID") val imdbID: String?, @JsonProperty("tmdbID") val tmdbID: Int?, @@ -67,19 +54,165 @@ open class TmdbProvider : MainAPI() { open val useMetaLoadResponse = false open val apiName = "TMDB" - // As some sites doesn't support s0 + // As some sites don't support s0 open val disableSeasonZero = true override val hasMainPage = true override val providerType = ProviderType.MetaProvider - // Fuck it, public private api key because github actions won't co-operate. - // Please no stealy. - private val tmdb = Tmdb("e6333b32409e02a4a6eba6fb7ff866bb") + private val tmdbApiKey = "e6333b32409e02a4a6eba6fb7ff866bb" + private val tmdbApiUrl = "https://api.themoviedb.org/3" + + data class TmdbIds( + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("tvdb_id") val tvdbId: Int? = null, + ) + + data class TmdbGenre( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + ) + + data class TmdbCastMember( + @JsonProperty("name") val name: String? = null, + @JsonProperty("character") val character: String? = null, + @JsonProperty("profile_path") val profilePath: String? = null, + ) + + data class TmdbCredits( + @JsonProperty("cast") val cast: List? = null, + ) + + data class TmdbVideo( + @JsonProperty("key") val key: String? = null, + @JsonProperty("site") val site: String? = null, + @JsonProperty("type") val type: String? = null, + ) + + data class TmdbVideos( + @JsonProperty("results") val results: List? = null, + ) + + // Shared between movie and tv search results + data class TmdbSearchResult( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("title") val title: String? = null, // movies + @JsonProperty("original_title") val originalTitle: String? = null, + @JsonProperty("name") val name: String? = null, // tv + @JsonProperty("original_name") val originalName: String? = null, + @JsonProperty("poster_path") val posterPath: String? = null, + @JsonProperty("vote_average") val voteAverage: Double? = null, + @JsonProperty("release_date") val releaseDate: String? = null, + @JsonProperty("first_air_date") val firstAirDate: String? = null, + @JsonProperty("media_type") val mediaType: String? = null, // for multi-search + ) { + val isTv get() = name != null || mediaType == "tv" + val displayTitle get() = title ?: originalTitle ?: name ?: originalName ?: "" + val year get() = (releaseDate ?: firstAirDate)?.take(4)?.toIntOrNull() + } + + data class TmdbPageResult( + @JsonProperty("results") val results: List? = null, + @JsonProperty("total_pages") val totalPages: Int? = null, + @JsonProperty("total_results") val totalResults: Int? = null, + ) + + data class TmdbMultiResult( + @JsonProperty("results") val results: List? = null, + ) + + data class TmdbEpisode( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("episode_number") val episodeNumber: Int? = null, + @JsonProperty("season_number") val seasonNumber: Int? = null, + @JsonProperty("still_path") val stillPath: String? = null, + @JsonProperty("air_date") val airDate: String? = null, + @JsonProperty("vote_average") val voteAverage: Double? = null, + @JsonProperty("external_ids") val externalIds: TmdbIds? = null, + ) + + data class TmdbSeasonDetail( + @JsonProperty("season_number") val seasonNumber: Int? = null, + @JsonProperty("episodes") val episodes: List? = null, + ) + + data class TmdbSeasonSummary( + @JsonProperty("season_number") val seasonNumber: Int? = null, + @JsonProperty("episode_count") val episodeCount: Int? = null, + ) + + data class TmdbContentRating( + @JsonProperty("iso_3166_1") val country: String? = null, + @JsonProperty("rating") val rating: String? = null, + ) + + data class TmdbContentRatings( + @JsonProperty("results") val results: List? = null, + ) + + data class TmdbReleaseDateEntry( + @JsonProperty("certification") val certification: String? = null, + @JsonProperty("type") val type: Int? = null, + ) + + data class TmdbReleaseDateResult( + @JsonProperty("iso_3166_1") val country: String? = null, + @JsonProperty("release_dates") val releaseDates: List? = null, + ) + + data class TmdbReleaseDates( + @JsonProperty("results") val results: List? = null, + ) + + data class TmdbTvDetail( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("original_name") val originalName: String? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("poster_path") val posterPath: String? = null, + @JsonProperty("first_air_date") val firstAirDate: String? = null, + @JsonProperty("vote_average") val voteAverage: Double? = null, + @JsonProperty("genres") val genres: List? = null, + @JsonProperty("episode_run_time") val episodeRunTime: List? = null, + @JsonProperty("seasons") val seasons: List? = null, + @JsonProperty("external_ids") val externalIds: TmdbIds? = null, + @JsonProperty("videos") val videos: TmdbVideos? = null, + @JsonProperty("credits") val credits: TmdbCredits? = null, + @JsonProperty("recommendations") val recommendations: TmdbPageResult? = null, + @JsonProperty("similar") val similar: TmdbPageResult? = null, + @JsonProperty("content_ratings") val contentRatings: TmdbContentRatings? = null, + ) { + val displayTitle get() = name ?: originalName ?: "" + val year get() = firstAirDate?.take(4)?.toIntOrNull() + } + + data class TmdbMovieDetail( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("original_title") val originalTitle: String? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("poster_path") val posterPath: String? = null, + @JsonProperty("release_date") val releaseDate: String? = null, + @JsonProperty("vote_average") val voteAverage: Double? = null, + @JsonProperty("genres") val genres: List? = null, + @JsonProperty("runtime") val runtime: Int? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("external_ids") val externalIds: TmdbIds? = null, + @JsonProperty("videos") val videos: TmdbVideos? = null, + @JsonProperty("credits") val credits: TmdbCredits? = null, + @JsonProperty("recommendations") val recommendations: TmdbPageResult? = null, + @JsonProperty("similar") val similar: TmdbPageResult? = null, + @JsonProperty("release_dates") val releaseDates: TmdbReleaseDates? = null, + ) { + val displayTitle get() = title ?: originalTitle ?: "" + val year get() = releaseDate?.take(4)?.toIntOrNull() + } private fun getImageUrl(link: String?): String? { - if (link == null) return null - return if (link.startsWith("/")) "https://image.tmdb.org/t/p/w500/$link" else link + link ?: return null + return if (link.startsWith("/")) "https://image.tmdb.org/t/p/w500$link" else link } private fun getUrl(id: Int?, tvShow: Boolean): String { @@ -87,199 +220,197 @@ open class TmdbProvider : MainAPI() { else "https://www.themoviedb.org/movie/${id ?: -1}" } - private fun BaseTvShow.toSearchResponse(): TvSeriesSearchResponse { - return newTvSeriesSearchResponse( - name = this.name ?: this.original_name, + private suspend fun getApi(path: String, extraParams: Map = emptyMap()): String { + val params = buildMap { + put("api_key", tmdbApiKey) + putAll(extraParams) + } + return app.get( + url = "$tmdbApiUrl$path", + params = params, + ).text + } + + private fun TmdbSearchResult.toSearchResponse() = if (isTv) { + newTvSeriesSearchResponse( + name = displayTitle, url = getUrl(id, true), type = TvType.TvSeries, - fix = false + fix = false, ) { this.id = this@toSearchResponse.id - this.posterUrl = getImageUrl(poster_path) - this.score = Score.from10(vote_average) - this.year = first_air_date?.let { - Calendar.getInstance().apply { - time = it - }.get(Calendar.YEAR) - } + this.posterUrl = getImageUrl(posterPath) + this.score = Score.from10(voteAverage) + this.year = this@toSearchResponse.year } - } - - private fun BaseMovie.toSearchResponse(): MovieSearchResponse { - return newMovieSearchResponse( - name = this.title ?: this.original_title, + } else { + newMovieSearchResponse( + name = displayTitle, url = getUrl(id, false), type = TvType.Movie, - fix = false + fix = false, ) { this.id = this@toSearchResponse.id - this.posterUrl = getImageUrl(poster_path) - this.score = Score.from10(vote_average) - this.year = release_date?.let { - Calendar.getInstance().apply { - time = it - }.get(Calendar.YEAR) - } + this.posterUrl = getImageUrl(posterPath) + this.score = Score.from10(voteAverage) + this.year = this@toSearchResponse.year } } - private fun List?.toActors(): List>? { + private fun List?.toActors(): List>? { return this?.mapNotNull { + it ?: return@mapNotNull null Pair( - Actor(it?.name ?: return@mapNotNull null, getImageUrl(it.profile_path)), + Actor(it.name ?: return@mapNotNull null, getImageUrl(it.profilePath)), it.character ) } } - private suspend fun TvShow.toLoadResponse(): TvSeriesLoadResponse { - val tvSeasonsService = tmdb.tvSeasonsService() - val episodes = mutableListOf() - - val validSeasons = this.seasons?.filter { !disableSeasonZero || (it.season_number ?: 0) != 0 } ?: emptyList() - for (season in validSeasons) { - val seasonNumber = season.season_number ?: continue + private fun TmdbVideos?.toTrailers(): List? { + val skipTypes = setOf("Opening Credits", "Featurette") + return this?.results + ?.filter { it.type !in skipTypes } + ?.sortedBy { it.type } + ?.mapNotNull { + when (it.site?.trim()?.lowercase()) { + "youtube" -> "https://www.youtube.com/watch?v=${it.key}" + else -> null + } + } + } - val response: Response = tmdb.tvSeasonsService() - .season(this.id, seasonNumber, "external_ids,images,episodes") - .awaitResponse() + open suspend fun fetchContentRating(id: Int?, country: String): String? { + id ?: return null + // Try TV content ratings first + val tvRating = parseJson( + getApi("/tv/$id/content_ratings") + ).results?.firstOrNull { it.country == country }?.rating + if (tvRating != null) return tvRating + + // Fall back to movie release dates + return parseJson( + getApi("/movie/$id/release_dates") + ).results?.firstOrNull { it.country == country } + ?.releaseDates?.firstOrNull { !it.certification.isNullOrBlank() } + ?.certification + } - val fullSeason = response.body() ?: continue + private suspend fun TmdbTvDetail.toLoadResponse(): TvSeriesLoadResponse { + val episodes = mutableListOf() + val validSeasons = seasons?.filter { !disableSeasonZero || (it.seasonNumber ?: 0) != 0 } + ?: emptyList() + for (season in validSeasons) { + val seasonNum = season.seasonNumber ?: continue + val fullSeason = parseJson( + getApi("/tv/$id/season/$seasonNum", mapOf("append_to_response" to "external_ids")) + ) fullSeason.episodes?.forEach { episode -> episodes += newEpisode( TmdbLink( - episode.external_ids?.imdb_id ?: this.external_ids?.imdb_id, - this.id, - episode.episode_number, - episode.season_number, - this.name ?: this.original_name + episode.externalIds?.imdbId ?: externalIds?.imdbId, + id, + episode.episodeNumber, + episode.seasonNumber, + displayTitle, ).toJson() ) { this.name = episode.name - this.season = episode.season_number - this.episode = episode.episode_number - this.score = Score.from10(episode.vote_average) + this.season = episode.seasonNumber + this.episode = episode.episodeNumber + this.score = Score.from10(episode.voteAverage) this.description = episode.overview - this.date = episode.air_date?.time - this.posterUrl = getImageUrl(episode.still_path) + this.posterUrl = getImageUrl(episode.stillPath) + this.addDate(episode.airDate) } } } return newTvSeriesLoadResponse( - this.name ?: this.original_name, + displayTitle, getUrl(id, true), TvType.TvSeries, - episodes + episodes, ) { - posterUrl = getImageUrl(poster_path) - year = first_air_date?.let { - Calendar.getInstance().apply { - time = it - }.get(Calendar.YEAR) - } + posterUrl = getImageUrl(posterPath) + this.year = this@toLoadResponse.year plot = overview - addImdbId(external_ids?.imdb_id) + addImdbId(externalIds?.imdbId) tags = genres?.mapNotNull { it.name } - duration = episode_run_time?.average()?.toInt() - score = Score.from10(vote_average) + duration = episodeRunTime?.average()?.toInt() + score = Score.from10(voteAverage) addTrailer(videos.toTrailers()) recommendations = (this@toLoadResponse.recommendations ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } addActors(credits?.cast?.toList().toActors()) - contentRating = fetchContentRating(id, "US") + contentRating = contentRatings?.results?.firstOrNull { it.country == "US" }?.rating + ?: fetchContentRating(id, "US") } } - private fun Videos?.toTrailers(): List? { - return this?.results?.filter { it.type != VideoType.OPENING_CREDITS && it.type != VideoType.FEATURETTE } - ?.sortedBy { it.type?.ordinal ?: 10000 } - ?.mapNotNull { - when (it.site?.trim()?.lowercase()) { - "youtube" -> { // TODO FILL SITES - "https://www.youtube.com/watch?v=${it.key}" - } - else -> null - } - } - } - - private suspend fun Movie.toLoadResponse(): MovieLoadResponse { + private suspend fun TmdbMovieDetail.toLoadResponse(): MovieLoadResponse { return newMovieLoadResponse( - this.title ?: this.original_title, getUrl(id, false), TvType.Movie, TmdbLink( - this.imdb_id, - this.id, + displayTitle, + getUrl(id, false), + TvType.Movie, + TmdbLink( + imdbId ?: externalIds?.imdbId, + id, null, null, - this.title ?: this.original_title, + displayTitle, ).toJson() ) { - posterUrl = getImageUrl(poster_path) - year = release_date?.let { - Calendar.getInstance().apply { - time = it - }.get(Calendar.YEAR) - } + posterUrl = getImageUrl(posterPath) + this.year = this@toLoadResponse.year plot = overview - addImdbId(external_ids?.imdb_id) + addImdbId(imdbId ?: externalIds?.imdbId) tags = genres?.mapNotNull { it.name } duration = runtime - score = Score.from10(vote_average) + score = Score.from10(voteAverage) addTrailer(videos.toTrailers()) - recommendations = (this@toLoadResponse.recommendations ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } addActors(credits?.cast?.toList().toActors()) - - contentRating = fetchContentRating(id, "US") + contentRating = releaseDates?.results + ?.firstOrNull { it.country == "US" } + ?.releaseDates?.firstOrNull { !it.certification.isNullOrBlank() } + ?.certification } } override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { - - // SAME AS DISCOVER IT SEEMS -// val popularSeries = tmdb.tvService().popular(page, "en-US").execute().body()?.results?.map { -// it.toSearchResponse() -// } ?: listOf() -// -// val popularMovies = -// tmdb.moviesService().popular(page, "en-US", "840").execute().body()?.results?.map { -// it.toSearchResponse() -// } ?: listOf() - var discoverMovies: List = listOf() var discoverSeries: List = listOf() var topMovies: List = listOf() var topSeries: List = listOf() + runAllAsync( { - discoverMovies = tmdb.discoverMovie().page(page).build().awaitResponse().body()?.results?.map { - it.toSearchResponse() - } ?: listOf() - }, { - discoverSeries = tmdb.discoverTv().page(page).build().awaitResponse().body()?.results?.map { - it.toSearchResponse() - } ?: listOf() - }, { - // https://en.wikipedia.org/wiki/ISO_3166-1 - topMovies = - tmdb.moviesService().topRated(page, "en-US", "US").awaitResponse() - .body()?.results?.map { - it.toSearchResponse() - } ?: listOf() - }, { - topSeries = - tmdb.tvService().topRated(page, "en-US").awaitResponse().body()?.results?.map { - it.toSearchResponse() - } ?: listOf() - } + discoverMovies = parseJson>( + getApi("/discover/movie", mapOf("page" to "$page")) + ).results?.map { it.toSearchResponse() as MovieSearchResponse } ?: listOf() + }, + { + discoverSeries = parseJson>( + getApi("/discover/tv", mapOf("page" to "$page")) + ).results?.map { it.toSearchResponse() as TvSeriesSearchResponse } ?: listOf() + }, + { + topMovies = parseJson>( + getApi("/movie/top_rated", mapOf("page" to "$page", "language" to "en-US", "region" to "US")) + ).results?.map { it.toSearchResponse() as MovieSearchResponse } ?: listOf() + }, + { + topSeries = parseJson>( + getApi("/tv/top_rated", mapOf("page" to "$page", "language" to "en-US")) + ).results?.map { it.toSearchResponse() as TvSeriesSearchResponse } ?: listOf() + }, ) return newHomePageResponse( listOf( -// HomePageList("Popular Series", popularSeries), -// HomePageList("Popular Movies", popularMovies), HomePageList("Popular Movies", discoverMovies), HomePageList("Popular Series", discoverSeries), HomePageList("Top Movies", topMovies), @@ -288,47 +419,14 @@ open class TmdbProvider : MainAPI() { ) } - open fun loadFromImdb(imdb: String, seasons: List): LoadResponse? { - return null - } - - open fun loadFromTmdb(tmdb: Int, seasons: List): LoadResponse? { - return null - } - - open fun loadFromImdb(imdb: String): LoadResponse? { - return null - } - - open fun loadFromTmdb(tmdb: Int): LoadResponse? { - return null - } + open fun loadFromImdb(imdb: String, seasons: List): LoadResponse? = null + open fun loadFromTmdb(tmdbId: Int, seasons: List): LoadResponse? = null + open fun loadFromImdb(imdb: String): LoadResponse? = null + open fun loadFromTmdb(tmdbId: Int): LoadResponse? = null - open suspend fun fetchContentRating(id: Int?, country: String): String? { - id ?: return null - - val contentRatings = tmdb.tvService().content_ratings(id).awaitResponse().body()?.results - return if (!contentRatings.isNullOrEmpty()) { - contentRatings.firstOrNull { it: ContentRating -> - it.iso_3166_1 == country - }?.rating - } else { - val releaseDates = tmdb.moviesService().releaseDates(id).awaitResponse().body()?.results - val certification = releaseDates?.firstOrNull { it: ReleaseDatesResult -> - it.iso_3166_1 == country - }?.release_dates?.firstOrNull { it: ReleaseDate -> - !it.certification.isNullOrBlank() - }?.certification - - certification - } - } - - // Possible to add recommendations and such here. override suspend fun load(url: String): LoadResponse? { // https://www.themoviedb.org/movie/7445-brothers // https://www.themoviedb.org/tv/71914-the-wheel-of-time - val idRegex = Regex("""themoviedb\.org/(.*)/(\d+)""") val found = idRegex.find(url) @@ -337,86 +435,64 @@ open class TmdbProvider : MainAPI() { ?: throw ErrorLoadingException("No id found") return if (useMetaLoadResponse) { - return if (isTvSeries) { - val body = tmdb.tvService() - .tv( - id, - "en-US", - AppendToResponse( - AppendToResponseItem.EXTERNAL_IDS, - AppendToResponseItem.VIDEOS + if (isTvSeries) { + val detail = parseJson( + getApi( + "/tv/$id", + mapOf( + "language" to "en-US", + "append_to_response" to "external_ids,videos,credits,recommendations,similar,content_ratings", ) ) - .awaitResponse().body() - val response = body?.toLoadResponse() - if (response != null) { - if (response.recommendations.isNullOrEmpty()) - tmdb.tvService().recommendations(id, 1, "en-US").awaitResponse().body() - ?.let { - it.results?.map { res -> res.toSearchResponse() } - }?.let { list -> - response.recommendations = list - } - - if (response.actors.isNullOrEmpty()) - tmdb.tvService().credits(id, "en-US").awaitResponse().body()?.let { - response.addActors(it.cast?.toActors()) - } - } - - response + ) + detail.toLoadResponse() } else { - val body = tmdb.moviesService() - .summary( - id, - "en-US", - AppendToResponse( - AppendToResponseItem.EXTERNAL_IDS, - AppendToResponseItem.VIDEOS + val detail = parseJson( + getApi( + "/movie/$id", + mapOf( + "language" to "en-US", + "append_to_response" to "external_ids,videos,credits,recommendations,similar,release_dates", ) ) - .awaitResponse().body() - val response = body?.toLoadResponse() - if (response != null) { - if (response.recommendations.isNullOrEmpty()) - tmdb.moviesService().recommendations(id, 1, "en-US").awaitResponse().body() - ?.let { - it.results?.map { res -> res.toSearchResponse() } - }?.let { list -> - response.recommendations = list - } - - if (response.actors.isNullOrEmpty()) - tmdb.moviesService().credits(id).awaitResponse().body()?.let { - response.addActors(it.cast?.toActors()) - } - } - response + ) + detail.toLoadResponse() } } else { loadFromTmdb(id)?.let { return it } if (isTvSeries) { - tmdb.tvService().externalIds(id).awaitResponse().body()?.imdb_id?.let { - val fromImdb = loadFromImdb(it) - val result = if (fromImdb == null) { - val details = tmdb.tvService().tv(id, "en-US").awaitResponse().body() - loadFromImdb(it, details?.seasons ?: listOf()) - ?: loadFromTmdb(id, details?.seasons ?: listOf()) - } else fromImdb - - result + val externalIds = parseJson(getApi("/tv/$id/external_ids")) + val imdbId = externalIds.imdbId + if (imdbId != null) { + val fromImdb = loadFromImdb(imdbId) + if (fromImdb != null) return fromImdb + } + val seasons = parseJson(getApi("/tv/$id")).seasons ?: listOf() + if (imdbId != null) { + loadFromImdb(imdbId, seasons) ?: loadFromTmdb(id, seasons) + } else { + loadFromTmdb(id, seasons) } } else { - tmdb.moviesService().externalIds(id).awaitResponse() - .body()?.imdb_id?.let { loadFromImdb(it) } + val imdbId = parseJson(getApi("/movie/$id")).imdbId + if (imdbId != null) loadFromImdb(imdbId) else null } } } override suspend fun search(query: String, page: Int): SearchResponseList? { - return tmdb.searchService().multi(query, page, "en-US", "US", includeAdult).awaitResponse() - .body()?.results?.mapNotNull { - it.movie?.toSearchResponse() ?: it.tvShow?.toSearchResponse() - }?.toNewSearchResponseList() + return parseJson( + getApi( + "/search/multi", + mapOf( + "query" to query, + "page" to "$page", + "language" to "en-US", + "include_adult" to "$includeAdult", + ) + ) + ).results?.mapNotNull { + if (it.mediaType == "person") null else it.toSearchResponse() + }?.toNewSearchResponseList() } -} \ No newline at end of file +}