Bump Livepeer version and enhance streaming functionality#2
Conversation
- Updated version from 1.0.3 to 1.0.4 in pyproject.toml. - Changed livepeer-gateway dependency to a newer commit in requirements files. - Introduced new uv.lock file for dependency management. - Refactored orchestrator resolution and authentication handling in the codebase.
…hentication improvements - Added TrickleStartAndPreview node to start a Livepeer trickle stream and provide immediate access to stream URLs and the latest frame. - Introduced TrickleBrowserPlayer for opening a browser viewer that plays trickle output continuously. - Updated stream URL handling with improved proxy management in the TrickleStreamURL node. - Enhanced authentication flow with new routes for login/logout and status checks, improving user experience during Livepeer login. - Refactored existing nodes to integrate new features and ensure consistent categorization under "Livepeer/Trickle".
📝 WalkthroughWalkthroughThis pull request introduces OIDC device-code authentication, updates the networking infrastructure to use a new gateway library (lv2v instead of orchestrator), adds frontend login UI and viewer controls, implements new composite streaming nodes with preview capabilities, and refactors frame handling with improved warmup logic. Node categories are updated from "Trickle" to "Livepeer/Trickle" throughout, and the gateway dependency is switched to a new repository. Changes
Sequence DiagramssequenceDiagram
participant Client as Client/Browser
participant AuthExt as Auth Extension<br/>(JavaScript)
participant AuthAPI as Auth Routes<br/>(/livepeer/auth/*)
participant OIDCWorker as OIDC Worker<br/>(Background)
participant OIDCProvider as OIDC Provider
Client->>AuthExt: Initialize (GET /status)
AuthExt->>AuthAPI: GET /livepeer/auth/status
AuthAPI-->>AuthExt: {authenticated: false}
Client->>AuthExt: Click Login Button
AuthExt->>AuthAPI: POST /livepeer/auth/login
AuthAPI->>OIDCWorker: Start device auth flow
OIDCWorker->>OIDCProvider: Request device code
OIDCProvider-->>OIDCWorker: {device_code, user_code, auth_url}
OIDCWorker-->>AuthAPI: Auth URL available
AuthAPI-->>AuthExt: {pending: true, auth_url: "..."}
AuthExt->>Client: Open browser (window.open auth_url)
loop Poll every 1 second (180x)
AuthExt->>AuthAPI: GET /livepeer/auth/status
AuthAPI-->>AuthExt: {login_in_progress: true}
end
Client->>OIDCProvider: User approves
OIDCProvider->>OIDCWorker: Device auth complete
OIDCWorker->>OIDCWorker: Save cached token
AuthExt->>AuthAPI: GET /livepeer/auth/status
AuthAPI-->>AuthExt: {authenticated: true}
AuthExt->>Client: Update UI (show logout)
sequenceDiagram
participant User as User/Node
participant StartPreview as TrickleStartAndPreview<br/>Node
participant NetController as NetworkController
participant LV2V as LiveVideoToVideo<br/>(lv2v library)
participant FrameOutput as TrickleFrameOutput<br/>Subscriber
participant Proxy as HttpStreamProxy
User->>StartPreview: start_and_preview(config)
StartPreview->>NetController: start() with config
NetController->>LV2V: start_lv2v(orchestrator_url)
LV2V-->>NetController: job, stream URLs
NetController->>NetController: Emit PromptServer event
StartPreview->>Proxy: get_stream_proxy() → set subscribe_url
Proxy->>Proxy: Return MPEG-TS stream_url
StartPreview->>FrameOutput: Warmup loop (poll INITIAL_FRAME_WAIT_SECONDS)
loop Poll every INITIAL_FRAME_POLL_SECONDS
FrameOutput->>FrameOutput: Pull frame from subscriber
end
FrameOutput-->>StartPreview: First frame received
StartPreview-->>User: {publish_url, subscribe_url, stream_url, viewer_url, image}
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@nodes/frame_nodes.py`:
- Around line 476-484: The code currently calls
_get_controller(controller_config) before checking cached_auth_status(), which
can leave an IDLE controller stored in _NETWORK_RUNTIME.controller and cause
downstream nodes to skip the last_startup_error path; move the
cached_auth_status() auth gate so it runs before calling _get_controller(), or
if you prefer minimal changes clear _NETWORK_RUNTIME.controller (set to None)
immediately before returning the auth error; update both locations (the block
using controller_config/_get_controller at lines around the shown snippet and
the similar block at 539-553) so the auth check precedes controller creation or
ensure the controller is cleared on auth failure.
In `@nodes/js/livepeer_settings.js`:
- Around line 26-37: fetchAuthStatus currently treats any unauthenticated
response as idle; update it to read and apply the device-auth fields from the
response (e.g. data.login_in_progress, data.auth_url, data.user_code,
data.login_error) and set the existing loginInProgress state accordingly instead
of forcing a "Login" idle state—also update the button and status text to the
pending verification UI when data.login_in_progress is true and resume the
device-code polling/timeout flow (the same polling routine used when initiating
login) so a remounted settings panel restores the pending auth URL/code and
continues polling rather than resetting to Login.
In `@nodes/js/stream_url_display.js`:
- Around line 76-98: The long-running poll in startLivepeerLoginFlow must be
cancellable so closing or retrying the modal stops the previous loop: introduce
a shared AbortController (or a boolean flag) referenced by
startLivepeerLoginFlow and store it on a module-scoped variable (e.g.,
currentLoginAbortController); before starting a new login flow abort any
existing controller; pass the controller.signal to fetchApi or check the flag
inside the loop and break immediately if aborted; update closeModal() and the
retry handler to call currentLoginAbortController.abort() (or set the flag) so
the loop stops and does not continue to poll /livepeer/auth/status or emit stale
updates. Ensure startLivepeerLoginFlow cleans up the controller when finished or
on error.
In `@nodes/stream/auth_routes.py`:
- Around line 126-160: The code ignores the boolean result of
_AUTH_URL_READY.wait which lets slow device-code initialization fall through to
returning a pending payload with empty auth_url; fix by capturing the return
value (e.g., ready = await asyncio.to_thread(_AUTH_URL_READY.wait, 5.0)) and if
ready is False return a distinct timeout/in-progress response (e.g., ok: True,
authenticated: False, pending: True, timed_out: True or status 202) before
reading _AUTH_STATE inside the _AUTH_LOCK; keep using _AUTH_LOCK,
_AUTH_STATE["auth_url"], _AUTH_STATE["user_code"], _AUTH_STATE["expires_in"],
and cached_auth_status() as currently referenced.
In `@nodes/stream/credentials.py`:
- Around line 42-55: The code currently calls load_cached_token and
token.is_expired directly which can raise on corrupt cache or partial token
objects; wrap the load_cached_token(...) call and subsequent token attribute
access (including token.is_expired()) in a try/except that catches broad
exceptions and returns a recoverable status tuple (e.g. False with a clear
message like "Saved Livepeer login session is invalid or unreadable: {exc}" or
"Please log in again") instead of letting exceptions bubble; apply the same
defensive try/except pattern to the other auth-check block referenced (the code
around the token handling at lines ~72-96) so both places return the (bool, str)
contract on any cache-read or token-access failure.
In `@nodes/stream/http_stream_proxy.py`:
- Around line 120-127: The MediaOutput instance is not being closed on all exit
paths causing lingering subscriptions/sessions; wrap the creation and iteration
of MediaOutput in an async context manager (use "async with MediaOutput(... ) as
media_output:") and move the loop that consumes media_output.bytes() into that
block (mirroring the pattern in network_subscriber.py) so that both normal
completion and the two break paths (session change and idle timeout) always run
MediaOutput.__aexit__ to cleanup the trickle subscription and HTTP session;
update both occurrences around the current media_output creation and the similar
block at lines 135-168 to use the async with pattern.
In `@nodes/stream/network_controller.py`:
- Around line 202-207: The current LOGGER.info call in network_controller.py
leaks the OIDC device user_code; remove the secret from normal logs by changing
the log to exclude or redact user_code (e.g., log only auth_url and expires_in
or replace user_code with a fixed placeholder like "[REDACTED]") in the
LOGGER.info invocation where auth_url, user_code, expires_in are used; ensure
the UI event still provides the code to the user and do not add the raw
user_code elsewhere in application logs (refer to the LOGGER.info call and the
variables user_code, auth_url, expires_in).
In `@nodes/stream/viewer_routes.py`:
- Around line 149-158: scheduleReconnect currently queues multiple setTimeout
restarts and startPlayer repeatedly re-attaches video event listeners causing
duplicate handlers; fix by introducing a single reconnectTimeoutId and
clearTimeout(reconnectTimeoutId) before scheduling to debounce reconnects,
define and bind the video handlers (e.g., onPause, onClick, onEnded) once at
module scope or on first init and reuse them (or removeEventListener before
add), ensure startPlayer checks/sets a flag like playerInitialized to avoid
re-binding, reset retries on a successful start, and use the existing
allowReconnect/retries logic with the new debounce so overlapping reconnect
attempts are prevented.
- Line 123: Add Subresource Integrity and CORS to the mpegts.js CDN import:
update the script tag (the one loading
"https://cdn.jsdelivr.net/npm/mpegts.js@1.8.0/dist/mpegts.min.js") to include an
integrity="sha384-..." attribute with the correct SHA-384 hash and add
crossorigin="anonymous". Also prevent event-listener accumulation on reconnects
by removing previously attached handlers in cleanupPlayer(): ensure
cleanupPlayer() calls video.removeEventListener('pause', onPauseHandler),
video.removeEventListener('click', onClickHandler), and
video.removeEventListener('ended', onEndedHandler) (use the same handler
references that startPlayer() registers) before destroying the player or
alternatively recreate the video element in startPlayer()/cleanupPlayer() to
avoid reusing the element with already-bound listeners.
In `@nodes/trickle_start_preview_node.py`:
- Around line 93-105: The node currently returns the wrong-shaped result on
error and unpacks the success tuple in the wrong order: ensure the result tuple
has 5 outputs (manifest_id, publish_url, subscribe_url, error, image) and unpack
status_values as (manifest_id, publish_url, subscribe_url, error_msg).
Concretely, in the StartTrickleStream handling around variables status and
status_values, change the error return to include five elements (e.g. ("", "",
"", "", self._blank_image())) and change the unpacking line to: manifest_id,
publish_url, subscribe_url, error_msg = status_values; then use those values
when constructing the returned "result" tuple so manifest_id is the first
element and the image is appended as the fifth.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2d9ce740-7e6d-4b6a-a5d6-eb3451751708
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (14)
nodes/__init__.pynodes/frame_nodes.pynodes/js/livepeer_settings.jsnodes/js/stream_url_display.jsnodes/stream/auth_routes.pynodes/stream/credentials.pynodes/stream/http_stream_proxy.pynodes/stream/network_controller.pynodes/stream/network_subscriber.pynodes/stream/viewer_routes.pynodes/stream_url_node.pynodes/trickle_start_preview_node.pypyproject.tomlrequirements.txt
| controller_config = NetworkControllerConfig( | ||
| orchestrator_url=resolved_orch, | ||
| signer_url=resolved_signer or None, | ||
| model_id=model_id, | ||
| fps=float(fps), | ||
| frame_width=width, | ||
| frame_height=height, | ||
| keyframe_interval_s=float(keyframe_interval), | ||
|
|
||
| orchestrator_url=resolve_orchestrator(orchestrator_url), | ||
| ) | ||
| controller = _get_controller(controller_config) |
There was a problem hiding this comment.
Run the auth check before creating/stashing a controller.
_get_controller() is called before cached_auth_status(), so an unauthenticated start leaves an IDLE controller in _NETWORK_RUNTIME.controller. Downstream nodes then stop hitting the last_startup_error path and just skip frames as if a controller exists. Move the auth gate above _get_controller() or clear _NETWORK_RUNTIME.controller before returning the auth error.
Also applies to: 539-553
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/frame_nodes.py` around lines 476 - 484, The code currently calls
_get_controller(controller_config) before checking cached_auth_status(), which
can leave an IDLE controller stored in _NETWORK_RUNTIME.controller and cause
downstream nodes to skip the last_startup_error path; move the
cached_auth_status() auth gate so it runs before calling _get_controller(), or
if you prefer minimal changes clear _NETWORK_RUNTIME.controller (set to None)
immediately before returning the auth error; update both locations (the block
using controller_config/_get_controller at lines around the shown snippet and
the similar block at 539-553) so the auth check precedes controller creation or
ensure the controller is cleared on auth failure.
| const fetchAuthStatus = async () => { | ||
| const response = await api.fetchApi("/livepeer/auth/status", { method: "GET" }); | ||
| const data = await response.json().catch(() => ({})); | ||
| if (!response.ok || !data.ok) { | ||
| throw new Error(data.error || `HTTP ${response.status}`); | ||
| } | ||
| isAuthenticated = Boolean(data.authenticated); | ||
| button.textContent = isAuthenticated ? "Logout" : "Login"; | ||
| status.textContent = isAuthenticated | ||
| ? "Logged in. Click Logout to clear this device session." | ||
| : "Not logged in. Click Login for device code auth."; | ||
| }; |
There was a problem hiding this comment.
Resume pending device-code auth from the status payload.
This branch treats every unauthenticated state as idle, but GET /livepeer/auth/status also returns login_in_progress, auth_url, user_code, and login_error. If the settings panel remounts during an active device flow, the user loses the verification URL/code and the button becomes clickable again. Please hydrate loginInProgress from the response here and restore the pending UI/polling path instead of resetting straight to “Login”.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/js/livepeer_settings.js` around lines 26 - 37, fetchAuthStatus
currently treats any unauthenticated response as idle; update it to read and
apply the device-auth fields from the response (e.g. data.login_in_progress,
data.auth_url, data.user_code, data.login_error) and set the existing
loginInProgress state accordingly instead of forcing a "Login" idle state—also
update the button and status text to the pending verification UI when
data.login_in_progress is true and resume the device-code polling/timeout flow
(the same polling routine used when initiating login) so a remounted settings
panel restores the pending auth URL/code and continues polling rather than
resetting to Login.
| for (let attempt = 0; attempt < 180; attempt += 1) { | ||
| await new Promise((resolve) => setTimeout(resolve, 1000)); | ||
| const statusResponse = await api.fetchApi("/livepeer/auth/status", { method: "GET" }); | ||
| const statusData = await statusResponse.json().catch(() => ({})); | ||
| if (!statusResponse.ok || !statusData.ok) { | ||
| throw new Error(statusData.error || `HTTP ${statusResponse.status}`); | ||
| } | ||
| if (statusData.authenticated) { | ||
| updateStatus("Login successful. Re-run StartTrickleStream.", true); | ||
| return; | ||
| } | ||
| if (statusData.login_error) { | ||
| throw new Error(statusData.login_error); | ||
| } | ||
| if (!statusData.login_in_progress) { | ||
| throw new Error("Login did not complete. Please try again."); | ||
| } | ||
| if (statusData.auth_url) { | ||
| const liveCodeText = statusData.user_code ? ` Code: ${statusData.user_code}.` : ""; | ||
| updateStatus(`Complete login in browser.${liveCodeText} Waiting for confirmation...`); | ||
| } | ||
| } | ||
| throw new Error("Login timed out. Please try again."); |
There was a problem hiding this comment.
Cancel the auth-status poll when the modal closes or retries.
startLivepeerLoginFlow() keeps polling for up to 180 seconds, but closeModal() only removes the overlay. If the user closes/reopens the modal or clicks retry, the old poll keeps running in the background and can race the new attempt, spam /livepeer/auth/status, and emit stale status/toasts.
Suggested direction
+let livepeerLoginAttemptId = 0;
@@
-async function startLivepeerLoginFlow(updateStatus) {
+async function startLivepeerLoginFlow(updateStatus, attemptId) {
@@
for (let attempt = 0; attempt < 180; attempt += 1) {
+ if (attemptId !== livepeerLoginAttemptId) {
+ throw new Error("Login attempt cancelled.");
+ }
await new Promise((resolve) => setTimeout(resolve, 1000));
@@
const closeModal = () => {
+ livepeerLoginAttemptId += 1;
overlay.remove();
window.__livepeerLoginModalOpen = false;
};
@@
loginBtn.onclick = async () => {
+ livepeerLoginAttemptId += 1;
+ const attemptId = livepeerLoginAttemptId;
loginBtn.disabled = true;
@@
- await startLivepeerLoginFlow(setStatus);
+ await startLivepeerLoginFlow(setStatus, attemptId);Also applies to: 166-196
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/js/stream_url_display.js` around lines 76 - 98, The long-running poll
in startLivepeerLoginFlow must be cancellable so closing or retrying the modal
stops the previous loop: introduce a shared AbortController (or a boolean flag)
referenced by startLivepeerLoginFlow and store it on a module-scoped variable
(e.g., currentLoginAbortController); before starting a new login flow abort any
existing controller; pass the controller.signal to fetchApi or check the flag
inside the loop and break immediately if aborted; update closeModal() and the
retry handler to call currentLoginAbortController.abort() (or set the flag) so
the loop stops and does not continue to poll /livepeer/auth/status or emit stale
updates. Ensure startLivepeerLoginFlow cleans up the controller when finished or
on error.
| await asyncio.to_thread(_AUTH_URL_READY.wait, 5.0) | ||
| with _AUTH_LOCK: | ||
| auth_url = str(_AUTH_STATE["auth_url"]) | ||
| user_code = str(_AUTH_STATE["user_code"]) | ||
| expires_in = int(_AUTH_STATE["expires_in"]) | ||
| login_error = str(_AUTH_STATE["error"]) | ||
|
|
||
| authenticated, _ = cached_auth_status() | ||
| if authenticated: | ||
| return web.json_response( | ||
| { | ||
| "ok": True, | ||
| "authenticated": True, | ||
| "pending": False, | ||
| } | ||
| ) | ||
|
|
||
| if login_error and not auth_url: | ||
| return web.json_response( | ||
| { | ||
| "ok": False, | ||
| "error": login_error, | ||
| }, | ||
| status=500, | ||
| ) | ||
|
|
||
| return web.json_response( | ||
| { | ||
| "ok": True, | ||
| "authenticated": False, | ||
| "pending": True, | ||
| "auth_url": auth_url, | ||
| "user_code": user_code, | ||
| "expires_in": expires_in, | ||
| } |
There was a problem hiding this comment.
Handle _AUTH_URL_READY.wait() timeouts explicitly.
Right now the wait result is ignored, so a slow device-code initialization falls through to the normal pending=True response even when auth_url is still empty. The UI rejects that payload and stops polling, while the worker can continue in the background. Check the boolean from wait() and return a distinct timeout/in-progress response before emitting the pending payload.
Possible fix
- await asyncio.to_thread(_AUTH_URL_READY.wait, 5.0)
+ ready = await asyncio.to_thread(_AUTH_URL_READY.wait, 5.0)
with _AUTH_LOCK:
auth_url = str(_AUTH_STATE["auth_url"])
user_code = str(_AUTH_STATE["user_code"])
expires_in = int(_AUTH_STATE["expires_in"])
login_error = str(_AUTH_STATE["error"])
+ in_progress = bool(_AUTH_STATE["in_progress"])
+
+ if not ready and in_progress and not auth_url and not login_error:
+ return web.json_response(
+ {
+ "ok": True,
+ "authenticated": False,
+ "pending": True,
+ "login_in_progress": True,
+ },
+ status=202,
+ )📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| await asyncio.to_thread(_AUTH_URL_READY.wait, 5.0) | |
| with _AUTH_LOCK: | |
| auth_url = str(_AUTH_STATE["auth_url"]) | |
| user_code = str(_AUTH_STATE["user_code"]) | |
| expires_in = int(_AUTH_STATE["expires_in"]) | |
| login_error = str(_AUTH_STATE["error"]) | |
| authenticated, _ = cached_auth_status() | |
| if authenticated: | |
| return web.json_response( | |
| { | |
| "ok": True, | |
| "authenticated": True, | |
| "pending": False, | |
| } | |
| ) | |
| if login_error and not auth_url: | |
| return web.json_response( | |
| { | |
| "ok": False, | |
| "error": login_error, | |
| }, | |
| status=500, | |
| ) | |
| return web.json_response( | |
| { | |
| "ok": True, | |
| "authenticated": False, | |
| "pending": True, | |
| "auth_url": auth_url, | |
| "user_code": user_code, | |
| "expires_in": expires_in, | |
| } | |
| ready = await asyncio.to_thread(_AUTH_URL_READY.wait, 5.0) | |
| with _AUTH_LOCK: | |
| auth_url = str(_AUTH_STATE["auth_url"]) | |
| user_code = str(_AUTH_STATE["user_code"]) | |
| expires_in = int(_AUTH_STATE["expires_in"]) | |
| login_error = str(_AUTH_STATE["error"]) | |
| in_progress = bool(_AUTH_STATE["in_progress"]) | |
| authenticated, _ = cached_auth_status() | |
| if authenticated: | |
| return web.json_response( | |
| { | |
| "ok": True, | |
| "authenticated": True, | |
| "pending": False, | |
| } | |
| ) | |
| if login_error and not auth_url: | |
| return web.json_response( | |
| { | |
| "ok": False, | |
| "error": login_error, | |
| }, | |
| status=500, | |
| ) | |
| if not ready and in_progress and not auth_url and not login_error: | |
| return web.json_response( | |
| { | |
| "ok": True, | |
| "authenticated": False, | |
| "pending": True, | |
| "login_in_progress": True, | |
| }, | |
| status=202, | |
| ) | |
| return web.json_response( | |
| { | |
| "ok": True, | |
| "authenticated": False, | |
| "pending": True, | |
| "auth_url": auth_url, | |
| "user_code": user_code, | |
| "expires_in": expires_in, | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/stream/auth_routes.py` around lines 126 - 160, The code ignores the
boolean result of _AUTH_URL_READY.wait which lets slow device-code
initialization fall through to returning a pending payload with empty auth_url;
fix by capturing the return value (e.g., ready = await
asyncio.to_thread(_AUTH_URL_READY.wait, 5.0)) and if ready is False return a
distinct timeout/in-progress response (e.g., ok: True, authenticated: False,
pending: True, timed_out: True or status 202) before reading _AUTH_STATE inside
the _AUTH_LOCK; keep using _AUTH_LOCK, _AUTH_STATE["auth_url"],
_AUTH_STATE["user_code"], _AUTH_STATE["expires_in"], and cached_auth_status() as
currently referenced.
| try: | ||
| from livepeer_gateway.oidc_auth import load_cached_token | ||
| except Exception as exc: | ||
| return False, f"Could not import OIDC auth client: {exc}" | ||
|
|
||
| token = load_cached_token( | ||
| BILLING_URL, | ||
| client_id=CLIENT_ID, | ||
| scopes=OIDC_SCOPES, | ||
| ) | ||
| if token is None: | ||
| return False, "No saved Livepeer login session found." | ||
| if token.is_expired(): | ||
| return False, "Saved Livepeer login session is expired." |
There was a problem hiding this comment.
Handle cache-read failures inside the auth helpers.
Both helpers promise a (bool, str) status result, but load_cached_token() and the subsequent token access can still raise out of band here. A corrupt cache file or partial token object will crash the auth-status / refresh path instead of surfacing a recoverable “please log in again” state.
Proposed hardening
def cached_auth_status() -> Tuple[bool, str]:
@@
- token = load_cached_token(
- BILLING_URL,
- client_id=CLIENT_ID,
- scopes=OIDC_SCOPES,
- )
- if token is None:
- return False, "No saved Livepeer login session found."
- if token.is_expired():
- return False, "Saved Livepeer login session is expired."
- return True, ""
+ try:
+ token = load_cached_token(
+ BILLING_URL,
+ client_id=CLIENT_ID,
+ scopes=OIDC_SCOPES,
+ )
+ if token is None:
+ return False, "No saved Livepeer login session found."
+ if token.is_expired():
+ return False, "Saved Livepeer login session is expired."
+ return True, ""
+ except Exception as exc:
+ return False, f"Could not load saved Livepeer login session: {exc}"
@@
- cached = load_cached_token(
- BILLING_URL,
- client_id=CLIENT_ID,
- scopes=OIDC_SCOPES,
- )
- if cached is None:
- return False, "No saved Livepeer session found to refresh."
-
- refresh_token = cached.get("refresh_token")
- if not refresh_token:
- return False, "Saved session has no refresh token."
+ try:
+ cached = load_cached_token(
+ BILLING_URL,
+ client_id=CLIENT_ID,
+ scopes=OIDC_SCOPES,
+ )
+ if cached is None:
+ return False, "No saved Livepeer session found to refresh."
+
+ refresh_token = cached.get("refresh_token")
+ if not refresh_token:
+ return False, "Saved session has no refresh token."
+ except Exception as exc:
+ return False, f"Could not load saved Livepeer session: {exc}"Also applies to: 72-96
🧰 Tools
🪛 Ruff (0.15.10)
[warning] 44-44: Do not catch blind exception: Exception
(BLE001)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/stream/credentials.py` around lines 42 - 55, The code currently calls
load_cached_token and token.is_expired directly which can raise on corrupt cache
or partial token objects; wrap the load_cached_token(...) call and subsequent
token attribute access (including token.is_expired()) in a try/except that
catches broad exceptions and returns a recoverable status tuple (e.g. False with
a clear message like "Saved Livepeer login session is invalid or unreadable:
{exc}" or "Please log in again") instead of letting exceptions bubble; apply the
same defensive try/except pattern to the other auth-check block referenced (the
code around the token handling at lines ~72-96) so both places return the (bool,
str) contract on any cache-read or token-access failure.
| media_output = MediaOutput( | ||
| self._current_subscribe_url, | ||
| start_seq=-2, | ||
| subscribe_url, | ||
| # Use 0 to start from the latest available without deep backfill | ||
| # to reduce stalls on reconnect. | ||
| start_seq=0, | ||
| max_retries=5, | ||
| chunk_size=64 * 1024, | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In the livepeer_gateway.media_output.MediaOutputAPI, isMediaOutputintended to be used as an async context manager, and does breaking out ofasync for media_output.bytes() without an explicit close leave the underlying subscription or HTTP session open?
💡 Result:
No definitive evidence found in available sources confirming whether MediaOutput in livepeer_gateway.media_output is intended as an async context manager or the behavior of breaking out of async for media_output.bytes without explicit close. The repositories https://github.com/livepeer/livepeer-python-gateway and https://github.com/j0sh/livepeer-python-gateway contain relevant examples like camera_capture.py that use MediaOutput for subscribing to media output, but source code details are not accessible in search snippets. Examples show piping output to ffplay or files, suggesting streaming consumption, but no explicit usage as async with or close calls visible. General Python async iterator best practices recommend explicit cleanup, but no Livepeer-specific documentation confirms subscription/HTTP session persistence without proper exit.
Citations:
- 1: https://github.com/livepeer/livepeer-python-gateway
- 2: https://github.com/j0sh/livepeer-python-gateway
- 3: https://github.com/livepeer/livepeer-python
🏁 Script executed:
# Find the MediaOutput class definition
fd -t f -i "\.py$" | xargs rg -l "class MediaOutput" | head -5Repository: eliteprox/livepeer-ai-nodes
Length of output: 53
🏁 Script executed:
# Search for MediaOutput imports in the mentioned files
rg "from.*MediaOutput|import.*MediaOutput" -A 2 -B 2 nodes/stream/http_stream_proxy.py nodes/stream/network_subscriber.pyRepository: eliteprox/livepeer-ai-nodes
Length of output: 694
🏁 Script executed:
# Check the actual code at lines 120-127 in http_stream_proxy.py and surrounding context
sed -n '100,180p' nodes/stream/http_stream_proxy.pyRepository: eliteprox/livepeer-ai-nodes
Length of output: 3213
🏁 Script executed:
# Find how MediaOutput is used in network_subscriber.py
sed -n '1,300p' nodes/stream/network_subscriber.py | grep -A 15 -B 5 "MediaOutput"Repository: eliteprox/livepeer-ai-nodes
Length of output: 2622
🏁 Script executed:
# Check if MediaOutput is defined locally or imported
fd -t f -i "\.py$" | xargs rg "class MediaOutput"Repository: eliteprox/livepeer-ai-nodes
Length of output: 55
🏁 Script executed:
# Search for all MediaOutput usages in codebase to understand patterns
rg "MediaOutput\(" -B 2 -A 5Repository: eliteprox/livepeer-ai-nodes
Length of output: 1282
🏁 Script executed:
# Check requirements or setup files to confirm livepeer_gateway dependency
fd -E node_modules "requirements\.|setup\.|pyproject\." -type fRepository: eliteprox/livepeer-ai-nodes
Length of output: 241
🏁 Script executed:
# Check for dependency files
find . -maxdepth 2 -type f \( -name "requirements*.txt" -o -name "setup.py" -o -name "pyproject.toml" -o -name "poetry.lock" -o -name "Pipfile" \)Repository: eliteprox/livepeer-ai-nodes
Length of output: 131
🏁 Script executed:
# Check the full context of http_stream_proxy.py to see if there's any cleanup in finally block
sed -n '100,200p' nodes/stream/http_stream_proxy.py | tail -30Repository: eliteprox/livepeer-ai-nodes
Length of output: 893
🏁 Script executed:
# Get the complete try-except-finally block around MediaOutput usage
sed -n '110,180p' nodes/stream/http_stream_proxy.pyRepository: eliteprox/livepeer-ai-nodes
Length of output: 2861
🏁 Script executed:
# Check pyproject.toml for livepeer_gateway dependency
cat pyproject.toml | grep -A 5 -B 5 "livepeer"Repository: eliteprox/livepeer-ai-nodes
Length of output: 1410
Wrap MediaOutput in async with to ensure cleanup on every exit path.
The code creates a MediaOutput instance and iterates media_output.bytes() but has two explicit break paths (session change and idle timeout) that exit the loop without closing the underlying subscription/session. Unlike network_subscriber.py which uses async with MediaOutput(...) as output:, this code has no deterministic cleanup, leaving the old trickle subscription and HTTP session open on reconnect or timeout.
Suggested fix
- media_output = MediaOutput(
- subscribe_url,
- # Use 0 to start from the latest available without deep backfill
- # to reduce stalls on reconnect.
- start_seq=0,
- max_retries=5,
- chunk_size=64 * 1024,
- )
-
- # Stream MPEG-TS bytes from trickle to client
- bytes_sent = 0
- chunk_count = 0
- last_chunk_time = asyncio.get_event_loop().time()
- idle_timeout = 8.0 # seconds without chunks before ending
-
- async for chunk in media_output.bytes():
+ async with MediaOutput(
+ subscribe_url,
+ # Use 0 to start from the latest available without deep backfill
+ # to reduce stalls on reconnect.
+ start_seq=0,
+ max_retries=5,
+ chunk_size=64 * 1024,
+ ) as media_output:
+ # Stream MPEG-TS bytes from trickle to client
+ bytes_sent = 0
+ chunk_count = 0
+ last_chunk_time = asyncio.get_event_loop().time()
+ idle_timeout = 8.0 # seconds without chunks before ending
+
+ async for chunk in media_output.bytes():
# Abort this client if the subscribe URL changed.
if session_id != self._session_id:
print("[HTTP_PROXY] Session changed; closing old client")
break
@@
- await response.write(chunk)
- bytes_sent += len(chunk)
- chunk_count += 1
- last_chunk_time = asyncio.get_event_loop().time()
+ await response.write(chunk)
+ bytes_sent += len(chunk)
+ chunk_count += 1
+ last_chunk_time = asyncio.get_event_loop().time()Also applies to: 135-168
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/stream/http_stream_proxy.py` around lines 120 - 127, The MediaOutput
instance is not being closed on all exit paths causing lingering
subscriptions/sessions; wrap the creation and iteration of MediaOutput in an
async context manager (use "async with MediaOutput(... ) as media_output:") and
move the loop that consumes media_output.bytes() into that block (mirroring the
pattern in network_subscriber.py) so that both normal completion and the two
break paths (session change and idle timeout) always run MediaOutput.__aexit__
to cleanup the trickle subscription and HTTP session; update both occurrences
around the current media_output creation and the similar block at lines 135-168
to use the async with pattern.
| LOGGER.info( | ||
| "OIDC Device Authorization: visit %s and enter code: %s (expires in %ds)", | ||
| auth_url, | ||
| user_code, | ||
| expires_in, | ||
| ) |
There was a problem hiding this comment.
Do not log the device authorization code at INFO level.
user_code is the credential that completes the device flow. Persisting it in normal logs unnecessarily exposes a live login secret, and the UI event above already gives the user what they need. Please redact the code or drop this log entirely.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/stream/network_controller.py` around lines 202 - 207, The current
LOGGER.info call in network_controller.py leaks the OIDC device user_code;
remove the secret from normal logs by changing the log to exclude or redact
user_code (e.g., log only auth_url and expires_in or replace user_code with a
fixed placeholder like "[REDACTED]") in the LOGGER.info invocation where
auth_url, user_code, expires_in are used; ensure the UI event still provides the
code to the user and do not add the raw user_code elsewhere in application logs
(refer to the LOGGER.info call and the variables user_code, auth_url,
expires_in).
| </div> | ||
| </main> | ||
|
|
||
| <script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.8.0/dist/mpegts.min.js"></script> |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
For a same-origin application page embedded in an iframe, what are the security implications of loading a third-party JavaScript bundle from a public CDN, and what mitigations are recommended (self-hosting, SRI, iframe sandboxing)?
💡 Result:
Loading a third-party JavaScript bundle from a public CDN in a same-origin application page embedded in an iframe introduces significant security risks primarily due to supply chain attacks. If the CDN or third-party host is compromised, attackers can inject malicious code into the bundle. Once loaded, this script executes with full access to the page's DOM, cookies (unless HttpOnly), storage, and browser APIs, enabling data theft, session hijacking, form manipulation, redirects, or further exploits. The same-origin policy does not restrict this because the script runs within the page's origin, treating it identically to first-party code. Recommended mitigations include: - Self-hosting: Host the JS bundle on your own server to maintain full control over updates and content, eliminating third-party compromise risks. - Subresource Integrity (SRI): Add an integrity attribute with a cryptographic hash (e.g., SHA-384 or SHA-512) to the script tag, requiring the crossorigin="anonymous" attribute for cross-origin loads. The browser verifies the hash before execution, blocking tampered files. Requires CDN to support CORS. Update hashes after legitimate vendor changes. - Iframe sandboxing: For containing third-party JS, load it in a sandboxed iframe (sandbox="allow-scripts" without allow-same-origin) from a different domain or srcdoc. This treats the iframe as cross-origin, preventing access to the parent page's DOM/cookies/storage. Use postMessage for controlled parent-iframe communication. Combine with CSP for stronger isolation. Additional best practices: Implement Content Security Policy (CSP) to restrict script-src, connect-src, and sandbox frames; audit and minimize third-party dependencies; monitor for changes.
Citations:
- 1: http://cheatsheetseries.owasp.org/cheatsheets/Third_Party_Javascript_Management_Cheat_Sheet.html
- 2: https://www.shieldreport.io/blog/third-party-javascript-risks
- 3: https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity
- 4: https://security.stackexchange.com/questions/254512/how-do-i-safely-host-third-party-javascript-code-in-an-iframe
🏁 Script executed:
# Locate and examine the viewer_routes.py file around line 123
find . -name "viewer_routes.py" -type fRepository: eliteprox/livepeer-ai-nodes
Length of output: 102
🏁 Script executed:
# Locate stream_url_display.js to understand iframe embedding
find . -name "stream_url_display.js" -type fRepository: eliteprox/livepeer-ai-nodes
Length of output: 103
🏁 Script executed:
# Read viewer_routes.py to see context around line 123 and check script tag setup
wc -l ./nodes/stream/viewer_routes.pyRepository: eliteprox/livepeer-ai-nodes
Length of output: 106
🏁 Script executed:
# Get the context around line 123 in viewer_routes.py
sed -n '100,150p' ./nodes/stream/viewer_routes.pyRepository: eliteprox/livepeer-ai-nodes
Length of output: 1441
🏁 Script executed:
# Read stream_url_display.js to check iframe setup and player code
cat -n ./nodes/js/stream_url_display.jsRepository: eliteprox/livepeer-ai-nodes
Length of output: 32198
🏁 Script executed:
# Read the full viewer_routes.py file to see complete context including scheduleReconnect and startPlayer
cat -n ./nodes/stream/viewer_routes.py | head -200Repository: eliteprox/livepeer-ai-nodes
Length of output: 6815
🏁 Script executed:
# Continue reading viewer_routes.py from line 200 onwards
sed -n '200,228p' ./nodes/stream/viewer_routes.pyRepository: eliteprox/livepeer-ai-nodes
Length of output: 837
Add Subresource Integrity to the CDN script and clean up event listeners on reconnects.
The mpegts.js script loads from jsDelivr without an SRI (Subresource Integrity) attribute. Since /livepeer/viewer is same-origin with ComfyUI and embedded as an iframe by the frontend, a compromised CDN asset executes inside app-origin content with full access to the page DOM and APIs. Add an integrity attribute with a SHA-384 hash (e.g., integrity="sha384-...") and crossorigin="anonymous" to the script tag.
Additionally, startPlayer() registers event listeners (pause, click, ended) on the video element on every reconnect without removing the previous listeners. This causes listeners to accumulate and fire repeatedly during subsequent reconnects. Call video.removeEventListener() for each handler in cleanupPlayer() before destroying the player, or recreate the video element.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/stream/viewer_routes.py` at line 123, Add Subresource Integrity and
CORS to the mpegts.js CDN import: update the script tag (the one loading
"https://cdn.jsdelivr.net/npm/mpegts.js@1.8.0/dist/mpegts.min.js") to include an
integrity="sha384-..." attribute with the correct SHA-384 hash and add
crossorigin="anonymous". Also prevent event-listener accumulation on reconnects
by removing previously attached handlers in cleanupPlayer(): ensure
cleanupPlayer() calls video.removeEventListener('pause', onPauseHandler),
video.removeEventListener('click', onClickHandler), and
video.removeEventListener('ended', onEndedHandler) (use the same handler
references that startPlayer() registers) before destroying the player or
alternatively recreate the video element in startPlayer()/cleanupPlayer() to
avoid reusing the element with already-bound listeners.
| function scheduleReconnect(delayMs = 1200) { | ||
| if (!allowReconnect) { | ||
| setStatus("Stream ended.", "err"); | ||
| return; | ||
| } | ||
| retries += 1; | ||
| const wait = Math.min(delayMs * retries, 5000); | ||
| setStatus(`Reconnecting... attempt ${retries}`, "err"); | ||
| setTimeout(startPlayer, wait); | ||
| } |
There was a problem hiding this comment.
Debounce reconnects and bind the video handlers once.
Every startPlayer() call adds new pause, click, and ended listeners, and scheduleReconnect() can queue multiple pending setTimeout() restarts. After a few transient errors the viewer can end up with overlapping reconnect attempts and duplicated handler execution.
One way to stabilize the reconnect lifecycle
+ let reconnectTimer = null;
@@
function cleanupPlayer() {
if (!player) return;
try { player.destroy(); } catch (_e) {}
player = null;
}
@@
function scheduleReconnect(delayMs = 1200) {
if (!allowReconnect) {
setStatus("Stream ended.", "err");
return;
}
retries += 1;
const wait = Math.min(delayMs * retries, 5000);
setStatus(`Reconnecting... attempt ${retries}`, "err");
- setTimeout(startPlayer, wait);
+ clearTimeout(reconnectTimer);
+ reconnectTimer = setTimeout(startPlayer, wait);
}
@@
- video.addEventListener("pause", preventPause);
- video.addEventListener("click", (event) => {
+ video.onpause = preventPause;
+ video.onclick = (event) => {
event.preventDefault();
preventPause();
- });
+ };
@@
- video.addEventListener("ended", () => {
+ video.onended = () => {
allowReconnect = false;
setStatus("Stream ended.", "err");
cleanupPlayer();
- });
+ };Also applies to: 160-210
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/stream/viewer_routes.py` around lines 149 - 158, scheduleReconnect
currently queues multiple setTimeout restarts and startPlayer repeatedly
re-attaches video event listeners causing duplicate handlers; fix by introducing
a single reconnectTimeoutId and clearTimeout(reconnectTimeoutId) before
scheduling to debounce reconnects, define and bind the video handlers (e.g.,
onPause, onClick, onEnded) once at module scope or on first init and reuse them
(or removeEventListener before add), ensure startPlayer checks/sets a flag like
playerInitialized to avoid re-binding, reset retries on a successful start, and
use the existing allowReconnect/retries logic with the new debounce so
overlapping reconnect attempts are prevented.
| # start_trickle_stream returns dict with "ui" on error | ||
| if isinstance(status, dict) and "ui" in status and "result" not in status: | ||
| return { | ||
| "ui": status.get("ui", {}), | ||
| "result": ("", "", "", self._blank_image()), | ||
| } | ||
|
|
||
| status_values = status.get("result", None) if isinstance(status, dict) else status | ||
| if not status_values: | ||
| status_values = ("", "", "", "") | ||
|
|
||
| publish_url, _, subscribe_url, error_msg = status_values | ||
|
|
There was a problem hiding this comment.
Fix the output tuple shape and tuple unpacking.
There are two correctness bugs here: the error branch returns 4 values even though the node declares 5 outputs, and the success path unpacks status_values in the wrong order. StartTrickleStream returns (manifest_id, publish_url, subscribe_url, error), so the first output from this node is currently exposing the manifest ID as publish_url.
Possible fix
if isinstance(status, dict) and "ui" in status and "result" not in status:
return {
"ui": status.get("ui", {}),
- "result": ("", "", "", self._blank_image()),
+ "result": ("", "", "", "", self._blank_image()),
}
@@
- publish_url, _, subscribe_url, error_msg = status_values
+ _, publish_url, subscribe_url, error_msg = status_values🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@nodes/trickle_start_preview_node.py` around lines 93 - 105, The node
currently returns the wrong-shaped result on error and unpacks the success tuple
in the wrong order: ensure the result tuple has 5 outputs (manifest_id,
publish_url, subscribe_url, error, image) and unpack status_values as
(manifest_id, publish_url, subscribe_url, error_msg). Concretely, in the
StartTrickleStream handling around variables status and status_values, change
the error return to include five elements (e.g. ("", "", "", "",
self._blank_image())) and change the unpacking line to: manifest_id,
publish_url, subscribe_url, error_msg = status_values; then use those values
when constructing the returned "result" tuple so manifest_id is the first
element and the image is appended as the fifth.
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Refactor