Send Chrome User-Agent and Referer on Viaplay CDN fetches from Android, iOS and Tizen players.

This commit is contained in:
Jos Vooges | STH 2026-09-28 20:30:33 +02:00
parent 75969d87ec
commit 752ab8dc8a
8 changed files with 205 additions and 29 deletions

View file

@ -39,6 +39,7 @@ import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
import nl.vonas.mediacluster.tv.data.DrmInfo
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.nio.ByteBuffer
import java.util.UUID
@ -190,11 +191,32 @@ fun buildTvPlayer(context: Context): ExoPlayer {
}
/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
private const val VIAPLAY_BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
private fun isViaplayCdnHost(host: String): Boolean =
host.contains("viaplay", ignoreCase = true)
/** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */
private val viaplayBrowserHeaders = Interceptor { chain ->
val req = chain.request()
if (!isViaplayCdnHost(req.url.host)) return@Interceptor chain.proceed(req)
chain.proceed(
req.newBuilder()
.header("User-Agent", VIAPLAY_BROWSER_UA)
.header("Referer", "https://viaplay.com/")
.header("Origin", "https://viaplay.com")
.header("Accept", "*/*")
.build(),
)
}
private val liveOkHttp: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(viaplayBrowserHeaders)
.build()
}
@ -334,6 +356,7 @@ fun liveMediaItem(
url: String,
format: String,
drm: DrmInfo? = null,
live: Boolean = true,
): MediaItem {
// URL wint: ESPN/FAAB levert .m3u8 terwijl de API soms format=dash hardcodet.
val isHls = url.contains("m3u8", ignoreCase = true) ||
@ -347,7 +370,7 @@ fun liveMediaItem(
else -> MimeTypes.VIDEO_MP2T
}
val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
if (isHls) {
if (live && isHls) {
builder.setLiveConfiguration(
MediaItem.LiveConfiguration.Builder()
.setTargetOffsetMs(2_000)
@ -356,7 +379,7 @@ fun liveMediaItem(
.build(),
)
}
if (isDash) {
if (live && isDash && !url.contains("/start/") && !url.contains("/stop/")) {
builder.setLiveConfiguration(
MediaItem.LiveConfiguration.Builder()
.setTargetOffsetMs(3_000)

View file

@ -88,6 +88,8 @@ fun EventPlayerScreen(
api: ApiClient,
eventId: String,
onBack: () -> Unit,
hubPlay: Boolean = false,
productUrl: String? = null,
) {
val context = LocalContext.current
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
@ -132,7 +134,7 @@ fun EventPlayerScreen(
}
}
LaunchedEffect(eventId) {
LaunchedEffect(eventId, hubPlay, productUrl) {
loading = true
error = null
watchSessionId = null
@ -142,7 +144,19 @@ fun EventPlayerScreen(
audioTracks = emptyList()
textTracks = emptyList()
try {
val play = api.scheduleEventPlay(eventId)
val play = when {
hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> {
val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:")
api.viaplayContentPlay(guid, productUrl)
}
hubPlay -> {
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
?.groupValues?.getOrNull(1)
?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:")
api.f1tvContentPlay(contentId)
}
else -> api.scheduleEventPlay(eventId)
}
if (!isActive) return@LaunchedEffect
title = play.name
watchSessionId = play.watchSessionId
@ -151,14 +165,14 @@ fun EventPlayerScreen(
error = "Geen stream-URL beschikbaar"
return@LaunchedEffect
}
if (play.drm == null || play.drm.keys.isEmpty()) {
error = "Encryptiesleutels ontbreken — stream start binnenkort"
return@LaunchedEffect
}
val kapWindow = url.contains("/start/") && url.contains("/stop/")
val asVod = play.live == false ||
kapWindow ||
(hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true))
val item = try {
liveMediaItem(url, play.format, play.drm)
liveMediaItem(url, play.format, play.drm, live = !asVod)
} catch (e: Exception) {
error = e.message ?: "Ongeldige DRM-sleutels"
error = e.message ?: "Kon mediastream niet openen"
return@LaunchedEffect
}
if (!isActive) return@LaunchedEffect

View file

@ -43,6 +43,7 @@ import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@ -199,11 +200,32 @@ fun buildTvPlayer(context: Context, audioPassthrough: Boolean = false): ExoPlaye
}
/** Gedeelde HTTP-client voor HLS-segmenten (connectie-hergebruik). */
private const val VIAPLAY_BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
private fun isViaplayCdnHost(host: String): Boolean =
host.contains("viaplay", ignoreCase = true)
/** Viaplay-CDN geeft Don't go terug op app-UA's; zelfde headers als de server-play. */
private val viaplayBrowserHeaders = Interceptor { chain ->
val req = chain.request()
if (!isViaplayCdnHost(req.url.host)) return@Interceptor chain.proceed(req)
chain.proceed(
req.newBuilder()
.header("User-Agent", VIAPLAY_BROWSER_UA)
.header("Referer", "https://viaplay.com/")
.header("Origin", "https://viaplay.com")
.header("Accept", "*/*")
.build(),
)
}
private val liveOkHttp: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(viaplayBrowserHeaders)
.build()
}
@ -414,7 +436,7 @@ fun liveMediaItem(
.build(),
)
}
if (live && isDash) {
if (live && isDash && !url.contains("/start/") && !url.contains("/stop/")) {
builder.setLiveConfiguration(
MediaItem.LiveConfiguration.Builder()
.setTargetOffsetMs(3_000)

View file

@ -170,8 +170,11 @@ fun EventPlayerScreen(
return@LaunchedEffect
}
// ClearKey optioneel: F1TV plain HLS heeft drm=null.
// VOD/clips: geen live-config — anders seek naar “live edge” = eindscherm.
val asVod = hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true)
// Catch-up/VOD: geen live-config — anders seek naar live-edge.
val kapWindow = url.contains("/start/") && url.contains("/stop/")
val asVod = play.live == false ||
kapWindow ||
(hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true))
val item = try {
liveMediaItem(url, play.format, play.drm, live = !asVod)
} catch (e: Exception) {
@ -350,6 +353,7 @@ fun EventPlayerScreen(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
keepScreenOn = true
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,

View file

@ -1047,7 +1047,17 @@ final class ClearKeyDashProxy {
var req = URLRequest(url: url)
req.timeoutInterval = 15
req.cachePolicy = .reloadIgnoringLocalCacheData
let host = url.host?.lowercased() ?? ""
if host.contains("viaplay") {
req.setValue(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent"
)
req.setValue("https://viaplay.com/", forHTTPHeaderField: "Referer")
req.setValue("https://viaplay.com", forHTTPHeaderField: "Origin")
} else {
req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent")
}
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
let (data, resp) = try await urlSession.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {

View file

@ -58,6 +58,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var knownVodDurationMs: Int64 = 0
/// Playlist is geknipt vanaf dit punt (VLC-tijd 0 = deze offset).
private var seekOriginMs: Int64 = 0
/// Taal/naam van het gekozen audiospoor — na seek-reload terugzetten (VLC pakt anders weer Spaans).
private var restoreAudioHint: String = ""
override init() {
// Shared library = alle demux/access plugins (DASH adaptive in VLC 4).
@ -93,6 +95,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
drawableHost.attach(to: view)
return
}
// VOD: PiP-host zetten vóór VLC een gewone vout opent (anders komt pictureInPictureReady nooit).
if wantsPipDrawable, mediaPlayer.media == nil, view.bounds.width > 1 {
applyDrawable(force: true)
return
}
if mediaPlayer.drawable as AnyObject? === view {
return
}
@ -118,27 +125,31 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
drawableHost.attach(to: view)
return
}
if wantsPipDrawable, mediaPlayer.media == nil {
applyDrawable(force: true)
return
}
if mediaPlayer.drawable == nil {
applyDrawable(force: true)
}
}
private var wantsPipDrawable: Bool { !liveMode && !clearKeyProxyActive && !stopping }
/// Live/ClearKey → plain UIView. VOD → VLCPipDrawableHost (PiP vanaf start).
private func applyDrawable(force: Bool = false) {
guard !stopping else { return }
guard let view = plainVideoView else { return }
// Nooit binden op 0×0 — VLC opent dan vout zonder beeld (audio-only).
guard view.bounds.width > 1, view.bounds.height > 1 else { return }
let wantPipHost = !liveMode && !clearKeyProxyActive
let wantPipHost = wantsPipDrawable
usePipDrawable = wantPipHost
if wantPipHost {
drawableHost.attach(to: view)
if force || mediaPlayer.drawable as AnyObject? !== drawableHost {
mediaPlayer.drawable = drawableHost
}
if pipWindow != nil {
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
}
} else {
// ClearKey/live: plain UIView. Nooit detach/rebind als al gezet tijdens play.
if mediaPlayer.drawable as AnyObject? === view, !force { return }
@ -188,9 +199,10 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
else { startPictureInPicture() }
}
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) {
func play(url: URL, startMs: Int64 = 0, subtitleFileURL: URL? = nil) async {
// Annuleer pending finishStop van eerdere/spurious onDisappear.
stopGeneration &+= 1
let playGen = stopGeneration
stopping = false
hasError = false
errorMessage = nil
@ -199,12 +211,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
audioTracks = []
canSeek = false
didApplyPreferredAudio = false
restoreAudioHint = ""
knownVodDurationMs = 0
seekOriginMs = 0
liveMode = false
clearKeyProxyActive = false
await waitForSurface(timeout: 2.0)
guard stopGeneration == playGen, !stopping else { return }
await MainActor.run {
plainVideoView?.layoutIfNeeded()
applyDrawable(force: true)
}
guard stopGeneration == playGen, !stopping else { return }
pipPossible = AVPictureInPictureController.isPictureInPictureSupported()
guard let media = VLCMedia(url: url) else {
hasError = true
@ -221,6 +239,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
media.addOption(":freetype-bold=1")
media.addOption(":sub-margin=0")
if mediaPlayer.drawable as AnyObject? !== drawableHost {
applyDrawable(force: true)
}
mediaPlayer.media = media
_ = VLCMediaParser.shared().queue(
media,
@ -267,6 +288,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
liveStartedAt = Date()
canSeek = false
didApplyPreferredAudio = false
restoreAudioHint = ""
knownVodDurationMs = 0
seekOriginMs = 0
liveMode = true
@ -436,8 +458,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
media.addOption(":http-reconnect")
media.addOption(":avcodec-hw=any")
media.addOption(":freetype-rel-fontsize=\(subtitleRelFontSize)")
// IPTV/CDN's weigeren soms exotic UA's.
let viaplay = url.host?.localizedCaseInsensitiveContains("viaplay") == true
if viaplay && !viaLocalProxy {
media.addOption(":http-user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36")
media.addOption(":http-referrer=https://viaplay.com/")
} else {
media.addOption(":http-user-agent=VLC/3.0.21 LibVLC/3.0.21")
}
// VLC 4: unified adaptive demux (DASH+HLS). Forceer GEEN :demux=dash (VLC3-module).
media.addOption(":adaptive-logic=highest")
if !viaLocalProxy {
@ -472,6 +499,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if let url = comps?.url {
position = knownVodDurationMs > 0 ? Float(Double(clamped) / Double(knownVodDurationMs)) : 0
timeText = Self.format(ms: Int32(min(clamped, Int64(Int32.max))))
captureAudioRestoreHint()
didApplyPreferredAudio = false
startLiveMedia(url: url, format: livePlayFormat)
invalidatePipPlaybackState()
return
@ -516,6 +545,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if let track = mediaPlayer.audioTracks.first(where: { $0.trackId == id }) {
track.isSelectedExclusively = true
currentAudioId = id
restoreAudioHint = Self.audioHint(language: track.language, name: track.trackName, description: track.trackDescription)
}
invalidatePipPlaybackState()
}
@ -592,10 +622,36 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
applyPreferredAudioIfNeeded()
}
private func captureAudioRestoreHint() {
if let track = mediaPlayer.audioTracks.first(where: { $0.isSelected || $0.trackId == currentAudioId }) {
restoreAudioHint = Self.audioHint(language: track.language, name: track.trackName, description: track.trackDescription)
return
}
if restoreAudioHint.isEmpty, let name = audioTracks.first(where: { $0.id == currentAudioId })?.name {
restoreAudioHint = name.lowercased()
}
}
private func applyPreferredAudioIfNeeded() {
guard !didApplyPreferredAudio else { return }
let list = mediaPlayer.audioTracks
guard !list.isEmpty else { return }
if !restoreAudioHint.isEmpty {
let hint = restoreAudioHint
if let match = list.max(by: {
Self.audioHintScore(language: $0.language, name: $0.trackName, description: $0.trackDescription, hint: hint)
< Self.audioHintScore(language: $1.language, name: $1.trackName, description: $1.trackDescription, hint: hint)
}),
Self.audioHintScore(language: match.language, name: match.trackName, description: match.trackDescription, hint: hint) > 0
{
if !match.isSelected {
match.isSelectedExclusively = true
}
currentAudioId = match.trackId
didApplyPreferredAudio = true
return
}
}
guard !didApplyPreferredAudio else { return }
didApplyPreferredAudio = true
let ranked = list.enumerated().map { i, t in
(track: t, score: Self.audioPreferenceScore(language: t.language, name: t.trackName, description: t.trackDescription, index: i))
@ -605,6 +661,26 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
best.track.isSelectedExclusively = true
}
currentAudioId = best.track.trackId
restoreAudioHint = Self.audioHint(language: best.track.language, name: best.track.trackName, description: best.track.trackDescription)
}
private static func audioHint(language: String?, name: String?, description: String?) -> String {
[language, name, description]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
.joined(separator: " ")
}
private static func audioHintScore(language: String?, name: String?, description: String?, hint: String) -> Int {
let blob = audioHint(language: language, name: name, description: description)
if blob.isEmpty || hint.isEmpty { return 0 }
if blob == hint { return 100 }
let lang = (language ?? "").lowercased()
if !lang.isEmpty, hint.contains(lang) { return 80 }
let trackName = (name ?? "").lowercased()
if !trackName.isEmpty, hint.contains(trackName) { return 70 }
let tokens = hint.split(whereSeparator: { !$0.isLetter }).map(String.init).filter { $0.count >= 2 }
return tokens.contains(where: { blob.contains($0) }) ? 40 : 0
}
/// NL eerst, anders Engels. Geen match → 0 (niet forceren).

View file

@ -20,6 +20,13 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var lengthMs: Int32 = 0
private var tick: AnyCancellable?
private static func applyCdnHeaders(to media: VLCMedia, url: URL) {
if url.host?.localizedCaseInsensitiveContains("viaplay") == true {
media.addOption(":http-user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36")
media.addOption(":http-referrer=https://viaplay.com/")
}
}
override init() {
super.init()
mediaPlayer.delegate = self
@ -44,6 +51,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
media.addOption(":sub-track=0")
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
media.addOption(":freetype-bold=1")
Self.applyCdnHeaders(to: media, url: url)
mediaPlayer.media = media
media.parse()
mediaPlayer.play()
@ -75,6 +83,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
media.addOption(":http-reconnect")
media.addOption(":live-caching=1000")
media.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
Self.applyCdnHeaders(to: media, url: url)
mediaPlayer.media = media
media.parse()
mediaPlayer.play()
@ -87,6 +96,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
fb.addOption(":network-caching=1200")
fb.addOption(":http-reconnect")
fb.addOption(":freetype-rel-fontsize=\(Self.subtitleRelFontSize)")
Self.applyCdnHeaders(to: fb, url: fallback)
self.mediaPlayer.media = fb
fb.parse()
self.mediaPlayer.play()

View file

@ -13,6 +13,7 @@
var lastAvSubText = "";
var playSession = 0;
var fallbackOnce = false;
var pendingStartMs = 0;
function $(id) {
return document.getElementById(id);
@ -228,9 +229,20 @@
setSubOverlay("");
}
function applyPendingStart() {
var ms = pendingStartMs;
if (!ms || ms < 5000) return;
pendingStartMs = 0;
try {
if (usingAvplay) webapis.avplay.seekTo(ms);
else if (htmlVideo) htmlVideo.currentTime = ms / 1000;
} catch (e) {}
}
function stop() {
playSession += 1;
fallbackOnce = false;
pendingStartMs = 0;
stopTick();
setError(null);
clearExternal();
@ -275,6 +287,7 @@
if (session !== playSession) return;
setError(null);
setPlayingIcon(true);
applyPendingStart();
};
htmlVideo.onended = function () {
if (session !== playSession) return;
@ -368,10 +381,12 @@
webapis.avplay.setDisplayRect(0, 0, 1920, 1080);
try {
webapis.avplay.setStreamingProperty(
"USER_AGENT",
"Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36"
);
var ua = "Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0) AppleWebKit/537.36";
if (/viaplay/i.test(url)) {
ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
}
webapis.avplay.setStreamingProperty("USER_AGENT", ua);
} catch (e2) {}
webapis.avplay.setListener({
@ -417,6 +432,7 @@
return;
}
webapis.avplay.play();
applyPendingStart();
startTick();
setError(null);
setPlayingIcon(true);
@ -437,10 +453,11 @@
}, 80);
}
function playUrl(url, title) {
function playUrl(url, title, startMs) {
stop();
playSession += 1;
fallbackOnce = false;
pendingStartMs = startMs > 5000 ? startMs : 0;
currentUrl = url;
currentTitle = title || "";
$("player-title").textContent = currentTitle;