diff --git a/README.md b/README.md index fa1a7574..d945c42a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,25 @@ bun ny sites domains add example.com # attach a custom production domain bun ny sites ssl --no-force-ssl # stop forcing HTTPS on the site's b-cdn.net system host bun ny sites open # open the site's live URL in the browser bun ny sites ci init # add a GitHub Actions workflow (push to main goes live) +bun ny stream library list # list Stream video libraries (videos, storage, traffic, replication regions) +bun ny stream library create my-library # create a video library (omit the name to be prompted; --name also works) +bun ny stream library create my-library --replication-regions NY,SG # replicate the library's storage to New York and Singapore +bun ny stream library show my-library # show one library (accepts a name or ID; omit it to use the linked library, or to pick interactively when nothing is linked). API keys are never printed here, in any output format +bun ny stream library update my-library --resolutions 720p,1080p # edit library settings; omit the flags to edit interactively (encoding tier, codecs, transcribing all have flags) +bun ny stream library credentials my-library --show-secret # deliberately retrieve a library's Stream API key (--read-only for the read-only key; masked without --show-secret) +bun ny stream library delete my-library # delete a library and all of its videos (--force skips the confirmation, and is required non-interactively) +bun ny stream library link my-library # link the directory to a library so video commands can omit it (bun ny stream library unlink removes the link) +bun ny stream video upload ./video.mp4 # upload a local video to the linked library (--title sets the title; files over 2 GB upload resumably via TUS, retried and resumed automatically) +bun ny stream video fetch https://example.com/video.mp4 --lib 12345 # let bunny.net fetch the video server side (--header "Name: value" for an origin that needs auth) +bun ny stream video list # list the videos in the linked library (ID, title, status, size, length, views, upload date) +bun ny stream video show 1a2b3c4d-... # show one video by GUID, including its Direct Play URL +bun ny stream video thumbnail 1a2b3c4d-... --file ./thumb.jpg # set a thumbnail (--url has bunny.net download one instead) +bun ny stream video stats 1a2b3c4d-... # views and watch time for one video (--heatmap, --play-data for the other views) +bun ny stream video cleanup 1a2b3c4d-... --non-configured --dry-run # preview deleting renditions the library no longer configures +bun ny stream collection list # list a library's collections (create/show/rename/delete too; videos join one with --collection, and deleting a collection deletes the videos inside it) +bun ny stream caption add 1a2b3c4d-... en --file ./captions.vtt # upload your own caption file for one language +bun ny stream transcribe 1a2b3c4d-... --languages en,de # paid: transcribe the audio into captions ($0.10 per language-minute) +bun ny stream smart 1a2b3c4d-... --title --chapters # paid: generate a title and chapters from an existing transcript (transcribe the video first if it has no captions) ``` Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). diff --git a/packages/cli/README.md b/packages/cli/README.md index 356d9f04..75f98207 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -978,6 +978,147 @@ Every deploy publishes: the files land in an immutable `deploys//` directory | `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files | | `--force`, `-f` | `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompts | +### `bunny stream` + +> **Experimental**: hidden from `--help` and the landing page while it stabilizes. + +Manage bunny.net Stream through four resource groups: **`bunny stream library`** (the library itself: create, list, inspect, update, delete; aliases `libraries`, `lib`), **`bunny stream video`** (the videos inside one library; alias `videos`), **`bunny stream collection`** (groups of videos within a library; alias `collections`), and **`bunny stream caption`** (caption files on one video; alias `captions`). Three paid operations live alongside them: **`bunny stream encode`**, **`bunny stream transcribe`**, and **`bunny stream smart`**. Library management uses the account API key on the core API; each library also carries its own Stream API key for the video-level API, which the CLI never prints unless you ask for it with `credentials`. Video commands use that per-library key, resolved automatically from the library, so there is nothing extra to configure. + +`library` commands take the library as an optional positional (name or numeric ID); every other stream command takes it as the `--lib`/`--library` flag, since their positional is the video GUID, the collection ID, or a local file path. When the library is omitted it resolves from the directory's linked library (`bunny stream library link`, stored in `.bunny/stream.json`), then an interactive picker, which offers to link the directory to the picked library (destructive commands never offer it). Non-interactive runs (`--output json`, no TTY, or `--force`) error with a hint instead of prompting: pass a library or link the directory. A `video` or `collection` command with no ID behaves the same way, offering a picker interactively and erroring otherwise. `caption` is the exception: both of its commands take the video GUID as a required positional, because a language argument follows it. + +There is deliberately no `video create`: a video is added either by uploading a local file with `video upload`, or by handing bunny.net a URL to download with `video fetch`. + +```bash +# Libraries +bunny stream library list # ID, name, videos, storage, traffic, replication regions +bunny stream library create my-library # interactive: prompts for the name when omitted +bunny stream library create --name my-library # the name also takes a flag +bunny stream library create my-library --replication-regions NY,SG # replicate the underlying storage (create-time only) +bunny stream library create my-library --encoding-tier premium --codecs x264,vp9 # premium encoding with extra codecs +bunny stream library show my-library # details; API keys are never printed here, in any output format +bunny stream library update my-library # interactive: edit name, resolutions, transcribing +bunny stream library update my-library --resolutions 720p,1080p # set just the enabled resolutions +bunny stream library update my-library --transcribing --transcribing-languages en,de +bunny stream library credentials my-library # library ID + API key, masked by default +bunny stream library credentials my-library --show-secret --read-only # reveal the read-only key +bunny stream library delete my-library # confirms with the video count; --force skips it + +# Link the working directory to a library so video commands can omit it +bunny stream library link my-library +bunny stream library unlink + +# Adding videos: upload a local file, or let bunny.net fetch a URL +bunny stream video upload ./video.mp4 # linked library +bunny stream video upload ./video.mp4 --lib 12345 --title "Launch demo" +bunny stream video upload ./video.mp4 --collection 8a7b6c5d-... # straight into a collection +bunny stream video fetch https://example.com/video.mp4 --lib 12345 +bunny stream video fetch https://example.com/video.mp4 --header "Authorization: Bearer abc" + +# Videos within a library (GUIDs come from `video list`) +bunny stream video list # ID, title, status, size, length, views, upload date +bunny stream video list --lib 12345 --search launch # a specific library, filtered by title +bunny stream video list --collection 8a7b6c5d-... # only one collection +bunny stream video show 1a2b3c4d-... # details, including the caption languages and the Direct Play URL +bunny stream video update 1a2b3c4d-... --title "Launch demo" # prompts for the title when omitted +bunny stream video update 1a2b3c4d-... --chapters '[{"title":"Intro","start":0,"end":30}]' +bunny stream video thumbnail 1a2b3c4d-... --file ./thumb.jpg # or --url for bunny.net to download it +bunny stream video resolutions 1a2b3c4d-... # what is configured, encoded, and stored +bunny stream video cleanup 1a2b3c4d-... --non-configured --dry-run # preview a rendition cleanup +bunny stream video stats 1a2b3c4d-... # views and watch time (--heatmap, --play-data) +bunny stream video delete 1a2b3c4d-... # confirms first; --force skips it + +# Collections group a library's videos +bunny stream collection list # ID, name, videos, size +bunny stream collection create --name Tutorials # the name also takes a positional +bunny stream collection show 8a7b6c5d-... +bunny stream collection rename 8a7b6c5d-... --name Guides +bunny stream collection delete 8a7b6c5d-... # deletes the videos inside it too; confirms first + +# Caption files on one video (manual captions, not the paid transcription) +bunny stream caption add 1a2b3c4d-... en --file ./captions.vtt --label "English" +bunny stream caption delete 1a2b3c4d-... en # confirms first; --force skips it + +# Paid operations +bunny stream encode enable --codecs x264,vp9 # switch the library to premium encoding +bunny stream encode reencode 1a2b3c4d-... # re-encode one video with the current settings +bunny stream transcribe 1a2b3c4d-... --languages en,de # $0.10 per language-minute +bunny stream smart 1a2b3c4d-... --title --chapters # needs an existing transcript or captions +``` + +Deleting a library deletes all of its videos, and deleting the linked library also removes the stale `.bunny/stream.json`. `--force` is required for non-interactive deletes, cleanups, and unlinks; without it, a run that cannot prompt exits with an error instead of hanging. On destructive and paid commands `--force` also disables the pickers, so it can never act on something you did not name. + +`library create` and `library update` share the encoding and transcribing flags, and `update` sends only the fields you pass, leaving every other setting untouched. With no flags at all, `update` opens a small interactive editor (name, enabled resolutions, transcribing) prefilled with the library's current values; unattended runs need at least one flag. + +A local upload happens in two steps: the video entry is created first, then the file's bytes are sent to it. If the byte upload fails, the CLI checks the video's status: an entry that never received bytes is deleted so a retry does not leave orphans behind, while anything that may already hold the upload (including a status the CLI could not read) is kept, with a warning naming its GUID, since a lost response does not mean the bytes were rejected. Encoding continues on bunny.net after the command returns, so the reported status is usually `Uploaded` or `Processing` rather than `Finished`. + +Files over 2 GB switch to resumable uploads (TUS) automatically, with no extra flag: the file goes up in 64 MiB chunks, and a chunk that fails is retried from the offset the server reports it actually holds, so a blip in the middle of a large upload does not restart the whole transfer. The spinner reports progress as it goes. Resume state lives only in the running command, so a cancelled upload starts over on the next run. Every other flag behaves identically on both paths. + +`video fetch` uses bunny.net's server-side fetch instead: the origin is downloaded by bunny.net, not by the CLI, so nothing is transferred through your machine and the command returns as soon as the fetch is queued. Use `--header` (repeatable, `"Name: value"`) for an origin that needs authentication. That endpoint answers with a status rather than a video, so `fetch` reports no video ID or Direct Play URL: the video appears in `bunny stream video list` once it has been fetched and encoded, titled after the remote file name unless `--title` says otherwise. + +`video cleanup` deletes encoded renditions, MP4 fallbacks, or the stored original, and needs at least one selector so it can never look like a no-op success. Start with `--dry-run`, which asks the API to report what would go without deleting anything and skips the confirmation. + +A video moves in or out of a collection with `video update --collection ` (an empty value clears it). Creating a collection is free, but deleting one is not just removing a label: the API deletes every video inside it as well, so the confirmation prompt names the count that is about to go. Move the videos you want to keep out of the collection first (`video update --collection ""` clears it), then delete it. `video upload` and `video fetch` can drop a video straight into a collection at ingest, and both also accept `--thumbnail-time ` to pick the main thumbnail from a frame while the video is being ingested. `video thumbnail` replaces the image afterwards, from a URL or a local file. + +`caption add` reads a local `.vtt` or `.srt`, sends it inline, and reports what the API's validator says: a rejected file lists what is wrong, and an accepted file with non-breaking issues prints them as warnings. These are captions you wrote yourself, unrelated to the paid transcription below. + +Three commands cost money and say so before they run. `stream encode enable` switches a library to the premium encoding tier, billed per output codec per minute of encoded video, and `stream encode reencode` regenerates every output for one video at the same rate. `stream transcribe` is billed at $0.10 per language-minute of audio. `stream smart` generates a title, description, chapters, or moments from the transcript, and needs at least one of those flags. It reads an existing transcript and never makes one: a video with no captions is refused before the request goes out, with a pointer to `stream transcribe` (or `stream caption add`, if you have the captions already). Note that `--force` on `stream transcribe` is the API's own force flag, which re-runs and overrides the library defaults, rather than a confirmation skip; on `stream smart` it only disables the pickers. Neither command has a confirmation to skip. + +Every command that operates inside a library accepts `--lib ` (alias `--library`) and falls back to the linked directory, so none of the tables below repeat it. + +Library flags (`bunny stream library ...`): + +| Flag | Commands | Description | +| --------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------ | +| `--name` | `create`, `update` | Library name; on `create` it is interchangeable with the positional | +| `--replication-regions` | `create` | Storage replication region codes, comma-separated or repeated; fixed after creation | +| `--encoding-tier` (`free` \| `premium`) | `create`, `update` | Encoding tier; premium adds JIT encoding and extra codecs | +| `--jit` / `--no-jit` | `create`, `update` | Just-in-time encoding | +| `--codecs` | `create`, `update` | Output codecs, comma-separated: `x264`, `vp9`, `hevc`, `av1` (all but `x264` need premium) | +| `--resolutions` | `create`, `update` | Enabled resolutions, comma-separated: `240p` through `2160p` | +| `--transcribing` / `--no-transcribing` | `create`, `update` | Automatic audio transcribing (billed per use) | +| `--transcribing-languages` | `create`, `update` | Caption languages to transcribe to, comma-separated (e.g. `en,de`) | +| `--transcribing-title`, `--transcribing-description`, `--transcribing-chapters`, `--transcribing-moments` | `create`, `update` | Generate each field from the transcript | +| `--read-only`, `--show-secret` | `credentials` | Use the read-only key; reveal the key (masked by default) | +| `--force`, `-f` | `update`, `unlink`, `delete` | Skip the prompts (required when there is no TTY to answer them) | + +Video flags (`bunny stream video ...`): + +| Flag | Commands | Description | +| ---------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------- | +| `--title` | `upload`, `fetch`, `update` | Video title; defaults to the file name (or the remote file name), prompts on `update` | +| `--collection` | `upload`, `fetch`, `list`, `update` | Collection ID to put the video in, or to filter the listing by | +| `--thumbnail-time` | `upload`, `fetch` | Video time in milliseconds to grab the main thumbnail from at ingest | +| `--header` | `fetch` | Header to send with the fetch as `"Name: value"`; repeatable | +| `--search` | `list` | Only list videos matching this search term | +| `--chapters`, `--moments` | `update` | Replace the chapter or moment list with a JSON array | +| `--url`, `--file` | `thumbnail` | Thumbnail image to download or upload; exactly one is required | +| `--resolutions`, `--non-configured`, `--all`, `--original`, `--mp4`, `--outputs` (`hls` \| `mp4` \| `all`) | `cleanup` | What to delete; at least one selector is required | +| `--dry-run` | `cleanup` | Report what would be deleted without deleting anything | +| `--heatmap`, `--play-data` | `stats` | Show the watch heatmap or the playback data instead of the statistics | +| `--from`, `--to`, `--hourly` | `stats` | Statistics range (UTC) and bucket size | +| `--force`, `-f` | `delete`, `cleanup` | Skip the prompts (required when there is no TTY to answer them) | + +Collection and caption flags: + +| Flag | Commands | Description | +| ------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- | +| `--name` | `collection create`, `collection rename` | Collection name; on `create` it is interchangeable with the positional | +| `--search` | `collection list` | Only list collections matching this search term | +| `--file`, `--label` | `caption add` | Caption file to upload (`.vtt` or `.srt`), and the label shown in the player | +| `--force`, `-f` | `collection delete`, `caption delete` | Skip the prompts (required when there is no TTY to answer them) | + +Paid command flags: + +| Flag | Commands | Description | +| ----------------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------- | +| `--jit`, `--codecs`, `--resolutions` | `encode enable` | Applied in the same request that switches the library to premium | +| `--languages`, `--source-language` | `transcribe` | Target languages (comma-separated) and the language spoken in the video | +| `--generate-title`, `--generate-description`, `--generate-chapters`, `--generate-moments` | `transcribe` | Also generate each field from the new transcript | +| `--title`, `--description`, `--chapters`, `--moments` | `smart` | What to generate; at least one is required | +| `--source-language` | `smart` | Language spoken in the video, as an ISO 639-1 code | +| `--force`, `-f` | `transcribe` | The API's force flag: re-run and override the library's transcribing defaults | +| `--force`, `-f` | `smart` | Disable the library and video pickers, so nothing is guessed | + ### `bunny sandbox` Manage on-demand cloud sandbox environments backed by Bunny Magic Containers. Each sandbox is a fully isolated Ubuntu container with Node.js, Bun, Python (plus `uv`), the bunny CLI, and Claude Code pre-installed, alongside the tooling agents reach for: `git`, `gh`, `ripgrep`, `fd`, `jq`, `tmux`, `sqlite3`, `tree`, and `fzf`. A 10 GB persistent volume is mounted at `/workplace`, your default working directory. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cd68f0e2..c441c975 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -18,6 +18,7 @@ import { scriptsNamespace } from "./commands/scripts/index.ts"; import { sitesNamespace } from "./commands/sites/index.ts"; import { skillsNamespace } from "./commands/skills/index.ts"; import { storageNamespace } from "./commands/storage/index.ts"; +import { streamNamespace } from "./commands/stream/index.ts"; import { whoamiCommand } from "./commands/whoami.ts"; import { bunny } from "./core/colors.ts"; import { logger } from "./core/logger.ts"; @@ -45,6 +46,7 @@ const experimentalCommands: CommandModule[] = [ registriesNamespace, registryNamespace, sitesNamespace, + streamNamespace, ]; let instance = yargs(hideBin(process.argv)) diff --git a/packages/cli/src/commands/stream/api.test.ts b/packages/cli/src/commands/stream/api.test.ts new file mode 100644 index 00000000..c3c3e9df --- /dev/null +++ b/packages/cli/src/commands/stream/api.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import { + type CoreClient, + fetchLibraries, + fetchLibrary, + resolveLibrary, + toSafeVideoLibrary, + type VideoLibraryModel, +} from "./api.ts"; + +interface Call { + method: string; + path: string; + params?: Record; +} + +/** + * Path-branching fake core client (same shape as sites/api.test.ts): only the + * /videolibrary endpoints the stream commands touch are implemented. + * + * GET /videolibrary is modelled on the spec: the { Items, ... } envelope only + * appears when `page` is greater than 0; without it the endpoint answers with + * a plain array, which is what made an omitted `page` silently match nothing. + */ +function fakeCoreClient(opts: { + calls: Call[]; + libraries?: VideoLibraryModel[]; + /** Split the listing across pages so HasMoreItems paging is exercised. */ + pageSize?: number; +}): CoreClient { + const libraries = opts.libraries ?? []; + return { + GET: async ( + path: string, + options?: { + params?: { + path?: { id?: number }; + query?: { page?: number; perPage?: number; search?: string }; + }; + }, + ) => { + opts.calls.push({ method: "GET", path, params: options?.params }); + if (path === "/videolibrary/{id}") { + return { + data: libraries.find((lib) => lib.Id === options?.params?.path?.id), + }; + } + if (path === "/videolibrary") { + const search = options?.params?.query?.search; + const matched = search + ? libraries.filter((lib) => + (lib.Name ?? "").toLowerCase().includes(search.toLowerCase()), + ) + : libraries; + const page = options?.params?.query?.page ?? 0; + // page 0 (or omitted) → plain array, no pagination envelope. + if (page < 1) return { data: matched }; + const pageSize = opts.pageSize ?? Math.max(matched.length, 1); + const start = (page - 1) * pageSize; + return { + data: { + Items: matched.slice(start, start + pageSize), + CurrentPage: page, + TotalItems: matched.length, + HasMoreItems: start + pageSize < matched.length, + }, + }; + } + throw new Error(`unexpected GET ${path}`); + }, + } as unknown as CoreClient; +} + +const LIBRARIES: VideoLibraryModel[] = [ + { Id: 2, Name: "zebra", VideoCount: 1 }, + { Id: 1, Name: "Alpha", VideoCount: 3 }, + { Id: 3, Name: "marketing", VideoCount: 0 }, +]; + +test("fetchLibraries pages through the listing and sorts by name", async () => { + const calls: Call[] = []; + const client = fakeCoreClient({ + calls, + libraries: LIBRARIES, + pageSize: 2, // force a second page + }); + + const libraries = await fetchLibraries(client); + + expect(libraries.map((lib) => lib.Name)).toEqual([ + "Alpha", + "marketing", + "zebra", + ]); + const pages = calls + .filter((c) => c.path === "/videolibrary") + .map((c) => (c.params as { query: { page: number } }).query.page); + expect(pages).toEqual([1, 2]); +}); + +test("fetchLibraries returns an empty list when the account has none", async () => { + expect(await fetchLibraries(fakeCoreClient({ calls: [] }))).toEqual([]); +}); + +test("fetchLibrary throws a UserError when the ID does not exist", async () => { + const client = fakeCoreClient({ calls: [], libraries: LIBRARIES }); + await expect(fetchLibrary(client, 99)).rejects.toThrow( + "Video library 99 not found.", + ); +}); + +test("resolveLibrary treats numeric input as an ID", async () => { + const calls: Call[] = []; + const client = fakeCoreClient({ calls, libraries: LIBRARIES }); + + const lib = await resolveLibrary(client, "3"); + + expect(lib.Name).toBe("marketing"); + // Straight to the by-ID endpoint: no search listing. + expect(calls.map((c) => c.path)).toEqual(["/videolibrary/{id}"]); +}); + +test("resolveLibrary matches a name case-insensitively and re-fetches by ID", async () => { + const calls: Call[] = []; + const client = fakeCoreClient({ calls, libraries: LIBRARIES }); + + const lib = await resolveLibrary(client, "ALPHA"); + + expect(lib.Id).toBe(1); + expect(calls.map((c) => c.path)).toEqual([ + "/videolibrary", + "/videolibrary/{id}", + ]); + const search = calls[0]?.params as { + query: { search: string; page: number }; + }; + expect(search.query.search).toBe("ALPHA"); + // Regression: without page >= 1 the endpoint answers with a plain array, + // data.Items is undefined, and every name lookup "finds" nothing. + expect(search.query.page).toBeGreaterThanOrEqual(1); +}); + +// A search is a substring match server-side, so a partial hit must not be +// mistaken for the requested library. +test("resolveLibrary rejects a partial name match", async () => { + const client = fakeCoreClient({ calls: [], libraries: LIBRARIES }); + await expect(resolveLibrary(client, "market")).rejects.toThrow( + 'No video library found for "market".', + ); +}); + +test("resolveLibrary requires a non-empty reference", async () => { + const client = fakeCoreClient({ calls: [], libraries: LIBRARIES }); + await expect(resolveLibrary(client, " ")).rejects.toThrow( + "A library name or ID is required.", + ); +}); + +describe("toSafeVideoLibrary", () => { + const library = { + Id: 1, + Name: "my-library", + VideoCount: 3, + ApiKey: "rw-secret", + ReadOnlyApiKey: "ro-secret", + // Deprecated, but the API still returns it and its value equals ApiKey. + ApiAccessKey: "rw-secret", + StorageUsage: 1024, + } as VideoLibraryModel; + + test("drops every API key, including the deprecated ApiAccessKey", () => { + const safe = toSafeVideoLibrary(library); + expect("ApiKey" in safe).toBe(false); + expect("ReadOnlyApiKey" in safe).toBe(false); + expect("ApiAccessKey" in safe).toBe(false); + expect(JSON.stringify(safe)).not.toContain("secret"); + }); + + test("preserves every non-secret field", () => { + expect(toSafeVideoLibrary(library)).toEqual({ + Id: 1, + Name: "my-library", + VideoCount: 3, + StorageUsage: 1024, + } as VideoLibraryModel); + }); + + test("does not mutate the original library", () => { + toSafeVideoLibrary(library); + expect(library.ApiKey).toBe("rw-secret"); + }); +}); diff --git a/packages/cli/src/commands/stream/api.ts b/packages/cli/src/commands/stream/api.ts new file mode 100644 index 00000000..8641bcc2 --- /dev/null +++ b/packages/cli/src/commands/stream/api.ts @@ -0,0 +1,87 @@ +import type { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { UserError } from "@/core/errors.ts"; + +export type CoreClient = ReturnType; +export type VideoLibraryModel = components["schemas"]["VideoLibraryModel"]; +export type VideoLibraryCreateModel = + components["schemas"]["VideoLibraryCreateModel"]; +export type VideoLibraryUpdateModel = + components["schemas"]["VideoLibraryUpdateModel"]; + +export type SafeVideoLibrary = Omit< + VideoLibraryModel, + "ApiKey" | "ReadOnlyApiKey" | "ApiAccessKey" +>; + +// Strip the read-write/read-only library keys so list/show/create JSON never +// leaks credentials; use `stream library credentials` to retrieve those on purpose. +// ApiAccessKey is deprecated but carries the same value as ApiKey, so leaving it +// in would leak the write-capable key right back out. +export function toSafeVideoLibrary( + library: VideoLibraryModel, +): SafeVideoLibrary { + const { ApiKey: _k, ReadOnlyApiKey: _r, ApiAccessKey: _a, ...safe } = library; + return safe; +} + +/** Fetch all Stream video libraries on the account, paginated and sorted by name. */ +export async function fetchLibraries( + client: CoreClient, +): Promise { + const libraries: VideoLibraryModel[] = []; + let page = 1; + for (;;) { + const { data } = await client.GET("/videolibrary", { + params: { query: { page, perPage: 1000 } }, + }); + libraries.push(...(data?.Items ?? [])); + if (!data?.HasMoreItems) break; + page++; + } + return libraries.sort((a, b) => (a.Name ?? "").localeCompare(b.Name ?? "")); +} + +/** Fetch a single video library by ID. */ +export async function fetchLibrary( + client: CoreClient, + id: number, +): Promise { + const { data } = await client.GET("/videolibrary/{id}", { + params: { path: { id } }, + }); + if (!data) throw new UserError(`Video library ${id} not found.`); + return data; +} + +/** + * Resolve a library reference (numeric ID or name) to a full library. + * + * Numeric input is treated as a library ID; anything else is matched against + * the account's libraries by name. + */ +export async function resolveLibrary( + client: CoreClient, + nameOrId: string, +): Promise { + const ref = nameOrId.trim(); + if (!ref) throw new UserError("A library name or ID is required."); + + if (/^\d+$/.test(ref)) return fetchLibrary(client, Number(ref)); + + // page must be >= 1: at page 0 the endpoint returns a plain array instead of + // the { Items, ... } envelope, and the match below would never find anything. + const { data } = await client.GET("/videolibrary", { + params: { query: { page: 1, search: ref, perPage: 1000 } }, + }); + const match = (data?.Items ?? []).find( + (lib) => (lib.Name ?? "").toLowerCase() === ref.toLowerCase(), + ); + if (!match?.Id) { + throw new UserError( + `No video library found for "${nameOrId}".`, + 'Run "bunny stream library list" to see your libraries.', + ); + } + return fetchLibrary(client, match.Id); +} diff --git a/packages/cli/src/commands/stream/caption-api.test.ts b/packages/cli/src/commands/stream/caption-api.test.ts new file mode 100644 index 00000000..3bdf8fdf --- /dev/null +++ b/packages/cli/src/commands/stream/caption-api.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from "bun:test"; +import { addVideoCaption, deleteVideoCaption } from "./caption-api.ts"; +import type { StreamClient } from "./videos-api.ts"; + +interface Call { + method: string; + path: string; + init?: Record; +} + +function fakeClient(opts: { calls: Call[]; data?: unknown }): StreamClient { + const record = (method: string) => async (path: string, init?: any) => { + opts.calls.push({ method, path, init }); + return { data: opts.data }; + }; + return { + POST: record("POST"), + DELETE: record("DELETE"), + } as unknown as StreamClient; +} + +test("addVideoCaption posts the base64 file, label, and srclang", async () => { + const calls: Call[] = []; + const client = fakeClient({ + calls, + data: { success: true, data: { valid: true } }, + }); + + const result = await addVideoCaption(client, 4321, "video-guid", "en", { + label: "English", + base64: "V0VCVlRU", + }); + + expect(result.warnings).toEqual([]); + expect(calls[0]?.path).toBe( + "/library/{libraryId}/videos/{videoId}/captions/{srclang}", + ); + expect(calls[0]?.init?.params?.path).toEqual({ + libraryId: 4321, + videoId: "video-guid", + srclang: "en", + }); + // srclang travels in the body as well as the path, per the schema. + expect(calls[0]?.init?.body).toEqual({ + srclang: "en", + label: "English", + captionsFile: "V0VCVlRU", + }); +}); + +// The API validates the file, so a rejected upload has to name what is wrong. +test("addVideoCaption turns an invalid file into an error listing the problems", async () => { + const client = fakeClient({ + calls: [], + data: { + success: false, + message: "Invalid captions file", + data: { valid: false, errorList: ["line 3: bad timestamp"] }, + }, + }); + + const error = (await addVideoCaption(client, 4321, "v", "en", { + base64: "eA==", + }).then( + () => { + throw new Error("expected the upload to be rejected"); + }, + (err) => err, + )) as { message: string; hint?: string }; + + expect(error.message).toContain("The en captions were rejected"); + expect(error.hint).toBe("line 3: bad timestamp"); +}); + +test("addVideoCaption rejects valid:false even when success is true", async () => { + const client = fakeClient({ + calls: [], + data: { success: true, data: { valid: false, errorList: [] } }, + }); + await expect( + addVideoCaption(client, 4321, "v", "en", { base64: "eA==" }), + ).rejects.toThrow(/The en captions were rejected/); +}); + +test("addVideoCaption returns warnings for a valid file with issues", async () => { + const client = fakeClient({ + calls: [], + data: { + success: true, + data: { + valid: true, + warningList: ["overlapping cues"], + warningMessage: "1 warning found", + }, + }, + }); + + const result = await addVideoCaption(client, 4321, "v", "en", { + base64: "eA==", + }); + expect(result.warnings).toEqual(["overlapping cues"]); + expect(result.warningMessage).toBe("1 warning found"); +}); + +test("deleteVideoCaption deletes one language and surfaces a failed status", async () => { + const calls: Call[] = []; + await deleteVideoCaption( + fakeClient({ calls, data: { success: true } }), + 4321, + "video-guid", + "en", + ); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.init?.params?.path?.srclang).toBe("en"); + + await expect( + deleteVideoCaption( + fakeClient({ calls: [], data: { success: false, message: "Not found" } }), + 4321, + "v", + "de", + ), + ).rejects.toThrow("Deleting the de captions failed: Not found"); +}); diff --git a/packages/cli/src/commands/stream/caption-api.ts b/packages/cli/src/commands/stream/caption-api.ts new file mode 100644 index 00000000..ed1f4c72 --- /dev/null +++ b/packages/cli/src/commands/stream/caption-api.ts @@ -0,0 +1,72 @@ +import type { components } from "@bunny.net/openapi-client/generated/stream.d.ts"; +import { UserError } from "@/core/errors.ts"; +import type { StreamClient } from "./videos-api.ts"; + +export type CaptionModel = components["schemas"]["CaptionModel"]; +export type CaptionValidationModel = + components["schemas"]["CaptionValidationModel"]; + +export interface AddCaptionResult { + /** Non-breaking issues the API found in an otherwise valid file. */ + warnings: string[]; + warningMessage?: string; +} + +/** + * Upload a caption file for one language. + * + * The API takes the file base64-encoded in the JSON body and validates it, + * answering with a validation model: an invalid file is a hard failure listing + * what is wrong, and a valid one may still carry warnings worth showing. + */ +export async function addVideoCaption( + client: StreamClient, + libraryId: number, + videoId: string, + srclang: string, + captions: { label?: string; base64: string }, +): Promise { + const { data } = await client.POST( + "/library/{libraryId}/videos/{videoId}/captions/{srclang}", + { + params: { path: { libraryId, videoId, srclang } }, + body: { + srclang, + label: captions.label, + captionsFile: captions.base64, + }, + }, + ); + + const validation = data?.data; + if (data?.success === false || validation?.valid === false) { + const errors = validation?.errorList ?? []; + throw new UserError( + `The ${srclang} captions were rejected: ${data?.message ?? "invalid captions file"}`, + errors.length > 0 ? errors.join("; ") : undefined, + ); + } + + return { + warnings: validation?.warningList ?? [], + warningMessage: validation?.warningMessage ?? undefined, + }; +} + +/** Delete the captions for one language. */ +export async function deleteVideoCaption( + client: StreamClient, + libraryId: number, + videoId: string, + srclang: string, +): Promise { + const { data } = await client.DELETE( + "/library/{libraryId}/videos/{videoId}/captions/{srclang}", + { params: { path: { libraryId, videoId, srclang } } }, + ); + if (data && data.success === false) { + throw new UserError( + `Deleting the ${srclang} captions failed: ${data.message ?? "the request was rejected"}`, + ); + } +} diff --git a/packages/cli/src/commands/stream/caption/add.test.ts b/packages/cli/src/commands/stream/caption/add.test.ts new file mode 100644 index 00000000..5d4a7ce1 --- /dev/null +++ b/packages/cli/src/commands/stream/caption/add.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { captionLanguage, readCaptionFile } from "./add.ts"; + +let dir = ""; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "bunny-stream-caption-")); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +test("captionLanguage normalizes a language code", () => { + expect(captionLanguage("EN")).toBe("en"); + expect(captionLanguage(" de ")).toBe("de"); + expect(captionLanguage("pt-BR")).toBe("pt-br"); + expect(captionLanguage("fil")).toBe("fil"); +}); + +test("captionLanguage rejects something that is not a language code", () => { + expect(() => captionLanguage("english")).toThrow(/Invalid language code/); + expect(() => captionLanguage("e")).toThrow(/Invalid language code/); + expect(() => captionLanguage("./en.vtt")).toThrow(/Invalid language code/); +}); + +test("readCaptionFile base64-encodes the file for the JSON body", async () => { + const file = join(dir, "captions.vtt"); + await Bun.write(file, "WEBVTT\n\n00:00.000 --> 00:02.000\nHello\n"); + + const encoded = await readCaptionFile(file); + + expect(Buffer.from(encoded, "base64").toString()).toContain("WEBVTT"); +}); + +test("readCaptionFile accepts .srt too", async () => { + const file = join(dir, "captions.srt"); + await Bun.write(file, "1\n00:00:00,000 --> 00:00:02,000\nHello\n"); + expect(await readCaptionFile(file)).toBeTruthy(); +}); + +test("readCaptionFile rejects a missing file before any request", async () => { + await expect(readCaptionFile(join(dir, "nope.vtt"))).rejects.toThrow( + /Caption file not found/, + ); +}); + +test("readCaptionFile rejects a directory", async () => { + const nested = join(dir, "subs"); + await mkdir(nested); + await expect(readCaptionFile(nested)).rejects.toThrow( + /is not a regular file/, + ); +}); + +// A video file here would be a silent, expensive mistake. +test("readCaptionFile rejects a file that is not a caption format", async () => { + const file = join(dir, "clip.mp4"); + await Bun.write(file, "not captions"); + await expect(readCaptionFile(file)).rejects.toThrow( + /does not look like a caption file/, + ); +}); + +test("readCaptionFile rejects an empty file", async () => { + const file = join(dir, "empty.vtt"); + await Bun.write(file, ""); + await expect(readCaptionFile(file)).rejects.toThrow(/is empty/); +}); diff --git a/packages/cli/src/commands/stream/caption/add.ts b/packages/cli/src/commands/stream/caption/add.ts new file mode 100644 index 00000000..e45da177 --- /dev/null +++ b/packages/cli/src/commands/stream/caption/add.ts @@ -0,0 +1,160 @@ +import { stat } from "node:fs/promises"; +import { extname } from "node:path"; +import { addVideoCaption } from "@/commands/stream/caption-api.ts"; +import { + resolveVideoInteractive, + streamLibraryContext, +} from "@/commands/stream/context.ts"; +import { defineCommand } from "@/core/define-command.ts"; +import { UserError } from "@/core/errors.ts"; +import { logger } from "@/core/logger.ts"; +import { withSpinner } from "@/core/ui.ts"; + +interface CaptionAddArgs { + video: string; + lang: string; + lib?: string; + label?: string; + file: string; +} + +/** Caption formats bunny.net accepts; anything else is almost certainly a mistake. */ +const CAPTION_EXTENSIONS = [".vtt", ".srt"]; + +/** Normalize a language code the way the API's srclang path segment expects. */ +export function captionLanguage(lang: string): string { + const code = lang.trim().toLowerCase(); + if (!/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(code)) { + throw new UserError( + `Invalid language code "${lang}".`, + "Use an ISO 639-1 code, optionally with a region (e.g. en, de, pt-br).", + ); + } + return code; +} + +/** + * Read a caption file and base64 it for the JSON body. + * + * Validated first: the API takes the bytes inline, so a missing or wrong-typed + * file should fail here rather than as a rejected upload. + */ +export async function readCaptionFile(file: string): Promise { + const entry = await stat(file).catch(() => null); + if (!entry) throw new UserError(`Caption file not found: ${file}`); + if (!entry.isFile()) { + throw new UserError(`${file} is not a regular file.`); + } + if (!CAPTION_EXTENSIONS.includes(extname(file).toLowerCase())) { + throw new UserError( + `${file} does not look like a caption file.`, + `Expected one of: ${CAPTION_EXTENSIONS.join(", ")}.`, + ); + } + if (entry.size === 0) throw new UserError(`${file} is empty.`); + + return Buffer.from(await Bun.file(file).arrayBuffer()).toString("base64"); +} + +export const streamCaptionAddCommand = defineCommand({ + command: "add