check to see if an HLS playlist is a master playlist before parsing it

Changelog: changed
This commit is contained in:
Kai 2025-05-22 15:00:34 -05:00
commit 1509c11f64
No known key found for this signature in database
2 changed files with 172 additions and 109 deletions

View file

@ -4,8 +4,14 @@ import android.app.NotificationManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.parsers.HLS.MediaRendition
import com.futo.platformplayer.parsers.HLS.StreamInfo
import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory
@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
class UISlideOverlays {
companion object {
@ -269,6 +280,7 @@ class UISlideOverlays {
}
@OptIn(UnstableApi::class)
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
@ -292,55 +304,103 @@ class UISlideOverlays {
val masterPlaylist: HLS.MasterPlaylist
try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl, source is IHLSManifestAudioSource)
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
.parse(sourceUrl.toUri(), inputStream)
masterPlaylist.getAudioSources().forEach { it ->
if (playlist is HlsMediaPlaylist) {
if (source is IHLSManifestAudioSource) {
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
variant.name,
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedAudioVariant = variant
slideUpMenuOverlay.selectOption(audioButtons, variant)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
},
invokeParent = false
))
} else {
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
variant.name,
"${variant.width}x${variant.height}",
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedVideoVariant = variant
slideUpMenuOverlay.selectOption(videoButtons, variant)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
} else if (playlist is HlsMultivariantPlaylist) {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
masterPlaylist.getAudioSources().forEach { it ->
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
masterPlaylist.getVideoSources().forEach {
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
}
val newItems = arrayListOf<View>()
@ -950,26 +1010,30 @@ class UISlideOverlays {
+ actions).filterNotNull()
));
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(container.context,
SlideUpMenuGroup(
container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add,
container.context.getString(R.string.add_to_queue),
"${queue.size} " + container.context.getString(R.string.videos),
tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context,
SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add,
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
SlideUpMenuItem(container.context,
SlideUpMenuItem(
container.context,
R.drawable.ic_history,
container.context.getString(R.string.add_to_history),
"Mark as watched",
tag = "history",
call = { StateHistory.instance.markAsWatched(video); }),
));
));
val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem(
@ -1033,22 +1097,26 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(container.context,
SlideUpMenuGroup(
container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add,
container.context.getString(R.string.queue),
"${queue.size} " + container.context.getString(R.string.videos),
tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context,
SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add,
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true);
call = {
StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true);
UIDialogs.appToast("Added to watch later", false);
}),
)
)
);
val playlistItems = arrayListOf<SlideUpMenuItem>();

View file

@ -15,18 +15,14 @@ import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean
import java.io.ByteArrayInputStream
import java.net.URI
import java.net.URLConnection
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import kotlin.text.ifEmpty
class HLS {
companion object {
@OptIn(UnstableApi::class)
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String, isAudioSource: Boolean? = null): MasterPlaylist {
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
.parse(Uri.parse(sourceUrl), inputStream)
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
val baseUrl = URI(sourceUrl).resolve("./").toString()
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
@ -34,36 +30,27 @@ class HLS {
val sessionDataList = mutableListOf<SessionData>()
var independentSegments = false
if (playlist is HlsMediaPlaylist) {
independentSegments = playlist.hasIndependentSegments
if (isAudioSource == true) {
mediaRenditions.add(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))
} else {
variantPlaylists.add(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
}
} else if (playlist is HlsMultivariantPlaylist) {
masterPlaylistContent.lines().forEachIndexed { index, line ->
when {
line.startsWith("#EXT-X-STREAM-INF") -> {
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
val url = resolveUrl(baseUrl, nextLine)
masterPlaylistContent.lines().forEachIndexed { index, line ->
when {
line.startsWith("#EXT-X-STREAM-INF") -> {
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
val url = resolveUrl(baseUrl, nextLine)
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
}
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
}
line.startsWith("#EXT-X-MEDIA") -> {
mediaRenditions.add(parseMediaRendition(line, baseUrl))
}
line.startsWith("#EXT-X-MEDIA") -> {
mediaRenditions.add(parseMediaRendition(line, baseUrl))
}
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
independentSegments = true
}
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
independentSegments = true
}
line.startsWith("#EXT-X-SESSION-DATA") -> {
val sessionData = parseSessionData(line)
sessionDataList.add(sessionData)
}
line.startsWith("#EXT-X-SESSION-DATA") -> {
val sessionData = parseSessionData(line)
sessionDataList.add(sessionData)
}
}
}
@ -71,6 +58,31 @@ class HLS {
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
}
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
if (rendition.uri == null) {
return null
}
val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return when (rendition.type) {
"AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, rendition.uri)
else -> null
}
}
fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource {
var width: Int? = null
var height: Int? = null
val resolutionTokens = reference.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
}
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
@ -137,10 +149,10 @@ class HLS {
}
}
fun parseAndGetAudioSources(source: Any, content: String, url: String, isAudioSource: Boolean? = null): List<HLSVariantAudioUrlSource> {
fun parseAndGetAudioSources(source: Any, content: String, url: String): List<HLSVariantAudioUrlSource> {
val masterPlaylist: MasterPlaylist
try {
masterPlaylist = parseMasterPlaylist(content, url, isAudioSource)
masterPlaylist = parseMasterPlaylist(content, url)
return masterPlaylist.getAudioSources()
} catch (e: Throwable) {
if (content.lines().any { it.startsWith("#EXTINF:") }) {
@ -347,30 +359,13 @@ class HLS {
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map {
var width: Int? = null
var height: Int? = null
val resolutionTokens = it.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
variantReferenceToVariant(it)
}
}
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull {
if (it.uri == null) {
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri)
else -> null
}
return@mapNotNull mediaRenditionToVariant(it)
}
}