@gentbajko/slopify 2.4.0 → 3.0.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.
- package/LICENSE +201 -21
- package/NOTICE +12 -0
- package/README.md +21 -40
- package/SUBTITLES.md +1 -1
- package/dist/adapter-registry.js +11 -1
- package/dist/adapters/alignment/cache.js +4 -1
- package/dist/adapters/alignment/ctc.js +25 -23
- package/dist/adapters/alignment/index.js +37 -6
- package/dist/adapters/alignment/multilingual.js +267 -0
- package/dist/adapters/alignment/protocol.js +11 -0
- package/dist/adapters/alignment/quality.js +4 -3
- package/dist/adapters/alignment/sentences.js +95 -0
- package/dist/adapters/alignment/spec.js +24 -0
- package/dist/adapters/alignment/spell.js +769 -0
- package/dist/adapters/alignment/text.js +56 -9
- package/dist/adapters/alignment/window.js +34 -30
- package/dist/adapters/alignment/worker.js +23 -21
- package/dist/adapters/explain.js +11 -11
- package/dist/adapters/host-cli/index.js +55 -5
- package/dist/adapters/host-cli/transport.js +13 -6
- package/dist/adapters/image/bytes.js +2 -2
- package/dist/adapters/image/codex-output.js +60 -18
- package/dist/adapters/image/codex.js +166 -25
- package/dist/adapters/image/fal.js +31 -3
- package/dist/adapters/image/google.js +17 -2
- package/dist/adapters/image/openai.js +45 -16
- package/dist/adapters/image/reference.js +41 -0
- package/dist/adapters/image/replicate.js +14 -2
- package/dist/adapters/image/video.js +2 -2
- package/dist/adapters/key-probes.js +36 -0
- package/dist/adapters/llm/claude-code.js +168 -32
- package/dist/adapters/llm/cli-login-error.js +1 -1
- package/dist/adapters/llm/codex-limits.js +157 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/codex.js +99 -12
- package/dist/adapters/llm/document-workspace.js +2 -2
- package/dist/adapters/llm/gemini-workspace.js +1 -1
- package/dist/adapters/llm/gemini.js +16 -3
- package/dist/adapters/llm/image-workspace.js +58 -0
- package/dist/adapters/llm/openrouter.js +11 -3
- package/dist/adapters/llm/run-cli.js +26 -9
- package/dist/adapters/tts/cartesia.js +10 -0
- package/dist/adapters/tts/elevenlabs.js +31 -9
- package/dist/adapters/tts/inworld-async.js +9 -5
- package/dist/adapters/tts/inworld.js +17 -3
- package/dist/adapters/tts/openai.js +3 -0
- package/dist/adapters/tts/voice-languages.js +80 -0
- package/dist/assets/fonts/noto/NotoSans-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansArabic-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansDevanagari-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansHebrew-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/NotoSansThai-Regular.ttf +0 -0
- package/dist/assets/fonts/noto/OFL.txt +93 -0
- package/dist/assets/fonts/noto/SOURCE.txt +18 -0
- package/dist/assets/models.yaml +7 -0
- package/dist/assets/sample/sample-audiobook.tar +0 -0
- package/dist/assets/sample/sample-podcast.tar +0 -0
- package/dist/assets/sample/sample-project.tar +0 -0
- package/dist/catalog/merge.js +132 -0
- package/dist/catalog/registry.js +33 -12
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +12 -1
- package/dist/catalog/store.js +147 -8
- package/dist/catalog/validate.js +17 -2
- package/dist/compose.yaml +71 -0
- package/dist/edge/autostart/docker-record.js +32 -0
- package/dist/edge/autostart/docker.js +50 -0
- package/dist/edge/autostart/index.js +78 -0
- package/dist/edge/autostart/launcher.js +176 -0
- package/dist/edge/autostart/model.js +3 -0
- package/dist/edge/autostart/native.js +210 -0
- package/dist/edge/autostart/prompt.js +66 -0
- package/dist/edge/autostart/service.js +81 -0
- package/dist/edge/cli.js +57 -51
- package/dist/edge/docker-install/activation.js +56 -0
- package/dist/edge/docker-install/apply.js +458 -0
- package/dist/edge/docker-install/engine.js +362 -0
- package/dist/edge/docker-install/run.js +154 -0
- package/dist/edge/docker-install/state.js +146 -0
- package/dist/edge/{docker-projects → docker-install}/volume.js +1 -5
- package/dist/edge/docker.js +14 -22
- package/dist/edge/events/hub.js +11 -0
- package/dist/edge/host-cli.js +1 -1
- package/dist/edge/http/actions.js +32 -10
- package/dist/edge/http/app.js +60 -2
- package/dist/edge/http/auditions.js +70 -0
- package/dist/edge/http/autostart.js +37 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/channel-memory.js +92 -0
- package/dist/edge/http/channels.js +128 -0
- package/dist/edge/http/entries.js +22 -3
- package/dist/edge/http/files.js +1 -1
- package/dist/edge/http/folder-location.js +1 -1
- package/dist/edge/http/home.js +39 -0
- package/dist/edge/http/host-cli.js +45 -6
- package/dist/edge/http/narration-peaks.js +76 -0
- package/dist/edge/http/onboarding.js +152 -0
- package/dist/edge/http/open-folder.js +1 -1
- package/dist/edge/http/patch-notes.js +45 -0
- package/dist/edge/http/planning.js +4 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/project-create.js +87 -0
- package/dist/edge/http/projects.js +31 -99
- package/dist/edge/http/prompts.js +40 -3
- package/dist/edge/http/pronunciations.js +23 -4
- package/dist/edge/http/providers.js +86 -0
- package/dist/edge/http/reviews.js +121 -0
- package/dist/edge/http/revision-files.js +1 -1
- package/dist/edge/http/revisions.js +1 -1
- package/dist/edge/http/run-cost.js +25 -0
- package/dist/edge/http/schedules.js +111 -6
- package/dist/edge/http/settings.js +128 -6
- package/dist/edge/http/storage-files.js +50 -0
- package/dist/edge/http/storage.js +29 -2
- package/dist/edge/http/studio.js +207 -0
- package/dist/edge/http/style-preview.js +100 -0
- package/dist/edge/http/subtitles.js +1 -1
- package/dist/edge/http/trash.js +69 -0
- package/dist/edge/http/update.js +24 -3
- package/dist/edge/http/whats-new.js +15 -0
- package/dist/edge/http/youtube-edits.js +83 -0
- package/dist/edge/native-update.js +62 -0
- package/dist/host-cli/open-folder.js +10 -10
- package/dist/host-cli/runtime.js +12 -3
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/db/migrations/0025-automatic-reviews.sql +40 -0
- package/dist/kernel/db/migrations/0026-schedule-topic-generation.sql +23 -0
- package/dist/kernel/db/migrations/0027-run-cost.sql +58 -0
- package/dist/kernel/db/migrations/0030-automatic-retries.sql +18 -0
- package/dist/kernel/db/migrations/0031-prompt-history-and-description-edits.sql +33 -0
- package/dist/kernel/db/migrations/0032-channels.sql +65 -0
- package/dist/kernel/db/migrations/0034-script-prompts.sql +23 -0
- package/dist/kernel/db/migrations/0035-narration-aliases.sql +13 -0
- package/dist/kernel/db/migrations/0037-project-uploads.sql +8 -0
- package/dist/kernel/db/migrations/0038-voice-languages.sql +7 -0
- package/dist/kernel/db/migrations/0039-channel-essentials.sql +64 -0
- package/dist/kernel/db/migrations/0040-standalone-usage.sql +35 -0
- package/dist/kernel/paths.js +27 -6
- package/dist/kernel/pipeline.js +4 -1
- package/dist/kernel/ports/host-cli.js +139 -16
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/key-probe.js +1 -0
- package/dist/kernel/ports/languages.js +157 -0
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/ports/model.js +5 -2
- package/dist/kernel/ports/narration-aliases.js +60 -0
- package/dist/kernel/ports/plan-limits.js +36 -0
- package/dist/kernel/runner/attempt.js +18 -9
- package/dist/kernel/runner/audition.js +36 -0
- package/dist/kernel/runner/graph.js +24 -5
- package/dist/kernel/runner/index.js +35 -5
- package/dist/kernel/runner/meter.js +1 -0
- package/dist/kernel/runner/providers.js +164 -9
- package/dist/kernel/runner/retry-policy.js +43 -0
- package/dist/kernel/runner/standalone.js +134 -0
- package/dist/kernel/runner/work-authority.js +28 -2
- package/dist/main.js +321 -20
- package/dist/patch-notes/0.4.2-to-1.0.md +93 -0
- package/dist/patch-notes/1.0-to-2.0.md +74 -0
- package/dist/patch-notes/2.0-to-3.0.md +257 -0
- package/dist/patch-notes/3.0.0.md +70 -0
- package/dist/patch-notes/index.json +26 -0
- package/dist/slices/admission/model.js +35 -0
- package/dist/slices/admission/repo.js +24 -4
- package/dist/slices/admission/rules.js +126 -1
- package/dist/slices/admission/schema.js +68 -1
- package/dist/slices/admission/short-mode.js +47 -0
- package/dist/slices/admission/start.js +90 -16
- package/dist/slices/article/continuation.js +2 -2
- package/dist/slices/article/provided-entries.js +2 -2
- package/dist/slices/article/run.js +6 -2
- package/dist/slices/article/segments.js +3 -3
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +43 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +9 -2
- package/dist/slices/cancel/index.js +1 -1
- package/dist/slices/channels/cast-images.js +110 -0
- package/dist/slices/channels/cast-match.js +32 -0
- package/dist/slices/channels/images.js +19 -0
- package/dist/slices/channels/model.js +5 -0
- package/dist/slices/channels/rebrand.js +100 -0
- package/dist/slices/channels/repo.js +218 -0
- package/dist/slices/channels/runs.js +129 -0
- package/dist/slices/channels/schema.js +129 -0
- package/dist/slices/channels/service.js +180 -0
- package/dist/slices/channels/studio-csv.js +110 -0
- package/dist/slices/channels/videos.js +120 -0
- package/dist/slices/checkpoints/recovery.js +10 -0
- package/dist/slices/control/index.js +2 -1
- package/dist/slices/document/fonts.js +1 -1
- package/dist/slices/document/render.js +1 -1
- package/dist/slices/episodes/related.js +90 -0
- package/dist/slices/episodes/repo.js +60 -0
- package/dist/slices/episodes/service.js +58 -0
- package/dist/slices/episodes/summarize.js +170 -0
- package/dist/slices/estimate/index.js +113 -24
- package/dist/slices/estimate/requests.js +60 -2
- package/dist/slices/fixes/rules.js +73 -0
- package/dist/slices/fonts/coverage.js +82 -0
- package/dist/slices/fonts/sfnt.js +74 -0
- package/dist/slices/images/run.js +8 -5
- package/dist/slices/images/scale.js +84 -0
- package/dist/slices/library/diff.js +122 -0
- package/dist/slices/library/history.js +100 -0
- package/dist/slices/library/model.js +3 -0
- package/dist/slices/library/photorealistic.js +49 -0
- package/dist/slices/library/repo.js +19 -14
- package/dist/slices/library/save.js +62 -12
- package/dist/slices/library/slots.js +15 -3
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/library/used-by.js +153 -0
- package/dist/slices/loudness/level-pieces.js +41 -0
- package/dist/slices/loudness/loudnorm.js +164 -0
- package/dist/slices/loudness/model.js +153 -0
- package/dist/slices/model-upkeep/model.js +10 -0
- package/dist/slices/model-upkeep/switch.js +217 -0
- package/dist/slices/model-upkeep/usage.js +183 -0
- package/dist/slices/narration/aliases-library.js +31 -0
- package/dist/slices/narration/aliases-schema.js +53 -0
- package/dist/slices/narration/aliases.js +49 -0
- package/dist/slices/narration/blocks.js +352 -0
- package/dist/slices/narration/chunk.js +19 -7
- package/dist/slices/narration/concat.js +2 -2
- package/dist/slices/narration/describe.js +45 -0
- package/dist/slices/narration/pauses-model.js +63 -0
- package/dist/slices/narration/pauses.js +269 -0
- package/dist/slices/narration/peaks-model.js +1 -0
- package/dist/slices/narration/peaks.js +94 -0
- package/dist/slices/narration/preparation.js +21 -3
- package/dist/slices/narration/pronunciation-chunks.js +3 -3
- package/dist/slices/narration/pronunciation.js +72 -25
- package/dist/slices/narration/run.js +6 -6
- package/dist/slices/narration/shared-glossary.js +1 -1
- package/dist/slices/narration/steering.js +15 -4
- package/dist/slices/notifications/notifier.js +94 -0
- package/dist/slices/notifications/rules.js +102 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/onboarding/copy.js +143 -0
- package/dist/slices/onboarding/first-run.js +46 -0
- package/dist/slices/onboarding/install.js +119 -0
- package/dist/slices/onboarding/model.js +64 -0
- package/dist/slices/onboarding/packs.js +491 -0
- package/dist/slices/onboarding/quick-short.js +129 -0
- package/dist/slices/onboarding/sample.js +132 -0
- package/dist/slices/onboarding/state.js +68 -0
- package/dist/slices/onboarding/template.js +73 -0
- package/dist/slices/patch-notes/library.js +73 -0
- package/dist/slices/patch-notes/seen.js +77 -0
- package/dist/slices/play-drafts/convert.js +160 -9
- package/dist/slices/play-drafts/readiness.js +3 -0
- package/dist/slices/play-drafts/repo.js +9 -1
- package/dist/slices/play-drafts/review-inputs.js +22 -3
- package/dist/slices/play-drafts/schema.js +109 -2
- package/dist/slices/play-drafts/service.js +10 -1
- package/dist/slices/play-drafts/start.js +43 -18
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +70 -4
- package/dist/slices/project-templates/one-off.js +26 -0
- package/dist/slices/project-templates/repo.js +2 -1
- package/dist/slices/project-templates/service.js +15 -4
- package/dist/slices/project-templates/setup.js +29 -2
- package/dist/slices/rebuild/admission-repo.js +1 -1
- package/dist/slices/rebuild/model.js +6 -0
- package/dist/slices/rebuild/preview-details.js +67 -2
- package/dist/slices/rebuild/preview-plan.js +13 -6
- package/dist/slices/rebuild/recipe-audio-parts.js +40 -7
- package/dist/slices/rebuild/recipe-audio.js +66 -12
- package/dist/slices/rebuild/recipe-build.js +15 -5
- package/dist/slices/rebuild/recipe-cast.js +18 -0
- package/dist/slices/rebuild/recipe-checkpoints.js +11 -9
- package/dist/slices/rebuild/recipe-describe.js +93 -0
- package/dist/slices/rebuild/recipe-edit.js +13 -1
- package/dist/slices/rebuild/recipe-exports.js +62 -8
- package/dist/slices/rebuild/recipe-input-schema.js +43 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -0
- package/dist/slices/rebuild/recipe-loudness.js +45 -0
- package/dist/slices/rebuild/recipe-model.js +25 -6
- package/dist/slices/rebuild/recipe-pauses.js +7 -0
- package/dist/slices/rebuild/recipe-preparation.js +3 -3
- package/dist/slices/rebuild/recipe-provider-choice.js +11 -1
- package/dist/slices/rebuild/recipe-reference.js +58 -0
- package/dist/slices/rebuild/recipe-reviews.js +117 -0
- package/dist/slices/rebuild/recipe-save.js +4 -1
- package/dist/slices/rebuild/recipe-shorts.js +32 -5
- package/dist/slices/rebuild/recipe-text.js +114 -19
- package/dist/slices/rebuild/recipe-validation.js +29 -3
- package/dist/slices/rebuild/recipe-visual.js +104 -29
- package/dist/slices/rebuild/recipe-voices.js +286 -0
- package/dist/slices/rebuild/recipe-work.js +77 -6
- package/dist/slices/rebuild/recipe-youtube.js +2 -0
- package/dist/slices/rebuild/recovery-conflict.js +7 -3
- package/dist/slices/rebuild/recovery-model.js +3 -0
- package/dist/slices/rebuild/recovery-selection.js +28 -2
- package/dist/slices/rebuild/recovery.js +35 -17
- package/dist/slices/rebuild/repo.js +3 -3
- package/dist/slices/rebuild/retry.js +18 -0
- package/dist/slices/rebuild/review-redo.js +110 -0
- package/dist/slices/rebuild/runtime-actions.js +14 -2
- package/dist/slices/rebuild/runtime-admission.js +4 -1
- package/dist/slices/rebuild/runtime-animate.js +4 -4
- package/dist/slices/rebuild/runtime-article.js +5 -5
- package/dist/slices/rebuild/runtime-document.js +4 -4
- package/dist/slices/rebuild/runtime-export-bed.js +43 -0
- package/dist/slices/rebuild/runtime-export-edit.js +70 -11
- package/dist/slices/rebuild/runtime-export-inputs.js +52 -10
- package/dist/slices/rebuild/runtime-export-short.js +85 -0
- package/dist/slices/rebuild/runtime-export.js +84 -19
- package/dist/slices/rebuild/runtime-figure-card.js +87 -0
- package/dist/slices/rebuild/runtime-image.js +69 -0
- package/dist/slices/rebuild/runtime-local.js +154 -27
- package/dist/slices/rebuild/runtime-materialize.js +8 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +2 -1
- package/dist/slices/rebuild/runtime-narration-text.js +7 -7
- package/dist/slices/rebuild/runtime-pictures.js +28 -0
- package/dist/slices/rebuild/runtime-piece-label.js +29 -0
- package/dist/slices/rebuild/runtime-provider.js +134 -19
- package/dist/slices/rebuild/runtime-publication.js +21 -5
- package/dist/slices/rebuild/runtime-review.js +237 -0
- package/dist/slices/rebuild/runtime-run.js +17 -11
- package/dist/slices/rebuild/runtime-shorts.js +48 -23
- package/dist/slices/rebuild/runtime-store.js +39 -10
- package/dist/slices/rebuild/runtime-subtitles.js +72 -19
- package/dist/slices/rebuild/runtime-voices.js +103 -0
- package/dist/slices/rebuild/runtime-youtube.js +7 -6
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/soften.js +49 -0
- package/dist/slices/rebuild/transition-repo.js +10 -3
- package/dist/slices/research/run.js +4 -4
- package/dist/slices/research/synthesis.js +2 -2
- package/dist/slices/reviews/model.js +105 -0
- package/dist/slices/reviews/outcome.js +22 -0
- package/dist/slices/reviews/repo.js +106 -0
- package/dist/slices/reviews/rules.js +63 -0
- package/dist/slices/reviews/verdict.js +71 -0
- package/dist/slices/reviews/view.js +30 -0
- package/dist/slices/revisions/adopt-content.js +12 -2
- package/dist/slices/revisions/adopt.js +9 -2
- package/dist/slices/revisions/downloads.js +7 -1
- package/dist/slices/revisions/image-plan.js +183 -0
- package/dist/slices/revisions/mutation-assets.js +43 -9
- package/dist/slices/revisions/mutation-cues.js +71 -0
- package/dist/slices/revisions/mutation-prepare.js +8 -10
- package/dist/slices/revisions/mutation-request.js +2 -1
- package/dist/slices/revisions/mutations.js +97 -3
- package/dist/slices/revisions/schema.js +16 -2
- package/dist/slices/run-cost/limits.js +128 -0
- package/dist/slices/run-cost/meter.js +47 -0
- package/dist/slices/run-cost/panel.js +234 -0
- package/dist/slices/run-cost/pricing.js +92 -0
- package/dist/slices/run-cost/week.js +108 -0
- package/dist/slices/schedules/agenda.js +158 -0
- package/dist/slices/schedules/repo.js +58 -18
- package/dist/slices/schedules/scheduler.js +67 -16
- package/dist/slices/schedules/schema.js +138 -1
- package/dist/slices/schedules/service.js +48 -4
- package/dist/slices/schedules/similar.js +90 -0
- package/dist/slices/schedules/topic-list.js +163 -0
- package/dist/slices/schedules/topics.js +422 -0
- package/dist/slices/settings/first-run.js +102 -0
- package/dist/slices/settings/health.js +190 -0
- package/dist/slices/settings/key-guides.js +128 -0
- package/dist/slices/settings/key-test.js +85 -0
- package/dist/slices/settings/model.js +11 -1
- package/dist/slices/settings/playback.js +15 -0
- package/dist/slices/settings/repo.js +17 -3
- package/dist/slices/settings/voices.js +64 -4
- package/dist/slices/settings/whats-new.js +64 -0
- package/dist/slices/shorts/captions.js +9 -0
- package/dist/slices/shorts/pick.js +3 -3
- package/dist/slices/shorts/prompts.js +1 -1
- package/dist/slices/shorts/render.js +54 -5
- package/dist/slices/storage/asset-name.js +3 -0
- package/dist/slices/storage/backup-export.js +48 -9
- package/dist/slices/storage/backup-format.js +38 -1
- package/dist/slices/storage/backup-import.js +191 -19
- package/dist/slices/storage/delete-project.js +7 -4
- package/dist/slices/storage/documents.js +79 -0
- package/dist/slices/storage/files-location.js +531 -0
- package/dist/slices/storage/layout.js +20 -0
- package/dist/slices/storage/model.js +14 -0
- package/dist/slices/storage/portable.js +95 -12
- package/dist/slices/storage/reconcile.js +19 -2
- package/dist/slices/storage/repo.js +4 -1
- package/dist/slices/storage/schema.js +11 -0
- package/dist/slices/storage/trim.js +146 -0
- package/dist/slices/studio/disclosure.js +74 -0
- package/dist/slices/studio/model.js +28 -0
- package/dist/slices/studio/pack.js +209 -0
- package/dist/slices/studio/settings.js +133 -0
- package/dist/slices/style-preview/images.js +61 -0
- package/dist/slices/style-preview/render.js +161 -0
- package/dist/slices/style-preview/schema.js +45 -0
- package/dist/slices/style-preview/service.js +93 -0
- package/dist/slices/style-preview/settings.js +89 -0
- package/dist/slices/subtitles/captions.js +42 -9
- package/dist/slices/subtitles/model.js +22 -0
- package/dist/slices/subtitles/prepare.js +27 -16
- package/dist/slices/subtitles/transcript.js +1 -1
- package/dist/slices/thumbnail/run.js +7 -7
- package/dist/slices/trash/model.js +25 -0
- package/dist/slices/trash/service.js +273 -0
- package/dist/slices/uploads/repo.js +20 -0
- package/dist/slices/video/ambient-bed-schema.js +15 -0
- package/dist/slices/video/ambient-bed.js +95 -0
- package/dist/slices/video/ambient-mix.js +72 -0
- package/dist/slices/video/audio-inputs.js +2 -2
- package/dist/slices/video/cards.js +5 -2
- package/dist/slices/video/edit-list.js +23 -2
- package/dist/slices/video/edit-settings.js +13 -3
- package/dist/slices/video/ffmpeg.js +63 -9
- package/dist/slices/video/figure-card.js +492 -0
- package/dist/slices/video/figure-spans.js +51 -0
- package/dist/slices/video/plan.js +125 -13
- package/dist/slices/video/run.js +14 -12
- package/dist/slices/video/slideshow.js +39 -4
- package/dist/slices/voices/attribution.js +69 -0
- package/dist/slices/voices/audio-files.js +82 -0
- package/dist/slices/voices/audition.js +25 -0
- package/dist/slices/voices/cast.js +56 -0
- package/dist/slices/voices/grouping.js +49 -0
- package/dist/slices/voices/join.js +35 -0
- package/dist/slices/voices/languages.js +25 -0
- package/dist/slices/voices/model.js +179 -0
- package/dist/slices/voices/palette.js +26 -0
- package/dist/slices/voices/panel.js +91 -0
- package/dist/slices/voices/portraits.js +55 -0
- package/dist/slices/voices/script.js +136 -0
- package/dist/slices/voices/timing.js +58 -0
- package/dist/slices/youtube/answer.js +1 -1
- package/dist/slices/youtube/chapters.js +126 -0
- package/dist/slices/youtube/edits-repo.js +69 -0
- package/dist/slices/youtube/edits.js +89 -0
- package/dist/slices/youtube/placeholders.js +98 -0
- package/dist/slices/youtube/transcript.js +11 -4
- package/dist/updater/service.js +66 -21
- package/dist/web/assets/index-CUA2sUxU.css +1 -0
- package/dist/web/assets/index-CX590kE-.js +303 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/web/assets/{pdf-CAJNT254.js → pdf-BG8n_uc5.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +4 -4
- package/dist/edge/docker-launch.js +0 -81
- package/dist/edge/docker-projects/activation.js +0 -35
- package/dist/edge/docker-projects/claims.js +0 -85
- package/dist/edge/docker-projects/committed.js +0 -40
- package/dist/edge/docker-projects/engine.js +0 -461
- package/dist/edge/docker-projects/install.js +0 -254
- package/dist/edge/docker-projects/prune.js +0 -66
- package/dist/edge/docker-projects/recover.js +0 -100
- package/dist/edge/docker-projects/state.js +0 -225
- package/dist/web/assets/index-BGgYMm2u.css +0 -1
- package/dist/web/assets/index-BVVgtntL.js +0 -171
- package/scripts/docker-run.sh +0 -3
- /package/dist/edge/{docker-projects → docker-install}/tree.js +0 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
// Only files whose whole name matches these are this feature's. Pruning and the leftover sweep
|
|
5
|
+
// touch nothing else in the folder: not an Export everything download
|
|
6
|
+
// (slopify-backup-2026-09-27.tar), not a file the user renamed, not a symlink.
|
|
7
|
+
const finalPattern = /^slopify-backup-(\d{4})-(\d{2})-(\d{2})T(\d{2})(\d{2})(\d{2})Z\.tar$/;
|
|
8
|
+
const partialPattern = /^\.slopify-backup-\d{4}-\d{2}-\d{2}T\d{6}Z\.tar\.partial$/;
|
|
9
|
+
export function backupFileName(at) {
|
|
10
|
+
const iso = at.toISOString();
|
|
11
|
+
return `slopify-backup-${iso.slice(0, 10)}T${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}Z.tar`;
|
|
12
|
+
}
|
|
13
|
+
export function isBackupFileName(name) {
|
|
14
|
+
return finalPattern.test(name);
|
|
15
|
+
}
|
|
16
|
+
function createdAtOf(name) {
|
|
17
|
+
const match = finalPattern.exec(name);
|
|
18
|
+
if (match === null)
|
|
19
|
+
return null;
|
|
20
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
21
|
+
return `${y}-${mo}-${d}T${h}:${mi}:${s}.000Z`;
|
|
22
|
+
}
|
|
23
|
+
function partialName(name) {
|
|
24
|
+
return `.${name}.partial`;
|
|
25
|
+
}
|
|
26
|
+
export async function ensureFolder(folder) {
|
|
27
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
28
|
+
const stat = await lstat(folder);
|
|
29
|
+
if (!stat.isDirectory())
|
|
30
|
+
throw Object.assign(new Error("not a folder"), { code: "ENOTDIR" });
|
|
31
|
+
}
|
|
32
|
+
// Writes the archive under a hidden temporary name and renames it into place only after every
|
|
33
|
+
// byte is on disk, so a crash, a full disk or a stop never leaves a file that looks like a
|
|
34
|
+
// finished backup, and pruning never counts one.
|
|
35
|
+
export async function writeAtomically(folder, name, chunks, signal) {
|
|
36
|
+
if (!isBackupFileName(name))
|
|
37
|
+
throw new Error(`${name} is not a backup file name.`);
|
|
38
|
+
const target = join(folder, name);
|
|
39
|
+
if ((await lstat(target).catch(() => undefined)) !== undefined)
|
|
40
|
+
throw Object.assign(new Error(`${name} already exists.`), { code: "EEXIST" });
|
|
41
|
+
const temporary = join(folder, partialName(name));
|
|
42
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
let renamed = false;
|
|
45
|
+
try {
|
|
46
|
+
for await (const chunk of chunks) {
|
|
47
|
+
signal?.throwIfAborted();
|
|
48
|
+
let written = 0;
|
|
49
|
+
while (written < chunk.byteLength) {
|
|
50
|
+
const result = await handle.write(chunk, written, chunk.byteLength - written);
|
|
51
|
+
written += result.bytesWritten;
|
|
52
|
+
}
|
|
53
|
+
bytes += chunk.byteLength;
|
|
54
|
+
// One turn of the event loop per chunk, so requests keep being answered while a large
|
|
55
|
+
// archive is written.
|
|
56
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
57
|
+
}
|
|
58
|
+
signal?.throwIfAborted();
|
|
59
|
+
await handle.sync();
|
|
60
|
+
await handle.close();
|
|
61
|
+
await rename(temporary, target);
|
|
62
|
+
renamed = true;
|
|
63
|
+
await syncFolder(folder);
|
|
64
|
+
return { path: target, bytes };
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
if (!renamed) {
|
|
68
|
+
await handle.close().catch(() => undefined);
|
|
69
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function syncFolder(folder) {
|
|
74
|
+
try {
|
|
75
|
+
const handle = await open(folder, constants.O_RDONLY);
|
|
76
|
+
try {
|
|
77
|
+
await handle.sync();
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await handle.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Some filesystems refuse fsync on a directory; the rename itself already happened.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// This feature's finished backups in the folder, oldest first. The time in the name orders
|
|
88
|
+
// them, not the file's mtime, which a copy or a restore can change.
|
|
89
|
+
export async function listBackups(folder) {
|
|
90
|
+
let names;
|
|
91
|
+
try {
|
|
92
|
+
names = await readdir(folder);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (codeOf(error) === "ENOENT")
|
|
96
|
+
return [];
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const name of names.filter(isBackupFileName).toSorted()) {
|
|
101
|
+
const stat = await lstat(join(folder, name)).catch(() => undefined);
|
|
102
|
+
const createdAt = createdAtOf(name);
|
|
103
|
+
if (stat?.isFile() !== true || createdAt === null)
|
|
104
|
+
continue;
|
|
105
|
+
out.push({ name, bytes: stat.size, createdAt });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
// Deletes this feature's oldest backups until `keep` remain. Nothing else is ever removed.
|
|
110
|
+
export async function pruneBackups(folder, keep) {
|
|
111
|
+
const files = await listBackups(folder);
|
|
112
|
+
const doomed = files.slice(0, Math.max(0, files.length - Math.max(1, keep)));
|
|
113
|
+
for (const file of doomed)
|
|
114
|
+
await rm(join(folder, file.name));
|
|
115
|
+
return doomed.map((file) => file.name);
|
|
116
|
+
}
|
|
117
|
+
// A partial file left by a crash or a power cut. Only called while no backup is being written.
|
|
118
|
+
export async function removeLeftovers(folder) {
|
|
119
|
+
let removed = 0;
|
|
120
|
+
for (const name of await readdir(folder)) {
|
|
121
|
+
if (!partialPattern.test(name))
|
|
122
|
+
continue;
|
|
123
|
+
const stat = await lstat(join(folder, name)).catch(() => undefined);
|
|
124
|
+
if (stat?.isFile() !== true)
|
|
125
|
+
continue;
|
|
126
|
+
await rm(join(folder, name));
|
|
127
|
+
removed += 1;
|
|
128
|
+
}
|
|
129
|
+
return removed;
|
|
130
|
+
}
|
|
131
|
+
export function codeOf(error) {
|
|
132
|
+
return error instanceof Error && "code" in error && typeof error.code === "string"
|
|
133
|
+
? error.code
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { defaultBackupsDir } from "../storage/layout.js";
|
|
3
|
+
export function effectiveFolder(paths, folder) {
|
|
4
|
+
return folder === null ? defaultBackupsDir(paths) : resolve(folder);
|
|
5
|
+
}
|
|
6
|
+
function within(root, path) {
|
|
7
|
+
const inside = relative(root, path);
|
|
8
|
+
return (inside === "" || (!inside.startsWith(`..${sep}`) && inside !== ".." && !isAbsolute(inside)));
|
|
9
|
+
}
|
|
10
|
+
// Why a chosen folder cannot hold backups, as the sentence the Folder field shows; undefined
|
|
11
|
+
// when it can. Staging and logs are swept by Slopify itself, and the projects folder belongs to
|
|
12
|
+
// storage reconciliation except for its Backups folder.
|
|
13
|
+
export function folderProblem(paths, folder) {
|
|
14
|
+
if (folder === null)
|
|
15
|
+
return undefined;
|
|
16
|
+
if (!isAbsolute(folder))
|
|
17
|
+
return "Enter the folder's full path, starting from the top of the disk (for example /home/you/Slopify backups), or leave Folder empty to use the default.";
|
|
18
|
+
const path = resolve(folder);
|
|
19
|
+
if (within(paths.projects, path) && !within(defaultBackupsDir(paths), path))
|
|
20
|
+
return "This folder is inside Slopify's projects folder, which Slopify cleans up on its own. Pick a folder outside it, or leave Folder empty to use the default Backups folder.";
|
|
21
|
+
for (const own of [paths.staging, paths.logs, join(paths.dataDir, "models")])
|
|
22
|
+
if (within(own, path))
|
|
23
|
+
return "This folder is one Slopify uses for its own temporary files. Pick another folder, or leave Folder empty to use the default.";
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// Where the folder is on the user's computer. In Docker only the projects folder (and, from
|
|
27
|
+
// 3.0, the Backups folder) is shared with the host, so a folder elsewhere lives in the
|
|
28
|
+
// container's private volume.
|
|
29
|
+
export function hostFolder(paths, location, folder) {
|
|
30
|
+
if (!location.container)
|
|
31
|
+
return folder;
|
|
32
|
+
const shared = [
|
|
33
|
+
[paths.projects, location.hostProjects],
|
|
34
|
+
[paths.backups, location.hostBackups],
|
|
35
|
+
];
|
|
36
|
+
for (const [inContainer, onHost] of shared.toSorted((a, b) => b[0].length - a[0].length)) {
|
|
37
|
+
if (!onHost || !within(inContainer, folder))
|
|
38
|
+
continue;
|
|
39
|
+
const inside = relative(inContainer, folder);
|
|
40
|
+
return inside === "" ? onHost : join(onHost, inside);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { validTimeZone } from "../schedules/calendar.js";
|
|
3
|
+
// Scheduled backups write the same archive Export everything downloads (storage/backup-export)
|
|
4
|
+
// into a folder on disk, once a day, keeping the newest few. The settings table holds the
|
|
5
|
+
// configuration and the last result under two keys no backup carries: exportableSettings
|
|
6
|
+
// only lets known portable keys through, and a folder path belongs to this machine alone.
|
|
7
|
+
export const configKey = "backups.config";
|
|
8
|
+
export const statusKey = "backups.status";
|
|
9
|
+
export const keepMin = 1;
|
|
10
|
+
export const keepMax = 30;
|
|
11
|
+
export const folderMax = 1024;
|
|
12
|
+
const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Pick a time as HH:MM, 00:00 to 23:59.");
|
|
13
|
+
export const backupConfigInputSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
enabled: z.boolean(),
|
|
16
|
+
time,
|
|
17
|
+
timeZone: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(64)
|
|
21
|
+
.refine(validTimeZone, "This time zone is not one Slopify knows."),
|
|
22
|
+
keep: z
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.min(keepMin)
|
|
26
|
+
.max(keepMax, `Keep between ${keepMin} and ${keepMax} backups.`),
|
|
27
|
+
// null is the default folder, so moving the projects folder moves the backups with it.
|
|
28
|
+
folder: z.string().trim().min(1).max(folderMax).nullable(),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
export const storedConfigSchema = backupConfigInputSchema.extend({
|
|
32
|
+
// When the toggle was last turned on. A slot before it does not count, so turning backups on
|
|
33
|
+
// at 14:00 waits for tonight's 03:00 instead of starting a multi-gigabyte copy at once.
|
|
34
|
+
enabledAt: z.string().nullable(),
|
|
35
|
+
});
|
|
36
|
+
// Off by default, for new and existing installs alike: an archive holds every project's video
|
|
37
|
+
// and can be many gigabytes, so filling a disk with copies is something the user asks for.
|
|
38
|
+
export function defaultConfig(timeZone) {
|
|
39
|
+
return { enabled: false, time: "03:00", timeZone, keep: 5, folder: null, enabledAt: null };
|
|
40
|
+
}
|
|
41
|
+
export const triggers = ["scheduled", "catch-up", "manual"];
|
|
42
|
+
export const backupStatusSchema = z.object({
|
|
43
|
+
lastAttemptAt: z.string().nullable(),
|
|
44
|
+
// "waiting": projects were being made, so the backup is held until they finish.
|
|
45
|
+
lastResult: z.enum(["succeeded", "failed", "waiting"]).nullable(),
|
|
46
|
+
lastTrigger: z.enum(triggers).nullable(),
|
|
47
|
+
// The slot the last attempt was for, so a failure is retried for that slot, not repeated.
|
|
48
|
+
lastSlot: z.string().nullable(),
|
|
49
|
+
detail: z.string().nullable(),
|
|
50
|
+
lastSuccessAt: z.string().nullable(),
|
|
51
|
+
lastSuccessFile: z.string().nullable(),
|
|
52
|
+
lastSuccessBytes: z.number().int().min(0).nullable(),
|
|
53
|
+
lastDurationMs: z.number().int().min(0).nullable(),
|
|
54
|
+
});
|
|
55
|
+
export const emptyStatus = {
|
|
56
|
+
lastAttemptAt: null,
|
|
57
|
+
lastResult: null,
|
|
58
|
+
lastTrigger: null,
|
|
59
|
+
lastSlot: null,
|
|
60
|
+
detail: null,
|
|
61
|
+
lastSuccessAt: null,
|
|
62
|
+
lastSuccessFile: null,
|
|
63
|
+
lastSuccessBytes: null,
|
|
64
|
+
lastDurationMs: null,
|
|
65
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
2
|
+
import { backupStatusSchema, configKey, defaultConfig, emptyStatus, statusKey, storedConfigSchema, } from "./model.js";
|
|
3
|
+
// A value this build cannot read (hand-edited, or written by a newer Slopify) falls back to
|
|
4
|
+
// the default rather than stopping the app; the next save writes a good one.
|
|
5
|
+
export function readConfig(db, fallbackTimeZone) {
|
|
6
|
+
const parsed = storedConfigSchema.safeParse(parseJson(readSetting(db, configKey)));
|
|
7
|
+
return parsed.success ? parsed.data : defaultConfig(fallbackTimeZone);
|
|
8
|
+
}
|
|
9
|
+
export function writeConfig(db, config) {
|
|
10
|
+
writeSetting(db, configKey, JSON.stringify(storedConfigSchema.parse(config)));
|
|
11
|
+
}
|
|
12
|
+
export function readStatus(db) {
|
|
13
|
+
const parsed = backupStatusSchema.safeParse(parseJson(readSetting(db, statusKey)));
|
|
14
|
+
return parsed.success ? parsed.data : emptyStatus;
|
|
15
|
+
}
|
|
16
|
+
export function writeStatus(db, status) {
|
|
17
|
+
writeSetting(db, statusKey, JSON.stringify(backupStatusSchema.parse(status)));
|
|
18
|
+
}
|
|
19
|
+
function parseJson(value) {
|
|
20
|
+
if (value === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(value);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { nextOccurrence } from "../schedules/calendar.js";
|
|
2
|
+
// After a start, the first backup waits this long so it never competes with the boot's own
|
|
3
|
+
// work (migrations, reconcile, the queue resuming).
|
|
4
|
+
export const startupDelayMs = 2 * 60_000;
|
|
5
|
+
// A failed backup is tried again this often until the slot's backup succeeds or the next slot.
|
|
6
|
+
export const retryAfterFailureMs = 60 * 60_000;
|
|
7
|
+
// A backup held back by projects being made looks again this often.
|
|
8
|
+
export const retryAfterWaitingMs = 10 * 60_000;
|
|
9
|
+
// A slot missed while Slopify was off is made up for after the next start, unless a backup
|
|
10
|
+
// already succeeded this recently ("about a day", with room for a slot that ran late).
|
|
11
|
+
export const catchUpFreshMs = 20 * 60 * 60_000;
|
|
12
|
+
// The most recent time-of-day slot at or before `now`, in the configured zone. Days are 23 to 25
|
|
13
|
+
// hours long, so 26 hours back always holds at least one slot.
|
|
14
|
+
export function latestSlot(time, timeZone, now) {
|
|
15
|
+
const daily = { kind: "daily", time };
|
|
16
|
+
let slot = nextOccurrence(daily, timeZone, new Date(now.valueOf() - 26 * 60 * 60_000));
|
|
17
|
+
if (slot === null || slot > now)
|
|
18
|
+
return null;
|
|
19
|
+
for (;;) {
|
|
20
|
+
const next = nextOccurrence(daily, timeZone, slot);
|
|
21
|
+
if (next === null || next > now)
|
|
22
|
+
return slot;
|
|
23
|
+
slot = next;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function nextSlot(time, timeZone, now) {
|
|
27
|
+
return nextOccurrence({ kind: "daily", time }, timeZone, now);
|
|
28
|
+
}
|
|
29
|
+
// At most one automatic backup per daily slot. A slot is due once no backup has succeeded
|
|
30
|
+
// since it; a slot that passed while Slopify was off is caught up after the next start.
|
|
31
|
+
export function decideBackup(input) {
|
|
32
|
+
const { config, status, now, bootedAt } = input;
|
|
33
|
+
if (!config.enabled)
|
|
34
|
+
return { due: false, reason: "off" };
|
|
35
|
+
if (input.running)
|
|
36
|
+
return { due: false, reason: "running" };
|
|
37
|
+
const slot = latestSlot(config.time, config.timeZone, now);
|
|
38
|
+
if (slot === null)
|
|
39
|
+
return { due: false, reason: "not-yet" };
|
|
40
|
+
if (config.enabledAt !== null && slot.valueOf() < Date.parse(config.enabledAt))
|
|
41
|
+
return { due: false, reason: "not-yet" };
|
|
42
|
+
const lastSuccess = status.lastSuccessAt === null ? null : Date.parse(status.lastSuccessAt);
|
|
43
|
+
if (lastSuccess !== null && lastSuccess >= slot.valueOf())
|
|
44
|
+
return { due: false, reason: "done" };
|
|
45
|
+
const missed = bootedAt.valueOf() > slot.valueOf();
|
|
46
|
+
if (missed && lastSuccess !== null && now.valueOf() - lastSuccess < catchUpFreshMs)
|
|
47
|
+
return { due: false, reason: "done" };
|
|
48
|
+
if (now.valueOf() - bootedAt.valueOf() < startupDelayMs)
|
|
49
|
+
return { due: false, reason: "starting" };
|
|
50
|
+
if (status.lastSlot === slot.toISOString() && status.lastAttemptAt !== null) {
|
|
51
|
+
const wait = status.lastResult === "failed"
|
|
52
|
+
? retryAfterFailureMs
|
|
53
|
+
: status.lastResult === "waiting"
|
|
54
|
+
? retryAfterWaitingMs
|
|
55
|
+
: 0;
|
|
56
|
+
if (now.valueOf() - Date.parse(status.lastAttemptAt) < wait)
|
|
57
|
+
return { due: false, reason: "retry-later" };
|
|
58
|
+
}
|
|
59
|
+
return { due: true, slot: slot.toISOString(), trigger: missed ? "catch-up" : "scheduled" };
|
|
60
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { statfs } from "node:fs/promises";
|
|
2
|
+
import { BackupBusyError, planBackup, streamBackup, } from "../storage/backup-export.js";
|
|
3
|
+
import { defaultBackupsDir } from "../storage/layout.js";
|
|
4
|
+
import { backupFileName, codeOf, ensureFolder, listBackups, pruneBackups, removeLeftovers, writeAtomically, } from "./files.js";
|
|
5
|
+
import { effectiveFolder, folderProblem, hostFolder } from "./folder.js";
|
|
6
|
+
import { readConfig, readStatus, writeConfig, writeStatus } from "./repo.js";
|
|
7
|
+
import { decideBackup, nextSlot } from "./schedule.js";
|
|
8
|
+
// Scheduled backups reuse Export everything's own code path (planBackup, streamBackup) and so
|
|
9
|
+
// its rule too: nothing is copied while a project is being made, because its files are still
|
|
10
|
+
// being written. A backup that meets one waits and looks again, so it never competes with a
|
|
11
|
+
// render; the writing itself yields to the event loop after every chunk.
|
|
12
|
+
export function createBackupService(deps) {
|
|
13
|
+
let current;
|
|
14
|
+
let stopped = false;
|
|
15
|
+
const zone = deps.serverTimeZone ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
|
|
16
|
+
const config = () => readConfig(deps.db, zone);
|
|
17
|
+
const folderOf = (value) => effectiveFolder(deps.paths, value.folder);
|
|
18
|
+
const record = (status) => {
|
|
19
|
+
try {
|
|
20
|
+
writeStatus(deps.db, status);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
deps.log.write("error", "backups.status", { detail: String(error) });
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const start = (plan, trigger, slot, release) => {
|
|
27
|
+
const abort = new AbortController();
|
|
28
|
+
const done = write(plan, trigger, slot, abort.signal)
|
|
29
|
+
.catch((error) => {
|
|
30
|
+
deps.log.write("error", "backups.run", { detail: String(error) });
|
|
31
|
+
})
|
|
32
|
+
.finally(() => {
|
|
33
|
+
release();
|
|
34
|
+
current = undefined;
|
|
35
|
+
});
|
|
36
|
+
current = { abort, done };
|
|
37
|
+
return done;
|
|
38
|
+
};
|
|
39
|
+
async function write(plan, trigger, slot, signal) {
|
|
40
|
+
const settings = config();
|
|
41
|
+
const folder = folderOf(settings);
|
|
42
|
+
const startedAt = deps.clock.now();
|
|
43
|
+
const previous = readStatus(deps.db);
|
|
44
|
+
const attempt = {
|
|
45
|
+
...previous,
|
|
46
|
+
lastAttemptAt: startedAt.toISOString(),
|
|
47
|
+
lastTrigger: trigger,
|
|
48
|
+
lastSlot: slot,
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
await ensureFolder(folder);
|
|
52
|
+
await removeLeftovers(folder);
|
|
53
|
+
await checkSpace(folder, plan.archiveBytes);
|
|
54
|
+
const name = backupFileName(startedAt);
|
|
55
|
+
const written = await writeAtomically(folder, name, streamBackup(plan), signal);
|
|
56
|
+
let detail = null;
|
|
57
|
+
try {
|
|
58
|
+
await pruneBackups(folder, settings.keep);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
deps.log.write("warn", "backups.prune", { detail: String(error) });
|
|
62
|
+
detail = `The backup was saved, but older backups in ${folder} could not be removed (${codeOf(error) ?? "unknown error"}), so more than ${settings.keep} are kept. Check Slopify can delete files in that folder, or pick another Folder in Settings → Backups.`;
|
|
63
|
+
}
|
|
64
|
+
record({
|
|
65
|
+
...attempt,
|
|
66
|
+
lastResult: "succeeded",
|
|
67
|
+
detail,
|
|
68
|
+
lastSuccessAt: deps.clock.now().toISOString(),
|
|
69
|
+
lastSuccessFile: written.path,
|
|
70
|
+
lastSuccessBytes: written.bytes,
|
|
71
|
+
lastDurationMs: deps.clock.now().valueOf() - startedAt.valueOf(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
deps.log.write("error", "backups.run", { detail: String(error) });
|
|
76
|
+
record({
|
|
77
|
+
...attempt,
|
|
78
|
+
lastResult: "failed",
|
|
79
|
+
detail: explain(error, folder, trigger !== "manual", signal),
|
|
80
|
+
lastDurationMs: deps.clock.now().valueOf() - startedAt.valueOf(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const waiting = (projects, trigger, slot) => {
|
|
85
|
+
record({
|
|
86
|
+
...readStatus(deps.db),
|
|
87
|
+
lastAttemptAt: deps.clock.now().toISOString(),
|
|
88
|
+
lastResult: "waiting",
|
|
89
|
+
lastTrigger: trigger,
|
|
90
|
+
lastSlot: slot,
|
|
91
|
+
detail: `Waiting for ${named(projects)} to finish: a backup never copies a project while its files are being written. It starts by itself when they finish or are paused.`,
|
|
92
|
+
lastDurationMs: null,
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
view: async () => {
|
|
97
|
+
const settings = config();
|
|
98
|
+
const folder = folderOf(settings);
|
|
99
|
+
const now = deps.clock.now();
|
|
100
|
+
let files = [];
|
|
101
|
+
try {
|
|
102
|
+
files = await listBackups(folder);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
files = [];
|
|
106
|
+
}
|
|
107
|
+
const decision = decideBackup({
|
|
108
|
+
config: settings,
|
|
109
|
+
status: readStatus(deps.db),
|
|
110
|
+
now,
|
|
111
|
+
bootedAt: deps.bootedAt,
|
|
112
|
+
running: current !== undefined,
|
|
113
|
+
});
|
|
114
|
+
const overdue = decision.due || (!decision.due && ["starting", "retry-later"].includes(decision.reason));
|
|
115
|
+
return {
|
|
116
|
+
config: {
|
|
117
|
+
enabled: settings.enabled,
|
|
118
|
+
time: settings.time,
|
|
119
|
+
timeZone: settings.timeZone,
|
|
120
|
+
keep: settings.keep,
|
|
121
|
+
folder: settings.folder,
|
|
122
|
+
},
|
|
123
|
+
folder,
|
|
124
|
+
defaultFolder: defaultBackupsDir(deps.paths),
|
|
125
|
+
hostFolder: hostFolder(deps.paths, deps.location, folder),
|
|
126
|
+
container: deps.location.container,
|
|
127
|
+
running: current !== undefined,
|
|
128
|
+
nextRunAt: settings.enabled
|
|
129
|
+
? (nextSlot(settings.time, settings.timeZone, now)?.toISOString() ?? null)
|
|
130
|
+
: null,
|
|
131
|
+
overdue: settings.enabled && overdue,
|
|
132
|
+
status: readStatus(deps.db),
|
|
133
|
+
files,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
save: (input) => {
|
|
137
|
+
const problem = folderProblem(deps.paths, input.folder);
|
|
138
|
+
if (problem !== undefined)
|
|
139
|
+
return { ok: false, detail: problem };
|
|
140
|
+
const before = config();
|
|
141
|
+
const now = deps.clock.now().toISOString();
|
|
142
|
+
const moved = before.time !== input.time || before.timeZone !== input.timeZone;
|
|
143
|
+
const enabledAt = !input.enabled
|
|
144
|
+
? null
|
|
145
|
+
: !before.enabled || moved || before.enabledAt === null
|
|
146
|
+
? now
|
|
147
|
+
: before.enabledAt;
|
|
148
|
+
writeConfig(deps.db, { ...input, enabledAt });
|
|
149
|
+
return { ok: true };
|
|
150
|
+
},
|
|
151
|
+
runNow: () => {
|
|
152
|
+
if (stopped)
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
status: 409,
|
|
156
|
+
detail: "Slopify is shutting down. Start it again, then press Back up now.",
|
|
157
|
+
};
|
|
158
|
+
if (current !== undefined)
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 409,
|
|
162
|
+
detail: "A backup is already being written. Wait for it to finish; its result shows here.",
|
|
163
|
+
};
|
|
164
|
+
const problem = folderProblem(deps.paths, config().folder);
|
|
165
|
+
if (problem !== undefined)
|
|
166
|
+
return { ok: false, status: 400, detail: problem };
|
|
167
|
+
const release = deps.beginMutation === undefined ? () => { } : deps.beginMutation();
|
|
168
|
+
if (release === undefined)
|
|
169
|
+
return busyUpdating();
|
|
170
|
+
let plan;
|
|
171
|
+
try {
|
|
172
|
+
plan = planBackup(deps);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
release();
|
|
176
|
+
if (error instanceof BackupBusyError)
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
status: 409,
|
|
180
|
+
detail: `Slopify can't back up while ${named(error.projects)} ${error.projects.length === 1 ? "is" : "are"} being made: their files are still being written. Wait for them to finish, or pause them on their project page, then press Back up now again.`,
|
|
181
|
+
};
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
void start(plan, "manual", null, release);
|
|
185
|
+
return { ok: true };
|
|
186
|
+
},
|
|
187
|
+
tick: async () => {
|
|
188
|
+
if (stopped || current !== undefined)
|
|
189
|
+
return;
|
|
190
|
+
const settings = config();
|
|
191
|
+
const decision = decideBackup({
|
|
192
|
+
config: settings,
|
|
193
|
+
status: readStatus(deps.db),
|
|
194
|
+
now: deps.clock.now(),
|
|
195
|
+
bootedAt: deps.bootedAt,
|
|
196
|
+
running: false,
|
|
197
|
+
});
|
|
198
|
+
if (!decision.due)
|
|
199
|
+
return;
|
|
200
|
+
if (folderProblem(deps.paths, settings.folder) !== undefined)
|
|
201
|
+
return;
|
|
202
|
+
const release = deps.beginMutation === undefined ? () => { } : deps.beginMutation();
|
|
203
|
+
// An update is being installed; the backup runs after the restart.
|
|
204
|
+
if (release === undefined)
|
|
205
|
+
return;
|
|
206
|
+
let plan;
|
|
207
|
+
try {
|
|
208
|
+
plan = planBackup(deps);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
release();
|
|
212
|
+
if (error instanceof BackupBusyError) {
|
|
213
|
+
waiting(error.projects, decision.trigger, decision.slot);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
deps.log.write("error", "backups.plan", { detail: String(error) });
|
|
217
|
+
record({
|
|
218
|
+
...readStatus(deps.db),
|
|
219
|
+
lastAttemptAt: deps.clock.now().toISOString(),
|
|
220
|
+
lastResult: "failed",
|
|
221
|
+
lastTrigger: decision.trigger,
|
|
222
|
+
lastSlot: decision.slot,
|
|
223
|
+
detail: "The backup was not saved because Slopify could not read what to back up. It tries again in an hour; if it keeps failing, use Download diagnostics in Settings and report it.",
|
|
224
|
+
lastDurationMs: null,
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
await start(plan, decision.trigger, decision.slot, release);
|
|
229
|
+
},
|
|
230
|
+
stop: async () => {
|
|
231
|
+
stopped = true;
|
|
232
|
+
const running = current;
|
|
233
|
+
if (running === undefined)
|
|
234
|
+
return;
|
|
235
|
+
running.abort.abort(new Error("Slopify is stopping."));
|
|
236
|
+
await running.done;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function busyUpdating() {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
status: 409,
|
|
244
|
+
detail: "Slopify is installing an update. Press Back up now again after it restarts.",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async function checkSpace(folder, needed) {
|
|
248
|
+
let free;
|
|
249
|
+
try {
|
|
250
|
+
const stats = await statfs(folder);
|
|
251
|
+
free = Number(stats.bavail) * Number(stats.bsize);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (free < needed)
|
|
257
|
+
throw new NoSpaceError(needed, free);
|
|
258
|
+
}
|
|
259
|
+
class NoSpaceError extends Error {
|
|
260
|
+
needed;
|
|
261
|
+
free;
|
|
262
|
+
constructor(needed, free) {
|
|
263
|
+
super("Not enough free space for the backup.");
|
|
264
|
+
this.needed = needed;
|
|
265
|
+
this.free = free;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function named(projects) {
|
|
269
|
+
const titles = projects
|
|
270
|
+
.slice(0, 3)
|
|
271
|
+
.map((project) => `"${project.title}"`)
|
|
272
|
+
.join(", ");
|
|
273
|
+
return projects.length > 3 ? `${titles} and ${projects.length - 3} more` : titles;
|
|
274
|
+
}
|
|
275
|
+
// What failed, why, and what to change - the sentence Settings → Backups shows.
|
|
276
|
+
export function explain(error, folder, retries, signal) {
|
|
277
|
+
const fix = "Pick another Folder in Settings → Backups, or leave it empty to use the default";
|
|
278
|
+
if (signal?.aborted === true && error === signal.reason)
|
|
279
|
+
return "The backup was not saved: Slopify stopped while writing it, and the unfinished file was removed. It runs again after Slopify starts.";
|
|
280
|
+
if (error instanceof NoSpaceError)
|
|
281
|
+
return `The backup was not saved: it needs about ${bytes(error.needed)}, but the disk holding ${folder} has ${bytes(error.free)} free. Free up space there, lower "Keep last" in Settings → Backups, or pick another Folder.`;
|
|
282
|
+
switch (codeOf(error)) {
|
|
283
|
+
case "ENOSPC":
|
|
284
|
+
case "EDQUOT":
|
|
285
|
+
return `The backup was not saved: the disk holding ${folder} ran out of space. Free up space there, lower "Keep last" in Settings → Backups, or pick another Folder.`;
|
|
286
|
+
case "EACCES":
|
|
287
|
+
case "EPERM":
|
|
288
|
+
case "EROFS":
|
|
289
|
+
return `The backup was not saved: Slopify is not allowed to write to ${folder}. ${fix}.`;
|
|
290
|
+
case "ENOTDIR":
|
|
291
|
+
case "EEXIST":
|
|
292
|
+
return `The backup was not saved: ${folder} (or a part of its path) is a file, not a folder. ${fix}.`;
|
|
293
|
+
}
|
|
294
|
+
const again = retries
|
|
295
|
+
? "It tries again within the hour, or press Back up now"
|
|
296
|
+
: "Press Back up now to try again";
|
|
297
|
+
if (error instanceof Error &&
|
|
298
|
+
error.message.endsWith("changed while the backup was being written."))
|
|
299
|
+
return `The backup was not saved: a project file changed while it was being copied. ${again}.`;
|
|
300
|
+
return `The backup was not saved because of an unexpected error. ${again}; if it keeps failing, use Download diagnostics in Settings and report it.`;
|
|
301
|
+
}
|
|
302
|
+
function bytes(value) {
|
|
303
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
304
|
+
let amount = value;
|
|
305
|
+
let unit = 0;
|
|
306
|
+
while (amount >= 1024 && unit < units.length - 1) {
|
|
307
|
+
amount /= 1024;
|
|
308
|
+
unit += 1;
|
|
309
|
+
}
|
|
310
|
+
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
|
|
311
|
+
}
|