Merge branch 'request-modifiers' into 'master'

Improve request modifier support for casting and downloads

See merge request videostreaming/grayjay!171
This commit is contained in:
Koen
2026-04-30 15:32:54 +00:00
6 changed files with 119 additions and 80 deletions
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
@@ -382,7 +383,8 @@ class UISlideOverlays {
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl) val modifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl, HashMap(), modifier)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string() val masterPlaylistContent = masterPlaylistResponse.body?.string()
@@ -515,7 +517,7 @@ class UISlideOverlays {
slideUpMenuOverlay.onOK.subscribe { slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue //TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null); StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} }
@@ -526,11 +528,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null) StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null, videoModifier = modifier)
UIDialogs.toast(container.context, "Variant video HLS playlist download started") UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) { } else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null) StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null, audioModifier = modifier)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started") UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else { } else {
@@ -23,6 +23,7 @@ import java.time.Duration
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
open class ManagedHttpClient { open class ManagedHttpClient {
@@ -89,10 +90,16 @@ open class ManagedHttpClient {
return clonedClient; return clonedClient;
} }
fun tryHead(url: String): Map<String, String>? { private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
if (modifier == null) return Pair(url, headers)
val modified = modifier.modifyRequest(url, headers)
return Pair(modified.url ?: url, modified.headers.toMutableMap())
}
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
ensureNotMainThread() ensureNotMainThread()
try { try {
val result = head(url); val result = head(url, HashMap(), modifier);
if(result.isOk) if(result.isOk)
return result.getHeadersFlat(); return result.getHeadersFlat();
else else
@@ -141,12 +148,14 @@ open class ManagedHttpClient {
return Socket(websocket); return Socket(websocket);
} }
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response { fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
return execute(Request(url, "GET", null, headers)); val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "GET", null, finalHeaders));
} }
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response { fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
return execute(Request(url, "HEAD", null, headers)); val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
} }
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response { fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
@@ -920,6 +920,7 @@ class StateCasting {
if (videoSource != null) { if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -927,6 +928,7 @@ class StateCasting {
if (audioSource != null) { if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -968,8 +970,7 @@ class StateCasting {
val headers = masterContext.headers.clone() val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl"; headers["Content-Type"] = "application/vnd.apple.mpegurl";
val req = requestModifier?.modifyRequest(sourceUrl, mapOf()) val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
val masterPlaylistResponse = _client.get(req?.url ?: sourceUrl, (req?.headers ?: mapOf()).toMutableMap())
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" } check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
@@ -1022,7 +1023,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone() val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(variantPlaylistRef.url) val response = _client.get(variantPlaylistRef.url, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" } check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string() val vpContent = response.body?.string()
@@ -1059,7 +1060,7 @@ class StateCasting {
val vpHeaders = vpContext.headers.clone() val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val response = _client.get(mediaRendition.uri) val response = _client.get(mediaRendition.uri, mutableMapOf(), requestModifier)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" } check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val vpContent = response.body?.string() val vpContent = response.body?.string()
@@ -1190,6 +1191,7 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant"); ).withTag("castHlsIndirectVariant");
@@ -1267,6 +1269,7 @@ class StateCasting {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant"); ).withTag("castHlsIndirectVariant");
@@ -1350,6 +1353,7 @@ class StateCasting {
if (videoSource != null) { if (videoSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true) HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -1357,6 +1361,7 @@ class StateCasting {
if (audioSource != null) { if (audioSource != null) {
_castServer.addHandlerWithAllowAllOptions( _castServer.addHandlerWithAllowAllOptions(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true) HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
@@ -156,6 +156,18 @@ class VideoDownload {
var hasVideoRequestModifier: Boolean = false; var hasVideoRequestModifier: Boolean = false;
var hasAudioRequestModifier: Boolean = false; var hasAudioRequestModifier: Boolean = false;
// Transient: IRequestModifier is a runtime object from the JS plugin engine and cannot be
// serialized. After deserialization these are null - DownloadService must re-prepare to
// recapture them from the live plugin source (see needsReprepareForAuth).
@kotlinx.serialization.Transient
private var preparedVideoRequestModifier: IRequestModifier? = null;
@kotlinx.serialization.Transient
private var preparedAudioRequestModifier: IRequestModifier? = null;
val needsReprepareForAuth: Boolean get() =
(hasVideoRequestModifier && preparedVideoRequestModifier == null && videoSourceLive == null) ||
(hasAudioRequestModifier && preparedAudioRequestModifier == null && audioSourceLive == null);
var progress: Double = 0.0; var progress: Double = 0.0;
var isCancelled = false; var isCancelled = false;
@@ -211,7 +223,7 @@ class VideoDownload {
this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch? this.requireAudioSource = targetBitrate != null; //TODO: May not be a valid check.. can only be determined after live fetch?
this.requiredCheck = optionalSources; this.requiredCheck = optionalSources;
} }
constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) { constructor(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
this.video = SerializedPlatformVideo.fromVideo(video); this.video = SerializedPlatformVideo.fromVideo(video);
this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf()); this.videoDetails = SerializedPlatformVideoDetails.fromVideo(video, if (subtitleSource != null) listOf(subtitleSource) else listOf());
this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null; this.videoSource = if(videoSource is IVideoUrlSource) VideoUrlSource.fromUrlSource(videoSource) else null;
@@ -220,12 +232,22 @@ class VideoDownload {
this.audioSourceLive = if(audioSource is JSSource) audioSource else null; this.audioSourceLive = if(audioSource is JSSource) audioSource else null;
this.subtitleSource = subtitleSource; this.subtitleSource = subtitleSource;
this.prepareTime = OffsetDateTime.now(); this.prepareTime = OffsetDateTime.now();
this.preparedVideoRequestModifier = videoModifier ?: (if (videoSource is JSSource && videoSource.hasRequestModifier) videoSource.getRequestModifier() else null);
this.preparedAudioRequestModifier = audioModifier ?: (if (audioSource is JSSource && audioSource.hasRequestModifier) audioSource.getRequestModifier() else null);
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor; this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor; this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier; // Set modifier flags from either the source or an explicitly provided modifier
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier; // (e.g. from the HLS picker, where the source is an HLSVariant, not JSSource).
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate); // These flags are serialized and used by needsReprepareForAuth after restore.
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate); this.hasVideoRequestModifier = preparedVideoRequestModifier != null;
this.hasAudioRequestModifier = preparedAudioRequestModifier != null;
// requiresLiveVideoSource means a live JSSource is needed at download time (for executors
// or DASH generation). Modifiers alone don't require a live source - they're already
// captured in preparedVideoRequestModifier and recaptured via needsReprepareForAuth.
val sourceHasVideoModifier = videoSource is JSSource && videoSource.hasRequestModifier;
val sourceHasAudioModifier = audioSource is JSSource && audioSource.hasRequestModifier;
this.requiresLiveVideoSource = sourceHasVideoModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
this.requiresLiveAudioSource = sourceHasAudioModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
this.targetVideoName = videoSource?.name; this.targetVideoName = videoSource?.name;
this.targetAudioName = audioSource?.name; this.targetAudioName = audioSource?.name;
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null; this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
@@ -321,8 +343,10 @@ class VideoDownload {
val videoSources = arrayListOf<IVideoSource>() val videoSources = arrayListOf<IVideoSource>()
for (source in original.video.videoSources) { for (source in original.video.videoSources) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedVideoRequestModifier = sourceModifier
try { try {
val playlistResponse = client.get(source.url) val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) { if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string() val playlistContent = playlistResponse.body?.string()
@@ -349,6 +373,8 @@ class VideoDownload {
if(vsource is JSSource) { if(vsource is JSSource) {
this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor; this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || vsource.hasRequestExecutor;
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate); this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (vsource is JSDashManifestRawSource && vsource.hasGenerate);
if (vsource.hasRequestModifier && preparedVideoRequestModifier == null)
preparedVideoRequestModifier = vsource.getRequestModifier()
} }
if(vsource == null) { if(vsource == null) {
@@ -370,8 +396,10 @@ class VideoDownload {
if (video is VideoUnMuxedSourceDescriptor) { if (video is VideoUnMuxedSourceDescriptor) {
for (source in video.audioSources) { for (source in video.audioSources) {
if (source is IHLSManifestAudioSource) { if (source is IHLSManifestAudioSource) {
val sourceModifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
if (sourceModifier != null) preparedAudioRequestModifier = sourceModifier
try { try {
val playlistResponse = client.get(source.url) val playlistResponse = client.get(source.url, HashMap(), sourceModifier)
if (playlistResponse.isOk) { if (playlistResponse.isOk) {
val resolvedPlaylistUrl = playlistResponse.url val resolvedPlaylistUrl = playlistResponse.url
val playlistContent = playlistResponse.body?.string() val playlistContent = playlistResponse.body?.string()
@@ -406,6 +434,8 @@ class VideoDownload {
if(asource is JSSource) { if(asource is JSSource) {
this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor; this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor;
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate); this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate);
if (asource.hasRequestModifier && preparedAudioRequestModifier == null)
preparedAudioRequestModifier = asource.getRequestModifier()
} }
if(asource == null) { if(asource == null) {
@@ -502,10 +532,16 @@ class VideoDownload {
} }
} }
val videoModifier = preparedVideoRequestModifier
if(actualVideoSource is IVideoUrlSource) if(actualVideoSource is IVideoUrlSource)
videoFileSize = when (videoSource!!.container) { videoFileSize = when (videoSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) "application/vnd.apple.mpegurl" -> {
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback) // HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
videoOverrideContainer = "video/mp4";
downloadHlsSource(context, "Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
}
else -> downloadFileSource("Video", client, videoModifier, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
} }
else if(actualVideoSource is JSDashManifestRawSource) { else if(actualVideoSource is JSDashManifestRawSource) {
if(actualAudioSource == null) if(actualAudioSource == null)
@@ -546,10 +582,16 @@ class VideoDownload {
} }
} }
val audioModifier = preparedAudioRequestModifier
if(actualAudioSource is IAudioUrlSource) if(actualAudioSource is IAudioUrlSource)
audioFileSize = when (audioSource!!.container) { audioFileSize = when (audioSource!!.container) {
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) "application/vnd.apple.mpegurl" -> {
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback) // HLS segments are concatenated into an MP4 file during download,
// so override the container for local playback/casting
audioOverrideContainer = "audio/mp4";
downloadHlsSource(context, "Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
}
else -> downloadFileSource("Audio", client, audioModifier, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
} }
else if(actualAudioSource is JSDashManifestRawAudioSource) { else if(actualAudioSource is JSDashManifestRawAudioSource) {
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2); audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback, 2);
@@ -663,15 +705,11 @@ class VideoDownload {
} }
} }
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, modifier: IRequestModifier?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if (targetFile.exists()) if (targetFile.exists())
targetFile.delete() targetFile.delete()
var downloadedTotalLength = 0L var downloadedTotalLength = 0L
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier()
else
null
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray { fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
val headers = mutableMapOf<String, String>() val headers = mutableMapOf<String, String>()
@@ -685,17 +723,13 @@ class VideoDownload {
} }
} }
val modified = modifier?.modifyRequest(url, headers) val resp = client.get(url, headers, modifier)
val finalUrl = modified?.url ?: url
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
val resp = client.get(finalUrl, finalHeaders)
if (!resp.isOk) { if (!resp.isOk) {
resp.body?.close() resp.body?.close()
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}") throw IllegalStateException("Failed to download HLS resource ($url): HTTP ${resp.code}")
} }
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body") val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($url): Empty body")
val bytes = body.bytes() val bytes = body.bytes()
body.close() body.close()
return bytes return bytes
@@ -710,12 +744,7 @@ class VideoDownload {
val segmentFiles = arrayListOf<File>() val segmentFiles = arrayListOf<File>()
try { try {
val playlistHeaders = mutableMapOf<String, String>() val playlistResp = client.get(hlsUrl, mutableMapOf(), modifier)
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
val playlistResp = client.get(
modifiedPlaylistReq?.url ?: hlsUrl,
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
)
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" } check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
@@ -964,16 +993,7 @@ class VideoDownload {
Logger.i(TAG, "Downloading cue ${indexCounter}") Logger.i(TAG, "Downloading cue ${indexCounter}")
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString()); val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
val modified = modifier?.modifyRequest(url, mapOf()); val data = executeOrGet(client, executor, modifier, url)
val data = if(executor != null)
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
else {
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream.write(data, 0, data.size); fileStream.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong()); speedTracker.addWork(data.size.toLong());
written += data.size; written += data.size;
@@ -993,16 +1013,7 @@ class VideoDownload {
val t2 = cue2.groupValues[1]; val t2 = cue2.groupValues[1];
val d2 = cue2.groupValues[2]; val d2 = cue2.groupValues[2];
val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString()); val url2 = foundTemplateUrl2!!.replace("\$Number\$", (index2).toString());
val modified2 = modifier?.modifyRequest(url, mapOf()); val data = executeOrGet(client, executor, modifier, url2)
val data = if(executor != null)
executor.executeRequest("GET", modified2?.url ?: url2, null, modified2?.headers ?: mapOf());
else {
val resp = client.get(modified2?.url ?: url, modified2?.headers?.toMutableMap() ?: mutableMapOf());
if(!resp.isOk)
throw IllegalStateException("Dash request2 failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
resp.body!!.bytes()
}
fileStream2.write(data, 0, data.size); fileStream2.write(data, 0, data.size);
speedTracker.addWork(data.size.toLong()); speedTracker.addWork(data.size.toLong());
written2 += data.size; written2 += data.size;
@@ -1071,7 +1082,7 @@ class VideoDownload {
} }
} }
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private fun downloadFileSource(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@@ -1080,13 +1091,8 @@ class VideoDownload {
val sourceLength: Long?; val sourceLength: Long?;
val fileStream = FileOutputStream(targetFile); val fileStream = FileOutputStream(targetFile);
val modifier = if (source is JSSource && source.hasRequestModifier)
source.getRequestModifier();
else
null;
try { try {
val head = client.tryHead(videoUrl); val head = client.tryHead(videoUrl, modifier);
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null }; val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length")) if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
{ {
@@ -1161,12 +1167,7 @@ class VideoDownload {
var lastSpeed: Long = 0; var lastSpeed: Long = 0;
val result = if (modifier != null) { val result = client.get(url, HashMap(), modifier)
val modified = modifier.modifyRequest(url, mapOf())
client.get(modified.url!!, modified.headers.toMutableMap())
} else {
client.get(url)
}
if (!result.isOk) { if (!result.isOk) {
result.body?.close() result.body?.close()
throw IllegalStateException("Failed to download source. Web[${result.code}] Error"); throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
@@ -1379,13 +1380,12 @@ class VideoDownload {
var lastException: Throwable? = null; var lastException: Throwable? = null;
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")); val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
val modified = modifier?.modifyRequest(url, headers);
while (retryCount <= 3) { while (retryCount <= 3) {
try { try {
val toRead = rangeEnd - rangeStart; val toRead = rangeEnd - rangeStart;
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers); val req = client.get(url, headers.toMutableMap(), modifier);
if (!req.isOk) { if (!req.isOk) {
val bodyString = req.body?.string() val bodyString = req.body?.string()
req.body?.close() req.body?.close()
@@ -1519,6 +1519,18 @@ class VideoDownload {
} }
} }
private fun executeOrGet(client: ManagedHttpClient, executor: JSRequestExecutor?, modifier: IRequestModifier?, url: String, headers: Map<String, String> = mapOf()): ByteArray {
if (executor != null) {
val modified = modifier?.modifyRequest(url, headers)
return executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: headers)
} else {
val resp = client.get(url, headers.toMutableMap(), modifier)
if (!resp.isOk)
throw IllegalStateException("Request failed for ($url) with code: ${resp.code}")
return resp.body!!.bytes()
}
}
companion object { companion object {
const val TAG = "VideoDownload"; const val TAG = "VideoDownload";
const val GROUP_PLAYLIST = "Playlist"; const val GROUP_PLAYLIST = "Playlist";
@@ -238,6 +238,16 @@ class DownloadService : Service() {
download.targetBitrate = download.audioSource!!.bitrate.toLong(); download.targetBitrate = download.audioSource!!.bitrate.toLong();
download.audioSource = null; download.audioSource = null;
} }
// Force re-prepare if auth modifiers are needed but lost (e.g. after deserialization,
// since IRequestModifier is transient and cannot survive serialization).
// Must also clear sources so prepare() enters the source selection branches where
// modifiers are recaptured from the live plugin JSSource.
if(download.needsReprepareForAuth) {
Logger.w(TAG, "Video Download [${download.name}] needs re-prepare for auth modifiers");
download.videoDetails = null;
download.videoSource = null;
download.audioSource = null;
}
if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady)) if(download.videoDetails == null || (!download.isVideoDownloadReady || !download.isAudioDownloadReady))
download.changeState(VideoDownload.State.PREPARING); download.changeState(VideoDownload.State.PREPARING);
notifyDownload(download); notifyDownload(download);
@@ -10,6 +10,7 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
@@ -341,8 +342,8 @@ class StateDownloads {
fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) { fun download(video: IPlatformVideo, targetPixelcount: Long?, targetBitrate: Long?) {
download(VideoDownload(video, targetPixelcount, targetBitrate)); download(VideoDownload(video, targetPixelcount, targetBitrate));
} }
fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?) { fun download(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: SubtitleRawSource?, videoModifier: IRequestModifier? = null, audioModifier: IRequestModifier? = null) {
download(VideoDownload(video, videoSource, audioSource, subtitleSource)); download(VideoDownload(video, videoSource, audioSource, subtitleSource, videoModifier, audioModifier));
} }
private fun download(videoState: VideoDownload, notify: Boolean = true) { private fun download(videoState: VideoDownload, notify: Boolean = true) {