@gentbajko/slopify 3.0.0 → 3.0.2

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 (198) hide show
  1. package/README.md +6 -4
  2. package/dist/adapter-registry.js +28 -0
  3. package/dist/adapters/alignment/text.js +3 -1
  4. package/dist/adapters/key-probes.js +57 -8
  5. package/dist/adapters/tts/gemini.js +206 -0
  6. package/dist/adapters/tts/pcm-mp3.js +62 -0
  7. package/dist/adapters/tts/system.js +147 -0
  8. package/dist/assets/models.yaml +38 -0
  9. package/dist/assets/style-preview/narration.mp3 +0 -0
  10. package/dist/assets/style-preview/tall-1.jpg +0 -0
  11. package/dist/assets/style-preview/tall-2.jpg +0 -0
  12. package/dist/assets/style-preview/tall-3.jpg +0 -0
  13. package/dist/assets/style-preview/wide-1.jpg +0 -0
  14. package/dist/assets/style-preview/wide-2.jpg +0 -0
  15. package/dist/assets/style-preview/wide-3.jpg +0 -0
  16. package/dist/catalog/registry.js +4 -1
  17. package/dist/catalog/validate.js +2 -2
  18. package/dist/edge/autostart/docker-record.js +2 -2
  19. package/dist/edge/autostart/docker.js +68 -0
  20. package/dist/edge/autostart/prompt.js +7 -1
  21. package/dist/edge/autostart/service.js +18 -9
  22. package/dist/edge/cli-args.js +71 -0
  23. package/dist/edge/cli.js +11 -16
  24. package/dist/edge/docker-install/run.js +7 -1
  25. package/dist/edge/events/hub.js +3 -1
  26. package/dist/edge/http/actions.js +6 -1
  27. package/dist/edge/http/app.js +2 -0
  28. package/dist/edge/http/byte-range.js +42 -0
  29. package/dist/edge/http/diagnostics.js +1 -0
  30. package/dist/edge/http/draft-files.js +2 -4
  31. package/dist/edge/http/files.js +10 -6
  32. package/dist/edge/http/onboarding.js +64 -4
  33. package/dist/edge/http/project-templates.js +19 -2
  34. package/dist/edge/http/projects.js +6 -1
  35. package/dist/edge/http/providers.js +39 -9
  36. package/dist/edge/http/reviews.js +18 -8
  37. package/dist/edge/http/revision-files.js +2 -4
  38. package/dist/edge/http/schedules.js +9 -5
  39. package/dist/edge/http/storage.js +16 -2
  40. package/dist/edge/http/studio.js +111 -31
  41. package/dist/edge/http/style-preview.js +3 -42
  42. package/dist/edge/http/tutorials.js +59 -0
  43. package/dist/edge/http/youtube-edits.js +11 -2
  44. package/dist/extension/slopify-studio-chrome.zip +0 -0
  45. package/dist/extension/slopify-studio-firefox.zip +0 -0
  46. package/dist/host-cli/gemini-login.js +83 -0
  47. package/dist/host-cli/status.js +4 -2
  48. package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
  49. package/dist/kernel/ports/model.js +5 -3
  50. package/dist/kernel/ports/system-speech.js +203 -0
  51. package/dist/kernel/runner/graph.js +17 -1
  52. package/dist/kernel/runner/index.js +4 -4
  53. package/dist/kernel/runner/providers.js +1 -1
  54. package/dist/main.js +5 -0
  55. package/dist/model-catalog.js +2 -0
  56. package/dist/patch-notes/3.0.1.md +105 -0
  57. package/dist/patch-notes/3.0.2.md +20 -0
  58. package/dist/patch-notes/index.json +12 -0
  59. package/dist/slices/admission/rules.js +3 -3
  60. package/dist/slices/admission/start.js +1 -1
  61. package/dist/slices/channels/cast-match.js +1 -1
  62. package/dist/slices/channels/repo.js +3 -0
  63. package/dist/slices/channels/schema.js +23 -0
  64. package/dist/slices/channels/service.js +6 -6
  65. package/dist/slices/control/index.js +3 -1
  66. package/dist/slices/control/providers.js +4 -1
  67. package/dist/slices/episodes/related.js +2 -2
  68. package/dist/slices/estimate/requests.js +9 -1
  69. package/dist/slices/eta/history.js +56 -0
  70. package/dist/slices/eta/model.js +63 -0
  71. package/dist/slices/eta/view.js +25 -0
  72. package/dist/slices/fixes/rules.js +1 -1
  73. package/dist/slices/fonts/coverage.js +1 -1
  74. package/dist/slices/loudness/model.js +1 -1
  75. package/dist/slices/model-upkeep/usage.js +4 -2
  76. package/dist/slices/narration/pauses-model.js +1 -1
  77. package/dist/slices/narration/pronunciation.js +2 -2
  78. package/dist/slices/notifications/notifier.js +28 -2
  79. package/dist/slices/notifications/rules.js +26 -2
  80. package/dist/slices/onboarding/first-run.js +40 -14
  81. package/dist/slices/onboarding/full-video.js +81 -0
  82. package/dist/slices/onboarding/model.js +21 -0
  83. package/dist/slices/onboarding/quick-short.js +25 -10
  84. package/dist/slices/play-drafts/convert.js +3 -3
  85. package/dist/slices/play-drafts/readiness.js +1 -1
  86. package/dist/slices/project-templates/from-project.js +10 -3
  87. package/dist/slices/project-templates/next-chapter.js +94 -0
  88. package/dist/slices/project-templates/one-off.js +1 -1
  89. package/dist/slices/project-templates/schema.js +7 -1
  90. package/dist/slices/rebuild/recipe-exports.js +5 -0
  91. package/dist/slices/rebuild/recipe-save.js +1 -1
  92. package/dist/slices/rebuild/recipe-voices.js +1 -1
  93. package/dist/slices/rebuild/review-redo.js +15 -0
  94. package/dist/slices/rebuild/runtime-export-bed.js +1 -1
  95. package/dist/slices/rebuild/runtime-review.js +35 -5
  96. package/dist/slices/rebuild/runtime-store.js +23 -1
  97. package/dist/slices/rebuild/runtime-voices.js +2 -2
  98. package/dist/slices/rebuild/service-readiness.js +3 -3
  99. package/dist/slices/reviews/repo.js +16 -4
  100. package/dist/slices/run-cost/panel.js +30 -8
  101. package/dist/slices/run-cost/pricing.js +4 -1
  102. package/dist/slices/schedules/agenda.js +66 -2
  103. package/dist/slices/schedules/repo.js +1 -1
  104. package/dist/slices/schedules/scheduler.js +1 -1
  105. package/dist/slices/schedules/schema.js +43 -2
  106. package/dist/slices/schedules/similar.js +41 -3
  107. package/dist/slices/schedules/topic-list.js +2 -2
  108. package/dist/slices/schedules/topics.js +118 -17
  109. package/dist/slices/settings/first-run.js +5 -1
  110. package/dist/slices/settings/health.js +64 -7
  111. package/dist/slices/settings/key-guides.js +17 -7
  112. package/dist/slices/settings/key-test.js +12 -4
  113. package/dist/slices/settings/keys.js +10 -5
  114. package/dist/slices/settings/model-reach.js +52 -0
  115. package/dist/slices/settings/model.js +49 -0
  116. package/dist/slices/settings/models.js +5 -1
  117. package/dist/slices/settings/readiness.js +14 -2
  118. package/dist/slices/settings/tutorial-schema.js +5 -0
  119. package/dist/slices/shorts/render.js +18 -17
  120. package/dist/slices/storage/documents.js +11 -3
  121. package/dist/slices/storage/downloads.js +33 -1
  122. package/dist/slices/storage/portable.js +4 -2
  123. package/dist/slices/studio/model.js +2 -0
  124. package/dist/slices/studio/pack.js +6 -4
  125. package/dist/slices/studio/queue.js +69 -0
  126. package/dist/slices/studio/settings.js +42 -5
  127. package/dist/slices/style-preview/narration.js +33 -0
  128. package/dist/slices/style-preview/render.js +43 -56
  129. package/dist/slices/style-preview/schema.js +17 -3
  130. package/dist/slices/style-preview/service.js +3 -4
  131. package/dist/slices/style-preview/settings.js +75 -25
  132. package/dist/slices/tutorials/anchors.js +57 -0
  133. package/dist/slices/tutorials/library.js +185 -0
  134. package/dist/slices/voices/audio-files.js +7 -2
  135. package/dist/slices/voices/cast.js +26 -8
  136. package/dist/slices/voices/grouping.js +10 -0
  137. package/dist/slices/voices/model.js +49 -10
  138. package/dist/slices/voices/script.js +1 -1
  139. package/dist/slices/youtube/edits-repo.js +19 -4
  140. package/dist/slices/youtube/placeholders.js +2 -2
  141. package/dist/tutorials/AI-CLIs.md +146 -0
  142. package/dist/tutorials/Backups.md +121 -0
  143. package/dist/tutorials/Calendar.md +103 -0
  144. package/dist/tutorials/Cast-Library.md +112 -0
  145. package/dist/tutorials/Channels.md +148 -0
  146. package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
  147. package/dist/tutorials/Docker.md +171 -0
  148. package/dist/tutorials/Document-Themes.md +207 -0
  149. package/dist/tutorials/Editing-a-Project.md +204 -0
  150. package/dist/tutorials/FAQ.md +134 -0
  151. package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
  152. package/dist/tutorials/Home-and-Projects.md +174 -0
  153. package/dist/tutorials/Home.md +119 -0
  154. package/dist/tutorials/Install.md +154 -0
  155. package/dist/tutorials/Intros-and-Outros.md +86 -0
  156. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
  157. package/dist/tutorials/Library-Overview.md +75 -0
  158. package/dist/tutorials/Models.md +133 -0
  159. package/dist/tutorials/Multiple-Voices.md +161 -0
  160. package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
  161. package/dist/tutorials/Notifications.md +86 -0
  162. package/dist/tutorials/Other-Languages.md +110 -0
  163. package/dist/tutorials/PDF-Documents.md +100 -0
  164. package/dist/tutorials/Play-Images.md +135 -0
  165. package/dist/tutorials/Play-Narration.md +167 -0
  166. package/dist/tutorials/Play-Outputs.md +109 -0
  167. package/dist/tutorials/Play-Overview.md +216 -0
  168. package/dist/tutorials/Play-Title-and-Article.md +135 -0
  169. package/dist/tutorials/Play-Video-and-Style.md +143 -0
  170. package/dist/tutorials/Project-Page.md +228 -0
  171. package/dist/tutorials/Prompts.md +136 -0
  172. package/dist/tutorials/Providers-and-Keys.md +290 -0
  173. package/dist/tutorials/Publishing-to-YouTube.md +135 -0
  174. package/dist/tutorials/Recovery-and-Retries.md +141 -0
  175. package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
  176. package/dist/tutorials/Schedules.md +154 -0
  177. package/dist/tutorials/Settings-Reference.md +258 -0
  178. package/dist/tutorials/Shorts.md +116 -0
  179. package/dist/tutorials/Start-at-Login.md +126 -0
  180. package/dist/tutorials/Studio-Extension.md +146 -0
  181. package/dist/tutorials/Templates.md +146 -0
  182. package/dist/tutorials/Trash.md +87 -0
  183. package/dist/tutorials/Troubleshooting.md +149 -0
  184. package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
  185. package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
  186. package/dist/tutorials/Video-Editing.md +135 -0
  187. package/dist/tutorials/Where-Your-Files-Live.md +149 -0
  188. package/dist/tutorials/YouTube-Description.md +132 -0
  189. package/dist/tutorials/Your-First-Short.md +125 -0
  190. package/dist/tutorials/_Footer.md +1 -0
  191. package/dist/tutorials/_Sidebar.md +69 -0
  192. package/dist/web/assets/index-C4McGNke.js +307 -0
  193. package/dist/web/assets/index-DBLSfqA-.css +1 -0
  194. package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
  195. package/dist/web/index.html +2 -2
  196. package/package.json +2 -2
  197. package/dist/web/assets/index-CUA2sUxU.css +0 -1
  198. package/dist/web/assets/index-CX590kE-.js +0 -303
