prismcast 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/app.d.ts +88 -6
  2. package/dist/app.js +287 -154
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +6 -17
  5. package/dist/browser/cdp.js +6 -57
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.d.ts +106 -23
  8. package/dist/browser/index.js +292 -225
  9. package/dist/browser/index.js.map +1 -1
  10. package/dist/browser/precaching.d.ts +25 -11
  11. package/dist/browser/precaching.js +245 -97
  12. package/dist/browser/precaching.js.map +1 -1
  13. package/dist/browser/tabCapture.js +3 -10
  14. package/dist/browser/tabCapture.js.map +1 -1
  15. package/dist/browser/tuning/comcastPolymer.js +13 -14
  16. package/dist/browser/tuning/comcastPolymer.js.map +1 -1
  17. package/dist/browser/tuning/hbo.js +34 -17
  18. package/dist/browser/tuning/hbo.js.map +1 -1
  19. package/dist/browser/video.d.ts +1 -1
  20. package/dist/browser/video.js +3 -3
  21. package/dist/channels/index.js +5 -5
  22. package/dist/channels/index.js.map +1 -1
  23. package/dist/cliHelp.d.ts +11 -0
  24. package/dist/cliHelp.js +148 -0
  25. package/dist/cliHelp.js.map +1 -0
  26. package/dist/config/channelForm.js +5 -4
  27. package/dist/config/channelForm.js.map +1 -1
  28. package/dist/config/consistencyProbe.d.ts +2 -4
  29. package/dist/config/consistencyProbe.js +8 -51
  30. package/dist/config/consistencyProbe.js.map +1 -1
  31. package/dist/config/index.d.ts +107 -55
  32. package/dist/config/index.js +499 -267
  33. package/dist/config/index.js.map +1 -1
  34. package/dist/config/paths.d.ts +13 -8
  35. package/dist/config/paths.js +21 -25
  36. package/dist/config/paths.js.map +1 -1
  37. package/dist/config/persistence.d.ts +57 -11
  38. package/dist/config/persistence.js +127 -44
  39. package/dist/config/persistence.js.map +1 -1
  40. package/dist/config/reactivity.d.ts +60 -36
  41. package/dist/config/reactivity.js +89 -116
  42. package/dist/config/reactivity.js.map +1 -1
  43. package/dist/config/services.d.ts +61 -26
  44. package/dist/config/services.js +137 -68
  45. package/dist/config/services.js.map +1 -1
  46. package/dist/config/streamSettings.d.ts +24 -0
  47. package/dist/config/streamSettings.js +17 -0
  48. package/dist/config/streamSettings.js.map +1 -0
  49. package/dist/config/userChannels.d.ts +60 -52
  50. package/dist/config/userChannels.js +138 -188
  51. package/dist/config/userChannels.js.map +1 -1
  52. package/dist/config/userConfig.d.ts +224 -61
  53. package/dist/config/userConfig.js +351 -134
  54. package/dist/config/userConfig.js.map +1 -1
  55. package/dist/hdhr/discover.d.ts +7 -1
  56. package/dist/hdhr/discover.js +12 -6
  57. package/dist/hdhr/discover.js.map +1 -1
  58. package/dist/hdhr/index.d.ts +51 -10
  59. package/dist/hdhr/index.js +108 -112
  60. package/dist/hdhr/index.js.map +1 -1
  61. package/dist/hdhr/udp.d.ts +11 -4
  62. package/dist/hdhr/udp.js +25 -20
  63. package/dist/hdhr/udp.js.map +1 -1
  64. package/dist/index.js +16 -143
  65. package/dist/index.js.map +1 -1
  66. package/dist/native/index.d.ts +1 -1
  67. package/dist/native/index.js +4 -3
  68. package/dist/native/index.js.map +1 -1
  69. package/dist/native/proxy.d.ts +1 -1
  70. package/dist/native/proxy.js +36 -28
  71. package/dist/native/proxy.js.map +1 -1
  72. package/dist/routes/cdp.d.ts +7 -6
  73. package/dist/routes/cdp.js +9 -8
  74. package/dist/routes/cdp.js.map +1 -1
  75. package/dist/routes/components.d.ts +23 -5
  76. package/dist/routes/components.js +21 -4
  77. package/dist/routes/components.js.map +1 -1
  78. package/dist/routes/config/channels/endpoints/browse.js +39 -29
  79. package/dist/routes/config/channels/endpoints/browse.js.map +1 -1
  80. package/dist/routes/config/channels/endpoints/bulk.js +2 -6
  81. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
  82. package/dist/routes/config/channels/endpoints/predefined.js +5 -15
  83. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -1
  84. package/dist/routes/config/channels/endpoints/prefs.js +7 -6
  85. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -1
  86. package/dist/routes/config/channels/endpoints/service.js +6 -5
  87. package/dist/routes/config/channels/endpoints/service.js.map +1 -1
  88. package/dist/routes/config/channels/endpoints/tags.js +4 -16
  89. package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
  90. package/dist/routes/config/channels/table.d.ts +2 -1
  91. package/dist/routes/config/channels/table.js +11 -13
  92. package/dist/routes/config/channels/table.js.map +1 -1
  93. package/dist/routes/config/http/envelope.d.ts +6 -3
  94. package/dist/routes/config/http/envelope.js +18 -5
  95. package/dist/routes/config/http/envelope.js.map +1 -1
  96. package/dist/routes/config/index.d.ts +21 -13
  97. package/dist/routes/config/index.js +47 -34
  98. package/dist/routes/config/index.js.map +1 -1
  99. package/dist/routes/config/settings.d.ts +43 -6
  100. package/dist/routes/config/settings.js +225 -125
  101. package/dist/routes/config/settings.js.map +1 -1
  102. package/dist/routes/config/vocabulary.d.ts +24 -0
  103. package/dist/routes/config/vocabulary.js +33 -0
  104. package/dist/routes/config/vocabulary.js.map +1 -0
  105. package/dist/routes/debug.js +28 -13
  106. package/dist/routes/debug.js.map +1 -1
  107. package/dist/routes/root/content.js +33 -26
  108. package/dist/routes/root/content.js.map +1 -1
  109. package/dist/routes/root/scripts/channels.js +23 -11
  110. package/dist/routes/root/scripts/channels.js.map +1 -1
  111. package/dist/routes/root/scripts/config.d.ts +1 -1
  112. package/dist/routes/root/scripts/config.js +127 -79
  113. package/dist/routes/root/scripts/config.js.map +1 -1
  114. package/dist/routes/root/scripts/shared.js +34 -23
  115. package/dist/routes/root/scripts/shared.js.map +1 -1
  116. package/dist/routes/root/scripts/status.handlers.d.ts +8 -13
  117. package/dist/routes/root/scripts/status.handlers.js +35 -21
  118. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  119. package/dist/routes/root/styles.js +13 -16
  120. package/dist/routes/root/styles.js.map +1 -1
  121. package/dist/routes/services.js +12 -16
  122. package/dist/routes/services.js.map +1 -1
  123. package/dist/routes/theme.js +12 -12
  124. package/dist/routes/theme.js.map +1 -1
  125. package/dist/routes/ui.d.ts +4 -3
  126. package/dist/routes/ui.js +17 -13
  127. package/dist/routes/ui.js.map +1 -1
  128. package/dist/service/generators.js +12 -9
  129. package/dist/service/generators.js.map +1 -1
  130. package/dist/streaming/captureSession.d.ts +4 -4
  131. package/dist/streaming/captureSession.js +6 -7
  132. package/dist/streaming/captureSession.js.map +1 -1
  133. package/dist/streaming/codec.d.ts +12 -2
  134. package/dist/streaming/codec.js +40 -28
  135. package/dist/streaming/codec.js.map +1 -1
  136. package/dist/streaming/fmp4Segmenter.d.ts +14 -4
  137. package/dist/streaming/fmp4Segmenter.js +99 -79
  138. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  139. package/dist/streaming/hls.d.ts +21 -14
  140. package/dist/streaming/hls.js +148 -122
  141. package/dist/streaming/hls.js.map +1 -1
  142. package/dist/streaming/hlsResume.d.ts +36 -13
  143. package/dist/streaming/hlsResume.js +68 -37
  144. package/dist/streaming/hlsResume.js.map +1 -1
  145. package/dist/streaming/monitor.d.ts +5 -3
  146. package/dist/streaming/monitor.js +47 -37
  147. package/dist/streaming/monitor.js.map +1 -1
  148. package/dist/streaming/mp4Parser.d.ts +29 -4
  149. package/dist/streaming/mp4Parser.js +49 -7
  150. package/dist/streaming/mp4Parser.js.map +1 -1
  151. package/dist/streaming/playlistBuilder.d.ts +1 -2
  152. package/dist/streaming/playlistBuilder.js +2 -3
  153. package/dist/streaming/playlistBuilder.js.map +1 -1
  154. package/dist/streaming/preroll.d.ts +43 -16
  155. package/dist/streaming/preroll.js +112 -62
  156. package/dist/streaming/preroll.js.map +1 -1
  157. package/dist/streaming/pretune.d.ts +2 -2
  158. package/dist/streaming/pretune.js +11 -7
  159. package/dist/streaming/pretune.js.map +1 -1
  160. package/dist/streaming/recovery.d.ts +29 -2
  161. package/dist/streaming/recovery.js +43 -4
  162. package/dist/streaming/recovery.js.map +1 -1
  163. package/dist/streaming/registry.d.ts +13 -10
  164. package/dist/streaming/registry.js +6 -5
  165. package/dist/streaming/registry.js.map +1 -1
  166. package/dist/streaming/setup.d.ts +29 -20
  167. package/dist/streaming/setup.js +181 -170
  168. package/dist/streaming/setup.js.map +1 -1
  169. package/dist/streaming/showInfo.d.ts +24 -12
  170. package/dist/streaming/showInfo.js +120 -113
  171. package/dist/streaming/showInfo.js.map +1 -1
  172. package/dist/streaming/statusEmitter.d.ts +15 -10
  173. package/dist/streaming/statusEmitter.js +24 -20
  174. package/dist/streaming/statusEmitter.js.map +1 -1
  175. package/dist/types/config.d.ts +24 -13
  176. package/dist/types/config.js +5 -1
  177. package/dist/types/config.js.map +1 -1
  178. package/dist/types/index.d.ts +3 -2
  179. package/dist/types/index.js +2 -1
  180. package/dist/types/index.js.map +1 -1
  181. package/dist/types/profiles.d.ts +2 -2
  182. package/dist/types/selection.d.ts +12 -10
  183. package/dist/types/streaming.d.ts +12 -5
  184. package/dist/types/streaming.js +12 -5
  185. package/dist/types/streaming.js.map +1 -1
  186. package/dist/utils/debugFilter.d.ts +3 -2
  187. package/dist/utils/debugFilter.js +7 -5
  188. package/dist/utils/debugFilter.js.map +1 -1
  189. package/dist/{hdhr → utils}/deviceId.d.ts +1 -1
  190. package/dist/{hdhr → utils}/deviceId.js +11 -29
  191. package/dist/utils/deviceId.js.map +1 -0
  192. package/dist/utils/errors.d.ts +7 -0
  193. package/dist/utils/errors.js +12 -3
  194. package/dist/utils/errors.js.map +1 -1
  195. package/dist/utils/fileLogger.d.ts +23 -12
  196. package/dist/utils/fileLogger.js +73 -41
  197. package/dist/utils/fileLogger.js.map +1 -1
  198. package/dist/utils/httpErrors.d.ts +13 -0
  199. package/dist/utils/httpErrors.js +22 -0
  200. package/dist/utils/httpErrors.js.map +1 -0
  201. package/dist/utils/index.d.ts +3 -0
  202. package/dist/utils/index.js +3 -0
  203. package/dist/utils/index.js.map +1 -1
  204. package/dist/utils/logger.d.ts +9 -8
  205. package/dist/utils/logger.js +64 -20
  206. package/dist/utils/logger.js.map +1 -1
  207. package/dist/utils/markup.js +8 -3
  208. package/dist/utils/markup.js.map +1 -1
  209. package/dist/utils/plainObject.d.ts +6 -0
  210. package/dist/utils/plainObject.js +18 -0
  211. package/dist/utils/plainObject.js.map +1 -0
  212. package/dist/utils/runtimeIdentity.context.js +1 -29
  213. package/dist/utils/runtimeIdentity.context.js.map +1 -1
  214. package/dist/utils/runtimeIdentity.d.ts +17 -6
  215. package/dist/utils/runtimeIdentity.js +50 -17
  216. package/dist/utils/runtimeIdentity.js.map +1 -1
  217. package/package.json +5 -5
  218. package/dist/hdhr/deviceId.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { LOG, extractDomain, formatError, startTimer, timeoutSignal } from "../utils/index.js";
