diff --git a/packages/amara/api.test.ts b/packages/amara/api.test.ts new file mode 100644 index 000000000..e1f221188 --- /dev/null +++ b/packages/amara/api.test.ts @@ -0,0 +1,211 @@ +import { AmaraAPIError, makeAmaraRequest } from './client'; +import { AmaraEndpointOutputSchemas } from './endpoints/types'; + +/** + * Live contract tests — they call the real Amara API and therefore need a key. + * + * Run locally with: + * AMARA_API_KEY=... pnpm --filter @corsair-dev/amara test api.test.ts + * + * The suite skips itself when no key is present so a keyless local run stays green. + */ +jest.setTimeout(60_000); + +const API_KEY = process.env.AMARA_API_KEY; +const describeLive = API_KEY ? describe : describe.skip; + +const KNOWN_VIDEO_ID = 'vkMyJ7Ty7JgJ'; +const KNOWN_VIDEO_URL = 'http://www.youtube.com/watch?v=AtmU1ZtPIlo'; + +describeLive('Amara API contract', () => { + const key = API_KEY as string; + + it('users/me matches the user output schema', async () => { + const response = await makeAmaraRequest('users/me/', key); + const parsed = AmaraEndpointOutputSchemas.usersGetData.parse(response); + expect(parsed.id || parsed.username).toBeTruthy(); + }); + + it('languages list matches the output schema', async () => { + const response = await makeAmaraRequest('languages/', key); + const parsed = + AmaraEndpointOutputSchemas.languagesListAvailable.parse(response); + expect(typeof parsed.languages).toBe('object'); + expect(parsed.languages.en).toBe('English'); + }); + + it('videos list matches the output schema', async () => { + const response = await makeAmaraRequest('videos/', key, { + query: { limit: 5 }, + }); + const parsed = AmaraEndpointOutputSchemas.videosList.parse(response); + expect(Array.isArray(parsed.objects)).toBe(true); + expect(typeof parsed.meta?.total_count).toBe('number'); + }); + + it('video view details for a known video matches the schema', async () => { + const response = await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/`, key); + const parsed = AmaraEndpointOutputSchemas.videosViewDetails.parse(response); + expect(parsed.id).toBe(KNOWN_VIDEO_ID); + }); + + it('subtitle languages for a known video match the schema', async () => { + const response = await makeAmaraRequest( + `videos/${KNOWN_VIDEO_ID}/languages/`, + key, + ); + const parsed = + AmaraEndpointOutputSchemas.videosListSubtitleLanguages.parse(response); + expect(Array.isArray(parsed.objects)).toBe(true); + }); + + it('fetch subtitles for a known video language matches the schema', async () => { + const languages = await makeAmaraRequest<{ + objects?: Array<{ language_code?: string }>; + }>(`videos/${KNOWN_VIDEO_ID}/languages/`, key); + + const languageCode = languages.objects?.[0]?.language_code ?? 'en'; + const response = await makeAmaraRequest( + `videos/${KNOWN_VIDEO_ID}/languages/${languageCode}/subtitles/`, + key, + { query: { sub_format: 'json' } }, + ); + const parsed = + AmaraEndpointOutputSchemas.videosFetchSubtitlesData.parse(response); + expect( + parsed.sub_format === undefined || typeof parsed.sub_format === 'string', + ).toBe(true); + }); + + it('teams list/details/languages match schemas', async () => { + const list = AmaraEndpointOutputSchemas.teamsList.parse( + await makeAmaraRequest('teams/', key, { query: { limit: 2 } }), + ); + expect(Array.isArray(list.objects)).toBe(true); + + const details = AmaraEndpointOutputSchemas.teamsGetDetails.parse( + await makeAmaraRequest('teams/ability/', key), + ); + expect(details.slug).toBe('ability'); + + const languages = AmaraEndpointOutputSchemas.teamsGetLanguages.parse( + await makeAmaraRequest('teams/ability/languages/', key), + ); + expect( + typeof languages.preferred === 'string' || + languages.preferred === undefined, + ).toBe(true); + }); + + it('activity list/get match schemas', async () => { + const list = AmaraEndpointOutputSchemas.activityList.parse( + await makeAmaraRequest('activity/', key, { query: { limit: 2 } }), + ); + expect(Array.isArray(list.objects)).toBe(true); + const id = list.objects?.[0]?.id; + expect(id).toBeTruthy(); + + const one = AmaraEndpointOutputSchemas.activityGet.parse( + await makeAmaraRequest(`activity/${id}/`, key), + ); + expect(one.id).toBe(id); + }); + + it('video urls list/get and url lookup match schemas', async () => { + const urls = AmaraEndpointOutputSchemas.videosListUrls.parse( + await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/urls/`, key), + ); + expect((urls.objects?.length ?? 0) > 0).toBe(true); + const urlId = urls.objects?.[0]?.id; + expect(typeof urlId).toBe('number'); + + const one = AmaraEndpointOutputSchemas.videosGetUrl.parse( + await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/urls/${urlId}/`, key), + ); + expect(one.id).toBe(urlId); + + const byUrl = AmaraEndpointOutputSchemas.videosGetUrlDetails.parse( + await makeAmaraRequest('videos/', key, { + query: { video_url: KNOWN_VIDEO_URL, limit: 1 }, + }), + ); + expect(byUrl.objects?.[0]?.id).toBe(KNOWN_VIDEO_ID); + }); + + it('subtitle language details and video activity match schemas', async () => { + const lang = + AmaraEndpointOutputSchemas.videosGetSubtitleLanguageDetails.parse( + await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/languages/en/`, key), + ); + expect(lang.language_code).toBe('en'); + + const activity = AmaraEndpointOutputSchemas.videosListActivity.parse( + await makeAmaraRequest(`videos/${KNOWN_VIDEO_ID}/activity/`, key, { + query: { limit: 2 }, + }), + ); + expect(Array.isArray(activity.objects)).toBe(true); + }); + + it('users.getData accepts id$ identifiers without mangling $', async () => { + const me = AmaraEndpointOutputSchemas.usersGetData.parse( + await makeAmaraRequest('users/me/', key), + ); + expect(me.id).toBeTruthy(); + + const byId = AmaraEndpointOutputSchemas.usersGetData.parse( + await makeAmaraRequest(`users/id$${me.id}/`, key), + ); + expect(byId.id).toBe(me.id); + }); + + it('users.getActivity is available when Amara exposes it for the user', async () => { + // Live user payloads currently omit activity_uri and /activity/ often + // 404s for this key — still verify the path + schema when it works. + const me = AmaraEndpointOutputSchemas.usersGetData.parse( + await makeAmaraRequest('users/me/', key), + ); + try { + const activity = AmaraEndpointOutputSchemas.usersGetActivity.parse( + await makeAmaraRequest(`users/id$${me.id}/activity/`, key, { + query: { limit: 2 }, + }), + ); + expect(activity.meta || Array.isArray(activity.objects)).toBeTruthy(); + } catch (error) { + expect(String(error)).toMatch(/404|Not Found/i); + } + }); + + it('videos.create reports team requirement clearly for this API key', async () => { + // This Amara account requires a team on create; public teams reject us. + let createdId: string | undefined; + try { + const created = AmaraEndpointOutputSchemas.videosCreate.parse( + await makeAmaraRequest('videos/', key, { + method: 'POST', + body: { + video_url: 'https://www.youtube.com/watch?v=BaW_jenozKc', + title: 'Corsair Amara plugin smoke', + primary_audio_language_code: 'en', + }, + }), + ); + createdId = created.id; + expect(created.id).toBeTruthy(); + } catch (error) { + expect(error).toBeInstanceOf(AmaraAPIError); + const amaraError = error as AmaraAPIError; + expect(amaraError.status).toBe(400); + expect(JSON.stringify(amaraError.body ?? amaraError.message)).toMatch( + /Team is required/i, + ); + } finally { + if (createdId) { + await makeAmaraRequest(`videos/${createdId}/`, key, { + method: 'DELETE', + }); + } + } + }); +}); diff --git a/packages/amara/client.test.ts b/packages/amara/client.test.ts new file mode 100644 index 000000000..7dde24c46 --- /dev/null +++ b/packages/amara/client.test.ts @@ -0,0 +1,138 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; +import { + AMARA_API_BASE, + AmaraAPIError, + encodeAmaraPathSegment, + makeAmaraRequest, +} from './client'; + +jest.mock('corsair/http', () => { + const actual = jest.requireActual('corsair/http'); + return { ...actual, request: jest.fn() }; +}); + +const mockRequest = request as jest.MockedFunction; + +function lastCall(): [OpenAPIConfig, ApiRequestOptions] { + const call = mockRequest.mock.calls.at(-1); + if (!call) throw new Error('request() was never called'); + return call as unknown as [OpenAPIConfig, ApiRequestOptions]; +} + +function apiError(status: number, retryAfter?: number): ApiError { + return new ApiError( + { method: 'GET', url: 'videos/' }, + { + url: `${AMARA_API_BASE}/videos/`, + ok: false, + status, + statusText: 'Error', + body: { detail: 'failed' }, + }, + 'Amara request failed', + { retryAfter }, + ); +} + +beforeEach(() => { + mockRequest.mockReset(); +}); + +describe('makeAmaraRequest', () => { + it('sends the API key in the X-api-key header and never as a bearer token', async () => { + mockRequest.mockResolvedValue({ objects: [] }); + + await makeAmaraRequest('videos/', 'secret-key', { + query: { limit: 5 }, + }); + + const [config] = lastCall(); + expect(config.BASE).toBe(AMARA_API_BASE); + expect(config.HEADERS).toMatchObject({ 'X-api-key': 'secret-key' }); + expect(config.TOKEN).toBeUndefined(); + }); + + it('issues GET with path and query parameters', async () => { + mockRequest.mockResolvedValue({ objects: [] }); + + await makeAmaraRequest('videos/', 'k', { + query: { team: 'ability', limit: 10 }, + }); + + const [, options] = lastCall(); + expect(options.method).toBe('GET'); + expect(options.url).toBe('videos/'); + expect(options.query).toEqual({ team: 'ability', limit: 10 }); + }); + + it('issues POST with a JSON body', async () => { + mockRequest.mockResolvedValue({ id: 'abc' }); + + await makeAmaraRequest('videos/', 'k', { + method: 'POST', + body: { video_url: 'https://example.com/v.mp4', title: 'Hi' }, + }); + + const [, options] = lastCall(); + expect(options.method).toBe('POST'); + expect(options.body).toEqual({ + video_url: 'https://example.com/v.mp4', + title: 'Hi', + }); + }); + + it('normalises an empty DELETE body to { ok: true }', async () => { + mockRequest.mockResolvedValue(undefined); + + const result = await makeAmaraRequest('videos/x/urls/1/', 'k', { + method: 'DELETE', + }); + + expect(result).toEqual({ ok: true }); + }); + + it('wraps an ApiError in AmaraAPIError, preserving status and cause', async () => { + expect.assertions(4); + const original = apiError(429, 1500); + mockRequest.mockRejectedValue(original); + + try { + await makeAmaraRequest('videos/', 'k'); + throw new Error('expected makeAmaraRequest to throw'); + } catch (error) { + const amaraError = error as AmaraAPIError; + expect(amaraError.status).toBe(429); + expect(amaraError.code).toBe(429); + expect(amaraError.retryAfter).toBe(1500); + expect(amaraError.cause).toBe(original); + } + }); + + it('wraps a non-ApiError failure without inventing a status', async () => { + mockRequest.mockRejectedValue(new Error('socket hang up')); + + try { + await makeAmaraRequest('videos/', 'k'); + throw new Error('expected makeAmaraRequest to throw'); + } catch (error) { + const amaraError = error as AmaraAPIError; + expect(amaraError).toBeInstanceOf(AmaraAPIError); + expect(amaraError.message).toBe('socket hang up'); + expect(amaraError.status).toBeUndefined(); + } + }); +}); + +describe('AMARA_API_BASE', () => { + it('points at the official Amara API host', () => { + expect(AMARA_API_BASE).toBe('https://amara.org/api'); + }); +}); + +describe('encodeAmaraPathSegment', () => { + it('keeps id$ user identifiers literal while encoding unsafe chars', () => { + expect(encodeAmaraPathSegment('id$abc_123')).toBe('id$abc_123'); + expect(encodeAmaraPathSegment('a/b')).toBe('a%2Fb'); + }); +}); diff --git a/packages/amara/client.ts b/packages/amara/client.ts new file mode 100644 index 000000000..cf0cb0399 --- /dev/null +++ b/packages/amara/client.ts @@ -0,0 +1,123 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +/** + * Error thrown for every failed Amara API call. + * + * The originating `ApiError` is kept as `cause`, and its status/rate-limit + * fields are copied onto this error so `error-handlers.ts` can route 401/429 + * responses without needing an `instanceof ApiError` check. + */ +export class AmaraAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + public readonly body?: unknown; + public readonly retryAfter?: number; + public readonly rateLimitReset?: number; + public readonly rateLimitRemaining?: number; + public readonly rateLimitLimit?: number; + + constructor( + message: string, + public readonly code?: number, + options?: { cause?: Error }, + ) { + super(message, options); + this.name = 'AmaraAPIError'; + + if (options?.cause instanceof ApiError) { + this.status = options.cause.status; + this.statusText = options.cause.statusText; + this.body = options.cause.body; + this.retryAfter = options.cause.retryAfter; + this.rateLimitReset = options.cause.rateLimitReset; + this.rateLimitRemaining = options.cause.rateLimitRemaining; + this.rateLimitLimit = options.cause.rateLimitLimit; + } + } +} + +/** Docs: https://apidocs.amara.org/ */ +export const AMARA_API_BASE = 'https://amara.org/api'; + +export type AmaraQuery = Record; + +export type AmaraRequestOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: AmaraQuery; +}; + +/** + * Performs a request against the Amara API. + * + * Auth: the API key is sent in the `X-api-key` header. `TOKEN` is left unset + * so the request layer does not add an `Authorization: Bearer` header. + * + * DELETE (and some action POSTs) may return an empty body — those are + * normalised to `{ ok: true }`. + */ +export async function makeAmaraRequest( + endpoint: string, + apiKey: string, + options: AmaraRequestOptions = {}, +): Promise { + const { method = 'GET', body, query } = options; + const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH'; + + const config: OpenAPIConfig = { + BASE: AMARA_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + 'Content-Type': 'application/json', + 'X-api-key': apiKey, + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: isWrite ? body : undefined, + mediaType: isWrite ? 'application/json; charset=utf-8' : undefined, + query, + }; + + try { + const result = await request(config, requestOptions); + if (result === undefined || result === null || result === '') { + return { ok: true } as T; + } + return result; + } catch (error) { + if (error instanceof ApiError) { + throw new AmaraAPIError(error.message, error.status, { cause: error }); + } + if (error instanceof Error) { + throw new AmaraAPIError(error.message, undefined, { cause: error }); + } + throw new AmaraAPIError('Unknown error'); + } +} + +/** Drop undefined values so we don't send `?foo=undefined` query noise. */ +export function compactQuery(query: AmaraQuery): AmaraQuery { + const out: AmaraQuery = {}; + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) out[key] = value; + } + return out; +} + +/** + * Encode a path segment for Amara URLs. + * + * Amara user identifiers use an `id$…` prefix — `$` must stay literal in the + * path (docs: `/api/users/id$…/`). `encodeURIComponent` turns `$` into `%24`, + * which Amara 404s on, so we restore it after encoding. + */ +export function encodeAmaraPathSegment(value: string): string { + return encodeURIComponent(value).replace(/%24/gi, '$'); +} diff --git a/packages/amara/endpoints/activity.ts b/packages/amara/endpoints/activity.ts new file mode 100644 index 000000000..52193df58 --- /dev/null +++ b/packages/amara/endpoints/activity.ts @@ -0,0 +1,38 @@ +import { logEventFromContext } from 'corsair/core'; +import { compactQuery, makeAmaraRequest } from '../client'; +import type { AmaraEndpoints } from '../index'; +import { ActivityListResponseSchema, ActivitySchema } from './types'; + +export const list: AmaraEndpoints['activityList'] = async (ctx, input) => { + const raw = await makeAmaraRequest('activity/', ctx.key, { + query: compactQuery({ + team: input.team, + type: input.type, + after: input.after, + limit: input.limit, + video: input.video, + before: input.before, + offset: input.offset, + language: input.language, + team_activity: input.team_activity, + }), + }); + const response = ActivityListResponseSchema.parse(raw); + await logEventFromContext(ctx, 'amara.activity.list', {}, 'completed'); + return response; +}; + +export const get: AmaraEndpoints['activityGet'] = async (ctx, input) => { + const raw = await makeAmaraRequest( + `activity/${encodeURIComponent(String(input.activity_id))}/`, + ctx.key, + ); + const response = ActivitySchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.activity.get', + { activity_id: input.activity_id }, + 'completed', + ); + return response; +}; diff --git a/packages/amara/endpoints/handlers.test.ts b/packages/amara/endpoints/handlers.test.ts new file mode 100644 index 000000000..0d16a6713 --- /dev/null +++ b/packages/amara/endpoints/handlers.test.ts @@ -0,0 +1,329 @@ +import { makeAmaraRequest } from '../client'; +import type { AmaraContext } from '../index'; +import * as Activity from './activity'; +import * as Languages from './languages'; +import * as Messages from './messages'; +import * as Teams from './teams'; +import * as Users from './users'; +import * as Videos from './videos'; + +jest.mock('../client', () => ({ + makeAmaraRequest: jest.fn(), + compactQuery: jest.requireActual('../client').compactQuery, + encodeAmaraPathSegment: + jest.requireActual('../client').encodeAmaraPathSegment, +})); + +jest.mock('corsair/core', () => ({ + logEventFromContext: jest.fn().mockResolvedValue(null), +})); + +const mockRequest = makeAmaraRequest as jest.MockedFunction< + typeof makeAmaraRequest +>; + +function ctx(): AmaraContext { + return { key: 'k', options: {} } as unknown as AmaraContext; +} + +beforeEach(() => { + mockRequest.mockReset(); +}); + +const video = { + id: 'v1', + title: 't', + resource_uri: 'https://amara.org/api/videos/v1/', +}; +const url = { + id: 9, + url: 'https://example.com/v.mp4', + primary: true, + resource_uri: 'https://amara.org/api/videos/v1/urls/9/', +}; +const lang = { + language_code: 'en', + name: 'English', + resource_uri: 'https://amara.org/api/videos/v1/languages/en/', +}; +const subs = { + sub_format: 'json', + subtitles: [], + resource_uri: 'https://amara.org/api/videos/v1/languages/en/subtitles/', +}; +const note = { body: 'n', created: '2026-01-01T00:00:00Z' }; +const page = { meta: { total_count: 0 }, objects: [] }; +const activity = { + id: 1, + type: 1, + resource_uri: 'https://amara.org/api/activity/1/', +}; + +describe('all Amara endpoint handlers', () => { + it('videos.list / viewDetails / create / update', async () => { + mockRequest.mockResolvedValueOnce(page); + await Videos.list(ctx(), { limit: 2 }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/', 'k', { + query: { limit: 2 }, + }); + + mockRequest.mockResolvedValueOnce(video); + await Videos.viewDetails(ctx(), { video_id: 'v1' }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/', 'k'); + + mockRequest.mockResolvedValueOnce(video); + await Videos.create(ctx(), { + video_url: 'https://example.com/v.mp4', + title: 't', + }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/', 'k', { + method: 'POST', + body: { video_url: 'https://example.com/v.mp4', title: 't' }, + }); + + mockRequest.mockResolvedValueOnce(video); + await Videos.update(ctx(), { video_id: 'v1', title: 'u' }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/', 'k', { + method: 'PUT', + body: { title: 'u' }, + }); + }); + + it('videos urls: list / add / get / delete / makePrimary / getUrlDetails', async () => { + mockRequest.mockResolvedValueOnce({ objects: [url] }); + await Videos.listUrls(ctx(), { video_id: 'v1' }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/urls/', 'k', { + query: {}, + }); + + mockRequest.mockResolvedValueOnce(url); + await Videos.addUrl(ctx(), { + video_id: 'v1', + url: 'https://example.com/v.mp4', + primary: true, + }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/urls/', 'k', { + method: 'POST', + body: { url: 'https://example.com/v.mp4', primary: true }, + }); + + mockRequest.mockResolvedValueOnce(url); + await Videos.getUrl(ctx(), { video_id: 'v1', url_id: 9 }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/urls/9/', 'k'); + + mockRequest.mockResolvedValueOnce({ ok: true }); + await Videos.deleteUrl(ctx(), { video_id: 'v1', url_id: 9 }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/urls/9/', 'k', { + method: 'DELETE', + }); + + mockRequest.mockResolvedValueOnce(url); + await Videos.makeUrlPrimary(ctx(), { + video_id: 'v1', + url_id: 9, + primary: true, + }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/urls/9/', 'k', { + method: 'PUT', + body: { primary: true }, + }); + + mockRequest.mockResolvedValueOnce({ objects: [video] }); + await Videos.getUrlDetails(ctx(), { + url: 'https://example.com/v.mp4', + }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/', 'k', { + query: { video_url: 'https://example.com/v.mp4', limit: 1 }, + }); + }); + + it('videos subtitle languages / subtitles / actions / notes', async () => { + mockRequest.mockResolvedValueOnce({ objects: [lang] }); + await Videos.listSubtitleLanguages(ctx(), { video_id: 'v1' }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/languages/', 'k', { + query: {}, + }); + + mockRequest.mockResolvedValueOnce(lang); + await Videos.getSubtitleLanguageDetails(ctx(), { + video_id: 'v1', + language_code: 'en', + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/', + 'k', + ); + + mockRequest.mockResolvedValueOnce(lang); + await Videos.createSubtitleLanguage(ctx(), { + video_id: 'v1', + language_code: 'fr', + }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/languages/', 'k', { + method: 'POST', + body: { language_code: 'fr' }, + }); + + mockRequest.mockResolvedValueOnce(lang); + await Videos.updateSubtitleLanguage(ctx(), { + video_id: 'v1', + language_code: 'en', + subtitles_complete: true, + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/', + 'k', + { method: 'PUT', body: { subtitles_complete: true } }, + ); + + mockRequest.mockResolvedValueOnce(subs); + await Videos.fetchSubtitlesData(ctx(), { + video_id: 'v1', + language_code: 'en', + format: 'srt', + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/subtitles/', + 'k', + { query: { sub_format: 'srt' } }, + ); + + mockRequest.mockResolvedValueOnce(subs); + await Videos.createSubtitles(ctx(), { + video_id: 'v1', + language_code: 'en', + subtitles: '1\n00:00:00,000 --> 00:00:01,000\nhi\n', + sub_format: 'srt', + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/subtitles/', + 'k', + { + method: 'POST', + body: { + subtitles: '1\n00:00:00,000 --> 00:00:01,000\nhi\n', + sub_format: 'srt', + }, + }, + ); + + mockRequest.mockResolvedValueOnce([ + { action: 'publish', label: 'Publish' }, + ]); + await Videos.listSubtitleActions(ctx(), { + video_id: 'v1', + language_code: 'en', + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/subtitles/actions/', + 'k', + ); + + mockRequest.mockResolvedValueOnce({ ok: true }); + await Videos.performSubtitleAction(ctx(), { + video_id: 'v1', + language_code: 'en', + action: 'publish', + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/subtitles/actions/', + 'k', + { method: 'POST', body: { action: 'publish' } }, + ); + + mockRequest.mockResolvedValueOnce({ objects: [note] }); + await Videos.listSubtitleNotes(ctx(), { + video_id: 'v1', + language_code: 'en', + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/subtitles/notes/', + 'k', + { query: {} }, + ); + + mockRequest.mockResolvedValueOnce(note); + await Videos.addSubtitleNote(ctx(), { + video_id: 'v1', + language_code: 'en', + body: 'n', + }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'videos/v1/languages/en/subtitles/notes/', + 'k', + { method: 'POST', body: { body: 'n' } }, + ); + }); + + it('videos.listActivity', async () => { + mockRequest.mockResolvedValueOnce({ objects: [activity] }); + await Videos.listActivity(ctx(), { video_id: 'v1', limit: 1 }); + expect(mockRequest).toHaveBeenLastCalledWith('videos/v1/activity/', 'k', { + query: { limit: 1 }, + }); + }); + + it('users / teams / activity / languages / messages', async () => { + mockRequest.mockResolvedValueOnce({ + id: 'u1', + username: 'me', + resource_uri: 'https://amara.org/api/users/id$u1/', + }); + await Users.getData(ctx(), { identifier: 'id$u1' }); + expect(mockRequest).toHaveBeenLastCalledWith('users/id$u1/', 'k'); + + mockRequest.mockResolvedValueOnce({ objects: [activity] }); + await Users.getActivity(ctx(), { identifier: 'id$u1', limit: 2 }); + expect(mockRequest).toHaveBeenLastCalledWith('users/id$u1/activity/', 'k', { + query: { limit: 2 }, + }); + + mockRequest.mockResolvedValueOnce({ objects: [{ slug: 'ability' }] }); + await Teams.list(ctx(), { limit: 1 }); + expect(mockRequest).toHaveBeenLastCalledWith('teams/', 'k', { + query: { limit: 1 }, + }); + + mockRequest.mockResolvedValueOnce({ + slug: 'ability', + name: 'ABILITY Magazine', + }); + await Teams.getDetails(ctx(), { slug: 'ability' }); + expect(mockRequest).toHaveBeenLastCalledWith('teams/ability/', 'k'); + + mockRequest.mockResolvedValueOnce({ + preferred: 'https://amara.org/api/teams/ability/languages/preferred/', + blacklisted: 'https://amara.org/api/teams/ability/languages/blacklisted/', + }); + await Teams.getLanguages(ctx(), { slug: 'ability' }); + expect(mockRequest).toHaveBeenLastCalledWith( + 'teams/ability/languages/', + 'k', + ); + + mockRequest.mockResolvedValueOnce({ objects: [activity] }); + await Activity.list(ctx(), { limit: 1, type: 9 }); + expect(mockRequest).toHaveBeenLastCalledWith('activity/', 'k', { + query: { limit: 1, type: 9 }, + }); + + mockRequest.mockResolvedValueOnce(activity); + await Activity.get(ctx(), { activity_id: 1 }); + expect(mockRequest).toHaveBeenLastCalledWith('activity/1/', 'k'); + + mockRequest.mockResolvedValueOnce({ languages: { en: 'English' } }); + await Languages.listAvailable(ctx(), {}); + expect(mockRequest).toHaveBeenLastCalledWith('languages/', 'k'); + + mockRequest.mockResolvedValueOnce({}); + await Messages.send(ctx(), { + subject: 'Hi', + content: 'Hello', + user: 'alice', + }); + expect(mockRequest).toHaveBeenLastCalledWith('message/', 'k', { + method: 'POST', + body: { subject: 'Hi', content: 'Hello', user: 'alice' }, + }); + }); +}); diff --git a/packages/amara/endpoints/index.ts b/packages/amara/endpoints/index.ts new file mode 100644 index 000000000..4f14fbf74 --- /dev/null +++ b/packages/amara/endpoints/index.ts @@ -0,0 +1,56 @@ +import * as Activity from './activity'; +import * as Languages from './languages'; +import * as Messages from './messages'; +import * as Teams from './teams'; +import * as Users from './users'; +import * as Videos from './videos'; + +export const VideosEndpoints = { + list: Videos.list, + viewDetails: Videos.viewDetails, + create: Videos.create, + update: Videos.update, + listActivity: Videos.listActivity, + listUrls: Videos.listUrls, + addUrl: Videos.addUrl, + getUrl: Videos.getUrl, + deleteUrl: Videos.deleteUrl, + makeUrlPrimary: Videos.makeUrlPrimary, + getUrlDetails: Videos.getUrlDetails, + listSubtitleLanguages: Videos.listSubtitleLanguages, + getSubtitleLanguageDetails: Videos.getSubtitleLanguageDetails, + createSubtitleLanguage: Videos.createSubtitleLanguage, + updateSubtitleLanguage: Videos.updateSubtitleLanguage, + fetchSubtitlesData: Videos.fetchSubtitlesData, + createSubtitles: Videos.createSubtitles, + listSubtitleActions: Videos.listSubtitleActions, + performSubtitleAction: Videos.performSubtitleAction, + listSubtitleNotes: Videos.listSubtitleNotes, + addSubtitleNote: Videos.addSubtitleNote, +}; + +export const UsersEndpoints = { + getData: Users.getData, + getActivity: Users.getActivity, +}; + +export const TeamsEndpoints = { + list: Teams.list, + getDetails: Teams.getDetails, + getLanguages: Teams.getLanguages, +}; + +export const ActivityEndpoints = { + list: Activity.list, + get: Activity.get, +}; + +export const LanguagesEndpoints = { + listAvailable: Languages.listAvailable, +}; + +export const MessagesEndpoints = { + send: Messages.send, +}; + +export * from './types'; diff --git a/packages/amara/endpoints/languages.ts b/packages/amara/endpoints/languages.ts new file mode 100644 index 000000000..69f9346df --- /dev/null +++ b/packages/amara/endpoints/languages.ts @@ -0,0 +1,19 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAmaraRequest } from '../client'; +import type { AmaraEndpoints } from '../index'; +import { LanguagesListResponseSchema } from './types'; + +export const listAvailable: AmaraEndpoints['languagesListAvailable'] = async ( + ctx, + _input, +) => { + const raw = await makeAmaraRequest('languages/', ctx.key); + const response = LanguagesListResponseSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.languages.listAvailable', + {}, + 'completed', + ); + return response; +}; diff --git a/packages/amara/endpoints/messages.ts b/packages/amara/endpoints/messages.ts new file mode 100644 index 000000000..69a026db8 --- /dev/null +++ b/packages/amara/endpoints/messages.ts @@ -0,0 +1,21 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAmaraRequest } from '../client'; +import type { AmaraEndpoints } from '../index'; +import { MessageSendResponseSchema } from './types'; + +export const send: AmaraEndpoints['messagesSend'] = async (ctx, input) => { + const body: Record = { + subject: input.subject, + content: input.content, + }; + if (input.user !== undefined) body.user = input.user; + if (input.team !== undefined) body.team = input.team; + + const raw = await makeAmaraRequest('message/', ctx.key, { + method: 'POST', + body, + }); + const response = MessageSendResponseSchema.parse(raw); + await logEventFromContext(ctx, 'amara.messages.send', {}, 'completed'); + return response; +}; diff --git a/packages/amara/endpoints/teams.ts b/packages/amara/endpoints/teams.ts new file mode 100644 index 000000000..83bd94c61 --- /dev/null +++ b/packages/amara/endpoints/teams.ts @@ -0,0 +1,60 @@ +import { logEventFromContext } from 'corsair/core'; +import { + compactQuery, + encodeAmaraPathSegment, + makeAmaraRequest, +} from '../client'; +import type { AmaraEndpoints } from '../index'; +import { + TeamLanguagesSchema, + TeamListResponseSchema, + TeamSchema, +} from './types'; + +export const list: AmaraEndpoints['teamsList'] = async (ctx, input) => { + const raw = await makeAmaraRequest('teams/', ctx.key, { + query: compactQuery({ + limit: input.limit, + offset: input.offset, + }), + }); + const response = TeamListResponseSchema.parse(raw); + await logEventFromContext(ctx, 'amara.teams.list', {}, 'completed'); + return response; +}; + +export const getDetails: AmaraEndpoints['teamsGetDetails'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `teams/${encodeAmaraPathSegment(input.slug)}/`, + ctx.key, + ); + const response = TeamSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.teams.getDetails', + { slug: input.slug }, + 'completed', + ); + return response; +}; + +export const getLanguages: AmaraEndpoints['teamsGetLanguages'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `teams/${encodeAmaraPathSegment(input.slug)}/languages/`, + ctx.key, + ); + const response = TeamLanguagesSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.teams.getLanguages', + { slug: input.slug }, + 'completed', + ); + return response; +}; diff --git a/packages/amara/endpoints/types.test.ts b/packages/amara/endpoints/types.test.ts new file mode 100644 index 000000000..bd0577d1d --- /dev/null +++ b/packages/amara/endpoints/types.test.ts @@ -0,0 +1,227 @@ +import { + ActivitySchema, + AmaraEndpointInputSchemas, + AmaraEndpointOutputSchemas, + LanguagesListResponseSchema, + SubtitlesResourceSchema, + TeamSchema, + UserSchema, + VideoListResponseSchema, + VideoSchema, +} from './types'; + +describe('input schemas', () => { + it('requires video_url and title when creating a video', () => { + expect( + AmaraEndpointInputSchemas.videosCreate.safeParse({ + video_url: 'https://example.com/v.mp4', + title: 'Meet Amara', + }).success, + ).toBe(true); + expect( + AmaraEndpointInputSchemas.videosCreate.safeParse({ + title: 'Meet Amara', + }).success, + ).toBe(false); + expect( + AmaraEndpointInputSchemas.videosCreate.safeParse({ + video_url: 'https://example.com/v.mp4', + }).success, + ).toBe(false); + }); + + it('requires exactly one of user or team for messages.send', () => { + expect( + AmaraEndpointInputSchemas.messagesSend.safeParse({ + subject: 'Hi', + content: 'Hello', + user: 'alice', + }).success, + ).toBe(true); + expect( + AmaraEndpointInputSchemas.messagesSend.safeParse({ + subject: 'Hi', + content: 'Hello', + team: 'ability', + }).success, + ).toBe(true); + expect( + AmaraEndpointInputSchemas.messagesSend.safeParse({ + subject: 'Hi', + content: 'Hello', + }).success, + ).toBe(false); + expect( + AmaraEndpointInputSchemas.messagesSend.safeParse({ + subject: 'Hi', + content: 'Hello', + user: 'alice', + team: 'ability', + }).success, + ).toBe(false); + }); + + it('requires video_id for viewDetails', () => { + expect( + AmaraEndpointInputSchemas.videosViewDetails.safeParse({ + video_id: 'vkMyJ7Ty7JgJ', + }).success, + ).toBe(true); + expect( + AmaraEndpointInputSchemas.videosViewDetails.safeParse({}).success, + ).toBe(false); + }); +}); + +describe('output schemas — live Amara shapes', () => { + it('parses a video list page', () => { + const parsed = VideoListResponseSchema.parse({ + meta: { + previous: null, + next: null, + offset: 0, + limit: 20, + total_count: 1, + }, + objects: [ + { + id: 'NI1hLjBxuTpk', + video_type: 'Y', + primary_audio_language_code: 'en', + title: 'SBIE 2019 Stage 1', + description: '', + duration: 421, + thumbnail: 'https://example.com/t.jpg', + created: '2019-01-25T21:42:59Z', + team: null, + project: null, + all_urls: ['http://www.youtube.com/watch?v=o0vnlylsQwc'], + metadata: {}, + languages: [ + { + code: 'en', + name: 'English', + published: false, + dir: 'ltr', + resource_uri: + 'https://amara.org/api/videos/NI1hLjBxuTpk/languages/en/', + subtitles_uri: + 'https://amara.org/api/videos/NI1hLjBxuTpk/languages/en/subtitles/', + }, + ], + activity_uri: 'https://amara.org/api/videos/NI1hLjBxuTpk/activity/', + urls_uri: 'https://amara.org/api/videos/NI1hLjBxuTpk/urls/', + subtitle_languages_uri: + 'https://amara.org/api/videos/NI1hLjBxuTpk/languages/', + resource_uri: 'https://amara.org/api/videos/NI1hLjBxuTpk/', + }, + ], + }); + + expect(parsed.meta?.total_count).toBe(1); + expect(parsed.objects?.[0]?.id).toBe('NI1hLjBxuTpk'); + expect(parsed.objects?.[0]?.team).toBeNull(); + }); + + it('parses a video detail object', () => { + const parsed = VideoSchema.parse({ + id: 'vkMyJ7Ty7JgJ', + title: 'Meet Amara', + team: null, + project: null, + all_urls: ['https://www.youtube.com/watch?v=aQ-xe-GSjdA'], + languages: [], + metadata: {}, + }); + + expect(parsed.id).toBe('vkMyJ7Ty7JgJ'); + expect(parsed.title).toBe('Meet Amara'); + }); + + it('parses a subtitles resource with cue array', () => { + const parsed = SubtitlesResourceSchema.parse({ + version_number: 1, + sub_format: 'json', + subtitles: [ + { + start: 830, + end: 3153, + text: 'Amara makes video globally accessible', + position: 1, + meta: { new_paragraph: true, region: null }, + }, + ], + author: { + id: 'jGvdcg-jNoGc32ySWS13tD-Q4SM_0sb8-rU61IYh66o', + uri: 'https://amara.org/api/users/id$jGvdcg/', + }, + language: { code: 'en', dir: 'ltr', name: 'English' }, + title: 'Meet Amara', + actions_uri: + 'https://amara.org/api/videos/x/languages/en/subtitles/actions/', + }); + + expect(parsed.sub_format).toBe('json'); + expect(Array.isArray(parsed.subtitles)).toBe(true); + expect((parsed.subtitles as { text: string }[])[0]?.text).toContain( + 'Amara', + ); + }); + + it('parses user, team, languages, and activity fixtures', () => { + expect( + UserSchema.parse({ + id: '67oVmk', + username: 'bendk', + full_name: '', + languages: ['en', 'fr'], + num_videos: 4, + is_partner: false, + created_by: null, + }).username, + ).toBe('bendk'); + + expect( + TeamSchema.parse({ + name: 'ABILITY Magazine', + slug: 'ability', + type: 'default', + is_visible: true, + languages_uri: 'https://amara.org/api/teams/ability/languages/', + applications_uri: null, + tasks_uri: null, + }).slug, + ).toBe('ability'); + + expect( + LanguagesListResponseSchema.parse({ + languages: { en: 'English', fr: 'French' }, + }).languages.en, + ).toBe('English'); + + expect( + ActivitySchema.parse({ + id: 248188, + type: 4, + type_name: 'version-added', + created: '2019-01-29T22:22:48Z', + video: 'K1TIDcoIxhir', + video_uri: 'https://amara.org/api/videos/K1TIDcoIxhir/', + language: 'fr', + language_url: 'https://amara.org/api/videos/K1TIDcoIxhir/languages/fr/', + user: { + id: '67oVmk', + username: 'bendk', + uri: 'https://amara.org/api/users/id$67oVmk/', + }, + comment: null, + new_video_title: null, + resource_uri: 'https://amara.org/api/activity/248188/', + }).type_name, + ).toBe('version-added'); + + expect( + AmaraEndpointOutputSchemas.videosDeleteUrl.parse({ ok: true }).ok, + ).toBe(true); + }); +}); diff --git a/packages/amara/endpoints/types.ts b/packages/amara/endpoints/types.ts new file mode 100644 index 000000000..b54e16b2f --- /dev/null +++ b/packages/amara/endpoints/types.ts @@ -0,0 +1,760 @@ +import { z } from 'zod'; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared response shapes (official Amara API — permissive where fields null) +// ───────────────────────────────────────────────────────────────────────────── + +export const PaginationMetaSchema = z + .object({ + previous: z.string().nullable().optional(), + next: z.string().nullable().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + total_count: z.number().optional(), + }) + .loose(); + +export type PaginationMeta = z.infer; + +const PaginationInput = { + limit: z.number().int().positive().optional().describe('Page size'), + offset: z.number().int().nonnegative().optional().describe('Result offset'), +}; + +export const VideoLanguageSummarySchema = z + .object({ + code: z.string().optional(), + name: z.string().optional(), + published: z.boolean().optional(), + dir: z.string().nullable().optional(), + subtitles_uri: z.string().optional(), + resource_uri: z.string().optional(), + }) + .loose(); + +export const VideoSchema = z + .object({ + id: z.string(), + video_type: z.string().nullable().optional(), + primary_audio_language_code: z.string().nullable().optional(), + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + duration: z.number().nullable().optional(), + thumbnail: z.string().nullable().optional(), + created: z.string().nullable().optional(), + team: z.string().nullable().optional(), + project: z.string().nullable().optional(), + all_urls: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + languages: z.array(VideoLanguageSummarySchema).optional(), + activity_uri: z.string().optional(), + urls_uri: z.string().optional(), + subtitle_languages_uri: z.string().optional(), + resource_uri: z.string().optional(), + }) + .loose(); + +export type Video = z.infer; + +export const VideoListResponseSchema = z + .object({ + meta: PaginationMetaSchema.optional(), + objects: z.array(VideoSchema).optional(), + }) + .loose(); + +export type VideoListResponse = z.infer; + +export const VideoUrlSchema = z + .object({ + created: z.string().nullable().optional(), + url: z.string().optional(), + primary: z.boolean().optional(), + original: z.boolean().optional(), + id: z.number().optional(), + resource_uri: z.string().optional(), + videoid: z.string().nullable().optional(), + type: z.string().nullable().optional(), + }) + .loose(); + +export type VideoUrl = z.infer; + +export const VideoUrlListResponseSchema = z + .object({ + meta: PaginationMetaSchema.optional(), + objects: z.array(VideoUrlSchema).optional(), + }) + .loose(); + +export type VideoUrlListResponse = z.infer; + +export const SubtitleVersionSchema = z + .object({ + author: z + .object({ + id: z.string().optional(), + username: z.string().optional(), + uri: z.string().optional(), + }) + .loose() + .nullable() + .optional(), + published: z.boolean().optional(), + version_number: z.number().optional(), + created: z.string().nullable().optional(), + }) + .loose(); + +export const SubtitleLanguageSchema = z + .object({ + created: z.string().nullable().optional(), + language_code: z.string().optional(), + is_primary_audio_language: z.boolean().optional(), + is_rtl: z.boolean().optional(), + soft_limit_cpl: z.number().nullable().optional(), + soft_limit_cps: z.number().nullable().optional(), + soft_limit_lines: z.number().nullable().optional(), + soft_limit_max_duration: z.number().nullable().optional(), + soft_limit_min_duration: z.number().nullable().optional(), + published: z.boolean().optional(), + name: z.string().nullable().optional(), + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + subtitle_count: z.number().optional(), + subtitles_complete: z.boolean().optional(), + versions: z.array(SubtitleVersionSchema).optional(), + subtitles_uri: z.string().optional(), + resource_uri: z.string().optional(), + }) + .loose(); + +export type SubtitleLanguage = z.infer; + +export const SubtitleLanguageListResponseSchema = z + .object({ + meta: PaginationMetaSchema.optional(), + objects: z.array(SubtitleLanguageSchema).optional(), + }) + .loose(); + +export type SubtitleLanguageListResponse = z.infer< + typeof SubtitleLanguageListResponseSchema +>; + +export const SubtitleCueSchema = z + .object({ + start: z.number().optional(), + end: z.number().optional(), + text: z.string().optional(), + meta: z.record(z.string(), z.unknown()).optional(), + position: z.number().optional(), + }) + .loose(); + +export const SubtitlesResourceSchema = z + .object({ + version_number: z.number().nullable().optional(), + sub_format: z.string().nullable().optional(), + subtitles: z + .union([z.array(SubtitleCueSchema), z.string()]) + .nullable() + .optional(), + author: z + .object({ + id: z.string().optional(), + username: z.string().optional(), + uri: z.string().optional(), + }) + .loose() + .nullable() + .optional(), + created: z.string().nullable().optional(), + description: z.string().nullable().optional(), + language: z + .object({ + code: z.string().optional(), + dir: z.string().nullable().optional(), + name: z.string().optional(), + }) + .loose() + .optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + notes_uri: z.string().optional(), + resource_uri: z.string().optional(), + site_uri: z.string().optional(), + title: z.string().nullable().optional(), + video_description: z.string().nullable().optional(), + video_title: z.string().nullable().optional(), + actions_uri: z.string().optional(), + }) + .loose(); + +export type SubtitlesResource = z.infer; + +export const SubtitleActionSchema = z + .object({ + action: z.string(), + label: z.string().optional(), + complete: z.boolean().nullable().optional(), + }) + .loose(); + +export const SubtitleActionsListSchema = z.array(SubtitleActionSchema); + +export type SubtitleActionsList = z.infer; + +export const SubtitleNoteSchema = z + .object({ + body: z.string().optional(), + created: z.string().nullable().optional(), + user: z + .object({ + id: z.string().optional(), + username: z.string().optional(), + uri: z.string().optional(), + }) + .loose() + .nullable() + .optional(), + }) + .loose(); + +export const SubtitleNotesListResponseSchema = z + .object({ + meta: PaginationMetaSchema.optional(), + objects: z.array(SubtitleNoteSchema).optional(), + }) + .loose(); + +export type SubtitleNotesListResponse = z.infer< + typeof SubtitleNotesListResponseSchema +>; + +export const ActivityUserSchema = z + .object({ + id: z.string().optional(), + username: z.string().optional(), + uri: z.string().optional(), + }) + .loose(); + +export const ActivitySchema = z + .object({ + id: z.union([z.number(), z.string()]).optional(), + type: z.union([z.number(), z.string()]).optional(), + type_name: z.string().optional(), + created: z.string().nullable().optional(), + video: z.string().nullable().optional(), + video_uri: z.string().nullable().optional(), + language: z.string().nullable().optional(), + language_url: z.string().nullable().optional(), + // Live probes return a user object; legacy list responses may use a string id. + user: z.union([ActivityUserSchema, z.string()]).nullable().optional(), + comment: z.string().nullable().optional(), + new_video_title: z.string().nullable().optional(), + resource_uri: z.string().optional(), + }) + .loose(); + +export type Activity = z.infer; + +export const ActivityListResponseSchema = z + .object({ + meta: PaginationMetaSchema.optional(), + objects: z.array(ActivitySchema).optional(), + }) + .loose(); + +export type ActivityListResponse = z.infer; + +export const UserSchema = z + .object({ + username: z.string().nullable().optional(), + id: z.string().optional(), + full_name: z.string().nullable().optional(), + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + biography: z.string().nullable().optional(), + homepage: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + languages: z.array(z.string()).optional(), + num_videos: z.number().optional(), + resource_uri: z.string().optional(), + created_by: z.string().nullable().optional(), + is_partner: z.boolean().optional(), + activity_uri: z.string().optional(), + }) + .loose(); + +export type User = z.infer; + +export const TeamSchema = z + .object({ + name: z.string().optional(), + slug: z.string().optional(), + type: z.string().nullable().optional(), + description: z.string().nullable().optional(), + team_visibility: z.string().nullable().optional(), + video_visibility: z.string().nullable().optional(), + is_visible: z.boolean().optional(), + membership_policy: z.string().nullable().optional(), + video_policy: z.string().nullable().optional(), + activity_uri: z.string().optional(), + members_uri: z.string().optional(), + projects_uri: z.string().optional(), + applications_uri: z.string().nullable().optional(), + languages_uri: z.string().nullable().optional(), + tasks_uri: z.string().nullable().optional(), + resource_uri: z.string().optional(), + }) + .loose(); + +export type Team = z.infer; + +export const TeamListResponseSchema = z + .object({ + meta: PaginationMetaSchema.optional(), + objects: z.array(TeamSchema).optional(), + }) + .loose(); + +export type TeamListResponse = z.infer; + +export const TeamLanguagesSchema = z + .object({ + preferred: z.string().optional(), + blacklisted: z.string().optional(), + }) + .loose(); + +export type TeamLanguages = z.infer; + +export const LanguagesListResponseSchema = z + .object({ + languages: z.record(z.string(), z.string()), + }) + .loose(); + +export type LanguagesListResponse = z.infer; + +/** Empty DELETE / action responses normalised by the client to `{ ok: true }`. */ +export const EmptyOkSchema = z + .object({ + ok: z.literal(true).optional(), + }) + .loose(); + +export type EmptyOk = z.infer; + +export const MessageSendResponseSchema = z.object({}).loose(); + +export type MessageSendResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Videos — inputs +// ───────────────────────────────────────────────────────────────────────────── + +export const VideosListInputSchema = z.object({ + sort: z.string().optional().describe('List ordering (mapped to order_by)'), + team: z.string().optional(), + limit: PaginationInput.limit, + owner: z.string().optional(), + offset: PaginationInput.offset, + archive: z.union([z.string(), z.boolean()]).optional(), + project: z.string().optional(), + language: z.string().optional(), + video_id: z.string().optional(), + video_url: z.string().optional(), +}); +export type VideosListInput = z.infer; + +export const VideosViewDetailsInputSchema = z.object({ + video_id: z.string().min(1), +}); +export type VideosViewDetailsInput = z.infer< + typeof VideosViewDetailsInputSchema +>; + +export const VideosCreateInputSchema = z.object({ + video_url: z.string().min(1), + title: z.string().min(1), + team: z.string().optional(), + project: z.string().optional(), + duration: z.number().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + thumbnail: z.string().optional(), + description: z.string().optional(), + primary_audio_language_code: z.string().optional(), +}); +export type VideosCreateInput = z.infer; + +export const VideosUpdateInputSchema = z.object({ + video_id: z.string().min(1), + title: z.string().optional(), + description: z.string().optional(), + duration: z.number().optional(), + team: z.string().nullable().optional(), + project: z.string().nullable().optional(), + thumbnail: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + primary_audio_language_code: z.string().optional(), +}); +export type VideosUpdateInput = z.infer; + +export const VideosListActivityInputSchema = z.object({ + video_id: z.string().min(1), + ...PaginationInput, +}); +export type VideosListActivityInput = z.infer< + typeof VideosListActivityInputSchema +>; + +export const VideosListUrlsInputSchema = z.object({ + video_id: z.string().min(1), + ...PaginationInput, +}); +export type VideosListUrlsInput = z.infer; + +export const VideosAddUrlInputSchema = z.object({ + video_id: z.string().min(1), + url: z.string().min(1), + primary: z.boolean().optional(), +}); +export type VideosAddUrlInput = z.infer; + +export const VideosGetUrlInputSchema = z.object({ + video_id: z.string().min(1), + url_id: z.union([z.string(), z.number()]), +}); +export type VideosGetUrlInput = z.infer; + +export const VideosDeleteUrlInputSchema = VideosGetUrlInputSchema; +export type VideosDeleteUrlInput = z.infer; + +export const VideosMakeUrlPrimaryInputSchema = z.object({ + video_id: z.string().min(1), + url_id: z.union([z.string(), z.number()]), + primary: z.boolean(), +}); +export type VideosMakeUrlPrimaryInput = z.infer< + typeof VideosMakeUrlPrimaryInputSchema +>; + +export const VideosGetUrlDetailsInputSchema = z.object({ + url: z.string().min(1).describe('Public video URL to look up'), +}); +export type VideosGetUrlDetailsInput = z.infer< + typeof VideosGetUrlDetailsInputSchema +>; + +export const VideosListSubtitleLanguagesInputSchema = z.object({ + video_id: z.string().min(1), + ...PaginationInput, +}); +export type VideosListSubtitleLanguagesInput = z.infer< + typeof VideosListSubtitleLanguagesInputSchema +>; + +export const VideosGetSubtitleLanguageDetailsInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), +}); +export type VideosGetSubtitleLanguageDetailsInput = z.infer< + typeof VideosGetSubtitleLanguageDetailsInputSchema +>; + +export const VideosCreateSubtitleLanguageInputSchema = z.object({ + video_id: z.string().min(1), + /** Official Amara field (docs use `language_code`, not Composio's `language`). */ + language_code: z.string().min(1), + is_primary_audio_language: z.boolean().optional(), + soft_limit_cpl: z.number().nullable().optional(), + soft_limit_cps: z.number().nullable().optional(), + soft_limit_lines: z.number().nullable().optional(), + soft_limit_max_duration: z.number().nullable().optional(), + soft_limit_min_duration: z.number().nullable().optional(), + subtitles_complete: z.boolean().optional(), +}); +export type VideosCreateSubtitleLanguageInput = z.infer< + typeof VideosCreateSubtitleLanguageInputSchema +>; + +export const VideosUpdateSubtitleLanguageInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), + is_primary_audio_language: z.boolean().optional(), + soft_limit_cpl: z.number().nullable().optional(), + soft_limit_cps: z.number().nullable().optional(), + soft_limit_lines: z.number().nullable().optional(), + soft_limit_max_duration: z.number().nullable().optional(), + soft_limit_min_duration: z.number().nullable().optional(), + subtitles_complete: z.boolean().optional(), +}); +export type VideosUpdateSubtitleLanguageInput = z.infer< + typeof VideosUpdateSubtitleLanguageInputSchema +>; + +export const VideosFetchSubtitlesDataInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), + format: z.string().optional().describe('Subtitle format (default json)'), + sub_format: z.string().optional().describe('Alias for format'), +}); +export type VideosFetchSubtitlesDataInput = z.infer< + typeof VideosFetchSubtitlesDataInputSchema +>; + +export const VideosCreateSubtitlesInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), + title: z.string().optional(), + action: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + subtitles: z.union([z.string(), z.array(z.unknown())]).optional(), + sub_format: z.string().optional(), + description: z.string().optional(), + subtitles_url: z.string().optional(), +}); +export type VideosCreateSubtitlesInput = z.infer< + typeof VideosCreateSubtitlesInputSchema +>; + +export const VideosListSubtitleActionsInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), +}); +export type VideosListSubtitleActionsInput = z.infer< + typeof VideosListSubtitleActionsInputSchema +>; + +export const VideosPerformSubtitleActionInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), + action: z.string().min(1), +}); +export type VideosPerformSubtitleActionInput = z.infer< + typeof VideosPerformSubtitleActionInputSchema +>; + +export const VideosListSubtitleNotesInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), + ...PaginationInput, +}); +export type VideosListSubtitleNotesInput = z.infer< + typeof VideosListSubtitleNotesInputSchema +>; + +export const VideosAddSubtitleNoteInputSchema = z.object({ + video_id: z.string().min(1), + language_code: z.string().min(1), + body: z.string().min(1), +}); +export type VideosAddSubtitleNoteInput = z.infer< + typeof VideosAddSubtitleNoteInputSchema +>; + +// ───────────────────────────────────────────────────────────────────────────── +// Users / teams / activity / languages / messages +// ───────────────────────────────────────────────────────────────────────────── + +export const UsersGetDataInputSchema = z.object({ + identifier: z.string().min(1).describe('Username, id$…, or "me"'), +}); +export type UsersGetDataInput = z.infer; + +export const UsersGetActivityInputSchema = z.object({ + identifier: z.string().min(1), + ...PaginationInput, +}); +export type UsersGetActivityInput = z.infer; + +export const TeamsListInputSchema = z.object({ + ...PaginationInput, +}); +export type TeamsListInput = z.infer; + +export const TeamsGetDetailsInputSchema = z.object({ + slug: z.string().min(1), +}); +export type TeamsGetDetailsInput = z.infer; + +export const TeamsGetLanguagesInputSchema = z.object({ + slug: z.string().min(1), +}); +export type TeamsGetLanguagesInput = z.infer< + typeof TeamsGetLanguagesInputSchema +>; + +export const ActivityListInputSchema = z.object({ + team: z.string().optional(), + type: z.union([z.string(), z.number()]).optional(), + after: z.string().optional(), + limit: PaginationInput.limit, + video: z.string().optional(), + before: z.string().optional(), + offset: PaginationInput.offset, + language: z.string().optional(), + team_activity: z.union([z.string(), z.boolean()]).optional(), +}); +export type ActivityListInput = z.infer; + +export const ActivityGetInputSchema = z.object({ + activity_id: z.union([z.string(), z.number()]), +}); +export type ActivityGetInput = z.infer; + +export const LanguagesListAvailableInputSchema = z.object({}); +export type LanguagesListAvailableInput = z.infer< + typeof LanguagesListAvailableInputSchema +>; + +export const MessagesSendInputSchema = z + .object({ + subject: z.string().min(1), + content: z.string().min(1), + user: z.string().optional(), + team: z.string().optional(), + }) + .refine((v) => (v.user !== undefined) !== (v.team !== undefined), { + message: 'Provide exactly one of user or team', + }); +export type MessagesSendInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint input/output maps +// ───────────────────────────────────────────────────────────────────────────── + +export type AmaraEndpointInputs = { + videosList: VideosListInput; + videosViewDetails: VideosViewDetailsInput; + videosCreate: VideosCreateInput; + videosUpdate: VideosUpdateInput; + videosListActivity: VideosListActivityInput; + videosListUrls: VideosListUrlsInput; + videosAddUrl: VideosAddUrlInput; + videosGetUrl: VideosGetUrlInput; + videosDeleteUrl: VideosDeleteUrlInput; + videosMakeUrlPrimary: VideosMakeUrlPrimaryInput; + videosGetUrlDetails: VideosGetUrlDetailsInput; + videosListSubtitleLanguages: VideosListSubtitleLanguagesInput; + videosGetSubtitleLanguageDetails: VideosGetSubtitleLanguageDetailsInput; + videosCreateSubtitleLanguage: VideosCreateSubtitleLanguageInput; + videosUpdateSubtitleLanguage: VideosUpdateSubtitleLanguageInput; + videosFetchSubtitlesData: VideosFetchSubtitlesDataInput; + videosCreateSubtitles: VideosCreateSubtitlesInput; + videosListSubtitleActions: VideosListSubtitleActionsInput; + videosPerformSubtitleAction: VideosPerformSubtitleActionInput; + videosListSubtitleNotes: VideosListSubtitleNotesInput; + videosAddSubtitleNote: VideosAddSubtitleNoteInput; + usersGetData: UsersGetDataInput; + usersGetActivity: UsersGetActivityInput; + teamsList: TeamsListInput; + teamsGetDetails: TeamsGetDetailsInput; + teamsGetLanguages: TeamsGetLanguagesInput; + activityList: ActivityListInput; + activityGet: ActivityGetInput; + languagesListAvailable: LanguagesListAvailableInput; + messagesSend: MessagesSendInput; +}; + +export type AmaraEndpointOutputs = { + videosList: VideoListResponse; + videosViewDetails: Video; + videosCreate: Video; + videosUpdate: Video; + videosListActivity: ActivityListResponse; + videosListUrls: VideoUrlListResponse; + videosAddUrl: VideoUrl; + videosGetUrl: VideoUrl; + videosDeleteUrl: EmptyOk; + videosMakeUrlPrimary: VideoUrl; + videosGetUrlDetails: VideoListResponse; + videosListSubtitleLanguages: SubtitleLanguageListResponse; + videosGetSubtitleLanguageDetails: SubtitleLanguage; + videosCreateSubtitleLanguage: SubtitleLanguage; + videosUpdateSubtitleLanguage: SubtitleLanguage; + videosFetchSubtitlesData: SubtitlesResource; + videosCreateSubtitles: SubtitlesResource; + videosListSubtitleActions: SubtitleActionsList; + videosPerformSubtitleAction: EmptyOk; + videosListSubtitleNotes: SubtitleNotesListResponse; + videosAddSubtitleNote: z.infer; + usersGetData: User; + usersGetActivity: ActivityListResponse; + teamsList: TeamListResponse; + teamsGetDetails: Team; + teamsGetLanguages: TeamLanguages; + activityList: ActivityListResponse; + activityGet: Activity; + languagesListAvailable: LanguagesListResponse; + messagesSend: MessageSendResponse; +}; + +export const AmaraEndpointInputSchemas = { + videosList: VideosListInputSchema, + videosViewDetails: VideosViewDetailsInputSchema, + videosCreate: VideosCreateInputSchema, + videosUpdate: VideosUpdateInputSchema, + videosListActivity: VideosListActivityInputSchema, + videosListUrls: VideosListUrlsInputSchema, + videosAddUrl: VideosAddUrlInputSchema, + videosGetUrl: VideosGetUrlInputSchema, + videosDeleteUrl: VideosDeleteUrlInputSchema, + videosMakeUrlPrimary: VideosMakeUrlPrimaryInputSchema, + videosGetUrlDetails: VideosGetUrlDetailsInputSchema, + videosListSubtitleLanguages: VideosListSubtitleLanguagesInputSchema, + videosGetSubtitleLanguageDetails: VideosGetSubtitleLanguageDetailsInputSchema, + videosCreateSubtitleLanguage: VideosCreateSubtitleLanguageInputSchema, + videosUpdateSubtitleLanguage: VideosUpdateSubtitleLanguageInputSchema, + videosFetchSubtitlesData: VideosFetchSubtitlesDataInputSchema, + videosCreateSubtitles: VideosCreateSubtitlesInputSchema, + videosListSubtitleActions: VideosListSubtitleActionsInputSchema, + videosPerformSubtitleAction: VideosPerformSubtitleActionInputSchema, + videosListSubtitleNotes: VideosListSubtitleNotesInputSchema, + videosAddSubtitleNote: VideosAddSubtitleNoteInputSchema, + usersGetData: UsersGetDataInputSchema, + usersGetActivity: UsersGetActivityInputSchema, + teamsList: TeamsListInputSchema, + teamsGetDetails: TeamsGetDetailsInputSchema, + teamsGetLanguages: TeamsGetLanguagesInputSchema, + activityList: ActivityListInputSchema, + activityGet: ActivityGetInputSchema, + languagesListAvailable: LanguagesListAvailableInputSchema, + messagesSend: MessagesSendInputSchema, +} as const; + +export const AmaraEndpointOutputSchemas = { + videosList: VideoListResponseSchema, + videosViewDetails: VideoSchema, + videosCreate: VideoSchema, + videosUpdate: VideoSchema, + videosListActivity: ActivityListResponseSchema, + videosListUrls: VideoUrlListResponseSchema, + videosAddUrl: VideoUrlSchema, + videosGetUrl: VideoUrlSchema, + videosDeleteUrl: EmptyOkSchema, + videosMakeUrlPrimary: VideoUrlSchema, + videosGetUrlDetails: VideoListResponseSchema, + videosListSubtitleLanguages: SubtitleLanguageListResponseSchema, + videosGetSubtitleLanguageDetails: SubtitleLanguageSchema, + videosCreateSubtitleLanguage: SubtitleLanguageSchema, + videosUpdateSubtitleLanguage: SubtitleLanguageSchema, + videosFetchSubtitlesData: SubtitlesResourceSchema, + videosCreateSubtitles: SubtitlesResourceSchema, + videosListSubtitleActions: SubtitleActionsListSchema, + videosPerformSubtitleAction: EmptyOkSchema, + videosListSubtitleNotes: SubtitleNotesListResponseSchema, + videosAddSubtitleNote: SubtitleNoteSchema, + usersGetData: UserSchema, + usersGetActivity: ActivityListResponseSchema, + teamsList: TeamListResponseSchema, + teamsGetDetails: TeamSchema, + teamsGetLanguages: TeamLanguagesSchema, + activityList: ActivityListResponseSchema, + activityGet: ActivitySchema, + languagesListAvailable: LanguagesListResponseSchema, + messagesSend: MessageSendResponseSchema, +} as const; diff --git a/packages/amara/endpoints/users.ts b/packages/amara/endpoints/users.ts new file mode 100644 index 000000000..fd6b5bc27 --- /dev/null +++ b/packages/amara/endpoints/users.ts @@ -0,0 +1,37 @@ +import { logEventFromContext } from 'corsair/core'; +import { + compactQuery, + encodeAmaraPathSegment, + makeAmaraRequest, +} from '../client'; +import type { AmaraEndpoints } from '../index'; +import { ActivityListResponseSchema, UserSchema } from './types'; + +export const getData: AmaraEndpoints['usersGetData'] = async (ctx, input) => { + const raw = await makeAmaraRequest( + `users/${encodeAmaraPathSegment(input.identifier)}/`, + ctx.key, + ); + const response = UserSchema.parse(raw); + await logEventFromContext(ctx, 'amara.users.getData', {}, 'completed'); + return response; +}; + +export const getActivity: AmaraEndpoints['usersGetActivity'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `users/${encodeAmaraPathSegment(input.identifier)}/activity/`, + ctx.key, + { + query: compactQuery({ + limit: input.limit, + offset: input.offset, + }), + }, + ); + const response = ActivityListResponseSchema.parse(raw); + await logEventFromContext(ctx, 'amara.users.getActivity', {}, 'completed'); + return response; +}; diff --git a/packages/amara/endpoints/videos.ts b/packages/amara/endpoints/videos.ts new file mode 100644 index 000000000..772121b7a --- /dev/null +++ b/packages/amara/endpoints/videos.ts @@ -0,0 +1,410 @@ +import { logEventFromContext } from 'corsair/core'; +import { + compactQuery, + encodeAmaraPathSegment, + makeAmaraRequest, +} from '../client'; +import type { AmaraEndpoints } from '../index'; +import { + ActivityListResponseSchema, + EmptyOkSchema, + SubtitleActionsListSchema, + SubtitleLanguageListResponseSchema, + SubtitleLanguageSchema, + SubtitleNoteSchema, + SubtitleNotesListResponseSchema, + SubtitlesResourceSchema, + VideoListResponseSchema, + VideoSchema, + VideoUrlListResponseSchema, + VideoUrlSchema, +} from './types'; + +function videoPath(videoId: string): string { + return `videos/${encodeAmaraPathSegment(videoId)}/`; +} + +function langPath(videoId: string, languageCode: string): string { + return `${videoPath(videoId)}languages/${encodeAmaraPathSegment(languageCode)}/`; +} + +function subtitlesPath(videoId: string, languageCode: string): string { + return `${langPath(videoId, languageCode)}subtitles/`; +} + +export const list: AmaraEndpoints['videosList'] = async (ctx, input) => { + const raw = await makeAmaraRequest('videos/', ctx.key, { + query: compactQuery({ + // ponytail: Composio calls this `sort`; Amara docs use `order_by` + order_by: input.sort, + team: input.team, + limit: input.limit, + owner: input.owner, + offset: input.offset, + archive: input.archive, + project: input.project, + language: input.language, + video_id: input.video_id, + video_url: input.video_url, + }), + }); + const response = VideoListResponseSchema.parse(raw); + await logEventFromContext(ctx, 'amara.videos.list', {}, 'completed'); + return response; +}; + +export const viewDetails: AmaraEndpoints['videosViewDetails'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest(videoPath(input.video_id), ctx.key); + const response = VideoSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.viewDetails', + { video_id: input.video_id }, + 'completed', + ); + return response; +}; + +export const create: AmaraEndpoints['videosCreate'] = async (ctx, input) => { + const raw = await makeAmaraRequest('videos/', ctx.key, { + method: 'POST', + body: input, + }); + const response = VideoSchema.parse(raw); + await logEventFromContext(ctx, 'amara.videos.create', {}, 'completed'); + return response; +}; + +export const update: AmaraEndpoints['videosUpdate'] = async (ctx, input) => { + const { video_id, ...body } = input; + const raw = await makeAmaraRequest(videoPath(video_id), ctx.key, { + method: 'PUT', + body, + }); + const response = VideoSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.update', + { video_id }, + 'completed', + ); + return response; +}; + +export const listActivity: AmaraEndpoints['videosListActivity'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `${videoPath(input.video_id)}activity/`, + ctx.key, + { + query: compactQuery({ + limit: input.limit, + offset: input.offset, + }), + }, + ); + const response = ActivityListResponseSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.listActivity', + { video_id: input.video_id }, + 'completed', + ); + return response; +}; + +export const listUrls: AmaraEndpoints['videosListUrls'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `${videoPath(input.video_id)}urls/`, + ctx.key, + { + query: compactQuery({ + limit: input.limit, + offset: input.offset, + }), + }, + ); + const response = VideoUrlListResponseSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.listUrls', + { video_id: input.video_id }, + 'completed', + ); + return response; +}; + +export const addUrl: AmaraEndpoints['videosAddUrl'] = async (ctx, input) => { + const { video_id, url, primary } = input; + const raw = await makeAmaraRequest(`${videoPath(video_id)}urls/`, ctx.key, { + method: 'POST', + body: { url, ...(primary !== undefined ? { primary } : {}) }, + }); + const response = VideoUrlSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.addUrl', + { video_id }, + 'completed', + ); + return response; +}; + +export const getUrl: AmaraEndpoints['videosGetUrl'] = async (ctx, input) => { + const raw = await makeAmaraRequest( + `${videoPath(input.video_id)}urls/${encodeURIComponent(String(input.url_id))}/`, + ctx.key, + ); + const response = VideoUrlSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.getUrl', + { video_id: input.video_id, url_id: input.url_id }, + 'completed', + ); + return response; +}; + +export const deleteUrl: AmaraEndpoints['videosDeleteUrl'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `${videoPath(input.video_id)}urls/${encodeURIComponent(String(input.url_id))}/`, + ctx.key, + { method: 'DELETE' }, + ); + const response = EmptyOkSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.deleteUrl', + { video_id: input.video_id, url_id: input.url_id }, + 'completed', + ); + return response; +}; + +export const makeUrlPrimary: AmaraEndpoints['videosMakeUrlPrimary'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `${videoPath(input.video_id)}urls/${encodeURIComponent(String(input.url_id))}/`, + ctx.key, + { method: 'PUT', body: { primary: input.primary } }, + ); + const response = VideoUrlSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.makeUrlPrimary', + { video_id: input.video_id, url_id: input.url_id }, + 'completed', + ); + return response; +}; + +export const getUrlDetails: AmaraEndpoints['videosGetUrlDetails'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest('videos/', ctx.key, { + query: compactQuery({ video_url: input.url, limit: 1 }), + }); + const response = VideoListResponseSchema.parse(raw); + await logEventFromContext(ctx, 'amara.videos.getUrlDetails', {}, 'completed'); + return response; +}; + +export const listSubtitleLanguages: AmaraEndpoints['videosListSubtitleLanguages'] = + async (ctx, input) => { + const raw = await makeAmaraRequest( + `${videoPath(input.video_id)}languages/`, + ctx.key, + { + query: compactQuery({ + limit: input.limit, + offset: input.offset, + }), + }, + ); + const response = SubtitleLanguageListResponseSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.listSubtitleLanguages', + { video_id: input.video_id }, + 'completed', + ); + return response; + }; + +export const getSubtitleLanguageDetails: AmaraEndpoints['videosGetSubtitleLanguageDetails'] = + async (ctx, input) => { + const raw = await makeAmaraRequest( + langPath(input.video_id, input.language_code), + ctx.key, + ); + const response = SubtitleLanguageSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.getSubtitleLanguageDetails', + { video_id: input.video_id, language_code: input.language_code }, + 'completed', + ); + return response; + }; + +export const createSubtitleLanguage: AmaraEndpoints['videosCreateSubtitleLanguage'] = + async (ctx, input) => { + const { video_id, ...body } = input; + const raw = await makeAmaraRequest( + `${videoPath(video_id)}languages/`, + ctx.key, + { method: 'POST', body }, + ); + const response = SubtitleLanguageSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.createSubtitleLanguage', + { video_id }, + 'completed', + ); + return response; + }; + +export const updateSubtitleLanguage: AmaraEndpoints['videosUpdateSubtitleLanguage'] = + async (ctx, input) => { + const { video_id, language_code, ...body } = input; + const raw = await makeAmaraRequest( + langPath(video_id, language_code), + ctx.key, + { method: 'PUT', body }, + ); + const response = SubtitleLanguageSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.updateSubtitleLanguage', + { video_id, language_code }, + 'completed', + ); + return response; + }; + +export const fetchSubtitlesData: AmaraEndpoints['videosFetchSubtitlesData'] = + async (ctx, input) => { + const subFormat = input.sub_format ?? input.format ?? 'json'; + const raw = await makeAmaraRequest( + subtitlesPath(input.video_id, input.language_code), + ctx.key, + { query: compactQuery({ sub_format: subFormat }) }, + ); + const response = SubtitlesResourceSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.fetchSubtitlesData', + { video_id: input.video_id, language_code: input.language_code }, + 'completed', + ); + return response; + }; + +export const createSubtitles: AmaraEndpoints['videosCreateSubtitles'] = async ( + ctx, + input, +) => { + const { video_id, language_code, ...body } = input; + const raw = await makeAmaraRequest( + subtitlesPath(video_id, language_code), + ctx.key, + { method: 'POST', body }, + ); + const response = SubtitlesResourceSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.createSubtitles', + { video_id, language_code }, + 'completed', + ); + return response; +}; + +export const listSubtitleActions: AmaraEndpoints['videosListSubtitleActions'] = + async (ctx, input) => { + const raw = await makeAmaraRequest( + `${subtitlesPath(input.video_id, input.language_code)}actions/`, + ctx.key, + ); + const response = SubtitleActionsListSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.listSubtitleActions', + { video_id: input.video_id, language_code: input.language_code }, + 'completed', + ); + return response; + }; + +export const performSubtitleAction: AmaraEndpoints['videosPerformSubtitleAction'] = + async (ctx, input) => { + const raw = await makeAmaraRequest( + `${subtitlesPath(input.video_id, input.language_code)}actions/`, + ctx.key, + { method: 'POST', body: { action: input.action } }, + ); + const response = EmptyOkSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.performSubtitleAction', + { video_id: input.video_id, language_code: input.language_code }, + 'completed', + ); + return response; + }; + +export const listSubtitleNotes: AmaraEndpoints['videosListSubtitleNotes'] = + async (ctx, input) => { + const raw = await makeAmaraRequest( + `${subtitlesPath(input.video_id, input.language_code)}notes/`, + ctx.key, + { + query: compactQuery({ + limit: input.limit, + offset: input.offset, + }), + }, + ); + const response = SubtitleNotesListResponseSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.listSubtitleNotes', + { video_id: input.video_id, language_code: input.language_code }, + 'completed', + ); + return response; + }; + +export const addSubtitleNote: AmaraEndpoints['videosAddSubtitleNote'] = async ( + ctx, + input, +) => { + const raw = await makeAmaraRequest( + `${subtitlesPath(input.video_id, input.language_code)}notes/`, + ctx.key, + { method: 'POST', body: { body: input.body } }, + ); + const response = SubtitleNoteSchema.parse(raw); + await logEventFromContext( + ctx, + 'amara.videos.addSubtitleNote', + { video_id: input.video_id, language_code: input.language_code }, + 'completed', + ); + return response; +}; diff --git a/packages/amara/error-handlers.test.ts b/packages/amara/error-handlers.test.ts new file mode 100644 index 000000000..13df2d9b3 --- /dev/null +++ b/packages/amara/error-handlers.test.ts @@ -0,0 +1,85 @@ +import { AmaraAPIError } from './client'; +import { errorHandlers } from './error-handlers'; + +function amaraError(status: number, retryAfter?: number): AmaraAPIError { + const error = new AmaraAPIError(`Request failed with status ${status}`); + Object.assign(error, { status, retryAfter }); + return error; +} + +function route(error: Error): string { + const match = Object.entries(errorHandlers).find(([, entry]) => + entry.match(error), + ); + if (!match) throw new Error('no handler matched'); + return match[0]; +} + +beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('errorHandlers', () => { + it('routes a 429 to the rate-limit handler and retries with backoff', async () => { + const error = amaraError(429, 2000); + + expect(route(error)).toBe('RATE_LIMIT_ERROR'); + expect(await errorHandlers.RATE_LIMIT_ERROR.handler(error)).toEqual({ + maxRetries: 3, + retryStrategy: 'exponential_backoff', + headersRetryAfterMs: 2000, + }); + }); + + it('treats 401 and 403 as auth failures that must not be retried', async () => { + expect(route(amaraError(401))).toBe('AUTH_ERROR'); + expect(route(amaraError(403))).toBe('AUTH_ERROR'); + expect(await errorHandlers.AUTH_ERROR.handler()).toEqual({ + maxRetries: 0, + }); + }); + + it('treats a 404 as not-found rather than a transient failure', async () => { + expect(route(amaraError(404))).toBe('NOT_FOUND_ERROR'); + expect(await errorHandlers.NOT_FOUND_ERROR.handler()).toEqual({ + maxRetries: 0, + }); + }); + + it('routes 400 and 422 to validation without retrying', async () => { + expect(route(amaraError(400))).toBe('VALIDATION_ERROR'); + expect(route(amaraError(422))).toBe('VALIDATION_ERROR'); + expect(await errorHandlers.VALIDATION_ERROR.handler()).toEqual({ + maxRetries: 0, + }); + }); + + it('retries 5xx responses with exponential backoff', async () => { + expect(route(amaraError(503))).toBe('SERVER_ERROR'); + expect(await errorHandlers.SERVER_ERROR.handler()).toEqual({ + maxRetries: 2, + retryStrategy: 'exponential_backoff', + }); + }); + + it('falls back to DEFAULT for an error carrying no status', async () => { + const error = new Error('socket hang up'); + + expect(route(error)).toBe('DEFAULT'); + expect(await errorHandlers.DEFAULT.handler(error)).toEqual({ + maxRetries: 0, + }); + }); + + it('does not let message heuristics override a known non-matching status', () => { + // 400 body mentioning "rate limit" must stay VALIDATION, not RATE_LIMIT. + const error = amaraError(400); + error.message = 'rate limit exceeded somehow'; + expect(route(error)).toBe('VALIDATION_ERROR'); + }); +}); diff --git a/packages/amara/error-handlers.ts b/packages/amara/error-handlers.ts new file mode 100644 index 000000000..84321b9b6 --- /dev/null +++ b/packages/amara/error-handlers.ts @@ -0,0 +1,106 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import type { AmaraAPIError } from './client'; + +function getStatus(error: Error): number | undefined { + return (error as Partial).status; +} + +function getRetryAfter(error: Error): number | undefined { + return (error as Partial).retryAfter; +} + +/** + * Error handlers for the Amara plugin. + * + * - 401/403: missing or invalid API key / insufficient permissions + * - 404: resource not found + * - 400/422: malformed request + * - 429: rate limited + * - 5xx: upstream failure + * + * When an HTTP status is present, message heuristics must not override it. + */ +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + const status = getStatus(error); + if (status !== undefined) return status === 429; + const msg = error.message.toLowerCase(); + return msg.includes('429') || msg.includes('rate limit'); + }, + handler: async (error: Error) => ({ + maxRetries: 3, + retryStrategy: 'exponential_backoff' as const, + headersRetryAfterMs: getRetryAfter(error), + }), + }, + AUTH_ERROR: { + match: (error: Error) => { + const status = getStatus(error); + if (status !== undefined) return status === 401 || status === 403; + const msg = error.message.toLowerCase(); + return ( + msg.includes('401') || + msg.includes('403') || + msg.includes('unauthorized') || + msg.includes('forbidden') || + msg.includes('invalid api key') + ); + }, + handler: async () => { + console.error( + '[AMARA] Authentication failed — check that the API key is valid.', + ); + return { maxRetries: 0 }; + }, + }, + NOT_FOUND_ERROR: { + match: (error: Error) => { + const status = getStatus(error); + if (status !== undefined) return status === 404; + const msg = error.message.toLowerCase(); + return msg.includes('404') || msg.includes('not found'); + }, + handler: async () => { + console.warn('[AMARA] Resource not found.'); + return { maxRetries: 0 }; + }, + }, + VALIDATION_ERROR: { + match: (error: Error) => { + const status = getStatus(error); + if (status !== undefined) return status === 400 || status === 422; + const msg = error.message.toLowerCase(); + return ( + msg.includes('400') || + msg.includes('422') || + msg.includes('unprocessable') + ); + }, + handler: async () => { + console.warn( + '[AMARA] Request rejected — a required parameter is missing or malformed.', + ); + return { maxRetries: 0 }; + }, + }, + SERVER_ERROR: { + match: (error: Error) => { + const status = getStatus(error); + if (status !== undefined) return status >= 500; + const msg = error.message.toLowerCase(); + return msg.includes('500') || msg.includes('internal server error'); + }, + handler: async () => ({ + maxRetries: 2, + retryStrategy: 'exponential_backoff' as const, + }), + }, + DEFAULT: { + match: () => true, + handler: async (error: Error) => { + console.error(`[AMARA] Unhandled error: ${error.message}`); + return { maxRetries: 0 }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/amara/index.ts b/packages/amara/index.ts new file mode 100644 index 000000000..09ffc4cc7 --- /dev/null +++ b/packages/amara/index.ts @@ -0,0 +1,513 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { + ActivityEndpoints, + LanguagesEndpoints, + MessagesEndpoints, + TeamsEndpoints, + UsersEndpoints, + VideosEndpoints, +} from './endpoints'; +import type { + AmaraEndpointInputs, + AmaraEndpointOutputs, +} from './endpoints/types'; +import { + AmaraEndpointInputSchemas, + AmaraEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AmaraSchema } from './schema'; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Options +// ───────────────────────────────────────────────────────────────────────────── + +export type AmaraPluginOptions = { + /** Authentication method. Amara only supports API keys. */ + authType?: PickAuth<'api_key'>; + /** + * Amara API key, sent as the `X-api-key` header. When omitted the key is + * resolved from the account key manager instead. + */ + key?: string; + /** Optional: lifecycle hooks for endpoints */ + hooks?: InternalAmaraPlugin['hooks']; + /** Optional: custom error handlers (merged with defaults) */ + errorHandlers?: CorsairErrorHandler; + /** + * Permission configuration for the Amara plugin. + */ + permissions?: PluginPermissionsConfig; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Context & Type Helpers +// ───────────────────────────────────────────────────────────────────────────── + +export type AmaraContext = CorsairPluginContext< + typeof AmaraSchema, + AmaraPluginOptions, + undefined, + typeof amaraAuthConfig +>; + +export type AmaraKeyBuilderContext = KeyBuilderContext< + AmaraPluginOptions, + typeof amaraAuthConfig +>; + +export type AmaraBoundEndpoints = BindEndpoints; + +type AmaraEndpoint = CorsairEndpoint< + AmaraContext, + AmaraEndpointInputs[K], + AmaraEndpointOutputs[K] +>; + +export type AmaraEndpoints = { + videosList: AmaraEndpoint<'videosList'>; + videosViewDetails: AmaraEndpoint<'videosViewDetails'>; + videosCreate: AmaraEndpoint<'videosCreate'>; + videosUpdate: AmaraEndpoint<'videosUpdate'>; + videosListActivity: AmaraEndpoint<'videosListActivity'>; + videosListUrls: AmaraEndpoint<'videosListUrls'>; + videosAddUrl: AmaraEndpoint<'videosAddUrl'>; + videosGetUrl: AmaraEndpoint<'videosGetUrl'>; + videosDeleteUrl: AmaraEndpoint<'videosDeleteUrl'>; + videosMakeUrlPrimary: AmaraEndpoint<'videosMakeUrlPrimary'>; + videosGetUrlDetails: AmaraEndpoint<'videosGetUrlDetails'>; + videosListSubtitleLanguages: AmaraEndpoint<'videosListSubtitleLanguages'>; + videosGetSubtitleLanguageDetails: AmaraEndpoint<'videosGetSubtitleLanguageDetails'>; + videosCreateSubtitleLanguage: AmaraEndpoint<'videosCreateSubtitleLanguage'>; + videosUpdateSubtitleLanguage: AmaraEndpoint<'videosUpdateSubtitleLanguage'>; + videosFetchSubtitlesData: AmaraEndpoint<'videosFetchSubtitlesData'>; + videosCreateSubtitles: AmaraEndpoint<'videosCreateSubtitles'>; + videosListSubtitleActions: AmaraEndpoint<'videosListSubtitleActions'>; + videosPerformSubtitleAction: AmaraEndpoint<'videosPerformSubtitleAction'>; + videosListSubtitleNotes: AmaraEndpoint<'videosListSubtitleNotes'>; + videosAddSubtitleNote: AmaraEndpoint<'videosAddSubtitleNote'>; + usersGetData: AmaraEndpoint<'usersGetData'>; + usersGetActivity: AmaraEndpoint<'usersGetActivity'>; + teamsList: AmaraEndpoint<'teamsList'>; + teamsGetDetails: AmaraEndpoint<'teamsGetDetails'>; + teamsGetLanguages: AmaraEndpoint<'teamsGetLanguages'>; + activityList: AmaraEndpoint<'activityList'>; + activityGet: AmaraEndpoint<'activityGet'>; + languagesListAvailable: AmaraEndpoint<'languagesListAvailable'>; + messagesSend: AmaraEndpoint<'messagesSend'>; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint Tree +// ───────────────────────────────────────────────────────────────────────────── + +const amaraEndpointsNested = { + videos: { + list: VideosEndpoints.list, + viewDetails: VideosEndpoints.viewDetails, + create: VideosEndpoints.create, + update: VideosEndpoints.update, + listActivity: VideosEndpoints.listActivity, + listUrls: VideosEndpoints.listUrls, + addUrl: VideosEndpoints.addUrl, + getUrl: VideosEndpoints.getUrl, + deleteUrl: VideosEndpoints.deleteUrl, + makeUrlPrimary: VideosEndpoints.makeUrlPrimary, + getUrlDetails: VideosEndpoints.getUrlDetails, + listSubtitleLanguages: VideosEndpoints.listSubtitleLanguages, + getSubtitleLanguageDetails: VideosEndpoints.getSubtitleLanguageDetails, + createSubtitleLanguage: VideosEndpoints.createSubtitleLanguage, + updateSubtitleLanguage: VideosEndpoints.updateSubtitleLanguage, + fetchSubtitlesData: VideosEndpoints.fetchSubtitlesData, + createSubtitles: VideosEndpoints.createSubtitles, + listSubtitleActions: VideosEndpoints.listSubtitleActions, + performSubtitleAction: VideosEndpoints.performSubtitleAction, + listSubtitleNotes: VideosEndpoints.listSubtitleNotes, + addSubtitleNote: VideosEndpoints.addSubtitleNote, + }, + users: { + getData: UsersEndpoints.getData, + getActivity: UsersEndpoints.getActivity, + }, + teams: { + list: TeamsEndpoints.list, + getDetails: TeamsEndpoints.getDetails, + getLanguages: TeamsEndpoints.getLanguages, + }, + activity: { + list: ActivityEndpoints.list, + get: ActivityEndpoints.get, + }, + languages: { + listAvailable: LanguagesEndpoints.listAvailable, + }, + messages: { + send: MessagesEndpoints.send, + }, +} as const; + +// No webhooks — Amara is a pull-based REST API with no event delivery here. +const amaraWebhooksNested = {} as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const amaraEndpointSchemas = { + 'videos.list': { + input: AmaraEndpointInputSchemas.videosList, + output: AmaraEndpointOutputSchemas.videosList, + }, + 'videos.viewDetails': { + input: AmaraEndpointInputSchemas.videosViewDetails, + output: AmaraEndpointOutputSchemas.videosViewDetails, + }, + 'videos.create': { + input: AmaraEndpointInputSchemas.videosCreate, + output: AmaraEndpointOutputSchemas.videosCreate, + }, + 'videos.update': { + input: AmaraEndpointInputSchemas.videosUpdate, + output: AmaraEndpointOutputSchemas.videosUpdate, + }, + 'videos.listActivity': { + input: AmaraEndpointInputSchemas.videosListActivity, + output: AmaraEndpointOutputSchemas.videosListActivity, + }, + 'videos.listUrls': { + input: AmaraEndpointInputSchemas.videosListUrls, + output: AmaraEndpointOutputSchemas.videosListUrls, + }, + 'videos.addUrl': { + input: AmaraEndpointInputSchemas.videosAddUrl, + output: AmaraEndpointOutputSchemas.videosAddUrl, + }, + 'videos.getUrl': { + input: AmaraEndpointInputSchemas.videosGetUrl, + output: AmaraEndpointOutputSchemas.videosGetUrl, + }, + 'videos.deleteUrl': { + input: AmaraEndpointInputSchemas.videosDeleteUrl, + output: AmaraEndpointOutputSchemas.videosDeleteUrl, + }, + 'videos.makeUrlPrimary': { + input: AmaraEndpointInputSchemas.videosMakeUrlPrimary, + output: AmaraEndpointOutputSchemas.videosMakeUrlPrimary, + }, + 'videos.getUrlDetails': { + input: AmaraEndpointInputSchemas.videosGetUrlDetails, + output: AmaraEndpointOutputSchemas.videosGetUrlDetails, + }, + 'videos.listSubtitleLanguages': { + input: AmaraEndpointInputSchemas.videosListSubtitleLanguages, + output: AmaraEndpointOutputSchemas.videosListSubtitleLanguages, + }, + 'videos.getSubtitleLanguageDetails': { + input: AmaraEndpointInputSchemas.videosGetSubtitleLanguageDetails, + output: AmaraEndpointOutputSchemas.videosGetSubtitleLanguageDetails, + }, + 'videos.createSubtitleLanguage': { + input: AmaraEndpointInputSchemas.videosCreateSubtitleLanguage, + output: AmaraEndpointOutputSchemas.videosCreateSubtitleLanguage, + }, + 'videos.updateSubtitleLanguage': { + input: AmaraEndpointInputSchemas.videosUpdateSubtitleLanguage, + output: AmaraEndpointOutputSchemas.videosUpdateSubtitleLanguage, + }, + 'videos.fetchSubtitlesData': { + input: AmaraEndpointInputSchemas.videosFetchSubtitlesData, + output: AmaraEndpointOutputSchemas.videosFetchSubtitlesData, + }, + 'videos.createSubtitles': { + input: AmaraEndpointInputSchemas.videosCreateSubtitles, + output: AmaraEndpointOutputSchemas.videosCreateSubtitles, + }, + 'videos.listSubtitleActions': { + input: AmaraEndpointInputSchemas.videosListSubtitleActions, + output: AmaraEndpointOutputSchemas.videosListSubtitleActions, + }, + 'videos.performSubtitleAction': { + input: AmaraEndpointInputSchemas.videosPerformSubtitleAction, + output: AmaraEndpointOutputSchemas.videosPerformSubtitleAction, + }, + 'videos.listSubtitleNotes': { + input: AmaraEndpointInputSchemas.videosListSubtitleNotes, + output: AmaraEndpointOutputSchemas.videosListSubtitleNotes, + }, + 'videos.addSubtitleNote': { + input: AmaraEndpointInputSchemas.videosAddSubtitleNote, + output: AmaraEndpointOutputSchemas.videosAddSubtitleNote, + }, + 'users.getData': { + input: AmaraEndpointInputSchemas.usersGetData, + output: AmaraEndpointOutputSchemas.usersGetData, + }, + 'users.getActivity': { + input: AmaraEndpointInputSchemas.usersGetActivity, + output: AmaraEndpointOutputSchemas.usersGetActivity, + }, + 'teams.list': { + input: AmaraEndpointInputSchemas.teamsList, + output: AmaraEndpointOutputSchemas.teamsList, + }, + 'teams.getDetails': { + input: AmaraEndpointInputSchemas.teamsGetDetails, + output: AmaraEndpointOutputSchemas.teamsGetDetails, + }, + 'teams.getLanguages': { + input: AmaraEndpointInputSchemas.teamsGetLanguages, + output: AmaraEndpointOutputSchemas.teamsGetLanguages, + }, + 'activity.list': { + input: AmaraEndpointInputSchemas.activityList, + output: AmaraEndpointOutputSchemas.activityList, + }, + 'activity.get': { + input: AmaraEndpointInputSchemas.activityGet, + output: AmaraEndpointOutputSchemas.activityGet, + }, + 'languages.listAvailable': { + input: AmaraEndpointInputSchemas.languagesListAvailable, + output: AmaraEndpointOutputSchemas.languagesListAvailable, + }, + 'messages.send': { + input: AmaraEndpointInputSchemas.messagesSend, + output: AmaraEndpointOutputSchemas.messagesSend, + }, +} as const satisfies RequiredPluginEndpointSchemas; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint Meta +// ───────────────────────────────────────────────────────────────────────────── + +const amaraEndpointMeta = { + 'videos.list': { + riskLevel: 'read', + description: 'List videos with optional filters and pagination', + }, + 'videos.viewDetails': { + riskLevel: 'read', + description: 'Get details for a single video by id', + }, + 'videos.create': { + riskLevel: 'write', + description: 'Create a video from a public URL', + }, + 'videos.update': { + riskLevel: 'write', + description: 'Update video metadata', + }, + 'videos.listActivity': { + riskLevel: 'read', + description: 'List activity for a video', + }, + 'videos.listUrls': { + riskLevel: 'read', + description: 'List URLs associated with a video', + }, + 'videos.addUrl': { + riskLevel: 'write', + description: 'Add a URL to a video', + }, + 'videos.getUrl': { + riskLevel: 'read', + description: 'Get a single video URL by id', + }, + 'videos.deleteUrl': { + riskLevel: 'write', + description: 'Delete a video URL', + }, + 'videos.makeUrlPrimary': { + riskLevel: 'write', + description: 'Set a video URL as primary', + }, + 'videos.getUrlDetails': { + riskLevel: 'read', + description: 'Look up a video by its public URL', + }, + 'videos.listSubtitleLanguages': { + riskLevel: 'read', + description: 'List subtitle languages for a video', + }, + 'videos.getSubtitleLanguageDetails': { + riskLevel: 'read', + description: 'Get details for a subtitle language', + }, + 'videos.createSubtitleLanguage': { + riskLevel: 'write', + description: 'Create a subtitle language on a video', + }, + 'videos.updateSubtitleLanguage': { + riskLevel: 'write', + description: 'Update subtitle language settings', + }, + 'videos.fetchSubtitlesData': { + riskLevel: 'read', + description: 'Fetch subtitles for a video language', + }, + 'videos.createSubtitles': { + riskLevel: 'write', + description: 'Create or update subtitles for a language', + }, + 'videos.listSubtitleActions': { + riskLevel: 'read', + description: 'List available subtitle actions', + }, + 'videos.performSubtitleAction': { + riskLevel: 'write', + description: 'Perform a subtitle action (publish, save-draft, …)', + }, + 'videos.listSubtitleNotes': { + riskLevel: 'read', + description: 'List editor notes on a subtitle set', + }, + 'videos.addSubtitleNote': { + riskLevel: 'write', + description: 'Add an editor note to a subtitle set', + }, + 'users.getData': { + riskLevel: 'read', + description: 'Get a user profile by identifier (or "me")', + }, + 'users.getActivity': { + riskLevel: 'read', + description: 'List activity for a user', + }, + 'teams.list': { + riskLevel: 'read', + description: 'List teams', + }, + 'teams.getDetails': { + riskLevel: 'read', + description: 'Get team details by slug', + }, + 'teams.getLanguages': { + riskLevel: 'read', + description: 'Get preferred/blacklisted language URIs for a team', + }, + 'activity.list': { + riskLevel: 'read', + description: 'List platform activity with optional filters', + }, + 'activity.get': { + riskLevel: 'read', + description: 'Get a single activity item by id', + }, + 'languages.listAvailable': { + riskLevel: 'read', + description: 'List all supported Amara language codes', + }, + 'messages.send': { + riskLevel: 'write', + description: 'Send a message to a user or team', + }, +} as const satisfies RequiredPluginEndpointMeta; + +// ───────────────────────────────────────────────────────────────────────────── +// Auth Configuration +// ───────────────────────────────────────────────────────────────────────────── + +const defaultAuthType: AuthTypes = 'api_key' as const; + +export const amaraAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Types +// ───────────────────────────────────────────────────────────────────────────── + +export type BaseAmaraPlugin = CorsairPlugin< + 'amara', + typeof AmaraSchema, + typeof amaraEndpointsNested, + typeof amaraWebhooksNested, + T, + typeof defaultAuthType, + typeof amaraAuthConfig +>; + +export type InternalAmaraPlugin = BaseAmaraPlugin; + +export type ExternalAmaraPlugin = + BaseAmaraPlugin; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Factory +// ───────────────────────────────────────────────────────────────────────────── + +export function amara( + incomingOptions: AmaraPluginOptions & T = {} as AmaraPluginOptions & T, +): ExternalAmaraPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'amara', + authConfig: amaraAuthConfig, + schema: AmaraSchema, + options: options, + hooks: options.hooks, + webhookHooks: undefined, + endpoints: amaraEndpointsNested, + webhooks: amaraWebhooksNested, + endpointMeta: amaraEndpointMeta, + endpointSchemas: amaraEndpointSchemas, + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AmaraKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint') { + const res = await ctx.keys?.get_api_key(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalAmaraPlugin; +} + +export { AMARA_API_BASE, AmaraAPIError } from './client'; +export type { + Activity, + ActivityListResponse, + AmaraEndpointInputs, + AmaraEndpointOutputs, + LanguagesListResponse, + MessageSendResponse, + SubtitleLanguage, + SubtitlesResource, + Team, + TeamLanguages, + TeamListResponse, + User, + Video, + VideoListResponse, + VideoUrl, +} from './endpoints/types'; +export { + AmaraEndpointInputSchemas, + AmaraEndpointOutputSchemas, +} from './endpoints/types'; diff --git a/packages/amara/jest.config.cjs b/packages/amara/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/amara/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/amara/package.json b/packages/amara/package.json new file mode 100644 index 000000000..c104ab3e0 --- /dev/null +++ b/packages/amara/package.json @@ -0,0 +1,45 @@ +{ + "name": "@corsair-dev/amara", + "version": "0.1.0", + "description": "Amara (subtitling platform) plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^24.10.1", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "amara", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/amara/schema.test.ts b/packages/amara/schema.test.ts new file mode 100644 index 000000000..8b2750bad --- /dev/null +++ b/packages/amara/schema.test.ts @@ -0,0 +1,16 @@ +import { AmaraSchema } from './schema'; + +describe('Amara schema', () => { + it('declares a semver version', () => { + expect(AmaraSchema.version).toBeDefined(); + expect(AmaraSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares video, user, and team entities', () => { + expect(typeof AmaraSchema.entities).toBe('object'); + expect(AmaraSchema.entities).not.toBeNull(); + expect(AmaraSchema.entities.videos).toBeDefined(); + expect(AmaraSchema.entities.users).toBeDefined(); + expect(AmaraSchema.entities.teams).toBeDefined(); + }); +}); diff --git a/packages/amara/schema/database.ts b/packages/amara/schema/database.ts new file mode 100644 index 000000000..a39987620 --- /dev/null +++ b/packages/amara/schema/database.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +export const AmaraVideo = z.object({ + id: z.string(), + title: z.string().optional(), + description: z.string().nullable().optional(), + duration: z.number().nullable().optional(), + thumbnail: z.string().nullable().optional(), + team: z.string().nullable().optional(), + project: z.string().nullable().optional(), + primary_audio_language_code: z.string().nullable().optional(), + video_type: z.string().nullable().optional(), + all_urls: z.array(z.string()).optional(), + resource_uri: z.string().optional(), + fetchedAt: z.coerce.date().nullable().optional(), +}); + +export const AmaraUser = z.object({ + id: z.string(), + username: z.string().optional(), + full_name: z.string().nullable().optional(), + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + biography: z.string().nullable().optional(), + homepage: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + languages: z.array(z.string()).optional(), + num_videos: z.number().optional(), + resource_uri: z.string().optional(), + fetchedAt: z.coerce.date().nullable().optional(), +}); + +export const AmaraTeam = z.object({ + slug: z.string(), + name: z.string().optional(), + type: z.string().nullable().optional(), + description: z.string().nullable().optional(), + membership_policy: z.string().nullable().optional(), + video_policy: z.string().nullable().optional(), + is_visible: z.boolean().optional(), + resource_uri: z.string().optional(), + fetchedAt: z.coerce.date().nullable().optional(), +}); + +export type AmaraVideo = z.infer; +export type AmaraUser = z.infer; +export type AmaraTeam = z.infer; diff --git a/packages/amara/schema/index.ts b/packages/amara/schema/index.ts new file mode 100644 index 000000000..e0a8b3545 --- /dev/null +++ b/packages/amara/schema/index.ts @@ -0,0 +1,12 @@ +import { AmaraTeam, AmaraUser, AmaraVideo } from './database'; + +export const AmaraSchema = { + version: '1.0.0', + entities: { + videos: AmaraVideo, + users: AmaraUser, + teams: AmaraTeam, + }, +} as const; + +export * from './database'; diff --git a/packages/amara/tsconfig.json b/packages/amara/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/amara/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/amara/tsup.config.ts b/packages/amara/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/amara/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index f5141405a..971ccae5a 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -25,6 +25,7 @@ export const BaseProviders = [ 'airtable', 'algolia', 'alttextai', + 'amara', 'ambientweather', 'ambee', 'amplitude', @@ -136,6 +137,7 @@ export const ProviderDisplayNames = { airtable: 'Airtable', algolia: 'Algolia', alttextai: 'AltText.ai', + amara: 'Amara', ambientweather: 'Ambient Weather', ambee: 'Ambee', amplitude: 'Amplitude', @@ -254,6 +256,7 @@ export type AllProviders = | 'airtable' | 'algolia' | 'alttextai' + | 'amara' | 'ambientweather' | 'ambee' | 'amplitude' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9da9c65a7..8af41fa28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -596,6 +596,33 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/amara: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/ambee: devDependencies: '@types/jest':