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:
Jos Vooges | STH 2026-09-18 19:53:49 +02:00
parent 2a734cbcd1
commit f6c48ff488
2 changed files with 155 additions and 23 deletions

View file

@ -11,15 +11,25 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import android.util.Log
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.audio.AudioRendererEventListener
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.DefaultDrmSessionManagerProvider
import androidx.media3.exoplayer.drm.DrmSession
import androidx.media3.exoplayer.drm.DrmSessionEventListener
import androidx.media3.exoplayer.drm.DrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
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.source.DefaultMediaSourceFactory
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_PREFER
import okhttp3.OkHttpClient
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
import nl.vonas.mediacluster.tv.data.DrmInfo
@ -190,7 +202,7 @@ private val liveOkHttp: OkHttpClient by lazy {
.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)
fun buildLiveTvPlayer(context: Context): ExoPlayer {
val appContext = context.applicationContext
@ -221,9 +233,10 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
val fallbackDrmProvider = DefaultDrmSessionManagerProvider()
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
.setDrmSessionManagerProvider { mediaItem ->
clearKeyDrmSessionManagerFromTag(mediaItem) ?: DrmSessionManager.DRM_UNSUPPORTED
clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem)
}
return ExoPlayer.Builder(appContext, renderersFactory)
@ -237,18 +250,88 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.also { it.volume = 1f }
}
/** ClearKey-keys in MediaItem.tag — OkHttp kan geen data:-license-URI laden. */
private data class ClearKeyLicenseTag(val json: String)
/** ClearKey license + KIDs per MediaItem.mediaId. */
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)
private fun clearKeyDrmSessionManagerFromTag(mediaItem: MediaItem): DrmSessionManager? {
val tag = mediaItem.localConfiguration?.tag as? ClearKeyLicenseTag ?: return null
val callback = LocalMediaDrmCallback(tag.json.toByteArray(Charsets.UTF_8))
return DefaultDrmSessionManager.Builder()
private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? {
val mediaId = mediaItem.mediaId?.takeIf { it.isNotBlank() } ?: return null
val material = clearKeyLicenses[mediaId] ?: return null
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)
.setMultiSession(false)
.setMultiSession(true)
.setPlayClearSamplesWithoutKeys(true)
.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). */
@ -289,9 +372,23 @@ fun liveMediaItem(
}
val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty()
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)
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
.setTag(ClearKeyLicenseTag(licenseJson))
.setMediaId(mediaId)
.setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build(),
)
@ -299,15 +396,22 @@ fun liveMediaItem(
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 {
val arr = org.json.JSONArray()
for (k in keys) {
arr.put(
org.json.JSONObject()
.put("kty", "oct")
.put("kid", hexToBase64Url(k.kid))
.put("k", hexToBase64Url(k.key)),
)
val kidBytes = hexToBytes(k.kid)
val keyBytes = hexToBytes(k.key)
val kidNormal = bytesToBase64Url(kidBytes)
val kidSwapped = bytesToBase64Url(swapUuidEndianness(kidBytes))
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()
.put("keys", arr)
@ -315,8 +419,7 @@ private fun buildClearKeyLicenseJson(keys: List<ClearKeyEntry>): String {
.toString()
}
/** ClearKey JSON uses URL-safe base64 without padding. */
private fun hexToBase64Url(hex: String): String {
private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim()
.removePrefix("0x")
.removePrefix("0X")
@ -324,16 +427,29 @@ private fun hexToBase64Url(hex: String): String {
.replace(Regex("[\\s:]"), "")
.lowercase()
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()
}
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,
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)

View file

@ -168,6 +168,10 @@ fun LiveTvPlayerScreen(
if (isDash) {
fallbackUrl = 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)
} else if (!tsUrl.isNullOrBlank()) {
fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
@ -366,7 +370,19 @@ fun LiveTvPlayerScreen(
startStream(fb, fbFmt ?: "hls")
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)