diff --git a/README.md b/README.md index ed9e81f..24641b7 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,20 @@ mux webhooks trigger video.asset.created --forward-to http://localhost:3000/api/ Run `mux webhooks trigger ` to see all supported event types. +### Webhook Management + +Configure the webhooks that Mux delivers events to for the current environment. + +```bash +mux webhooks create --address https://example.com/api/webhooks/mux +mux webhooks list +mux webhooks get +mux webhooks update [--address ] [--enable | --disable] +mux webhooks delete [--force] +``` + +The signing secret is printed when a webhook is created; store it securely and use it to verify webhook signatures. + ## Commands
@@ -350,10 +364,14 @@ Update metadata fields on a video asset. At least one field flag must be provide - `--creator-id ` - Set `meta.creator_id` (max 128 characters) - `--external-id ` - Set `meta.external_id` (max 128 characters) - `--passthrough ` - Set `passthrough` (max 255 characters) +- `--thumbnail-time ` - Set the asset's default thumbnail time +- `--clear-thumbnail-time` - Reset the default thumbnail time ```bash mux assets update abc123xyz --title "My Video" --creator-id "user-42" mux assets update abc123xyz --title "" # clear a field +mux assets update abc123xyz --thumbnail-time 12.5 +mux assets update abc123xyz --clear-thumbnail-time ``` #### `mux assets delete ` @@ -413,6 +431,16 @@ mux assets static-renditions delete [--force] **Resolution options:** `highest`, `audio-only`, `2160p`, `1440p`, `1080p`, `720p`, `540p`, `480p`, `360p`, `270p` +#### Shots Management + +Shot detection data segments a video into its individual shots. Generated data is reused by Robots jobs. + +```bash +mux assets shots get +mux assets shots generate +mux assets shots delete [--force] +``` + #### Track Management Manage text and audio tracks (subtitles, captions, audio) on video assets. @@ -434,6 +462,16 @@ Add a text or audio track to an asset. mux assets tracks create abc123xyz --url https://example.com/subs.vtt --type text --language-code en --text-type subtitles ``` +##### `mux assets tracks update ` + +Update a text track (subtitles/captions) on an asset. At least one field flag must be provided. The API rejects non-text tracks. + +**Options:** `--name `, `--language-code `, `--closed-captions `, `--passthrough ` + +```bash +mux assets tracks update abc123xyz track456 --name "English (SDH)" --closed-captions true +``` + ##### `mux assets tracks delete ` Delete a track from an asset. Supports `--force`. @@ -861,6 +899,8 @@ mux robots cancel **List options:** `--workflow`, `--status`, `--asset-id`, `--limit`, `--page`, `--compact` +**Workflow values:** `summarize`, `moderate`, `generate-chapters`, `ask-questions`, `find-key-moments`, `translate-captions`, `edit-captions`, `find-best-thumbnails`, `find-scenes`, `generate-engagement-insights`, `generate-premium-captions`, `translate-audio` + #### `mux robots summarize ` Generate a title, description, and tags for a video. @@ -948,6 +988,104 @@ Translate captions on a video to another language. mux robots translate-captions abc123 --track-id track456 --to-language-code es ``` +#### `mux robots translate-audio ` + +Translate a video's audio track to another language. + +**Options:** +- `--to-language-code ` - BCP 47 code for the translated audio (required) +- `--no-upload` - Do not upload the translated audio track to Mux +- `--passthrough ` - Arbitrary metadata (max 255 chars) + +```bash +mux robots translate-audio abc123 --to-language-code es +``` + +#### `mux robots edit-captions ` + +Edit a caption track with find/replace rules or automatic profanity censoring. + +**Options:** +- `--track-id ` - Caption track ID to edit (required) +- `--replace ` - Find/replace rule (repeatable) +- `--case-sensitive` - Make `--replace` rules match case-sensitively +- `--censor-profanity` - Automatically censor profanity +- `--censor-mode ` - `blank`, `remove`, or `mask` (implies `--censor-profanity`) +- `--delete-original-track` - Delete the original track after editing +- `--track-name-suffix ` - Suffix appended to the edited track name +- `--no-upload` - Do not upload the edited VTT to Mux +- `--passthrough ` - Arbitrary metadata (max 255 chars) + +```bash +mux robots edit-captions abc123 --track-id track456 --replace "Muks=Mux" --censor-profanity +``` + +#### `mux robots generate-premium-captions ` + +Generate high-accuracy premium captions for a video. + +**Options:** +- `--language-code ` - BCP 47 code of the spoken audio (auto-detected when omitted) +- `--include-speakers` - Include speaker labels +- `--include-words` - Include word-level timing +- `--phrase ` - Domain-specific phrase to boost recognition (repeatable) +- `--replace-existing` - Replace an existing generated caption track in the same language +- `--track-name ` - Name for the generated caption track +- `--no-upload` - Do not upload the generated captions to Mux +- `--passthrough ` - Arbitrary metadata (max 255 chars) + +```bash +mux robots generate-premium-captions abc123 --language-code en --include-speakers +``` + +#### `mux robots find-best-thumbnails ` + +Find the best thumbnail candidates for a video. + +**Options:** +- `--max-thumbnails ` - Maximum number of candidates to return +- `--update-asset-thumbnail` - Set the asset's default thumbnail time to the top candidate +- `--audience ` - Intended audience used as scoring guidance +- `--campaign-style ` - Campaign/channel thumbnail style to prefer +- `--looking-for ` - What to look for in candidate thumbnails +- `--start-time ` / `--end-time ` - Execution window on the asset timeline +- `--passthrough ` - Arbitrary metadata (max 255 chars) + +```bash +mux robots find-best-thumbnails abc123 --max-thumbnails 3 --update-asset-thumbnail +``` + +#### `mux robots find-scenes ` + +Detect and describe the scenes in a video. + +**Options:** +- `--language-code ` - BCP 47 code of the caption track to analyze +- `--min-scene-duration-ms ` - Minimum scene duration +- `--min-scenes ` - Minimum number of scenes +- `--audience ` - Intended audience used as model guidance +- `--brand-term ` - Preferred brand or domain term (repeatable) +- `--narration-detail ` - `concise`, `balanced`, or `detailed` +- `--start-time ` / `--end-time ` - Execution window on the asset timeline +- `--passthrough ` - Arbitrary metadata (max 255 chars) + +```bash +mux robots find-scenes abc123 --min-scenes 5 --narration-detail detailed +``` + +#### `mux robots generate-engagement-insights ` + +Analyze a video's engagement data and generate insights. + +**Options:** +- `--passthrough ` - Arbitrary metadata (max 255 chars) + +```bash +mux robots generate-engagement-insights abc123 --wait +``` + +All job creation commands also support `--file ` (JSON config with the full parameters object), `--wait` (poll until the job completes), and `--json`. +
@@ -1032,6 +1170,18 @@ mux dimensions list # list available dimensio mux dimensions values [--timeframe "24:hours"] ``` +#### Engagement + +Engagement analytics for a specific video, addressed by exactly one of `--asset-id`, `--playback-id`, or `--video-id`. + +```bash +# Viewership across the video timeline +mux engagement heatmap --asset-id [--timeframe "7:days"] + +# Most-watched moments +mux engagement hotspots --asset-id [--limit 5] [--order-direction desc] +``` + #### Errors ```bash @@ -1180,9 +1330,10 @@ src/ │ ├── incidents/ # Mux Data: incident tracking │ ├── annotations/ # Mux Data: annotation management │ ├── dimensions/ # Mux Data: dimension queries +│ ├── engagement/ # Mux Data: engagement analytics │ ├── errors/ # Mux Data: error analytics │ ├── exports/ # Mux Data: export files -│ ├── webhooks/ # Webhook listening & replay +│ ├── webhooks/ # Webhook config, listening & replay │ │ ├── events/ # Event storage & replay │ │ └── listen.ts # Real-time SSE listener │ ├── env/ # Environment management diff --git a/package.json b/package.json index 47519bc..5ebd05e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@cliffy/ansi": "jsr:1.0.0-rc.8", "@cliffy/command": "jsr:1.0.0-rc.8", "@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0-rc.8", - "@mux/mux-node": "^14.0.0", + "@mux/ts": "15.1.0", "@opentui/core": "^0.1.50", "@opentui/react": "^0.1.50", "@std/path": "npm:@jsr/std__path@^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e6939c..95d140a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,9 @@ importers: '@cliffy/prompt': specifier: npm:@jsr/cliffy__prompt@1.0.0-rc.8 version: '@jsr/cliffy__prompt@1.0.0-rc.8' - '@mux/mux-node': - specifier: ^14.0.0 - version: 14.0.0 + '@mux/ts': + specifier: 15.1.0 + version: 15.1.0 '@opentui/core': specifier: ^0.1.50 version: 0.1.50(stage-js@1.0.0-alpha.17)(typescript@5.9.3)(web-tree-sitter@0.25.10) @@ -278,8 +278,8 @@ packages: '@jsr/std__text@1.0.16': resolution: {integrity: sha512-UawOIn+Vu0BBusfOpToMXHISa/z8vDUKxXntQGVM5w6e9W1XkFzylnbc0cWWa9f0dESlxDyjvReHzPNM86hYlA==, tarball: https://npm.jsr.io/~/11/@jsr/std__text/1.0.16.tgz} - '@mux/mux-node@14.0.0': - resolution: {integrity: sha512-jhp1a7OACEQKzOr7Zz9yquW0Gma/XSWvbvUnRiRRMs5ObNWKAaRVDCMBEorbVDawDTxwlQNfGKViuvzCXWUJpQ==} + '@mux/ts@15.1.0': + resolution: {integrity: sha512-VWK0NGZY73xgTxCa/CiUvG6xbnj/qZ6O0mAY1kOe5x7wCGvEg75YFjX9Pwbzqq80BvwL323eILIdhBT3Z3FVoQ==} hasBin: true '@opentui/core-darwin-arm64@0.1.50': @@ -884,7 +884,7 @@ snapshots: dependencies: '@jsr/std__regexp': 1.0.1 - '@mux/mux-node@14.0.0': {} + '@mux/ts@15.1.0': {} '@opentui/core-darwin-arm64@0.1.50': optional: true diff --git a/src/commands/assets/create.ts b/src/commands/assets/create.ts index 5dd21aa..142798b 100644 --- a/src/commands/assets/create.ts +++ b/src/commands/assets/create.ts @@ -1,5 +1,5 @@ import { Command } from '@cliffy/command'; -import type Mux from '@mux/mux-node'; +import type Mux from '@mux/ts'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { expandGlobPattern, uploadFile } from '@/lib/file-upload.ts'; diff --git a/src/commands/assets/index.ts b/src/commands/assets/index.ts index 6e1cc80..8a2a3ca 100644 --- a/src/commands/assets/index.ts +++ b/src/commands/assets/index.ts @@ -6,6 +6,7 @@ import { inputInfoCommand } from './input-info.ts'; import { listCommand } from './list.ts'; import { manageCommand } from './manage/index.ts'; import { playbackIdsCommand } from './playback-ids/index.ts'; +import { shotsCommand } from './shots/index.ts'; import { staticRenditionsCommand } from './static-renditions/index.ts'; import { tracksCommand } from './tracks/index.ts'; import { updateCommand } from './update.ts'; @@ -27,5 +28,6 @@ export const assetsCommand = new Command() .command('update-master-access', updateMasterAccessCommand) .command('manage', manageCommand) .command('playback-ids', playbackIdsCommand) + .command('shots', shotsCommand) .command('static-renditions', staticRenditionsCommand) .command('tracks', tracksCommand); diff --git a/src/commands/assets/list.ts b/src/commands/assets/list.ts index c761dc6..9df5427 100644 --- a/src/commands/assets/list.ts +++ b/src/commands/assets/list.ts @@ -1,5 +1,5 @@ import { Command } from '@cliffy/command'; -import type { Asset } from '@mux/mux-node/resources/video/assets'; +import type { Asset } from '@mux/ts/resources/video/assets'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { diff --git a/src/commands/assets/manage/AssetManageApp.tsx b/src/commands/assets/manage/AssetManageApp.tsx index 6a59752..1dd7fde 100644 --- a/src/commands/assets/manage/AssetManageApp.tsx +++ b/src/commands/assets/manage/AssetManageApp.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/react */ -import type Mux from '@mux/mux-node'; -import type { Asset } from '@mux/mux-node/resources/video/assets'; +import type Mux from '@mux/ts'; +import type { Asset } from '@mux/ts/resources/video/assets'; import { useKeyboard, useRenderer } from '@opentui/react'; import { useCallback, useEffect, useState } from 'react'; import { getCurrentEnvironment } from '../../../lib/config.ts'; diff --git a/src/commands/assets/shots/_shared.ts b/src/commands/assets/shots/_shared.ts new file mode 100644 index 0000000..9711f07 --- /dev/null +++ b/src/commands/assets/shots/_shared.ts @@ -0,0 +1,16 @@ +import type { AssetShots } from '@mux/ts/resources/video/assets'; + +export function printShots(assetId: string, shots: AssetShots): void { + console.log(`Shots for asset ${assetId}:`); + console.log(` Status: ${shots.status}`); + if (shots.shots_manifest_url) { + console.log(` Manifest URL: ${shots.shots_manifest_url}`); + } + if (shots.errors) { + const type = shots.errors.type ? ` (${shots.errors.type})` : ''; + console.log(` Errors${type}:`); + for (const message of shots.errors.messages ?? []) { + console.log(` ${message}`); + } + } +} diff --git a/src/commands/assets/shots/delete.ts b/src/commands/assets/shots/delete.ts new file mode 100644 index 0000000..d950a72 --- /dev/null +++ b/src/commands/assets/shots/delete.ts @@ -0,0 +1,49 @@ +import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { confirmPrompt } from '@/lib/prompt.ts'; + +interface DeleteOptions { + force?: boolean; + json?: boolean; +} + +export const deleteCommand = new Command() + .description('Delete the shot detection data for an asset') + .arguments('') + .option('-f, --force', 'Skip confirmation prompt') + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: DeleteOptions, assetId: string) => { + try { + if (!options.force) { + if (wantsJson(options)) { + throw new Error( + 'Deletion requires the --force flag with --json or in agent mode', + ); + } + + const confirmed = await confirmPrompt({ + message: `Are you sure you want to delete the shots data for asset ${assetId}?`, + default: false, + }); + + if (!confirmed) { + console.log('Deletion cancelled.'); + return; + } + } + + const mux = await createAuthenticatedMuxClient(); + + await mux.video.assets.deleteShots(assetId); + + if (wantsJson(options)) { + console.log(JSON.stringify({ success: true, assetId }, null, 2)); + } else { + console.log(`Shots data for asset ${assetId} deleted successfully`); + } + } catch (error) { + await handleCommandError(error, 'assets', 'shots delete', options); + } + }); diff --git a/src/commands/assets/shots/generate.ts b/src/commands/assets/shots/generate.ts new file mode 100644 index 0000000..04e3f38 --- /dev/null +++ b/src/commands/assets/shots/generate.ts @@ -0,0 +1,32 @@ +import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { printShots } from './_shared.ts'; + +interface GenerateOptions { + json?: boolean; +} + +export const generateCommand = new Command() + .description( + 'Generate shot detection data for an asset (reused by Robots jobs)', + ) + .arguments('') + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: GenerateOptions, assetId: string) => { + try { + const mux = await createAuthenticatedMuxClient(); + + const shots = await mux.video.assets.generateShots(assetId); + + if (wantsJson(options)) { + console.log(JSON.stringify(shots, null, 2)); + } else { + console.log(`Shot generation requested for asset ${assetId}.\n`); + printShots(assetId, shots); + } + } catch (error) { + await handleCommandError(error, 'assets', 'shots generate', options); + } + }); diff --git a/src/commands/assets/shots/get.ts b/src/commands/assets/shots/get.ts new file mode 100644 index 0000000..d0c37ab --- /dev/null +++ b/src/commands/assets/shots/get.ts @@ -0,0 +1,29 @@ +import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { printShots } from './_shared.ts'; + +interface GetOptions { + json?: boolean; +} + +export const getCommand = new Command() + .description('Get the shot detection data for an asset') + .arguments('') + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: GetOptions, assetId: string) => { + try { + const mux = await createAuthenticatedMuxClient(); + + const shots = await mux.video.assets.retrieveShots(assetId); + + if (wantsJson(options)) { + console.log(JSON.stringify(shots, null, 2)); + } else { + printShots(assetId, shots); + } + } catch (error) { + await handleCommandError(error, 'assets', 'shots get', options); + } + }); diff --git a/src/commands/assets/shots/index.ts b/src/commands/assets/shots/index.ts new file mode 100644 index 0000000..590cbf1 --- /dev/null +++ b/src/commands/assets/shots/index.ts @@ -0,0 +1,13 @@ +import { Command } from '@cliffy/command'; +import { deleteCommand } from './delete.ts'; +import { generateCommand } from './generate.ts'; +import { getCommand } from './get.ts'; + +export const shotsCommand = new Command() + .description('Manage shot detection data for Mux video assets') + .action(function () { + this.showHelp(); + }) + .command('get', getCommand) + .command('generate', generateCommand) + .command('delete', deleteCommand); diff --git a/src/commands/assets/shots/shots.test.ts b/src/commands/assets/shots/shots.test.ts new file mode 100644 index 0000000..93c66db --- /dev/null +++ b/src/commands/assets/shots/shots.test.ts @@ -0,0 +1,85 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { deleteCommand } from './delete.ts'; +import { generateCommand } from './generate.ts'; +import { getCommand } from './get.ts'; +import { shotsCommand } from './index.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux assets shots commands', () => { + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command group', () => { + test('has correct group description', () => { + expect(shotsCommand.getDescription()).toMatch(/shot/i); + }); + + test.each([ + 'get', + 'generate', + 'delete', + ])('registers the %s subcommand', (name) => { + expect(shotsCommand.getCommand(name)).toBeDefined(); + }); + }); + + describe.each([ + ['get', getCommand], + ['generate', generateCommand], + ['delete', deleteCommand], + ])('mux assets shots %s', (_name, command) => { + test('requires asset-id argument', () => { + const args = command.getArguments(); + expect(args.length).toBeGreaterThan(0); + expect(args[0].name).toBe('asset-id'); + }); + + test('has --json flag for output formatting', () => { + const option = command.getOptions().find((opt) => opt.name === 'json'); + expect(option).toBeDefined(); + }); + + test('rejects a missing asset-id argument', async () => { + // With a parent group registered, Cliffy throws instead of exiting + let rejected = false; + try { + await command.parse([]); + } catch (_error) { + rejected = true; + } + + expect(rejected || exitSpy.mock.calls.length > 0).toBe(true); + }); + }); + + describe('mux assets shots delete safety', () => { + test('has --force flag to skip confirmation', () => { + const option = deleteCommand + .getOptions() + .find((opt) => opt.name === 'force'); + expect(option).toBeDefined(); + }); + }); +}); diff --git a/src/commands/assets/static-renditions/create.ts b/src/commands/assets/static-renditions/create.ts index a6f94ae..8bf898c 100644 --- a/src/commands/assets/static-renditions/create.ts +++ b/src/commands/assets/static-renditions/create.ts @@ -1,5 +1,5 @@ import { Command } from '@cliffy/command'; -import type Mux from '@mux/mux-node'; +import type Mux from '@mux/ts'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -96,7 +96,7 @@ async function pollForRendition( assetId: string, renditionId: string, jsonOutput?: boolean, -): Promise { +): Promise { const POLL_INTERVAL_MS = 2000; const MAX_POLL_TIME_MS = 10 * 60 * 1000; // 10 minutes const startTime = Date.now(); @@ -115,19 +115,19 @@ async function pollForRendition( if (!jsonOutput) { console.log(' done!'); } - return rendition as Mux.Video.AssetCreateStaticRenditionResponse; + return rendition as Mux.Video.StaticRendition; } if (rendition.status === 'errored') { if (!jsonOutput) { console.log(' failed!'); } - return rendition as Mux.Video.AssetCreateStaticRenditionResponse; + return rendition as Mux.Video.StaticRendition; } if (rendition.status === 'skipped') { if (!jsonOutput) { console.log(' skipped!'); } - return rendition as Mux.Video.AssetCreateStaticRenditionResponse; + return rendition as Mux.Video.StaticRendition; } } @@ -145,7 +145,7 @@ function sleep(ms: number): Promise { } function outputRendition( - rendition: Mux.Video.AssetCreateStaticRenditionResponse, + rendition: Mux.Video.StaticRendition, jsonOutput?: boolean, showAsyncMessage?: boolean, ) { diff --git a/src/commands/assets/static-renditions/list.ts b/src/commands/assets/static-renditions/list.ts index d100e7e..9fe0e3c 100644 --- a/src/commands/assets/static-renditions/list.ts +++ b/src/commands/assets/static-renditions/list.ts @@ -1,5 +1,5 @@ import { Command } from '@cliffy/command'; -import type { Asset } from '@mux/mux-node/resources/video/assets'; +import type { Asset } from '@mux/ts/resources/video/assets'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; diff --git a/src/commands/assets/tracks/index.ts b/src/commands/assets/tracks/index.ts index 81f26ef..41d0bf7 100644 --- a/src/commands/assets/tracks/index.ts +++ b/src/commands/assets/tracks/index.ts @@ -2,6 +2,7 @@ import { Command } from '@cliffy/command'; import { createCommand } from './create.ts'; import { deleteCommand } from './delete.ts'; import { generateSubtitlesCommand } from './generate-subtitles.ts'; +import { updateCommand } from './update.ts'; export const tracksCommand = new Command() .description('Manage tracks (subtitles, audio) for Mux video assets') @@ -9,5 +10,6 @@ export const tracksCommand = new Command() this.showHelp(); }) .command('create', createCommand) + .command('update', updateCommand) .command('delete', deleteCommand) .command('generate-subtitles', generateSubtitlesCommand); diff --git a/src/commands/assets/tracks/update.test.ts b/src/commands/assets/tracks/update.test.ts new file mode 100644 index 0000000..98be6b4 --- /dev/null +++ b/src/commands/assets/tracks/update.test.ts @@ -0,0 +1,82 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { updateCommand } from './update.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux assets tracks update command', () => { + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command metadata', () => { + test('has correct command description', () => { + expect(updateCommand.getDescription()).toMatch(/update.*track/i); + }); + + test('requires asset-id and track-id arguments', () => { + const args = updateCommand.getArguments(); + expect(args.length).toBe(2); + expect(args[0].name).toBe('asset-id'); + expect(args[1].name).toBe('track-id'); + }); + }); + + describe('Optional flags', () => { + test.each([ + 'name', + 'language-code', + 'closed-captions', + 'passthrough', + 'json', + ])('has --%s flag', (flag) => { + const option = updateCommand + .getOptions() + .find((opt) => opt.name === flag); + expect(option).toBeDefined(); + }); + }); + + describe('Input validation', () => { + test('throws error when arguments are not provided', async () => { + try { + await updateCommand.parse([]); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalled(); + }); + + test('errors when no field flags are provided', async () => { + try { + await updateCommand.parse(['some-asset-id', 'some-track-id']); + } catch (_error) { + // Expected to throw due to no field flags + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/at least one field/i); + }); + }); +}); diff --git a/src/commands/assets/tracks/update.ts b/src/commands/assets/tracks/update.ts new file mode 100644 index 0000000..86350d9 --- /dev/null +++ b/src/commands/assets/tracks/update.ts @@ -0,0 +1,82 @@ +import { Command } from '@cliffy/command'; +import type { AssetUpdateTrackParams } from '@mux/ts/resources/video/assets'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; + +interface UpdateOptions { + name?: string; + languageCode?: string; + closedCaptions?: boolean; + passthrough?: string; + json?: boolean; +} + +export const updateCommand = new Command() + .description('Update a text track (subtitles/captions) on a Mux video asset') + .arguments(' ') + .option('--name ', 'Human-readable name for the track') + .option( + '--language-code ', + 'BCP 47 language code (e.g., en-US)', + ) + .option( + '--closed-captions ', + 'Whether the track provides Subtitles for the Deaf or Hard-of-hearing (SDH)', + ) + .option( + '--passthrough ', + 'Arbitrary metadata returned in API responses (max 255 chars)', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: UpdateOptions, assetId: string, trackId: string) => { + try { + const params: AssetUpdateTrackParams = {}; + + if (options.name !== undefined) { + params.name = options.name; + } + if (options.languageCode !== undefined) { + params.language_code = options.languageCode; + } + if (options.closedCaptions !== undefined) { + params.closed_captions = options.closedCaptions; + } + if (options.passthrough !== undefined) { + params.passthrough = options.passthrough; + } + + if (Object.keys(params).length === 0) { + throw new Error( + 'At least one field must be specified: --name, --language-code, --closed-captions, or --passthrough', + ); + } + + const mux = await createAuthenticatedMuxClient(); + + const track = await mux.video.assets.updateTrack( + assetId, + trackId, + params, + ); + + if (wantsJson(options)) { + console.log(JSON.stringify(track, null, 2)); + } else { + console.log('Track updated successfully'); + console.log(` ID: ${track.id}`); + console.log(` Type: ${track.type}`); + if (track.name) { + console.log(` Name: ${track.name}`); + } + if (track.language_code) { + console.log(` Language: ${track.language_code}`); + } + if (track.status) { + console.log(` Status: ${track.status}`); + } + } + } catch (error) { + await handleCommandError(error, 'assets', 'update', options); + } + }); diff --git a/src/commands/assets/update.test.ts b/src/commands/assets/update.test.ts index 01fbfbf..01ec016 100644 --- a/src/commands/assets/update.test.ts +++ b/src/commands/assets/update.test.ts @@ -72,6 +72,20 @@ describe('mux assets update command', () => { expect(option).toBeDefined(); }); + test('has --thumbnail-time flag', () => { + const option = updateCommand + .getOptions() + .find((opt) => opt.name === 'thumbnail-time'); + expect(option).toBeDefined(); + }); + + test('has --clear-thumbnail-time flag', () => { + const option = updateCommand + .getOptions() + .find((opt) => opt.name === 'clear-thumbnail-time'); + expect(option).toBeDefined(); + }); + test('has --json flag for output formatting', () => { const jsonOption = updateCommand .getOptions() @@ -101,5 +115,22 @@ describe('mux assets update command', () => { expect(exitSpy).toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalled(); }); + + test('errors when --thumbnail-time is combined with --clear-thumbnail-time', async () => { + try { + await updateCommand.parse([ + 'some-asset-id', + '--thumbnail-time', + '5', + '--clear-thumbnail-time', + ]); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/cannot be combined/i); + }); }); }); diff --git a/src/commands/assets/update.ts b/src/commands/assets/update.ts index a33c7b7..77cd6f3 100644 --- a/src/commands/assets/update.ts +++ b/src/commands/assets/update.ts @@ -9,12 +9,14 @@ interface UpdateOptions { creatorId?: string; externalId?: string; passthrough?: string; + thumbnailTime?: number; + clearThumbnailTime?: boolean; json?: boolean; } export const updateCommand = new Command() .description( - 'Update metadata fields on a Mux video asset (title, passthrough, etc.)', + 'Update metadata fields on a Mux video asset (title, passthrough, thumbnail time, etc.)', ) .arguments('') .option('--title ', 'Set meta.title (max 512 characters)') @@ -30,18 +32,36 @@ export const updateCommand = new Command() '--passthrough ', 'Set passthrough (max 255 characters)', ) + .option( + '--thumbnail-time ', + "Set the asset's default thumbnail time in seconds", + ) + .option( + '--clear-thumbnail-time', + "Reset the asset's default thumbnail time to the default", + ) .option('--json', 'Output JSON instead of pretty format') .action(async (options: UpdateOptions, assetId: string) => { try { - const hasField = + if ( + options.thumbnailTime !== undefined && + options.clearThumbnailTime !== undefined + ) { + throw new Error( + '--thumbnail-time and --clear-thumbnail-time cannot be combined', + ); + } + + const hasUpdateField = options.title !== undefined || options.creatorId !== undefined || options.externalId !== undefined || - options.passthrough !== undefined; + options.passthrough !== undefined || + options.thumbnailTime !== undefined; - if (!hasField) { + if (!hasUpdateField && options.clearThumbnailTime === undefined) { throw new Error( - 'At least one field must be specified: --title, --creator-id, --external-id, or --passthrough', + 'At least one field must be specified: --title, --creator-id, --external-id, --passthrough, --thumbnail-time, or --clear-thumbnail-time', ); } @@ -53,6 +73,10 @@ export const updateCommand = new Command() updateParams.passthrough = options.passthrough; } + if (options.thumbnailTime !== undefined) { + updateParams.thumbnail_time = options.thumbnailTime; + } + if ( options.title !== undefined || options.creatorId !== undefined || @@ -71,7 +95,18 @@ export const updateCommand = new Command() updateParams.meta = meta; } - const asset = await mux.video.assets.update(assetId, updateParams); + let asset = hasUpdateField + ? await mux.video.assets.update(assetId, updateParams) + : undefined; + + if (options.clearThumbnailTime) { + await mux.video.assets.deleteThumbnailTime(assetId); + asset = await mux.video.assets.retrieve(assetId); + } + + if (!asset) { + throw new Error(`Failed to load asset ${assetId} after update`); + } if (wantsJson(options)) { console.log(JSON.stringify(asset, null, 2)); diff --git a/src/commands/engagement/_shared.ts b/src/commands/engagement/_shared.ts new file mode 100644 index 0000000..5e6ca4b --- /dev/null +++ b/src/commands/engagement/_shared.ts @@ -0,0 +1,32 @@ +export interface TargetOptions { + assetId?: string; + playbackId?: string; + videoId?: string; +} + +export type EngagementTarget = + | { kind: 'asset'; id: string } + | { kind: 'playback-id'; id: string } + | { kind: 'video'; id: string }; + +export function resolveTarget(options: TargetOptions): EngagementTarget { + const targets: EngagementTarget[] = []; + if (options.assetId) targets.push({ kind: 'asset', id: options.assetId }); + if (options.playbackId) + targets.push({ kind: 'playback-id', id: options.playbackId }); + if (options.videoId) targets.push({ kind: 'video', id: options.videoId }); + + if (targets.length !== 1) { + throw new Error( + 'Specify exactly one of --asset-id, --playback-id, or --video-id', + ); + } + return targets[0]; +} + +export function formatMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} diff --git a/src/commands/engagement/engagement.test.ts b/src/commands/engagement/engagement.test.ts new file mode 100644 index 0000000..0457599 --- /dev/null +++ b/src/commands/engagement/engagement.test.ts @@ -0,0 +1,142 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { formatMs, resolveTarget } from './_shared.ts'; +import { heatmapCommand } from './heatmap.ts'; +import { hotspotsCommand } from './hotspots.ts'; +import { engagementCommand } from './index.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux engagement commands', () => { + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command group', () => { + test('has correct group description', () => { + expect(engagementCommand.getDescription()).toMatch(/engagement/i); + }); + + test.each([ + 'heatmap', + 'hotspots', + ])('registers the %s subcommand', (name) => { + expect(engagementCommand.getCommand(name)).toBeDefined(); + }); + }); + + describe('resolveTarget', () => { + test('resolves an asset target', () => { + expect(resolveTarget({ assetId: 'asset_1' })).toEqual({ + kind: 'asset', + id: 'asset_1', + }); + }); + + test('resolves a playback-id target', () => { + expect(resolveTarget({ playbackId: 'pb_1' })).toEqual({ + kind: 'playback-id', + id: 'pb_1', + }); + }); + + test('resolves a video target', () => { + expect(resolveTarget({ videoId: 'vid_1' })).toEqual({ + kind: 'video', + id: 'vid_1', + }); + }); + + test('rejects when no target is given', () => { + expect(() => resolveTarget({})).toThrow(/exactly one/i); + }); + + test('rejects when multiple targets are given', () => { + expect(() => + resolveTarget({ assetId: 'asset_1', playbackId: 'pb_1' }), + ).toThrow(/exactly one/i); + }); + }); + + describe('formatMs', () => { + test('formats milliseconds as minutes and seconds', () => { + expect(formatMs(0)).toBe('0:00'); + expect(formatMs(61_500)).toBe('1:01'); + expect(formatMs(600_000)).toBe('10:00'); + }); + }); + + describe.each([ + ['heatmap', heatmapCommand], + ['hotspots', hotspotsCommand], + ])('mux engagement %s', (_name, command) => { + test.each([ + 'asset-id', + 'playback-id', + 'video-id', + 'timeframe', + 'json', + ])('has --%s flag', (flag) => { + const option = command.getOptions().find((opt) => opt.name === flag); + expect(option).toBeDefined(); + }); + + test('errors when no target flag is provided', async () => { + try { + await command.parse([]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/exactly one/i); + }); + }); + + describe('mux engagement hotspots', () => { + test.each(['limit', 'order-direction'])('has --%s flag', (flag) => { + const option = hotspotsCommand + .getOptions() + .find((opt) => opt.name === flag); + expect(option).toBeDefined(); + }); + + test('rejects invalid --order-direction value', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await engagementCommand.parse([ + 'hotspots', + '--asset-id', + 'asset_1', + '--order-direction', + 'sideways', + ]); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/order-direction/i); + }); + }); +}); diff --git a/src/commands/engagement/heatmap.ts b/src/commands/engagement/heatmap.ts new file mode 100644 index 0000000..8f3207a --- /dev/null +++ b/src/commands/engagement/heatmap.ts @@ -0,0 +1,81 @@ +import { Command } from '@cliffy/command'; +import type { EngagementHeatmap } from '@mux/ts/resources/data/engagement/engagement'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { resolveTarget, type TargetOptions } from './_shared.ts'; + +interface HeatmapOptions extends TargetOptions { + timeframe?: string[]; + json?: boolean; +} + +export const heatmapCommand = new Command() + .description( + 'Get the engagement heatmap (viewership across the timeline) for a video', + ) + .option('--asset-id ', 'Target a Mux asset ID') + .option('--playback-id ', 'Target a Mux playback ID') + .option( + '--video-id ', + 'Target a custom video ID (video_id metadata)', + ) + .option( + '--timeframe ', + 'Timeframe as Unix timestamps or duration (e.g., "24:hours"). Can be specified multiple times.', + { collect: true }, + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: HeatmapOptions) => { + try { + const target = resolveTarget(options); + const mux = await createAuthenticatedMuxClient(); + + const params = + options.timeframe && options.timeframe.length > 0 + ? { timeframe: options.timeframe } + : undefined; + + let response: { data: EngagementHeatmap }; + switch (target.kind) { + case 'asset': + response = await mux.data.engagement.assets.heatmap( + target.id, + params, + ); + break; + case 'playback-id': + response = await mux.data.engagement.playbackIds.heatmap( + target.id, + params, + ); + break; + case 'video': + response = await mux.data.engagement.videos.heatmap( + target.id, + params, + ); + break; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + // The SDK types declare { total_views, value } but the live API + // currently returns { asset_id, heatmap } — accept both shapes. + const heatmap = response.data as Partial & { + heatmap?: number[]; + }; + const values = heatmap.value ?? heatmap.heatmap ?? []; + console.log(`Engagement heatmap for ${target.kind} ${target.id}:`); + if (heatmap.total_views !== undefined) { + console.log(` Total views: ${heatmap.total_views}`); + } + console.log(` Values (${values.length} buckets):`); + console.log(` ${JSON.stringify(values)}`); + } catch (error) { + await handleCommandError(error, 'engagement', 'heatmap', options); + } + }); diff --git a/src/commands/engagement/hotspots.ts b/src/commands/engagement/hotspots.ts new file mode 100644 index 0000000..b4ea6c3 --- /dev/null +++ b/src/commands/engagement/hotspots.ts @@ -0,0 +1,114 @@ +import { Command } from '@cliffy/command'; +import type { EngagementHotspots } from '@mux/ts/resources/data/engagement/engagement'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { formatMs, resolveTarget, type TargetOptions } from './_shared.ts'; + +interface HotspotsOptions extends TargetOptions { + limit?: number; + orderDirection?: 'asc' | 'desc'; + timeframe?: string[]; + json?: boolean; +} + +export const hotspotsCommand = new Command() + .description('Get the most-watched moments (engagement hotspots) for a video') + .option('--asset-id ', 'Target a Mux asset ID') + .option('--playback-id ', 'Target a Mux playback ID') + .option( + '--video-id ', + 'Target a custom video ID (video_id metadata)', + ) + .option('--limit ', 'Maximum number of hotspots to return') + .option( + '--order-direction ', + 'Sort order (asc or desc)', + { + value: (value: string): 'asc' | 'desc' => { + if (value !== 'asc' && value !== 'desc') { + throw new Error( + `Invalid --order-direction: ${value}. Must be "asc" or "desc".`, + ); + } + return value; + }, + }, + ) + .option( + '--timeframe ', + 'Timeframe as Unix timestamps or duration (e.g., "24:hours"). Can be specified multiple times.', + { collect: true }, + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: HotspotsOptions) => { + try { + const target = resolveTarget(options); + const mux = await createAuthenticatedMuxClient(); + + const params: { + limit?: number; + order_direction?: 'asc' | 'desc'; + timeframe?: string[]; + } = {}; + if (options.limit !== undefined) params.limit = options.limit; + if (options.orderDirection !== undefined) + params.order_direction = options.orderDirection; + if (options.timeframe && options.timeframe.length > 0) + params.timeframe = options.timeframe; + + let response: { data: EngagementHotspots }; + switch (target.kind) { + case 'asset': + response = await mux.data.engagement.assets.hotspots( + target.id, + params, + ); + break; + case 'playback-id': + response = await mux.data.engagement.playbackIds.hotspots( + target.id, + params, + ); + break; + case 'video': + response = await mux.data.engagement.videos.hotspots( + target.id, + params, + ); + break; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + // The SDK types declare { hotspots, total_views } but the live API + // currently omits total_views — treat every field as optional. + const data = response.data as Partial; + const hotspots = data.hotspots ?? []; + console.log(`Engagement hotspots for ${target.kind} ${target.id}:`); + if (data.total_views !== undefined) { + console.log(` Total views: ${data.total_views}`); + } + + if (hotspots.length === 0) { + console.log(' No hotspots found.'); + return; + } + + for (const hotspot of hotspots) { + // Guard against field names diverging from the SDK types, as the + // envelope fields above already have. + if (hotspot.start_ms === undefined || hotspot.end_ms === undefined) { + console.log(` ${JSON.stringify(hotspot)}`); + continue; + } + const range = `${formatMs(hotspot.start_ms)} - ${formatMs(hotspot.end_ms)}`; + console.log(` ${range.padEnd(16)} score: ${hotspot.score}`); + } + } catch (error) { + await handleCommandError(error, 'engagement', 'hotspots', options); + } + }); diff --git a/src/commands/engagement/index.ts b/src/commands/engagement/index.ts new file mode 100644 index 0000000..a1b153a --- /dev/null +++ b/src/commands/engagement/index.ts @@ -0,0 +1,11 @@ +import { Command } from '@cliffy/command'; +import { heatmapCommand } from './heatmap.ts'; +import { hotspotsCommand } from './hotspots.ts'; + +export const engagementCommand = new Command() + .description('Explore engagement analytics for your videos (Mux Data)') + .action(function () { + this.showHelp(); + }) + .command('heatmap', heatmapCommand) + .command('hotspots', hotspotsCommand); diff --git a/src/commands/live/create.test.ts b/src/commands/live/create.test.ts index 831806a..cd89f96 100644 --- a/src/commands/live/create.test.ts +++ b/src/commands/live/create.test.ts @@ -8,7 +8,7 @@ import { spyOn, test, } from 'bun:test'; -import type Mux from '@mux/mux-node'; +import type Mux from '@mux/ts'; import * as muxModule from '@/lib/mux.ts'; import { createCommand } from './create.ts'; diff --git a/src/commands/live/create.ts b/src/commands/live/create.ts index 5b04cd1..e93a95d 100644 --- a/src/commands/live/create.ts +++ b/src/commands/live/create.ts @@ -1,5 +1,5 @@ import { Command } from '@cliffy/command'; -import type Mux from '@mux/mux-node'; +import type Mux from '@mux/ts'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; diff --git a/src/commands/live/list.ts b/src/commands/live/list.ts index fbdfed2..12629b4 100644 --- a/src/commands/live/list.ts +++ b/src/commands/live/list.ts @@ -1,5 +1,5 @@ import { Command } from '@cliffy/command'; -import type { LiveStream } from '@mux/mux-node/resources/video/live-streams'; +import type { LiveStream } from '@mux/ts/resources/video/live-streams'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { diff --git a/src/commands/robots/_shared.test.ts b/src/commands/robots/_shared.test.ts index 4ba01c1..f70aaa2 100644 --- a/src/commands/robots/_shared.test.ts +++ b/src/commands/robots/_shared.test.ts @@ -8,9 +8,13 @@ import { spyOn, test, } from 'bun:test'; -import type Mux from '@mux/mux-node'; -import type { AnyRobotsJob } from './_shared.ts'; -import { assertJobCompleted, pollForRobotsJob } from './_shared.ts'; +import type Mux from '@mux/ts'; +import type { AnyRobotsJob, RobotsWorkflow } from './_shared.ts'; +import { + assertJobCompleted, + pollForRobotsJob, + retrieveRobotsJob, +} from './_shared.ts'; const baseJob = { id: 'rjob_xyz', @@ -77,12 +81,45 @@ function makeMuxStub(statuses: string[]): Mux { } as AnyRobotsJob); }); return { - robotsPreview: { + robots: { jobs: { summarize: { retrieve } }, }, } as unknown as Mux; } +describe('retrieveRobotsJob', () => { + const workflowResources: Array<[RobotsWorkflow, string]> = [ + ['ask-questions', 'askQuestions'], + ['edit-captions', 'editCaptions'], + ['find-best-thumbnails', 'findBestThumbnails'], + ['find-key-moments', 'findKeyMoments'], + ['find-scenes', 'findScenes'], + ['generate-chapters', 'generateChapters'], + ['generate-engagement-insights', 'generateEngagementInsights'], + ['generate-premium-captions', 'generatePremiumCaptions'], + ['moderate', 'moderate'], + ['summarize', 'summarize'], + ['translate-audio', 'translateAudio'], + ['translate-captions', 'translateCaptions'], + ]; + + test.each( + workflowResources, + )('routes %s to the matching jobs resource', async (workflow, resource) => { + const retrieve = mock(() => + Promise.resolve({ ...baseJob, workflow } as AnyRobotsJob), + ); + const mux = { + robots: { jobs: { [resource]: { retrieve } } }, + } as unknown as Mux; + + const job = await retrieveRobotsJob(mux, workflow, 'rjob_test123'); + + expect(retrieve).toHaveBeenCalledWith('rjob_test123'); + expect(job.workflow).toBe(workflow); + }); +}); + describe('pollForRobotsJob', () => { let stderrSpy: Mock; diff --git a/src/commands/robots/_shared.ts b/src/commands/robots/_shared.ts index 9f76ec0..111c136 100644 --- a/src/commands/robots/_shared.ts +++ b/src/commands/robots/_shared.ts @@ -1,20 +1,32 @@ import { readFile } from 'node:fs/promises'; -import type Mux from '@mux/mux-node'; +import type Mux from '@mux/ts'; import type { AskQuestionsJob, + EditCaptionsJob, + FindBestThumbnailsJob, FindKeyMomentsJob, + FindScenesJob, GenerateChaptersJob, + GenerateEngagementInsightsJob, + GeneratePremiumCaptionsJob, ModerateJob, SummarizeJob, + TranslateAudioJob, TranslateCaptionsJob, -} from '@mux/mux-node/resources/robots-preview/jobs'; +} from '@mux/ts/resources/robots/jobs'; export type AnyRobotsJob = | AskQuestionsJob + | EditCaptionsJob + | FindBestThumbnailsJob | FindKeyMomentsJob + | FindScenesJob | GenerateChaptersJob + | GenerateEngagementInsightsJob + | GeneratePremiumCaptionsJob | ModerateJob | SummarizeJob + | TranslateAudioJob | TranslateCaptionsJob; export type RobotsWorkflow = AnyRobotsJob['workflow']; @@ -67,17 +79,29 @@ export function retrieveRobotsJob( ): Promise { switch (workflow) { case 'ask-questions': - return mux.robotsPreview.jobs.askQuestions.retrieve(jobId); + return mux.robots.jobs.askQuestions.retrieve(jobId); + case 'edit-captions': + return mux.robots.jobs.editCaptions.retrieve(jobId); + case 'find-best-thumbnails': + return mux.robots.jobs.findBestThumbnails.retrieve(jobId); case 'find-key-moments': - return mux.robotsPreview.jobs.findKeyMoments.retrieve(jobId); + return mux.robots.jobs.findKeyMoments.retrieve(jobId); + case 'find-scenes': + return mux.robots.jobs.findScenes.retrieve(jobId); case 'generate-chapters': - return mux.robotsPreview.jobs.generateChapters.retrieve(jobId); + return mux.robots.jobs.generateChapters.retrieve(jobId); + case 'generate-engagement-insights': + return mux.robots.jobs.generateEngagementInsights.retrieve(jobId); + case 'generate-premium-captions': + return mux.robots.jobs.generatePremiumCaptions.retrieve(jobId); case 'moderate': - return mux.robotsPreview.jobs.moderate.retrieve(jobId); + return mux.robots.jobs.moderate.retrieve(jobId); case 'summarize': - return mux.robotsPreview.jobs.summarize.retrieve(jobId); + return mux.robots.jobs.summarize.retrieve(jobId); + case 'translate-audio': + return mux.robots.jobs.translateAudio.retrieve(jobId); case 'translate-captions': - return mux.robotsPreview.jobs.translateCaptions.retrieve(jobId); + return mux.robots.jobs.translateCaptions.retrieve(jobId); } } diff --git a/src/commands/robots/ask-questions.ts b/src/commands/robots/ask-questions.ts index 27e0933..e68892b 100644 --- a/src/commands/robots/ask-questions.ts +++ b/src/commands/robots/ask-questions.ts @@ -2,7 +2,7 @@ import { Command } from '@cliffy/command'; import type { AskQuestionCreateParams, AskQuestionsJobParameters, -} from '@mux/mux-node/resources/robots-preview/jobs'; +} from '@mux/ts/resources/robots/jobs'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -118,7 +118,7 @@ export const askQuestionsCommand: Command = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - let job = await mux.robotsPreview.jobs.askQuestions.create(body); + let job = await mux.robots.jobs.askQuestions.create(body); if (!wantsJson(options)) { console.log('Ask questions job created'); diff --git a/src/commands/robots/cancel.ts b/src/commands/robots/cancel.ts index 4e24cd4..5c11148 100644 --- a/src/commands/robots/cancel.ts +++ b/src/commands/robots/cancel.ts @@ -14,7 +14,7 @@ export const cancelCommand = new Command() .action(async (options: CancelOptions, jobId: string) => { try { const mux = await createAuthenticatedMuxClient(); - const job = await mux.robotsPreview.jobs.cancel(jobId); + const job = await mux.robots.jobs.cancel(jobId); if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); diff --git a/src/commands/robots/edit-captions.test.ts b/src/commands/robots/edit-captions.test.ts new file mode 100644 index 0000000..4dd15c9 --- /dev/null +++ b/src/commands/robots/edit-captions.test.ts @@ -0,0 +1,173 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { editCaptionsCommand, parseReplacement } from './edit-captions.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux robots edit-captions', () => { + let tempDir: string; + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command metadata', () => { + test('has correct command description', () => { + expect(editCaptionsCommand.getDescription()).toMatch(/edit.*caption/i); + }); + + test('requires asset-id argument', () => { + const args = editCaptionsCommand.getArguments(); + expect(args.length).toBeGreaterThan(0); + expect(args[0].name).toBe('asset-id'); + }); + }); + + describe('Flags', () => { + test.each([ + 'track-id', + 'replace', + 'case-sensitive', + 'censor-profanity', + 'censor-mode', + 'delete-original-track', + 'track-name-suffix', + 'no-upload', + 'passthrough', + 'file', + 'wait', + 'json', + ])('has --%s flag', (flag) => { + const opt = editCaptionsCommand.getOptions().find((o) => o.name === flag); + expect(opt).toBeDefined(); + }); + }); + + describe('parseReplacement', () => { + test('splits find and replace on the first equals sign', () => { + expect(parseReplacement('foo=bar')).toEqual({ + find: 'foo', + replace: 'bar', + }); + }); + + test('keeps additional equals signs in the replacement', () => { + expect(parseReplacement('a=b=c')).toEqual({ find: 'a', replace: 'b=c' }); + }); + + test('allows an empty replacement to delete the found text', () => { + expect(parseReplacement('profanity=')).toEqual({ + find: 'profanity', + replace: '', + }); + }); + + test('passes case sensitivity through', () => { + expect(parseReplacement('Foo=Bar', true)).toEqual({ + find: 'Foo', + replace: 'Bar', + case_sensitive: true, + }); + }); + + test('rejects values without an equals sign', () => { + expect(() => parseReplacement('nope')).toThrow(/find=replace/); + }); + + test('rejects values with an empty find term', () => { + expect(() => parseReplacement('=bar')).toThrow(/find=replace/); + }); + }); + + describe('Input validation', () => { + test('errors when --track-id is missing without --file', async () => { + try { + await editCaptionsCommand.parse(['asset_abc', '--replace', 'a=b']); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--track-id is required/i); + }); + + test('errors when no edit is specified', async () => { + try { + await editCaptionsCommand.parse(['asset_abc', '--track-id', 'trk_1']); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/at least one edit/i); + }); + + test('rejects invalid --censor-mode value', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await editCaptionsCommand.parse([ + 'asset_abc', + '--track-id', + 'trk_1', + '--censor-mode', + 'bleep', + ]); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/censor-mode/i); + }); + }); + + describe('--file mode', () => { + test('errors when --file combined with --replace', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile( + configPath, + JSON.stringify({ track_id: 'trk_1', replacements: [] }), + ); + try { + await editCaptionsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--replace', + 'a=b', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + }); +}); diff --git a/src/commands/robots/edit-captions.ts b/src/commands/robots/edit-captions.ts new file mode 100644 index 0000000..ddbbfa4 --- /dev/null +++ b/src/commands/robots/edit-captions.ts @@ -0,0 +1,202 @@ +import { Command } from '@cliffy/command'; +import type { + EditCaptionCreateParams, + EditCaptionsJobParameters, +} from '@mux/ts/resources/robots/jobs'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + assertJobCompleted, + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; + +type CensorMode = NonNullable< + EditCaptionsJobParameters.AutoCensorProfanity['mode'] +>; +const VALID_CENSOR_MODES: CensorMode[] = ['blank', 'remove', 'mask']; + +interface EditCaptionsOptions { + trackId?: string; + replace?: string[]; + caseSensitive?: boolean; + censorProfanity?: boolean; + censorMode?: CensorMode; + deleteOriginalTrack?: boolean; + trackNameSuffix?: string; + upload?: boolean; + passthrough?: string; + file?: string; + wait?: boolean; + json?: boolean; +} + +export function parseReplacement( + value: string, + caseSensitive?: boolean, +): EditCaptionsJobParameters.Replacement { + const separator = value.indexOf('='); + if (separator <= 0) { + throw new Error( + `Invalid --replace value: "${value}". Use the form "find=replace".`, + ); + } + const replacement: EditCaptionsJobParameters.Replacement = { + find: value.slice(0, separator), + replace: value.slice(separator + 1), + }; + if (caseSensitive !== undefined) { + replacement.case_sensitive = caseSensitive; + } + return replacement; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const editCaptionsCommand: Command = new Command() + .description( + 'Create a job to edit a caption track with find/replace rules or profanity censoring', + ) + .arguments('') + .option('--track-id ', 'Caption track ID to edit') + .option( + '--replace ', + 'Find/replace rule in the form "find=replace" (repeatable)', + { collect: true }, + ) + .option( + '--case-sensitive', + 'Make --replace rules match case-sensitively (default: insensitive)', + ) + .option( + '--censor-profanity', + 'Automatically censor profanity in the captions', + ) + .option( + '--censor-mode ', + 'How to censor profanity (blank, remove, mask); implies --censor-profanity', + { + value: (value: string): CensorMode => { + if (!VALID_CENSOR_MODES.includes(value as CensorMode)) { + throw new Error( + `Invalid --censor-mode: ${value}. Must be one of: ${VALID_CENSOR_MODES.join(', ')}`, + ); + } + return value as CensorMode; + }, + }, + ) + .option( + '--delete-original-track', + 'Delete the original caption track after the edited track is created', + ) + .option( + '--track-name-suffix ', + 'Suffix appended to the edited track name', + ) + .option( + '--no-upload', + 'Do not upload the edited VTT to Mux (default: uploads)', + ) + .option( + '--passthrough ', + 'Arbitrary metadata returned in API responses (max 255 chars)', + ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (track_id, replacements, auto_censor_profanity, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: EditCaptionsOptions, assetId: string) => { + try { + const hasReplacements = + options.replace !== undefined && options.replace.length > 0; + const hasShapeFlags = + options.trackId !== undefined || + hasReplacements || + options.caseSensitive !== undefined || + options.censorProfanity !== undefined || + options.censorMode !== undefined || + options.deleteOriginalTrack !== undefined || + options.trackNameSuffix !== undefined || + options.upload === false; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: EditCaptionsJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + if (!options.trackId) { + throw new Error('--track-id is required (or provide via --file)'); + } + const wantsCensoring = + options.censorProfanity || options.censorMode !== undefined; + if (!hasReplacements && !wantsCensoring) { + throw new Error( + 'Provide at least one edit: --replace, --censor-profanity, or a --file with parameters.', + ); + } + parameters = { + asset_id: assetId, + track_id: options.trackId, + }; + if (hasReplacements) { + parameters.replacements = (options.replace ?? []).map((value) => + parseReplacement(value, options.caseSensitive), + ); + } + if (wantsCensoring) { + const censor: EditCaptionsJobParameters.AutoCensorProfanity = {}; + if (options.censorMode !== undefined) + censor.mode = options.censorMode; + parameters.auto_censor_profanity = censor; + } + if (options.deleteOriginalTrack !== undefined) + parameters.delete_original_track = options.deleteOriginalTrack; + if (options.trackNameSuffix !== undefined) + parameters.track_name_suffix = options.trackNameSuffix; + if (options.upload === false) parameters.upload_to_mux = false; + } + + const body: EditCaptionCreateParams = { parameters }; + if (options.passthrough !== undefined) + body.passthrough = options.passthrough; + + const mux = await createAuthenticatedMuxClient(); + let job = await mux.robots.jobs.editCaptions.create(body); + + if (!wantsJson(options)) { + console.log('Edit captions job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + + if (options.wait) { + job = (await pollForRobotsJob(mux, 'edit-captions', job.id, { + json: wantsJson(options), + })) as typeof job; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(job, null, 2)); + } else if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } + + if (options.wait) assertJobCompleted(job); + } catch (error) { + await handleCommandError(error, 'robots', 'edit-captions', options); + } + }); diff --git a/src/commands/robots/find-best-thumbnails.test.ts b/src/commands/robots/find-best-thumbnails.test.ts new file mode 100644 index 0000000..876eff4 --- /dev/null +++ b/src/commands/robots/find-best-thumbnails.test.ts @@ -0,0 +1,139 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findBestThumbnailsCommand } from './find-best-thumbnails.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux robots find-best-thumbnails', () => { + let tempDir: string; + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command metadata', () => { + test('has correct command description', () => { + expect(findBestThumbnailsCommand.getDescription()).toMatch(/thumbnail/i); + }); + + test('requires asset-id argument', () => { + const args = findBestThumbnailsCommand.getArguments(); + expect(args.length).toBeGreaterThan(0); + expect(args[0].name).toBe('asset-id'); + }); + }); + + describe('Flags', () => { + test.each([ + 'max-thumbnails', + 'update-asset-thumbnail', + 'audience', + 'campaign-style', + 'looking-for', + 'start-time', + 'end-time', + 'passthrough', + 'file', + 'wait', + 'json', + ])('has --%s flag', (flag) => { + const opt = findBestThumbnailsCommand + .getOptions() + .find((o) => o.name === flag); + expect(opt).toBeDefined(); + }); + }); + + describe('Input validation', () => { + test('throws error when asset-id is not provided', async () => { + try { + await findBestThumbnailsCommand.parse([]); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalled(); + }); + }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await findBestThumbnailsCommand.parse([ + 'asset_abc', + '--file', + configPath, + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when --file combined with --max-thumbnails', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ max_thumbnails: 3 })); + try { + await findBestThumbnailsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--max-thumbnails', + '3', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + + test('errors when --file combined with steering flags', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({})); + try { + await findBestThumbnailsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--audience', + 'developers', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + }); +}); diff --git a/src/commands/robots/find-best-thumbnails.ts b/src/commands/robots/find-best-thumbnails.ts new file mode 100644 index 0000000..27f0843 --- /dev/null +++ b/src/commands/robots/find-best-thumbnails.ts @@ -0,0 +1,158 @@ +import { Command } from '@cliffy/command'; +import type { + FindBestThumbnailCreateParams, + FindBestThumbnailsJobParameters, + FindBestThumbnailsOutputSteering, +} from '@mux/ts/resources/robots/jobs'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + assertJobCompleted, + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; + +interface FindBestThumbnailsOptions { + maxThumbnails?: number; + updateAssetThumbnail?: boolean; + audience?: string; + campaignStyle?: string; + lookingFor?: string; + startTime?: number; + endTime?: number; + passthrough?: string; + file?: string; + wait?: boolean; + json?: boolean; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const findBestThumbnailsCommand: Command = new Command() + .description('Create a job to find the best thumbnail candidates for a video') + .arguments('') + .option( + '--max-thumbnails ', + 'Maximum number of thumbnail candidates to return', + ) + .option( + '--update-asset-thumbnail', + "Set the asset's default thumbnail time to the top candidate", + ) + .option( + '--audience ', + 'Intended audience used as best-effort scoring guidance', + ) + .option( + '--campaign-style ', + 'Description of the campaign/channel thumbnail style to prefer', + ) + .option( + '--looking-for ', + 'Description of what to look for in candidate thumbnails', + ) + .option( + '--start-time ', + 'Start of the execution window in seconds on the asset timeline', + ) + .option( + '--end-time ', + 'End of the execution window in seconds on the asset timeline', + ) + .option( + '--passthrough ', + 'Arbitrary metadata returned in API responses (max 255 chars)', + ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (max_thumbnails, output_steering, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: FindBestThumbnailsOptions, assetId: string) => { + try { + const outputSteering = buildOutputSteering(options); + const hasShapeFlags = + options.maxThumbnails !== undefined || + options.updateAssetThumbnail !== undefined || + outputSteering !== undefined; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: FindBestThumbnailsJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { asset_id: assetId }; + if (options.maxThumbnails !== undefined) + parameters.max_thumbnails = options.maxThumbnails; + if (options.updateAssetThumbnail !== undefined) + parameters.update_asset_thumbnail = options.updateAssetThumbnail; + if (outputSteering !== undefined) + parameters.output_steering = outputSteering; + } + + const body: FindBestThumbnailCreateParams = { parameters }; + if (options.passthrough !== undefined) + body.passthrough = options.passthrough; + + const mux = await createAuthenticatedMuxClient(); + let job = await mux.robots.jobs.findBestThumbnails.create(body); + + if (!wantsJson(options)) { + console.log('Find best thumbnails job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + + if (options.wait) { + job = (await pollForRobotsJob(mux, 'find-best-thumbnails', job.id, { + json: wantsJson(options), + })) as typeof job; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(job, null, 2)); + } else if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } + + if (options.wait) assertJobCompleted(job); + } catch (error) { + await handleCommandError( + error, + 'robots', + 'find-best-thumbnails', + options, + ); + } + }); + +function buildOutputSteering( + options: FindBestThumbnailsOptions, +): FindBestThumbnailsOutputSteering | undefined { + const steering: FindBestThumbnailsOutputSteering = {}; + if (options.audience !== undefined) steering.audience = options.audience; + if (options.campaignStyle !== undefined) + steering.campaign_style = options.campaignStyle; + if (options.lookingFor !== undefined) + steering.looking_for = options.lookingFor; + if (options.startTime !== undefined || options.endTime !== undefined) { + steering.scope = {}; + if (options.startTime !== undefined) + steering.scope.start_time = options.startTime; + if (options.endTime !== undefined) + steering.scope.end_time = options.endTime; + } + return Object.keys(steering).length > 0 ? steering : undefined; +} diff --git a/src/commands/robots/find-key-moments.ts b/src/commands/robots/find-key-moments.ts index 01ca029..5f4760d 100644 --- a/src/commands/robots/find-key-moments.ts +++ b/src/commands/robots/find-key-moments.ts @@ -2,7 +2,7 @@ import { Command } from '@cliffy/command'; import type { FindKeyMomentCreateParams, FindKeyMomentsJobParameters, -} from '@mux/mux-node/resources/robots-preview/jobs'; +} from '@mux/ts/resources/robots/jobs'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -102,7 +102,7 @@ export const findKeyMomentsCommand: Command = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - let job = await mux.robotsPreview.jobs.findKeyMoments.create(body); + let job = await mux.robots.jobs.findKeyMoments.create(body); if (!wantsJson(options)) { console.log('Find key moments job created'); diff --git a/src/commands/robots/find-scenes.test.ts b/src/commands/robots/find-scenes.test.ts new file mode 100644 index 0000000..315e8cf --- /dev/null +++ b/src/commands/robots/find-scenes.test.ts @@ -0,0 +1,120 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findScenesCommand } from './find-scenes.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux robots find-scenes', () => { + let tempDir: string; + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command metadata', () => { + test('has correct command description', () => { + expect(findScenesCommand.getDescription()).toMatch(/scene/i); + }); + + test('requires asset-id argument', () => { + const args = findScenesCommand.getArguments(); + expect(args.length).toBeGreaterThan(0); + expect(args[0].name).toBe('asset-id'); + }); + }); + + describe('Flags', () => { + test.each([ + 'language-code', + 'min-scene-duration-ms', + 'min-scenes', + 'audience', + 'brand-term', + 'narration-detail', + 'start-time', + 'end-time', + 'passthrough', + 'file', + 'wait', + 'json', + ])('has --%s flag', (flag) => { + const opt = findScenesCommand.getOptions().find((o) => o.name === flag); + expect(opt).toBeDefined(); + }); + }); + + describe('Input validation', () => { + test('throws error when asset-id is not provided', async () => { + try { + await findScenesCommand.parse([]); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalled(); + }); + + test('rejects invalid --narration-detail value', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await findScenesCommand.parse([ + 'asset_abc', + '--narration-detail', + 'verbose', + ]); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/narration-detail/i); + }); + }); + + describe('--file mode', () => { + test('errors when --file combined with --min-scenes', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ min_scenes: 3 })); + try { + await findScenesCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--min-scenes', + '3', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + }); +}); diff --git a/src/commands/robots/find-scenes.ts b/src/commands/robots/find-scenes.ts new file mode 100644 index 0000000..d1eeca1 --- /dev/null +++ b/src/commands/robots/find-scenes.ts @@ -0,0 +1,178 @@ +import { Command } from '@cliffy/command'; +import type { + FindSceneCreateParams, + FindScenesJobParameters, + FindScenesOutputSteering, +} from '@mux/ts/resources/robots/jobs'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + assertJobCompleted, + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; + +type NarrationDetail = NonNullable< + FindScenesOutputSteering['narration_detail'] +>; +const VALID_NARRATION_DETAILS: NarrationDetail[] = [ + 'concise', + 'balanced', + 'detailed', +]; + +interface FindScenesOptions { + languageCode?: string; + minSceneDurationMs?: number; + minScenes?: number; + audience?: string; + brandTerm?: string[]; + narrationDetail?: NarrationDetail; + startTime?: number; + endTime?: number; + passthrough?: string; + file?: string; + wait?: boolean; + json?: boolean; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const findScenesCommand: Command = new Command() + .description('Create a job to detect and describe the scenes in a video') + .arguments('') + .option( + '--language-code ', + 'BCP 47 language code of the caption track to analyze', + ) + .option( + '--min-scene-duration-ms ', + 'Minimum scene duration in milliseconds', + ) + .option('--min-scenes ', 'Minimum number of scenes') + .option( + '--audience ', + 'Intended audience used as best-effort model guidance', + ) + .option( + '--brand-term ', + 'Preferred brand or domain term to use when supported (repeatable)', + { collect: true }, + ) + .option( + '--narration-detail ', + 'How much detail scene narratives should include (concise, balanced, detailed)', + { + value: (value: string): NarrationDetail => { + if (!VALID_NARRATION_DETAILS.includes(value as NarrationDetail)) { + throw new Error( + `Invalid --narration-detail: ${value}. Must be one of: ${VALID_NARRATION_DETAILS.join(', ')}`, + ); + } + return value as NarrationDetail; + }, + }, + ) + .option( + '--start-time ', + 'Start of the execution window in seconds on the asset timeline', + ) + .option( + '--end-time ', + 'End of the execution window in seconds on the asset timeline', + ) + .option( + '--passthrough ', + 'Arbitrary metadata returned in API responses (max 255 chars)', + ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (min_scenes, output_steering, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: FindScenesOptions, assetId: string) => { + try { + const outputSteering = buildOutputSteering(options); + const hasShapeFlags = + options.languageCode !== undefined || + options.minSceneDurationMs !== undefined || + options.minScenes !== undefined || + outputSteering !== undefined; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: FindScenesJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { asset_id: assetId }; + if (options.languageCode !== undefined) + parameters.language_code = options.languageCode; + if (options.minSceneDurationMs !== undefined) + parameters.min_scene_duration_ms = options.minSceneDurationMs; + if (options.minScenes !== undefined) + parameters.min_scenes = options.minScenes; + if (outputSteering !== undefined) + parameters.output_steering = outputSteering; + } + + const body: FindSceneCreateParams = { parameters }; + if (options.passthrough !== undefined) + body.passthrough = options.passthrough; + + const mux = await createAuthenticatedMuxClient(); + let job = await mux.robots.jobs.findScenes.create(body); + + if (!wantsJson(options)) { + console.log('Find scenes job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + + if (options.wait) { + job = (await pollForRobotsJob(mux, 'find-scenes', job.id, { + json: wantsJson(options), + })) as typeof job; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(job, null, 2)); + } else if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } + + if (options.wait) assertJobCompleted(job); + } catch (error) { + await handleCommandError(error, 'robots', 'find-scenes', options); + } + }); + +function buildOutputSteering( + options: FindScenesOptions, +): FindScenesOutputSteering | undefined { + const steering: FindScenesOutputSteering = {}; + if (options.audience !== undefined) steering.audience = options.audience; + if (options.brandTerm !== undefined && options.brandTerm.length > 0) + steering.brand_terms = options.brandTerm; + if (options.narrationDetail !== undefined) + steering.narration_detail = options.narrationDetail; + if (options.startTime !== undefined || options.endTime !== undefined) { + steering.scope = {}; + if (options.startTime !== undefined) + steering.scope.start_time = options.startTime; + if (options.endTime !== undefined) + steering.scope.end_time = options.endTime; + } + return Object.keys(steering).length > 0 ? steering : undefined; +} diff --git a/src/commands/robots/generate-chapters.ts b/src/commands/robots/generate-chapters.ts index 9be7e6c..d3edad8 100644 --- a/src/commands/robots/generate-chapters.ts +++ b/src/commands/robots/generate-chapters.ts @@ -2,7 +2,7 @@ import { Command } from '@cliffy/command'; import type { GenerateChapterCreateParams, GenerateChaptersJobParameters, -} from '@mux/mux-node/resources/robots-preview/jobs'; +} from '@mux/ts/resources/robots/jobs'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -100,7 +100,7 @@ export const generateChaptersCommand: Command = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - let job = await mux.robotsPreview.jobs.generateChapters.create(body); + let job = await mux.robots.jobs.generateChapters.create(body); if (!wantsJson(options)) { console.log('Generate chapters job created'); diff --git a/src/commands/robots/generate-engagement-insights.test.ts b/src/commands/robots/generate-engagement-insights.test.ts new file mode 100644 index 0000000..e492994 --- /dev/null +++ b/src/commands/robots/generate-engagement-insights.test.ts @@ -0,0 +1,66 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { generateEngagementInsightsCommand } from './generate-engagement-insights.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux robots generate-engagement-insights', () => { + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command metadata', () => { + test('has correct command description', () => { + expect(generateEngagementInsightsCommand.getDescription()).toMatch( + /engagement/i, + ); + }); + + test('requires asset-id argument', () => { + const args = generateEngagementInsightsCommand.getArguments(); + expect(args.length).toBeGreaterThan(0); + expect(args[0].name).toBe('asset-id'); + }); + }); + + describe('Flags', () => { + test.each(['passthrough', 'wait', 'json'])('has --%s flag', (flag) => { + const opt = generateEngagementInsightsCommand + .getOptions() + .find((o) => o.name === flag); + expect(opt).toBeDefined(); + }); + }); + + describe('Input validation', () => { + test('throws error when asset-id is not provided', async () => { + try { + await generateEngagementInsightsCommand.parse([]); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/robots/generate-engagement-insights.ts b/src/commands/robots/generate-engagement-insights.ts new file mode 100644 index 0000000..f32a1d0 --- /dev/null +++ b/src/commands/robots/generate-engagement-insights.ts @@ -0,0 +1,73 @@ +import { Command } from '@cliffy/command'; +import type { GenerateEngagementInsightCreateParams } from '@mux/ts/resources/robots/jobs'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { assertJobCompleted, pollForRobotsJob } from './_shared.ts'; + +interface GenerateEngagementInsightsOptions { + passthrough?: string; + wait?: boolean; + json?: boolean; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const generateEngagementInsightsCommand: Command = new Command() + .description( + "Create a job to analyze a video's engagement data and generate insights", + ) + .arguments('') + .option( + '--passthrough ', + 'Arbitrary metadata returned in API responses (max 255 chars)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) + .option('--json', 'Output JSON instead of pretty format') + .action( + async (options: GenerateEngagementInsightsOptions, assetId: string) => { + try { + const body: GenerateEngagementInsightCreateParams = { + parameters: { asset_id: assetId }, + }; + if (options.passthrough !== undefined) + body.passthrough = options.passthrough; + + const mux = await createAuthenticatedMuxClient(); + let job = await mux.robots.jobs.generateEngagementInsights.create(body); + + if (!wantsJson(options)) { + console.log('Generate engagement insights job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'generate-engagement-insights', + job.id, + { json: wantsJson(options) }, + )) as typeof job; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(job, null, 2)); + } else if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } + + if (options.wait) assertJobCompleted(job); + } catch (error) { + await handleCommandError( + error, + 'robots', + 'generate-engagement-insights', + options, + ); + } + }, + ); diff --git a/src/commands/robots/generate-premium-captions.test.ts b/src/commands/robots/generate-premium-captions.test.ts new file mode 100644 index 0000000..d8a81c0 --- /dev/null +++ b/src/commands/robots/generate-premium-captions.test.ts @@ -0,0 +1,113 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { generatePremiumCaptionsCommand } from './generate-premium-captions.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux robots generate-premium-captions', () => { + let tempDir: string; + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command metadata', () => { + test('has correct command description', () => { + expect(generatePremiumCaptionsCommand.getDescription()).toMatch( + /premium captions/i, + ); + }); + + test('requires asset-id argument', () => { + const args = generatePremiumCaptionsCommand.getArguments(); + expect(args.length).toBeGreaterThan(0); + expect(args[0].name).toBe('asset-id'); + }); + }); + + describe('Flags', () => { + test.each([ + 'language-code', + 'include-speakers', + 'include-words', + 'phrase', + 'replace-existing', + 'track-name', + 'no-upload', + 'passthrough', + 'file', + 'wait', + 'json', + ])('has --%s flag', (flag) => { + const opt = generatePremiumCaptionsCommand + .getOptions() + .find((o) => o.name === flag); + expect(opt).toBeDefined(); + }); + + test('--phrase is repeatable', () => { + const opt = generatePremiumCaptionsCommand + .getOptions() + .find((o) => o.name === 'phrase'); + expect(opt?.collect).toBe(true); + }); + }); + + describe('Input validation', () => { + test('throws error when asset-id is not provided', async () => { + try { + await generatePremiumCaptionsCommand.parse([]); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalled(); + }); + }); + + describe('--file mode', () => { + test('errors when --file combined with --language-code', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ language_code: 'en' })); + try { + await generatePremiumCaptionsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--language-code', + 'en', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + }); +}); diff --git a/src/commands/robots/generate-premium-captions.ts b/src/commands/robots/generate-premium-captions.ts new file mode 100644 index 0000000..25f0043 --- /dev/null +++ b/src/commands/robots/generate-premium-captions.ts @@ -0,0 +1,152 @@ +import { Command } from '@cliffy/command'; +import type { + GeneratePremiumCaptionCreateParams, + GeneratePremiumCaptionsJobParameters, +} from '@mux/ts/resources/robots/jobs'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + assertJobCompleted, + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; + +interface GeneratePremiumCaptionsOptions { + languageCode?: string; + includeSpeakers?: boolean; + includeWords?: boolean; + phrase?: string[]; + replaceExisting?: boolean; + trackName?: string; + upload?: boolean; + passthrough?: string; + file?: string; + wait?: boolean; + json?: boolean; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const generatePremiumCaptionsCommand: Command = new Command() + .description( + 'Create a job to generate high-accuracy premium captions for a video', + ) + .arguments('') + .option( + '--language-code ', + 'BCP 47 language code of the spoken audio (auto-detected when omitted)', + ) + .option( + '--include-speakers', + 'Include speaker labels in the generated captions', + ) + .option('--include-words', 'Include word-level timing in the output') + .option( + '--phrase ', + 'Domain-specific phrase to boost recognition accuracy (repeatable)', + { collect: true }, + ) + .option( + '--replace-existing', + 'Replace an existing generated caption track in the same language', + ) + .option( + '--track-name ', + 'Name for the generated caption track', + ) + .option( + '--no-upload', + 'Do not upload the generated captions to Mux (default: uploads)', + ) + .option( + '--passthrough ', + 'Arbitrary metadata returned in API responses (max 255 chars)', + ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (language_code, phrases, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: GeneratePremiumCaptionsOptions, assetId: string) => { + try { + const hasShapeFlags = + options.languageCode !== undefined || + options.includeSpeakers !== undefined || + options.includeWords !== undefined || + (options.phrase !== undefined && options.phrase.length > 0) || + options.replaceExisting !== undefined || + options.trackName !== undefined || + options.upload === false; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: GeneratePremiumCaptionsJobParameters; + if (options.file) { + parameters = + await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { asset_id: assetId }; + if (options.languageCode !== undefined) + parameters.language_code = options.languageCode; + if (options.includeSpeakers !== undefined) + parameters.include_speakers = options.includeSpeakers; + if (options.includeWords !== undefined) + parameters.include_words = options.includeWords; + if (options.phrase !== undefined && options.phrase.length > 0) + parameters.phrases = options.phrase; + if (options.replaceExisting !== undefined) + parameters.replace_existing = options.replaceExisting; + if (options.trackName !== undefined) + parameters.track_name = options.trackName; + if (options.upload === false) parameters.upload_to_mux = false; + } + + const body: GeneratePremiumCaptionCreateParams = { parameters }; + if (options.passthrough !== undefined) + body.passthrough = options.passthrough; + + const mux = await createAuthenticatedMuxClient(); + let job = await mux.robots.jobs.generatePremiumCaptions.create(body); + + if (!wantsJson(options)) { + console.log('Generate premium captions job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'generate-premium-captions', + job.id, + { json: wantsJson(options) }, + )) as typeof job; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(job, null, 2)); + } else if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } + + if (options.wait) assertJobCompleted(job); + } catch (error) { + await handleCommandError( + error, + 'robots', + 'generate-premium-captions', + options, + ); + } + }); diff --git a/src/commands/robots/get.ts b/src/commands/robots/get.ts index e967062..37d6c75 100644 --- a/src/commands/robots/get.ts +++ b/src/commands/robots/get.ts @@ -7,10 +7,16 @@ import { type RobotsWorkflow, retrieveRobotsJob } from './_shared.ts'; const WORKFLOWS: RobotsWorkflow[] = [ 'ask-questions', + 'edit-captions', + 'find-best-thumbnails', 'find-key-moments', + 'find-scenes', 'generate-chapters', + 'generate-engagement-insights', + 'generate-premium-captions', 'moderate', 'summarize', + 'translate-audio', 'translate-captions', ]; @@ -33,7 +39,7 @@ export const getCommand = new Command() .arguments('') .option( '--workflow ', - 'Workflow type (summarize, moderate, generate-chapters, translate-captions, ask-questions, find-key-moments)', + `Workflow type (${WORKFLOWS.join(', ')})`, { required: true }, ) .option('--json', 'Output JSON instead of pretty format') diff --git a/src/commands/robots/index.ts b/src/commands/robots/index.ts index 92f8ae0..8196a06 100644 --- a/src/commands/robots/index.ts +++ b/src/commands/robots/index.ts @@ -1,12 +1,18 @@ import { Command } from '@cliffy/command'; import { askQuestionsCommand } from './ask-questions.ts'; import { cancelCommand } from './cancel.ts'; +import { editCaptionsCommand } from './edit-captions.ts'; +import { findBestThumbnailsCommand } from './find-best-thumbnails.ts'; import { findKeyMomentsCommand } from './find-key-moments.ts'; +import { findScenesCommand } from './find-scenes.ts'; import { generateChaptersCommand } from './generate-chapters.ts'; +import { generateEngagementInsightsCommand } from './generate-engagement-insights.ts'; +import { generatePremiumCaptionsCommand } from './generate-premium-captions.ts'; import { getCommand } from './get.ts'; import { listCommand } from './list.ts'; import { moderateCommand } from './moderate.ts'; import { summarizeCommand } from './summarize.ts'; +import { translateAudioCommand } from './translate-audio.ts'; import { translateCaptionsCommand } from './translate-captions.ts'; // biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer @@ -23,4 +29,10 @@ export const robotsCommand: Command = new Command() .command('generate-chapters', generateChaptersCommand) .command('ask-questions', askQuestionsCommand) .command('find-key-moments', findKeyMomentsCommand) - .command('translate-captions', translateCaptionsCommand); + .command('translate-captions', translateCaptionsCommand) + .command('edit-captions', editCaptionsCommand) + .command('find-best-thumbnails', findBestThumbnailsCommand) + .command('find-scenes', findScenesCommand) + .command('generate-engagement-insights', generateEngagementInsightsCommand) + .command('generate-premium-captions', generatePremiumCaptionsCommand) + .command('translate-audio', translateAudioCommand); diff --git a/src/commands/robots/list.ts b/src/commands/robots/list.ts index cce597e..dd5c2c0 100644 --- a/src/commands/robots/list.ts +++ b/src/commands/robots/list.ts @@ -1,5 +1,5 @@ import { Command } from '@cliffy/command'; -import type { JobListParams } from '@mux/mux-node/resources/robots-preview/jobs'; +import type { JobListParams } from '@mux/ts/resources/robots/jobs'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatCreatedAt } from '@/lib/formatters.ts'; @@ -22,7 +22,7 @@ export const listCommand = new Command() .description('List Mux Robots jobs') .option( '--workflow ', - 'Filter by workflow type (summarize, moderate, generate-chapters, translate-captions, ask-questions, find-key-moments)', + 'Filter by workflow type (e.g. summarize, moderate, generate-chapters, translate-captions, edit-captions, translate-audio, find-scenes, find-best-thumbnails)', ) .option('--status ', 'Filter by job status') .option('--asset-id ', 'Filter by asset ID') @@ -44,7 +44,7 @@ export const listCommand = new Command() if (options.status) params.status = options.status as JobStatus; if (options.assetId) params.asset_id = options.assetId; - const response = await mux.robotsPreview.jobs.list(params); + const response = await mux.robots.jobs.list(params); const data = response.data ?? []; if (wantsJson(options)) { diff --git a/src/commands/robots/moderate.ts b/src/commands/robots/moderate.ts index 09c40fd..a8e7e95 100644 --- a/src/commands/robots/moderate.ts +++ b/src/commands/robots/moderate.ts @@ -2,7 +2,7 @@ import { Command } from '@cliffy/command'; import type { ModerateCreateParams, ModerateJobParameters, -} from '@mux/mux-node/resources/robots-preview/jobs'; +} from '@mux/ts/resources/robots/jobs'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -132,7 +132,7 @@ export const moderateCommand: Command = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - let job = await mux.robotsPreview.jobs.moderate.create(body); + let job = await mux.robots.jobs.moderate.create(body); if (!wantsJson(options)) { console.log('Moderate job created'); diff --git a/src/commands/robots/summarize.ts b/src/commands/robots/summarize.ts index d0719fb..a855d68 100644 --- a/src/commands/robots/summarize.ts +++ b/src/commands/robots/summarize.ts @@ -2,7 +2,7 @@ import { Command } from '@cliffy/command'; import type { SummarizeCreateParams, SummarizeJobParameters, -} from '@mux/mux-node/resources/robots-preview/jobs'; +} from '@mux/ts/resources/robots/jobs'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -148,7 +148,7 @@ export const summarizeCommand: Command = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - let job = await mux.robotsPreview.jobs.summarize.create(body); + let job = await mux.robots.jobs.summarize.create(body); if (!wantsJson(options)) { console.log('Summarize job created'); diff --git a/src/commands/robots/translate-audio.test.ts b/src/commands/robots/translate-audio.test.ts new file mode 100644 index 0000000..654d02e --- /dev/null +++ b/src/commands/robots/translate-audio.test.ts @@ -0,0 +1,124 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { translateAudioCommand } from './translate-audio.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux robots translate-audio', () => { + let tempDir: string; + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('Command metadata', () => { + test('has correct command description', () => { + expect(translateAudioCommand.getDescription()).toMatch( + /translate.*audio/i, + ); + }); + + test('requires asset-id argument', () => { + const args = translateAudioCommand.getArguments(); + expect(args.length).toBeGreaterThan(0); + expect(args[0].name).toBe('asset-id'); + }); + }); + + describe('Flags', () => { + test.each([ + 'to-language-code', + 'no-upload', + 'passthrough', + 'file', + 'wait', + 'json', + ])('has --%s flag', (flag) => { + const opt = translateAudioCommand + .getOptions() + .find((o) => o.name === flag); + expect(opt).toBeDefined(); + }); + }); + + describe('Input validation', () => { + test('throws error when asset-id is not provided', async () => { + try { + await translateAudioCommand.parse([]); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalled(); + }); + + test('errors when --to-language-code is missing without --file', async () => { + try { + await translateAudioCommand.parse(['asset_abc']); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--to-language-code is required/i); + }); + }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await translateAudioCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when --file combined with --to-language-code', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ to_language_code: 'es' })); + try { + await translateAudioCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--to-language-code', + 'es', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + }); +}); diff --git a/src/commands/robots/translate-audio.ts b/src/commands/robots/translate-audio.ts new file mode 100644 index 0000000..cf8badb --- /dev/null +++ b/src/commands/robots/translate-audio.ts @@ -0,0 +1,112 @@ +import { Command } from '@cliffy/command'; +import type { + TranslateAudioCreateParams, + TranslateAudioJobParameters, +} from '@mux/ts/resources/robots/jobs'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + assertJobCompleted, + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; + +interface TranslateAudioOptions { + toLanguageCode?: string; + upload?: boolean; + passthrough?: string; + file?: string; + wait?: boolean; + json?: boolean; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const translateAudioCommand: Command = new Command() + .description( + "Create a job to translate a video's audio track to another language", + ) + .arguments('') + .option( + '--to-language-code ', + 'BCP 47 language code for the translated audio (e.g. "es", "ja")', + ) + .option( + '--no-upload', + 'Do not upload the translated audio track to Mux (default: uploads)', + ) + .option( + '--passthrough ', + 'Arbitrary metadata returned in API responses (max 255 chars)', + ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (to_language_code, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: TranslateAudioOptions, assetId: string) => { + try { + const hasShapeFlags = + options.toLanguageCode !== undefined || options.upload === false; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: TranslateAudioJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + if (!options.toLanguageCode) { + throw new Error( + '--to-language-code is required (or provide via --file)', + ); + } + parameters = { + asset_id: assetId, + to_language_code: options.toLanguageCode, + }; + if (options.upload === false) { + parameters.upload_to_mux = false; + } + } + + const body: TranslateAudioCreateParams = { parameters }; + if (options.passthrough !== undefined) + body.passthrough = options.passthrough; + + const mux = await createAuthenticatedMuxClient(); + let job = await mux.robots.jobs.translateAudio.create(body); + + if (!wantsJson(options)) { + console.log('Translate audio job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + + if (options.wait) { + job = (await pollForRobotsJob(mux, 'translate-audio', job.id, { + json: wantsJson(options), + })) as typeof job; + } + + if (wantsJson(options)) { + console.log(JSON.stringify(job, null, 2)); + } else if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } + + if (options.wait) assertJobCompleted(job); + } catch (error) { + await handleCommandError(error, 'robots', 'translate-audio', options); + } + }); diff --git a/src/commands/robots/translate-captions.ts b/src/commands/robots/translate-captions.ts index 5edf62b..7a70b2e 100644 --- a/src/commands/robots/translate-captions.ts +++ b/src/commands/robots/translate-captions.ts @@ -2,7 +2,7 @@ import { Command } from '@cliffy/command'; import type { TranslateCaptionCreateParams, TranslateCaptionsJobParameters, -} from '@mux/mux-node/resources/robots-preview/jobs'; +} from '@mux/ts/resources/robots/jobs'; import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -92,7 +92,7 @@ export const translateCaptionsCommand: Command = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - let job = await mux.robotsPreview.jobs.translateCaptions.create(body); + let job = await mux.robots.jobs.translateCaptions.create(body); if (!wantsJson(options)) { console.log('Translate captions job created'); diff --git a/src/commands/signing-keys/create.test.ts b/src/commands/signing-keys/create.test.ts index afb927a..b837c07 100644 --- a/src/commands/signing-keys/create.test.ts +++ b/src/commands/signing-keys/create.test.ts @@ -11,7 +11,7 @@ import { import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type Mux from '@mux/mux-node'; +import type Mux from '@mux/ts'; import * as configModule from '@/lib/config.ts'; import { getEnvironment, setEnvironment } from '@/lib/config.ts'; import * as muxModule from '@/lib/mux.ts'; diff --git a/src/commands/webhooks/create.ts b/src/commands/webhooks/create.ts new file mode 100644 index 0000000..aad6419 --- /dev/null +++ b/src/commands/webhooks/create.ts @@ -0,0 +1,44 @@ +import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; + +interface CreateOptions { + address: string; + json?: boolean; +} + +export const createCommand = new Command() + .description('Create a webhook that delivers Mux events to a URL') + .option( + '--address ', + 'Publicly accessible URL that receives webhook events', + { required: true }, + ) + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: CreateOptions) => { + try { + const mux = await createAuthenticatedMuxClient(); + + const webhook = await mux.system.webhooks.create({ + address: options.address, + }); + + if (wantsJson(options)) { + console.log(JSON.stringify(webhook, null, 2)); + } else { + console.log('Webhook created successfully'); + console.log(` ID: ${webhook.id}`); + console.log(` Address: ${webhook.address}`); + console.log(` Enabled: ${webhook.enabled}`); + if (webhook.signing_secret) { + console.log(` Signing secret: ${webhook.signing_secret}`); + console.log( + '\nStore the signing secret securely; use it to verify webhook signatures.', + ); + } + } + } catch (error) { + await handleCommandError(error, 'webhooks', 'create', options); + } + }); diff --git a/src/commands/webhooks/crud.test.ts b/src/commands/webhooks/crud.test.ts new file mode 100644 index 0000000..df36717 --- /dev/null +++ b/src/commands/webhooks/crud.test.ts @@ -0,0 +1,150 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { createCommand } from './create.ts'; +import { deleteCommand } from './delete.ts'; +import { getCommand } from './get.ts'; +import { listCommand } from './list.ts'; +import { updateCommand } from './update.ts'; + +// Note: These tests focus on CLI flag parsing and command structure +// They do NOT test the actual Mux API integration (that's tested via E2E) + +describe('mux webhooks CRUD commands', () => { + let exitSpy: Mock; + let consoleErrorSpy: Mock; + + beforeEach(() => { + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy?.mockRestore(); + consoleErrorSpy?.mockRestore(); + }); + + describe('mux webhooks create', () => { + test('has correct command description', () => { + expect(createCommand.getDescription()).toMatch(/create.*webhook/i); + }); + + test('has a required --address flag', () => { + const option = createCommand + .getOptions() + .find((opt) => opt.name === 'address'); + expect(option).toBeDefined(); + expect(option?.required).toBe(true); + }); + + test('errors when --address is not provided', async () => { + let rejected = false; + try { + await createCommand.parse([]); + } catch (_error) { + rejected = true; + } + expect(rejected || exitSpy.mock.calls.length > 0).toBe(true); + }); + }); + + describe('mux webhooks list', () => { + test('has correct command description', () => { + expect(listCommand.getDescription()).toMatch(/list.*webhook/i); + }); + + test('has --json flag for output formatting', () => { + const option = listCommand + .getOptions() + .find((opt) => opt.name === 'json'); + expect(option).toBeDefined(); + }); + }); + + describe('mux webhooks get', () => { + test('requires webhook-id argument', () => { + const args = getCommand.getArguments(); + expect(args.length).toBe(1); + expect(args[0].name).toBe('webhook-id'); + }); + }); + + describe('mux webhooks update', () => { + test('requires webhook-id argument', () => { + const args = updateCommand.getArguments(); + expect(args.length).toBe(1); + expect(args[0].name).toBe('webhook-id'); + }); + + test.each([ + 'address', + 'enable', + 'disable', + 'json', + ])('has --%s flag', (flag) => { + const option = updateCommand + .getOptions() + .find((opt) => opt.name === flag); + expect(option).toBeDefined(); + }); + + test('errors when no field flags are provided', async () => { + try { + await updateCommand.parse(['webhook_123']); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/at least one field/i); + }); + + test('errors when --enable is combined with --disable', async () => { + try { + await updateCommand.parse(['webhook_123', '--enable', '--disable']); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/cannot be combined/i); + }); + }); + + describe('mux webhooks delete', () => { + test('requires webhook-id argument', () => { + const args = deleteCommand.getArguments(); + expect(args.length).toBe(1); + expect(args[0].name).toBe('webhook-id'); + }); + + test('has --force flag to skip confirmation', () => { + const option = deleteCommand + .getOptions() + .find((opt) => opt.name === 'force'); + expect(option).toBeDefined(); + }); + + test('requires --force in JSON mode', async () => { + try { + await deleteCommand.parse(['webhook_123', '--json']); + } catch (_error) { + // Expected to throw + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const output = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(String(output)).toMatch(/--force/); + }); + }); +}); diff --git a/src/commands/webhooks/delete.ts b/src/commands/webhooks/delete.ts new file mode 100644 index 0000000..f1162c6 --- /dev/null +++ b/src/commands/webhooks/delete.ts @@ -0,0 +1,49 @@ +import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { confirmPrompt } from '@/lib/prompt.ts'; + +interface DeleteOptions { + force?: boolean; + json?: boolean; +} + +export const deleteCommand = new Command() + .description('Permanently delete a webhook (cannot be undone)') + .arguments('') + .option('-f, --force', 'Skip confirmation prompt') + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: DeleteOptions, webhookId: string) => { + try { + if (!options.force) { + if (wantsJson(options)) { + throw new Error( + 'Deletion requires the --force flag with --json or in agent mode', + ); + } + + const confirmed = await confirmPrompt({ + message: `Are you sure you want to delete webhook ${webhookId}?`, + default: false, + }); + + if (!confirmed) { + console.log('Deletion cancelled.'); + return; + } + } + + const mux = await createAuthenticatedMuxClient(); + + await mux.system.webhooks.delete(webhookId); + + if (wantsJson(options)) { + console.log(JSON.stringify({ success: true, webhookId }, null, 2)); + } else { + console.log(`Webhook ${webhookId} deleted successfully`); + } + } catch (error) { + await handleCommandError(error, 'webhooks', 'delete', options); + } + }); diff --git a/src/commands/webhooks/get.ts b/src/commands/webhooks/get.ts new file mode 100644 index 0000000..b30a719 --- /dev/null +++ b/src/commands/webhooks/get.ts @@ -0,0 +1,31 @@ +import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; + +interface GetOptions { + json?: boolean; +} + +export const getCommand = new Command() + .description('Get details about a specific webhook') + .arguments('') + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: GetOptions, webhookId: string) => { + try { + const mux = await createAuthenticatedMuxClient(); + + const webhook = await mux.system.webhooks.retrieve(webhookId); + + if (wantsJson(options)) { + console.log(JSON.stringify(webhook, null, 2)); + } else { + console.log(`Webhook ID: ${webhook.id}`); + console.log(`Address: ${webhook.address}`); + console.log(`Enabled: ${webhook.enabled}`); + console.log(`Created: ${webhook.created_at}`); + } + } catch (error) { + await handleCommandError(error, 'webhooks', 'get', options); + } + }); diff --git a/src/commands/webhooks/index.ts b/src/commands/webhooks/index.ts index 60a827b..ef0b9dc 100644 --- a/src/commands/webhooks/index.ts +++ b/src/commands/webhooks/index.ts @@ -1,13 +1,23 @@ import { Command } from '@cliffy/command'; +import { createCommand } from './create.ts'; +import { deleteCommand } from './delete.ts'; import { eventsCommand } from './events/index.ts'; +import { getCommand } from './get.ts'; +import { listCommand } from './list.ts'; import { listenCommand } from './listen.ts'; import { triggerCommand } from './trigger.ts'; +import { updateCommand } from './update.ts'; export const webhooksCommand = new Command() - .description('Listen for and manage Mux webhook events') + .description('Listen for, configure, and manage Mux webhooks and events') .action(function () { this.showHelp(); }) .command('listen', listenCommand) .command('trigger', triggerCommand) - .command('events', eventsCommand); + .command('events', eventsCommand) + .command('create', createCommand) + .command('list', listCommand) + .command('get', getCommand) + .command('update', updateCommand) + .command('delete', deleteCommand); diff --git a/src/commands/webhooks/list.ts b/src/commands/webhooks/list.ts new file mode 100644 index 0000000..3582b55 --- /dev/null +++ b/src/commands/webhooks/list.ts @@ -0,0 +1,43 @@ +import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; + +interface ListOptions { + json?: boolean; +} + +export const listCommand = new Command() + .description('List the webhooks configured for this environment') + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: ListOptions) => { + try { + const mux = await createAuthenticatedMuxClient(); + + const webhooks = await mux.system.webhooks.list(); + + if (wantsJson(options)) { + const data = []; + for await (const webhook of webhooks) { + data.push(webhook); + } + console.log(JSON.stringify({ data }, null, 2)); + } else { + console.log('Webhooks:'); + let hasWebhooks = false; + for await (const webhook of webhooks) { + hasWebhooks = true; + const status = webhook.enabled ? 'enabled' : 'disabled'; + console.log(` ${webhook.id} [${status}] ${webhook.address}`); + } + + if (!hasWebhooks) { + console.log( + " No webhooks found. Run 'mux webhooks create --address ' to add one.", + ); + } + } + } catch (error) { + await handleCommandError(error, 'webhooks', 'list', options); + } + }); diff --git a/src/commands/webhooks/update.ts b/src/commands/webhooks/update.ts new file mode 100644 index 0000000..8e33cf8 --- /dev/null +++ b/src/commands/webhooks/update.ts @@ -0,0 +1,62 @@ +import { Command } from '@cliffy/command'; +import type { WebhookUpdateParams } from '@mux/ts/resources/system/webhooks'; +import { wantsJson } from '@/lib/context.ts'; +import { handleCommandError } from '@/lib/errors.ts'; +import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; + +interface UpdateOptions { + address?: string; + enable?: boolean; + disable?: boolean; + json?: boolean; +} + +export const updateCommand = new Command() + .description('Update the address of a webhook, or enable/disable it') + .arguments('') + .option( + '--address ', + 'Publicly accessible URL that receives webhook events', + ) + .option('--enable', 'Enable delivery of events to this webhook') + .option('--disable', 'Disable delivery of events to this webhook') + .option('--json', 'Output JSON instead of pretty format') + .action(async (options: UpdateOptions, webhookId: string) => { + try { + if (options.enable && options.disable) { + throw new Error('--enable and --disable cannot be combined'); + } + + const params: WebhookUpdateParams = {}; + if (options.address !== undefined) { + params.address = options.address; + } + if (options.enable) { + params.enabled = true; + } + if (options.disable) { + params.enabled = false; + } + + if (Object.keys(params).length === 0) { + throw new Error( + 'At least one field must be specified: --address, --enable, or --disable', + ); + } + + const mux = await createAuthenticatedMuxClient(); + + const webhook = await mux.system.webhooks.update(webhookId, params); + + if (wantsJson(options)) { + console.log(JSON.stringify(webhook, null, 2)); + } else { + console.log('Webhook updated successfully'); + console.log(` ID: ${webhook.id}`); + console.log(` Address: ${webhook.address}`); + console.log(` Enabled: ${webhook.enabled}`); + } + } catch (error) { + await handleCommandError(error, 'webhooks', 'update', options); + } + }); diff --git a/src/index.ts b/src/index.ts index 14f46e6..ce46258 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { completionsInstallCommand } from './commands/completions-install.ts'; import { deliveryUsageCommand } from './commands/delivery-usage/index.ts'; import { dimensionsCommand } from './commands/dimensions/index.ts'; import { drmConfigurationsCommand } from './commands/drm-configurations/index.ts'; +import { engagementCommand } from './commands/engagement/index.ts'; import { envCommand } from './commands/env/index.ts'; import { errorsCommand } from './commands/errors/index.ts'; import { exportsCommand } from './commands/exports/index.ts'; @@ -64,6 +65,7 @@ const cli = new Command() .command('incidents', incidentsCommand) .command('annotations', annotationsCommand) .command('dimensions', dimensionsCommand) + .command('engagement', engagementCommand) .command('errors', errorsCommand) .command('exports', exportsCommand) .command('robots', robotsCommand) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 0bc90df..8050165 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,4 +1,4 @@ -import { AuthenticationError, NotFoundError } from '@mux/mux-node'; +import { AuthenticationError, NotFoundError } from '@mux/ts'; import { wantsJson } from '@/lib/context.ts'; import { getAuthContext } from './mux.ts'; diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 24cb09b..fe020be 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -1,6 +1,6 @@ import { colors } from '@cliffy/ansi/colors'; -import type { Asset } from '@mux/mux-node/resources/video/assets'; -import type { LiveStream } from '@mux/mux-node/resources/video/live-streams'; +import type { Asset } from '@mux/ts/resources/video/assets'; +import type { LiveStream } from '@mux/ts/resources/video/live-streams'; /** * Format created_at timestamp to short format (MM/DD HH:MM) diff --git a/src/lib/json-output.test.ts b/src/lib/json-output.test.ts index 1efae30..aec4281 100644 --- a/src/lib/json-output.test.ts +++ b/src/lib/json-output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { AbstractPage } from '@mux/mux-node/core/pagination'; +import { AbstractPage } from '@mux/ts/core/pagination'; import { formatJson, unwrapPage } from './json-output.ts'; /** diff --git a/src/lib/json-output.ts b/src/lib/json-output.ts index 73610e0..a271188 100644 --- a/src/lib/json-output.ts +++ b/src/lib/json-output.ts @@ -1,4 +1,4 @@ -import { AbstractPage } from '@mux/mux-node/core/pagination'; +import { AbstractPage } from '@mux/ts/core/pagination'; /** * SDK page objects carry internal request state (options, response, body) diff --git a/src/lib/mux.ts b/src/lib/mux.ts index 8fc5419..126eaf5 100644 --- a/src/lib/mux.ts +++ b/src/lib/mux.ts @@ -1,4 +1,4 @@ -import Mux from '@mux/mux-node'; +import Mux from '@mux/ts'; import pkg from '../../package.json'; import { type Environment, diff --git a/src/lib/playback-ids.ts b/src/lib/playback-ids.ts index 63fab98..63e6826 100644 --- a/src/lib/playback-ids.ts +++ b/src/lib/playback-ids.ts @@ -1,4 +1,4 @@ -import type Mux from '@mux/mux-node'; +import type Mux from '@mux/ts'; export type PlaybackIdPolicy = 'public' | 'signed'; diff --git a/src/lib/signing.ts b/src/lib/signing.ts index 4d62684..47f76af 100644 --- a/src/lib/signing.ts +++ b/src/lib/signing.ts @@ -1,4 +1,4 @@ -import Mux from '@mux/mux-node'; +import Mux from '@mux/ts'; export interface SigningCredentials { signingKeyId?: string;