2
2
  import { clearDomainAuthRequirement, getDomainAuthState, markDomainAuth, markDomainAuthRequired } from "../config/health.js";
3
- import { createDiscoveryPage, emulateLayoutSurface, getCurrentBrowser, isGracefulShutdown, registerManagedPage, syncWindowVisibility, unregisterManagedPage } from "./index.js";
3
+ import { createDiscoveryPage, emulateLayoutSurface, getCurrentBrowser, isBrowserConnected, isGracefulShutdown, registerManagedPage, syncWindowVisibility, unregisterManagedPage } from "./index.js";
4
4
  import { getPersistedLineup, persistProviderLineup } from "../config/providerLineups.js";
5
5
  import { getProviderBySlug, getProvidersForDomain } from "./channelSelection.js";
6
6
  import { systemClock, waitWithSignal } from "homebridge-plugin-utils";
@@ -8,14 +8,17 @@ import { CONFIG } from "../config/index.js";
8
8
  import { classifyBlockedPage } from "./blockedPage.js";
9
9
  import { getProfileForUrl } from "../config/profiles.js";
10
10
  import { isLoginModeActive } from "./login.js";
11
+ import { isServiceTagEnabled } from "../config/services.js";
12
+ import { registerConfigChangeHandler } from "../config/reactivity.js";
11
13
  import { startOverlayHandling } from "./consent.js";
