From 72c03262d8321cc892b0f2e7b6fb7b410bd61958 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 18 Oct 2026 23:17:59 -0400 Subject: [PATCH 1/3] Show a Time's up screen when a user's Jellyfin access schedule ends. Co-Authored-By: Claude Sonnet 5.5 --- .../accessSchedule/AccessScheduleTask.bs | 28 ++++ .../accessSchedule/AccessScheduleTask.xml | 10 ++ .../accessSchedule/AccessScheduleWatcher.bs | 124 ++++++++++++++++++ .../accessSchedule/AccessScheduleWatcher.xml | 20 +++ components/accessSchedule/TimesUp.bs | 121 +++++++++++++++++ components/accessSchedule/TimesUp.xml | 26 ++++ components/video/VideoPlayerView.bs | 4 + locale/en_US/translations.ts | 35 +++++ source/Main.bs | 18 +++ source/ShowScenes.bs | 37 ++++++ source/api/baserequest.bs | 14 ++ source/utils/accessSchedule.bs | 108 +++++++++++++++ source/utils/globals.bs | 3 + test/access-schedule.bs | 74 +++++++++++ test/review-regressions.bs | 3 + test/server-compatibility.bs | 1 + test/server-compatibility.mjs | 4 +- 17 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 components/accessSchedule/AccessScheduleTask.bs create mode 100644 components/accessSchedule/AccessScheduleTask.xml create mode 100644 components/accessSchedule/AccessScheduleWatcher.bs create mode 100644 components/accessSchedule/AccessScheduleWatcher.xml create mode 100644 components/accessSchedule/TimesUp.bs create mode 100644 components/accessSchedule/TimesUp.xml create mode 100644 source/utils/accessSchedule.bs create mode 100644 test/access-schedule.bs diff --git a/components/accessSchedule/AccessScheduleTask.bs b/components/accessSchedule/AccessScheduleTask.bs new file mode 100644 index 000000000..6f9b5f3fb --- /dev/null +++ b/components/accessSchedule/AccessScheduleTask.bs @@ -0,0 +1,28 @@ +import "pkg:/source/api/baserequest.bs" +import "pkg:/source/utils/accessSchedule.bs" +import "pkg:/source/utils/misc.bs" + +sub init() + m.top.functionName = "readSchedule" +end sub + +sub readSchedule() + m.top.result = loadSchedule(m.top.userId) +end sub + +function loadSchedule(userId as string) as object + failed = { ok: false, exempt: false, entries: [] } + if not isValidAndNotEmpty(userId) then return failed + + response = requestResponse(APIRequest("Users/" + userId), "GET") + if response.status <> 200 or type(response.data) <> "roAssociativeArray" then return failed + + policy = response.data.Policy + if type(policy) <> "roAssociativeArray" then return failed + + return { + ok: true, + exempt: policy.IsAdministrator = true, + entries: accessSchedule.Read(policy) + } +end function diff --git a/components/accessSchedule/AccessScheduleTask.xml b/components/accessSchedule/AccessScheduleTask.xml new file mode 100644 index 000000000..78aee1b96 --- /dev/null +++ b/components/accessSchedule/AccessScheduleTask.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/components/accessSchedule/AccessScheduleWatcher.bs b/components/accessSchedule/AccessScheduleWatcher.bs new file mode 100644 index 000000000..228adcbca --- /dev/null +++ b/components/accessSchedule/AccessScheduleWatcher.bs @@ -0,0 +1,124 @@ +import "pkg:/source/utils/accessSchedule.bs" +import "pkg:/source/utils/misc.bs" + +' How long to wait between looks when no opening or closing is nearer, and before asking again +' after a look that failed +const POLL_SECONDS = 60 +const RETRY_SECONDS = 15 +' The least time between looks prompted by the server turning a request away, which can come in +' bursts +const REFUSAL_SECONDS = 5 + +sub init() + m.timer = m.top.findNode("timer") + m.timer.observeField("fire", "onTimer") + m.entries = [] + m.exempt = false + m.task = invalid + m.lookAgain = false + m.lastRefusalLook = 0 + m.clock = CreateObject("roTimespan") + m.top.blocked = false + + if m.global.hasField("accessRefused") then m.global.observeField("accessRefused", "onRefused") +end sub + +sub onUserChange() + m.timer.control = "stop" + ' A look still under way for the previous user must not answer for the next + cancelTask() + m.entries = [] + m.exempt = false + m.lookAgain = false + setBlocked(false, invalid) + if m.top.userId <> "" then look() +end sub + +sub recheck() + if m.top.userId <> "" then look() +end sub + +sub onRefused() + if m.top.userId = "" then return + now = m.clock.TotalMilliseconds() + if m.lastRefusalLook <> 0 and now - m.lastRefusalLook < REFUSAL_SECONDS * 1000 then return + m.lastRefusalLook = now + look() +end sub + +sub onTimer() + look() +end sub + +sub cancelTask() + if m.task <> invalid + m.task.unobserveField("result") + m.task.control = "stop" + m.task = invalid + end if +end sub + +' Reads the schedule again. One look at a time: a request that arrives while one is under way +' asks for another when it ends. +sub look() + m.timer.control = "stop" + if m.task <> invalid + m.lookAgain = true + return + end if + m.task = CreateObject("roSGNode", "AccessScheduleTask") + m.task.userId = m.top.userId + m.task.observeField("result", "onResult") + m.task.control = "run" +end sub + +sub onResult(event as object) + result = event.getData() + m.task.unobserveField("result") + m.task = invalid + + ' A schedule that couldn't be read leaves the last one in place + if result.ok + m.entries = result.entries + m.exempt = result.exempt + end if + apply(not result.ok) + + if m.lookAgain + m.lookAgain = false + look() + end if +end sub + +sub apply(failed as boolean) + now = CreateObject("roDateTime") + now.ToLocalTime() + day = now.GetDayOfWeek() + secondOfDay = now.GetHours() * 3600 + now.GetMinutes() * 60 + now.GetSeconds() + + allowed = m.exempt or accessSchedule.IsAllowed(m.entries, day, secondOfDay) + if allowed + setBlocked(false, invalid) + else + setBlocked(true, accessSchedule.NextAccess(m.entries, day, secondOfDay)) + end if + + delay = POLL_SECONDS + if failed then delay = RETRY_SECONDS + if not m.exempt + boundary = accessSchedule.SecondsToBoundary(m.entries, day, secondOfDay) + if boundary <> invalid and boundary < delay then delay = boundary + end if + if delay < 1 then delay = 1 + m.timer.duration = delay + m.timer.control = "start" +end sub + +' Told only when the answer changes, so the app stops playback once for each time the user is +' turned away +sub setBlocked(blocked as boolean, upcoming as dynamic) + if blocked = m.top.blocked then return + m.top.next = upcoming + m.top.blocked = blocked + m.top.accessStatus = blocked +end sub diff --git a/components/accessSchedule/AccessScheduleWatcher.xml b/components/accessSchedule/AccessScheduleWatcher.xml new file mode 100644 index 000000000..e32309182 --- /dev/null +++ b/components/accessSchedule/AccessScheduleWatcher.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/components/accessSchedule/TimesUp.bs b/components/accessSchedule/TimesUp.bs new file mode 100644 index 000000000..087454389 --- /dev/null +++ b/components/accessSchedule/TimesUp.bs @@ -0,0 +1,121 @@ +import "pkg:/source/utils/themeApply.bs" + +const CARD_WIDTH = 640 +const CARD_PAD = 32 +const GAP = 14 +const BUTTON_WIDTH = 260 +const BUTTON_HEIGHT = 64 + +sub init() + m.top.optionsAvailable = false + ' The screen owns the whole display, so the nav bar is hidden + m.top.overhangVisible = false + m.card = m.top.findNode("card") + m.cardFill = m.top.findNode("cardFill") + m.cardBorder = m.top.findNode("cardBorder") + m.title = m.top.findNode("title") + m.message = m.top.findNode("message") + m.nextLabel = m.top.findNode("next") + m.switchUser = m.top.findNode("switchUser") + + width = CARD_WIDTH - CARD_PAD * 2 + m.title.font.size = 48 + m.title.width = width + m.title.text = tr("Time's up") + m.message.font.size = 28 + m.message.width = width + m.message.text = tr("This account isn't available right now.") + m.nextLabel.font.size = 28 + m.nextLabel.width = width + + m.switchUser.text = tr("Switch User") + m.switchUser.width = BUTTON_WIDTH + m.switchUser.height = BUTTON_HEIGHT + m.switchUser.observeField("buttonSelected", "onSwitchUser") + + themeApply.ApplyToItem(m.top) + layoutCard() +end sub + +sub OnScreenShown(_isReturning = false as boolean) + layoutCard() + m.switchUser.setFocus(true) +end sub + +sub onNextChange() + m.nextLabel.text = describeNext(m.top.next) + layoutCard() +end sub + +' When the user can come back, in words. Empty when no entry opens soon. +function describeNext(opening as dynamic) as string + if type(opening) <> "roAssociativeArray" then return "" + time = timeOfDayText(opening.secondOfDay) + if opening.dayOffset = 0 then return Substitute(tr("You can use it again today at {0}."), time) + if opening.dayOffset = 1 then return Substitute(tr("You can use it again tomorrow at {0}."), time) + days = [tr("Sunday"), tr("Monday"), tr("Tuesday"), tr("Wednesday"), tr("Thursday"), tr("Friday"), tr("Saturday")] + ' A week out is the same weekday as today, which would read as today, so the date is named too + if opening.dayOffset >= 7 + months = [tr("Jan"), tr("Feb"), tr("Mar"), tr("Apr"), tr("May"), tr("Jun"), tr("Jul"), tr("Aug"), tr("Sep"), tr("Oct"), tr("Nov"), tr("Dec")] + date = CreateObject("roDateTime") + date.FromSeconds(date.AsSeconds() + opening.seconds) + date.ToLocalTime() + return Substitute(tr("You can use it again on {0}, {1} {2} at {3}."), days[opening.day], months[date.GetMonth() - 1], date.GetDayOfMonth().ToStr(), time) + end if + return Substitute(tr("You can use it again on {0} at {1}."), days[opening.day], time) +end function + +' Hours and minutes in the clock style the device is set to +function timeOfDayText(secondOfDay as integer) as string + hours = secondOfDay \ 3600 + minutes = (secondOfDay mod 3600) \ 60 + mm = minutes.ToStr() + if minutes < 10 then mm = "0" + mm + if CreateObject("roDeviceInfo").GetClockFormat() = "12h" + suffix = "AM" + if hours >= 12 then suffix = "PM" + shown = hours mod 12 + if shown = 0 then shown = 12 + return shown.ToStr() + ":" + mm + " " + suffix + end if + hh = hours.ToStr() + if hours < 10 then hh = "0" + hh + return hh + ":" + mm +end function + +' The line about the next opening only takes room while it has something to say +sub layoutCard() + y = CARD_PAD + + m.title.translation = [CARD_PAD, y] + y += m.title.boundingRect().height + GAP + + m.message.translation = [CARD_PAD, y] + y += m.message.boundingRect().height + + m.nextLabel.visible = m.nextLabel.text <> "" + if m.nextLabel.visible + y += GAP + m.nextLabel.translation = [CARD_PAD, y] + y += m.nextLabel.boundingRect().height + end if + + y += CARD_PAD + m.switchUser.translation = [(CARD_WIDTH - BUTTON_WIDTH) / 2, y] + y += BUTTON_HEIGHT + CARD_PAD + + for each layer in [m.cardFill, m.cardBorder] + layer.width = CARD_WIDTH + layer.height = y + end for + m.card.translation = [(1920 - CARD_WIDTH) / 2, (1080 - y) / 2] +end sub + +sub onSwitchUser() + m.top.switchUser = true +end sub + +' Nothing gets past the screen to the one behind it, and Back does not leave it +function onKeyEvent(key as string, press as boolean) as boolean + return true +end function diff --git a/components/accessSchedule/TimesUp.xml b/components/accessSchedule/TimesUp.xml new file mode 100644 index 000000000..7053b1ae2 --- /dev/null +++ b/components/accessSchedule/TimesUp.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/components/video/VideoPlayerView.bs b/components/video/VideoPlayerView.bs index 8b3e9eb90..1f7b7edcf 100644 --- a/components/video/VideoPlayerView.bs +++ b/components/video/VideoPlayerView.bs @@ -1951,6 +1951,10 @@ sub onState() errorInfo = FormatJson(m.top.errorInfo ?? {}) diagnostics.Playback(`Player error ${m.top.errorCode} ${m.top.errorMsg}, ${m.top.errorStr}, ${errorInfo}, during ${m.streamSummary}`, diagnostics.LEVEL_WARN) + ' A user whose hours have just ended is refused the stream too, and the player does not say + ' why, so a failure is a cue to look at the schedule + noteAccessRefused() + if isValid(m.liveChannel) onLiveChannelError() else if isValid(m.channelSwitch) diff --git a/locale/en_US/translations.ts b/locale/en_US/translations.ts index 84ad9e625..5e8b2f130 100644 --- a/locale/en_US/translations.ts +++ b/locale/en_US/translations.ts @@ -7125,5 +7125,40 @@ This account can't sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator. Shown when the server refuses a sign in because the account can't be used right now, for example outside its access schedule + + Time's up + Time's up + Title of the screen shown when the signed in user's access schedule has closed + + + This account isn't available right now. + This account isn't available right now. + Shown on the Time's up screen + + + You can use it again today at {0}. + You can use it again today at {0}. + Shown on the Time's up screen; {0} is a time of day + + + You can use it again tomorrow at {0}. + You can use it again tomorrow at {0}. + Shown on the Time's up screen; {0} is a time of day + + + You can use it again on {0} at {1}. + You can use it again on {0} at {1}. + Shown on the Time's up screen; {0} is a day of the week and {1} a time of day + + + Switch User + Switch User + Button on the Time's up screen that opens the list of users + + + You can use it again on {0}, {1} {2} at {3}. + You can use it again on {0}, {1} {2} at {3}. + Shown on the Time's up screen when the next opening is a week or more away; {0} is a day of the week, {1} a month, {2} the day of the month and {3} a time of day + diff --git a/source/Main.bs b/source/Main.bs index 757ad0575..7b63858d8 100644 --- a/source/Main.bs +++ b/source/Main.bs @@ -76,8 +76,12 @@ sub Main (args as dynamic) as void audioMiniPlayer.callFunc("setup") end if + m.accessWatcher = CreateObject("roSGNode", "AccessScheduleWatcher") + m.accessWatcher.observeField("accessStatus", m.port) + app_start: stopAchievementsPoll() + m.accessWatcher.userId = "" ' Hide both nav elements before login to prevent flash/overlap overhang.visible = false @@ -90,6 +94,9 @@ sub Main (args as dynamic) as void parentalControls.Activate() if not RunPinGate() then return + ' Jellyfin turns a user away outside their access schedule, so the app says so when it closes + if not isEmbyServer() then m.accessWatcher.userId = chainLookupReturn(m.global.session, "user.id", "") + 'Pull user configuration from server and apply settings 'The server reports no language preference as an empty string. The client uses "anyLanguage" subtitleMode = chainLookupReturn(m.global.session, "user.Configuration.SubtitleMode", "Default") @@ -277,6 +284,17 @@ sub Main (args as dynamic) as void onButtonSelectedEvent(msg) else if isNodeEvent(msg, "content") onContentEvent(msg) + else if isNodeEvent(msg, "accessStatus") + ' The event may be an old one by now, so the watcher is asked how things stand + if m.accessWatcher.blocked + timesUpResult = RunTimesUp(m.accessWatcher) + if timesUpResult = "exit" then return + ' The account stays saved, so it can be picked again from the list + if timesUpResult = "switch" then SignOut(false) + setUserColors() + sceneManager.callFunc("clearScenes") + goto app_start + end if else if isNodeEvent(msg, "accountAction") accountDialog.visible = false action = msg.getData() diff --git a/source/ShowScenes.bs b/source/ShowScenes.bs index 802ab5008..b9de2dae0 100644 --- a/source/ShowScenes.bs +++ b/source/ShowScenes.bs @@ -594,6 +594,43 @@ function RunPinGate() as boolean return true end function +' Covers the app while the signed in user's access schedule is closed, after stopping what was +' playing. Returns "switch" to pick another user, "open" when the hours open again, or "exit" when +' the app is closed from under it. +function RunTimesUp(watcher as object) as string + m.global.queueManager.callFunc("clear") + m.global.audioPlayer.control = "stop" + active = m.global.sceneManager.callFunc("getActiveScene") + if isValid(active) and active.isSubType("VideoPlayerView") then active.control = "stop" + + stopLoadingSpinner() + screen = CreateObject("roSGNode", "TimesUp") + screen.next = watcher.next + m.global.sceneManager.callFunc("clearScenes") + m.global.sceneManager.callFunc("pushScene", screen) + port = CreateObject("roMessagePort") + screen.observeField("switchUser", port) + watcher.observeField("accessStatus", port) + + result = "exit" + while true + msg = wait(0, port) + if type(msg) = "roSGScreenEvent" and msg.isScreenClosed() + exit while + else if isNodeEvent(msg, "switchUser") + result = "switch" + exit while + else if isNodeEvent(msg, "accessStatus") and not watcher.blocked + result = "open" + exit while + end if + end while + + watcher.unobserveField("accessStatus") + watcher.observeField("accessStatus", m.port) + return result +end function + function CreateHomeGroup() ' Main screen after logging in. Shows the user's libraries group = CreateObject("roSGNode", "Home") diff --git a/source/api/baserequest.bs b/source/api/baserequest.bs index 1c4c129ef..7eb3d2ea0 100644 --- a/source/api/baserequest.bs +++ b/source/api/baserequest.bs @@ -119,6 +119,17 @@ function APIRequest(url as string, params = {} as object) as dynamic return req end function +' Lets the access schedule watcher know the server turned a request away with a 403. A user whose +' hours have just ended is refused everything, and nothing else would tell the app. +sub noteAccessRefused() + if type(m.global) <> "roSGNode" or not m.global.hasField("accessRefused") then return + m.global.accessRefused = m.global.accessRefused + 1 +end sub + +sub noteRefusalIn(req as object) + if InStr(1, req.GetFailureReason(), "403") > 0 then noteAccessRefused() +end sub + ' APIRequest hands back invalid when there is no server to build a URL against, ' so callers can reach here before a server has been picked function getJson(req) @@ -128,12 +139,14 @@ function getJson(req) data = req.GetToString() if data = invalid or data = "" + noteRefusalIn(req) diagnostics.EndTrace(started, "no body", diagnostics.LEVEL_WARN) return invalid end if json = ParseJson(data) if json = invalid + noteRefusalIn(req) diagnostics.EndTrace(started, "unparseable", diagnostics.LEVEL_WARN) return invalid end if @@ -666,6 +679,7 @@ function requestResponse(req as object, method = "GET" as string, body = "" as s event = wait(100, port) if type(event) = "roUrlEvent" code = event.GetResponseCode() + if code = 403 then noteAccessRefused() data = invalid answer = event.GetString() ' A 204 has no body, and parsing nothing logs an error diff --git a/source/utils/accessSchedule.bs b/source/utils/accessSchedule.bs new file mode 100644 index 000000000..4d9d09322 --- /dev/null +++ b/source/utils/accessSchedule.bs @@ -0,0 +1,108 @@ +' A user's access schedule as Jellyfin keeps it: the days an entry covers and the hours of those +' days the user may use the server. Jellyfin applies it by the rules below, so the app does too, +' to say so when the time is up instead of letting everything start failing. +' +' Times are given as a day of the week (0 is Sunday) and the second of that day, in the server's +' local time, which is taken to be this device's. + +namespace accessSchedule + + const SECONDS_PER_DAY = 86400 + const LOOK_AHEAD_DAYS = 8 + + ' The entries of a policy as plain { days, start, end } records, with the days as the list of + ' weekday numbers the entry covers and the hours as seconds into the day. Anything that isn't + ' an entry is left out, and so is a day name the server doesn't use. + function Read(policy as dynamic) as object + entries = [] + if type(policy) <> "roAssociativeArray" then return entries + raw = policy.AccessSchedules + if type(raw) <> "roArray" then return entries + for each item in raw + if type(item) = "roAssociativeArray" + days = accessSchedule.DaysOf(item.DayOfWeek) + entries.push({ + days: days, + start: accessSchedule.HourToSeconds(item.StartHour), + end: accessSchedule.HourToSeconds(item.EndHour) + }) + end if + end for + return entries + end function + + function HourToSeconds(hours as dynamic) as integer + if type(hours) <> "roInt" and type(hours) <> "roFloat" and type(hours) <> "roDouble" and type(hours) <> "Integer" and type(hours) <> "Float" and type(hours) <> "Double" then return 0 + return Cint(hours * 3600) + end function + + function DaysOf(name as dynamic) as object + if type(name) <> "roString" and type(name) <> "String" then return [] + if name = "Everyday" then return [0, 1, 2, 3, 4, 5, 6] + if name = "Weekday" then return [1, 2, 3, 4, 5] + if name = "Weekend" then return [0, 6] + names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + for index = 0 to 6 + if names[index] = name then return [index] + end for + return [] + end function + + function Covers(entry as object, day as integer) as boolean + for each covered in entry.days + if covered = day then return true + end for + return false + end function + + ' Whether the user may use the server at that moment: an entry allows it when it covers the + ' day and the time of day is between its start and its end, both included. A user with no + ' entries is always allowed. Administrators are exempt, which is for the caller to check. + function IsAllowed(entries as object, day as integer, secondOfDay as integer) as boolean + if entries.Count() = 0 then return true + for each entry in entries + if accessSchedule.Covers(entry, day) and secondOfDay >= entry.start and secondOfDay <= entry.end then return true + end for + return false + end function + + ' When the user is next let in, as { seconds, dayOffset, secondOfDay, day }: how long from now, + ' how many days ahead that day is, and the time of day it opens. Invalid when they are allowed + ' now or no entry opens in the next week. + function NextAccess(entries as object, day as integer, secondOfDay as integer) as dynamic + if accessSchedule.IsAllowed(entries, day, secondOfDay) then return invalid + best = invalid + for offset = 0 to accessSchedule.LOOK_AHEAD_DAYS - 1 + target = (day + offset) mod 7 + for each entry in entries + if accessSchedule.Covers(entry, target) + seconds = offset * accessSchedule.SECONDS_PER_DAY + entry.start - secondOfDay + if seconds > 0 and (best = invalid or seconds < best.seconds) + best = { seconds: seconds, dayOffset: offset, secondOfDay: entry.start, day: target } + end if + end if + end for + end for + return best + end function + + ' How long until the answer of IsAllowed could change, in seconds: every start, and the second + ' after every end, since the end itself is still allowed. Invalid when no entry has one in the + ' next week. + function SecondsToBoundary(entries as object, day as integer, secondOfDay as integer) as dynamic + best = invalid + for offset = 0 to accessSchedule.LOOK_AHEAD_DAYS - 1 + target = (day + offset) mod 7 + for each entry in entries + if accessSchedule.Covers(entry, target) + for each moment in [entry.start, entry.end + 1] + seconds = offset * accessSchedule.SECONDS_PER_DAY + moment - secondOfDay + if seconds > 0 and (best = invalid or seconds < best) then best = seconds + end for + end if + end for + end for + return best + end function + +end namespace diff --git a/source/utils/globals.bs b/source/utils/globals.bs index d348729fb..ad58d1b8e 100644 --- a/source/utils/globals.bs +++ b/source/utils/globals.bs @@ -44,6 +44,9 @@ sub setConstants() ' What the friends screens ask of those reads: one now, which chat is open, or whether message ' banners are wanted globals.addField("socialRequest", "assocarray", true) + ' Counts the times the server turned a request away with a 403, which is what a user sees when + ' their access schedule closes while they are using the app + globals.addField("accessRefused", "integer", true) end sub ' Save information from roAppInfo to m.global.app diff --git a/test/access-schedule.bs b/test/access-schedule.bs new file mode 100644 index 000000000..68d87438b --- /dev/null +++ b/test/access-schedule.bs @@ -0,0 +1,74 @@ +sub testAccessSchedule() + HOUR = 3600 + MON = 1 + TUE = 2 + FRI = 5 + SAT = 6 + SUN = 0 + entry = function(name, startHour, endHour) + return { DayOfWeek: name, StartHour: startHour, EndHour: endHour } + end function + read = function(entries) + return accessSchedule.Read({ AccessSchedules: entries }) + end function + + window = read([entry("Monday", 8, 20)]) + assertEqual(accessSchedule.IsAllowed([], MON, 3 * HOUR), true, "A user with no entries is always allowed") + assertEqual(accessSchedule.IsAllowed(window, MON, 12 * HOUR), true, "Allowed inside the hours") + assertEqual(accessSchedule.IsAllowed(window, MON, 8 * HOUR), true, "The start itself is allowed") + assertEqual(accessSchedule.IsAllowed(window, MON, 20 * HOUR), true, "The end itself is allowed") + assertEqual(accessSchedule.IsAllowed(window, MON, 8 * HOUR - 1), false, "Not before the start") + assertEqual(accessSchedule.IsAllowed(window, MON, 20 * HOUR + 1), false, "Not after the end") + assertEqual(accessSchedule.IsAllowed(window, TUE, 12 * HOUR), false, "Not on another day") + + halves = read([entry("Monday", 8.5, 17.5)]) + assertEqual(accessSchedule.IsAllowed(halves, MON, 8 * HOUR + 1799), false, "Half hours: before 8:30") + assertEqual(accessSchedule.IsAllowed(halves, MON, 8 * HOUR + 1800), true, "Half hours: at 8:30") + assertEqual(accessSchedule.IsAllowed(halves, MON, 17 * HOUR + 1800), true, "Half hours: at 17:30") + assertEqual(accessSchedule.IsAllowed(halves, MON, 17 * HOUR + 1801), false, "Half hours: after 17:30") + + for each day in [MON, FRI, SAT, SUN] + assertEqual(accessSchedule.IsAllowed(read([entry("Everyday", 8, 20)]), day, 12 * HOUR), true, "Everyday covers every day") + end for + assertEqual(accessSchedule.IsAllowed(read([entry("Weekday", 8, 20)]), FRI, 12 * HOUR), true, "Weekday covers Friday") + assertEqual(accessSchedule.IsAllowed(read([entry("Weekday", 8, 20)]), SAT, 12 * HOUR), false, "Weekday leaves out Saturday") + assertEqual(accessSchedule.IsAllowed(read([entry("Weekend", 8, 20)]), SUN, 12 * HOUR), true, "Weekend covers Sunday") + assertEqual(accessSchedule.IsAllowed(read([entry("Weekend", 8, 20)]), MON, 12 * HOUR), false, "Weekend leaves out Monday") + + two = read([entry("Monday", 8, 10), entry("Monday", 18, 20)]) + assertEqual(accessSchedule.IsAllowed(two, MON, 9 * HOUR), true, "Any one entry is enough") + assertEqual(accessSchedule.IsAllowed(two, MON, 12 * HOUR), false, "Between two entries is closed") + + toMidnight = read([entry("Monday", 18, 24)]) + assertEqual(accessSchedule.IsAllowed(toMidnight, MON, 24 * HOUR - 1), true, "An end of 24 runs to the end of the day") + assertEqual(accessSchedule.IsAllowed(read([entry("Someday", 0, 24)]), MON, 12 * HOUR), false, "A day name the server doesn't use covers nothing") + + assertEqual(accessSchedule.Read(invalid).Count(), 0, "No policy reads as no entries") + assertEqual(accessSchedule.Read({}).Count(), 0, "No schedules read as no entries") + assertEqual(accessSchedule.Read({ AccessSchedules: "nonsense" }).Count(), 0, "Nonsense reads as no entries") + assertEqual(accessSchedule.Read({ AccessSchedules: [invalid, 5, "x", entry("Monday", 1, 2)] }).Count(), 1, "What is not an entry is left out") + + ' When the user is next let in + assertEqual(accessSchedule.NextAccess(window, MON, 12 * HOUR), invalid, "Nothing to wait for while allowed") + sameDay = accessSchedule.NextAccess(window, MON, 7 * HOUR) + assertEqual(sameDay.seconds, HOUR, "A later start the same day") + assertEqual(sameDay.dayOffset, 0, "Opens today") + assertEqual(sameDay.secondOfDay, 8 * HOUR, "Opens at 8:00") + nextDay = accessSchedule.NextAccess(read([entry("Monday", 8, 20), entry("Tuesday", 9, 20)]), MON, 21 * HOUR) + assertEqual(nextDay.dayOffset, 1, "The next day an entry covers") + assertEqual(nextDay.day, TUE, "Opens on Tuesday") + assertEqual(nextDay.seconds, 12 * HOUR, "From 21:00 to 9:00 the next day") + weekend = accessSchedule.NextAccess(read([entry("Weekday", 8, 20)]), FRI, 21 * HOUR) + assertEqual(weekend.dayOffset, 3, "The weekend is skipped") + assertEqual(weekend.day, MON, "Opens on Monday") + nextWeek = accessSchedule.NextAccess(window, MON, 21 * HOUR) + assertEqual(nextWeek.dayOffset, 7, "An entry that only covers today opens a week on") + earliest = accessSchedule.NextAccess(read([entry("Monday", 15, 20), entry("Monday", 9, 10)]), MON, 6 * HOUR) + assertEqual(earliest.secondOfDay, 9 * HOUR, "The earliest of several entries") + assertEqual(accessSchedule.NextAccess(read([entry("Someday", 8, 20)]), MON, 6 * HOUR), invalid, "No entry opens, so no time to name") + + ' When the answer could change + assertEqual(accessSchedule.SecondsToBoundary(window, MON, 12 * HOUR), 8 * HOUR + 1, "The second after the end while allowed") + assertEqual(accessSchedule.SecondsToBoundary(read([entry("Monday", 8, 20), entry("Tuesday", 8, 20)]), MON, 21 * HOUR), 11 * HOUR, "The next start while closed") + assertEqual(accessSchedule.SecondsToBoundary([], MON, 12 * HOUR), invalid, "No entries have no boundary") +end sub diff --git a/test/review-regressions.bs b/test/review-regressions.bs index 989183341..6ba1eec91 100644 --- a/test/review-regressions.bs +++ b/test/review-regressions.bs @@ -16,6 +16,9 @@ function testUrlTransfer() as object end sub, GetToString: function() as string return m.fixtureBody + end function, + GetFailureReason: function() as string + return "" end function } end function diff --git a/test/server-compatibility.bs b/test/server-compatibility.bs index 0d825dfa5..0e6ef3653 100644 --- a/test/server-compatibility.bs +++ b/test/server-compatibility.bs @@ -134,6 +134,7 @@ sub Main() testAccentFolding() testQuickConnectUrl() testSignInRefused() + testAccessSchedule() testLogRedaction() testYoutubeTrailer() testPlaybackFallback() diff --git a/test/server-compatibility.mjs b/test/server-compatibility.mjs index 236af5f91..cc7d8ad8d 100644 --- a/test/server-compatibility.mjs +++ b/test/server-compatibility.mjs @@ -76,6 +76,7 @@ const selections = { 'source/utils/homePreview.bs': null, 'source/utils/remoteSubtitles.bs': null, 'source/utils/quickConnectUrl.bs': null, + 'source/utils/accessSchedule.bs': null, 'components/extras/collectionLookup.bs': ['findParents', 'namedCollection', 'namedFirst', 'membership'], 'components/extras/LoadExtrasTask.bs': ['personRoleIn'], 'components/details/detailTrackHost.bs': ['SetUpVideoOptions', 'SetUpAudioOptions', 'detailServerSubtitleIndex', 'audioOrdinal', 'audioStreamPosition'], @@ -106,7 +107,7 @@ const selections = { 'components/account/AccountDialog.bs': ['accountImageURL'], 'components/config/SigninScene.bs': ['checkQuickConnectEnabled'], 'source/utils/parsedUrl.bs': ['ParsedUrl', '__ParsedUrl_ToString'], - 'source/api/baserequest.bs': ['buildParams', 'buildServerURL', 'buildURL', 'buildURLForServer', 'buildAuthHeader', 'buildAuthHeaderForServer', 'APIRequest', 'APIRequestForServer', 'authRequest', 'authRequestForServer', 'setCertificateAuthority', 'getJson', 'postPlaybackInfo', 'requestCanceled'], + 'source/api/baserequest.bs': ['buildParams', 'buildServerURL', 'buildURL', 'buildURLForServer', 'buildAuthHeader', 'buildAuthHeaderForServer', 'APIRequest', 'APIRequestForServer', 'authRequest', 'authRequestForServer', 'setCertificateAuthority', 'getJson', 'noteAccessRefused', 'noteRefusalIn', 'postPlaybackInfo', 'requestCanceled'], }; let source = 'namespace bslib\n' + bslib.source + '\nend namespace\n' + await readFile('source/enums/String.bs', 'utf8'); for (const [file, names] of Object.entries(selections)) { @@ -132,6 +133,7 @@ source += '\n' + await readFile('test/review-regressions.bs', 'utf8'); source += '\n' + await readFile('test/accent-folding.bs', 'utf8'); source += '\n' + await readFile('test/quick-connect-url.bs', 'utf8'); source += '\n' + await readFile('test/sign-in-refused.bs', 'utf8'); +source += '\n' + await readFile('test/access-schedule.bs', 'utf8'); source += '\n' + await readFile('test/log-redaction.bs', 'utf8'); source += '\n' + await readFile('test/youtube-trailer.bs', 'utf8'); source += '\n' + await readFile('test/playback-fallback.bs', 'utf8'); From 132f83d2cf2c1d570d893da1356efe375b8781fa Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 19 Oct 2026 09:09:21 -0400 Subject: [PATCH 2/3] Address PR review feedback (#313) Set the Time's up label sizes after the theme is applied, and keep the shown opening time current while the schedule stays closed. Co-Authored-By: Claude Sonnet 5.5 --- components/accessSchedule/AccessScheduleWatcher.bs | 7 +++++-- components/accessSchedule/TimesUp.bs | 3 ++- source/ShowScenes.bs | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/components/accessSchedule/AccessScheduleWatcher.bs b/components/accessSchedule/AccessScheduleWatcher.bs index 228adcbca..a1f74955e 100644 --- a/components/accessSchedule/AccessScheduleWatcher.bs +++ b/components/accessSchedule/AccessScheduleWatcher.bs @@ -115,9 +115,12 @@ sub apply(failed as boolean) end sub ' Told only when the answer changes, so the app stops playback once for each time the user is -' turned away +' turned away. The next opening is kept current while blocked, since a changed schedule moves it. sub setBlocked(blocked as boolean, upcoming as dynamic) - if blocked = m.top.blocked then return + if blocked = m.top.blocked + if blocked then m.top.next = upcoming + return + end if m.top.next = upcoming m.top.blocked = blocked m.top.accessStatus = blocked diff --git a/components/accessSchedule/TimesUp.bs b/components/accessSchedule/TimesUp.bs index 087454389..1c517cb01 100644 --- a/components/accessSchedule/TimesUp.bs +++ b/components/accessSchedule/TimesUp.bs @@ -18,6 +18,8 @@ sub init() m.nextLabel = m.top.findNode("next") m.switchUser = m.top.findNode("switchUser") + ' The theme replaces the labels' fonts, so the sizes are set after it + themeApply.ApplyToItem(m.top) width = CARD_WIDTH - CARD_PAD * 2 m.title.font.size = 48 m.title.width = width @@ -33,7 +35,6 @@ sub init() m.switchUser.height = BUTTON_HEIGHT m.switchUser.observeField("buttonSelected", "onSwitchUser") - themeApply.ApplyToItem(m.top) layoutCard() end sub diff --git a/source/ShowScenes.bs b/source/ShowScenes.bs index b9de2dae0..151701274 100644 --- a/source/ShowScenes.bs +++ b/source/ShowScenes.bs @@ -611,6 +611,7 @@ function RunTimesUp(watcher as object) as string port = CreateObject("roMessagePort") screen.observeField("switchUser", port) watcher.observeField("accessStatus", port) + watcher.observeField("next", port) result = "exit" while true @@ -623,10 +624,13 @@ function RunTimesUp(watcher as object) as string else if isNodeEvent(msg, "accessStatus") and not watcher.blocked result = "open" exit while + else if isNodeEvent(msg, "next") and watcher.blocked + screen.next = watcher.next end if end while watcher.unobserveField("accessStatus") + watcher.unobserveField("next") watcher.observeField("accessStatus", m.port) return result end function From d7f43e85ef7ebe9108cda8a5ee0ec7ffdd2d7f83 Mon Sep 17 00:00:00 2001 From: RadicalMuffinMan <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:37:42 -0400 Subject: [PATCH 3/3] updated #313 to let the server's answer to /Users/Me decide whether the access schedule has closed, keep the last answer when a check fails, stop polling administrators, check users without a schedule every 15 minutes and ignore refused requests while blocked --- .../accessSchedule/AccessScheduleTask.bs | 10 ++--- .../accessSchedule/AccessScheduleTask.xml | 5 ++- .../accessSchedule/AccessScheduleWatcher.bs | 41 +++++++++++-------- .../accessSchedule/AccessScheduleWatcher.xml | 6 +-- source/utils/accessSchedule.bs | 22 +++++++++- test/access-schedule.bs | 16 ++++++++ test/server-compatibility.mjs | 10 +++++ 7 files changed, 82 insertions(+), 28 deletions(-) diff --git a/components/accessSchedule/AccessScheduleTask.bs b/components/accessSchedule/AccessScheduleTask.bs index 6f9b5f3fb..566c2cc93 100644 --- a/components/accessSchedule/AccessScheduleTask.bs +++ b/components/accessSchedule/AccessScheduleTask.bs @@ -20,9 +20,9 @@ function loadSchedule(userId as string) as object policy = response.data.Policy if type(policy) <> "roAssociativeArray" then return failed - return { - ok: true, - exempt: policy.IsAdministrator = true, - entries: accessSchedule.Read(policy) - } + exempt = policy.IsAdministrator = true + entries = accessSchedule.Read(policy) + refused = invalid + if not exempt and entries.Count() > 0 then refused = accessSchedule.RefusedBy(requestResponse(APIRequest("Users/Me"), "GET").status) + return { ok: true, exempt: exempt, entries: entries, refused: refused } end function diff --git a/components/accessSchedule/AccessScheduleTask.xml b/components/accessSchedule/AccessScheduleTask.xml index 78aee1b96..450f954a2 100644 --- a/components/accessSchedule/AccessScheduleTask.xml +++ b/components/accessSchedule/AccessScheduleTask.xml @@ -1,10 +1,11 @@ + schedule, and /Users/Me says whether the server is turning the user away. --> - + diff --git a/components/accessSchedule/AccessScheduleWatcher.bs b/components/accessSchedule/AccessScheduleWatcher.bs index a1f74955e..c897a7e32 100644 --- a/components/accessSchedule/AccessScheduleWatcher.bs +++ b/components/accessSchedule/AccessScheduleWatcher.bs @@ -1,9 +1,10 @@ import "pkg:/source/utils/accessSchedule.bs" import "pkg:/source/utils/misc.bs" -' How long to wait between looks when no opening or closing is nearer, and before asking again -' after a look that failed +' How long to wait between looks when no opening or closing is nearer, for a user with a schedule +' and for one without, and before asking again after a look that failed const POLL_SECONDS = 60 +const IDLE_SECONDS = 900 const RETRY_SECONDS = 15 ' The least time between looks prompted by the server turning a request away, which can come in ' bursts @@ -39,7 +40,9 @@ sub recheck() end sub sub onRefused() - if m.top.userId = "" then return + ' While blocked every request is refused, the server check included, and a look under way + ' already brings the newest answer + if m.top.userId = "" or m.top.blocked or m.task <> invalid then return now = m.clock.TotalMilliseconds() if m.lastRefusalLook <> 0 and now - m.lastRefusalLook < REFUSAL_SECONDS * 1000 then return m.lastRefusalLook = now @@ -77,12 +80,14 @@ sub onResult(event as object) m.task.unobserveField("result") m.task = invalid - ' A schedule that couldn't be read leaves the last one in place if result.ok m.entries = result.entries m.exempt = result.exempt + apply(result.refused) + else + ' A schedule that couldn't be read leaves the last answer in place + startTimer(RETRY_SECONDS) end if - apply(not result.ok) if m.lookAgain m.lookAgain = false @@ -90,27 +95,31 @@ sub onResult(event as object) end if end sub -sub apply(failed as boolean) +sub apply(refused as dynamic) now = CreateObject("roDateTime") now.ToLocalTime() day = now.GetDayOfWeek() secondOfDay = now.GetHours() * 3600 + now.GetMinutes() * 60 + now.GetSeconds() - allowed = m.exempt or accessSchedule.IsAllowed(m.entries, day, secondOfDay) - if allowed + status = accessSchedule.Status(m.entries, refused, day, secondOfDay) + if m.exempt or not status.blocked setBlocked(false, invalid) else - setBlocked(true, accessSchedule.NextAccess(m.entries, day, secondOfDay)) + setBlocked(true, status.next) end if + ' An administrator has no schedule to enforce, so only a refused request looks again + if m.exempt then return delay = POLL_SECONDS - if failed then delay = RETRY_SECONDS - if not m.exempt - boundary = accessSchedule.SecondsToBoundary(m.entries, day, secondOfDay) - if boundary <> invalid and boundary < delay then delay = boundary - end if - if delay < 1 then delay = 1 - m.timer.duration = delay + if m.entries.Count() = 0 then delay = IDLE_SECONDS + boundary = accessSchedule.SecondsToBoundary(m.entries, day, secondOfDay) + if boundary <> invalid and boundary < delay then delay = boundary + startTimer(delay) +end sub + +sub startTimer(seconds as integer) + if seconds < 1 then seconds = 1 + m.timer.duration = seconds m.timer.control = "start" end sub diff --git a/components/accessSchedule/AccessScheduleWatcher.xml b/components/accessSchedule/AccessScheduleWatcher.xml index e32309182..f8d65097d 100644 --- a/components/accessSchedule/AccessScheduleWatcher.xml +++ b/components/accessSchedule/AccessScheduleWatcher.xml @@ -1,8 +1,8 @@ + outside their schedule, so nearly every request fails with it. For a user with a schedule the + server's answer decides. It looks again just before an opening or closing, when a request is + refused, and every minute for a user with a schedule or every 15 minutes for one without. --> diff --git a/source/utils/accessSchedule.bs b/source/utils/accessSchedule.bs index 4d9d09322..21aaea686 100644 --- a/source/utils/accessSchedule.bs +++ b/source/utils/accessSchedule.bs @@ -2,8 +2,9 @@ ' days the user may use the server. Jellyfin applies it by the rules below, so the app does too, ' to say so when the time is up instead of letting everything start failing. ' -' Times are given as a day of the week (0 is Sunday) and the second of that day, in the server's -' local time, which is taken to be this device's. +' Times are given as a day of the week (0 is Sunday) and the second of that day, on this device's +' clock. The server checks on its own clock, which can be in another time zone, so Status lets its +' answer decide when there is one. namespace accessSchedule @@ -66,6 +67,23 @@ namespace accessSchedule return false end function + ' What the server's answer to /Users/Me says: true when it turns the user away, false when it + ' lets them in, and invalid when it gave no answer + function RefusedBy(code as integer) as dynamic + if code = 403 then return true + if code >= 200 and code < 300 then return false + return invalid + end function + + ' Whether the user is turned away now and, while they are, when they're next let in. The + ' opening is worked out on this device's clock, so it's only named when that clock agrees. + function Status(entries as object, refused as dynamic, day as integer, secondOfDay as integer) as object + allowed = accessSchedule.IsAllowed(entries, day, secondOfDay) + if type(refused) = "roBoolean" or type(refused) = "Boolean" then allowed = not refused + if allowed then return { blocked: false, next: invalid } + return { blocked: true, next: accessSchedule.NextAccess(entries, day, secondOfDay) } + end function + ' When the user is next let in, as { seconds, dayOffset, secondOfDay, day }: how long from now, ' how many days ahead that day is, and the time of day it opens. Invalid when they are allowed ' now or no entry opens in the next week. diff --git a/test/access-schedule.bs b/test/access-schedule.bs index 68d87438b..d3505ba7d 100644 --- a/test/access-schedule.bs +++ b/test/access-schedule.bs @@ -71,4 +71,20 @@ sub testAccessSchedule() assertEqual(accessSchedule.SecondsToBoundary(window, MON, 12 * HOUR), 8 * HOUR + 1, "The second after the end while allowed") assertEqual(accessSchedule.SecondsToBoundary(read([entry("Monday", 8, 20), entry("Tuesday", 8, 20)]), MON, 21 * HOUR), 11 * HOUR, "The next start while closed") assertEqual(accessSchedule.SecondsToBoundary([], MON, 12 * HOUR), invalid, "No entries have no boundary") + + ' Whose answer decides + assertEqual(accessSchedule.RefusedBy(403), true, "A 403 from /Users/Me is the server turning the user away") + assertEqual(accessSchedule.RefusedBy(200), false, "An answer from /Users/Me lets the user in") + for each status in [0, 401, 500] + assertEqual(accessSchedule.RefusedBy(status), invalid, "Anything else is no answer") + end for + assertEqual(accessSchedule.Status(window, false, MON, 21 * HOUR).blocked, false, "The server letting the user in wins over this device's clock") + refusedHere = accessSchedule.Status(window, true, MON, 12 * HOUR) + assertEqual(refusedHere.blocked, true, "The server turning the user away wins over this device's clock") + assertEqual(refusedHere.next, invalid, "No opening is named while this device's clock says the hours are open") + assertEqual(accessSchedule.Status(window, true, MON, 7 * HOUR).next.secondOfDay, 8 * HOUR, "The opening is named when both clocks agree") + assertEqual(accessSchedule.Status(window, invalid, MON, 12 * HOUR).blocked, false, "Without an answer this device's clock decides, open") + noAnswer = accessSchedule.Status(window, invalid, MON, 21 * HOUR) + assertEqual(noAnswer.blocked, true, "Without an answer this device's clock decides, closed") + assertEqual(noAnswer.next.dayOffset, 7, "And names the next opening") end sub diff --git a/test/server-compatibility.mjs b/test/server-compatibility.mjs index cc7d8ad8d..c9bf9a4b9 100644 --- a/test/server-compatibility.mjs +++ b/test/server-compatibility.mjs @@ -808,3 +808,13 @@ assert.equal(cornerPng.readUInt32BE(16), 8, 'The corner piece is 8 wide'); assert.equal(cornerPng.readUInt32BE(20), 8, 'The corner piece is 8 high'); assert.ok(cornerPng.length < 1024, 'The corner piece is small'); process.stdout.write('PASS: home preview wiring (28 checks)\n'); + +// The server's answer to /Users/Me decides whether an access schedule has closed +const scheduleTask = await readFile('components/accessSchedule/AccessScheduleTask.bs', 'utf8'); +assert.match(scheduleTask, /if not exempt and entries\.Count\(\) > 0 then refused = accessSchedule\.RefusedBy\(requestResponse\(APIRequest\("Users\/Me"\), "GET"\)\.status\)/, 'Only a user with a schedule who isnt an administrator asks the server'); +const scheduleWatcher = await readFile('components/accessSchedule/AccessScheduleWatcher.bs', 'utf8'); +assert.match(scheduleWatcher, /status = accessSchedule\.Status\(m\.entries, refused, day, secondOfDay\)/, 'The server answer decides whether the user is blocked'); +assert.match(scheduleWatcher, /sub onRefused\(\)(?:\s+'[^\n]*)*\s+if m\.top\.userId = "" or m\.top\.blocked or m\.task <> invalid then return/, 'A refused request starts no look while blocked or while one is under way'); +assert.match(scheduleWatcher, /if result\.ok[^]*?else(?:\s+'[^\n]*)*\s+startTimer\(RETRY_SECONDS\)/, 'A failed read keeps the last answer and asks again'); +assert.match(scheduleWatcher, /if m\.exempt then return\s+delay = POLL_SECONDS\s+if m\.entries\.Count\(\) = 0 then delay = IDLE_SECONDS/, 'An administrator isnt polled and a user without a schedule is polled every 15 minutes'); +process.stdout.write('PASS: access schedule wiring (5 checks)\n');