From f6c48ff4889017a389195abd57ef3c738f59fff9 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Fri, 18 Sep 2026 19:53:49 +0200 Subject: [PATCH] Inject ClearKey PSSH when MPD lacks default_KID for Odido-style DASH. Also improve live player DRM error logging for easier diagnosis. --- .../mediacluster/tv/playback/PlayerFactory.kt | 160 +++++++++++++++--- .../mediacluster/tv/ui/LiveTvPlayerScreen.kt | 18 +- 2 files changed, 155 insertions(+), 23 deletions(-) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index 97f83a6..1b5bfc6 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -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, +) + +private val clearKeyLicenses = ConcurrentHashMap() +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, +) : 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): 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): 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) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt index 7cb0dac..e005ea8 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt @@ -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)