12
- /* Precaching discovers channel lineups for selected services at startup so that even the first tune benefits from cached lineup data. Each service is precached
13
- * sequentially - discovery opens a browser page in a window of its own and navigates to a heavy SPA, so running all services concurrently would stress CPU and
14
- * GPU on resource-constrained systems. The HTTP server starts immediately; precaching begins in the background after a brief delay.
14
+ /* Precaching discovers channel lineups for selected services ahead of any tune, so that even the first tune benefits from cached lineup data. Each service is
15
+ * precached sequentially - discovery opens a browser page in a window of its own and navigates to a heavy SPA, so running all services concurrently would stress
16
+ * CPU and GPU on resource-constrained systems. A cycle runs in the background after a settle delay, never on a request's path.
15
17
  *
16
- * Precaching is triggered from launchBrowser() in browser/index.ts. This covers both initial server startup and browser crash recovery (where all caches are cleared).
17
- * Each service has its own try/catch - one failure does not stop the rest. The browser reference is obtained per-service via getCurrentBrowser() so that a browser
18
- * crash between services is handled transparently (the next service gets the relaunched browser).
18
+ * A cycle is requested with a scope. A browser launch requests one over every listed service, because the launched browser starts with every provider cache
19
+ * empty, and a settings save requests one over the services it adds to the list, so the save warms what it selected without clearing any other provider's
20
+ * cache. Precaching never launches the browser: a cycle or a deferred pass that finds no browser running stops, and the next launch requests its own cycle. Each
21
+ * service has its own try/catch - one failure does not stop the rest.
19
22
  *
20
23
  * This module also owns the discovery-outcome policy (recordDiscoveryOutcome): the single source of truth for how a completed discovery walk translates into domain
21
24
  * auth state and a persisted channel lineup, shared by the precache cycle here and the /services/:slug/channels endpoint. The routes layer never calls a health
@@ -23,10 +26,11 @@ import { startOverlayHandling } from "./consent.js";
23
26
  * primitive both the precache cycle and that endpoint walk their guides through, and the one place the empty-walk retry policy lives, so no provider carries a
24
27
  * retry of its own.
25
28
  *
26
- * Every timer and deadline this module arms - the startup cycle's delay, the deferred re-attempt, and each walk's ceiling - runs on the clock its dependencies
27
- * carry, so one injected clock drives the whole schedule.
29
+ * Every timer and deadline this module arms - the settle delay before a cycle, the deferred re-attempt, and each walk's ceiling - runs on the clock its
30
+ * dependencies carry, so one injected clock drives the whole schedule.
28
31
  */
29
- // Delay in milliseconds before precaching begins after browser launch. This gives the browser time to settle after initialization.
32
+ // The settle delay in milliseconds before a requested cycle walks its first guide. It gives a browser that just launched time to settle, and every request that
33
+ // arrives inside it joins the pending cycle rather than starting another.
30
34
  const PRECACHE_DELAY = 5000;
31
35
  /* Delay in milliseconds before the services a cycle could not settle are re-attempted. Five minutes puts the second pass well past the contention a boot creates -
32
36
  * the browser launch, the first tunes, the DVR's own channel scan - which is the likeliest reason a provider's lazy content never appeared inside its walk. A
@@ -66,41 +70,70 @@ export class DiscoveryWalkTimeoutError extends Error {
66
70
  this.name = "DiscoveryWalkTimeoutError";
67
71
  }
68
72
  }
69
- // Guard flag preventing overlapping precache cycles. Set to true before the cycle starts, cleared through releasePrecacheGuard in a finally block.
73
+ /**
74
+ * Merges a new request's scope into the scope already requested, giving the one scope that answers each request, so a request that reaches a pending cycle or a
75
+ * held guard joins what is already requested rather than queueing behind it. A scope over every service absorbs any other, and slug scopes union their slugs.
76
+ * @param current - The scope already requested.
77
+ * @param added - The scope a new request carries.
78
+ * @returns The scope that answers each request.
79
+ */
80
+ function mergeScopes(current, added) {
81
+ if (current.kind === "all") {
82
+ return current;
83
+ }
84
+ if (added.kind === "all") {
85
+ return added;
86
+ }
87
+ return { kind: "services", slugs: current.slugs.union(added.slugs) };
88
+ }
89
+ // The single-flight guard. A cycle holds it from the moment it is armed until its run ends, and a deferred re-attempt and a post-login revalidation each hold it
90
+ // while they run, so runs never overlap. Every holder releases it through releasePrecacheGuard, and the shutdown paths clear it directly.
70
91
  let precacheInProgress = false;
