11#!/usr/bin/env python3
2- """Augment a generated NuvioDesktop corpus test with the official native player.
2+ """Augment a generated NuvioDesktop corpus test with the production player surface .
33
4- This runs only on macOS/Windows. Linux NuvioDesktop intentionally uses a stub player
5- and is therefore never accepted as native-reader proof.
4+ Human-UX invariant: provider output is handed to NuvioDesktop's real
5+ PlatformPlayerSurface. The lab does not construct NativePlayerController directly,
6+ does not sanitize/rewrite the stream itself and does not choose decoder/network
7+ settings. Whatever Nuvio's production player does with the source is the evidence.
68"""
79from __future__ import annotations
810
1113import re
1214from pathlib import Path
1315
14-
1516IMPORT_ANCHOR = "import com.nuvio.app.features.plugins.runtime.PluginRuntime\n "
1617TEST_ANCHOR = " @Test\n "
17-
18- IMPORTS = """import com.nuvio.app.features.player.desktop.DesktopHostOs
19- import com.nuvio.app.features.player.desktop.NativePlayerController
20- import com.nuvio.app.features.player.desktop.NativePlayerHost
18+ DEFAULT_PR_STREAM_LIMIT = 2
19+
20+ IMPORTS = """import androidx.compose.foundation.layout.fillMaxSize
21+ import androidx.compose.ui.Modifier
22+ import androidx.compose.ui.awt.ComposePanel
23+ import com.nuvio.app.features.player.PlatformPlayerSurface
24+ import com.nuvio.app.features.player.PlayerControlsState
25+ import com.nuvio.app.features.player.PlayerPlaybackSnapshot
26+ import com.nuvio.app.features.player.PlayerResizeMode
2127import java.awt.BorderLayout
2228import java.awt.Rectangle
2329import java.awt.Robot
2430import java.awt.Toolkit
31+ import java.util.concurrent.CountDownLatch
32+ import java.util.concurrent.TimeUnit
2533import java.util.concurrent.atomic.AtomicReference
2634import javax.imageio.ImageIO
2735import javax.swing.JFrame
5462 }
5563 }
5664
57- private fun probeDesktopNativePlayer (
65+ private fun probeDesktopProductionPlayer (
5866 url: String,
5967 headers: Map<String, String>?,
68+ streamType: String?,
6069 expectedDurationMinutes: Int,
6170 ): DesktopNativePlayerProbe {
62- if (DesktopHostOs.current != DesktopHostOs.MACOS && DesktopHostOs.current != DesktopHostOs.WINDOWS) {
63- return DesktopNativePlayerProbe("unsupported", "DesktopHostOs", "LINUX_STUB", 0, "player_setup", null, null)
64- }
6571 val frameRef = AtomicReference<JFrame?>(null)
66- val controllerRef = AtomicReference<NativePlayerController ?>(null)
72+ val latestSnapshot = AtomicReference<PlayerPlaybackSnapshot ?>(null)
6773 val errorRef = AtomicReference<String?>(null)
74+ val terminal = CountDownLatch(1)
6875 try {
6976 SwingUtilities.invokeAndWait {
7077 val frame = JFrame("Nuvio Desktop native reader lab")
71- val host = NativePlayerHost ()
78+ val panel = ComposePanel ()
7279 frame.layout = BorderLayout()
73- frame.add(host , BorderLayout.CENTER)
80+ frame.add(panel , BorderLayout.CENTER)
7481 frame.setSize(960, 540)
7582 frame.setLocationRelativeTo(null)
76- frame.isVisible = true
77- val controller = NativePlayerController(host)
83+ frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE
7884 frameRef.set(frame)
79- controllerRef.set(controller)
80- }
81- val controller = controllerRef.get()
82- ?: return DesktopNativePlayerProbe("error", "NativePlayerController", "NO_CONTROLLER", 0, "player_setup", null, null)
83- controller.attach(
84- // Observational-purity contract: the lab cannot make a source easier
85- // to play than Nuvio would receive it. URL and provider headers are
86- // passed byte-for-byte/value-for-value to the official controller.
87- sourceUrl = url,
88- sourceHeaders = headers.orEmpty(),
89- playWhenReady = true,
90- initialPositionMs = 0L,
91- decoderPriority = 1,
92- nvidiaRtxSuperResolutionEnabled = false,
93- onError = { message -> errorRef.compareAndSet(null, message ?: "native_player_error") },
94- )
95- val deadline = System.currentTimeMillis() + __READER_TIMEOUT_MS__L
96- var lastDuration: Long = 0L
97- var lastPosition: Long = 0L
98- while (System.currentTimeMillis() < deadline) {
99- errorRef.get()?.let { message ->
100- return DesktopNativePlayerProbe(
101- "error", "NativePlayer", message.replace(Regex("\\s+"), "_").take(120),
102- 0, "player", null, null,
85+ panel.setContent {
86+ // Production entry point. Provider output enters Nuvio unchanged;
87+ // Nuvio's own production surface decides sanitation, settings,
88+ // native bridge and playback behavior exactly as the app does.
89+ PlatformPlayerSurface(
90+ sourceUrl = url,
91+ sourceHeaders = headers.orEmpty(),
92+ sourceResponseHeaders = emptyMap(),
93+ externalSubtitles = emptyList(),
94+ streamType = streamType,
95+ useYoutubeChunkedPlayback = false,
96+ modifier = Modifier.fillMaxSize(),
97+ playWhenReady = true,
98+ initialPositionMs = 0L,
99+ initialPositionRequestKey = "niakvio-native-reader",
100+ resizeMode = PlayerResizeMode.Fit,
101+ useNativeController = true,
102+ playerControlsState = PlayerControlsState(),
103+ onControllerReady = { _ -> },
104+ onSnapshot = { snapshot ->
105+ latestSnapshot.set(snapshot)
106+ if (snapshot.isEnded || (!snapshot.isLoading && (snapshot.isPlaying || snapshot.positionMs > 0L || snapshot.durationMs > 0L))) {
107+ terminal.countDown()
108+ }
109+ },
110+ onError = { message ->
111+ if (!message.isNullOrBlank()) {
112+ errorRef.compareAndSet(null, message)
113+ terminal.countDown()
114+ }
115+ },
103116 )
104117 }
105- val snapshot = controller.snapshot()
106- lastDuration = snapshot.durationMs.coerceAtLeast(0L)
107- lastPosition = snapshot.positionMs.coerceAtLeast(0L)
108- val durationSeconds = lastDuration.takeIf { it > 0 }?.div(1000.0)
109- val expected = expectedDurationMinutes.takeIf { it > 0 }?.times(60.0)
110- val shortMedia = durationSeconds != null && (
111- durationSeconds < 60.0 || (expected != null && durationSeconds / expected < 0.55)
118+ frame.isVisible = true
119+ }
120+
121+ terminal.await(__READER_TIMEOUT_MS__L, TimeUnit.MILLISECONDS)
122+ val error = errorRef.get()
123+ val snapshot = latestSnapshot.get()
124+ if (!error.isNullOrBlank()) {
125+ return DesktopNativePlayerProbe(
126+ "error", "NuvioDesktopProductionPlayer",
127+ error.replace(Regex("\\s+"), "_").take(160),
128+ 0, "player", null, null,
112129 )
113- if (shortMedia) {
114- return DesktopNativePlayerProbe("short_media", "", "", 0, "duration_identity", durationSeconds, lastPosition / 1000.0)
115- }
116- if (snapshot.isEnded) {
117- return DesktopNativePlayerProbe("ended", "", "", 0, "none", durationSeconds, lastPosition / 1000.0)
118- }
119- if (!snapshot.isLoading && (snapshot.isPlaying || lastPosition > 0L || lastDuration > 0L)) {
120- return DesktopNativePlayerProbe("ready", "", "", 0, "none", durationSeconds, lastPosition / 1000.0)
121- }
122- Thread.sleep(500L)
123130 }
124- return DesktopNativePlayerProbe(
125- "timeout", "NativePlayer", "READER_TIMEOUT", 0, "timeout",
126- lastDuration.takeIf { it > 0 }?.div(1000.0),
127- lastPosition.takeIf { it > 0 }?.div(1000.0),
131+ val durationSeconds = snapshot?.durationMs?.takeIf { it > 0L }?.div(1000.0)
132+ val positionSeconds = snapshot?.positionMs?.takeIf { it >= 0L }?.div(1000.0)
133+ val expected = expectedDurationMinutes.takeIf { it > 0 }?.times(60.0)
134+ val shortMedia = durationSeconds != null && (
135+ durationSeconds < 60.0 || (expected != null && durationSeconds / expected < 0.55)
128136 )
137+ if (shortMedia) {
138+ return DesktopNativePlayerProbe("short_media", "", "", 0, "duration_identity", durationSeconds, positionSeconds)
139+ }
140+ if (snapshot?.isEnded == true) {
141+ return DesktopNativePlayerProbe("ended", "", "", 0, "none", durationSeconds, positionSeconds)
142+ }
143+ if (snapshot != null && !snapshot.isLoading && (snapshot.isPlaying || snapshot.positionMs > 0L || snapshot.durationMs > 0L)) {
144+ return DesktopNativePlayerProbe("ready", "", "", 0, "none", durationSeconds, positionSeconds)
145+ }
146+ return DesktopNativePlayerProbe("timeout", "NuvioDesktopProductionPlayer", "READER_TIMEOUT", 0, "timeout", durationSeconds, positionSeconds)
129147 } catch (error: Throwable) {
130148 return DesktopNativePlayerProbe(
131- "error", error::class.qualifiedName.orEmpty(), "PLAYER_SETUP", 0, "player_setup", null, null,
149+ "error", error::class.qualifiedName.orEmpty(),
150+ (error.message ?: "PLAYER_SETUP").replace(Regex("\\s+"), "_").take(160),
151+ 0, "player_setup", null, null,
132152 )
133153 } finally {
134- runCatching { controllerRef.getAndSet(null)?.dispose() }
135154 runCatching { SwingUtilities.invokeAndWait { frameRef.getAndSet(null)?.dispose() } }
136155 }
137156 }
@@ -147,20 +166,21 @@ def replace_once(text: str, old: str, new: str, label: str) -> str:
147166
148167def augment (path : Path , expected_minutes : int , stream_scope : str ) -> None :
149168 text = path .read_text (encoding = "utf-8" )
150- if "DesktopNativePlayerProbe " in text :
169+ if "probeDesktopProductionPlayer " in text :
151170 return
152171 pr_bounded = os .environ .get ("GITHUB_EVENT_NAME" , "" ).strip ().lower () == "pull_request"
153172 if pr_bounded and stream_scope == "all" :
154- # Match Android's bounded PR proof: two returned rows are enough to expose
155- # the common "first source works, second source breaks" class without
156- # turning every PR into the trusted exhaustive run.
157- stream_scope = "2"
173+ configured = os .environ .get ("NIAKVIO_PR_STREAM_LIMIT" , str (DEFAULT_PR_STREAM_LIMIT )).strip ()
174+ try :
175+ stream_scope = str (max (1 , min (int (configured ), 4 )))
176+ except ValueError :
177+ stream_scope = str (DEFAULT_PR_STREAM_LIMIT )
158178 reader_timeout_ms = 12_000 if pr_bounded else 25_000
179+
159180 text = replace_once (text , IMPORT_ANCHOR , IMPORT_ANCHOR + IMPORTS , "imports" )
160181 helpers = HELPERS .replace ("__READER_TIMEOUT_MS__" , str (reader_timeout_ms ))
161182 text = replace_once (text , TEST_ANCHOR , helpers + "\n " + TEST_ANCHOR , "test" )
162183
163- # Make row evidence match the actual reader scope.
164184 if stream_scope == "all" :
165185 text = re .sub (r"rows\.take\(\d+\)\.forEachIndexed" , "rows.forEachIndexed" , text )
166186 reader_iter = "rows.forEachIndexed"
@@ -175,11 +195,13 @@ def augment(path: Path, expected_minutes: int, stream_scope: str) -> None:
175195 r''' \}\n'''
176196 )
177197 replacement = f''' { reader_iter } {{ index, row ->
178- emit("FIELD_NATIVE_PLAYER_BEGIN client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index")
198+ emit("FIELD_NATIVE_PLAYER_BEGIN client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index entry=PlatformPlayerSurface ")
179199 captureDesktopPhase("player-start", fixtureSlug)
180- val reader = probeDesktopNativePlayer (row.url, row.headers, { expected_minutes } )
181- emit("FIELD_NATIVE_PLAYER client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index state=${{reader.state}} engine=native -desktop http_status=${{reader.httpStatus}} failure_stage=${{reader.failureStage}} duration_seconds=${{reader.durationSeconds ?: 0.0}} host64=${{b64(hostOnly(row.url))}} error_class64=${{b64(reader.errorClass)}} error_code64=${{b64(reader.errorCode)}} exception_chain64=${{b64("")}} response_header_names64=${{b64("")}} load_bytes=0 load_duration_ms=0 media_data_type=-1 track_type=-1")
200+ val reader = probeDesktopProductionPlayer (row.url, row.headers, row.type , { expected_minutes } )
201+ emit("FIELD_NATIVE_PLAYER client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index state=${{reader.state}} engine=nuvio-production -desktop http_status=${{reader.httpStatus}} failure_stage=${{reader.failureStage}} duration_seconds=${{reader.durationSeconds ?: 0.0}} host64=${{b64(hostOnly(row.url))}} error_class64=${{b64(reader.errorClass)}} error_code64=${{b64(reader.errorCode)}} exception_chain64=${{b64("")}} response_header_names64=${{b64("")}} load_bytes=0 load_duration_ms=0 media_data_type=-1 track_type=-1")
182202 captureDesktopPhase("player-result", fixtureSlug)
203+ // Independent transport diagnostics run only after the production
204+ // player has reached a terminal observation for this source.
183205 val transport = probeTransport(row.url, row.headers)
184206 emit("FIELD_NATIVE_TRANSPORT client=desktop fixture=$fixtureSlug provider64=${{b64(provider.id)}} request_type=$requestMediaType route_mode=$routeMode index=$index state=${{transport.state}} kind=${{transport.kind}} status=${{transport.status}} content_type64=${{b64(transport.contentType)}} extm3u=${{transport.extm3u}} duration_seconds=${{transport.durationSeconds ?: 0.0}} host64=${{b64(transport.host)}} media_hint64=${{b64(transport.mediaHint)}}")
185207 }}
@@ -199,7 +221,8 @@ def augment(path: Path, expected_minutes: int, stream_scope: str) -> None:
199221 path .write_text (text , encoding = "utf-8" )
200222 print (
201223 f"FIELD_NATIVE_DESKTOP_READER_AUGMENTED source={ path } streams={ stream_scope } "
202- f"expected_minutes={ expected_minutes } timeout_ms={ reader_timeout_ms } ci_mode={ 'pr-bounded' if pr_bounded else 'deep' } "
224+ f"expected_minutes={ expected_minutes } timeout_ms={ reader_timeout_ms } entry=PlatformPlayerSurface "
225+ f"ci_mode={ 'pr-bounded' if pr_bounded else 'deep' } "
203226 )
204227
205228
0 commit comments