From 39a3151cb47adc355c5fe9e9defd55300aff65d1 Mon Sep 17 00:00:00 2001 From: KingLucius Date: Sat, 29 Mar 2025 21:03:01 +0200 Subject: [PATCH] feat(Extensions): MyDramaList Meta provider (#1622) * feat(Extensions): MyDramaList Meta provider * use JsonProperty * BuildConfig KEY & applyMedia --- library/build.gradle.kts | 10 + .../cloudstream3/metaproviders/MyDramaList.kt | 452 ++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 7a255ff27..22dc049d9 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import com.codingfeline.buildkonfig.compiler.FieldSpec import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier @@ -54,6 +55,15 @@ buildkonfig { logger.quiet("Compiling library with release flag") } buildConfigField(FieldSpec.Type.BOOLEAN, "DEBUG", isDebug.toString()) + + // Reads local.properties + val localProperties = gradleLocalProperties(rootDir, project.providers) + + buildConfigField( + FieldSpec.Type.STRING, + "MDL_API_KEY", + "\"" + (System.getenv("MDL_API_KEY") ?: localProperties["mdl.key"]) + "\"" + ) } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt new file mode 100644 index 000000000..8751e6456 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt @@ -0,0 +1,452 @@ +package com.lagradost.cloudstream3.metaproviders + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.api.BuildConfig +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.Actor +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.ProviderType +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.addDate +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mainPageOf +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.newEpisode +import com.lagradost.cloudstream3.newHomePageResponse +import com.lagradost.cloudstream3.newMovieLoadResponse +import com.lagradost.cloudstream3.newMovieSearchResponse +import com.lagradost.cloudstream3.newTvSeriesLoadResponse +import com.lagradost.cloudstream3.newTvSeriesSearchResponse +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import okhttp3.Interceptor +import okhttp3.Response +import java.text.SimpleDateFormat +import java.util.Locale + +//Reference: https://mydramalist.github.io/MDL-API/ +open class MyDramaListDemo : MainAPI() { + override var name = "MyDramaList" + override val hasMainPage = true + override val providerType = ProviderType.MetaProvider + override val supportedTypes = setOf( + TvType.Movie, + TvType.TvSeries, + TvType.AsianDrama, + ) + + companion object { + const val TAG = "MyDramaList" + val API_KEY: String = BuildConfig.MDL_API_KEY + const val API_HOST = "https://api.mydramalist.com/v1" + const val SITE_HOST = "https://mydramalist.com" + private val headerInterceptor = MyDramaListInterceptor() + } + + /** Automatically adds required api headers */ + private class MyDramaListInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder() + .removeHeader("user-agent") + .addHeader("user-agent", "Dart/3.6 (dart:io)") + .addHeader("mdl-api-key", API_KEY) + .build() + ) + } + } + + override val mainPage = mainPageOf( + "$API_HOST/titles/trending?type=shows" to "Trending Shows This week", + "$API_HOST/titles/top_airing?type=shows" to "Top Airing Shows", + "$API_HOST/titles/upcoming?type=shows" to "Upcoming Shows", + "$API_HOST/titles/trending?type=movies" to "Trending Movies This week", + "$API_HOST/titles/top_movies?type=movies" to "Top Movies", + "$API_HOST/titles/upcoming?type=movies" to "Upcoming Movies", + ) + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val list = app.get( + url = "${request.data}&limit=20&page=$page&lang=en-US", + interceptor = headerInterceptor + ).parsed().map { element -> + element.toSearchResponse() + } + return newHomePageResponse(request.name, list) + } + + override suspend fun search(query: String): List? { + return app.post( + url = "$API_HOST/search/titles", + data = mapOf("q" to query), + interceptor = headerInterceptor + ).parsed().map { element -> + element.toSearchResponse() + } + } + + private fun MediaSummary.toSearchResponse(): SearchResponse { + + val mediaType = if (type == "Movie") TvType.Movie else TvType.TvSeries + + if (mediaType == TvType.Movie) { + return newMovieSearchResponse( + name = title, + url = Data( + type = mediaType, + media = this, + ).toJson(), + type = TvType.Movie, + ) { + posterUrl = images.poster + } + } else { + return newTvSeriesSearchResponse( + name = title, + url = Data( + type = mediaType, + media = this, + ).toJson(), + type = TvType.TvSeries, + ) { + this.posterUrl = images.poster + } + } + } + + override suspend fun load(url: String): LoadResponse { + val data = parseJson(url) + + return app.get( + url = "$API_HOST/titles/${data.media?.id}", + interceptor = headerInterceptor + ).parsed().toLoadResponse(data) + } + + private suspend fun Media.toLoadResponse(data: Data): LoadResponse { + + return if (type == "Movie") { + + val link = LinkData( + id = id, + type = "Movie", + season = 0, + episode = 0, + title = title, + year = mediaYear, + orgTitle = originalTitle, + date = released, + ) + + newMovieLoadResponse( + name = title, + url = data.toJson(), + type = TvType.Movie, + dataUrl = link.toJson(), + ) { + this.type = TvType.Movie + } + } else { + newTvSeriesLoadResponse( + name = this.title, + url = data.toJson(), + type = TvType.TvSeries, + episodes = fetchEpisodes() + ) { + + this.type = TvType.AsianDrama + this.showStatus = getStatus(status) + } + }.applyMedia(this) + } + + private suspend fun LoadResponse.applyMedia(media: Media): LoadResponse { + this.name = media.title + this.posterUrl = media.images.poster + this.year = media.mediaYear + this.plot = media.synopsis + this.rating = media.mediaRating.times(1000).toInt() + this.tags = media.fixGenres() + this.duration = media.runtime.toInt() + this.recommendations = media.fetchRecommendations().map { it.toSearchResponse() } + this.actors = media.fetchCredits() + this.comingSoon = isUpcoming(media.airedStart) + this.backgroundPosterUrl = media.images.poster + this.contentRating = fixCertification(media.certification) + addTrailer( + media.fetchTrailer(), + addRaw = true, + headers = mapOf( + "User-Agent" to "Dart/3.6 (dart:io)" + ) + ) + return this + } + + private fun isUpcoming(dateString: String?): Boolean { + return try { + val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val dateTime = dateString?.let { format.parse(it)?.time } ?: return false + unixTimeMS < dateTime + } catch (t: Throwable) { + logError(t) + false + } + } + + private fun getStatus(status: String?): ShowStatus? { + return when (status) { + "Airing" -> ShowStatus.Ongoing + "Finished Airing" -> ShowStatus.Completed + else -> null + } + } + + private fun fixCertification(status: String?): String? { + return when (status) { + "G - All Ages" -> "G" + "13+ - Teens 13 or older" -> "13+" + "15+ - Teens 15 or older" -> "15+" + "18+ Restricted (violence & profanity)" -> "18+" + "Not Yet Rated" -> null + else -> "null" + } + } + + data class Data( + val type: TvType? = null, + val media: MediaSummary? = null, + ) + + class SearchResult : ArrayList() + + class Recommendations : ArrayList() + + data class MediaSummary( + @JsonProperty("id") val id: Long, + @JsonProperty("title") val title: String, + @JsonProperty("original_title") val originalTitle: String, + @JsonProperty("year") val year: Int? = null, + @JsonProperty("rating") val rating: Double? = null, + @JsonProperty("permalink") val permalink: String? = null, + @JsonProperty("type") val type: String, + @JsonProperty("media_type") val mediaType: String? = null, + @JsonProperty("country") val country: String? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("images") val images: Images, + ) + + data class Images( + @JsonProperty("thumb") val thumb: String? = null, + @JsonProperty("medium") val medium: String? = null, + @JsonProperty("poster") val poster: String? = null, + ) + + data class Media( + @JsonProperty("id") val id: Long, + @JsonProperty("slug") val slug: String, + @JsonProperty("title") val title: String, + @JsonProperty("original_title") val originalTitle: String, + @JsonProperty("year") val mediaYear: Int, + @JsonProperty("episodes") val episodes: Long, + @JsonProperty("rating") val mediaRating: Double, + @JsonProperty("permalink") val permalink: String? = null, + @JsonProperty("synopsis") val synopsis: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("media_type") val mediaType: String? = null, + @JsonProperty("country") val country: String? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("images") val images: Images, + @JsonProperty("alt_titles") val altTitles: List? = null, + @JsonProperty("votes") val votes: Long? = null, + @JsonProperty("aired_start") val airedStart: String? = null, + @JsonProperty("released") val released: String? = null, + @JsonProperty("release_dates_fmt") val releaseDatesFmt: String, + @JsonProperty("genres") val genres: List? = null, + @JsonProperty("trailer") val trailer: Trailer?, + @JsonProperty("watchers") val watchers: Long, + @JsonProperty("ranked") val ranked: Long, + @JsonProperty("popularity") val popularity: Long, + @JsonProperty("runtime") val runtime: Long, + @JsonProperty("reviews_count") val reviewsCount: Long, + @JsonProperty("recs_count") val recsCount: Long, + @JsonProperty("comments_count") val commentsCount: Long, + @JsonProperty("certification") val certification: String, + @JsonProperty("status") val status: String, + @JsonProperty("enable_ads") val enableAds: Boolean, + @JsonProperty("sources") val sources: List, + @JsonProperty("updated_at") val updatedAt: Long, + ) { + suspend fun fetchCredits(): List { + val actors = app.get( + url = "$API_HOST/titles/$id/credits", + interceptor = headerInterceptor + ).parsed().cast.map { + ActorData( + Actor( + name = it.name, + image = it.images.poster + ), + roleString = it.characterName + ) + } + return actors + } + + suspend fun fetchRecommendations(): Recommendations { + return app.get( + url = "$API_HOST/titles/$id/recommendations", + interceptor = headerInterceptor + ).parsed() + } + + suspend fun fetchTrailer(): String? { + return app.get( + url = "$SITE_HOST/v1/trailers/${trailer?.id}", + interceptor = headerInterceptor + ).parsedSafe()?.trailer?.trailerDetails?.source + } + + fun fixGenres(): List { + return (genres as List<*>).mapNotNull { + when (it) { + is String -> it + else -> (it as? Map)?.getValue("name") + } + } + } + } + + private suspend fun Media.fetchEpisodes(): List { + return app.get( + url = "$API_HOST/titles/${this.id}/episodes", + interceptor = headerInterceptor + ).parsed().map { + it.episodes + }.flatten().map { ep -> + val link = LinkData( + id = id, + type = "Series", + season = 0, + episode = ep.episodeNumber, + title = title, + year = mediaYear, + orgTitle = originalTitle, + date = released, + airedDate = airedStart, + ) + newEpisode( + link.toJson() + ) { + name = null + season = null + episode = ep.episodeNumber + posterUrl = null + rating = ep.rating.times(1000).toInt() + description = null + runTime = null + addDate(ep.releasedAt) + } + } + } + + data class Genre( + @JsonProperty("id") val id: Long, + @JsonProperty("name") val name: String, + @JsonProperty("slug") val slug: String, + ) + + data class Tag( + @JsonProperty("id") val id: Long, + @JsonProperty("name") val name: String, + @JsonProperty("slug") val slug: String, + ) + + data class Source( + @JsonProperty("xid") val xid: String, + @JsonProperty("name") val name: String, + @JsonProperty("source") val source: String, + @JsonProperty("source_type") val sourceType: String, + @JsonProperty("link") val link: String, + @JsonProperty("image") val image: String, + ) + + data class Trailer( + @JsonProperty("id") val id: Long? = null, + ) + + data class Credits( + @JsonProperty("cast") val cast: List, + @JsonProperty("crew") val crew: List, + ) + + data class Cast( + @JsonProperty("id") val id: Long, + @JsonProperty("name") val name: String, + @JsonProperty("url") val url: String, + @JsonProperty("slug") val slug: String, + @JsonProperty("images") val images: Images, + @JsonProperty("character_name") val characterName: String, + @JsonProperty("role") val role: String, + ) + + data class Crew( + @JsonProperty("id") val id: Long, + @JsonProperty("name") val name: String, + @JsonProperty("slug") val slug: String, + @JsonProperty("images") val images: Images, + @JsonProperty("job") val job: String, + ) + + class ShowEpisodes : ArrayList() + + data class ShowEpisodesItem( + @JsonProperty("name") val name: String, + @JsonProperty("release_date") val releaseDate: String, + @JsonProperty("episodes") val episodes: List, + @JsonProperty("timezone") val timezone: String, + @JsonProperty("total") val total: Int, + ) + + data class ShowEpisode( + @JsonProperty("id") val id: Int, + @JsonProperty("episode_number") val episodeNumber: Int, + @JsonProperty("rating") val rating: Double, + @JsonProperty("votes") val votes: Int, + @JsonProperty("released_at") val releasedAt: String, + ) + + data class TrailerRoot( + @JsonProperty("trailer") val trailer: TrailerNode, + ) + + data class TrailerNode( + @JsonProperty("trailer") val trailerDetails: TrailerDetails, + ) + + data class TrailerDetails( + @JsonProperty("id") val id: Long, + @JsonProperty("source") val source: String, + ) + + data class LinkData( + @JsonProperty("id") val id: Long? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("year") val year: Int? = null, + @JsonProperty("orgTitle") val orgTitle: String? = null, + @JsonProperty("lastSeason") val lastSeason: Int? = null, + @JsonProperty("date") val date: String? = null, + @JsonProperty("airedDate") val airedDate: String? = null, + ) +} \ No newline at end of file