71
- // The scheduler's pending work, or null when it has none.
72
- let scheduled = null;
73
- /* Whether a full precache cycle was requested while the single-flight guard was held. The request always comes from a browser relaunch, which cleared every
74
- * provider cache, so the run holding the guard is walking guides for a browser that no longer exists and its result is worth nothing - dropping the request would
75
- * leave the new browser with no lineups at all. Whoever releases the guard runs it; releasePrecacheGuard is where that happens, and it is the only place.
92
+ /* The scope requested while the guard was held, merged across every such request, or null when none arrived. The run holding the guard walks the scope it was
93
+ * armed with and cannot take on another, so a request is recorded rather than dropped: a launch's means every provider cache was just cleared, and a save's means
94
+ * services were added after that run read the list. Whoever releases the guard hands it on; releasePrecacheGuard is where that happens, and it is the only place.
76
95
  */
77
- let fullCycleRequested = false;
96
+ let requestedScope = null;
97
+ // The scheduler's pending work. updateSchedule is its only writer.
98
+ let schedule = { cycle: null, retry: null };
78
99
  /**
79
- * Cancels a pending deferred re-attempt and drops its state as one unit. A no-op when nothing is pending, and equally a no-op when what is pending is a cycle -
80
- * only the re-attempt arm is cancelled here.
100
+ * Writes the scheduler state, and is the only code that does. It builds the next value from the current one and the change, disposes each timer the current
101
+ * value holds and the next value does not, compared by identity, and assigns the next value. A record replaced by one carrying the same timer keeps that
102
+ * timer armed, as a slug scope merged into a pending cycle does; a record cleared, or replaced by one carrying a new timer, has its timer disposed. So no path
103
+ * can drop a record and leave its timer armed, and none can cancel a timer the state still holds. Disposing a timer that already fired does nothing, so a fire
104
+ * clears its own slot through here as well.
105
+ * @param change - The slots to write; a slot the change leaves out keeps its record.
81
106
  */
