diff --git a/FreeTVProvider/build.gradle.kts b/FreeTVProvider/build.gradle.kts new file mode 100644 index 0000000..9306c22 --- /dev/null +++ b/FreeTVProvider/build.gradle.kts @@ -0,0 +1,24 @@ +// use an integer for version numbers +version = 1 + + +cloudstream { + // All of these properties are optional, you can safely remove them + + // description = "Lorem Ipsum" + authors = listOf("Adippe") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 1 // will be 3 if unspecified + tvTypes = listOf( + "Live", + ) + + iconUrl = "https://www.google.com/s2/favicons?domain=github.com&sz=%size%" +} diff --git a/FreeTVProvider/src/main/AndroidManifest.xml b/FreeTVProvider/src/main/AndroidManifest.xml new file mode 100644 index 0000000..29aec9d --- /dev/null +++ b/FreeTVProvider/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/FreeTVProvider/src/main/kotlin/com/lagradost/FreeTVProvider.kt b/FreeTVProvider/src/main/kotlin/com/lagradost/FreeTVProvider.kt new file mode 100644 index 0000000..8b7b3e9 --- /dev/null +++ b/FreeTVProvider/src/main/kotlin/com/lagradost/FreeTVProvider.kt @@ -0,0 +1,343 @@ +package com.lagradost + +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.Qualities +import java.io.InputStream + +class FreeTVProvider : MainAPI() { + override var lang = "en" + override var mainUrl = "https://raw.githubusercontent.com/Free-TV/IPTV/master/playlist.m3u8" + override var name = "Free-TV" + override val hasMainPage = true + override val hasChromecastSupport = true + override val supportedTypes = setOf( + TvType.Live, + ) + + override suspend fun getMainPage( + page: Int, + request : MainPageRequest + ): HomePageResponse { + val data = IptvPlaylistParser().parseM3U(app.get(mainUrl).text) + return HomePageResponse(data.items.groupBy{it.attributes["group-title"]}.map { group -> + val title = group.key ?: "" + val show = group.value.map { channel -> + val streamurl = channel.url.toString() + val channelname = channel.title.toString() + val posterurl = channel.attributes["tvg-logo"].toString() + val nation = channel.attributes["group-title"].toString() + LiveSearchResponse( + channelname, + LoadData(streamurl, channelname, posterurl, nation).toJson(), + this@FreeTVProvider.name, + TvType.Live, + posterurl, + lang = channel.attributes["group-title"] + ) + } + HomePageList( + title, + show, + isHorizontalImages = true + ) + }) + } + + override suspend fun search(query: String): List { + val data = IptvPlaylistParser().parseM3U(app.get(mainUrl).text) + + return data.items.filter { it.attributes["tvg-id"]?.contains(query) ?: false }.map { channel -> + val streamurl = channel.url.toString() + val channelname = channel.attributes["tvg-id"].toString() + val posterurl = channel.attributes["tvg-logo"].toString() + val nation = channel.attributes["group-title"].toString() + LiveSearchResponse( + channelname, + LoadData(streamurl, channelname, posterurl, nation).toJson(), + this@FreeTVProvider.name, + TvType.Live, + posterurl, + ) + } + } + + override suspend fun load(url: String): LoadResponse { + val data = parseJson(url) + return LiveStreamLoadResponse( + data.title, + data.url, + this.name, + url, + data.poster, + plot = data.nation + ) + } + data class LoadData( + val url: String, + val title: String, + val poster: String, + val nation: String + + ) + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + val loadData = parseJson(data) + callback.invoke( + ExtractorLink( + this.name, + loadData.title, + loadData.url, + "", + Qualities.Unknown.value, + isM3u8 = true + ) + ) + return true + } +} + + +data class Playlist( + val items: List = emptyList(), +) + +data class PlaylistItem( + val title: String? = null, + val attributes: Map = emptyMap(), + val headers: Map = emptyMap(), + val url: String? = null, + val userAgent: String? = null, +) + + +class IptvPlaylistParser { + + + /** + * Parse M3U8 string into [Playlist] + * + * @param content M3U8 content string. + * @throws PlaylistParserException if an error occurs. + */ + fun parseM3U(content: String): Playlist { + return parseM3U(content.byteInputStream()) + } + + /** + * Parse M3U8 content [InputStream] into [Playlist] + * + * @param input Stream of input data. + * @throws PlaylistParserException if an error occurs. + */ + @Throws(PlaylistParserException::class) + fun parseM3U(input: InputStream): Playlist { + val reader = input.bufferedReader() + + if (!reader.readLine().isExtendedM3u()) { + throw PlaylistParserException.InvalidHeader() + } + + val playlistItems: MutableList = mutableListOf() + var currentIndex = 0 + + var line: String? = reader.readLine() + + while (line != null) { + if (line.isNotEmpty()) { + if (line.startsWith(EXT_INF)) { + val title = line.getTitle() + val attributes = line.getAttributes() + playlistItems.add(PlaylistItem(title, attributes)) + } else if (line.startsWith(EXT_VLC_OPT)) { + val item = playlistItems[currentIndex] + val userAgent = line.getTagValue("http-user-agent") + val referrer = line.getTagValue("http-referrer") + val headers = if (referrer != null) { + item.headers + mapOf("referrer" to referrer) + } else item.headers + playlistItems[currentIndex] = + item.copy(userAgent = userAgent, headers = headers) + } else { + if (!line.startsWith("#")) { + val item = playlistItems[currentIndex] + val url = line.getUrl() + val userAgent = line.getUrlParameter("user-agent") + val referrer = line.getUrlParameter("referer") + val urlHeaders = if (referrer != null) { + item.headers + mapOf("referrer" to referrer) + } else item.headers + playlistItems[currentIndex] = + item.copy( + url = url, + headers = item.headers + urlHeaders, + userAgent = userAgent + ) + currentIndex++ + } + } + } + + line = reader.readLine() + } + return Playlist(playlistItems) + } + + /** + * Replace "" (quotes) from given string. + */ + private fun String.replaceQuotesAndTrim(): String { + return replace("\"", "").trim() + } + + /** + * Check if given content is valid M3U8 playlist. + */ + private fun String.isExtendedM3u(): Boolean = startsWith(EXT_M3U) + + /** + * Get title of media. + * + * Example:- + * + * Input: + * ``` + * #EXTINF:-1 tvg-id="1234" group-title="Kids" tvg-logo="url/to/logo", Title + * ``` + * Result: Title + */ + private fun String.getTitle(): String? { + return split(",").lastOrNull()?.replaceQuotesAndTrim() + } + + /** + * Get media url. + * + * Example:- + * + * Input: + * ``` + * https://example.com/sample.m3u8|user-agent="Custom" + * ``` + * Result: https://example.com/sample.m3u8 + */ + private fun String.getUrl(): String? { + return split("|").firstOrNull()?.replaceQuotesAndTrim() + } + + /** + * Get url parameters. + * + * Example:- + * + * Input: + * ``` + * http://192.54.104.122:8080/d/abcdef/video.mp4|User-Agent=Mozilla&Referer=CustomReferrer + * ``` + * Result will be equivalent to kotlin map: + * ```Kotlin + * mapOf( + * "User-Agent" to "Mozilla", + * "Referer" to "CustomReferrer" + * ) + * ``` + */ + private fun String.getUrlParameters(): Map { + val urlRegex = Regex("^(.*)\\|", RegexOption.IGNORE_CASE) + val headersString = replace(urlRegex, "").replaceQuotesAndTrim() + return headersString.split("&").mapNotNull { + val pair = it.split("=") + if (pair.size == 2) pair.first() to pair.last() else null + }.toMap() + } + + /** + * Get url parameter with key. + * + * Example:- + * + * Input: + * ``` + * http://192.54.104.122:8080/d/abcdef/video.mp4|User-Agent=Mozilla&Referer=CustomReferrer + * ``` + * If given key is `user-agent`, then + * + * Result: Mozilla + */ + private fun String.getUrlParameter(key: String): String? { + val urlRegex = Regex("^(.*)\\|", RegexOption.IGNORE_CASE) + val keyRegex = Regex("$key=(\\w[^&]*)", RegexOption.IGNORE_CASE) + val paramsString = replace(urlRegex, "").replaceQuotesAndTrim() + return keyRegex.find(paramsString)?.groups?.get(1)?.value + } + + /** + * Get attributes from `#EXTINF` tag as Map. + * + * Example:- + * + * Input: + * ``` + * #EXTINF:-1 tvg-id="1234" group-title="Kids" tvg-logo="url/to/logo", Title + * ``` + * Result will be equivalent to kotlin map: + * ```Kotlin + * mapOf( + * "tvg-id" to "1234", + * "group-title" to "Kids", + * "tvg-logo" to "url/to/logo" + *) + * ``` + */ + private fun String.getAttributes(): Map { + val extInfRegex = Regex("(#EXTINF:.?[0-9]+)", RegexOption.IGNORE_CASE) + val attributesString = replace(extInfRegex, "").replaceQuotesAndTrim().split(",").first() + return attributesString.split(Regex("\\s")).mapNotNull { + val pair = it.split("=") + if (pair.size == 2) pair.first() to pair.last() + .replaceQuotesAndTrim() else null + }.toMap() + } + + /** + * Get value from a tag. + * + * Example:- + * + * Input: + * ``` + * #EXTVLCOPT:http-referrer=http://example.com/ + * ``` + * Result: http://example.com/ + */ + private fun String.getTagValue(key: String): String? { + val keyRegex = Regex("$key=(.*)", RegexOption.IGNORE_CASE) + return keyRegex.find(this)?.groups?.get(1)?.value?.replaceQuotesAndTrim() + } + + companion object { + const val EXT_M3U = "#EXTM3U" + const val EXT_INF = "#EXTINF" + const val EXT_VLC_OPT = "#EXTVLCOPT" + } + +} + +/** + * Exception thrown when an error occurs while parsing playlist. + */ +sealed class PlaylistParserException(message: String) : Exception(message) { + + /** + * Exception thrown if given file content is not valid. + */ + class InvalidHeader : + PlaylistParserException("Invalid file header. Header doesn't start with #EXTM3U") + +} \ No newline at end of file diff --git a/FreeTVProvider/src/main/kotlin/com/lagradost/FreeTVProviderPlugin.kt b/FreeTVProvider/src/main/kotlin/com/lagradost/FreeTVProviderPlugin.kt new file mode 100644 index 0000000..826e14e --- /dev/null +++ b/FreeTVProvider/src/main/kotlin/com/lagradost/FreeTVProviderPlugin.kt @@ -0,0 +1,14 @@ + +package com.lagradost + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class FreeTVProviderPlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(FreeTVProvider()) + } +} \ No newline at end of file diff --git a/IptvorgProvider/build.gradle.kts b/IptvorgProvider/build.gradle.kts new file mode 100644 index 0000000..9306c22 --- /dev/null +++ b/IptvorgProvider/build.gradle.kts @@ -0,0 +1,24 @@ +// use an integer for version numbers +version = 1 + + +cloudstream { + // All of these properties are optional, you can safely remove them + + // description = "Lorem Ipsum" + authors = listOf("Adippe") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 1 // will be 3 if unspecified + tvTypes = listOf( + "Live", + ) + + iconUrl = "https://www.google.com/s2/favicons?domain=github.com&sz=%size%" +} diff --git a/IptvorgProvider/src/main/AndroidManifest.xml b/IptvorgProvider/src/main/AndroidManifest.xml new file mode 100644 index 0000000..29aec9d --- /dev/null +++ b/IptvorgProvider/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/IptvorgProvider/src/main/kotlin/com/lagradost/IptvorgProvider.kt b/IptvorgProvider/src/main/kotlin/com/lagradost/IptvorgProvider.kt new file mode 100644 index 0000000..4dcbd44 --- /dev/null +++ b/IptvorgProvider/src/main/kotlin/com/lagradost/IptvorgProvider.kt @@ -0,0 +1,367 @@ +package com.lagradost + +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.Qualities +import java.io.InputStream + +class IptvorgProvider : MainAPI() { + override var lang = "en" + override var mainUrl = "https://raw.githubusercontent.com/iptv-org/iptv/master/README.md" + override var name = "Iptv-org" + override val hasMainPage = true + override val hasChromecastSupport = true + override val supportedTypes = setOf( + TvType.Live, + ) + + override suspend fun getMainPage( + page: Int, + request : MainPageRequest + ): HomePageResponse { + val data = app.get(mainUrl).document + val table = data.select("tbody")[2].select("td").chunked(3) + val shows = table.map { nation -> + val channelUrl = nation[2].text() + val nationName = nation[0].text() + val nationPoster = "https://github.com/emcrisostomo/flags/raw/master/png/256/${channelUrl + .substringAfterLast("/") + .substringBeforeLast(".").uppercase()}.png" + LiveSearchResponse( + nationName, + LoadData(channelUrl, nationName, nationPoster, 0).toJson(), + this.name, + TvType.TvSeries, + nationPoster, + ) + } + return HomePageResponse( + listOf(HomePageList( + "Nations", + shows, + true + )) + ) + + } + + override suspend fun search(query: String): List { + val data = IptvPlaylistParser().parseM3U(app.get("https://iptv-org.github.io/iptv/index.m3u").text) + + return data.items.filter { it.title?.lowercase()?.contains(query.lowercase()) ?: false }.map { channel -> + val streamurl = channel.url.toString() + val channelname = channel.attributes["tvg-id"].toString() + val posterurl = channel.attributes["tvg-logo"].toString() + LiveSearchResponse( + channelname, + LoadData(streamurl, channelname, posterurl, 1).toJson(), + this@IptvorgProvider.name, + TvType.Live, + posterurl, + ) + } + } + + + override suspend fun load(url: String): LoadResponse { + val loadData = parseJson(url) + + if (loadData.flag == 0){ + val playlist = IptvPlaylistParser().parseM3U(app.get(loadData.url).text) + val showlist = playlist.items.mapIndexed { index, channel -> + val streamurl = channel.url.toString() + val channelname = channel.title.toString() + val posterurl = channel.attributes["tvg-logo"].toString() + Episode( + LoadData(streamurl, channelname, posterurl, 0).toJson(), + channelname, + null, + index + 1, + posterurl + ) + } + + return TvSeriesLoadResponse( + loadData.channelName, + loadData.url, + this.name, + TvType.TvSeries, + showlist, + loadData.poster + ) + } + else return LiveStreamLoadResponse( + loadData.channelName, + loadData.url, + this.name, + LoadData(loadData.url, loadData.channelName, loadData.poster, 0).toJson(), + loadData.poster + ) + } + data class LoadData( + val url: String, + val channelName: String, + val poster: String, + val flag : Int + ) + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + val loadData = parseJson(data) + callback.invoke( + ExtractorLink( + this@IptvorgProvider.name, + loadData.channelName, + loadData.url, + "", + Qualities.Unknown.value, + isM3u8 = true + ) + ) + return true + } +} + + +data class Playlist( + val items: List = emptyList(), +) + +data class PlaylistItem( + val title: String? = null, + val attributes: Map = emptyMap(), + val headers: Map = emptyMap(), + val url: String? = null, + val userAgent: String? = null, +) + + +class IptvPlaylistParser { + + + /** + * Parse M3U8 string into [Playlist] + * + * @param content M3U8 content string. + * @throws PlaylistParserException if an error occurs. + */ + fun parseM3U(content: String): Playlist { + return parseM3U(content.byteInputStream()) + } + + /** + * Parse M3U8 content [InputStream] into [Playlist] + * + * @param input Stream of input data. + * @throws PlaylistParserException if an error occurs. + */ + @Throws(PlaylistParserException::class) + fun parseM3U(input: InputStream): Playlist { + val reader = input.bufferedReader() + + if (!reader.readLine().isExtendedM3u()) { + throw PlaylistParserException.InvalidHeader() + } + + val playlistItems: MutableList = mutableListOf() + var currentIndex = 0 + + var line: String? = reader.readLine() + + while (line != null) { + if (line.isNotEmpty()) { + if (line.startsWith(EXT_INF)) { + val title = line.getTitle() + val attributes = line.getAttributes() + playlistItems.add(PlaylistItem(title, attributes)) + } else if (line.startsWith(EXT_VLC_OPT)) { + val item = playlistItems[currentIndex] + val userAgent = line.getTagValue("http-user-agent") + val referrer = line.getTagValue("http-referrer") + val headers = if (referrer != null) { + item.headers + mapOf("referrer" to referrer) + } else item.headers + playlistItems[currentIndex] = + item.copy(userAgent = userAgent, headers = headers) + } else { + if (!line.startsWith("#")) { + val item = playlistItems[currentIndex] + val url = line.getUrl() + val userAgent = line.getUrlParameter("user-agent") + val referrer = line.getUrlParameter("referer") + val urlHeaders = if (referrer != null) { + item.headers + mapOf("referrer" to referrer) + } else item.headers + playlistItems[currentIndex] = + item.copy( + url = url, + headers = item.headers + urlHeaders, + userAgent = userAgent + ) + currentIndex++ + } + } + } + + line = reader.readLine() + } + return Playlist(playlistItems) + } + + /** + * Replace "" (quotes) from given string. + */ + private fun String.replaceQuotesAndTrim(): String { + return replace("\"", "").trim() + } + + /** + * Check if given content is valid M3U8 playlist. + */ + private fun String.isExtendedM3u(): Boolean = startsWith(EXT_M3U) + + /** + * Get title of media. + * + * Example:- + * + * Input: + * ``` + * #EXTINF:-1 tvg-id="1234" group-title="Kids" tvg-logo="url/to/logo", Title + * ``` + * Result: Title + */ + private fun String.getTitle(): String? { + return split(",").lastOrNull()?.replaceQuotesAndTrim() + } + + /** + * Get media url. + * + * Example:- + * + * Input: + * ``` + * https://example.com/sample.m3u8|user-agent="Custom" + * ``` + * Result: https://example.com/sample.m3u8 + */ + private fun String.getUrl(): String? { + return split("|").firstOrNull()?.replaceQuotesAndTrim() + } + + /** + * Get url parameters. + * + * Example:- + * + * Input: + * ``` + * http://192.54.104.122:8080/d/abcdef/video.mp4|User-Agent=Mozilla&Referer=CustomReferrer + * ``` + * Result will be equivalent to kotlin map: + * ```Kotlin + * mapOf( + * "User-Agent" to "Mozilla", + * "Referer" to "CustomReferrer" + * ) + * ``` + */ + private fun String.getUrlParameters(): Map { + val urlRegex = Regex("^(.*)\\|", RegexOption.IGNORE_CASE) + val headersString = replace(urlRegex, "").replaceQuotesAndTrim() + return headersString.split("&").mapNotNull { + val pair = it.split("=") + if (pair.size == 2) pair.first() to pair.last() else null + }.toMap() + } + + /** + * Get url parameter with key. + * + * Example:- + * + * Input: + * ``` + * http://192.54.104.122:8080/d/abcdef/video.mp4|User-Agent=Mozilla&Referer=CustomReferrer + * ``` + * If given key is `user-agent`, then + * + * Result: Mozilla + */ + private fun String.getUrlParameter(key: String): String? { + val urlRegex = Regex("^(.*)\\|", RegexOption.IGNORE_CASE) + val keyRegex = Regex("$key=(\\w[^&]*)", RegexOption.IGNORE_CASE) + val paramsString = replace(urlRegex, "").replaceQuotesAndTrim() + return keyRegex.find(paramsString)?.groups?.get(1)?.value + } + + /** + * Get attributes from `#EXTINF` tag as Map. + * + * Example:- + * + * Input: + * ``` + * #EXTINF:-1 tvg-id="1234" group-title="Kids" tvg-logo="url/to/logo", Title + * ``` + * Result will be equivalent to kotlin map: + * ```Kotlin + * mapOf( + * "tvg-id" to "1234", + * "group-title" to "Kids", + * "tvg-logo" to "url/to/logo" + *) + * ``` + */ + private fun String.getAttributes(): Map { + val extInfRegex = Regex("(#EXTINF:.?[0-9]+)", RegexOption.IGNORE_CASE) + val attributesString = replace(extInfRegex, "").replaceQuotesAndTrim().split(",").first() + return attributesString.split(Regex("\\s")).mapNotNull { + val pair = it.split("=") + if (pair.size == 2) pair.first() to pair.last() + .replaceQuotesAndTrim() else null + }.toMap() + } + + /** + * Get value from a tag. + * + * Example:- + * + * Input: + * ``` + * #EXTVLCOPT:http-referrer=http://example.com/ + * ``` + * Result: http://example.com/ + */ + private fun String.getTagValue(key: String): String? { + val keyRegex = Regex("$key=(.*)", RegexOption.IGNORE_CASE) + return keyRegex.find(this)?.groups?.get(1)?.value?.replaceQuotesAndTrim() + } + + companion object { + const val EXT_M3U = "#EXTM3U" + const val EXT_INF = "#EXTINF" + const val EXT_VLC_OPT = "#EXTVLCOPT" + } + +} + +/** + * Exception thrown when an error occurs while parsing playlist. + */ +sealed class PlaylistParserException(message: String) : Exception(message) { + + /** + * Exception thrown if given file content is not valid. + */ + class InvalidHeader : + PlaylistParserException("Invalid file header. Header doesn't start with #EXTM3U") + +} diff --git a/IptvorgProvider/src/main/kotlin/com/lagradost/IptvorgProviderPlugin.kt b/IptvorgProvider/src/main/kotlin/com/lagradost/IptvorgProviderPlugin.kt new file mode 100644 index 0000000..6bdd3bf --- /dev/null +++ b/IptvorgProvider/src/main/kotlin/com/lagradost/IptvorgProviderPlugin.kt @@ -0,0 +1,14 @@ + +package com.lagradost + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class IptvorgProviderPlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(IptvorgProvider()) + } +} \ No newline at end of file