@@ -3,6 +3,7 @@ import { Hono } from "hono";
3
3
  import { HTTPException } from "hono/http-exception";
4
4
  import { z } from "zod";
5
5
  import { createTemplateFromProject, templateFromProjectSchema, } from "../../slices/project-templates/from-project.js";
6
+ import { makeNextChapter, nextChapterSchema } from "../../slices/project-templates/next-chapter.js";
6
7
  import { templateCreateSchema, templateDeleteSchema, templateInstantiateSchema, templateUpdateSchema, } from "../../slices/project-templates/schema.js";
7
8
  import { createTemplate, deleteTemplate, instantiateTemplate, listTemplates, readTemplate, updateTemplate, } from "../../slices/project-templates/service.js";
8
9
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -37,7 +38,7 @@ function routes(deps) {
37
38
  throw new HTTPException(404);
38
39
  return deps;
39
40
  };
40
- return new Hono()
41
+ return (new Hono()
41
42
  .get("/", (c) => c.json({ templates: listTemplates(service()) }))
42
43
  .post("/from-project/:projectId", zValidator("param", templateFromProjectSchema.unwrap().pick({ projectId: true }), onInvalid), zValidator("json", templateFromProjectSchema.unwrap().omit({ projectId: true }), onInvalid), (c) => {
43
44
  const result = createTemplateFromProject(service(), {
@@ -45,6 +46,22 @@ function routes(deps) {
45
46
  projectId: c.req.valid("param").projectId,
46
47
  });
47
48
  return result.ok ? c.json(result.value, 201) : refused(c, result);
49
+ })
50
+ // A finished audiobook chapter's "Make the next chapter": a new Play draft for the book.
51
+ .post("/next-chapter/:projectId", zValidator("param", nextChapterSchema.unwrap().pick({ projectId: true }), onInvalid), zValidator("json", nextChapterSchema.unwrap().omit({ projectId: true }), onInvalid), (c) => {
52
+ const result = makeNextChapter(service(), {
53
+ ...c.req.valid("json"),
54
+ projectId: c.req.valid("param").projectId,
55
+ });
56
+ if (result.ok)
57
+ return c.json(result.value, 201);
58
+ const status = result.reason === "not-found" ? 404 : result.reason === "conflict" ? 409 : 400;
59
+ return problem(c, {
60
+ status,
61
+ title: titleOf(status),
62
+ detail: result.message,
63
+ extensions: { reason: result.reason },
64
+ });
48
65
  })
49
66
  .post("/", zValidator("json", templateCreateSchema, onInvalid), (c) => {
50
67
  const result = createTemplate(service(), c.req.valid("json"));
@@ -74,5 +91,5 @@ function routes(deps) {
74
91
  templateId: c.req.valid("param").id,
75
92
  });
76
93
  return result.ok ? c.json(result.value, 201) : refused(c, result);
77
- });
94
+ }));
78
95
  }