82
- function clearDeferredRetry() {
83
- if (scheduled?.kind === "deferred") {
84
- scheduled.timer[Symbol.dispose]();
85
- scheduled = null;
107
+ function updateSchedule(change) {
108
+ const next = { ...schedule, ...change };
109
+ // The timers a schedule value holds, one per slot, so the current value's timers are compared against the next value's by identity.
110
+ const timersOf = (value) => [value.cycle?.timer, value.retry?.timer];
111
+ const kept = new Set(timersOf(next));
112
+ for (const timer of timersOf(schedule)) {
113
+ if (timer && !kept.has(timer)) {
114
+ timer[Symbol.dispose]();
115
+ }
86
116
  }
117
+ schedule = next;
87
118
  }
88
119
  /**
89
- * Releases the single-flight guard and honors any full-cycle request that arrived while it was held.
120
+ * Releases the single-flight guard and hands on any scope requested while it was held.
90
121
  *
91
- * The order is the whole point. The flag is cleared first, so the reentrant startPrecaching below finds a free guard and schedules. Honoring first would have that
92
- * call see the guard still held and record the very request being honored, which is how a browser relaunch's cycle would go round forever without ever running.
93
- * Every path that takes the guard - the cycle, the deferred re-attempt, and the post-login revalidation - releases it here, so the hand-off has one home rather
94
- * than a copy at each site.
95
- * @param deps - The injected dependencies, handed to the cycle this may start.
122
+ * The order is the whole point. The guard is freed and the recorded scope taken before the hand-off, so the request below finds a free guard and arms. Handing on
123
+ * first would have that request see the guard still held and record the very scope being handed on, which is how a request would go round forever without ever
124
+ * running. Every path that takes the guard - the cycle, the deferred re-attempt, and the post-login revalidation - releases it here, so the hand-off has one home
125
+ * rather than a copy at each site. The scope goes to the request function rather than to startPrecaching, so it never meets the launch's empty-list gate: the
126
+ * cycle it arms reads the list when it fires.
127
+ * @param deps - The injected dependencies, handed to the cycle this may arm.
96
128
  */
97
129
  function releasePrecacheGuard(deps) {
98
130
  precacheInProgress = false;
99
- if (!fullCycleRequested) {
131
+ const scope = requestedScope;
132
+ if (!scope) {
100
133
  return;
101
134
  }
102
- fullCycleRequested = false;
103
- startPrecaching(deps);
135
+ requestedScope = null;
136
+ requestPrecache(scope, deps);
104
137
  }
105
138
  export const defaultPrecachingDeps = {
106
139
  clock: systemClock,
@@ -110,6 +143,7 @@ export const defaultPrecachingDeps = {
110
143
  getPersistedLineup,
111
144
  getProviderBySlug,
112
145
  getProvidersForDomain,
146
+ isBrowserConnected,
113
147
  isGracefulShutdown,
114
148
  persistProviderLineup,
115
149
  registerManagedPage,
@@ -118,55 +152,108 @@ export const defaultPrecachingDeps = {
118
152
  unregisterManagedPage
119
153
  };
120
154
  /**
121
- * Starts the precaching cycle if services are configured. Called from launchBrowser() after the browser is ready. If no services are selected, a shutdown is in
122
- * progress, or a precache cycle is already in progress, returns immediately. The actual work is scheduled on the dependencies' clock to avoid blocking browser
123
- * launch.
124
- * @param deps - The injected browser and provider-registry dependencies; defaults to defaultPrecachingDeps.
155
+ * Arms the cycle slot for a scope on a full settle delay. The callback reads the slot's scope when it fires, never the scope armed here, so every request merged
156
+ * into the slot before the fire reaches the walk, and it clears the slot through updateSchedule before the cycle runs. Arming a scope over every service clears
157
+ * the re-attempt slot in the same write, because that cycle walks every service the re-attempt owes and a second pass over the same guides would contend with it
158
+ * for one browser; a slug scope leaves the re-attempt on its own delay, with its skip for a lineup that arrived meanwhile and its one pass.
159
+ * @param scope - The scope the cycle walks unless a later request merges into it.
160
+ * @param deps - The injected dependencies; the timer arms on their clock and the cycle runs with them.
125
161
  */
126
- export function startPrecaching(deps = defaultPrecachingDeps) {
127
- if (CONFIG.channels.precacheServices.length === 0) {
162
+ function armCycle(scope, deps) {
163
+ const timer = deps.clock.schedule(() => {
164
+ const armed = schedule.cycle;
165
+ updateSchedule({ cycle: null });
166
+ // A fire finds its own record in the slot, because every write that drops a cycle record disposes that record's timer first.
167
+ if (!armed) {
168
+ return;
169
+ }
170
+ void runPrecacheCycle(armed.scope, deps);
171
+ }, PRECACHE_DELAY);
172
+ updateSchedule((scope.kind === "all") ? { cycle: { scope, timer }, retry: null } : { cycle: { scope, timer } });
173
+ }
174
+ /**
175
+ * Lands a precache request, and is the one place one lands. A request carries the scope it wants walked and takes the first of these that applies: during a
176
+ * graceful shutdown it is dropped; with a cycle armed and not yet fired, it merges into that cycle's scope; with the guard held by a run, it merges into
177
+ * requestedScope, which the release hands on; otherwise it takes the guard and arms a cycle.
178
+ *
179
+ * The pending cycle is consulted ahead of the guard, which that cycle holds, because it reads the list and the browser when it fires and so serves the request
180
+ * whole - recording the request instead would run a second cycle after the first. A scope over every service comes from a launch, so merging one into a pending
181
+ * cycle re-arms that cycle for a full settle delay from the merge, the time the new browser needs, and clears the re-attempt slot as arming one does. A slug
182
+ * scope merged into a pending cycle keeps its timer, so a save never postpones a cycle already on its way.
183
+ * @param scope - The services the request wants walked.
184
+ * @param deps - The injected dependencies, handed to the cycle this arms.
185
+ */
186
+ function requestPrecache(scope, deps) {
187
+ // Never arm during a graceful shutdown. A launch can be reached during teardown, and a cycle armed then would fire after teardown closed the browser.
188
+ if (deps.isGracefulShutdown()) {
128
189
  return;
129
190
  }
130
- // Never schedule a precache during graceful shutdown. launchBrowser() can be reached during teardown; without this guard the scheduled cycle would fire after the
131
- // browser is closed and relaunch Chrome.
132
- if (deps.isGracefulShutdown()) {
191
+ const pending = schedule.cycle;
192
+ if (pending) {
193
+ const merged = mergeScopes(pending.scope, scope);
194
+ if (scope.kind === "all") {
195
+ armCycle(merged, deps);
196
+ }
197
+ else {
198
+ updateSchedule({ cycle: { scope: merged, timer: pending.timer } });
199
+ }
133
200
  return;
134
201
  }
135
202
  if (precacheInProgress) {
136
- // Record the request rather than dropping it. This call is a browser relaunch's, and the run currently holding the guard is walking guides for a browser whose
137
- // caches were just cleared out from under it - so the request is the one worth keeping, and whoever releases the guard runs it.
138
- fullCycleRequested = true;
203
+ // Record the request rather than dropping it: the run holding the guard walks the scope it was armed with, and whoever releases the guard arms this one.
204
+ requestedScope = requestedScope ? mergeScopes(requestedScope, scope) : scope;
139
205
  LOG.debug("precache", "Precache deferred: already in progress.");
140
206
  return;
141
207
  }
142
- // Set the guard before scheduling so that a second call during the delay window (e.g., rapid browser crash + relaunch) sees the flag and defers.
208
+ // Take the guard as the cycle is armed, so every request until the cycle's run ends merges into it or is recorded for its release rather than arming another.
143
209
  precacheInProgress = true;
144
- // A full cycle supersedes any pending deferred re-attempt: it walks every configured service, the empty ones included, and letting both run would put two passes
145
- // over the same guides in contention for one browser.
146
- clearDeferredRetry();
147
- // Schedule the precache cycle after a brief delay to let the browser settle. The handle is tracked so stopPrecaching() can cancel it if shutdown begins during the
148
- // delay window; the ref is cleared when the timer fires since it is then spent.
149
- scheduled = { kind: "cycle", timer: deps.clock.schedule(() => {
150
- scheduled = null;
151
- void runPrecacheCycle(deps);
152
- }, PRECACHE_DELAY) };
210
+ armCycle(scope, deps);
211
+ }
212
+ /**
213
+ * Requests a precache cycle over every listed service. This is the browser launch's request, made once its browser is ready, because a launched browser starts
214
+ * with every provider cache empty; a save requests only the services it adds, through applyPrecacheConfigChanges. Returns at once when no service is listed, and
215
+ * otherwise lands the request through requestPrecache, whose cycle waits out the settle delay on the dependencies' clock, so the launch is never blocked.
216
+ * @param deps - The injected browser and provider-registry dependencies; defaults to defaultPrecachingDeps.
217
+ */
218
+ export function startPrecaching(deps = defaultPrecachingDeps) {
219
+ if (CONFIG.channels.precacheServices.length === 0) {
220
+ return;
221
+ }
222
+ requestPrecache({ kind: "all" }, deps);
153
223
  }
154
224
  /**
155
- * Cancels every scheduled precache - the startup cycle and the deferred re-attempt alike - and clears the in-progress guard. Called during graceful shutdown so
156
- * nothing scheduled can fire after the browser has been closed. Safe to call when nothing is pending.
225
+ * Cancels every scheduled precache - the pending cycle and the deferred re-attempt alike - drops a recorded request, and clears the in-progress guard. Called
226
+ * during graceful shutdown so nothing scheduled can fire after the browser has been closed. Safe to call when nothing is pending.
157
227
  *
158
- * The guard is cleared directly rather than through releasePrecacheGuard, and the hand-off flag with it: a shutdown must not honor a pending full-cycle request,
159
- * which is exactly what releasing through the hand-off would do.
228
+ * The slots are cleared through updateSchedule, which disposes their timers. The guard is cleared directly rather than through releasePrecacheGuard, and the
229
+ * recorded request with it: a shutdown must not hand on a pending request, which is exactly what releasing through the hand-off would do.
160
230
  */
161
231
  export function stopPrecaching() {
162
- if (scheduled) {
163
- scheduled.timer[Symbol.dispose]();
164
- scheduled = null;
165
- }
166
- clearDeferredRetry();
167
- fullCycleRequested = false;
232
+ updateSchedule({ cycle: null, retry: null });
233
+ requestedScope = null;
168
234
  precacheInProgress = false;
169
235
  }
236
+ /**
237
+ * Requests a walk of the services a saved precache list adds. The candidate carries the saved list and CONFIG still holds the running one, because the reconcile
238
+ * commits only after its handlers run, so the services the save adds are the candidate's slugs the running list lacks. They are requested as one scope while a
239
+ * browser is connected, so the save precaches them without clearing any other provider's cache. A save that only removes services needs nothing here, because
240
+ * every reader of the list reads it when it walks, and with no browser running the save requests nothing, because a browser is never launched for a save and
241
+ * the next launch requests every listed service. Requesting cannot fail, so the handler refuses nothing.
242
+ * @param _changes - The change to the precache list; the candidate carries the list, so the handler reads that instead.
243
+ * @param next - The candidate running configuration.
244
+ * @param deps - The injected dependencies; defaults to defaultPrecachingDeps.
245
+ * @returns No rejections.
246
+ */
247
+ export async function applyPrecacheConfigChanges(_changes, next, deps = defaultPrecachingDeps) {
248
+ const added = new Set(next.channels.precacheServices).difference(new Set(CONFIG.channels.precacheServices));
249
+ if ((added.size > 0) && deps.isBrowserConnected()) {
250
+ requestPrecache({ kind: "services", slugs: added }, deps);
251
+ }
252
+ return [];
253
+ }
254
+ // Module-load side effect: register the handler once per process, as every config-change handler registers, so it is in place before the first save can reach
255
+ // the reconcile.
256
+ registerConfigChangeHandler("channels.precacheServices", applyPrecacheConfigChanges);
170
257
  /**
171
258
  * Records the consequences of a completed channel discovery: the domain auth state it proves, and the durable lineup it produced. This is the single source of
172
259
  * truth for discovery-outcome policy, consumed by both the precache cycle (via precacheService) and the /services/:slug/channels endpoint - the routes layer never
@@ -401,8 +488,8 @@ export async function withProviderGuidePage(provider, options = {}, deps = defau
401
488
  if ((channels.length === 0) && !page.isClosed() && !signal?.aborted && !deps.isGracefulShutdown()) {
402
489
  ({ channels, classification } = await retryAfterEmptyWalk({ deps, page, profile, provider }));
403
490
  }
404
- // The hook runs once, on whichever result stands - and receives the first walk's classification only when the retry declined to reload, so the page it is
405
- // handed and the classification it records always describe the same moment.
491
+ // The hook runs once, on whichever result stands - and receives the first walk's classification only when no second walk ran (the page classified as blocked,
492
+ // or the reload before the retry failed), so the page it is handed and the classification it records always describe the same moment.
406
493
  await afterWalk?.(page, channels, classification);
407
494
  return channels;
408
495
  }
@@ -431,13 +518,14 @@ export async function withProviderGuidePage(provider, options = {}, deps = defau
431
518
  */
432
519
  export async function precacheService(provider, deps = defaultPrecachingDeps) {
433
520
  const serviceElapsed = startTimer();
434
- // Clear the service's cache before discovery to ensure a complete walk, even if a tune partially warmed the cache during the startup delay.
521
+ // Clear the service's cache before discovery to ensure a complete walk, even if a tune partially warmed the cache during the settle delay. Only the walked
522
+ // service's cache is cleared, so a cycle scoped to the services a save added leaves every other provider's warm cache alone.
435
523
  provider.strategy.clearCache?.();
436
524
  return withProviderGuidePage(provider, {
437
525
  afterWalk: async (page, channels, classification) => {
438
526
  LOG.info("Precached %s: %d channels (%ss).", provider.label, channels.length, (serviceElapsed() / 1000).toFixed(1).replace(/\.0$/, ""));
439
- // Record the outcome while the page is still open - an empty result classifies the page it walked, unless the session already classified it and declined to
440
- // reload, in which case that verdict travels here rather than being re-derived from a page the reload would have changed.
527
+ // Record the outcome while the page is still open - an empty result classifies the page it walked, unless the session already classified it and no second
528
+ // walk followed (the page classified as blocked, or the reload before the retry failed), in which case that verdict travels here rather than being re-derived.
441
529
  await recordDiscoveryOutcome(provider, channels, page, deps, classification);
442
530
  }
443
531
  }, deps);
@@ -448,8 +536,8 @@ export async function precacheService(provider, deps = defaultPrecachingDeps) {
448
536
  *
449
537
  * Never rejects - the observer wiring voids the returned promise, so every failure is logged and absorbed here. Revalidation deliberately ignores both the
450
538
  * precacheServices and enabledServices filters: this is clearing-evidence collection for a domain the user just signed in to, not precaching, and the cycle's
451
- * filter skip is unchanged. While it runs it holds the same single-flight guard the precache cycle holds, so a cycle scheduled mid-revalidation (a browser crash
452
- * relaunch) defers instead of overlapping.
539
+ * filter skip is unchanged. While it runs it holds the same single-flight guard the precache cycle holds, so a cycle requested mid-revalidation, by a launch or
540
+ * a save, is recorded and armed once the revalidation releases the guard, instead of overlapping it.
453
541
  * @param url - The login session's URL; its extracted domain selects the providers to revalidate.
454
542
  * @returns A promise that resolves when revalidation completes or is skipped. It never rejects.
455
543
  */
@@ -469,8 +557,9 @@ export async function revalidateDomainAuth(url, deps = defaultPrecachingDeps) {
469
557
  if (deps.isGracefulShutdown()) {
470
558
  return;
471
559
  }
472
- // Defer to an in-flight precache cycle rather than overlapping it. Limitation: if the flagged provider is not in that cycle's configured service set,
473
- // the flag persists until the next successful discovery or tune for the domain - an in-flight cycle cannot be retargeted.
560
+ // Defer to an in-flight precache run rather than overlapping it. Limitation: when the flagged provider is outside what that run walks - a cycle whose list
561
+ // or whose scope leaves it out - the flag persists until the next walk that reaches the provider, the next launch's cycle over every service among them, or a
562
+ // successful tune for the domain, because a run in flight cannot be retargeted.
474
563
  if (precacheInProgress) {
475
564
  LOG.info("Deferring the post-login revalidation for %s to the precache cycle already in progress.", domain);
476
565
  return;
@@ -509,6 +598,9 @@ export async function revalidateDomainAuth(url, deps = defaultPrecachingDeps) {
509
598
  * Schedules the deferred re-attempt for the services a pass could not settle. Does nothing when every service came back with a lineup, and nothing during a
510
599
  * shutdown, where scheduling work against the browser is precisely what teardown is closing down. This is the one place a re-attempt is scheduled, so every
511
600
  * reason a service is still owed a walk - it ran and found nothing, it was stopped at its budget, or it never ran at all - arrives on the same schedule.
601
+ *
602
+ * A re-attempt already pending takes the new services into its own list and is re-armed for a full delay from the merge, so every service it owes waits the full
603
+ * delay after its last walk rather than inheriting what is left of an earlier one's wait. updateSchedule disposes the timer the merged record replaces.
512
604
  * @param slugs - The services a pass could not settle: walked without settling, or deferred because a login session was on screen.
513
605
  * @param deps - The injected dependencies, handed to the pass this schedules.
514
606
  */
@@ -516,35 +608,46 @@ function armDeferredRetry(slugs, deps) {
516
608
  if ((slugs.length === 0) || deps.isGracefulShutdown()) {
517
609
  return;
518
610
  }
519
- LOG.debug("precache", "Scheduling one deferred discovery re-attempt for %d service%s in %d minutes.", slugs.length, (slugs.length === 1) ? "" : "s", PRECACHE_RETRY_DELAY / 60000);
520
- scheduled = { kind: "deferred", slugs, timer: deps.clock.schedule(() => void runDeferredRetry(deps), PRECACHE_RETRY_DELAY) };
611
+ const owed = Array.from(new Set([...(schedule.retry?.slugs ?? []), ...slugs]));
612
+ LOG.debug("precache", "Scheduling one deferred discovery re-attempt for %d service%s in %d minutes.", owed.length, (owed.length === 1) ? "" : "s", PRECACHE_RETRY_DELAY / 60000);
613
+ updateSchedule({ retry: { slugs: owed, timer: deps.clock.schedule(() => void runDeferredRetry(deps), PRECACHE_RETRY_DELAY) } });
521
614
  }
522
615
  /**
523
616
  * Runs the deferred re-attempt for the services a cycle could not settle. Never rejects - it is driven by a timer with nobody to hand a rejection to, so every
524
617
  * per-service failure is contained the same way the cycle contains its own.
525
618
  *
526
619
  * A service whose lineup arrived in the interval - from a later full cycle, or from an on-demand discovery a user triggered - is skipped rather than re-walked,
527
- * because the walk it would run is the expensive part and the answer is already in hand. A login session that begins before the pass fires stops it where it
528
- * stands and re-arms everything still owed, so the walks resume once the user is done rather than opening a window over the one they are signing in through.
620
+ * because the walk it would run is the expensive part and the answer is already in hand, and so is a service that has since left the precache list or the
621
+ * active service filter. A login session that begins before the pass fires stops it where it stands and re-arms everything still owed, so the walks resume once
622
+ * the user is done rather than opening a window over the one they are signing in through.
529
623
  * @param deps - The injected browser and provider-registry dependencies.
530
624
  * @returns A promise that resolves once the pass completes or is skipped.
531
625
  */
532
626
  async function runDeferredRetry(deps) {
533
- if (scheduled?.kind !== "deferred") {
627
+ const pending = schedule.retry;
628
+ // Clear the slot before acting on it, through the one writer. This is the only pass there will be, so a record left standing would tell a later cancellation or
629
+ // merge that something is still scheduled when nothing is.
630
+ updateSchedule({ retry: null });
631
+ // A fire finds its own record in the slot, because every write that drops a re-attempt record disposes that record's timer first.
632
+ if (!pending) {
534
633
  return;
535
634
  }
536
- const pending = scheduled;
537
- // Drop the state before acting on it. This is the only pass there will be, so a handle left standing would tell a later cancellation that something is still
538
- // scheduled when nothing is.
539
- scheduled = null;
540
635
  if (deps.isGracefulShutdown()) {
541
636
  return;
542
637
  }
638
+ // Stop rather than walk when no browser is running, as the cycle does. The pass re-arms nothing, because the next launch requests a cycle over every listed
639
+ // service, the ones this pass owes among them.
640
+ if (!deps.isBrowserConnected()) {
641
+ LOG.debug("precache", "Skipping the deferred discovery re-attempt: no browser is running.");
642
+ return;
643
+ }
543
644
  /* A cycle or a post-login revalidation holding the guard is already walking guides, quite possibly these same ones. This pass exists to try again on a settled
544
- * system, not to contend with a run in flight - and the run in flight is the better attempt of the two, so this one is dropped wholesale rather than rescheduled.
645
+ * system, not to contend with a run in flight, so it re-arms itself for its services on its own delay, as it does for a login session, rather than dropping
646
+ * them: the run in flight may walk a scope that leaves them out, and when the pass fires again it skips any service whose lineup arrived meanwhile.
545
647
  */
546
648
  if (precacheInProgress) {
547
- LOG.debug("precache", "Skipping the deferred discovery re-attempt: a precache cycle is already in progress.");
649
+ LOG.debug("precache", "Deferring the discovery re-attempt: a precache run is already in progress.");
650
+ armDeferredRetry(pending.slugs, deps);
548
651
  return;
549
652
  }
550
653
  precacheInProgress = true;
@@ -559,10 +662,24 @@ async function runDeferredRetry(deps) {
559
662
  if (deps.isGracefulShutdown()) {
560
663
  break;
561
664
  }
665
+ // Stop the same way once the browser is gone. No await lies between this check and the browser acquisition precacheService reaches through
666
+ // withProviderGuidePage, so a walk never acquires a browser this check found gone and the pass never launches one; an await inserted between them breaks that.
667
+ if (!deps.isBrowserConnected()) {
668
+ break;
669
+ }
562
670
  const provider = deps.getProviderBySlug(slug);
563
671
  if (!provider) {
564
672
  continue;
565
673
  }
674
+ // A service off the list or outside the running filter is owed nothing, so the pass reads the list and the filter at each service's turn, as the cycle does.
675
+ if (!CONFIG.channels.precacheServices.includes(slug)) {
676
+ LOG.debug("precache", "Skipping the deferred re-attempt for %s: it is no longer on the precache list.", provider.label);
677
+ continue;
678
+ }
679
+ if (!isServiceTagEnabled(slug)) {
680
+ LOG.debug("precache", "Skipping the deferred re-attempt for %s: not in active service filter.", provider.label);
681
+ continue;
682
+ }
566
683
  if (provider.getCachedChannels()) {
567
684
  LOG.debug("precache", "Skipping the deferred re-attempt for %s: its lineup was discovered in the meantime.", provider.label);
568
685
  continue;
@@ -611,24 +728,41 @@ async function runDeferredRetry(deps) {
611
728
  }
612
729
  }
613
730
  /**
614
- * Executes the sequential precaching cycle. Discovers channel lineups for each configured service, clearing the service's cache first to ensure a complete walk.
615
- * Services not in the active service filter are silently skipped when the filter is non-empty. Services the cycle could not settle - they walked and found
731
+ * Executes one sequential precache cycle over the listed services its scope admits, clearing each service's cache first to ensure a complete walk. The list is
732
+ * read when the cycle fires, so a service removed during the settle delay is never walked, and again at each service's turn, so one removed while the cycle walks
733
+ * an earlier service is skipped. Services the running service filter excludes are skipped as well. Services the cycle could not settle - they walked and found
616
734
  * nothing, their walk ran past its budget, or a login session on screen kept them from walking at all - are handed to the deferred re-attempt, minutes later,
617
- * once whatever startup contention or user session may have starved them has passed.
735
+ * once whatever contention or user session may have starved them has passed.
736
+ * @param scope - The services the cycle walks, read from the cycle slot when the cycle fired.
737
+ * @param deps - The injected browser and provider-registry dependencies.
738
+ * @returns A promise that resolves once the cycle completes or stops.
618
739
  */
619
- async function runPrecacheCycle(deps) {
620
- /* Bail if a graceful shutdown began while this cycle was queued. Discovery opens browser pages via getCurrentBrowser(), which would relaunch Chrome after shutdown
621
- * closed it; this guard makes the cycle a no-op during teardown regardless of how the timer-cancellation race resolves. Reset the in-progress flag since the
622
- * early return skips the finally block below - directly rather than through the hand-off, because honoring a full-cycle request is the one thing a teardown path
623
- * must not do.
740
+ async function runPrecacheCycle(scope, deps) {
741
+ /* Bail if a graceful shutdown began while this cycle was pending. Discovery opens browser pages via getCurrentBrowser(), which would relaunch Chrome after
742
+ * shutdown closed it; this guard makes the cycle a no-op during teardown regardless of how the timer-cancellation race resolves. Reset the in-progress flag
743
+ * since the early return skips the finally block below - directly rather than through the hand-off, because handing on a recorded request is the one thing a
744
+ * teardown path must not do.
624
745
  */
625
746
  if (deps.isGracefulShutdown()) {
626
747
  precacheInProgress = false;
627
748
  return;
628
749
  }
629
- const slugs = CONFIG.channels.precacheServices;
630
- const enabledFilter = CONFIG.channels.enabledServices;
631
- const hasFilter = enabledFilter.length > 0;
750
+ /* Stop rather than walk when no browser is running. Discovery acquires its browser through getCurrentBrowser(), which would launch Chrome for a cycle, and the
751
+ * next launch requests its own cycle over every listed service. The guard is released through the hand-off, so a request recorded while this cycle was pending
752
+ * still arms, and that cycle stops here in turn when the browser is still gone.
753
+ */
754
+ if (!deps.isBrowserConnected()) {
755
+ LOG.debug("precache", "Skipping a precache cycle: no browser is running.");
756
+ releasePrecacheGuard(deps);
757
+ return;
758
+ }
759
+ const listed = CONFIG.channels.precacheServices;
760
+ const slugs = (scope.kind === "all") ? listed : listed.filter((slug) => scope.slugs.has(slug));
761
+ // A scope that admits no listed service - the list was emptied, or the services a save added were removed again - walks nothing and announces nothing.
762
+ if (slugs.length === 0) {
763
+ releasePrecacheGuard(deps);
764
+ return;
765
+ }
632
766
  const cycleElapsed = startTimer();
633
767
  /* The services that walked without settling - they came back with nothing, or the walk ran past its budget and was stopped - named rather than counted, because
634
768
  * the deferred re-attempt below needs to know which ones to come back to. Both readings say the same thing about the service: the guide did not answer this
@@ -649,12 +783,26 @@ async function runPrecacheCycle(deps) {
649
783
  if (deps.isGracefulShutdown()) {
650
784
  break;
651
785
  }
786
+ /* Stop the same way once the browser is gone, leaving the services this cycle has not reached to the next launch's cycle. No await lies between this check
787
+ * and the browser acquisition precacheService reaches through withProviderGuidePage, so a walk never acquires a browser this check found gone and the
788
+ * scheduler never launches one; an await inserted between them breaks that guarantee.
789
+ */
790
+ if (!deps.isBrowserConnected()) {
791
+ break;
792
+ }
652
793
  const provider = deps.getProviderBySlug(slug);
653
794
  if (!provider) {
654
795
  continue;
655
796
  }
656
- // Skip services not in the active service filter. Their stored config is preserved for when the filter changes back.
657
- if (hasFilter && !enabledFilter.includes(slug)) {
797
+ // Skip a service missing from the running list. The list is read at each turn rather than once when the cycle fired, because a save commits a new list
798
+ // while a cycle can be walking.
799
+ if (!CONFIG.channels.precacheServices.includes(slug)) {
800
+ LOG.debug("precache", "Skipping precache for %s: it left the precache list during the cycle.", provider.label);
801
+ continue;
802
+ }
803
+ // Skip services not in the active service filter. The running filter rather than the persisted list, through the predicate every other filter reader uses,
804
+ // so the cycle skips exactly the services they hide. Their stored config is preserved for when the filter changes back.
805
+ if (!isServiceTagEnabled(slug)) {
658
806
  LOG.debug("precache", "Skipping precache for %s: not in active service filter.", provider.label);
659
807
  skipped++;
660
808
  continue;