source_replayRoute.bs

' ===========================================================================
' 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