@@ -6,9 +6,11 @@ import { listProjects, projectById, projectTrashed, runDraftSchema, stageStandin
6
6
  import { defaultChannelId } from "../../slices/channels/model.js";
7
7
  import { projectChannels } from "../../slices/channels/repo.js";
8
8
  import { withProjectControl } from "../../slices/control/lock.js";
9
+ import { stagesWithEta } from "../../slices/eta/view.js";
9
10
  import { resumable } from "../../slices/rebuild/recovery-repo.js";
10
11
  import { adoptBaseline } from "../../slices/revisions/adopt.js";
11
12
  import { currentRevisionId } from "../../slices/revisions/repo.js";
13
+ import { limitWaitsByProject, listingWait } from "../../slices/run-cost/panel.js";
12
14
  import { outputsOf } from "../../slices/storage/repo.js";
13
15
  import { trashProject } from "../../slices/trash/service.js";
14
16
  import { uploadedProjects } from "../../slices/uploads/repo.js";
@@ -56,14 +58,17 @@ export function projectRoutes(deps) {
56
58
  const standings = stageStandingsByProject(deps.db);
57
59
  const channels = projectChannels(deps.db);
58
60
  const uploads = uploadedProjects(deps.db);
61
+ const waits = limitWaitsByProject(deps.db);
59
62
  const projects = listProjects(deps.db).map((project) => {
60
63
  const stages = standings.get(project.id) ?? [];
64
+ const waiting = waits.get(project.id);
61
65
  return {
62
66
  ...project,
63
67
  status: derive(stages, project.paused),
64
68
  progress: progressOf(stages),
65
69
  channelId: channels.get(project.id) ?? defaultChannelId,
66
70
  uploadedAt: uploads.get(project.id) ?? null,
71
+ ...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
67
72
  };
68
73
  });
69
74
  return c.json({ projects });
@@ -85,7 +90,7 @@ export function projectRoutes(deps) {
85
90
  revisionId: currentRevisionId(deps.db, project.id) ?? null,
86
91
  resumable: resumable(deps.db, project.id),
87
92
  project: summarise(project),
88
- stages: stagesOf(deps.db, project.id),
93
+ stages: stagesWithEta(deps.db, stagesOf(deps.db, project.id), project.config, deps.clock.now()),
89
94
  outputs: outputsOf(deps.db, project.id),
90
95
  });
91
96
  })
@@ -4,6 +4,7 @@ import { Hono } from "hono";
4
4
  import { z } from "zod";
5
5
  import { keyProbes } from "../../adapters/key-probes.js";
6
6
  import { videoModelsOf } from "../../catalog/schema.js";
7
+ import { defaultSpeechVoice, detectSpeechCached } from "../../kernel/ports/system-speech.js";
7
8
  import { switchAllRetired, switchRetiredModel } from "../../slices/model-upkeep/switch.js";
8
9
  import { retiredModelUsage, usageKinds, usageSlots } from "../../slices/model-upkeep/usage.js";
9
10
  import { cliPathMaxLength, saveCliPath } from "../../slices/settings/cli-paths.js";
@@ -12,11 +13,12 @@ import { checkProviderHealth } from "../../slices/settings/health.js";
12
13
  import { keyGuides } from "../../slices/settings/key-guides.js";
13
14
  import { testProviderKey } from "../../slices/settings/key-test.js";
14
15
  import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
15
- import { isLocalCliProvider, providerById, providerIds } from "../../slices/settings/model.js";
16
+ import { isUncataloguedProvider, providerById, providerIds } from "../../slices/settings/model.js";
16
17
  import { createModelCatalog } from "../../slices/settings/models.js";
