Inject ClearKey PSSH when MPD lacks default_KID for Odido-style DASH.
Also improve live player DRM error logging for easier diagnosis.
This commit is contained in:
parent
2a734cbcd1
commit
f6c48ff488
2 changed files with 155 additions and 23 deletions
|
|
@ -11,15 +11,25 @@ import androidx.media3.common.TrackSelectionOverride
|
||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import android.util.Log
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.Renderer
|
import androidx.media3.exoplayer.Renderer
|
||||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||||
import androidx.media3.exoplayer.audio.AudioSink
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.common.DrmInitData
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.exoplayer.analytics.PlayerId
|
||||||
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
|
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
|
||||||
|
import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider
|
||||||
|
import androidx.media3.exoplayer.drm.DrmSession
|
||||||
|
import androidx.media3.exoplayer.drm.DrmSessionEventListener
|
||||||
import androidx.media3.exoplayer.drm.DrmSessionManager
|
import androidx.media3.exoplayer.drm.DrmSessionManager
|
||||||
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
|
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
|
||||||
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
|
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
|
||||||
|
import androidx.media3.extractor.mp4.PsshAtomUtil
|
||||||
|
import android.os.Looper
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
|
@ -29,6 +39,8 @@ import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
|
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
|
||||||
import nl.vonas.mediacluster.tv.data.DrmInfo
|
import nl.vonas.mediacluster.tv.data.DrmInfo
|
||||||
|
|
@ -190,7 +202,7 @@ private val liveOkHttp: OkHttpClient by lazy {
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live IPTV: TS-first, kleine buffer, lage latency. ClearKey via LocalMediaDrmCallback (geen data:-URI). */
|
/** Live IPTV: TS/HLS/DASH. ClearKey-license per mediaId (LocalMediaDrmCallback). */
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
|
@ -221,9 +233,10 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
val fallbackDrmProvider = DefaultDrmSessionManagerProvider()
|
||||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||||
.setDrmSessionManagerProvider { mediaItem ->
|
.setDrmSessionManagerProvider { mediaItem ->
|
||||||
clearKeyDrmSessionManagerFromTag(mediaItem) ?: DrmSessionManager.DRM_UNSUPPORTED
|
clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExoPlayer.Builder(appContext, renderersFactory)
|
return ExoPlayer.Builder(appContext, renderersFactory)
|
||||||
|
|
@ -237,18 +250,88 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
||||||
.also { it.volume = 1f }
|
.also { it.volume = 1f }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ClearKey-keys in MediaItem.tag — OkHttp kan geen data:-license-URI laden. */
|
/** ClearKey license + KIDs per MediaItem.mediaId. */
|
||||||
private data class ClearKeyLicenseTag(val json: String)
|
private data class ClearKeyMaterial(
|
||||||
|
val licenseJson: String,
|
||||||
|
val keyIds: List<ByteArray>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val clearKeyLicenses = ConcurrentHashMap<String, ClearKeyMaterial>()
|
||||||
|
private const val TAG = "VmcLiveDrm"
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun clearKeyDrmSessionManagerFromTag(mediaItem: MediaItem): DrmSessionManager? {
|
private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? {
|
||||||
val tag = mediaItem.localConfiguration?.tag as? ClearKeyLicenseTag ?: return null
|
val mediaId = mediaItem.mediaId?.takeIf { it.isNotBlank() } ?: return null
|
||||||
val callback = LocalMediaDrmCallback(tag.json.toByteArray(Charsets.UTF_8))
|
val material = clearKeyLicenses[mediaId] ?: return null
|
||||||
return DefaultDrmSessionManager.Builder()
|
Log.i(TAG, "ClearKey DRM session for mediaId=$mediaId kids=${material.keyIds.size}")
|
||||||
|
val callback = LocalMediaDrmCallback(material.licenseJson.toByteArray(Charsets.UTF_8))
|
||||||
|
val base = DefaultDrmSessionManager.Builder()
|
||||||
.setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
|
.setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
|
||||||
.setMultiSession(false)
|
.setMultiSession(true)
|
||||||
.setPlayClearSamplesWithoutKeys(true)
|
.setPlayClearSamplesWithoutKeys(true)
|
||||||
.build(callback)
|
.build(callback)
|
||||||
|
// Odido e.d.: MPD heeft vaak alleen mp4protection zonder default_KID → geen ClearKey SchemeData.
|
||||||
|
// Injecteer synthetische PSSH met onze KIDs zodat MediaDrm ClearKey mag starten.
|
||||||
|
return InjectingClearKeyDrmSessionManager(base, material.keyIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps DefaultDrmSessionManager and injects ClearKey SchemeData when the manifest/init
|
||||||
|
* segment has no ClearKey PSSH (MissingSchemeDataException otherwise).
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private class InjectingClearKeyDrmSessionManager(
|
||||||
|
private val wrapped: DefaultDrmSessionManager,
|
||||||
|
private val keyIds: List<ByteArray>,
|
||||||
|
) : DrmSessionManager {
|
||||||
|
override fun prepare() = wrapped.prepare()
|
||||||
|
override fun release() = wrapped.release()
|
||||||
|
|
||||||
|
override fun setPlayer(playbackLooper: Looper, playerId: PlayerId) {
|
||||||
|
wrapped.setPlayer(playbackLooper, playerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun acquireSession(
|
||||||
|
eventDispatcher: DrmSessionEventListener.EventDispatcher?,
|
||||||
|
format: Format,
|
||||||
|
): DrmSession? {
|
||||||
|
return wrapped.acquireSession(eventDispatcher, formatWithClearKeyScheme(format))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCryptoType(format: Format): Int {
|
||||||
|
val withScheme = formatWithClearKeyScheme(format)
|
||||||
|
val type = wrapped.getCryptoType(withScheme)
|
||||||
|
return if (type == C.CRYPTO_TYPE_NONE && keyIds.isNotEmpty()) {
|
||||||
|
C.CRYPTO_TYPE_FRAMEWORK
|
||||||
|
} else {
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatWithClearKeyScheme(format: Format): Format {
|
||||||
|
val existing = format.drmInitData
|
||||||
|
val hasClearKey = existing != null && (0 until existing.schemeDataCount).any {
|
||||||
|
existing.get(it).matches(C.CLEARKEY_UUID)
|
||||||
|
}
|
||||||
|
if (hasClearKey || keyIds.isEmpty()) return format
|
||||||
|
|
||||||
|
val keyUuids = keyIds.map { bytesToUuid(it) }.toTypedArray()
|
||||||
|
val pssh = PsshAtomUtil.buildPsshAtom(C.CLEARKEY_UUID, keyUuids, /* data= */ null)
|
||||||
|
val schemeData = DrmInitData.SchemeData(
|
||||||
|
C.CLEARKEY_UUID,
|
||||||
|
MimeTypes.VIDEO_MP4,
|
||||||
|
pssh,
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Injecting ClearKey PSSH with ${keyIds.size} key id(s)")
|
||||||
|
return format.buildUpon()
|
||||||
|
.setDrmInitData(DrmInitData(schemeData))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bytesToUuid(bytes: ByteArray): UUID {
|
||||||
|
val buf = ByteBuffer.wrap(bytes)
|
||||||
|
return UUID(buf.long, buf.long)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TS / HLS / DASH(+ClearKey). */
|
/** TS / HLS / DASH(+ClearKey). */
|
||||||
|
|
@ -289,9 +372,23 @@ fun liveMediaItem(
|
||||||
}
|
}
|
||||||
val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty()
|
val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty()
|
||||||
if (clearKeys.isNotEmpty()) {
|
if (clearKeys.isNotEmpty()) {
|
||||||
|
val mediaId = "ck-${UUID.randomUUID()}"
|
||||||
|
val keyIds = buildList {
|
||||||
|
for (k in clearKeys) {
|
||||||
|
val kid = hexToBytes(k.kid)
|
||||||
|
add(kid)
|
||||||
|
val swapped = swapUuidEndianness(kid)
|
||||||
|
if (!swapped.contentEquals(kid)) add(swapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
val licenseJson = buildClearKeyLicenseJson(clearKeys)
|
val licenseJson = buildClearKeyLicenseJson(clearKeys)
|
||||||
|
clearKeyLicenses[mediaId] = ClearKeyMaterial(licenseJson, keyIds)
|
||||||
|
if (clearKeyLicenses.size > 32) {
|
||||||
|
clearKeyLicenses.keys.take(clearKeyLicenses.size - 24).forEach { clearKeyLicenses.remove(it) }
|
||||||
|
}
|
||||||
|
Log.i(TAG, "ClearKey mediaItem keys=${clearKeys.size} url=${url.take(96)} json=$licenseJson")
|
||||||
builder
|
builder
|
||||||
.setTag(ClearKeyLicenseTag(licenseJson))
|
.setMediaId(mediaId)
|
||||||
.setDrmConfiguration(
|
.setDrmConfiguration(
|
||||||
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build(),
|
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build(),
|
||||||
)
|
)
|
||||||
|
|
@ -299,15 +396,22 @@ fun liveMediaItem(
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClearKey license JSON. KID in normale volgorde én GUID-endianness-swap
|
||||||
|
* (sommige MPD default_KID / PSSH verwachten die byte-order).
|
||||||
|
*/
|
||||||
private fun buildClearKeyLicenseJson(keys: List<ClearKeyEntry>): String {
|
private fun buildClearKeyLicenseJson(keys: List<ClearKeyEntry>): String {
|
||||||
val arr = org.json.JSONArray()
|
val arr = org.json.JSONArray()
|
||||||
for (k in keys) {
|
for (k in keys) {
|
||||||
arr.put(
|
val kidBytes = hexToBytes(k.kid)
|
||||||
org.json.JSONObject()
|
val keyBytes = hexToBytes(k.key)
|
||||||
.put("kty", "oct")
|
val kidNormal = bytesToBase64Url(kidBytes)
|
||||||
.put("kid", hexToBase64Url(k.kid))
|
val kidSwapped = bytesToBase64Url(swapUuidEndianness(kidBytes))
|
||||||
.put("k", hexToBase64Url(k.key)),
|
val keyB64 = bytesToBase64Url(keyBytes)
|
||||||
)
|
arr.put(org.json.JSONObject().put("kty", "oct").put("kid", kidNormal).put("k", keyB64))
|
||||||
|
if (kidSwapped != kidNormal) {
|
||||||
|
arr.put(org.json.JSONObject().put("kty", "oct").put("kid", kidSwapped).put("k", keyB64))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return org.json.JSONObject()
|
return org.json.JSONObject()
|
||||||
.put("keys", arr)
|
.put("keys", arr)
|
||||||
|
|
@ -315,8 +419,7 @@ private fun buildClearKeyLicenseJson(keys: List<ClearKeyEntry>): String {
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ClearKey JSON uses URL-safe base64 without padding. */
|
private fun hexToBytes(hex: String): ByteArray {
|
||||||
private fun hexToBase64Url(hex: String): String {
|
|
||||||
val clean = hex.trim()
|
val clean = hex.trim()
|
||||||
.removePrefix("0x")
|
.removePrefix("0x")
|
||||||
.removePrefix("0X")
|
.removePrefix("0X")
|
||||||
|
|
@ -324,16 +427,29 @@ private fun hexToBase64Url(hex: String): String {
|
||||||
.replace(Regex("[\\s:]"), "")
|
.replace(Regex("[\\s:]"), "")
|
||||||
.lowercase()
|
.lowercase()
|
||||||
require(clean.length == 32 && clean.all { it in "0123456789abcdef" }) {
|
require(clean.length == 32 && clean.all { it in "0123456789abcdef" }) {
|
||||||
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(16)}…"
|
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}"
|
||||||
}
|
}
|
||||||
val bytes = ByteArray(clean.length / 2) { i ->
|
return ByteArray(16) { i ->
|
||||||
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
}
|
}
|
||||||
return android.util.Base64.encodeToString(
|
}
|
||||||
|
|
||||||
|
/** Microsoft GUID / sommige ClearKey packs: DWORD/WORD little-endian in eerste 8 bytes. */
|
||||||
|
private fun swapUuidEndianness(b: ByteArray): ByteArray {
|
||||||
|
require(b.size == 16)
|
||||||
|
return byteArrayOf(
|
||||||
|
b[3], b[2], b[1], b[0],
|
||||||
|
b[5], b[4],
|
||||||
|
b[7], b[6],
|
||||||
|
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bytesToBase64Url(bytes: ByteArray): String =
|
||||||
|
android.util.Base64.encodeToString(
|
||||||
bytes,
|
bytes,
|
||||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING,
|
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
|
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,10 @@ fun LiveTvPlayerScreen(
|
||||||
if (isDash) {
|
if (isDash) {
|
||||||
fallbackUrl = null
|
fallbackUrl = null
|
||||||
fallbackFormat = null
|
fallbackFormat = null
|
||||||
|
android.util.Log.i(
|
||||||
|
"VmcLivePlayer",
|
||||||
|
"play dash drm=${play.drm?.type} keys=${play.drm?.keys?.size ?: 0} url=${play.streamUrl.take(120)}",
|
||||||
|
)
|
||||||
startStream(play.streamUrl, play.format, play.drm)
|
startStream(play.streamUrl, play.format, play.drm)
|
||||||
} else if (!tsUrl.isNullOrBlank()) {
|
} else if (!tsUrl.isNullOrBlank()) {
|
||||||
fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
|
fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
|
||||||
|
|
@ -366,7 +370,19 @@ fun LiveTvPlayerScreen(
|
||||||
startStream(fb, fbFmt ?: "hls")
|
startStream(fb, fbFmt ?: "hls")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
error = e.message ?: "Afspeelfout"
|
val cause = generateSequence(e as Throwable?) { it.cause }
|
||||||
|
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
|
||||||
|
.distinct()
|
||||||
|
.take(3)
|
||||||
|
.joinToString(" → ")
|
||||||
|
android.util.Log.e(
|
||||||
|
"VmcLivePlayer",
|
||||||
|
"playback error code=${e.errorCode} name=${e.errorCodeName} msg=$cause",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
error = listOfNotNull(e.errorCodeName, cause.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(": ")
|
||||||
|
.ifBlank { e.message ?: "Afspeelfout" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
player.addListener(listener)
|
player.addListener(listener)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue