diff --git a/README.md b/README.md index fa1a7574..dc36e7f4 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,18 @@ 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) +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 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 upload ./video.mp4 # upload a video to the linked library (--title sets the title; it defaults to the file name) +bun ny stream upload ./video.mp4 --lib 12345 # upload to a specific library by ID +bun ny stream upload 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 videos list # list the videos in the linked library (ID, title, status, size, length, views, upload date) +bun ny stream videos show 1a2b3c4d-... # show one video by GUID, including its Direct Play URL (also update and delete; new videos come from stream upload) +bun ny stream link my-library # link the directory to a library so upload can omit it (bun ny stream unlink removes the link) ``` 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 a8ed7c43..03aa88ee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -978,6 +978,63 @@ 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 video libraries with **`bunny stream library`** (aliases `libraries`, `lib`), and upload videos into them with **`bunny stream upload`**. 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`. Uploads and video commands use that per-library key, resolved automatically from the library, so there is nothing extra to configure. + +The videos inside a library are managed with **`bunny stream videos`** (alias `video`): `list`, `show`, `update`, and `delete`, each taking the video's GUID and resolving the library the same way `upload` does. There is deliberately no `videos create`: `bunny stream upload` is how a video is added. + +`library` commands take the library as an optional positional (name or numeric ID); `upload` and the `videos` commands take it as the `--lib`/`--library` flag (their positional is the local file path or the video GUID). When the library is omitted it resolves from the directory's linked library (`bunny stream 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) error with a hint instead of prompting: pass a library or link the directory. A `videos` command with no GUID behaves the same way, offering a picker interactively and erroring otherwise. + +```bash +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 my-library --replication-regions NY,SG # replicate the underlying storage (create-time only) +bunny stream library show my-library # details; API keys are never printed here, in any output format +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 + +# Upload a video (creates the video, streams the file, then reports its encoding status) +bunny stream upload ./video.mp4 # linked library +bunny stream upload ./video.mp4 --lib 12345 # a specific library +bunny stream upload ./video.mp4 --title "Launch demo" # defaults to the file name + +# Or hand bunny.net a URL and let it fetch the video server side +bunny stream upload https://example.com/video.mp4 --lib 12345 +bunny stream upload https://example.com/video.mp4 --header "Authorization: Bearer abc" + +# Videos within a library (GUIDs come from `videos list`) +bunny stream videos list # ID, title, status, size, length, views, upload date +bunny stream videos list --lib 12345 --search launch # a specific library, filtered by title +bunny stream videos show 1a2b3c4d-... # details, including the Direct Play URL +bunny stream videos update 1a2b3c4d-... --title "Launch demo" # prompts for the title when omitted +bunny stream videos delete 1a2b3c4d-... # confirms first; --force skips it + +# Link the working directory to a library so commands can omit it +bunny stream link my-library +bunny stream unlink +``` + +Deleting a library deletes all of its videos. `--force` is required for non-interactive deletes and unlinks; without it, a run that cannot prompt exits with an error instead of hanging. + +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`. + +Passing an `http://` or `https://` URL instead of a path switches to bunny.net's server-side fetch: 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; it applies to URL uploads only. That endpoint answers with a status rather than a video, so a URL upload reports no video ID or Direct Play URL: the video appears in `bunny stream videos list` once it has been fetched and encoded, titled after the remote file name unless `--title` says otherwise. + +| Flag | Commands | Description | +| ----------------------- | ----------------------------------- | ----------------------------------------------------------------------------------- | +| `--replication-regions` | `create` | Storage replication region codes, comma-separated or repeated; fixed after creation | +| `--read-only` | `credentials` | Show the read-only API key instead of the read-write one | +| `--show-secret` | `credentials` | Reveal the API key (masked by default in both table and JSON output) | +| `--lib`, `--library` | `upload`, all `videos` commands | Video library ID (defaults to the linked library) | +| `--search` | `videos list` | Only list videos matching this search term | +| `--title` | `upload`, `videos update` | Video title; defaults to the file name on `upload`, prompts on `update` | +| `--header` | `upload` (URL only) | Header to send with a URL fetch as `"Name: value"`; repeatable | +| `--force`, `-f` | `delete`, `videos delete`, `unlink` | Skip the confirmation prompt (required when there is no TTY to answer it) | + ### `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 0679e267..804013c9 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[] = [ registryNamespace, sitesNamespace, storageNamespace, + 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..31092652 --- /dev/null +++ b/packages/cli/src/commands/stream/api.ts @@ -0,0 +1,85 @@ +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 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/constants.ts b/packages/cli/src/commands/stream/constants.ts new file mode 100644 index 00000000..62b88ebd --- /dev/null +++ b/packages/cli/src/commands/stream/constants.ts @@ -0,0 +1,7 @@ +// `.bunny/stream.json` is written by `bunny stream link` and resolved by stream commands. +export const STREAM_MANIFEST = "stream.json"; + +export interface StreamLibraryManifest { + id: number; + name?: string; +} diff --git a/packages/cli/src/commands/stream/index.ts b/packages/cli/src/commands/stream/index.ts new file mode 100644 index 00000000..0144b12c --- /dev/null +++ b/packages/cli/src/commands/stream/index.ts @@ -0,0 +1,14 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { streamLibraryNamespace } from "./library/index.ts"; +import { streamLinkCommand } from "./link.ts"; +import { streamUnlinkCommand } from "./unlink.ts"; +import { streamUploadCommand } from "./upload.ts"; +import { streamVideoNamespace } from "./videos/index.ts"; + +export const streamNamespace = defineNamespace("stream", false, [ + streamLibraryNamespace, + streamVideoNamespace, + streamUploadCommand, + streamLinkCommand, + streamUnlinkCommand, +]); diff --git a/packages/cli/src/commands/stream/interactive.test.ts b/packages/cli/src/commands/stream/interactive.test.ts new file mode 100644 index 00000000..dda26f97 --- /dev/null +++ b/packages/cli/src/commands/stream/interactive.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CoreClient, VideoLibraryModel } from "./api.ts"; +import { STREAM_MANIFEST, type StreamLibraryManifest } from "./constants.ts"; +import { + resolveLibraryInteractive, + writeStreamManifest, +} from "./interactive.ts"; + +const LIBRARIES: VideoLibraryModel[] = [ + { Id: 1, Name: "Alpha", VideoCount: 3 }, + { Id: 2, Name: "marketing", VideoCount: 0 }, +]; + +/** Minimal path-branching fake core client (same shape as api.test.ts). */ +function fakeCoreClient(calls: string[]): CoreClient { + return { + GET: async (path: string, options?: any) => { + calls.push(path); + if (path === "/videolibrary/{id}") { + return { + data: LIBRARIES.find((lib) => lib.Id === options?.params?.path?.id), + }; + } + if (path === "/videolibrary") { + const search = (options?.params?.query?.search ?? "") as string; + return { + data: { + Items: LIBRARIES.filter((lib) => + (lib.Name ?? "").toLowerCase().includes(search.toLowerCase()), + ), + HasMoreItems: false, + }, + }; + } + throw new Error(`unexpected GET ${path}`); + }, + } as unknown as CoreClient; +} + +let dir = ""; +let cwd = ""; + +beforeEach(async () => { + cwd = process.cwd(); + dir = await mkdtemp(join(tmpdir(), "bunny-stream-link-")); + process.chdir(dir); +}); + +afterEach(async () => { + process.chdir(cwd); + await rm(dir, { recursive: true, force: true }); +}); + +async function readStreamManifest(): Promise { + const raw = await readFile(join(dir, ".bunny", STREAM_MANIFEST), "utf-8"); + return JSON.parse(raw) as StreamLibraryManifest; +} + +test("writeStreamManifest records the library ID and name", async () => { + writeStreamManifest(LIBRARIES[1] as VideoLibraryModel); + expect(await readStreamManifest()).toEqual({ id: 2, name: "marketing" }); +}); + +// `bun test` has no TTY, so every case here takes the unattended path: the +// manifest is the only thing that can stand in for an explicit reference. +test("a linked library resolves without a reference, even unattended", async () => { + writeStreamManifest(LIBRARIES[0] as VideoLibraryModel); + const calls: string[] = []; + + const lib = await resolveLibraryInteractive(fakeCoreClient(calls), undefined); + + expect(lib.Id).toBe(1); + expect(calls).toEqual(["/videolibrary/{id}"]); +}); + +test("an explicit reference wins over the linked library", async () => { + writeStreamManifest(LIBRARIES[0] as VideoLibraryModel); + const calls: string[] = []; + + const lib = await resolveLibraryInteractive( + fakeCoreClient(calls), + "marketing", + ); + + expect(lib.Id).toBe(2); + expect(calls).toEqual(["/videolibrary", "/videolibrary/{id}"]); +}); + +test("ignoreManifest skips the linked library so linking can re-pick", async () => { + writeStreamManifest(LIBRARIES[0] as VideoLibraryModel); + const calls: string[] = []; + + await expect( + resolveLibraryInteractive(fakeCoreClient(calls), undefined, { + ignoreManifest: true, + }), + ).rejects.toThrow("A library is required."); + expect(calls).toEqual([]); +}); + +test("force skips the picker instead of prompting", async () => { + const calls: string[] = []; + await expect( + resolveLibraryInteractive(fakeCoreClient(calls), undefined, { + force: true, + }), + ).rejects.toThrow("A library is required."); + expect(calls).toEqual([]); +}); + +test("the missing-library error points at linking", async () => { + try { + await resolveLibraryInteractive(fakeCoreClient([]), undefined, { + output: "json", + }); + throw new Error("expected a UserError"); + } catch (err) { + expect((err as { hint?: string }).hint).toContain("bunny stream link"); + } +}); diff --git a/packages/cli/src/commands/stream/interactive.ts b/packages/cli/src/commands/stream/interactive.ts new file mode 100644 index 00000000..82c6d065 --- /dev/null +++ b/packages/cli/src/commands/stream/interactive.ts @@ -0,0 +1,124 @@ +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, saveManifest } from "../../core/manifest.ts"; +import type { OutputFormat } from "../../core/types.ts"; +import { confirm, isInteractive, prompts, spinner } from "../../core/ui.ts"; +import { + type CoreClient, + fetchLibraries, + fetchLibrary, + resolveLibrary, + type VideoLibraryModel, +} from "./api.ts"; +import { STREAM_MANIFEST, type StreamLibraryManifest } from "./constants.ts"; + +/** Write `.bunny/stream.json` pointing at the library. */ +export function writeStreamManifest(library: VideoLibraryModel): void { + saveManifest(STREAM_MANIFEST, { + id: library.Id ?? 0, + name: library.Name ?? undefined, + }); +} + +// Offer to remember a library picked from the prompt; a no-op if the user declines. +async function maybeLinkLibrary(library: VideoLibraryModel): Promise { + if ( + !(await confirm(`Link this directory to ${library.Name}?`, { + optional: true, + })) + ) + return; + writeStreamManifest(library); + logger.success(`Linked this directory to video library ${library.Name}.`); +} + +/** + * Resolve a video library by name/ID, or prompt the user to pick one when no + * reference is given. Manages its own spinner so it never spins over a prompt. + * + * When `offerLink` is set and the library is chosen via the picker (not an + * explicit ref or the existing manifest), offer to link the directory to it. + * Pass `ignoreManifest` to always pick (used when (re)linking a directory). + * Never prompts non-interactively (json output, no TTY, or `force`): errors instead. + */ +export async function resolveLibraryInteractive( + client: CoreClient, + ref: string | undefined, + opts: { + output?: OutputFormat; + force?: boolean; + offerLink?: boolean; + ignoreManifest?: boolean; + } = {}, +): Promise { + if (ref) { + const spin = spinner("Resolving video library..."); + spin.start(); + try { + return await resolveLibrary(client, ref); + } finally { + spin.stop(); + } + } + + // A library linked via `bunny stream link` stands in for an explicit ref, even unattended. + if (!opts.ignoreManifest) { + const manifest = loadManifest(STREAM_MANIFEST); + if (manifest.id) { + const spin = spinner("Loading linked video library..."); + spin.start(); + try { + return await fetchLibrary(client, manifest.id); + } finally { + spin.stop(); + } + } + } + + // No library given: only fall back to the picker when we can actually prompt (--force opts out too). + if (opts.force || !isInteractive(opts.output)) { + throw new UserError( + "A library is required.", + "Pass a library name or ID, use --lib where applicable, or link one with `bunny stream link`.", + ); + } + + const spin = spinner("Fetching video libraries..."); + spin.start(); + let libraries: VideoLibraryModel[]; + try { + libraries = await fetchLibraries(client); + } finally { + spin.stop(); + } + + if (libraries.length === 0) { + throw new UserError( + "No video libraries found.", + 'Create one with "bunny stream library create ".', + ); + } + + const { id } = await prompts({ + type: "select", + name: "id", + message: "Video library:", + choices: libraries.map((lib) => ({ title: lib.Name ?? "", value: lib.Id })), + }); + if (id === undefined) throw new UserError("A library is required."); + + const loadSpin = spinner("Loading video library..."); + loadSpin.start(); + let library: VideoLibraryModel; + try { + library = await fetchLibrary(client, id); + } finally { + loadSpin.stop(); + } + + // The picker only runs interactively, so the link offer can't taint machine output. + if (opts.offerLink) { + await maybeLinkLibrary(library); + } + return library; +} diff --git a/packages/cli/src/commands/stream/library/create.ts b/packages/cli/src/commands/stream/library/create.ts new file mode 100644 index 00000000..cbe49cd6 --- /dev/null +++ b/packages/cli/src/commands/stream/library/create.ts @@ -0,0 +1,112 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { isInteractive, prompts, spinner } from "../../../core/ui.ts"; +import { + toSafeVideoLibrary, + type VideoLibraryCreateModel, + type VideoLibraryModel, +} from "../api.ts"; + +interface LibraryCreateArgs { + name?: string; + replicationRegions?: string[]; +} + +export const streamLibraryCreateCommand = defineCommand({ + command: "create [name]", + aliases: ["add"], + describe: "Create a new Stream video library.", + examples: [ + ["$0 stream library create my-library", "Create a video library"], + ["$0 stream library create", "Interactive: prompts for the name"], + [ + "$0 stream library create my-library --replication-regions NY,SG", + "Create a library replicated to New York and Singapore", + ], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + describe: "Name for the new video library", + }) + .option("replication-regions", { + type: "string", + array: true, + describe: + "Replication region codes for the underlying storage zone, set at creation time (comma-separated or repeated)", + }), + + handler: async ({ + name: nameArg, + replicationRegions, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // Trim the positional too, so a quoted " " is rejected like a blank answer. + let nameInput = nameArg?.trim(); + if (!nameInput && isInteractive(output)) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Name for the new video library:", + }); + nameInput = typeof value === "string" ? value.trim() : value; + } + if (!nameInput) { + throw new UserError( + "A library name is required.", + "Pass the name: bunny stream library create my-library", + ); + } + const name = nameInput; + + // Accept both `--replication-regions NY,SG` and repeated flags. + const regions = (replicationRegions ?? []) + .flatMap((value) => value.split(",")) + .map((value) => value.trim().toUpperCase()) + .filter(Boolean); + + const body: VideoLibraryCreateModel = { Name: name }; + if (regions.length) body.ReplicationRegions = regions; + + const spin = spinner("Creating video library..."); + spin.start(); + let created: VideoLibraryModel | undefined; + try { + const { data } = await client.POST("/videolibrary", { body }); + created = data; + } finally { + spin.stop(); + } + + if (output === "json") { + // The create response carries the new library's keys; read them back on + // purpose with `bunny stream library credentials`. + logger.log( + JSON.stringify( + created ? toSafeVideoLibrary(created) : { Name: name }, + null, + 2, + ), + ); + return; + } + + logger.success( + created?.Id + ? `Created video library ${name} (ID: ${created.Id}).` + : `Created video library ${name}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/stream/library/credentials.ts b/packages/cli/src/commands/stream/library/credentials.ts new file mode 100644 index 00000000..f2ecc83c --- /dev/null +++ b/packages/cli/src/commands/stream/library/credentials.ts @@ -0,0 +1,110 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { formatKeyValue, maskSecret } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { resolveLibraryInteractive } from "../interactive.ts"; + +interface CredentialsArgs { + library?: string; + readOnly?: boolean; + showSecret?: boolean; +} + +export const streamLibraryCredentialsCommand = defineCommand({ + command: "credentials [library]", + aliases: ["creds"], + describe: "Show the Stream API keys for a video library.", + examples: [ + [ + "$0 stream library credentials my-library", + "Show the library ID and API key (key masked)", + ], + [ + "$0 stream library credentials my-library --show-secret", + "Reveal the API key", + ], + [ + "$0 stream library credentials my-library --read-only", + "Use the read-only API key instead", + ], + ], + + builder: (yargs) => + yargs + .positional("library", { + type: "string", + describe: "Video library name or ID", + }) + .option("read-only", { + type: "boolean", + default: false, + describe: "Show the library's read-only API key", + }) + .option("show-secret", { + type: "boolean", + default: false, + describe: "Reveal the API key (masked by default)", + }), + + handler: async ({ + library, + readOnly, + showSecret, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const lib = await resolveLibraryInteractive(client, library, { output }); + + const keyKind = readOnly ? "Read-only API key" : "API key"; + + // A masked empty string reads as "here is your key" and exits 0; say what happened. + const key = readOnly ? lib.ReadOnlyApiKey : lib.ApiKey; + if (!key) { + throw new UserError( + `No ${keyKind.toLowerCase()} available for video library ${lib.Name ?? lib.Id}.`, + "The account key may not be allowed to read it; check the library in the bunny.net dashboard.", + ); + } + + if (output === "json") { + // Mask by default like the table; --show-secret opts into the raw key. + logger.log( + JSON.stringify( + { + libraryId: lib.Id, + name: lib.Name, + readOnly: readOnly ?? false, + apiKey: showSecret ? key : maskSecret(key), + }, + null, + 2, + ), + ); + return; + } + + logger.log( + formatKeyValue( + [ + { key: "Library ID", value: String(lib.Id ?? "") }, + { key: "Name", value: lib.Name ?? "" }, + { key: keyKind, value: showSecret ? key : maskSecret(key) }, + ], + output, + ), + ); + if (showSecret) { + logger.warn("Treat the API key like a password."); + } else { + logger.dim("Key masked. Pass --show-secret to reveal it."); + } + }, +}); diff --git a/packages/cli/src/commands/stream/library/delete.ts b/packages/cli/src/commands/stream/library/delete.ts new file mode 100644 index 00000000..318f845b --- /dev/null +++ b/packages/cli/src/commands/stream/library/delete.ts @@ -0,0 +1,88 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { logger } from "../../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../../core/manifest.ts"; +import { confirm, requireConfirmable, spinner } from "../../../core/ui.ts"; +import { STREAM_MANIFEST, type StreamLibraryManifest } from "../constants.ts"; +import { resolveLibraryInteractive } from "../interactive.ts"; + +interface LibraryDeleteArgs { + library?: string; + force?: boolean; +} + +export const streamLibraryDeleteCommand = defineCommand({ + command: "delete [library]", + aliases: ["rm", "remove"], + describe: "Delete a Stream video library and all of its videos.", + examples: [ + ["$0 stream library delete my-library", "Delete a video library"], + ["$0 stream library delete my-library --force", "Skip confirmation"], + ["$0 stream library delete", "Pick a library interactively"], + ], + + builder: (yargs) => + yargs + .positional("library", { + type: "string", + describe: "Video library name or ID", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ library, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // Destructive: --force must not silently delete a picked library, so it disables the picker. + const lib = await resolveLibraryInteractive(client, library, { + output, + force, + }); + + requireConfirmable(output, { + force, + message: `Deleting "${lib.Name}" needs a confirmation prompt.`, + hint: "Re-run with --force to delete non-interactively.", + }); + const confirmed = await confirm( + `Delete video library ${lib.Name} and all ${lib.VideoCount ?? 0} video(s)? This cannot be undone.`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const removeSpin = spinner("Deleting video library..."); + removeSpin.start(); + try { + await client.DELETE("/videolibrary/{id}", { + params: { path: { id: lib.Id as number } }, + }); + } finally { + removeSpin.stop(); + } + + // Drop a manifest that pointed at the deleted library so later commands don't resolve a ghost. + const manifest = loadManifest(STREAM_MANIFEST); + const unlinked = manifest.id === lib.Id; + if (unlinked) removeManifest(STREAM_MANIFEST); + + if (output === "json") { + logger.log( + JSON.stringify({ id: lib.Id, name: lib.Name, removed: true }, null, 2), + ); + return; + } + + logger.success(`Deleted video library ${lib.Name}.`); + if (unlinked) logger.dim(`Removed stale .bunny/${STREAM_MANIFEST}.`); + }, +}); diff --git a/packages/cli/src/commands/stream/library/index.ts b/packages/cli/src/commands/stream/library/index.ts new file mode 100644 index 00000000..c92735d9 --- /dev/null +++ b/packages/cli/src/commands/stream/library/index.ts @@ -0,0 +1,22 @@ +import type { CommandModule } from "yargs"; +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { streamLibraryCreateCommand } from "./create.ts"; +import { streamLibraryCredentialsCommand } from "./credentials.ts"; +import { streamLibraryDeleteCommand } from "./delete.ts"; +import { streamLibraryListCommand } from "./list.ts"; +import { streamLibraryShowCommand } from "./show.ts"; + +const subcommands: CommandModule[] = [ + streamLibraryListCommand, + streamLibraryCreateCommand, + streamLibraryShowCommand, + streamLibraryCredentialsCommand, + streamLibraryDeleteCommand, +]; + +export const streamLibraryNamespace = defineNamespace( + "library", + "Manage Stream video libraries.", + subcommands, + ["libraries", "lib"], +); diff --git a/packages/cli/src/commands/stream/library/list.ts b/packages/cli/src/commands/stream/library/list.ts new file mode 100644 index 00000000..d70d6fb8 --- /dev/null +++ b/packages/cli/src/commands/stream/library/list.ts @@ -0,0 +1,61 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatBytes, formatTable } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { + fetchLibraries, + toSafeVideoLibrary, + type VideoLibraryModel, +} from "../api.ts"; + +export const streamLibraryListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List all Stream video libraries.", + examples: [ + ["$0 stream library list", "List all video libraries"], + ["$0 stream library list --output json", "JSON output"], + ], + + handler: async ({ profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching video libraries..."); + spin.start(); + let libraries: VideoLibraryModel[]; + try { + libraries = await fetchLibraries(client); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify(libraries.map(toSafeVideoLibrary), null, 2)); + return; + } + + if (libraries.length === 0) { + logger.info("No video libraries found."); + return; + } + + logger.log( + formatTable( + ["ID", "Name", "Videos", "Storage", "Traffic", "Regions"], + libraries.map((lib) => [ + String(lib.Id ?? ""), + lib.Name ?? "", + String(lib.VideoCount ?? 0), + formatBytes(lib.StorageUsage ?? 0), + formatBytes(lib.TrafficUsage ?? 0), + (lib.ReplicationRegions ?? []).join(", "), + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/stream/library/show.ts b/packages/cli/src/commands/stream/library/show.ts new file mode 100644 index 00000000..04deb9a0 --- /dev/null +++ b/packages/cli/src/commands/stream/library/show.ts @@ -0,0 +1,85 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { + formatBytes, + formatDateTime, + formatKeyValue, +} from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { toSafeVideoLibrary } from "../api.ts"; +import { resolveLibraryInteractive } from "../interactive.ts"; + +interface LibraryShowArgs { + library?: string; +} + +export const streamLibraryShowCommand = defineCommand({ + command: "show [library]", + describe: "Show details for a Stream video library.", + examples: [ + ["$0 stream library show my-library", "Show library details"], + ["$0 stream library show 12345", "Show a library by ID"], + ["$0 stream library show my-library --output json", "JSON output"], + ], + + builder: (yargs) => + yargs.positional("library", { + type: "string", + describe: "Video library name or ID", + }), + + handler: async ({ library, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const lib = await resolveLibraryInteractive(client, library, { + output, + offerLink: true, + }); + + if (output === "json") { + logger.log(JSON.stringify(toSafeVideoLibrary(lib), null, 2)); + return; + } + + // No output format prints the raw keys; `bunny stream library credentials` + // is the deliberate way to retrieve them. + logger.log( + formatKeyValue( + [ + { key: "ID", value: String(lib.Id ?? "") }, + { key: "Name", value: lib.Name ?? "" }, + { key: "Videos", value: String(lib.VideoCount ?? 0) }, + { key: "Storage", value: formatBytes(lib.StorageUsage ?? 0) }, + { key: "Traffic", value: formatBytes(lib.TrafficUsage ?? 0) }, + { + key: "Replication regions", + value: (lib.ReplicationRegions ?? []).join(", ") || "—", + }, + { key: "Pull zone ID", value: String(lib.PullZoneId ?? "—") }, + { key: "Storage zone ID", value: String(lib.StorageZoneId ?? "—") }, + { key: "Enabled resolutions", value: lib.EnabledResolutions ?? "—" }, + { + key: "Token auth", + value: lib.PlayerTokenAuthenticationEnabled + ? "Enabled" + : "Disabled", + }, + { key: "DRM", value: lib.EnableDRM ? "Enabled" : "Disabled" }, + { + key: "Transcribing", + value: lib.EnableTranscribing ? "Enabled" : "Disabled", + }, + { key: "Created", value: formatDateTime(lib.DateCreated) }, + { key: "Modified", value: formatDateTime(lib.DateModified) }, + ], + output, + ), + ); + logger.dim( + `Run "bunny stream library credentials ${lib.Name}" for the API key.`, + ); + }, +}); diff --git a/packages/cli/src/commands/stream/link.ts b/packages/cli/src/commands/stream/link.ts new file mode 100644 index 00000000..fdb540d4 --- /dev/null +++ b/packages/cli/src/commands/stream/link.ts @@ -0,0 +1,47 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { + resolveLibraryInteractive, + writeStreamManifest, +} from "./interactive.ts"; + +interface LinkArgs { + library?: string; +} + +export const streamLinkCommand = defineCommand({ + command: "link [library]", + describe: "Link the current directory to a Stream video library.", + examples: [ + ["$0 stream link", "Interactive selection"], + ["$0 stream link my-library", "Direct link by name or ID"], + ], + + builder: (yargs) => + yargs.positional("library", { + type: "string", + describe: "Video library name or ID", + }), + + handler: async ({ library: ref, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // Always re-pick: linking is how the manifest changes, so the existing one must not short-circuit. + const lib = await resolveLibraryInteractive(client, ref, { + output, + ignoreManifest: true, + }); + writeStreamManifest(lib); + + if (output === "json") { + logger.log(JSON.stringify({ id: lib.Id, name: lib.Name })); + return; + } + + logger.success(`Linked to ${lib.Name} (${lib.Id}).`); + }, +}); diff --git a/packages/cli/src/commands/stream/unlink.ts b/packages/cli/src/commands/stream/unlink.ts new file mode 100644 index 00000000..17169d29 --- /dev/null +++ b/packages/cli/src/commands/stream/unlink.ts @@ -0,0 +1,62 @@ +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../core/manifest.ts"; +import { confirm, isInteractive } from "../../core/ui.ts"; +import { STREAM_MANIFEST, type StreamLibraryManifest } from "./constants.ts"; + +interface UnlinkArgs { + force?: boolean; +} + +export const streamUnlinkCommand = defineCommand({ + command: "unlink", + describe: `Remove .bunny/${STREAM_MANIFEST}, unlinking this directory from its video library.`, + + builder: (yargs) => + yargs.option("force", { + alias: "f", + type: "boolean", + describe: "Skip the confirmation prompt", + }), + + handler: async ({ force, output }) => { + const existing = loadManifest(STREAM_MANIFEST); + + if (!existing.id) { + if (output === "json") { + logger.log(JSON.stringify({ unlinked: false, reason: "no-manifest" })); + return; + } + logger.log( + `Nothing to unlink: no .bunny/${STREAM_MANIFEST} in this tree.`, + ); + return; + } + + if (!force) { + // Non-interactive (json output or no TTY) can't answer the prompt; require --force instead of silently no-op. + if (!isInteractive(output)) { + throw new UserError( + "Unlinking requires confirmation.", + "Re-run with --force to unlink non-interactively.", + ); + } + const confirmed = await confirm( + `Unlink from ${existing.name ?? existing.id}?`, + ); + if (!confirmed) { + logger.log("Unlink cancelled."); + return; + } + } + + removeManifest(STREAM_MANIFEST); + + if (output === "json") { + logger.log(JSON.stringify({ unlinked: true, id: existing.id })); + return; + } + logger.success("Unlinked."); + }, +}); diff --git a/packages/cli/src/commands/stream/upload.test.ts b/packages/cli/src/commands/stream/upload.test.ts new file mode 100644 index 00000000..fb7af3b3 --- /dev/null +++ b/packages/cli/src/commands/stream/upload.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + isEmptyVideoShell, + isUploadUrl, + parseFetchHeaders, + uploadFileSize, + videoTitle, +} from "./upload.ts"; + +let dir = ""; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "bunny-stream-upload-")); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +test("the title defaults to the file's name", () => { + expect(videoTitle("./media/launch demo.mp4")).toBe("launch demo.mp4"); + expect(videoTitle("/abs/path/clip.mp4", undefined)).toBe("clip.mp4"); +}); + +test("an explicit title wins, trimmed", () => { + expect(videoTitle("./clip.mp4", " Launch demo ")).toBe("Launch demo"); +}); + +// A blank --title would otherwise create an untitled video. +test("a blank title falls back to the file's name", () => { + expect(videoTitle("./clip.mp4", " ")).toBe("clip.mp4"); +}); + +// The cleanup guard: only a video that never received bytes may be deleted after +// a failed upload, because a lost response does not mean the bytes were rejected. +test("isEmptyVideoShell allows cleanup only for Created and UploadFailed", () => { + expect(isEmptyVideoShell(0)).toBe(true); // Created + expect(isEmptyVideoShell(6)).toBe(true); // UploadFailed +}); + +test("isEmptyVideoShell keeps a video that may hold bytes", () => { + for (const status of [1, 2, 3, 4, 5, 7, 8]) { + expect(isEmptyVideoShell(status)).toBe(false); + } + // An unknown status is ambiguous, so it is never deleted. + expect(isEmptyVideoShell(undefined)).toBe(false); + expect(isEmptyVideoShell(99)).toBe(false); +}); + +test("isUploadUrl recognizes http and https sources", () => { + expect(isUploadUrl("https://example.com/video.mp4")).toBe(true); + expect(isUploadUrl("http://example.com/video.mp4")).toBe(true); + expect(isUploadUrl(" https://example.com/video.mp4 ")).toBe(true); + expect(isUploadUrl("HTTPS://example.com/video.mp4")).toBe(true); +}); + +// Anything that is not an http(s) URL stays on the local-file path, including +// paths and schemes the Stream fetch endpoint could not use anyway. +test("isUploadUrl treats local paths and other schemes as files", () => { + expect(isUploadUrl("./video.mp4")).toBe(false); + expect(isUploadUrl("/abs/video.mp4")).toBe(false); + expect(isUploadUrl("video.mp4")).toBe(false); + expect(isUploadUrl("ftp://example.com/video.mp4")).toBe(false); + expect(isUploadUrl("https-video.mp4")).toBe(false); +}); + +test("parseFetchHeaders builds the header map, trimming around the colon", () => { + expect( + parseFetchHeaders(["Authorization: Bearer abc", "X-Tenant:acme"]), + ).toEqual({ + Authorization: "Bearer abc", + "X-Tenant": "acme", + }); +}); + +test("parseFetchHeaders keeps colons inside the value", () => { + expect(parseFetchHeaders(["Referer: https://example.com/a"])).toEqual({ + Referer: "https://example.com/a", + }); +}); + +test("parseFetchHeaders returns undefined when no headers were passed", () => { + expect(parseFetchHeaders(undefined)).toBeUndefined(); + expect(parseFetchHeaders([])).toBeUndefined(); +}); + +test("parseFetchHeaders rejects a header with no name", () => { + expect(() => parseFetchHeaders(["Authorization Bearer abc"])).toThrow( + /Invalid --header/, + ); + expect(() => parseFetchHeaders([": value"])).toThrow(/Invalid --header/); +}); + +test("uploadFileSize returns the byte size of a regular file", async () => { + const file = join(dir, "clip.mp4"); + await Bun.write(file, "video-bytes"); + expect(await uploadFileSize(file)).toBe("video-bytes".length); +}); + +test("uploadFileSize rejects a missing file", async () => { + await expect(uploadFileSize(join(dir, "nope.mp4"))).rejects.toThrow( + /File not found/, + ); +}); + +test("uploadFileSize rejects a directory with a targeted message", async () => { + const nested = join(dir, "videos"); + await mkdir(nested); + await expect(uploadFileSize(nested)).rejects.toThrow( + /is a directory, and upload takes a single video file/, + ); +}); + +// stat follows symlinks, so a link to a real file is uploadable as its target. +test("uploadFileSize follows a symlink to a file", async () => { + const file = join(dir, "clip.mp4"); + await Bun.write(file, "video-bytes"); + const link = join(dir, "link.mp4"); + await symlink(file, link); + expect(await uploadFileSize(link)).toBe("video-bytes".length); +}); diff --git a/packages/cli/src/commands/stream/upload.ts b/packages/cli/src/commands/stream/upload.ts new file mode 100644 index 00000000..468cc2d5 --- /dev/null +++ b/packages/cli/src/commands/stream/upload.ts @@ -0,0 +1,310 @@ +import { stat } from "node:fs/promises"; +import { basename } from "node:path"; +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { errorMessage, UserError } from "../../core/errors.ts"; +import { formatBytes, formatKeyValue } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { withSpinner } from "../../core/ui.ts"; +import { resolveLibraryInteractive } from "./interactive.ts"; +import { + connectStreamLibrary, + createVideo, + deleteVideo, + directPlayUrl, + fetchVideo, + queueVideoFetch, + type StreamClient, + uploadVideoFile, + videoStatusLabel, +} from "./videos-api.ts"; + +interface UploadArgs { + source: string; + lib?: string; + title?: string; + header?: string[]; +} + +/** The video title: `--title` when given, otherwise the file's name. */ +export function videoTitle(file: string, title?: string): string { + const trimmed = title?.trim(); + return trimmed || basename(file); +} + +/** Whether the positional is a URL for bunny.net to fetch rather than a local path. */ +export function isUploadUrl(source: string): boolean { + return /^https?:\/\//i.test(source.trim()); +} + +/** + * Turn repeated `--header "Key: Value"` flags into the fetch request's header map. + * + * Returns undefined when none were passed, so the field stays out of the body. + */ +export function parseFetchHeaders( + values: string[] | undefined, +): Record | undefined { + if (!values || values.length === 0) return undefined; + + const headers: Record = {}; + for (const raw of values) { + const separator = raw.indexOf(":"); + // A missing colon, or one at position 0, leaves no header name to send. + if (separator <= 0) { + throw new UserError( + `Invalid --header "${raw}".`, + 'Use "Name: value", e.g. --header "Authorization: Bearer abc".', + ); + } + headers[raw.slice(0, separator).trim()] = raw.slice(separator + 1).trim(); + } + return headers; +} + +/** + * Size of the file to upload, or a UserError explaining why it can't be sent. + * + * Checked before any API call so a typo never leaves an empty video behind. + */ +export async function uploadFileSize(file: string): Promise { + const entry = await stat(file).catch(() => null); + if (!entry) throw new UserError(`File not found: ${file}`); + if (entry.isDirectory()) { + throw new UserError( + `${file} is a directory, and upload takes a single video file.`, + "Upload each video in turn.", + ); + } + if (!entry.isFile()) { + throw new UserError(`${file} is not a regular file.`); + } + return entry.size; +} + +/** Video statuses that mean no bytes ever landed, so the shell is safe to delete. */ +const EMPTY_VIDEO_STATUSES = new Set([ + 0, // Created + 6, // UploadFailed +]); + +/** + * Whether a video is still an empty shell and can be cleaned up. + * + * Exported for testing: this is the guard that decides whether a failed upload + * deletes anything at all. + */ +export function isEmptyVideoShell(status: number | undefined): boolean { + return status !== undefined && EMPTY_VIDEO_STATUSES.has(status); +} + +/** + * Clean up after a failed byte upload, but only when nothing was stored. + * + * A lost or failed response does not prove the server rejected the bytes, so the + * video's own status decides: an untouched shell is deleted, and anything else + * (including a status that cannot be read) is kept and reported. Deleting on + * ambiguity would throw away a good upload. + */ +async function removeOrphanVideo( + client: StreamClient, + libraryId: number, + videoId: string, +): Promise { + let status: number | undefined; + try { + status = (await fetchVideo(client, libraryId, videoId)).status; + } catch (err) { + logger.warn( + `Upload failed, and the state of video ${videoId} could not be read (${errorMessage(err)}); leaving it in place.`, + ); + logger.dim("Check it with `bunny stream videos list`."); + return; + } + + if (!isEmptyVideoShell(status)) { + logger.warn( + `Upload reported a failure, but video ${videoId} is ${videoStatusLabel(status)}, so the upload may have succeeded; leaving it in place.`, + ); + logger.dim("Check it with `bunny stream videos list`."); + return; + } + + try { + await deleteVideo(client, libraryId, videoId); + logger.warn(`Upload failed; removed the empty video ${videoId}.`); + } catch (err) { + logger.warn( + `Upload failed, and removing the empty video ${videoId} also failed: ${errorMessage(err)}`, + ); + } +} + +export const streamUploadCommand = defineCommand({ + command: "upload ", + describe: "Upload a video file, or fetch one from a URL, into a library.", + examples: [ + ["$0 stream upload ./video.mp4", "Upload to the linked library"], + [ + "$0 stream upload ./video.mp4 --lib 12345", + "Upload to a specific library", + ], + [ + '$0 stream upload ./video.mp4 --title "Launch demo"', + "Set the video title", + ], + [ + "$0 stream upload https://example.com/video.mp4 --lib 12345", + "Have bunny.net fetch the video server side", + ], + [ + '$0 stream upload https://example.com/video.mp4 --header "Authorization: Bearer abc"', + "Send a header with the fetch", + ], + ], + + builder: (yargs) => + yargs + .positional("source", { + type: "string", + describe: + "Path to a local video file, or a URL for bunny.net to fetch server side", + demandOption: true, + }) + .option("lib", { + alias: "library", + type: "string", + describe: "Video library ID (defaults to the linked library)", + }) + .option("title", { + type: "string", + describe: + "Video title (defaults to the file name, or the remote file name for a URL)", + }) + .option("header", { + type: "string", + array: true, + describe: + 'Header to send with a URL fetch as "Name: value" (repeatable; URL uploads only)', + }), + + handler: async ({ + source, + lib: ref, + title, + header, + profile, + output, + verbose, + apiKey, + }) => { + const fromUrl = isUploadUrl(source); + const headers = parseFetchHeaders(header); + if (headers && !fromUrl) { + throw new UserError( + "--header only applies to a URL upload.", + "Drop --header, or pass a URL for bunny.net to fetch.", + ); + } + + // A URL is fetched by bunny.net, so there is nothing local to measure. + const size = fromUrl ? 0 : await uploadFileSize(source); + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const library = await resolveLibraryInteractive(client, ref, { + output, + offerLink: true, + }); + const libraryId = library.Id as number; + const streamClient = connectStreamLibrary(library, { config, verbose }); + + if (fromUrl) { + // Queue-and-report: the fetch response carries no video GUID, so there is + // no ID or direct play URL to print until the video shows up in the library. + const wantedTitle = title?.trim() || undefined; + // Detection trims, so the API must get the trimmed URL too. + const url = source.trim(); + const status = await withSpinner(`Queueing fetch of ${url}...`, () => + queueVideoFetch(streamClient, libraryId, { + url, + title: wantedTitle, + headers, + }), + ); + + if (output === "json") { + logger.log( + JSON.stringify( + { + queued: true, + url, + library: libraryId, + title: wantedTitle, + ...status, + }, + null, + 2, + ), + ); + return; + } + + logger.success(`Queued fetch of ${url} into ${library.Name}.`); + logger.dim( + "The video appears in `bunny stream videos list` once bunny.net has fetched and encoded it.", + ); + return; + } + + const wanted = videoTitle(source, title); + const created = await withSpinner(`Creating video ${wanted}...`, () => + createVideo(streamClient, libraryId, wanted), + ); + + try { + await withSpinner( + `Uploading ${basename(source)} (${formatBytes(size)})...`, + () => uploadVideoFile(streamClient, libraryId, created.guid, source), + ); + } catch (err) { + await removeOrphanVideo(streamClient, libraryId, created.guid); + throw err; + } + + // The bytes are already stored, so a failed refresh must not report the upload + // as failed; fall back to the freshly created video for the summary. + const video = await withSpinner("Reading video status...", () => + fetchVideo(streamClient, libraryId, created.guid), + ).catch((err) => { + logger.debug( + `Could not refresh video ${created.guid}: ${errorMessage(err)}`, + Boolean(verbose), + ); + return created; + }); + + if (output === "json") { + logger.log(JSON.stringify(video, null, 2)); + return; + } + + logger.success(`Uploaded ${video.title} to ${library.Name}.`); + logger.log( + formatKeyValue( + [ + { key: "Video ID", value: video.guid }, + { key: "Library", value: `${library.Name ?? ""} (${libraryId})` }, + { key: "Status", value: videoStatusLabel(video.status) }, + { key: "Direct play", value: directPlayUrl(libraryId, video.guid) }, + { key: "Size", value: formatBytes(size) }, + ], + output, + ), + ); + logger.dim("Encoding continues in the background."); + }, +}); diff --git a/packages/cli/src/commands/stream/videos-api.test.ts b/packages/cli/src/commands/stream/videos-api.test.ts new file mode 100644 index 00000000..7c9778f6 --- /dev/null +++ b/packages/cli/src/commands/stream/videos-api.test.ts @@ -0,0 +1,419 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ResolvedConfig } from "../../config/index.ts"; +import type { VideoLibraryModel } from "./api.ts"; +import { + connectStreamLibrary, + createVideo, + deleteVideo, + directPlayUrl, + fetchVideo, + fetchVideos, + formatDuration, + queueVideoFetch, + type StreamClient, + updateVideo, + uploadVideoFile, + type VideoModel, + videoStatusLabel, +} from "./videos-api.ts"; + +const CONFIG: ResolvedConfig = { + apiKey: "account-key", + // The core API host must not leak into the Stream client. + apiUrl: "https://api.bunny.net", + profile: "default", +}; + +const LIBRARY: VideoLibraryModel = { + Id: 4321, + Name: "my-library", + ApiKey: "library-key", +}; + +const VIDEO: VideoModel = { + videoLibraryId: 4321, + guid: "video-guid", + title: "clip.mp4", + status: 1, +} as VideoModel; + +interface Call { + method: string; + path: string; + init?: Record; +} + +/** Path-branching fake stream client: only the video endpoints used here exist. */ +function fakeStreamClient(opts: { + calls: Call[]; + video?: VideoModel | undefined; + error?: unknown; +}): StreamClient { + const record = (method: string) => async (path: string, init?: any) => { + opts.calls.push({ method, path, init }); + if (opts.error !== undefined) return { error: opts.error }; + return { data: opts.video }; + }; + return { + GET: record("GET"), + POST: record("POST"), + PUT: record("PUT"), + DELETE: record("DELETE"), + } as unknown as StreamClient; +} + +/** + * Fake client for the paginated listing. `totalItems` is settable on its own so + * a server that over-reports (and would page forever) can be exercised. + */ +function fakeListClient(opts: { + calls: Call[]; + videos: VideoModel[]; + pageSize?: number; + totalItems?: number; +}): StreamClient { + return { + GET: async (path: string, init?: any) => { + opts.calls.push({ method: "GET", path, init }); + const query = init?.params?.query ?? {}; + const search = (query.search ?? "") as string; + const matched = search + ? opts.videos.filter((video) => video.title.includes(search)) + : opts.videos; + const size = opts.pageSize ?? Math.max(matched.length, 1); + const start = ((query.page ?? 1) - 1) * size; + return { + data: { + totalItems: opts.totalItems ?? matched.length, + currentPage: query.page, + itemsPerPage: size, + items: matched.slice(start, start + size), + }, + }; + }, + } as unknown as StreamClient; +} + +const VIDEOS: VideoModel[] = [ + { ...VIDEO, guid: "a", title: "alpha" }, + { ...VIDEO, guid: "b", title: "beta" }, + { ...VIDEO, guid: "c", title: "gamma" }, +]; + +let dir = ""; +let file = ""; +const realFetch = globalThis.fetch; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "bunny-stream-")); + file = join(dir, "clip.mp4"); + await Bun.write(file, "video-bytes"); +}); + +afterEach(async () => { + globalThis.fetch = realFetch; + await rm(dir, { recursive: true, force: true }); +}); + +test("connectStreamLibrary requires the library's own API key", () => { + expect(() => + connectStreamLibrary({ ...LIBRARY, ApiKey: undefined }, { config: CONFIG }), + ).toThrow(/No API key available for video library my-library/); +}); + +test("connectStreamLibrary targets the Stream host with the library key", async () => { + let request: Request | undefined; + globalThis.fetch = (async (input: Request) => { + request = input; + return new Response(JSON.stringify(VIDEO), { + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const client = connectStreamLibrary(LIBRARY, { config: CONFIG }); + await fetchVideo(client, 4321, "video-guid"); + + expect(request?.url).toBe( + "https://video.bunnycdn.com/library/4321/videos/video-guid", + ); + // The per-library key authenticates, not the account key from the config. + expect(request?.headers.get("AccessKey")).toBe("library-key"); +}); + +test("createVideo posts the title and returns the created video", async () => { + const calls: Call[] = []; + const client = fakeStreamClient({ calls, video: VIDEO }); + + const video = await createVideo(client, 4321, "clip.mp4"); + + expect(video.guid).toBe("video-guid"); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.path).toBe("/library/{libraryId}/videos"); + expect(calls[0]?.init?.params).toEqual({ path: { libraryId: 4321 } }); + expect(calls[0]?.init?.body).toEqual({ title: "clip.mp4" }); +}); + +test("createVideo fails loudly when no video comes back", async () => { + const client = fakeStreamClient({ calls: [], video: undefined }); + await expect(createVideo(client, 4321, "clip.mp4")).rejects.toThrow( + 'Creating the video "clip.mp4" did not return a video ID.', + ); +}); + +test("fetchVideos drains every page until totalItems is covered", async () => { + const calls: Call[] = []; + const client = fakeListClient({ calls, videos: VIDEOS, pageSize: 2 }); + + const videos = await fetchVideos(client, 4321); + + expect(videos.map((video) => video.guid)).toEqual(["a", "b", "c"]); + const pages = calls.map((call) => call.init?.params?.query?.page); + expect(pages).toEqual([1, 2]); + expect(calls[0]?.path).toBe("/library/{libraryId}/videos"); + expect(calls[0]?.init?.params?.path).toEqual({ libraryId: 4321 }); +}); + +test("fetchVideos asks for a single page when everything fits", async () => { + const calls: Call[] = []; + const videos = await fetchVideos( + fakeListClient({ calls, videos: VIDEOS }), + 4321, + ); + expect(videos).toHaveLength(3); + expect(calls).toHaveLength(1); +}); + +// Without the empty-page guard, a totalItems that never gets reached loops forever. +test("fetchVideos stops on an empty page even if totalItems over-reports", async () => { + const calls: Call[] = []; + const client = fakeListClient({ + calls, + videos: VIDEOS, + pageSize: 3, + totalItems: 99, + }); + + const videos = await fetchVideos(client, 4321); + + expect(videos).toHaveLength(3); + expect(calls).toHaveLength(2); +}); + +test("fetchVideos passes the search term through", async () => { + const calls: Call[] = []; + const videos = await fetchVideos( + fakeListClient({ calls, videos: VIDEOS }), + 4321, + { search: "alph" }, + ); + + expect(videos.map((video) => video.title)).toEqual(["alpha"]); + expect(calls[0]?.init?.params?.query?.search).toBe("alph"); +}); + +test("fetchVideos returns an empty list for an empty library", async () => { + expect( + await fetchVideos(fakeListClient({ calls: [], videos: [] }), 4321), + ).toEqual([]); +}); + +test("updateVideo posts only the changed fields", async () => { + const calls: Call[] = []; + await updateVideo(fakeStreamClient({ calls }), 4321, "video-guid", { + title: "Launch demo", + }); + + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.path).toBe("/library/{libraryId}/videos/{videoId}"); + expect(calls[0]?.init?.params).toEqual({ + path: { libraryId: 4321, videoId: "video-guid" }, + }); + expect(calls[0]?.init?.body).toEqual({ title: "Launch demo" }); +}); + +/** Fake for the server-side fetch endpoint, whose response is a StatusModel. */ +function fakeFetchClient(opts: { + calls: Call[]; + status?: unknown; +}): StreamClient { + return { + POST: async (path: string, init?: any) => { + opts.calls.push({ method: "POST", path, init }); + return { data: opts.status }; + }, + } as unknown as StreamClient; +} + +test("queueVideoFetch posts the URL, title, and headers", async () => { + const calls: Call[] = []; + const client = fakeFetchClient({ + calls, + status: { success: true, message: "Video queued", statusCode: 200 }, + }); + + const status = await queueVideoFetch(client, 4321, { + url: "https://example.com/video.mp4", + title: "Launch demo", + headers: { Authorization: "Bearer abc" }, + }); + + expect(status.message).toBe("Video queued"); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.path).toBe("/library/{libraryId}/videos/fetch"); + expect(calls[0]?.init?.params).toEqual({ path: { libraryId: 4321 } }); + expect(calls[0]?.init?.body).toEqual({ + url: "https://example.com/video.mp4", + title: "Launch demo", + headers: { Authorization: "Bearer abc" }, + }); +}); + +// An unset title lets the API name the video after the remote file. +test("queueVideoFetch leaves an unset title and headers out of the body", async () => { + const calls: Call[] = []; + await queueVideoFetch( + fakeFetchClient({ calls, status: { success: true } }), + 4321, + { + url: "https://example.com/video.mp4", + title: undefined, + headers: undefined, + }, + ); + + expect(JSON.stringify(calls[0]?.init?.body)).toBe( + '{"url":"https://example.com/video.mp4"}', + ); +}); + +// This endpoint answers 200 with success: false for a URL it would not accept. +test("queueVideoFetch turns a failed status into a user-facing error", async () => { + const client = fakeFetchClient({ + calls: [], + status: { success: false, message: "Invalid URL", statusCode: 400 }, + }); + + await expect( + queueVideoFetch(client, 4321, { url: "https://example.com/nope" }), + ).rejects.toThrow( + "bunny.net could not fetch https://example.com/nope: Invalid URL", + ); +}); + +test("queueVideoFetch tolerates an empty body on success", async () => { + const client = fakeFetchClient({ calls: [], status: undefined }); + expect( + await queueVideoFetch(client, 4321, { url: "https://example.com/v.mp4" }), + ).toEqual({}); +}); + +test("fetchVideo reports a missing video", async () => { + const client = fakeStreamClient({ calls: [], video: undefined }); + await expect(fetchVideo(client, 4321, "nope")).rejects.toThrow( + "Video nope not found.", + ); +}); + +test("uploadVideoFile PUTs an octet-stream body through an identity serializer", async () => { + const calls: Call[] = []; + const client = fakeStreamClient({ calls, video: undefined }); + + await uploadVideoFile(client, 4321, "video-guid", file); + + const [call] = calls; + expect(call?.method).toBe("PUT"); + expect(call?.path).toBe("/library/{libraryId}/videos/{videoId}"); + expect(call?.init?.params).toEqual({ + path: { libraryId: 4321, videoId: "video-guid" }, + }); + expect(call?.init?.headers).toEqual({ + "Content-Type": "application/octet-stream", + }); + // The body must reach fetch untouched: openapi-fetch JSON-stringifies otherwise. + const body = call?.init?.body; + expect(call?.init?.bodySerializer(body)).toBe(body); + expect(body.size).toBe("video-bytes".length); +}); + +test("uploadVideoFile sends the file's bytes as application/octet-stream", async () => { + let request: Request | undefined; + globalThis.fetch = (async (input: Request) => { + request = input; + return new Response(JSON.stringify({ success: true }), { + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const client = connectStreamLibrary(LIBRARY, { config: CONFIG }); + await uploadVideoFile(client, 4321, "video-guid", file); + + expect(request?.method).toBe("PUT"); + expect(request?.headers.get("content-type")).toBe("application/octet-stream"); + expect(await request?.text()).toBe("video-bytes"); +}); + +test("uploadVideoFile surfaces the API message on failure", async () => { + const client = fakeStreamClient({ + calls: [], + error: { message: "The requested video was already uploaded" }, + }); + await expect( + uploadVideoFile(client, 4321, "video-guid", file), + ).rejects.toThrow(/already uploaded/); +}); + +test("uploadVideoFile wraps a thrown API error as a user-facing error", async () => { + const client = { + PUT: async () => { + throw new Error("Unauthorized. Check your API key."); + }, + } as unknown as StreamClient; + await expect( + uploadVideoFile(client, 4321, "video-guid", file), + ).rejects.toThrow(/Unauthorized/); +}); + +test("deleteVideo removes the video by ID", async () => { + const calls: Call[] = []; + await deleteVideo(fakeStreamClient({ calls }), 4321, "video-guid"); + + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.path).toBe("/library/{libraryId}/videos/{videoId}"); + expect(calls[0]?.init?.params).toEqual({ + path: { libraryId: 4321, videoId: "video-guid" }, + }); +}); + +test("formatDuration renders m:ss, and h:mm:ss past an hour", () => { + expect(formatDuration(0)).toBe("0:00"); + expect(formatDuration(5)).toBe("0:05"); + expect(formatDuration(65)).toBe("1:05"); + expect(formatDuration(599)).toBe("9:59"); + expect(formatDuration(3600)).toBe("1:00:00"); + expect(formatDuration(3725)).toBe("1:02:05"); + expect(formatDuration(59.6)).toBe("1:00"); +}); + +test("formatDuration dashes an unknown or nonsense length", () => { + expect(formatDuration(undefined)).toBe("—"); + expect(formatDuration(null)).toBe("—"); + expect(formatDuration(-1)).toBe("—"); + expect(formatDuration(Number.NaN)).toBe("—"); +}); + +test("directPlayUrl is derived from the library and video IDs", () => { + expect(directPlayUrl(4321, "video-guid")).toBe( + "https://iframe.mediadelivery.net/play/4321/video-guid", + ); +}); + +test("videoStatusLabel names every status the spec enumerates", () => { + expect(videoStatusLabel(0)).toBe("Created"); + expect(videoStatusLabel(4)).toBe("Finished"); + expect(videoStatusLabel(8)).toBe("JitPlaylistsCreated"); + expect(videoStatusLabel(99)).toBe("99"); + expect(videoStatusLabel(undefined)).toBe("—"); +}); diff --git a/packages/cli/src/commands/stream/videos-api.ts b/packages/cli/src/commands/stream/videos-api.ts new file mode 100644 index 00000000..049a0f4f --- /dev/null +++ b/packages/cli/src/commands/stream/videos-api.ts @@ -0,0 +1,246 @@ +import { createStreamClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/stream.d.ts"; +import type { ResolvedConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { errorMessage, UserError } from "../../core/errors.ts"; +import type { VideoLibraryModel } from "./api.ts"; + +export type StreamClient = ReturnType; +export type VideoModel = components["schemas"]["VideoModel"]; +export type UpdateVideoModel = components["schemas"]["UpdateVideoModel"]; +export type FetchVideoRequest = components["schemas"]["FetchVideoRequest"]; +export type StatusModel = components["schemas"]["StatusModel"]; + +// The listing endpoint's documented default; it has no documented maximum, so +// asking for more risks a clamp or a 400. Draining below tolerates either. +const VIDEOS_PER_PAGE = 100; + +/** Titles for VideoModel.status, in the order the spec enumerates them. */ +const VIDEO_STATUS_LABELS = [ + "Created", + "Uploaded", + "Processing", + "Transcoding", + "Finished", + "Error", + "UploadFailed", + "JitSegmenting", + "JitPlaylistsCreated", +]; + +export function videoStatusLabel(status: number | undefined): string { + if (status === undefined) return "—"; + return VIDEO_STATUS_LABELS[status] ?? String(status); +} + +/** A video length in seconds as `m:ss`, or `h:mm:ss` once it reaches an hour. */ +export function formatDuration(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined) return "—"; + if (!Number.isFinite(seconds) || seconds < 0) return "—"; + + const total = Math.round(seconds); + const pad = (value: number) => String(value).padStart(2, "0"); + const hours = Math.floor(total / 3600); + const minutes = Math.floor(total / 60) % 60; + const secs = total % 60; + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(secs)}` + : `${minutes}:${pad(secs)}`; +} + +/** + * The video's Direct Play URL, as the bunny.net dashboard calls it. + * + * Derived from the library and video IDs, so it needs no extra API call. + */ +export function directPlayUrl(libraryId: number, videoId: string): string { + return `https://iframe.mediadelivery.net/play/${libraryId}/${videoId}`; +} + +/** + * Build a Stream API client for one video library. + * + * The video-level API lives on its own host and authenticates with the + * library's own key, so the account key and the core API base URL from the + * CLI config are both dropped here. + */ +export function connectStreamLibrary( + library: VideoLibraryModel, + opts: { config: ResolvedConfig; verbose?: boolean }, +): StreamClient { + if (!library.ApiKey) { + throw new UserError( + `No API key available for video library ${library.Name ?? library.Id}.`, + "Video operations need the library's own Stream API key; check that the account key can read it.", + ); + } + + // baseUrl is the core API host: leaving it in would point the Stream client at api.bunny.net. + const { baseUrl: _core, ...options } = clientOptions( + opts.config, + opts.verbose, + ); + return createStreamClient({ ...options, apiKey: library.ApiKey }); +} + +/** Create the video entry that the file bytes are then uploaded into. */ +export async function createVideo( + client: StreamClient, + libraryId: number, + title: string, +): Promise { + const { data } = await client.POST("/library/{libraryId}/videos", { + params: { path: { libraryId } }, + body: { title }, + }); + if (!data?.guid) { + throw new UserError( + `Creating the video "${title}" did not return a video ID.`, + ); + } + return data; +} + +/** + * Fetch every video in a library, draining the paginated listing. + * + * This endpoint reports `totalItems` but no "has more" flag, so paging stops + * once the caller holds everything the server counted. An empty page also + * stops it, so a stale or wrong `totalItems` can't spin forever, and a server + * that clamps `itemsPerPage` below the request is handled the same way. + */ +export async function fetchVideos( + client: StreamClient, + libraryId: number, + opts: { search?: string } = {}, +): Promise { + const videos: VideoModel[] = []; + let page = 1; + for (;;) { + const { data } = await client.GET("/library/{libraryId}/videos", { + params: { + path: { libraryId }, + query: { + page, + itemsPerPage: VIDEOS_PER_PAGE, + search: opts.search, + }, + }, + }); + const items = data?.items ?? []; + videos.push(...items); + if (items.length === 0) break; + if (videos.length >= (data?.totalItems ?? videos.length)) break; + page++; + } + return videos; +} + +/** Fetch a video's current state (encoding status, size, resolutions). */ +export async function fetchVideo( + client: StreamClient, + libraryId: number, + videoId: string, +): Promise { + const { data } = await client.GET("/library/{libraryId}/videos/{videoId}", { + params: { path: { libraryId, videoId } }, + }); + if (!data) throw new UserError(`Video ${videoId} not found.`); + return data; +} + +// The shared middleware turns a non-OK response into an ApiError, so an `error` +// body only reaches the caller in odd cases; read the API's message out of it anyway. +function bodyMessage(error: unknown): string { + if (typeof error === "string") return error; + const body = error as { message?: string | null; Message?: string } | null; + return body?.message ?? body?.Message ?? JSON.stringify(error); +} + +/** + * Upload the file's bytes into an existing video. + * + * The endpoint takes a raw `application/octet-stream` body. openapi-fetch + * JSON-stringifies request bodies by default, so this passes an identity + * `bodySerializer` and hands it a `Bun.file` blob: fetch streams that straight + * off disk (and sets Content-Length) instead of buffering the video in memory. + */ +export async function uploadVideoFile( + client: StreamClient, + libraryId: number, + videoId: string, + filePath: string, +): Promise { + const file = Bun.file(filePath); + try { + const { error } = await client.PUT( + "/library/{libraryId}/videos/{videoId}", + { + params: { path: { libraryId, videoId } }, + headers: { "Content-Type": "application/octet-stream" }, + bodySerializer: (body: unknown) => body, + // The generated type for an octet-stream body is `string`; the blob is what actually gets sent. + body: file as unknown as string, + }, + ); + if (error) { + throw new UserError( + `Uploading ${filePath} failed: ${bodyMessage(error)}`, + ); + } + } catch (err) { + if (err instanceof UserError) throw err; + // A non-OK response arrives here as an ApiError from the shared middleware. + throw new UserError(`Uploading ${filePath} failed: ${errorMessage(err)}`); + } +} + +/** + * Ask bunny.net to fetch a video from a URL itself, server side. + * + * The response is a plain status with no video GUID, so this can only report + * that the fetch was queued: the video shows up in the library once bunny.net + * has downloaded and encoded it. + */ +export async function queueVideoFetch( + client: StreamClient, + libraryId: number, + body: FetchVideoRequest, +): Promise { + const { data } = await client.POST("/library/{libraryId}/videos/fetch", { + params: { path: { libraryId } }, + body, + }); + + // 200 with success: false is how this endpoint reports a rejected URL. + if (data && data.success === false) { + throw new UserError( + `bunny.net could not fetch ${body.url}: ${data.message ?? "the request was rejected"}`, + ); + } + return data ?? {}; +} + +/** Update a video's metadata. Only the fields present in `body` change. */ +export async function updateVideo( + client: StreamClient, + libraryId: number, + videoId: string, + body: UpdateVideoModel, +): Promise { + await client.POST("/library/{libraryId}/videos/{videoId}", { + params: { path: { libraryId, videoId } }, + body, + }); +} + +/** Delete a video. Used as best-effort cleanup when an upload fails part-way. */ +export async function deleteVideo( + client: StreamClient, + libraryId: number, + videoId: string, +): Promise { + await client.DELETE("/library/{libraryId}/videos/{videoId}", { + params: { path: { libraryId, videoId } }, + }); +} diff --git a/packages/cli/src/commands/stream/videos/delete.ts b/packages/cli/src/commands/stream/videos/delete.ts new file mode 100644 index 00000000..671ad22a --- /dev/null +++ b/packages/cli/src/commands/stream/videos/delete.ts @@ -0,0 +1,100 @@ +import { defineCommand } from "../../../core/define-command.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; +import { deleteVideo } from "../videos-api.ts"; +import { + resolveVideoInteractive, + streamLibraryContext, +} from "./interactive.ts"; + +interface VideoDeleteArgs { + video?: string; + lib?: string; + force?: boolean; +} + +export const streamVideoDeleteCommand = defineCommand({ + command: "delete [video]", + aliases: ["rm", "remove"], + describe: "Delete a video from a Stream video library.", + examples: [ + ["$0 stream videos delete 1a2b3c4d-...", "Delete a video by GUID"], + ["$0 stream videos delete 1a2b3c4d-... --force", "Skip confirmation"], + ["$0 stream videos delete", "Pick a video interactively"], + ], + + builder: (yargs) => + yargs + .positional("video", { + type: "string", + describe: "Video GUID", + }) + .option("lib", { + alias: "library", + type: "string", + describe: "Video library ID (defaults to the linked library)", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompt", + }), + + handler: async ({ + video: ref, + lib, + force, + profile, + output, + verbose, + apiKey, + }) => { + // Destructive, so it never offers to link the directory to the library, and + // --force disables both pickers rather than silently deleting a picked video. + const { client, libraryId } = await streamLibraryContext({ + lib, + profile, + output, + verbose, + apiKey, + force, + }); + + const video = await resolveVideoInteractive(client, libraryId, ref, { + output, + force, + }); + + requireConfirmable(output, { + force, + message: `Deleting "${video.title}" needs a confirmation prompt.`, + hint: "Re-run with --force to delete non-interactively.", + }); + const confirmed = await confirm( + `Delete video ${video.title}? This cannot be undone.`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + await withSpinner("Deleting video...", () => + deleteVideo(client, libraryId, video.guid), + ); + + if (output === "json") { + logger.log( + JSON.stringify( + { id: video.guid, title: video.title, removed: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Deleted video ${video.title}.`); + }, +}); diff --git a/packages/cli/src/commands/stream/videos/index.ts b/packages/cli/src/commands/stream/videos/index.ts new file mode 100644 index 00000000..4d108266 --- /dev/null +++ b/packages/cli/src/commands/stream/videos/index.ts @@ -0,0 +1,20 @@ +import type { CommandModule } from "yargs"; +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { streamVideoDeleteCommand } from "./delete.ts"; +import { streamVideoListCommand } from "./list.ts"; +import { streamVideoShowCommand } from "./show.ts"; +import { streamVideoUpdateCommand } from "./update.ts"; + +const subcommands: CommandModule[] = [ + streamVideoListCommand, + streamVideoShowCommand, + streamVideoUpdateCommand, + streamVideoDeleteCommand, +]; + +export const streamVideoNamespace = defineNamespace( + "videos", + "Manage the videos in a Stream video library (add one with `bunny stream upload`).", + subcommands, + ["video"], +); diff --git a/packages/cli/src/commands/stream/videos/interactive.test.ts b/packages/cli/src/commands/stream/videos/interactive.test.ts new file mode 100644 index 00000000..dace73a3 --- /dev/null +++ b/packages/cli/src/commands/stream/videos/interactive.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import type { StreamClient, VideoModel } from "../videos-api.ts"; +import { resolveVideoInteractive } from "./interactive.ts"; + +const VIDEO = { + videoLibraryId: 4321, + guid: "video-guid", + title: "clip.mp4", + status: 4, +} as VideoModel; + +function fakeStreamClient(paths: string[]): StreamClient { + return { + GET: async (path: string, init?: any) => { + paths.push(path); + if (path === "/library/{libraryId}/videos/{videoId}") { + return { + data: init?.params?.path?.videoId === VIDEO.guid ? VIDEO : undefined, + }; + } + if (path === "/library/{libraryId}/videos") { + return { data: { totalItems: 1, items: [VIDEO] } }; + } + throw new Error(`unexpected GET ${path}`); + }, + } as unknown as StreamClient; +} + +// `bun test` has no TTY, so every case here takes the unattended path. +test("an explicit GUID is fetched directly, with no listing", async () => { + const paths: string[] = []; + + const video = await resolveVideoInteractive( + fakeStreamClient(paths), + 4321, + "video-guid", + ); + + expect(video.title).toBe("clip.mp4"); + expect(paths).toEqual(["/library/{libraryId}/videos/{videoId}"]); +}); + +test("an unknown GUID reports the video as missing", async () => { + await expect( + resolveVideoInteractive(fakeStreamClient([]), 4321, "nope"), + ).rejects.toThrow("Video nope not found."); +}); + +test("no GUID and no way to prompt errors instead of listing videos", async () => { + const paths: string[] = []; + + await expect( + resolveVideoInteractive(fakeStreamClient(paths), 4321, undefined), + ).rejects.toThrow("A video is required."); + expect(paths).toEqual([]); +}); + +// --force must not let a destructive command delete a video the user never named. +test("force refuses to pick a video even when it could prompt", async () => { + const paths: string[] = []; + + await expect( + resolveVideoInteractive(fakeStreamClient(paths), 4321, undefined, { + force: true, + }), + ).rejects.toThrow("A video is required."); + expect(paths).toEqual([]); +}); + +test("force still resolves an explicit GUID", async () => { + const video = await resolveVideoInteractive( + fakeStreamClient([]), + 4321, + "video-guid", + { force: true }, + ); + expect(video.guid).toBe("video-guid"); +}); + +test("the missing-video error points at the listing command", async () => { + try { + await resolveVideoInteractive(fakeStreamClient([]), 4321, undefined, { + output: "json", + }); + throw new Error("expected a UserError"); + } catch (err) { + expect((err as { hint?: string }).hint).toContain( + "bunny stream videos list", + ); + } +}); diff --git a/packages/cli/src/commands/stream/videos/interactive.ts b/packages/cli/src/commands/stream/videos/interactive.ts new file mode 100644 index 00000000..8ab9d248 --- /dev/null +++ b/packages/cli/src/commands/stream/videos/interactive.ts @@ -0,0 +1,103 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { UserError } from "../../../core/errors.ts"; +import type { GlobalArgs, OutputFormat } from "../../../core/types.ts"; +import { isInteractive, prompts, withSpinner } from "../../../core/ui.ts"; +import type { VideoLibraryModel } from "../api.ts"; +import { resolveLibraryInteractive } from "../interactive.ts"; +import { + connectStreamLibrary, + fetchVideo, + fetchVideos, + type StreamClient, + type VideoModel, + videoStatusLabel, +} from "../videos-api.ts"; + +export interface StreamLibraryContext { + library: VideoLibraryModel; + libraryId: number; + client: StreamClient; +} + +/** + * The preamble every `videos` command shares: resolve the library the same way + * `stream upload` does (`--lib`, then the linked directory, then a picker), then + * open a Stream client authenticated with that library's own API key. + */ +export async function streamLibraryContext( + args: Pick & { + lib?: string; + offerLink?: boolean; + /** Destructive commands pass their --force so it disables the picker. */ + force?: boolean; + }, +): Promise { + const config = resolveConfig(args.profile, args.apiKey, args.verbose); + const coreClient = createCoreClient(clientOptions(config, args.verbose)); + + const library = await resolveLibraryInteractive(coreClient, args.lib, { + output: args.output, + offerLink: args.offerLink, + force: args.force, + }); + + return { + library, + libraryId: library.Id as number, + client: connectStreamLibrary(library, { config, verbose: args.verbose }), + }; +} + +/** + * Resolve a video by GUID, or prompt the user to pick one from the library when + * no reference is given. Manages its own spinner so it never spins over a prompt. + * + * Never prompts non-interactively (json output, no TTY, or `force`): errors + * instead, so a destructive command with --force can't delete a picked video. + */ +export async function resolveVideoInteractive( + client: StreamClient, + libraryId: number, + ref: string | undefined, + opts: { output?: OutputFormat; force?: boolean } = {}, +): Promise { + if (ref) { + return withSpinner("Resolving video...", () => + fetchVideo(client, libraryId, ref), + ); + } + + if (opts.force || !isInteractive(opts.output)) { + throw new UserError( + "A video is required.", + "Pass the video GUID, which `bunny stream videos list` prints.", + ); + } + + const videos = await withSpinner("Fetching videos...", () => + fetchVideos(client, libraryId), + ); + if (videos.length === 0) { + throw new UserError( + "No videos found in this library.", + 'Add one with "bunny stream upload ".', + ); + } + + const { guid } = await prompts({ + type: "select", + name: "guid", + message: "Video:", + choices: videos.map((video) => ({ + title: `${video.title} (${videoStatusLabel(video.status)})`, + value: video.guid, + })), + }); + if (guid === undefined) throw new UserError("A video is required."); + + // The listing returns full video models, so the picked one needs no re-fetch. + const picked = videos.find((video) => video.guid === guid); + return picked ?? (await fetchVideo(client, libraryId, guid)); +} diff --git a/packages/cli/src/commands/stream/videos/list.ts b/packages/cli/src/commands/stream/videos/list.ts new file mode 100644 index 00000000..a2569dda --- /dev/null +++ b/packages/cli/src/commands/stream/videos/list.ts @@ -0,0 +1,88 @@ +import { defineCommand } from "../../../core/define-command.ts"; +import { + formatBytes, + formatDateTime, + formatTable, +} from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { withSpinner } from "../../../core/ui.ts"; +import { + fetchVideos, + formatDuration, + videoStatusLabel, +} from "../videos-api.ts"; +import { streamLibraryContext } from "./interactive.ts"; + +interface VideoListArgs { + lib?: string; + search?: string; +} + +export const streamVideoListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List the videos in a Stream video library.", + examples: [ + ["$0 stream videos list", "List videos in the linked library"], + ["$0 stream videos list --lib 12345", "List videos in a specific library"], + ["$0 stream videos list --search launch", "Filter by title"], + [ + "$0 stream upload ./video.mp4", + "Videos are added by uploading; there is no videos create", + ], + ], + + builder: (yargs) => + yargs + .option("lib", { + alias: "library", + type: "string", + describe: "Video library ID (defaults to the linked library)", + }) + .option("search", { + type: "string", + describe: "Only list videos matching this search term", + }), + + handler: async ({ lib, search, profile, output, verbose, apiKey }) => { + const { client, libraryId } = await streamLibraryContext({ + lib, + profile, + output, + verbose, + apiKey, + offerLink: true, + }); + + const videos = await withSpinner("Fetching videos...", () => + fetchVideos(client, libraryId, { search }), + ); + + if (output === "json") { + logger.log(JSON.stringify(videos, null, 2)); + return; + } + + if (videos.length === 0) { + logger.info("No videos found."); + logger.dim("Add one with `bunny stream upload `."); + return; + } + + logger.log( + formatTable( + ["ID", "Title", "Status", "Size", "Length", "Views", "Uploaded"], + videos.map((video) => [ + video.guid, + video.title, + videoStatusLabel(video.status), + formatBytes(video.storageSize ?? 0), + formatDuration(video.length), + String(video.views ?? 0), + formatDateTime(video.dateUploaded), + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/stream/videos/show.ts b/packages/cli/src/commands/stream/videos/show.ts new file mode 100644 index 00000000..60321f81 --- /dev/null +++ b/packages/cli/src/commands/stream/videos/show.ts @@ -0,0 +1,81 @@ +import { defineCommand } from "../../../core/define-command.ts"; +import { + formatBytes, + formatDateTime, + formatKeyValue, +} from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { + directPlayUrl, + formatDuration, + videoStatusLabel, +} from "../videos-api.ts"; +import { + resolveVideoInteractive, + streamLibraryContext, +} from "./interactive.ts"; + +interface VideoShowArgs { + video?: string; + lib?: string; +} + +export const streamVideoShowCommand = defineCommand({ + command: "show [video]", + describe: "Show details for a video.", + examples: [ + ["$0 stream videos show 1a2b3c4d-...", "Show a video by GUID"], + ["$0 stream videos show", "Pick a video interactively"], + ["$0 stream videos show 1a2b3c4d-... --output json", "JSON output"], + ], + + builder: (yargs) => + yargs + .positional("video", { + type: "string", + describe: "Video GUID", + }) + .option("lib", { + alias: "library", + type: "string", + describe: "Video library ID (defaults to the linked library)", + }), + + handler: async ({ video: ref, lib, profile, output, verbose, apiKey }) => { + const { client, libraryId } = await streamLibraryContext({ + lib, + profile, + output, + verbose, + apiKey, + offerLink: true, + }); + + const video = await resolveVideoInteractive(client, libraryId, ref, { + output, + }); + + if (output === "json") { + logger.log(JSON.stringify(video, null, 2)); + return; + } + + logger.log( + formatKeyValue( + [ + { key: "ID", value: video.guid }, + { key: "Title", value: video.title }, + { key: "Status", value: videoStatusLabel(video.status) }, + { key: "Size", value: formatBytes(video.storageSize ?? 0) }, + { key: "Length", value: formatDuration(video.length) }, + { key: "Resolutions", value: video.availableResolutions || "—" }, + { key: "Views", value: String(video.views ?? 0) }, + { key: "Collection", value: video.collectionId || "—" }, + { key: "Direct play", value: directPlayUrl(libraryId, video.guid) }, + { key: "Uploaded", value: formatDateTime(video.dateUploaded) }, + ], + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/stream/videos/update.test.ts b/packages/cli/src/commands/stream/videos/update.test.ts new file mode 100644 index 00000000..d4b2a390 --- /dev/null +++ b/packages/cli/src/commands/stream/videos/update.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import prompts from "prompts"; +import { nextVideoTitle } from "./update.ts"; + +test("--title wins and is trimmed", async () => { + expect(await nextVideoTitle("old", " Launch demo ", false)).toBe( + "Launch demo", + ); +}); + +test("no --title and no way to prompt is an error, not a silent no-op", async () => { + await expect(nextVideoTitle("old", undefined, false)).rejects.toThrow( + "Nothing to update.", + ); + await expect(nextVideoTitle("old", " ", false)).rejects.toThrow( + "Nothing to update.", + ); +}); + +test("an answered prompt provides the new title", async () => { + prompts.inject(["Launch demo"]); + expect(await nextVideoTitle("old", undefined, true)).toBe("Launch demo"); +}); + +// Leaving the prefilled title alone must not be reported as a rename. +test("a blank answer leaves the title alone", async () => { + prompts.inject([""]); + expect(await nextVideoTitle("old", undefined, true)).toBeUndefined(); +}); + +test("cancelling the prompt leaves the title alone", async () => { + prompts.inject([new Error("cancelled")]); + expect(await nextVideoTitle("old", undefined, true)).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/stream/videos/update.ts b/packages/cli/src/commands/stream/videos/update.ts new file mode 100644 index 00000000..a0dd93ba --- /dev/null +++ b/packages/cli/src/commands/stream/videos/update.ts @@ -0,0 +1,130 @@ +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { isInteractive, prompts, withSpinner } from "../../../core/ui.ts"; +import { fetchVideo, updateVideo } from "../videos-api.ts"; +import { + resolveVideoInteractive, + streamLibraryContext, +} from "./interactive.ts"; + +interface VideoUpdateArgs { + video?: string; + lib?: string; + title?: string; +} + +/** + * The title to save, or undefined when the prompt was cancelled or left blank. + * + * `--title` wins; otherwise an interactive run is offered the current title to + * edit, and an unattended run has nothing to change and says so. + */ +export async function nextVideoTitle( + current: string, + title: string | undefined, + interactive: boolean, +): Promise { + const explicit = title?.trim(); + if (explicit) return explicit; + + if (!interactive) { + throw new UserError( + "Nothing to update.", + "Pass --title to set a new title.", + ); + } + + const { value } = await prompts({ + type: "text", + name: "value", + message: "Title:", + initial: current, + }); + // A blank answer is treated as "leave it alone", like a cancel. + return (value as string | undefined)?.trim() || undefined; +} + +export const streamVideoUpdateCommand = defineCommand({ + command: "update [video]", + describe: "Update a video's title.", + examples: [ + [ + '$0 stream videos update 1a2b3c4d-... --title "Launch demo"', + "Rename a video", + ], + ["$0 stream videos update", "Pick a video, then edit its title"], + ], + + builder: (yargs) => + yargs + .positional("video", { + type: "string", + describe: "Video GUID", + }) + .option("lib", { + alias: "library", + type: "string", + describe: "Video library ID (defaults to the linked library)", + }) + .option("title", { + type: "string", + describe: "New video title (prompts if omitted)", + }), + + handler: async ({ + video: ref, + lib, + title, + profile, + output, + verbose, + apiKey, + }) => { + const { client, libraryId } = await streamLibraryContext({ + lib, + profile, + output, + verbose, + apiKey, + offerLink: true, + }); + + const video = await resolveVideoInteractive(client, libraryId, ref, { + output, + }); + + const wanted = await nextVideoTitle( + video.title, + title, + isInteractive(output), + ); + if (wanted === undefined) { + logger.log("Cancelled."); + return; + } + + if (wanted === video.title) { + if (output === "json") { + logger.log(JSON.stringify(video, null, 2)); + return; + } + logger.log("Title unchanged."); + return; + } + + await withSpinner("Updating video...", () => + updateVideo(client, libraryId, video.guid, { title: wanted }), + ); + const updated = await withSpinner("Reading video...", () => + fetchVideo(client, libraryId, video.guid), + ); + + if (output === "json") { + logger.log(JSON.stringify(updated, null, 2)); + return; + } + + logger.success(`Renamed ${video.title} to ${updated.title}.`); + }, +}); diff --git a/packages/openapi-client/src/middleware.test.ts b/packages/openapi-client/src/middleware.test.ts index 4d9773c6..b16872bd 100644 --- a/packages/openapi-client/src/middleware.test.ts +++ b/packages/openapi-client/src/middleware.test.ts @@ -8,6 +8,29 @@ function runRequest(options: ClientOptions, request: Request) { return mw.onRequest!({ request } as never) as Promise; } +/** + * A request that records every attempt to clone or consume its body, so a test + * can prove the middleware left a large binary upload untouched. + */ +function spyRequest(url: string, init: RequestInit) { + const request = new Request(url, init); + const reads: string[] = []; + const spied = ["clone", "json", "text", "arrayBuffer", "blob"] as const; + for (const name of spied) { + const original = Request.prototype[name] as ( + this: Request, + ...args: unknown[] + ) => unknown; + Object.defineProperty(request, name, { + value: (...args: unknown[]) => { + reads.push(name); + return original.apply(request, args); + }, + }); + } + return { request, reads }; +} + function runResponse( options: ClientOptions, response: Response, @@ -46,6 +69,54 @@ describe("authMiddleware onRequest", () => { expect(logs).toContain("→ GET https://api.bunny.net/region"); }); + test("dumps a JSON request body", async () => { + const logs: string[] = []; + const { request, reads } = spyRequest( + "https://api.bunny.net/videolibrary", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ Name: "my-library" }), + }, + ); + + await runRequest( + { apiKey: "k", verbose: true, onDebug: (m) => logs.push(m) }, + request, + ); + + expect(logs.join("\n")).toContain('"Name": "my-library"'); + // The dump reads a clone, never the request that is about to be sent. + expect(reads).toEqual(["clone"]); + }); + + // Reading an octet-stream body would buffer the whole upload (a video, say) + // into memory just to log it, so it is described from its headers instead. + test("never reads a non-JSON request body", async () => { + const logs: string[] = []; + const { request, reads } = spyRequest( + "https://video.bunnycdn.com/library/1/videos/abc", + { + method: "PUT", + headers: { + "content-type": "application/octet-stream", + "content-length": "12", + }, + body: new Blob(["binary-bytes"]), + }, + ); + + await runRequest( + { apiKey: "k", verbose: true, onDebug: (m) => logs.push(m) }, + request, + ); + + expect(reads).toEqual([]); + expect(logs).toContain( + "→ Body (application/octet-stream): 12 bytes, not logged", + ); + }); + test("does not log when onDebug is set but verbose is false", async () => { const logs: string[] = []; await runRequest( @@ -101,6 +172,34 @@ describe("authMiddleware onResponse", () => { expect(error.message).toBe("Conflict"); }); + // Stream answers with StatusModel, whose message field is lowercase; without + // its own extractor the message is dropped for a generic HTTP failure. + test("normalizes the Stream StatusModel (lowercase message)", async () => { + const error = (await captureError( + runResponse( + { apiKey: "k" }, + jsonResponse( + { success: false, message: "URL validation failed", statusCode: 400 }, + 400, + ), + ), + )) as ApiError; + expect(error).toBeInstanceOf(ApiError); + expect(error.status).toBe(400); + expect(error.message).toBe("URL validation failed"); + }); + + // The Core format wins when both shapes are somehow present. + test("prefers the Core Message over a lowercase message", async () => { + const error = (await captureError( + runResponse( + { apiKey: "k" }, + jsonResponse({ Message: "Core wins.", message: "stream" }, 400), + ), + )) as ApiError; + expect(error.message).toBe("Core wins."); + }); + test("uses a friendly status message for an empty error body", async () => { const error = (await captureError( runResponse({ apiKey: "k" }, new Response(null, { status: 401 })), diff --git a/packages/openapi-client/src/middleware.ts b/packages/openapi-client/src/middleware.ts index 6b25f202..625a6571 100644 --- a/packages/openapi-client/src/middleware.ts +++ b/packages/openapi-client/src/middleware.ts @@ -52,6 +52,11 @@ const extractors: Array< // ApiErrorData (Core / Compute) (b) => b?.Message ? { message: b.Message, field: b.Field ?? undefined } : null, + + // StatusModel (Stream): { success, message, statusCode } — lowercase, so the + // Core extractor above misses it and the message would be lost. + (b) => + typeof b?.message === "string" && b.message ? { message: b.message } : null, ]; /** @@ -87,11 +92,24 @@ export function authMiddleware(options: ClientOptions): Middleware { if (debug) { debug(`→ ${request.method} ${request.url}`); if (request.body) { - const cloned = request.clone(); - try { - const body = await cloned.json(); - debug(`→ Body: ${JSON.stringify(body, null, 2)}`); - } catch {} + const contentType = request.headers.get("content-type") ?? ""; + if (looksLikeJson(contentType)) { + const cloned = request.clone(); + try { + const body = await cloned.json(); + debug(`→ Body: ${JSON.stringify(body, null, 2)}`); + } catch {} + } else { + // Never read a non-JSON request body: a binary upload (e.g. a video + // sent as application/octet-stream) would be buffered into memory in + // full just to be logged. Describe it from the headers instead. + const length = request.headers.get("content-length"); + debug( + `→ Body (${contentType || "no content-type"}): ${ + length ? `${length} bytes, not logged` : "not logged" + }`, + ); + } } }