' ===========================================================================
' Deep-link resolution + deferred replay
'
' Two stash sources, both replayed after login by replayAfterLogin():
'
' 1. The auth guard (components/auth/AuthManager.bs) stashes the PATH of any
' signed-out in-app navigation on m.global.AuthManager.stashedRoute (a string).
' Its queue (for a /play path) was already populated by the launcher, so replay
' just re-walks Home -> [Details ->] target (buildReplayRoutes).
'
' 2. A DEEP LINK (cold-start launchArgs or runtime roInputEvent) stashes a STRUCTURED
' intent on m.global.AuthManager.stashedDeepLink ({ itemId, mediaType, serverGuid }).
' A deep link's contentId is untrusted, so it routes through ItemDetails: navigate
' Home -> /details/<mediaType>/<itemId>?deeplink=<mediaType>. ItemDetails validates
' (fetch by id), and on load auto-launches with the REAL type + Jellyfin bookmark
' (resume position), or shows an error and returns Home if the id doesn't resolve.
' This reuses ItemDetails' canonical play path instead of a bare {id} queue seed.
'
' MAIN-THREAD code (shares Main()'s m). It never touches the sgrouter namespace (that
' resolves on the render thread); it hands route chains to JRScene via callFunc.
' ===========================================================================
' Parse a Roku deep-link contentId into { itemId, serverGuid, action }. contentId is the only
' deep-link field that survives BOTH Roku ingress paths intact (cold-start /launch standardizes
' only contentId+mediaType; /input forwards arbitrary params but /launch does not), and Roku
' forbids "&" inside it — so the whole contract rides inside contentId, pipe-delimited (Roku's
' own documented convention, e.g. series=x|Season=1). We mint it as
' "id=<itemId>|serverId=<serverGuid>|action=<verb>" (key names mirror Jellyfin web's ?id=&serverId=),
' and still accept a bare itemId (manual ECP tests, legacy). serverGuid is "" when absent (=> the
' active server). action defaults to "open" (land on the page, no autoplay) and is normalized to
' lower case; the consumer treats any UNKNOWN action as "open" too, so the contract is
' forward-compatible — a future verb degrades gracefully on an older build.
' Duplicate keys are LAST-WINS ("id=a|id=b" => "b") — an emergent property of the loop, pinned
' by a spec so a refactor can't silently change it. A value may itself contain "=" (we split each
' pair on the FIRST "=" only), so a key=value whose value carries an "=" is preserved, not dropped.
function parseDeepLinkContentId(raw as string) as object
result = { itemId: "", serverGuid: "", action: "open" }
if not isValidAndNotEmpty(raw) then return result
for each pair in raw.split("|")
pair = pair.trim()
if pair = "" then continue for
eq = Instr(1, pair, "=")
if eq = 0
' A bare segment (no "=") is taken as the item id when we don't have one yet, so
' "<id>|action=play" works as naturally as "id=<id>|action=play". First bare wins;
' once id is set, later bare segments (typos, stray values) are ignored.
if result.itemId = "" then result.itemId = pair
else
' Split on the FIRST "=" only so a value containing "=" survives (Left=key, Mid=value).
key = LCase(Left(pair, eq - 1).trim())
value = Mid(pair, eq + 1).trim()
if key = "id"
' Guard like action/server below: an explicit empty "id=" must not clobber a value an
' earlier bare segment already supplied (e.g. "abc|id=").
if isValidAndNotEmpty(value) then result.itemId = value
else if key = "server" or key = "serverid"
' Jellyfin server GUIDs are canonical lower-case hex; normalize so a sender that
' upper-cases the GUID still matches a saved server (the compare sites are also folded).
result.serverGuid = LCase(value)
else if key = "action"
if isValidAndNotEmpty(value) then result.action = LCase(value)
end if
end if
end for
return result
end function
' A deep-link itemId is concatenated raw into the details route AND the metadata-fetch URL
' ("/Items/<id>"), neither of which is URL-encoded. Reject an id carrying URL-structural
' characters (/ ? # & = | % or whitespace) so a crafted contentId can't reshape either — a
' whitelist of the characters real Jellyfin ids (GUIDs, hex +/- dashes) actually use. Deliberately
' NOT a strict GUID match: the goal is to block injection, not to assume an exact id format.
function isPlausibleDeepLinkItemId(id as string) as boolean
if not isValidAndNotEmpty(id) then return false
re = CreateObject("roRegex", "^[A-Za-z0-9_\-]{1,128}$", "")
return re.isMatch(id)
end function
' Safe-token check for deep-link fields that ride UNENCODED into the details route — mediaType
' in the PATH ("/details/<mediaType>/...") and action in the QUERY ("?deeplink=<action>"). Both
' come from sender-controlled launch params. Blocks the URL-structural characters (/ ? # & = | %
' and whitespace) a crafted value would use to inject extra path/query segments, while still
' allowing clean-but-unknown values through — a future Jellyfin item type or a forward-compat
' action verb degrades gracefully downstream (ItemDetails resolves the real type; an unknown
' action is treated as "open"). Mirrors isPlausibleDeepLinkItemId's whitelist approach.
function isPlausibleDeepLinkToken(value as string) as boolean
if not isValidAndNotEmpty(value) then return false
re = CreateObject("roRegex", "^[A-Za-z0-9_\-]{1,64}$", "")
return re.isMatch(value)
end function
' isDeepLinkPlaybackAction lives in source/deepLink.bs (a leaf module shared with the
' render-thread ItemDetails view); called here via source/ auto-scope.
' Stash a deep link (cold-start or runtime) as a structured intent for replayAfterLogin.
' Parses the contentId; records itemId + mediaType (+ serverGuid for the server resolver).
' Replaces the old queue-seed approach — ItemDetails now builds the queue from the
' validated item (real type + bookmark), so no queue work happens here.
' itemName is an OPTIONAL display title the sender (a casting client) may include so the
' server-switch prompt can name what's being cast — we can't fetch it ourselves before the
' switch (it lives on a server we're not yet authed to). Empty for bare/unknown senders.
sub stashDeepLink(rawContentId as string, mediaType as string, itemName = "" as string)
parsed = parseDeepLinkContentId(rawContentId)
' Drop a missing or structurally-implausible id BEFORE it reaches a route/URL — the same
' silent no-stash the empty-id case already used (a junk id is garbage, not a deferrable link).
if not isPlausibleDeepLinkItemId(parsed.itemId) then return
' mediaType is only a display hint (ItemDetails resolves the REAL type from the fetched item),
' but it lands raw in the route PATH — so an empty OR injection-bearing value falls back to the
' safe default rather than reshaping the route.
routeMediaType = mediaType
if not isPlausibleDeepLinkToken(routeMediaType) then routeMediaType = "Video"
' action rides into the route QUERY (?deeplink=). parseDeepLinkContentId already defaulted it to
' "open"; here we additionally scrub an injection-bearing action back to "open" (which is also
' how an unknown action is interpreted downstream, so this only tightens the wire contract).
routeAction = parsed.action
if not isPlausibleDeepLinkToken(routeAction) then routeAction = "open"
m.global.AuthManager.stashedDeepLink = {
itemId: parsed.itemId,
mediaType: routeMediaType,
serverGuid: parsed.serverGuid,
itemName: itemName,
action: routeAction
}
end sub
' Read + clear whatever is stashed and replay it after a successful login (or immediately
' when already authed). Deep link wins over a guard path. Always navigates Home first so it
' is the back-stack root.
sub replayAfterLogin()
deepLink = m.global.AuthManager.stashedDeepLink
if isValid(deepLink) and isValidAndNotEmpty(deepLink.itemId)
m.global.AuthManager.stashedDeepLink = invalid
' homeFirst: land on Home (the back-stack root) THEN validate. An invalid id just toasts on
' Home — good post-login UX. A valid id navigates on from Home.
m.scene.callFunc("resolveDeepLink", {
itemId: deepLink.itemId,
action: deepLink.action,
isPlayback: isDeepLinkPlaybackAction(deepLink.action),
homeFirst: true,
replacePlayer: false,
detailsRoute: deepLinkDetailsRoute(deepLink)
})
return
end if
stashed = m.global.AuthManager.stashedRoute
m.global.AuthManager.stashedRoute = ""
m.scene.callFunc("replayRoutedDeepLink", buildReplayRoutes(stashed))
end sub
' Runtime replay (app already signed in, deep link for the active server). Unlike the post-login
' path above, this does NOT prepend Home — an in-app cast shouldn't flash through the Home screen.
' - already on the target item's LOADED details -> launch the action in place (no fetch, no
' re-mount); for "open" we're already showing it, so nothing to do. The itemContent guard
' matters: a lingering empty details would otherwise swallow the cast.
' - otherwise -> hand the intent to JRScene.resolveDeepLink, which VALIDATES the id (a fetch)
' BEFORE navigating. An invalid id never disturbs the active session — it just toasts. A valid
' id then navigates (replacing an active player for a playback cast, else a plain push).
sub replayDeepLinkRuntime()
deepLink = m.global.AuthManager.stashedDeepLink
if not isValid(deepLink) or not isValidAndNotEmpty(deepLink.itemId) then return
m.global.AuthManager.stashedDeepLink = invalid
isPlayback = isDeepLinkPlaybackAction(deepLink.action)
active = m.global.activeRoutedView
onLoadedDetails = isValid(active) and active.subtype() = "ItemDetails" and active.itemId = deepLink.itemId and isValid(active.itemContent) and isValidAndNotEmpty(active.itemContent.id)
if onLoadedDetails
' Already on this item's loaded details — no need to re-validate. Launch in place; "open" no-op.
if isPlayback then active.callFunc("playFromDeepLink", deepLink.action)
return
end if
' Drop an incidental "open" that would stack details on top of live playback (jellyfin-web
' display-mirroring, or a Roku OS deep link arriving mid-playback). Silent — mirroring fires on
' every browse, so a toast would spam. Playback actions fall through to replacePlayer below.
if isValid(active) and wouldStackOverActivePlayer(deepLink.action, active.subtype()) then return
replacePlayer = isPlayback and isValid(active) and isDeepLinkPlayerView(active.subtype())
m.scene.callFunc("resolveDeepLink", {
itemId: deepLink.itemId,
action: deepLink.action,
isPlayback: isPlayback,
homeFirst: false,
replacePlayer: replacePlayer,
detailsRoute: deepLinkDetailsRoute(deepLink)
})
end sub
' Active routed views that ARE a media player (a deep link arriving over one must tear it down
' before launching the next, not stack on top).
function isDeepLinkPlayerView(subtype as string) as boolean
return subtype = "PlayerHostView" or subtype = "AudioPlayerView" or subtype = "VideoPlayerView"
end function
' True when a non-playback ("open") deep link would stack a details screen on TOP of an active media
' player, and so must be dropped rather than enacted. jellyfin-web's display-mirroring fires a
' DisplayContent ("open") on EVERY item-detail browse while JellyRock is the cast target, and the Roku
' OS can hand us an "open" deep link mid-playback — neither is an intent to interrupt playback, so the
' controller's browsing must never yank the cast target off the video. A PLAYBACK action
' (play/shuffle/trailer/instantmix) is exempt: it legitimately REPLACES the player (replacePlayer
' below). activeSubtype is the active routed view's subtype ("" / invalid when there's no active view).
' Pure so the rule is unit-testable without a live node.
function wouldStackOverActivePlayer(action as string, activeSubtype as dynamic) as boolean
if isDeepLinkPlaybackAction(action) then return false
if not isValidAndNotEmpty(activeSubtype) then return false
return isDeepLinkPlayerView(activeSubtype)
end function
' Build the /details route for a deep link. The :type is just a display hint (ItemDetails
' resolves the REAL type from the fetched item). The ?deeplink=<action> query does double duty:
' it marks this as an untrusted deep-link entry (so ItemDetails validates the id, sending an
' invalid one back to Home per cert), AND it carries the intent — "open" lands on the springboard;
' "play"/"shuffle"/"trailer" auto-launch the matching action once the item resolves. The intent is
' the SENDER'S explicit choice (the route decides), never inferred from the resolved item.
' The "Playing <title>" toast is fired by the player on mount using the REAL resolved title
' (ItemDetails sets m.global.deepLinkOpeningTitle) — not the sender's param, which may be a
' placeholder. So nothing is toasted here.
function deepLinkDetailsRoute(deepLink as object) as string
base = "/details/" + deepLink.mediaType + "/" + deepLink.itemId + "?deeplink="
if isValidAndNotEmpty(deepLink.action) then return base + deepLink.action
return base + "open"
end function
' Toast shown when a deep link arrives but the user must authenticate first — it explains why
' nothing happened yet and that the content will open after sign-in. No-op if nothing stashed.
sub notifyDeepLinkDeferred()
dl = m.global.AuthManager.stashedDeepLink
if isValid(dl) and isValidAndNotEmpty(dl.itemId)
displayToast(translate(translationKeys.MessageDeepLinkAfterSignIn), "info")
end if
end sub
' Pure: turn a guard-stashed path into the ordered route chain to replay. Home is always
' first (back-stack root); a /play path additionally inserts its Details screen between Home
' and the player (the details path is the play path minus the trailing "/play").
function buildReplayRoutes(stashed as dynamic) as object
if not isValidAndNotEmpty(stashed) then return ["/"]
' Separate the base path from any query string.
basePath = stashed
q = Instr(1, stashed, "?")
if q > 0 then basePath = Left(stashed, q - 1)
if basePath.endsWith("/play")
detailsPath = Left(basePath, Len(basePath) - Len("/play"))
return ["/", detailsPath, stashed]
end if
return ["/", stashed]
end function
' ---------------------------------------------------------------------------
' Server resolution — a deep link's contentId may name a serverId (GUID). The whole app
' is one-server-at-a-time, so a link for a DIFFERENT server means switching (a full re-auth,
' like Change Server). We only have URLs for SAVED servers, so an unknown GUID can't be
' fulfilled. See docs/architecture/navigation.md ("Deep links").
' ---------------------------------------------------------------------------
' Look up a saved server entry by its Jellyfin server id (GUID). Returns the entry
' ({ name, id, baseUrl, originalUrl, ... }) or invalid. The pure match logic lives in
' config.bs (findServerInList) next to the saved_servers shape it matches.
function findSavedServerByGuid(guid as string) as object
return findServerInList(getSetting("saved_servers"), guid)
end function
' COLD start: if the stashed deep link names a saved server, point the login at it BEFORE
' beginLogin runs (no prompt — there is no active session to disrupt). An unknown/absent
' GUID is left alone: login proceeds normally and the deep link validates on whatever server
' we land on (graceful error -> Home if the item isn't there).
sub steerColdStartDeepLinkServer()
dl = m.global.AuthManager.stashedDeepLink
if not isValid(dl) or not isValidAndNotEmpty(dl.serverGuid) then return
entry = findSavedServerByGuid(dl.serverGuid)
if isValid(entry) and isValidAndNotEmpty(entry.originalUrl)
setSetting("server", entry.originalUrl)
end if
end sub
' RUNTIME (app already signed in): resolve the stashed deep link's server and act.
' - no/absent server, or it's the active one -> replay now.
' - a different SAVED server -> prompt to switch.
' - an unknown server -> can't connect; toast + discard.
' When NOT signed in (user at pre-login), do nothing: the stash rides until login and
' validates on whatever server they sign into.
sub onRuntimeDeepLink()
if not isValidAndNotEmpty(m.global.user.authToken)
notifyDeepLinkDeferred()
return
end if
dl = m.global.AuthManager.stashedDeepLink
guid = ""
if isValid(dl) then guid = dl.serverGuid
if not isValidAndNotEmpty(guid) or guid = LCase(m.global.server.id)
replayDeepLinkRuntime()
return
end if
entry = findSavedServerByGuid(guid)
if isValid(entry)
promptServerSwitch(entry)
return
end if
m.global.AuthManager.stashedDeepLink = invalid
displayToast(translate(translationKeys.MessageContentOnUnknownServer), "error")
end sub
' Ask before switching servers (it signs the user out of the current one). The choice comes
' back through SceneManager.returnData -> main.bs's isDataReturned handler (the same bridge
' the exit dialog uses), which calls performServerSwitch on confirm.
sub promptServerSwitch(entry as object)
m.pendingSwitchServer = entry
m.global.sceneManager.isPendingServerSwitch = true
' Name what's being cast when the sender provided a title, so the user can tell a real cast
' from a stray/accidental one; fall back to a server-named, provenance-aware message otherwise.
dl = m.global.AuthManager.stashedDeepLink
itemName = ""
if isValid(dl) and isValidAndNotEmpty(dl.itemName) then itemName = dl.itemName
if isValidAndNotEmpty(itemName)
message = Substitute(translate(translationKeys.MessageSwitchServerToPlayTitled), itemName, entry.name)
else
message = Substitute(translate(translationKeys.MessageSwitchServerToPlay), entry.name)
end if
m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelChangeServer), [message], [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonSwitch)])
end sub
' Perform the confirmed switch. Pre-flight the target FIRST (non-destructive) so an offline
' server never strands the user. The probe runs on a Task thread (ServerReachableTask) so the
' up-to-8s reachability check can't freeze remote input; the switch itself continues in
' onServerProbeDone once the verdict lands back on the main loop.
sub performServerSwitch(entry as object)
m.pendingSwitchServer = invalid
if not isValid(entry) then return
startLoadingSpinner()
' Hold the entry for the async callback — the probe outcome decides whether we tear down the
' session, and the result arrives later via the main loop's "reachable" handler.
m.serverSwitchEntry = entry
task = CreateObject("roSGNode", "ServerReachableTask")
task.baseUrl = entry.baseUrl
task.observeField("reachable", m.port)
m.serverReachableTask = task
task.control = "RUN"
end sub
' Continuation of performServerSwitch once the off-thread reachability probe returns. On an
' unreachable target, surface the error and discard the deep link. On success, reuse the
' Change-Server machinery (resetRouter -> point at the new server -> SignOut -> reenterLogin);
' the stashed deep link survives the teardown and replays once the new server's login completes.
sub onServerProbeDone(reachable as boolean)
entry = m.serverSwitchEntry
m.serverSwitchEntry = invalid
if not isValid(entry) then return
if not reachable
stopLoadingSpinner()
m.global.AuthManager.stashedDeepLink = invalid
displayToast(Substitute(translate(translationKeys.MessageCouldNotReachServer), entry.name), "error")
return
end if
' Tell the user what's happening — the switch tears down the session and re-logs into the
' new server, so Home re-loading from scratch (the "everything's changing" moment) has context.
displayToast(Substitute(translate(translationKeys.MessageSwitchingToServer), entry.name), "info")
m.scene.callFunc("resetRouter")
setSetting("server", entry.originalUrl)
SignOut(false)
reenterLogin()
end sub