17
18
  import { providerStatuses } from "../../slices/settings/readiness.js";
18
19
  import { onInvalid, problem, titleOf } from "./problem.js";
19
20
  const providerParam = z.object({ id: z.enum(providerIds) });
21
+ const healthQuery = z.object({ provider: z.enum(providerIds).optional() });
20
22
  // ceiling: no format check is allowed on a key, so the only thing said about the value is
21
23
  // that it is a string of a length a key could plausibly have. Raise the bound if a provider
22
24
  // ever issues something longer.
@@ -41,6 +43,7 @@ export function providerRoutes(deps) {
41
43
  db: deps.db,
42
44
  probe: deps.probe,
43
45
  hostCliStatus: deps.hostCliStatus,
46
+ host: deps.speechHost,
44
47
  };
45
48
  const catalog = createModelCatalog({
46
49
  load: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
@@ -61,8 +64,10 @@ export function providerRoutes(deps) {
61
64
  dismissFirstRun(deps.db);
62
65
  return c.body(null, 204);
63
66
  })
64
- // "Check all": every CLI signed in, every key valid, every chosen model still offered.
65
- .post("/health", async (c) => {
67
+ // "Check all": every CLI signed in, every key valid, every chosen model still offered and
68
+ // answering for the key. `?provider=codex` checks that one only (its row's Check again,
69
+ // or a sign-in fix-it's).
70
+ .post("/health", zValidator("query", healthQuery, onInvalid), async (c) => {
66
71
  if (deps.fetch === undefined)
67
72
  return problem(c, { status: 503, title: titleOf(503), detail: noFetch });
68
73
  const catalogue = deps.catalogue;
@@ -74,7 +79,7 @@ export function providerRoutes(deps) {
74
79
  ...(catalogue === undefined ? {} : { catalogue: () => catalogue.read() }),
75
80
  ...(deps.modelsFor === undefined ? {} : { modelsFor: deps.modelsFor }),
76
81
  ...(deps.cliLogin === undefined ? {} : { login: deps.cliLogin }),
77
- }));
82
+ }, c.req.valid("query").provider));
78
83
  })
79
84
  // Checks for new, repriced and retired models now instead of waiting for the daily check.
