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.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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue