diff --git a/README.md b/README.md index a25cc68..dfaeeee 100644 --- a/README.md +++ b/README.md @@ -266,10 +266,20 @@ rawback videos list rawback videos upload --file ~/Movies/hike.mp4 rawback videos upload --file ~/Movies/hike.mp4 --thumbnail ~/Pictures/poster.jpg rawback videos repair --id 7 --file ~/Movies/hike.mp4 -rawback videos update --id 7 --title "Hike, day two" +rawback videos get 7 +rawback videos update --id 7 --title "Hike, day two" --title-zh "徒步第二天" +rawback videos update --id 7 --hashtag hiking,徒步 --link "https://example.com/route|Route|路线" +rawback videos subtitle upload --id 7 --language zh --file ~/Movies/hike.zh.srt +rawback videos audio upload --id 7 --language en --file ~/Music/narration.m4a --label Narration +rawback videos list --hashtag hiking rawback videos delete --id 7 ``` +A video's title and description are English by default, with an optional +Chinese copy. Each video can also carry hashtags, external links, one uploaded +subtitle per language (SRT or WebVTT) and one audio-only attachment per +language, played and downloaded on its own. + Supported containers are MP4, M4V, MOV, WebM, MKV, AVI, MPEG, 3GP, and TS, up to 100 GB. The file is sent in parts to presigned URLs and read from disk on demand. Before upload, FFmpeg tools extract a poster and audio for transcription. Pass `--thumbnail` to use a JPEG, diff --git a/docs/commands.md b/docs/commands.md index af8c3f8..42b2c14 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -727,11 +727,23 @@ disk on demand, so a very large file is never held in memory. rawback videos list [options] ``` -| Option | Description | Default | -| ---------------------- | ----------------------------- | ------- | -| `--page ` | Page number | `1` | -| `--page-size ` | Items per page, 1 through 100 | `24` | -| `--json` | Output machine-readable JSON | `false` | +| Option | Description | Default | +| ---------------------- | ------------------------------------------------------------------------------------------------- | ------- | +| `--page ` | Page number | `1` | +| `--page-size ` | Items per page, 1 through 100 | `24` | +| `--hashtag ` | Only videos carrying any of these hashtags, case-insensitive; repeat or comma-separate | — | +| `--search ` | Match titles and descriptions in either language, hashtags, file names and transcripts (2+ chars) | — | +| `--json` | Output machine-readable JSON | `false` | + +### `videos get` + +```bash +rawback videos get [--json] +``` + +Shows one video: its title and description in English and, when set, Chinese, +its hashtags, links, uploaded subtitles, audio attachments, file details and +playback URL. `view` is an alias. `--json` prints `{ "video": ... }`. ### `videos upload` @@ -795,11 +807,60 @@ transcription enablement and recovery limits, see ### `videos update` ```bash -rawback videos update --id [--title ] [--description <text>] +rawback videos update --id <id> [options] +rawback videos update --id 7 --title-zh "海港清晨" --hashtag travel,旅行 +rawback videos update --id 7 --link "https://example.com/notes|Notes|笔记" +``` + +English is a video's default language; the Chinese title and description are +optional and shown to people using Rawback in Chinese. + +| Option | Description | +| ------------------------- | ----------------------------------------------------------------------------- | +| `--title <title>` | English (default) title; cannot be empty | +| `--description <text>` | English description; an empty value clears it | +| `--title-zh <title>` | Chinese title; an empty value clears it | +| `--description-zh <text>` | Chinese description; an empty value clears it | +| `--hashtag <tag>` | Replace the hashtags; repeat or comma-separate, `#` optional, any language | +| `--clear-hashtags` | Remove every hashtag | +| `--link <link>` | Replace the links; each is `url`, `url\|title` or `url\|title\|Chinese title` | +| `--clear-links` | Remove every link | +| `--json` | Output machine-readable JSON | + +At least one change is required. Hashtags and links replace the whole list, so +pass the ones you want to keep; `--hashtag` with `--clear-hashtags` (or `--link` +with `--clear-links`) is rejected. Hashtags may hold letters and numbers of any +script plus `_` and `-`, up to 50 characters and 30 per video; links must be +`http` or `https`, up to 10 per video. A link title defaults to its host. + +### `videos subtitle` + +```bash +rawback videos subtitle upload --id <id> --language <en|zh> --file <path> +rawback videos subtitle view --id <id> --language <en|zh> [--format srt|vtt|text] [--output <path>] +rawback videos subtitle delete --id <id> --language <en|zh> +``` + +A video has at most one uploaded subtitle per language (`en`, `zh`); uploading +again replaces it. `upload` takes an SRT or WebVTT file, UTF-8, up to 1 MiB, and +the server rejects one whose cues do not parse. `view` prints SRT by default, +WebVTT with `--format vtt`, or the words alone with `--format text`, to stdout or +to `--output`. These are separate from the server's generated transcript. All +three accept `--json`. + +### `videos audio` + +```bash +rawback videos audio upload --id <id> --language <en|zh> --file <path> [--label <text>] +rawback videos audio delete --id <id> --language <en|zh> ``` -At least one of `--title` or `--description` is required. Passing an empty -description clears it. +Attaches an audio-only file — narration, a dub, the soundtrack — to a completed +video, one per language; uploading again replaces it. It is played and +downloaded on its own and never mixed into the video. MP3, M4A, AAC, WAV, FLAC, +Ogg, Opus and WebM are accepted, up to 2 GiB; the file is streamed to a +presigned URL and charged against the account's storage once confirmed. +`delete` releases that storage. Both accept `--json`. ### `videos delete` @@ -807,8 +868,8 @@ description clears it. rawback videos delete --id <id> ``` -Deletes the video and its poster frame and releases the storage against the -account quota. This cannot be undone. +Deletes the video with its poster frame, subtitles and audio attachments, and +releases the storage against the account quota. This cannot be undone. ## `rawback dream` diff --git a/package.json b/package.json index cec6b87..1079551 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "@inquirer/prompts": "^8.7.0", "@rawback/ccapi-js": "1.1.3", - "@rawback/sdk": "0.3.5", + "@rawback/sdk": "0.3.6", "ink": "^7.1.1", "react": "^19.2.8", "react-devtools-core": "^7.0.1", diff --git a/src/cli.ts b/src/cli.ts index cd22b03..22216dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -851,6 +851,16 @@ export function createProgram(version: string, output = new CommandOutput()): Ar describe: 'items per page (1-100)', type: 'number', }) + .option('hashtag', { + array: true, + describe: 'only videos with any of these hashtags (repeat or comma-separate)', + type: 'string', + }) + .option('search', { + describe: + 'match titles and descriptions in either language, hashtags, file names and transcripts', + type: 'string', + }) .option('json', { default: false, describe: 'output machine-readable JSON', @@ -864,10 +874,32 @@ export function createProgram(version: string, output = new CommandOutput()): Ar json: args.json, page: args.page, pageSize: args.pageSize, + ...(args.hashtag !== undefined ? { hashtag: args.hashtag } : {}), + ...(args.search !== undefined ? { search: args.search } : {}), }), ) }, ) + .command( + ['get <id>', 'view <id>'], + 'show a video with its text, hashtags, links, subtitles and audio', + (get) => + get + .positional('id', { + describe: 'video ID', + type: 'number', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runVideoGet } = await import('./video-media.ts') + await runCommand(() => runVideoGet({ id: args.id!, json: args.json })) + }, + ) .command( 'upload', 'upload a video directly to storage', @@ -965,7 +997,7 @@ export function createProgram(version: string, output = new CommandOutput()): Ar ) .command( 'update', - 'change a video title or description', + "change a video's text (English, optional Chinese), hashtags or links", (update) => update .option('id', { @@ -974,13 +1006,40 @@ export function createProgram(version: string, output = new CommandOutput()): Ar type: 'number', }) .option('title', { - describe: 'new title', + describe: 'new English (default) title', type: 'string', }) .option('description', { - describe: 'new description', + describe: 'new English (default) description; empty clears it', type: 'string', }) + .option('title-zh', { + describe: 'new Chinese title; empty clears it', + type: 'string', + }) + .option('description-zh', { + describe: 'new Chinese description; empty clears it', + type: 'string', + }) + .option('hashtag', { + array: true, + describe: 'replace the hashtags (repeat or comma-separate; the # is optional)', + type: 'string', + }) + .option('clear-hashtags', { + describe: 'remove every hashtag', + type: 'boolean', + }) + .option('link', { + array: true, + describe: + 'replace the links; each is "url", "url|title" or "url|title|Chinese title"', + type: 'string', + }) + .option('clear-links', { + describe: 'remove every link', + type: 'boolean', + }) .option('json', { default: false, describe: 'output machine-readable JSON', @@ -995,10 +1054,209 @@ export function createProgram(version: string, output = new CommandOutput()): Ar json: args.json, ...(args.title !== undefined ? { title: args.title } : {}), ...(args.description !== undefined ? { description: args.description } : {}), + ...(args.titleZh !== undefined ? { titleZh: args.titleZh } : {}), + ...(args.descriptionZh !== undefined + ? { descriptionZh: args.descriptionZh } + : {}), + ...(args.hashtag !== undefined ? { hashtag: args.hashtag } : {}), + ...(args.clearHashtags !== undefined + ? { clearHashtags: args.clearHashtags } + : {}), + ...(args.link !== undefined ? { link: args.link } : {}), + ...(args.clearLinks !== undefined ? { clearLinks: args.clearLinks } : {}), }), ) }, ) + .command( + 'subtitle', + "upload, view, or delete a video's subtitles (one per language: en, zh)", + (subtitle) => + subtitle + .command( + 'upload', + "upload an SRT or WebVTT file, replacing that language's subtitles", + (upload) => + upload + .option('id', { demandOption: true, describe: 'video id', type: 'number' }) + .option('language', { + demandOption: true, + describe: 'subtitle language: en or zh', + type: 'string', + }) + .option('file', { + coerce: expandHomePath, + demandOption: true, + describe: 'SRT or WebVTT file, UTF-8, up to 1 MiB', + type: 'string', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runVideoSubtitleUpload } = await import('./video-media.ts') + await runCommand(() => + runVideoSubtitleUpload({ + id: args.id, + language: args.language, + file: args.file, + json: args.json, + }), + ) + }, + ) + .command( + 'view', + "print or save a video's subtitles", + (view) => + view + .option('id', { demandOption: true, describe: 'video id', type: 'number' }) + .option('language', { + demandOption: true, + describe: 'subtitle language: en or zh', + type: 'string', + }) + .option('format', { + choices: ['srt', 'vtt', 'text'] as const, + default: 'srt' as const, + describe: 'SRT, WebVTT, or the words alone', + }) + .option('output', { + coerce: expandHomePath, + describe: 'write to this file instead of stdout', + type: 'string', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runVideoSubtitleView } = await import('./video-media.ts') + await runCommand(() => + runVideoSubtitleView({ + id: args.id, + language: args.language, + format: args.format, + json: args.json, + ...(args.output !== undefined ? { output: args.output } : {}), + }), + ) + }, + ) + .command( + 'delete', + "delete a video's subtitles in one language", + (remove) => + remove + .option('id', { demandOption: true, describe: 'video id', type: 'number' }) + .option('language', { + demandOption: true, + describe: 'subtitle language: en or zh', + type: 'string', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runVideoSubtitleDelete } = await import('./video-media.ts') + await runCommand(() => + runVideoSubtitleDelete({ + id: args.id, + language: args.language, + json: args.json, + }), + ) + }, + ) + .demandCommand(1, 'Choose a subtitle command: upload, view, or delete') + .strict(), + () => {}, + ) + .command( + 'audio', + "upload or delete a video's audio-only attachment (one per language: en, zh)", + (audio) => + audio + .command( + 'upload', + "attach an audio file, replacing that language's audio", + (upload) => + upload + .option('id', { demandOption: true, describe: 'video id', type: 'number' }) + .option('language', { + demandOption: true, + describe: 'audio language: en or zh', + type: 'string', + }) + .option('file', { + coerce: expandHomePath, + demandOption: true, + describe: 'MP3, M4A, AAC, WAV, FLAC, Ogg, Opus or WebM, up to 2 GiB', + type: 'string', + }) + .option('label', { + describe: 'display label, e.g. "Director\'s commentary"', + type: 'string', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runVideoAudioUpload } = await import('./video-media.ts') + await runCommand(() => + runVideoAudioUpload({ + id: args.id, + language: args.language, + file: args.file, + json: args.json, + ...(args.label !== undefined ? { label: args.label } : {}), + }), + ) + }, + ) + .command( + 'delete', + "delete a video's audio in one language and release its storage", + (remove) => + remove + .option('id', { demandOption: true, describe: 'video id', type: 'number' }) + .option('language', { + demandOption: true, + describe: 'audio language: en or zh', + type: 'string', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runVideoAudioDelete } = await import('./video-media.ts') + await runCommand(() => + runVideoAudioDelete({ + id: args.id, + language: args.language, + json: args.json, + }), + ) + }, + ) + .demandCommand(1, 'Choose an audio command: upload or delete') + .strict(), + () => {}, + ) .command( 'delete', 'delete a video and release its storage', @@ -1025,7 +1283,10 @@ export function createProgram(version: string, output = new CommandOutput()): Ar ) }, ) - .demandCommand(1, 'Choose a videos command: list, upload, repair, update, or delete') + .demandCommand( + 1, + 'Choose a videos command: list, get, upload, repair, update, subtitle, audio, or delete', + ) .strict(), () => {}, ) diff --git a/src/features/videos/view.ts b/src/features/videos/view.ts index 05a90ad..5ebf4f3 100644 --- a/src/features/videos/view.ts +++ b/src/features/videos/view.ts @@ -1,9 +1,22 @@ -import { type VideosQuery } from '@rawback/sdk' +import { type VideoQuery, type VideosQuery } from '@rawback/sdk' -import { formatBytes, formatDuration, formatTimestamp } from '../../ui/format.ts' -import { cell, type UiDocument } from '../../ui/model.ts' +import { formatBytes, formatDuration, formatTimestamp, sanitizeCell } from '../../ui/format.ts' +import { type UiBlock, cell, type UiDocument } from '../../ui/model.ts' type Video = VideosQuery['videos']['edges'][number] +type VideoDetail = NonNullable<VideoQuery['video']> + +const LANGUAGE_NAMES: Record<string, string> = { en: 'English', zh: 'Chinese' } + +function languageName(language: string): string { + return LANGUAGE_NAMES[language] ?? language +} + +function videoStatusCell(status: string) { + return cell(status, { + tone: status === 'completed' ? 'success' : status === 'failed' ? 'error' : 'warning', + }) +} type PageInfo = VideosQuery['videos']['pageInfo'] export function videoListDocument(videos: Video[], pageInfo: PageInfo): UiDocument { @@ -25,7 +38,7 @@ export function videoListDocument(videos: Video[], pageInfo: PageInfo): UiDocume ], rows: videos.map((video) => ({ id: video.id, - title: video.title, + title: sanitizeCell(video.title), duration: video.durationSeconds != null ? formatDuration(video.durationSeconds) @@ -53,3 +66,117 @@ export function videoListDocument(videos: Video[], pageInfo: PageInfo): UiDocume ], } } + +/** One video: its text in both languages, hashtags, links, subtitles and audio. */ +export function videoViewDocument(video: VideoDetail): UiDocument { + const none = cell('—', { dim: true }) + const blocks: UiBlock[] = [ + { + type: 'fields', + fields: [ + { label: 'ID', value: video.id }, + { label: 'Title', value: sanitizeCell(video.title) }, + { label: 'Title (Chinese)', value: video.titleZh ? sanitizeCell(video.titleZh) : none }, + { label: 'Status', value: videoStatusCell(video.status) }, + { + label: 'Hashtags', + value: + video.hashtags.length > 0 + ? video.hashtags.map((tag) => `#${sanitizeCell(tag)}`).join(' ') + : none, + }, + { label: 'File', value: sanitizeCell(video.filename) }, + { label: 'Size', value: formatBytes(video.sizeBytes) }, + { + label: 'Duration', + value: video.durationSeconds != null ? formatDuration(video.durationSeconds) : none, + }, + { + label: 'Dimensions', + value: + video.width != null && video.height != null + ? `${String(video.width)}×${String(video.height)}` + : none, + }, + { label: 'Transcript', value: video.transcript?.status ?? none }, + { label: 'Playback', value: video.url ? sanitizeCell(video.url) : none }, + { label: 'Uploaded', value: formatTimestamp(video.createdAt) }, + ], + }, + ] + if (video.description) { + blocks.push( + { type: 'text', text: 'Description', bold: true }, + { type: 'text', text: video.description }, + ) + } + if (video.descriptionZh) { + blocks.push( + { type: 'text', text: 'Description (Chinese)', bold: true }, + { type: 'text', text: video.descriptionZh }, + ) + } + if (video.links.length > 0) { + blocks.push( + { type: 'text', text: 'Links', bold: true }, + { + type: 'table', + columns: [ + { key: 'title', label: 'Title', required: true, priority: 1 }, + { key: 'titleZh', label: 'Chinese title', priority: 3 }, + { key: 'url', label: 'URL', required: true, priority: 1 }, + ], + rows: video.links.map((link) => ({ + title: sanitizeCell(link.title), + titleZh: link.titleZh ? sanitizeCell(link.titleZh) : none, + url: sanitizeCell(link.url), + })), + }, + ) + } + if (video.subtitles.length > 0) { + blocks.push( + { type: 'text', text: 'Subtitles', bold: true }, + { + type: 'table', + columns: [ + { key: 'language', label: 'Language', required: true, priority: 1 }, + { key: 'format', label: 'Format', priority: 2 }, + { key: 'cues', label: 'Cues', priority: 2 }, + { key: 'file', label: 'File', priority: 3 }, + ], + rows: video.subtitles.map((subtitle) => ({ + language: languageName(subtitle.language), + format: subtitle.format.toUpperCase(), + cues: subtitle.cueCount, + file: subtitle.filename ? sanitizeCell(subtitle.filename) : none, + })), + }, + ) + } + if (video.audioTracks.length > 0) { + blocks.push( + { type: 'text', text: 'Audio attachments', bold: true }, + { + type: 'table', + columns: [ + { key: 'language', label: 'Language', required: true, priority: 1 }, + { key: 'label', label: 'Label', priority: 3 }, + { key: 'file', label: 'File', priority: 2 }, + { key: 'duration', label: 'Duration', priority: 3 }, + { key: 'size', label: 'Size', priority: 2 }, + { key: 'url', label: 'URL', priority: 4 }, + ], + rows: video.audioTracks.map((track) => ({ + language: languageName(track.language), + label: track.label ? sanitizeCell(track.label) : none, + file: sanitizeCell(track.filename), + duration: track.durationSeconds != null ? formatDuration(track.durationSeconds) : none, + size: formatBytes(track.sizeBytes), + url: sanitizeCell(track.url), + })), + }, + ) + } + return { title: sanitizeCell(video.title), blocks } +} diff --git a/src/video-media.ts b/src/video-media.ts new file mode 100644 index 0000000..2b408f4 --- /dev/null +++ b/src/video-media.ts @@ -0,0 +1,309 @@ +import { writeFile } from 'node:fs/promises' +import { basename } from 'node:path' + +import { + DeleteVideoAudioTrackDocument, + DeleteVideoSubtitleDocument, + openAudioTrackFile, + parseVideoLanguage, + readSubtitleFile, + UploadVideoSubtitleDocument, + type VideoLanguage, + type VideoLinkInput, + VideoDocument, + VideoService, + VideoSubtitlesDocument, +} from '@rawback/sdk' + +import { commandOutput, createCommandClient } from './command.ts' +import { videoViewDocument } from './features/videos/view.ts' +import { formatBytes } from './ui/format.ts' +import type { VideoCommandDependencies } from './videos.ts' + +/** + * Hashtags as typed: repeat the option or comma-separate, with or without the + * #. Normalising beyond that (letters, numbers, _ and -; duplicates) is the + * server's job, so its message is the one the user sees. + */ +export function parseHashtags(values: Array<string | number> | undefined): string[] { + return (values ?? []) + .flatMap((value) => String(value).split(/[,,]/)) + .map((tag) => tag.trim().replace(/^[##]+/, '')) + .filter(Boolean) +} + +/** `url`, `url|title` or `url|title|中文 title`. */ +export function parseVideoLink(value: string): VideoLinkInput { + const [url = '', title, titleZh] = value.split('|').map((part) => part.trim()) + if (!/^https?:\/\//i.test(url)) { + throw new Error(`--link must start with an http or https URL: ${value}`) + } + return { + url, + ...(title ? { title } : {}), + ...(titleZh ? { titleZh } : {}), + } +} + +export function validateVideoLanguage(value: string): VideoLanguage { + const language = parseVideoLanguage(value) + if (!language) throw new Error(`--language must be en or zh, not ${value}`) + return language +} + +function validateVideoId(id: number): number { + if (!Number.isSafeInteger(id) || id < 1) throw new Error('Video ID must be a positive integer') + return id +} + +export interface VideoGetOptions { + id: number + json?: boolean +} + +export async function runVideoGet( + options: VideoGetOptions, + dependencies: VideoCommandDependencies = {}, +): Promise<void> { + const id = validateVideoId(options.id) + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + 'Loading video…', + async () => { + const client = await createCommandClient(dependencies) + return client.graphql.query({ query: VideoDocument, variables: { id } }) + }, + !options.json, + ) + if (result.error) throw result.error + const video = result.data?.video + if (!video) throw new Error(`Video ${String(id)} was not found`) + + if (options.json) { + ui.json({ video }) + return + } + ui.document(videoViewDocument(video)) +} + +export type SubtitleOutputFormat = 'srt' | 'vtt' | 'text' + +export interface VideoSubtitleUploadOptions { + id: number + language: string + file: string + json?: boolean +} + +export async function runVideoSubtitleUpload( + options: VideoSubtitleUploadOptions, + dependencies: VideoCommandDependencies = {}, +): Promise<void> { + const id = validateVideoId(options.id) + const language = validateVideoLanguage(options.language) + const subtitle = await readSubtitleFile(options.file) + + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + `Uploading ${subtitle.filename}…`, + async () => { + const client = await createCommandClient(dependencies) + return client.graphql.mutate({ + mutation: UploadVideoSubtitleDocument, + variables: { + videoId: id, + input: { language, content: subtitle.content, filename: subtitle.filename }, + }, + }) + }, + !options.json, + ) + if (result.error) throw result.error + const saved = result.data?.uploadVideoSubtitle + if (!saved) throw new Error('The upload response did not include the subtitle') + + if (options.json) { + ui.json({ subtitle: saved }) + return + } + ui.success( + `Saved ${language} subtitles for video ${String(id)} (${String(saved.cueCount)} cues, ${saved.format.toUpperCase()})`, + ) +} + +/** The words of a WebVTT document, one cue per line, without timing or markup. */ +export function subtitlePlainText(vtt: string): string { + const lines: string[] = [] + for (const block of vtt.replace(/\r\n?/g, '\n').split(/\n{2,}/)) { + const rows = block.split('\n') + const timing = rows.findIndex((row) => row.includes('-->')) + if (timing === -1) continue + const text = rows + .slice(timing + 1) + .map((row) => row.replace(/<[^>]*>/g, '').trim()) + .filter(Boolean) + .join(' ') + if (text) lines.push(text) + } + return lines.join('\n') +} + +export interface VideoSubtitleViewOptions { + id: number + language: string + format?: SubtitleOutputFormat + output?: string + json?: boolean +} + +export async function runVideoSubtitleView( + options: VideoSubtitleViewOptions, + dependencies: VideoCommandDependencies = {}, +): Promise<void> { + const id = validateVideoId(options.id) + const language = validateVideoLanguage(options.language) + const format = options.format ?? 'srt' + + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + 'Loading subtitles…', + async () => { + const client = await createCommandClient(dependencies) + return client.graphql.query({ query: VideoSubtitlesDocument, variables: { id } }) + }, + !options.json && !options.output, + ) + if (result.error) throw result.error + const video = result.data?.video + if (!video) throw new Error(`Video ${String(id)} was not found`) + const subtitle = video.subtitles.find((item) => item.language === language) + if (!subtitle) throw new Error(`Video ${String(id)} has no ${language} subtitles`) + + const meta = { + id: subtitle.id, + language: subtitle.language, + format: subtitle.format, + filename: subtitle.filename ?? null, + cueCount: subtitle.cueCount, + sizeBytes: subtitle.sizeBytes, + updatedAt: subtitle.updatedAt, + } + const content = + format === 'vtt' + ? subtitle.vttContent + : format === 'text' + ? subtitlePlainText(subtitle.vttContent) + : subtitle.srtContent + + if (options.output) { + await writeFile(options.output, content.endsWith('\n') ? content : `${content}\n`) + if (options.json) { + ui.json({ subtitle: meta, outputFormat: format, output: options.output }) + return + } + ui.success(`Wrote ${language} subtitles to ${options.output}`) + return + } + if (options.json) { + ui.json({ subtitle: meta, outputFormat: format, content }) + return + } + ui.raw(content.endsWith('\n') ? content : `${content}\n`) +} + +export interface VideoMediaDeleteOptions { + id: number + language: string + json?: boolean +} + +export async function runVideoSubtitleDelete( + options: VideoMediaDeleteOptions, + dependencies: VideoCommandDependencies = {}, +): Promise<void> { + const id = validateVideoId(options.id) + const language = validateVideoLanguage(options.language) + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + 'Deleting subtitles…', + async () => { + const client = await createCommandClient(dependencies) + return client.graphql.mutate({ + mutation: DeleteVideoSubtitleDocument, + variables: { videoId: id, language }, + }) + }, + !options.json, + ) + if (result.error) throw result.error + if (options.json) { + ui.json({ deleted: result.data?.deleteVideoSubtitle ?? false }) + return + } + ui.success(`Deleted the ${language} subtitles of video ${String(id)}`) +} + +export interface VideoAudioUploadOptions { + id: number + language: string + file: string + label?: string + json?: boolean +} + +export async function runVideoAudioUpload( + options: VideoAudioUploadOptions, + dependencies: VideoCommandDependencies = {}, +): Promise<void> { + const id = validateVideoId(options.id) + const language = validateVideoLanguage(options.language) + const label = options.label?.trim() + const track = await openAudioTrackFile(options.file, { + language, + ...(label ? { label } : {}), + }) + + const ui = commandOutput(dependencies) + const client = await createCommandClient(dependencies) + // Storage PUTs must carry no Rawback headers; see runVideoUpload. + const videos = new VideoService(client.http, dependencies.fetch ?? globalThis.fetch) + const saved = await ui.withActivity( + `Uploading ${basename(options.file)}…`, + () => videos.uploadAudioTrack(id, track), + !options.json, + ) + + if (options.json) { + ui.json({ audioTrack: saved }) + return + } + ui.success( + `Attached ${saved.filename} (${formatBytes(saved.sizeBytes)}) as the ${language} audio of video ${String(id)}`, + ) +} + +export async function runVideoAudioDelete( + options: VideoMediaDeleteOptions, + dependencies: VideoCommandDependencies = {}, +): Promise<void> { + const id = validateVideoId(options.id) + const language = validateVideoLanguage(options.language) + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + 'Deleting audio…', + async () => { + const client = await createCommandClient(dependencies) + return client.graphql.mutate({ + mutation: DeleteVideoAudioTrackDocument, + variables: { videoId: id, language }, + }) + }, + !options.json, + ) + if (result.error) throw result.error + if (options.json) { + ui.json({ deleted: result.data?.deleteVideoAudioTrack ?? false }) + return + } + ui.success(`Deleted the ${language} audio of video ${String(id)}`) +} diff --git a/src/videos.ts b/src/videos.ts index 8e32bf4..25ab061 100644 --- a/src/videos.ts +++ b/src/videos.ts @@ -8,6 +8,7 @@ import { type PrepareVideoOptions, RawbackHttpError, UpdateVideoDocument, + type UpdateVideoInput, VIDEO_MIME_TYPES, VideoService, VideoToolError, @@ -23,6 +24,7 @@ import { } from './command.ts' import { videoListDocument } from './features/videos/view.ts' import { findBundledFfmpegPath, findBundledFfprobePath } from './upload-identity.ts' +import { parseHashtags, parseVideoLink } from './video-media.ts' export interface VideoCommandDependencies extends ReadCommandDependencies { /** @@ -90,7 +92,9 @@ function serializeVideo(video: Video) { return { id: video.id, title: video.title, + titleZh: video.titleZh ?? null, description: video.description ?? null, + hashtags: video.hashtags, filename: video.filename, sizeBytes: video.sizeBytes, mimeType: video.mimeType, @@ -105,9 +109,13 @@ function serializeVideo(video: Video) { } export interface VideoListOptions { + /** Keep videos carrying any of these hashtags. */ + hashtag?: Array<string | number> json?: boolean page: number pageSize: number + /** Titles and descriptions in either language, hashtags, file names, transcripts. */ + search?: string } export async function runVideoList( @@ -115,6 +123,15 @@ export async function runVideoList( dependencies: VideoCommandDependencies = {}, ): Promise<void> { validatePagination(options.page, options.pageSize) + const hashtags = parseHashtags(options.hashtag) + const search = options.search?.trim() + if (search !== undefined && search.length > 0 && search.length < 2) { + throw new Error('--search needs at least 2 characters') + } + const filter = + hashtags.length > 0 || search + ? { ...(hashtags.length > 0 ? { hashtags } : {}), ...(search ? { search } : {}) } + : undefined const ui = commandOutput(dependencies) const result = await ui.withActivity( @@ -123,7 +140,10 @@ export async function runVideoList( const client = await createCommandClient(dependencies) return client.graphql.query({ query: VideosDocument, - variables: { pagination: { page: options.page, pageSize: options.pageSize } }, + variables: { + pagination: { page: options.page, pageSize: options.pageSize }, + ...(filter ? { filter } : {}), + }, }) }, !options.json, @@ -307,18 +327,50 @@ export async function runVideoUpload( export interface VideoUpdateOptions { id: number + clearHashtags?: boolean + clearLinks?: boolean description?: string + descriptionZh?: string + /** Replaces the hashtag list; each value may hold several, comma-separated. */ + hashtag?: Array<string | number> json?: boolean + /** Replaces the link list; each is `url`, `url|title` or `url|title|中文 title`. */ + link?: string[] title?: string + titleZh?: string +} + +/** The UpdateVideoInput an update command sends; exported for tests. */ +export function updateVideoInput(options: VideoUpdateOptions): UpdateVideoInput { + if (options.hashtag !== undefined && options.clearHashtags) { + throw new Error('--hashtag and --clear-hashtags cannot be used together') + } + if (options.link !== undefined && options.clearLinks) { + throw new Error('--link and --clear-links cannot be used together') + } + const input: UpdateVideoInput = { + ...(options.title !== undefined ? { title: options.title } : {}), + ...(options.description !== undefined ? { description: options.description } : {}), + ...(options.titleZh !== undefined ? { titleZh: options.titleZh } : {}), + ...(options.descriptionZh !== undefined ? { descriptionZh: options.descriptionZh } : {}), + ...(options.hashtag !== undefined ? { hashtags: parseHashtags(options.hashtag) } : {}), + ...(options.clearHashtags ? { hashtags: [] } : {}), + ...(options.link !== undefined ? { links: options.link.map(parseVideoLink) } : {}), + ...(options.clearLinks ? { links: [] } : {}), + } + if (Object.keys(input).length === 0) { + throw new Error( + 'Provide at least one of --title, --description, --title-zh, --description-zh, --hashtag, --clear-hashtags, --link or --clear-links', + ) + } + return input } export async function runVideoUpdate( options: VideoUpdateOptions, dependencies: VideoCommandDependencies = {}, ): Promise<void> { - if (options.title === undefined && options.description === undefined) { - throw new Error('Provide --title and/or --description') - } + const input = updateVideoInput(options) const ui = commandOutput(dependencies) const result = await ui.withActivity( @@ -327,13 +379,7 @@ export async function runVideoUpdate( const client = await createCommandClient(dependencies) return client.graphql.mutate({ mutation: UpdateVideoDocument, - variables: { - id: options.id, - input: { - ...(options.title !== undefined ? { title: options.title } : {}), - ...(options.description !== undefined ? { description: options.description } : {}), - }, - }, + variables: { id: options.id, input }, }) }, !options.json, diff --git a/test/cli.test.ts b/test/cli.test.ts index 41b0d3e..b335e68 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -281,6 +281,35 @@ describe('video command help and validation', () => { expect(result.stdout).toBe('') expect(result.stderr).toContain('--id must be a positive integer') }) + test('documents the bilingual text, hashtag and link options of update', () => { + const update = runCli('videos', 'update', '--help') + expect(update.exitCode).toBe(0) + for (const option of [ + '--title-zh', + '--description-zh', + '--hashtag', + '--clear-hashtags', + '--link', + '--clear-links', + ]) + expect(update.stdout).toContain(option) + }) + test('documents subtitle and audio commands', () => { + const subtitle = runCli('videos', 'subtitle', 'view', '--help') + expect(subtitle.exitCode).toBe(0) + for (const option of ['--language', '--format', '--output']) + expect(subtitle.stdout).toContain(option) + const audio = runCli('videos', 'audio', 'upload', '--help') + expect(audio.exitCode).toBe(0) + for (const option of ['--language', '--file', '--label']) expect(audio.stdout).toContain(option) + const get = runCli('videos', 'get', '--help') + expect(get.exitCode).toBe(0) + }) + test('rejects an unknown subtitle language before any request', () => { + const result = runCli('videos', 'subtitle', 'delete', '--id', '7', '--language', 'fr') + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('--language must be en or zh') + }) test('requires a nonempty original file', () => { const missing = runCli('videos', 'repair', '--id', '7') expect(missing.exitCode).toBe(1) diff --git a/test/video-media.test.ts b/test/video-media.test.ts new file mode 100644 index 0000000..c229bd4 --- /dev/null +++ b/test/video-media.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { writeCredentials } from '../src/credentials.ts' +import { + parseHashtags, + parseVideoLink, + runVideoAudioDelete, + runVideoAudioUpload, + runVideoGet, + runVideoSubtitleDelete, + runVideoSubtitleUpload, + runVideoSubtitleView, + subtitlePlainText, +} from '../src/video-media.ts' +import { runVideoList, runVideoUpdate, updateVideoInput } from '../src/videos.ts' +import type { VideoCommandDependencies } from '../src/videos.ts' + +const temporaryDirectories: string[] = [] + +async function scratch(): Promise<string> { + const directory = await mkdtemp(join(tmpdir(), 'rawback-video-media-')) + temporaryDirectories.push(directory) + return directory +} + +async function dependencies( + handler: (url: string, init: RequestInit | undefined) => Response, + output: string[], +): Promise<VideoCommandDependencies> { + const directory = await scratch() + const credentialsPath = join(directory, 'credentials.json') + await writeCredentials({ token: 'token', refreshToken: 'refresh' }, credentialsPath) + return { + configPath: join(directory, 'config.yml'), + credentialsPath, + fetch: ((input: unknown, init: RequestInit | undefined) => + handler(String(input), init)) as unknown as typeof fetch, + stdout: (message) => output.push(message), + } +} + +function graphql(init: RequestInit | undefined): { + operationName: string + variables: Record<string, unknown> +} { + return JSON.parse(String(init?.body)) +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +describe('option parsing', () => { + test('splits hashtags on commas and drops the #', () => { + expect(parseHashtags(['#travel,旅行', '#food', ' ', 2024])).toEqual([ + 'travel', + '旅行', + 'food', + '2024', + ]) + expect(parseHashtags(undefined)).toEqual([]) + }) + + test('reads url|title|Chinese title links', () => { + expect(parseVideoLink('https://a.example')).toEqual({ url: 'https://a.example' }) + expect(parseVideoLink('https://a.example | Notes | 笔记')).toEqual({ + url: 'https://a.example', + title: 'Notes', + titleZh: '笔记', + }) + expect(() => parseVideoLink('ftp://a.example')).toThrow(/http or https/) + }) + + test('builds the update input and refuses contradictions', () => { + expect( + updateVideoInput({ + id: 1, + titleZh: '海港', + descriptionZh: '', + hashtag: ['a,b'], + link: ['https://a.example|A'], + }), + ).toEqual({ + titleZh: '海港', + descriptionZh: '', + hashtags: ['a', 'b'], + links: [{ url: 'https://a.example', title: 'A' }], + }) + expect(updateVideoInput({ id: 1, clearHashtags: true, clearLinks: true })).toEqual({ + hashtags: [], + links: [], + }) + expect(() => updateVideoInput({ id: 1 })).toThrow(/Provide at least one/) + expect(() => updateVideoInput({ id: 1, hashtag: ['a'], clearHashtags: true })).toThrow( + /cannot be used together/, + ) + expect(() => updateVideoInput({ id: 1, link: ['https://a'], clearLinks: true })).toThrow( + /cannot be used together/, + ) + }) + + test('strips timing and markup for plain subtitle text', () => { + expect( + subtitlePlainText( + 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n<i>Hello</i>\nthere\n\n00:00:03.000 --> 00:00:04.000\n你好\n', + ), + ).toBe('Hello there\n你好') + }) +}) + +describe('videos update and list', () => { + test('sends the new fields to UpdateVideo', async () => { + const lines: string[] = [] + let variables: Record<string, unknown> | undefined + const deps = await dependencies((_url, init) => { + const body = graphql(init) + expect(body.operationName).toBe('UpdateVideo') + variables = body.variables + return Response.json({ + data: { + updateVideo: { + id: 4, + title: 'Harbour', + titleZh: '海港', + description: null, + descriptionZh: null, + hashtags: ['travel'], + links: [], + updatedAt: 1, + }, + }, + }) + }, lines) + + await runVideoUpdate({ id: 4, titleZh: '海港', hashtag: ['#travel'], json: true }, deps) + expect(variables).toEqual({ id: 4, input: { titleZh: '海港', hashtags: ['travel'] } }) + expect(JSON.parse(lines.join('')).video.titleZh).toBe('海港') + }) + + test('filters the list by hashtag and search', async () => { + const lines: string[] = [] + let variables: Record<string, unknown> | undefined + const deps = await dependencies((_url, init) => { + variables = graphql(init).variables + return Response.json({ + data: { + videos: { + edges: [], + pageInfo: { + page: 1, + pageSize: 24, + totalCount: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false, + }, + }, + }, + }) + }, lines) + + await runVideoList( + { json: true, page: 1, pageSize: 24, hashtag: ['travel,旅行'], search: ' harbour ' }, + deps, + ) + expect(variables).toEqual({ + pagination: { page: 1, pageSize: 24 }, + filter: { hashtags: ['travel', '旅行'], search: 'harbour' }, + }) + await expect(runVideoList({ page: 1, pageSize: 24, search: 'x' }, deps)).rejects.toThrow( + /at least 2 characters/, + ) + }) +}) + +const videoDetail = { + id: 4, + hasAudio: true, + audioChunkCount: 1, + transcript: { status: 'completed' }, + title: 'Harbour', + titleZh: '海港', + description: 'Morning light', + descriptionZh: '清晨', + hashtags: ['travel', '旅行'], + links: [{ url: 'https://a.example', title: 'Notes', titleZh: '笔记' }], + subtitles: [ + { + id: 9, + language: 'zh', + format: 'srt', + filename: 'talk.srt', + cueCount: 12, + sizeBytes: 400, + updatedAt: 1, + }, + ], + audioTracks: [ + { + id: 3, + language: 'en', + label: 'Narration', + filename: 'narration.mp3', + mimeType: 'audio/mpeg', + sizeBytes: 2048, + durationSeconds: 61, + url: 'https://cdn.test/narration.mp3', + updatedAt: 1, + }, + ], + filename: 'harbour.mov', + sizeBytes: 1_000_000, + mimeType: 'video/quicktime', + durationSeconds: 120, + width: 1920, + height: 1080, + status: 'completed', + url: 'https://cdn.test/harbour.mov', + thumbnailUrl: null, + createdAt: 1_704_067_200, + updatedAt: 1_704_067_200, +} + +describe('videos get', () => { + test('renders text in both languages, hashtags, links, subtitles and audio', async () => { + const lines: string[] = [] + const deps = await dependencies((_url, init) => { + expect(graphql(init).operationName).toBe('Video') + return Response.json({ data: { video: videoDetail } }) + }, lines) + + await runVideoGet({ id: 4 }, deps) + const text = lines.join('\n') + for (const expected of [ + '海港', + '#travel #旅行', + 'Notes', + '笔记', + 'Chinese', + 'Narration', + '清晨', + ]) + expect(text).toContain(expected) + }) + + test('reports a missing video', async () => { + const deps = await dependencies(() => Response.json({ data: { video: null } }), []) + await expect(runVideoGet({ id: 4 }, deps)).rejects.toThrow(/was not found/) + }) +}) + +describe('videos subtitle', () => { + test('uploads the file as text with its name', async () => { + const directory = await scratch() + const file = join(directory, 'talk.srt') + await writeFile(file, '1\n00:00:01,000 --> 00:00:02,000\n你好\n') + let variables: Record<string, unknown> | undefined + const lines: string[] = [] + const deps = await dependencies((_url, init) => { + const body = graphql(init) + expect(body.operationName).toBe('UploadVideoSubtitle') + variables = body.variables + return Response.json({ + data: { + uploadVideoSubtitle: { + id: 9, + language: 'zh', + format: 'srt', + filename: 'talk.srt', + cueCount: 1, + sizeBytes: 36, + updatedAt: 1, + }, + }, + }) + }, lines) + + await runVideoSubtitleUpload({ id: 4, language: 'zh-CN', file }, deps) + expect(variables).toEqual({ + videoId: 4, + input: { + language: 'zh', + content: '1\n00:00:01,000 --> 00:00:02,000\n你好\n', + filename: 'talk.srt', + }, + }) + expect(lines.join('\n')).toContain('1 cues') + }) + + test('prints or saves the requested format', async () => { + const lines: string[] = [] + const deps = await dependencies( + () => + Response.json({ + data: { + video: { + id: 4, + subtitles: [ + { + id: 9, + language: 'en', + format: 'srt', + filename: 'talk.srt', + cueCount: 1, + sizeBytes: 36, + content: '1\n00:00:01,000 --> 00:00:02,000\nHi\n', + vttContent: 'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHi\n', + srtContent: '1\n00:00:01,000 --> 00:00:02,000\nHi\n', + updatedAt: 1, + }, + ], + }, + }, + }), + lines, + ) + + await runVideoSubtitleView({ id: 4, language: 'en', format: 'vtt' }, deps) + expect(lines.join('')).toContain('WEBVTT') + + const output = join(await scratch(), 'out.txt') + await runVideoSubtitleView({ id: 4, language: 'en', format: 'text', output }, deps) + expect(await readFile(output, 'utf8')).toBe('Hi\n') + + await expect(runVideoSubtitleView({ id: 4, language: 'zh' }, deps)).rejects.toThrow( + /no zh subtitles/, + ) + }) + + test('deletes one language', async () => { + let variables: Record<string, unknown> | undefined + const lines: string[] = [] + const deps = await dependencies((_url, init) => { + const body = graphql(init) + expect(body.operationName).toBe('DeleteVideoSubtitle') + variables = body.variables + return Response.json({ data: { deleteVideoSubtitle: true } }) + }, lines) + await runVideoSubtitleDelete({ id: 4, language: 'EN', json: true }, deps) + expect(variables).toEqual({ videoId: 4, language: 'en' }) + expect(JSON.parse(lines.join(''))).toEqual({ deleted: true }) + }) +}) + +describe('videos audio', () => { + test('presigns, PUTs to storage without auth, and confirms', async () => { + const directory = await scratch() + const file = join(directory, 'dub.mp3') + await writeFile(file, Buffer.from('ID3 audio bytes')) + const requests: Array<{ url: string; method: string; headers: Headers; body?: unknown }> = [] + const lines: string[] = [] + const deps = await dependencies((url, init) => { + requests.push({ + url, + method: init?.method ?? 'GET', + headers: new Headers(init?.headers), + ...(typeof init?.body === 'string' ? { body: JSON.parse(init.body) } : {}), + }) + if (url.endsWith('/audio-tracks')) { + return Response.json({ + code: 200, + msg: '', + data: { + trackId: 11, + uploadUrl: 'https://s3.test/put', + s3Key: 'k', + contentType: 'audio/mpeg', + expiresAt: 'x', + }, + }) + } + if (url === 'https://s3.test/put') return new Response(null, { status: 200 }) + if (url.endsWith('/audio-tracks/11/confirm')) { + return Response.json({ + code: 200, + msg: '', + data: { + id: 11, + videoId: 4, + language: 'zh', + label: 'Dub', + filename: 'dub.mp3', + mimeType: 'audio/mpeg', + sizeBytes: 15, + url: 'https://cdn.test/dub.mp3', + createdAt: 1, + updatedAt: 1, + }, + }) + } + throw new Error(`unexpected request ${url}`) + }, lines) + + await runVideoAudioUpload({ id: 4, language: 'zh', file, label: ' Dub ', json: true }, deps) + expect(requests.map((request) => request.url.replace(/^.*\/api\/v1/, ''))).toEqual([ + '/upload/videos/4/audio-tracks', + 'https://s3.test/put', + '/upload/videos/4/audio-tracks/11/confirm', + ]) + expect(requests[0]?.body).toEqual({ + filename: 'dub.mp3', + label: 'Dub', + language: 'zh', + mimeType: 'audio/mpeg', + sizeBytes: 15, + }) + expect(requests[1]?.headers.get('authorization')).toBeNull() + expect(JSON.parse(lines.join('')).audioTrack.id).toBe(11) + }) + + test('refuses an unsupported file before any request', async () => { + const directory = await scratch() + const file = join(directory, 'clip.mp4') + await writeFile(file, 'x') + const deps = await dependencies(() => { + throw new Error('should not reach the network') + }, []) + await expect(runVideoAudioUpload({ id: 4, language: 'en', file }, deps)).rejects.toThrow( + /Unsupported audio/, + ) + await expect(runVideoAudioUpload({ id: 4, language: 'fr', file }, deps)).rejects.toThrow( + /--language must be en or zh/, + ) + }) + + test('deletes one language', async () => { + let variables: Record<string, unknown> | undefined + const deps = await dependencies((_url, init) => { + const body = graphql(init) + expect(body.operationName).toBe('DeleteVideoAudioTrack') + variables = body.variables + return Response.json({ data: { deleteVideoAudioTrack: true } }) + }, []) + await runVideoAudioDelete({ id: 4, language: 'zh' }, deps) + expect(variables).toEqual({ videoId: 4, language: 'zh' }) + }) +})