diff --git a/api.md b/api.md index c76e2666..9592c5b9 100644 --- a/api.md +++ b/api.md @@ -14,7 +14,7 @@ LosslessCut --http-api ## API endpoints -### `POST /api/shortcuts/action` +### `POST /api/action/:action` Execute a keyboard shortcut `action`, similar to the `--keyboard-action` CLI option. This is different from the CLI in that most of the actions will wait for the action to finish before responding to the HTTP request (but not all). @@ -25,9 +25,15 @@ Execute a keyboard shortcut `action`, similar to the `--keyboard-action` CLI opt Export the currently opened file: ```bash -curl -X POST http://localhost:8080/api/shortcuts/export +curl -X POST http://localhost:8080/api/action/export ``` +Seek to time: +```bash +curl -X POST http://localhost:8080/api/action/goToTimecodeDirect --json '{"time": "09:11"}' +``` + + ### Batch example Start the main LosslessCut in one terminal with the HTTP API enabled: @@ -42,7 +48,7 @@ Then run the script in a different terminal: for PROJECT in /path/to/folder/with/projects/*.llc LosslessCut $PROJECT sleep 5 # wait for the file to open - curl -X POST http://localhost:8080/api/shortcuts/export - curl -X POST http://localhost:8080/api/shortcuts/closeCurrentFile + curl -X POST http://localhost:8080/api/action/export + curl -X POST http://localhost:8080/api/action/closeCurrentFile done ``` diff --git a/src/main/httpServer.ts b/src/main/httpServer.ts index e48f1eda..5264ad8a 100644 --- a/src/main/httpServer.ts +++ b/src/main/httpServer.ts @@ -9,7 +9,7 @@ import logger from './logger.js'; export default ({ port, onKeyboardAction }: { - port: number, onKeyboardAction: (a: string) => Promise, + port: number, onKeyboardAction: (action: string, args: unknown[]) => Promise, }) => { const app = express(); @@ -26,11 +26,12 @@ export default ({ port, onKeyboardAction }: { app.use('/api', apiRouter); - apiRouter.post('/shortcuts/:action', express.json(), asyncHandler(async (req, res) => { + apiRouter.post('/action/:action', express.json(), asyncHandler(async (req, res) => { // eslint-disable-next-line prefer-destructuring const action = req.params['action']; + const parameters = req.body as unknown; assert(action != null); - await onKeyboardAction(action); + await onKeyboardAction(action, [parameters]); res.end(); })); diff --git a/src/main/index.ts b/src/main/index.ts index 0120d548..a961422e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,7 +27,7 @@ import { checkNewVersion } from './updateChecker.js'; import * as i18nCommon from './i18nCommon.js'; import './i18n.js'; -import { ApiKeyboardActionRequest } from '../../types.js'; +import { ApiActionRequest } from '../../types.js'; export * as ffmpeg from './ffmpeg.js'; @@ -107,19 +107,19 @@ let disableNetworking: boolean; const openFiles = (paths: string[]) => mainWindow!.webContents.send('openFiles', paths); -let apiKeyboardActionRequestsId = 0; -const apiKeyboardActionRequests = new Map void>(); +let apiActionRequestsId = 0; +const apiActionRequests = new Map void>(); -async function sendApiKeyboardAction(action: string) { +async function sendApiAction(action: string, args?: unknown[]) { try { - const id = apiKeyboardActionRequestsId; - apiKeyboardActionRequestsId += 1; - mainWindow!.webContents.send('apiKeyboardAction', { id, action } satisfies ApiKeyboardActionRequest); + const id = apiActionRequestsId; + apiActionRequestsId += 1; + mainWindow!.webContents.send('apiAction', { id, action, args } satisfies ApiActionRequest); await new Promise((resolve) => { - apiKeyboardActionRequests.set(id, resolve); + apiActionRequests.set(id, resolve); }); } catch (err) { - logger.error('sendApiKeyboardAction', err); + logger.error('sendApiAction', err); } } @@ -269,7 +269,7 @@ function initApp() { logger.info('second-instance', argv2); if (argv2._ && argv2._.length > 0) openFilesEventually(argv2._.map(String)); - else if (argv2['keyboardAction']) sendApiKeyboardAction(argv2['keyboardAction']); + else if (argv2['keyboardAction']) sendApiAction(argv2['keyboardAction']); }); // Quit when all windows are closed. @@ -317,8 +317,8 @@ function initApp() { ipcMain.handle('showItemInFolder', (_e, path) => shell.showItemInFolder(path)); - ipcMain.on('apiKeyboardActionResponse', (_e, { id }) => { - apiKeyboardActionRequests.get(id)?.(); + ipcMain.on('apiActionResponse', (_e, { id }) => { + apiActionRequests.get(id)?.(); }); } @@ -367,7 +367,7 @@ const readyPromise = app.whenReady(); if (httpApi != null) { const port = typeof httpApi === 'number' ? httpApi : 8080; - const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiKeyboardAction }); + const { startHttpServer } = HttpServer({ port, onKeyboardAction: sendApiAction }); await startHttpServer(); logger.info('HTTP API listening on port', port); } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 27948a58..f2318b95 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import i18n from 'i18next'; import { useTranslation } from 'react-i18next'; import { produce } from 'immer'; import screenfull from 'screenfull'; +import { IpcRendererEvent } from 'electron'; import fromPairs from 'lodash/fromPairs'; import sortBy from 'lodash/sortBy'; @@ -87,8 +88,8 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; -import { Chapter, ChromiumHTMLVideoElement, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, FormatTimecode, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types'; -import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode } from '../../../types'; +import { Chapter, ChromiumHTMLVideoElement, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, FormatTimecode, goToTimecodeDirectArgsSchema, openFilesActionArgsSchema, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types'; +import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode, ApiActionRequest } from '../../../types'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; const electron = window.require('electron'); @@ -1734,18 +1735,26 @@ function App() { const goToTimecode = useCallback(async () => { if (!filePath) return; - const timeCode = await promptTimeOffset({ + const timecode = await promptTimeOffset({ initialValue: formatTimecode({ seconds: commandedTimeRef.current }), title: i18n.t('Seek to timecode'), inputPlaceholder: timecodePlaceholder, parseTimecode, }); - if (timeCode === undefined) return; + if (timecode === undefined) return; - userSeekAbs(timeCode); + userSeekAbs(timecode); }, [filePath, formatTimecode, parseTimecode, timecodePlaceholder, userSeekAbs]); + const goToTimecodeDirect = useCallback(async ({ time: timeStr }: { time: string }) => { + if (!filePath) return; + invariant(timeStr != null); + const timecode = parseTimecode(timeStr); + invariant(timecode != null); + userSeekAbs(timecode); + }, [filePath, parseTimecode, userSeekAbs]); + const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []); const handleShowStreamsSelectorClick = useCallback(() => { @@ -2099,7 +2108,7 @@ function App() { onEditSegmentTags(currentSegIndexSafe); }, [currentSegIndexSafe, onEditSegmentTags]); - type MainKeyboardAction = Exclude; + type MainKeyboardAction = Exclude; const mainActions = useMemo(() => { async function exportYouTube() { @@ -2403,28 +2412,7 @@ function App() { } } - async function tryApiKeyboardAction(event, { id, action }) { - console.log('API keyboard action:', action); - try { - const fn = getKeyboardAction(action); - if (!fn) throw new Error(`Action not found: ${action}`); - await fn({ keyup: false }); - } catch (err) { - handleError(err); - } finally { - // todo correlation ids - event.sender.send('apiKeyboardActionResponse', { id }); - } - } - - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actionsWithArgs: Record void> = { - openFiles: (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); }, - // todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424 - importEdlFile, - exportEdlFile: tryExportEdlFile, - }; + const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); }; async function actionWithCatch(fn: () => void) { try { @@ -2434,30 +2422,83 @@ function App() { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actionsWithCatch: Readonly<[string, (event: unknown, ...a: any) => Promise]>[] = [ + const allActions = [ // actions with arguments: - ...Object.entries(actionsWithArgs).map(([key, fn]) => [ + [ + 'openFiles', + async (...argsRaw: unknown[]) => { + await openFiles(...openFilesActionArgsSchema.parse(argsRaw)); + }, + ] as const, + [ + 'goToTimecodeDirect', + async (...argsRaw: unknown[]) => { + await goToTimecodeDirect(...goToTimecodeDirectArgsSchema.parse(argsRaw)); + }, + ] as const, + ...Object.entries({ + // todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424 + importEdlFile, + exportEdlFile: tryExportEdlFile, + }).map(([key, fn]) => [ key, - async (_event: unknown, ...args: unknown[]) => actionWithCatch(() => fn(...args)), + async (...args: unknown[]) => { + await (fn as (...args2: unknown[]) => Promise)(...args); + }, ] as const), // all main actions (no arguments, so simulate keyup): ...Object.entries(mainActions).map(([key, fn]) => [ key, - async () => actionWithCatch(() => fn({ keyup: true })), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async () => { + fn({ keyup: true }); + }, ] as const), // also called from menu: - ['toggleKeyboardShortcuts', async () => actionWithCatch(() => toggleKeyboardShortcuts())], + [ + 'toggleKeyboardShortcuts', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async () => { + toggleKeyboardShortcuts(); + }, + ] as const, ]; - actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.on(key, action)); - electron.ipcRenderer.on('apiKeyboardAction', tryApiKeyboardAction); + const allActionsMap = Object.fromEntries(allActions); + + const actionsWithCatch = allActions.map(([key, fn]) => [ + key, + (...args: Parameters) => actionWithCatch(() => fn(...args)), + ] as const); + + async function tryApiAction(event: IpcRendererEvent, { id, action, args }: ApiActionRequest) { + console.log('API action:', action, args); + try { + const fn = allActionsMap[action]; + if (!fn) throw new Error(`Action not found: ${action}`); + // todo validate arguments + await (args != null ? fn(...args) : fn()); + } catch (err) { + handleError(err); + } finally { + // todo correlation ids + event.sender.send('apiActionResponse', { id }); + } + } + + const ipcActions = actionsWithCatch.map(([key, fn]) => [ + key, + (_event: IpcRendererEvent, ...args: Parameters) => actionWithCatch(() => fn(...args)), + ] as const); + + ipcActions.forEach(([key, action]) => electron.ipcRenderer.on(key, action)); + electron.ipcRenderer.on('apiAction', tryApiAction); return () => { - actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.off(key, action)); - electron.ipcRenderer.off('apiKeyboardAction', tryApiKeyboardAction); + ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action)); + electron.ipcRenderer.off('apiAction', tryApiAction); }; - }, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, loadCutSegments, mainActions, selectedSegments, toggleKeyboardShortcuts, userOpenFiles]); + }, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, loadCutSegments, mainActions, selectedSegments, toggleKeyboardShortcuts, userOpenFiles]); useEffect(() => { async function onDrop(ev: DragEvent) { diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index bf5cd959..91cb01bb 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -27,6 +27,12 @@ export interface ApparentSegmentBase { export interface ApparentSegmentWithColorIndex extends ApparentSegmentBase, SegmentColorIndex {} +export const openFilesActionArgsSchema = z.tuple([z.string().array()]); +export type OpenFilesActionArgs = z.infer + +export const goToTimecodeDirectArgsSchema = z.tuple([z.object({ time: z.string() })]); +export type GoToTimecodeDirectArgs = z.infer + export const segmentTagsSchema = z.record(z.string(), z.string()); export type SegmentTags = z.infer diff --git a/types.ts b/types.ts index 3c650c3b..cdbbbd03 100644 --- a/types.ts +++ b/types.ts @@ -106,9 +106,10 @@ export interface Waveform { buffer: Buffer, } -export interface ApiKeyboardActionRequest { +export interface ApiActionRequest { id: number action: string + args?: unknown[] | undefined, } export type Html5ifyMode = 'fastest' | 'fast-audio-remux' | 'fast-audio' | 'fast' | 'slow' | 'slow-audio' | 'slowest';