Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ bun ny sites domains add example.com # attach a custom production domain
bun ny sites ssl --no-force-ssl # stop forcing HTTPS on the site's b-cdn.net system host
bun ny sites open # open the site's live URL in the browser
bun ny sites ci init # add a GitHub Actions workflow (push to main goes live)
bun ny stream library list # list Stream video libraries (videos, storage, traffic, replication regions)
bun ny stream library create my-library # create a video library (omit the name to be prompted; --name also works)
bun ny stream library create my-library --replication-regions NY,SG # replicate the library's storage to New York and Singapore
bun ny stream library show my-library # show one library (accepts a name or ID; omit it to use the linked library, or to pick interactively when nothing is linked). API keys are never printed here, in any output format
bun ny stream library update my-library --resolutions 720p,1080p # edit library settings; omit the flags to edit interactively (encoding tier, codecs, transcribing all have flags)
bun ny stream library credentials my-library --show-secret # deliberately retrieve a library's Stream API key (--read-only for the read-only key; masked without --show-secret)
bun ny stream library delete my-library # delete a library and all of its videos (--force skips the confirmation, and is required non-interactively)
bun ny stream library link my-library # link the directory to a library so video commands can omit it (bun ny stream library unlink removes the link)
bun ny stream video upload ./video.mp4 # upload a local video to the linked library (--title sets the title; files over 2 GB upload resumably via TUS, retried and resumed automatically)
bun ny stream video fetch https://example.com/video.mp4 --lib 12345 # let bunny.net fetch the video server side (--header "Name: value" for an origin that needs auth)
bun ny stream video list # list the videos in the linked library (ID, title, status, size, length, views, upload date)
bun ny stream video show 1a2b3c4d-... # show one video by GUID, including its Direct Play URL
bun ny stream video thumbnail 1a2b3c4d-... --file ./thumb.jpg # set a thumbnail (--url has bunny.net download one instead)
bun ny stream video stats 1a2b3c4d-... # views and watch time for one video (--heatmap, --play-data for the other views)
bun ny stream video cleanup 1a2b3c4d-... --non-configured --dry-run # preview deleting renditions the library no longer configures
bun ny stream collection list # list a library's collections (create/show/rename/delete too; videos join one with --collection, and deleting a collection deletes the videos inside it)
bun ny stream caption add 1a2b3c4d-... en --file ./captions.vtt # upload your own caption file for one language
bun ny stream transcribe 1a2b3c4d-... --languages en,de # paid: transcribe the audio into captions ($0.10 per language-minute)
bun ny stream smart 1a2b3c4d-... --title --chapters # paid: generate a title and chapters from an existing transcript (transcribe the video first if it has no captions)
```

Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file).
Expand Down
141 changes: 141 additions & 0 deletions packages/cli/README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,7 @@ const experimentalCommands: CommandModule[] = [
registriesNamespace,
registryNamespace,
sitesNamespace,
streamNamespace,
];

let instance = yargs(hideBin(process.argv))
Expand Down
192 changes: 192 additions & 0 deletions packages/cli/src/commands/stream/api.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* 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");
});
});
87 changes: 87 additions & 0 deletions packages/cli/src/commands/stream/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { createCoreClient } from "@bunny.net/openapi-client";
import type { components } from "@bunny.net/openapi-client/generated/core.d.ts";
import { UserError } from "@/core/errors.ts";

export type CoreClient = ReturnType<typeof createCoreClient>;
export type VideoLibraryModel = components["schemas"]["VideoLibraryModel"];
export type VideoLibraryCreateModel =
components["schemas"]["VideoLibraryCreateModel"];
export type VideoLibraryUpdateModel =
components["schemas"]["VideoLibraryUpdateModel"];

export type SafeVideoLibrary = Omit<
VideoLibraryModel,
"ApiKey" | "ReadOnlyApiKey" | "ApiAccessKey"
>;

// Strip the read-write/read-only library keys so list/show/create JSON never
// leaks credentials; use `stream library credentials` to retrieve those on purpose.
// ApiAccessKey is deprecated but carries the same value as ApiKey, so leaving it
// in would leak the write-capable key right back out.
export function toSafeVideoLibrary(
library: VideoLibraryModel,
): SafeVideoLibrary {
const { ApiKey: _k, ReadOnlyApiKey: _r, ApiAccessKey: _a, ...safe } = library;
return safe;
}

/** Fetch all Stream video libraries on the account, paginated and sorted by name. */
export async function fetchLibraries(
client: CoreClient,
): Promise<VideoLibraryModel[]> {
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<VideoLibraryModel> {
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<VideoLibraryModel> {
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);
}
Loading
Loading