80
85
  .post("/catalogue/check", async (c) => {
@@ -132,6 +137,19 @@ export function providerRoutes(deps) {
132
137
  // What Settings draws its rails from and Play its dropdowns: every provider, with
133
138
  // the one fact that decides whether it is selectable.
134
139
  .get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
140
+ // The system voice's speech programs and their voices, for Settings → Voices' picker.
141
+ .get("/system-voice/voices", async (c) => {
142
+ const found = await detectSpeechCached(deps.probe, deps.speechHost ?? process);
143
+ return c.json({
144
+ engines: found.engines.map((engine) => ({
145
+ id: engine.id,
146
+ name: engine.name,
147
+ voices: engine.voices,
148
+ defaultVoice: defaultSpeechVoice(engine)?.id ?? null,
149
+ })),
150
+ issue: found.issue ?? null,
151
+ });
152
+ })
135
153
  .get("/:id/models", zValidator("param", providerParam, onInvalid), async (c) => {
136
154
  const { id } = c.req.valid("param");
137
155
  // Animate images lists the provider's image-to-video models, which only the catalogue
@@ -141,7 +159,7 @@ export function providerRoutes(deps) {
141
159
  models: deps.catalogue === undefined ? [] : videoModelsOf(deps.catalogue.read(), id),
142
160
  allowsCustom: false,
143
161
  });
144
- if (deps.catalogue && !isLocalCliProvider(id))
162
+ if (deps.catalogue && !isUncataloguedProvider(id))
145
163
  return c.json({
146
164
  models: deps.catalogue.models(id, providerById(id).family).map((m) => ({
147
165
  ...m,
@@ -179,14 +197,24 @@ export function providerRoutes(deps) {
179
197
  // The response says a key is stored and shows the mask, never the value.
180
198
  return c.json(keyStatus(keys, id));
181
199
  })
182
- // The Test button: the cheapest read the provider offers, answered in plain words.
200
+ // The Test button: the cheapest read the provider offers, answered in plain words. With
201
+ // a `key` in the body, that pasted key is tried instead of the saved one and is neither
202
+ // stored nor logged; with no body, the saved key is.
183
203
  .post("/:id/key/test", zValidator("param", providerParam, onInvalid), async (c) => {
184
204
  const { id } = c.req.valid("param");
185
- if (providerById(id).auth === "cli")
205
+ if (providerById(id).auth !== "key")
186
206
  return refusal(c, id, "cli-provider");
187
207
  if (deps.fetch === undefined)
188
208
  return problem(c, { status: 503, title: titleOf(503), detail: noFetch });
189
- return c.json(await testProviderKey({ db: deps.db, clock: deps.clock, fetch: deps.fetch, probes: keyProbes }, id));
209
+ const raw = await c.req.json().catch(() => undefined);
210
+ const candidate = raw === undefined ? undefined : keyBody.safeParse(raw);
211
+ if (candidate !== undefined && !candidate.success)
212
+ return problem(c, {
213
+ status: 400,
214
+ title: titleOf(400),
215
+ detail: `The pasted ${providerById(id).displayName} key could not be tested: it is empty or longer than any key. Paste the whole key in Settings → Providers → ${providerById(id).displayName}, then choose Test again.`,
216
+ });
217
+ return c.json(await testProviderKey({ db: deps.db, clock: deps.clock, fetch: deps.fetch, probes: keyProbes }, id, candidate?.data.key));
190
218
  })
191
219
  .delete("/:id/key", zValidator("param", providerParam, onInvalid), (c) => {
192
220
  const { id } = c.req.valid("param");
@@ -204,7 +232,9 @@ function refusal(c, id, reason) {
204
232
  return problem(c, {
205
233
  status: 400,
206
234
  title: titleOf(400),
207
- detail: `${name} signs in through its own command-line tool, so it has no API key to save here. Sign in with that tool on your computer instead.`,
235
+ detail: providerById(id).auth === "local"
236
+ ? `${name} is your computer's own speech, so it has no API key to save. It works as soon as a speech program is installed.`
237
+ : `${name} signs in through its own command-line tool, so it has no API key to save here. Sign in with that tool on your computer instead.`,
208
238
  });
209
239
  }
210
240
  if (reason === "absent") {
@@ -3,6 +3,7 @@ import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import { revisionControlSchema } from "../../slices/control/revision-control-schema.js";
5
5
  import { recoverProject } from "../../slices/rebuild/recovery.js";
6
+ import { redoStarting } from "../../slices/rebuild/review-redo.js";
6
7
  import { actOnVerdict, listVerdicts, verdictById } from "../../slices/reviews/repo.js";
7
8
  import { latestReviews } from "../../slices/reviews/view.js";
8
9
  import { currentRevisionId } from "../../slices/revisions/repo.js";
@@ -51,16 +52,25 @@ export function reviewRoutes(deps) {
51
52
  })
52
53
  .post("/:id/reviews/:verdictId/overrule", zValidator("param", verdictParam, onInvalid), (c) => {
53
54
  const { id: projectId, verdictId } = c.req.valid("param");
55
+ const busy = () => problem(c, {
56
+ status: 409,
57
+ title: titleOf(409),
58
+ detail: "This verdict can't be overruled: it passed, or Slopify has already started making the item again. Wait for the new version to land, then reload the project page and overrule its review if it fails again.",
59
+ extensions: { reason: "conflict" },
60
+ });
61
+ // Checked and changed in one synchronous step, so a redo cannot start in between.
62
+ if (redoStarting(verdictId))
63
+ return busy();
54
64
  const result = actOnVerdict(deps.db, projectId, verdictId, "overruled", deps.clock.now().toISOString());
55
65
  if (!result.ok)
56
- return result.reason === "not-found"
57
- ? gone(c)
58
- : problem(c, {
59
- status: 409,
60
- title: titleOf(409),
61
- detail: "This verdict can't be overruled: it passed, or Slopify is already making the item again. Reload the project page to see where it is.",
62
- extensions: { reason: "conflict" },
63
- });
66
+ return result.reason === "not-found" ? gone(c) : busy();
67
+ // A called-off redo released the work that was waiting on the item.
68
+ try {
69
+ deps.runner.tick(projectId);
70
+ }
71
+ catch {
72
+ // The next tick of this project picks the released work up.
73
+ }
64
74
  notify(projectId);
65
75
  return c.json({ review: result.value });
66
76
  })
@@ -1,9 +1,8 @@
1
- import { createReadStream } from "node:fs";
2
- import { Readable } from "node:stream";
3
1
  import { zValidator } from "@hono/zod-validator";
4
2
  import { Hono } from "hono";
5
3
  import { z } from "zod";
6
4
  import { findRevisionDownload, revisionImagesZip } from "../../slices/revisions/downloads.js";
5
+ import { fileResponse } from "./byte-range.js";
7
6
  import { replyForFolder } from "./folder-location.js";
8
7
  import { onInvalid, problem, titleOf } from "./problem.js";
9
8
  const id = z
@@ -36,9 +35,8 @@ export function revisionFileRoutes(deps) {
36
35
  // `?inline=1` lets the project page open a PDF in a browser tab instead of saving
37
36
  // it. Only PDFs: anything else the browser might render stays a download.
38
37
  const inline = c.req.query("inline") === "1" && value.contentType === "application/pdf";
39
- return c.body(Readable.toWeb(createReadStream(value.path)), 200, {
38
+ return fileResponse(c, value.path, value.bytes, {
40
39
  "content-type": value.contentType,
41
- "content-length": String(value.bytes),
42
40
  "content-disposition": `${inline ? "inline" : "attachment"}; filename="${value.filename}"`,
43
41
  "x-content-type-options": "nosniff",
44
42
  });
@@ -3,9 +3,9 @@ import { Hono } from "hono";
3
3
  import { HTTPException } from "hono/http-exception";
4
4
  import { z } from "zod";
5
5
  import { calendarRange } from "../../slices/schedules/agenda.js";
6
- import { calendarMaxDays, calendarSchema, heldTopicSchema, queueMax, scheduleCreateSchema, scheduleRunSchema, scheduleSummarySchema, scheduleUpdateSchema, topicMoveSchema, topicTransferSchema, } from "../../slices/schedules/schema.js";
6
+ import { calendarMaxDays, calendarSchema, heldTopicEditSchema, heldTopicSchema, queueMax, scheduleCreateSchema, scheduleRunSchema, scheduleSummarySchema, scheduleUpdateSchema, topicMoveSchema, topicQueueSchema, topicTransferSchema, } from "../../slices/schedules/schema.js";
7
7
  import { cancelSchedule, createSchedule, deleteSchedule, listScheduleRuns, listSchedules, pauseSchedule, resumeSchedule, updateSchedule, } from "../../slices/schedules/service.js";
8
- import { approveHeldTopics, editHeldTopic, heldTopics, moveTopic, rejectHeldTopic, transferTopic, } from "../../slices/schedules/topics.js";
8
+ import { approveHeldTopics, editHeldTopic, heldTopics, moveTopic, rejectHeldTopic, replaceTopics, transferTopic, } from "../../slices/schedules/topics.js";
9
9
  import { onInvalid, problem, titleOf } from "./problem.js";
10
10
  const id = z.object({ id: z.uuid() });
11
11
  const topicParam = z.object({ id: z.uuid(), topicId: z.string().min(1).max(100) });
@@ -28,7 +28,7 @@ function refused(c, result) {
28
28
  conflict: "This schedule changed while you were editing, possibly because a run just started. Reload the page to see the latest, then make your change again.",
29
29
  "cancel-required": "This schedule is still active. Cancel it first, then delete it. Completed schedules can be deleted straight away.",
30
30
  "missing-template": "The template you picked was deleted or changed. Choose a template again, then save.",
31
- "unsupported-media": "This template uses audio, images, a thumbnail, shorts background music or an ambient sound file you supplied, so it cannot run on a schedule: a scheduled run has no one to attach the file again. Pick a template that generates these instead, or on Play remove the music under Outputs → Export → More shorts options (or pick Rain, Fireplace or Wind under Outputs → Export → Ambient sound) and save the template again.",
31
+ "unsupported-media": "This template uses audio, images, a thumbnail, shorts background music or an ambient sound file you supplied, so it cannot run on a schedule: a scheduled run has no one to attach the file again. Pick a template that generates these instead, or on Play remove the music under Outputs → More shorts options (or pick Rain, Fireplace or Wind under Video and style → Ambient sound) and save the template again.",
32
32
  "not-due": "This one-off time has already passed. Choose a time in the future.",
33
33
  "spend-limit": "The estimated cost is above this schedule's spend limit, or some prices are unknown. Raise the spend limit or choose models with known prices.",
34
34
  readiness: "A provider this template needs is not ready, for example a missing API key, model or voice. Check Settings → Providers, then try again.",
@@ -160,10 +160,14 @@ function routes(deps) {
160
160
  const result = rejectHeldTopic(service(), scheduleId, topicId);
161
161
  return result.ok ? c.json(scheduleSummarySchema.parse(result.value)) : refused(c, result);
162
162
  })
163
- .put("/:id/topics/held/:topicId", zValidator("param", topicParam, onInvalid), zValidator("json", z.object({ title: z.string() }).strict(), onInvalid), (c) => {
163
+ .put("/:id/topics/held/:topicId", zValidator("param", topicParam, onInvalid), zValidator("json", heldTopicEditSchema, onInvalid), (c) => {
164
164
  const { id: scheduleId, topicId } = c.req.valid("param");
165
- const result = editHeldTopic(service(), scheduleId, topicId, c.req.valid("json").title);
165
+ const result = editHeldTopic(service(), scheduleId, topicId, c.req.valid("json"));
166
166
  return result.ok ? c.json(heldTopicSchema.parse(result.value)) : refused(c, result);
167
+ })
168
+ .put("/:id/topics", zValidator("param", id, onInvalid), zValidator("json", topicQueueSchema, onInvalid), (c) => {
169
+ const result = replaceTopics(service(), c.req.valid("param").id, c.req.valid("json"));
170
+ return result.ok ? c.json(scheduleSummarySchema.parse(result.value)) : refused(c, result);
167
171
  })
168
172
  .post("/:id/topics/move", zValidator("param", id, onInvalid), zValidator("json", topicMoveSchema, onInvalid), (c) => {
169
173
  const result = moveTopic(service(), c.req.valid("param").id, c.req.valid("json"));
@@ -1,11 +1,12 @@
1
1
  import { zValidator } from "@hono/zod-validator";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
+ import { projectExists } from "../../slices/admission/repo.js";
4
5
  import { BackupBusyError, planBackup, streamBackup, } from "../../slices/storage/backup-export.js";
5
6
  import { BackupImportRefused, importBackup } from "../../slices/storage/backup-import.js";
6
7
  import { importPortable, portableMaxArchiveBytes, storageUsage, } from "../../slices/storage/portable.js";
7
8
  import { reconcileStorage } from "../../slices/storage/reconcile.js";
8
- import { keepOutputsOnly } from "../../slices/storage/trim.js";
9
+ import { keepOutputsOnly, projectStorage } from "../../slices/storage/trim.js";
9
10
  import { onInvalid, problem, titleOf } from "./problem.js";
10
11
  const idParam = z.object({
11
12
  id: z
@@ -26,6 +27,19 @@ export function storageRoutes(deps) {
26
27
  });
27
28
  return (new Hono()
28
29
  .get("/", (c) => c.json(storageUsage({ db: deps.db, paths: deps.paths, hasInflight: deps.runner.hasInflight })))
30
+ // One project's split between outputs and working files, and what Keep outputs only
31
+ // would free now: the project page offers it once the project has finished.
32
+ .get("/projects/:id", zValidator("param", idParam, onInvalid), (c) => {
33
+ const id = c.req.valid("param").id;
34
+ if (!projectExists(deps.db, id))
35
+ return problem(c, {
36
+ status: 404,
37
+ title: titleOf(404),
38
+ detail: "This project no longer exists. Go back to Projects to pick another.",
39
+ });
40
+ c.header("Cache-Control", "no-store");
41
+ return c.json(projectStorage({ db: deps.db, paths: deps.paths, hasInflight: deps.runner.hasInflight }, id));
42
+ })
29
43
  // Keep outputs only: a finished project drops its working files (`slices/storage/trim.ts`).
30
44
  .post("/projects/:id/keep-outputs", zValidator("param", idParam, onInvalid), (c) => {
31
45
  const result = keepOutputsOnly({ db: deps.db, paths: deps.paths, hasInflight: deps.runner.hasInflight }, c.req.valid("param").id);
@@ -40,7 +54,7 @@ export function storageRoutes(deps) {
40
54
  : problem(c, {
41
55
  status: 409,
42
56
  title: titleOf(409),
43
- detail: "Only a finished project can drop its working files, and this one is running, waiting or has unfinished steps. Let it finish (or cancel it on its project page), then use Keep outputs only again.",
57
+ detail: "Only a finished project can drop its working files, and this one is running, waiting or has unfinished steps. Let it finish (or cancel it on its project page), then drop the working files again from the project page or Settings → Storage.",
44
58
  });
45
59
  })
46
60
  // What Export everything would write, asked before the download starts: a download
@@ -1,12 +1,16 @@
1
- import { createReadStream } from "node:fs";
1
+ import { createReadStream, statSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { Readable } from "node:stream";
3
4
  import { zValidator } from "@hono/zod-validator";
4
5
  import { Hono } from "hono";
5
6
  import { z } from "zod";
7
+ import { projectById } from "../../slices/admission/repo.js";
8
+ import { channelById } from "../../slices/channels/repo.js";
6
9
  import { findDownload } from "../../slices/storage/downloads.js";
7
10
  import { studioPlaylistMax } from "../../slices/studio/model.js";
8
11
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
9
- import { bearerToken, isExtensionOrigin, pairStudioExtension, readStudioPlaylist, resetStudioPairing, saveRealFootage, saveStudioPlaylist, studioPairing, studioPlaylistProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
12
+ import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
13
+ import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylist, resetStudioPairing, saveRealFootage, saveStudioPlaylist, studioPairing, studioPlaylistProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
10
14
  import { onInvalid, problem, titleOf } from "./problem.js";
11
15
  const id = z
12
16
  .string()
@@ -22,18 +26,33 @@ const fileParam = z.object({
22
26
  .regex(/^[a-z0-9-]+$/),
23
27
  });
24
28
  const chooseBody = z.object({ short: z.number().int().min(1).max(99).optional() });
25
- const playlistBody = z.object({ playlist: z.string().max(studioPlaylistMax * 2) });
29
+ const queueItemBody = z.object({
30
+ projectId: id,
31
+ short: z.number().int().min(1).max(99).nullable().optional(),
32
+ });
33
+ const playlistBody = z.object({
34
+ playlist: z.string().max(studioPlaylistMax * 2),
35
+ // A channel's own playlist; absent is the default every other channel uses.
36
+ channelId: id.optional(),
37
+ });
26
38
  const realFootageBody = z.object({ realFootage: z.boolean() });
27
- // How long a "Fill in YouTube Studio" choice stays the one the extension fills in.
28
- const choiceMs = 6 * 60 * 60 * 1000;
39
+ // The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
40
+ const extensionFiles = {
41
+ "chrome.zip": "slopify-studio-chrome.zip",
42
+ "firefox.zip": "slopify-studio-firefox.zip",
43
+ };
44
+ const extensionParam = z.object({ file: z.enum(["chrome.zip", "firefox.zip"]) });
29
45
  // The Studio routes. The ones under `/ext` are the only routes of the app a page from another
30
46
  // origin may read, and only the paired browser extension: every request carries the pairing
31
47
  // token, and the CORS headers name the paired extension's origin, never a web page's and never
32
48
  // `*`. The rest are the Slopify page's own, refused from any other origin.
33
49
  export function studioRoutes(deps) {
34
- // What the person chose to fill in last; kept in memory, so a restart asks them to choose
35
- // again rather than filling in an old choice.
36
- let chosen;
50
+ // What waits to be filled, with each project's title; entries whose project is gone are left
51
+ // out (the extension drops them when it reaches them).
52
+ const queueView = (entries) => entries.flatMap((entry) => {
53
+ const project = projectById(deps.db, entry.projectId);
54
+ return project === undefined ? [] : [{ ...entry, projectTitle: project.title }];
55
+ });
37
56
  const samePage = (c) => {
38
57
  const origin = c.req.header("origin");
39
58
  if (origin === undefined || origin === new URL(c.req.url).origin)
@@ -69,13 +88,23 @@ export function studioRoutes(deps) {
69
88
  const denied = samePage(c);
70
89
  if (denied !== undefined)
71
90
  return denied;
72
- return c.json({ playlist: readStudioPlaylist(deps.db), pairing: studioPairing(deps.db) });
91
+ return c.json({
92
+ playlist: readStudioPlaylist(deps.db),
93
+ channelPlaylists: readChannelPlaylists(deps.db),
94
+ pairing: studioPairing(deps.db),
95
+ });
73
96
  })
74
97
  .put("/settings/playlist", zValidator("json", playlistBody, onInvalid), (c) => {
75
98
  const denied = samePage(c);
76
99
  if (denied !== undefined)
77
100
  return denied;
78
- const raw = c.req.valid("json").playlist;
101
+ const { playlist: raw, channelId } = c.req.valid("json");
102
+ if (channelId !== undefined && channelById(deps.db, channelId) === undefined)
103
+ return problem(c, {
104
+ status: 404,
105
+ title: titleOf(404),
106
+ detail: "The playlist wasn't saved: that channel no longer exists. Reload Settings → YouTube Studio and pick the channel again.",
107
+ });
79
108
  const invalid = studioPlaylistProblem(raw);
80
109
  if (invalid !== undefined)
81
110
  return problem(c, {
@@ -84,7 +113,10 @@ export function studioRoutes(deps) {
84
113
  detail: invalid,
85
114
  extensions: { fields: [{ field: "playlist", message: invalid }] },
86
115
  });
87
- return c.json({ playlist: saveStudioPlaylist(deps.db, raw) });
116
+ return c.json({
117
+ playlist: saveStudioPlaylist(deps.db, raw, channelId),
118
+ channelId: channelId ?? null,
119
+ });
88
120
  })
89
121
  .post("/settings/pairing", (c) => {
90
122
  const denied = samePage(c);
@@ -125,8 +157,43 @@ export function studioRoutes(deps) {
125
157
  title: titleOf(404),
126
158
  detail: `This project has no short ${String(short)}. Reload the project page and press Prepare upload again.`,
127
159
  });
128
- chosen = { projectId, short, at: deps.clock.now().getTime() };
129
- return c.json({ chosen: { projectId, short: short ?? null } });
160
+ const queue = enqueueFill(deps.db, projectId, short ?? null, deps.clock.now());
161
+ return c.json({ chosen: { projectId, short: short ?? null }, queue: queueView(queue) });
162
+ })
163
+ // What waits for the extension, oldest first: each new upload dialog takes the first.
164
+ .get("/queue", (c) => {
165
+ const denied = samePage(c);
166
+ if (denied !== undefined)
167
+ return denied;
168
+ return c.json({ queue: queueView(readFillQueue(deps.db, deps.clock.now())) });
169
+ })
170
+ .post("/queue/remove", zValidator("json", queueItemBody, onInvalid), (c) => {
171
+ const denied = samePage(c);
172
+ if (denied !== undefined)
173
+ return denied;
174
+ const { projectId, short } = c.req.valid("json");
175
+ return c.json({
176
+ queue: queueView(removeFill(deps.db, projectId, short ?? null, deps.clock.now())),
177
+ });
178
+ })
179
+ // The extension's own zips, for Settings' and Prepare upload's Download.
180
+ .get("/extension/:file", zValidator("param", extensionParam, onInvalid), (c) => {
181
+ const { file } = c.req.valid("param");
182
+ const name = extensionFiles[file] ?? "";
183
+ const path = deps.extensionDist === undefined ? undefined : join(deps.extensionDist, name);
184
+ const stat = path === undefined ? undefined : statSync(path, { throwIfNoEntry: false });
185
+ if (path === undefined || stat === undefined || !stat.isFile())
186
+ return problem(c, {
187
+ status: 404,
188
+ title: titleOf(404),
189
+ detail: "The Slopify Studio extension isn't included in this copy of Slopify, so there is nothing to download. Update or reinstall Slopify; when running from the repository, run `npm run build` at its root, which builds the extension too.",
190
+ });
191
+ return c.body(Readable.toWeb(createReadStream(path)), 200, {
192
+ "content-type": "application/zip",
193
+ "content-length": String(stat.size),
194
+ "content-disposition": `attachment; filename="${name}"`,
195
+ "x-content-type-options": "nosniff",
196
+ });
130
197
  })
131
198
  // The extension's routes.
132
199
  .options("/ext/pair", (c) => {
@@ -156,24 +223,37 @@ export function studioRoutes(deps) {
156
223
  allowOrigin(c, false);
157
224
  if (!studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin")))
158
225
  return refused(c);
159
- const fresh = chosen !== undefined && deps.clock.now().getTime() - chosen.at <= choiceMs
160
- ? chosen
161
- : undefined;
162
- if (fresh === undefined)
163
- return problem(c, {
164
- status: 404,
165
- title: titleOf(404),
166
- detail: "Nothing is chosen to fill in. In Slopify, open the project, press Prepare upload, then Fill in YouTube Studio.",
167
- });
168
- const result = uploadPack(deps, fresh.projectId);
169
- const item = result.ok ? packItem(result.pack, fresh.short) : undefined;
170
- if (!result.ok || item === undefined)
171
- return problem(c, {
172
- status: 404,
173
- title: titleOf(404),
174
- detail: "The project chosen to fill in is gone. In Slopify, open the project again and press Fill in YouTube Studio.",
175
- });
176
- return c.json({ pack: result.pack, item });
226
+ // The first waiting item that can still be filled; one whose project or short is gone
227
+ // leaves the queue.
228
+ const now = deps.clock.now();
229
+ let gone = 0;
230
+ for (const entry of readFillQueue(deps.db, now)) {
231
+ const result = uploadPack(deps, entry.projectId);
232
+ const item = result.ok ? packItem(result.pack, entry.short ?? undefined) : undefined;
233
+ if (result.ok && item !== undefined) {
234
+ const waiting = readFillQueue(deps.db, now).length;
235
+ return c.json({ pack: result.pack, item, waiting });
236
+ }
237
+ removeFill(deps.db, entry.projectId, entry.short, now);
238
+ gone += 1;
239
+ }
240
+ return problem(c, {
241
+ status: 404,
242
+ title: titleOf(404),
243
+ detail: gone > 0
244
+ ? "The project chosen to fill in is gone. In Slopify, open the project again, press Prepare upload, then Fill in YouTube Studio."
245
+ : "Nothing is waiting to be filled in. In Slopify, open the project, press Prepare upload, then Fill in YouTube Studio.",
246
+ });
247
+ })
248
+ // The extension filled an item: it leaves the queue, so the next upload dialog takes the
249
+ // next one.
250
+ .post("/ext/filled", zValidator("json", queueItemBody, onInvalid), (c) => {
251
+ allowOrigin(c, false);
252
+ if (!studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin")))
253
+ return refused(c);
254
+ const { projectId, short } = c.req.valid("json");
255
+ const left = removeFill(deps.db, projectId, short ?? null, deps.clock.now());
256
+ return c.json({ waiting: left.length });
177
257
  })
178
258
  .get("/ext/files/:projectId/:asset", zValidator("param", fileParam, onInvalid), (c) => {
179
259
  allowOrigin(c, false);
@@ -1,11 +1,11 @@
1
- import { createReadStream } from "node:fs";
2
- import { Readable } from "node:stream";
3
1
  import { zValidator } from "@hono/zod-validator";
4
2
  import { Hono } from "hono";
5
3
  import { z } from "zod";
6
4
  import { StylePreviewError } from "../../slices/style-preview/render.js";
7
5
  import { stylePreviewRequestSchema } from "../../slices/style-preview/schema.js";
6
+ import { fileResponse } from "./byte-range.js";
8
7
  import { onInvalid, problem, titleOf } from "./problem.js";
8
+ export { byteRange } from "./byte-range.js";
9
9
  // POST renders (or finds) the preview for a set of caption and Look settings and answers where
10
10
  // to play it; GET streams the mp4, with byte ranges so the browser's player can seek.
11
11
  const fileParam = z.object({ file: z.string().regex(/^[a-f0-9]{64}\.mp4$/) });
@@ -45,52 +45,13 @@ export function stylePreviewRoutes(deps) {
45
45
  title: titleOf(404),
46
46
  detail: "This style preview is no longer saved; Slopify keeps only the most recent ones. Press Render again to make it.",
47
47
  });
48
- const headers = {
48
+ return fileResponse(c, saved.path, saved.bytes, {
49
49
  "content-type": "video/mp4",
50
- "accept-ranges": "bytes",
51
50
  "cache-control": "no-cache",
52
51
  "x-content-type-options": "nosniff",
53
- };
54
- const range = byteRange(c.req.header("range"), saved.bytes);
55
- if (range === "unsatisfiable")
56
- return new Response(null, {
57
- status: 416,
58
- headers: { ...headers, "content-range": `bytes */${String(saved.bytes)}` },
59
- });
60
- const { start, end } = range ?? { start: 0, end: saved.bytes - 1 };
61
- const body = Readable.toWeb(createReadStream(saved.path, { start, end }));
62
- return new Response(body, {
63
- status: range === undefined ? 200 : 206,
64
- headers: {
65
- ...headers,
66
- "content-length": String(end - start + 1),
67
- ...(range === undefined
68
- ? {}
69
- : { "content-range": `bytes ${String(start)}-${String(end)}/${String(saved.bytes)}` }),
70
- },
71
52
  });
72
53
  });
73
54
  }
74
- // One `bytes=` range, as browsers ask for media; anything else is answered with the whole file.
75
- export function byteRange(header, size) {
76
- const matched = /^bytes=(\d*)-(\d*)$/.exec(header?.trim() ?? "");
77
- if (matched === null)
78
- return undefined;
79
- const [, from = "", to = ""] = matched;
80
- if (from === "" && to === "")
81
- return undefined;
82
- if (from === "") {
83
- const suffix = Number(to);
84
- if (suffix === 0)
85
- return "unsatisfiable";
86
- return { start: Math.max(0, size - suffix), end: size - 1 };
87
- }
88
- const start = Number(from);
89
- const end = to === "" ? size - 1 : Math.min(Number(to), size - 1);
90
- if (start >= size || end < start)
91
- return "unsatisfiable";
92
- return { start, end };
93
- }
94
55
  function unavailable(c) {
95
56
  return problem(c, {
96
57
  status: 503,