diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..f361c3e --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/bin/sh + +deno fmt diff --git a/mod.ts b/mod.ts index b10ca34..3d18903 100644 --- a/mod.ts +++ b/mod.ts @@ -1,19 +1,3 @@ export * from "./src/nomalab.ts"; -export type { - BroadcastableKind, - Deliveries, - DeliverPayload, - Delivery, - FileWrapper, - Job, - Material, - Node, - NodeClass, - NodeKind, - Organization, - Path, - Show, - ShowClass, - ShowKind, - Subtitle, -} from "./src/types.ts"; +export * from "./src/types.ts"; +export * from "./src/formats.ts"; diff --git a/src/deps.ts b/src/deps.ts new file mode 100644 index 0000000..57bd117 --- /dev/null +++ b/src/deps.ts @@ -0,0 +1,2 @@ +export * from "https://deno.land/std@0.170.0/http/cookie.ts"; +export * from "https://deno.land/std@0.170.0/testing/asserts.ts"; diff --git a/src/formats.ts b/src/formats.ts new file mode 100644 index 0000000..4222ad5 --- /dev/null +++ b/src/formats.ts @@ -0,0 +1,223 @@ +export type Format = { + id: string; + name: string; + specification: FormatSpec; + audioCodec: FormatAudioCodec; + audioBitrate?: number; + startTimecode: string; + frameRate: FrameRate; + noFrameRateConversion: boolean; + ffmpegArgs?: string; + audioArgs?: string; + bmxArgs?: string; + options?: FormatMxfOptions; + noLoudness?: boolean; + loudnessRange?: number; + loudnessProgram?: number; + loudnessTruePeak?: number; + videoEdit?: FormatVideoEdit; + writeTimecode: boolean; + videoBitrate?: number; + encodeSubtitle: boolean; + crop?: FormatCropParameters; + scale?: FormatScaleParameters; + clip?: FormatClipParameters; + qcTestPlan: string; + qcReportTemplate: string; + subtitleVersion?: Version; + subtitleTypeVersion?: SubtitleTypeVersion; + subtitleFormat?: string; +}; + +export enum FormatSpec { + Mxf = "Mxf", + Mp4 = "Mp4", + ProRes422 = "ProRes422", + ProRes4444 = "ProRes4444", + ADN = "ADN", + AVCIntra100 = "AVCIntra100", + IMX50 = "IMX50", + Mp4Salto = "Mp4Salto", + MovH264 = "MovH264", + MovHevc = "MovHevc", + MxfProgressive = "MxfProgressive", + Demux = "Demux", + AudioExtract = "AudioExtract", +} + +export enum FormatAudioCodec { + PCMS24LE = "PCMS24LE", + PCMS16LE = "PCMS16LE", + PCMS24BE = "PCMS24BE", + AAC = "AAC", + MP3 = "MP3", + MOV_Conteneur = "MOV_Conteneur", +} + +export type FrameRate = { + id: string; + numerator: number; + denominator: number; +}; + +export type FormatMxfOptions = { + controlInstantaneousBitrate: boolean; + as10: boolean; + as11: boolean; + bwf: boolean; + afd?: number; + version12: boolean; + deinterlacing: boolean; + qmax?: number; +}; + +export type FormatVideoEdit = { + before: FormatSegment[]; + after: FormatSegment[]; +}; + +export type FormatSegment = { + index: number; + kind: FormatSegmentKind; + duration: number; + sourceName?: string; + sourceBucket?: string; + sourceKey?: string; + subtract: boolean; +}; + +export enum FormatSegmentKind { + Mire = "Mire", + Black = "Black", + Slate = "Slate", + Video = "Video", + Countdown = "Countdown", +} + +export type FormatCropParameters = { + leftPx: number; + rightPx: number; + topPx: number; + bottomPx: number; +}; + +export type FormatScaleParameters = { + width: number; + height: number; + scaleAspectRatio: boolean; + scaleLetterbox: boolean; +}; + +export type FormatClipParameters = { + clipMin: number; + clipMax: number; +}; + +export type FormatSubtitle = { + id: string; + name: string; + fileFormat: SubtitleFileFormat; + subtitleTimecode?: string; + subtitleFrameRate?: FrameRate; + displayStandard?: SubtitleDisplayStandard; + offset?: string; +}; + +export enum SubtitleFileFormat { + STL = "STL", + WebVTT = "WebVTT", + SRT = "SRT", +} + +export enum SubtitleDisplayStandard { + Open = "Open", + Teletext1 = "Teletext1", + Teletext2 = "Teletext2", +} + +export enum SubtitleTypeVersion { + PARTIAL = "PARTIAL", + COMPLETE = "COMPLETE", + COMPLETE_WITHOUT_PARTIAL = "COMPLETE_WITHOUT_PARTIAL", + SDH = "SDH", +} + +export enum SegmentLabel { + OpeningCredits = "OpeningCredits", + EndingCredits = "EndingCredits", + Introduction = "Introduction", + Program = "Program", + Trailer = "Trailer", + Advertising = "Advertising", + TestPattern = "TestPattern", + Black = "Black", + Slate = "Slate", + NeutralBases = "NeutralBases", + CustomDelivery = "CustomDelivery", +} + +export enum Version { + ARA = "ARA", + CHI = "CHI", + KOR = "KOR", + DAN = "DAN", + DUT = "DUT", + HEB = "HEB", + NLD = "NLD", + RUS = "RUS", + SWE = "SWE", + FRA = "FRA", + GER = "GER", + ITA = "ITA", + POR = "POR", + ENG = "ENG", + SPA = "SPA", + JPN = "JPN", + NOR = "NOR", + UKR = "UKR", + INT = "INT", + NOTHING = "", +} + +export enum Mapping { + AsMaster = "AsMaster", + NoSound = "NoSound", + VD = "VD", + VO = "VO", + VI = "VI", + VDVO = "VDVO", + VOAD = "VOAD", + VDAD = "VDAD", + VIVD = "VIVD", + VIVO = "VIVO", + VDVOAD = "VDVOAD", + VDVIVONLY = "VDVIVONLY", + VDVIMEVONLY = "VDVIMEVONLY", +} + +export enum Layout { + Mono = "Mono", + DualMono = "DualMono", + Stereo = "Stereo", + StereoL = "StereoL", + StereoR = "StereoR", + FiveDotOne = "FiveDotOne", + FiveDotOneL = "FiveDotOneL", + FiveDotOneR = "FiveDotOneR", + FiveDotOneC = "FiveDotOneC", + FiveDotOneSL = "FiveDotOneSL", + FiveDotOneSR = "FiveDotOneSR", + FiveDotOneLFE = "FiveDotOneLFE", + SevenDotOne = "SevenDotOne", + OneTrack = "OneTrack", +} + +export enum TypeVersion { + ORIGINAL = "ORIGINAL", + DUBBED = "DUBBED", + AD = "AD", + MUTE = "MUTE", + INT = "INT", + ME = "ME", + VONLY = "VONLY", +} diff --git a/src/nomalab.ts b/src/nomalab.ts index bfe4174..d5df9bb 100644 --- a/src/nomalab.ts +++ b/src/nomalab.ts @@ -1,18 +1,25 @@ import { CopyToBroadcastable, + DeliverableOrganization, Deliveries, DeliverPayload, + DeliveryApi, + AudioMappingPayload, Job, + MeUser, Node, NodeClass, NodeKind, Organization, Path, + Segment, Show, ShowClass, ShowKind, + SubtitleFormat, } from "./types.ts"; -import * as mod from "https://deno.land/std@0.148.0/http/cookie.ts"; +import { Format } from "./formats.ts"; +import { assert, getSetCookies } from "./deps.ts"; export class AlreadyPresentDeliverable extends Error { constructor(msg: string) { @@ -29,6 +36,14 @@ export class Nomalab { this.#apiToken = apiToken; } + async me(): Promise { + const response = await this.#fetch(`users/me`, {}); + if (!response.ok) { + this.#throwError(`ERROR - Can't load user infos.`, response); + } + return response.json() as Promise; + } + async getShow(showUuid: string): Promise { const response = await this.#fetch(`shows/${showUuid}`, {}); if (!response.ok) { @@ -44,6 +59,7 @@ export class Nomalab { const response = await this.#requestWithSwitch( organizationId, "hierarchy", + {}, ); if (!response.ok) this.#throwError(`ERROR - Can't find root.`, response); return response.json() as Promise; @@ -58,7 +74,6 @@ export class Nomalab { const response = await this.#requestWithSwitch( organizationId, "hierarchy", - "POST", { name, parent, @@ -94,8 +109,12 @@ export class Nomalab { async #requestWithSwitch( organizationId: string, partialUrl: string, - method?: "GET" | "POST", - body?: unknown, + optionalArg: { + method?: string; + bodyJsonObject?: unknown; + contentType?: string; + cookieHeader?: Record; + }, ): Promise { const response = await this.#fetch( `users/switch`, @@ -104,26 +123,21 @@ export class Nomalab { method: "POST", }, ); - if (response.status != 200) { + if (!response.ok) { this.#throwError(`Can't switch to org ${organizationId}`, response); } - const headers = new Headers(); - const setCookie = response.headers.get("set-cookie"); - if (setCookie != null) { - headers.append("Cookie", setCookie); - } + + const setCookie = getSetCookies(response.headers)[0]; + assert(setCookie, "No cookie"); + this.#apiToken = setCookie.value; + getSetCookies(response.headers)[0]; + // To avoid leak since we don't use the body of the response await response.body?.cancel(); - const cookie = mod.getCookies(headers); - const bodyJsonObject = method == "POST" ? (body ?? {}) : undefined; return this.#fetch( partialUrl, - { - bodyJsonObject, - method, - cookieHeader: cookie, - }, + optionalArg, ); } @@ -261,27 +275,14 @@ export class Nomalab { } // Deliver with starting a transcode - async deliver(showId: string, deliverPayload: DeliverPayload): Promise { - const response = await this.#fetch( - `broadcastables/${showId}/deliver`, + deliver(broadcastableId: string, deliverPayload: DeliverPayload): Promise { + return this.#fetch( + `broadcastables/${broadcastableId}/deliver`, { bodyJsonObject: deliverPayload, method: "POST", }, - ); - if (!response.ok) { - if (response.status == 409) { - throw new AlreadyPresentDeliverable( - "Can't deliver because of an already present deliverable.", - ); - } else { - this.#throwError( - `ERROR - Can't deliver with payload.${deliverPayload}`, - response, - ); - } - } - return Promise.resolve() as Promise; + ) as Promise; } async triggerUpload(broadcastableId: string) { @@ -315,7 +316,6 @@ export class Nomalab { `broadcastables/${broadcastableId}/delivery`, { method: "POST", bodyJsonObject: {} }, ); - console.log(response.headers); if (!response.ok) { if (response.status == 409) { throw new AlreadyPresentDeliverable( @@ -374,11 +374,63 @@ export class Nomalab { return response.blob() as Promise; } + async getDeliverableOrgs() { + const response = await this.#fetch(`organizations/deliverables`, {}); + return response.json() as Promise; + } + + async getFileSegments(materialId: string) { + const response = await this.#fetch(`files/${materialId}/segments`, {}); + return response.json() as Promise; + } + + async getOrganizationDeliveries(orgId: string) { + const response = await this.#fetch( + `organizations/${orgId}/shows/deliveries`, + {}, + ); + return response.json() as Promise; + } + + async getFormats(orgId: string) { + const response = await this.#fetch(`organizations/${orgId}/formats`, {}); + return response.json() as Promise; + } + + async getSubtitleFormats(orgId: string) { + const response = await this.#fetch( + `organizations/${orgId}/subtitleFormats`, + {}, + ); + return response.json() as Promise; + } + + setAudioMapping(fileId : string, mappingPayload : AudioMappingPayload): Promise { + return this.#fetch( + `files/${fileId}/audioMapping`, + { + bodyJsonObject: mappingPayload, + method: "POST", + }, + ); + } + #throwError(message: string, response: Response): void { console.error(message); console.error(response); throw new Error(message); } + proxy( + partialUrl: string, + optionalArg?: { + method?: string; + bodyJsonObject?: unknown; + contentType?: string; + cookieHeader?: Record; + }, + ): Promise { + return this.#fetch(partialUrl, optionalArg || {}); + } #fetch( partialUrl: string, optionalArg: { @@ -393,13 +445,11 @@ export class Nomalab { "Content-Type", optionalArg.contentType ?? "application/json", ); - myHeaders.append("Authorization", `Bearer ${this.#apiToken} `); - if (optionalArg.cookieHeader) { - myHeaders.append( - "Cookie", - `sessionJwt=${optionalArg.cookieHeader["sessionJwt"]}`, - ); - } + myHeaders.append( + "Cookie", + `sessionJwt=${this.#apiToken}`, + ); + const request = new Request( `https://${this.#contextSubDomain()}.nomalab.com/v3/${partialUrl}`, { diff --git a/src/types.ts b/src/types.ts index b676d3b..a6b2468 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,20 @@ +import * as Formats from "./formats.ts"; + +export interface MeUser { + admin: boolean; + avatar: string; + disableOrganizationEmails: boolean; + email: string; + id: string; + name: string; + organization: string; + organizations: { + organizationId: string; + organizationName: string; + logo?: string; + }[]; +} + export interface Job { id: string; createdAt: string; @@ -74,12 +91,13 @@ export interface FileWrapper { } export interface Material extends FileWrapper { container: Container; - streams: Array>; + streams: FileStream[]; proxies: Proxies; reportXml: FileClass; reportPdf: FileClass; deliveries: Delivery[]; segments: Segment[]; + subtitleWarnings: SubtitleWarning[]; } export interface Container { @@ -91,6 +109,13 @@ export interface Container { timecode: null; } +export interface AudioMappingPayload { + index: number; + channelLayout?: string; + version?: string; + typeVersion?: string; +} + export interface Deliveries { shows: Show[]; nodes: DeliveryNode[]; @@ -121,24 +146,25 @@ export interface Subtitles { subtitle: unknown[]; } -export enum ArchiveState { - Active = "active", - Archived = "archived", +export interface SubtitleWarning { + name: string; + timecode: string; } export interface SubtitleFormat { - organizationId: string; - organizationName: string; - subtitleFormats: FormatElement[]; + id: string; + name: string; + formats: string; } export interface DeliverPayload { format: string; - versionMapping: "VO" | "VD" | "VDVO"; - timecodeOut: string | null; - timecodeIn: string | null; + versionMapping: Formats.Mapping; + timecodeOut?: string; + timecodeIn?: string; + segments?: string[]; subtitles: DeliverSubtitle | null; targetOrg: string; - targetId: null; + targetId: string | null; } export interface DeliverSubtitle { format: string | null; @@ -160,7 +186,7 @@ export interface DeliveryTranscoding { startedAt: string; progressedAt: string; log: null | string; - warning: unknown[]; + warning: TranscodeWarning[]; } export enum Phase { @@ -242,7 +268,7 @@ export interface StreamValue { name: string; issues: Issues; parent: string; - nodeType: Array | FluffyNodeType | string>; + nodeType: NodeType; properties: Properties; } @@ -419,12 +445,12 @@ export interface ProgramLoudnessEBU { export interface Proxies { lowRes: FileClass; - hiRes: null; + hiRes: FileClass | null; } export interface Segment { id: string; - label: string; + label: Formats.SegmentLabel; creator: Creator; createdAt: string; file: string; @@ -451,7 +477,13 @@ export interface OrganizationElement { logo: null | string; } -export interface PurpleStream { +export type FileStream = + | ["VideoStream", FileVideoStream] + | ["AudioStream", FileAudioStream] + | ["SubtitleStream", FileSubtitleStream] + | ["DataStream", FileDataStream]; + +export interface FileVideoStream { fileId: string; index: number; codecName?: string; @@ -462,21 +494,51 @@ export interface PurpleStream { displayAspectRatioNumerator?: number; displayAspectRatioDenominator?: number; bitRate?: number; - rFrameRateNumerator?: number; - rFrameRateDenominator?: number; - level?: null; - profile?: null; + rFrameRateNumerator: number; + rFrameRateDenominator: number; + level?: number; + profile?: string; startTime?: number; chromaSubsampling?: string; scanningType?: string; timecode?: string; - sampleRate?: number; - sampleFormat?: string; - channels?: number; - bitsPerSample?: number; - channelLayout?: string; - version?: string; - typeVersion?: string; + nbFrames?: number; +} + +export interface FileAudioStream { + fileId: string; + index: number; + codecName: string; + codecLongName: string; + duration?: number; + sampleRate: number; + sampleFormat: string; + channels: number; + bitsPerSample: number; + bitRate: number; + channelLayout: Formats.Layout | null; + version: Formats.Version | null; + typeVersion: Formats.TypeVersion | null; +} + +export interface FileSubtitleStream { + fileId: string; + index: number; + codecName?: string; + codecLongName?: string; + version?: Formats.Version; + frameRateNumerator?: number; + frameRateDenominator?: number; + startTimecode?: string; + firstCue?: string; + subtitleType?: string; + typeVersion: Formats.SubtitleTypeVersion | null; +} + +export interface FileDataStream { + fileId: string; + index: number; + timecode?: string; } export interface Subtitle { @@ -564,7 +626,7 @@ export interface ShowClass { accepted: boolean; commandInfoXML: null; kind: ShowKind; - state: ArchiveState; + state: State; parent: string; } @@ -601,11 +663,31 @@ export interface Organization { allowDeliveryWithoutTranscoding: boolean; replication: boolean; logo: null | string; - formats: Format[]; - subtitleFormats: Format[]; + formats: FormatClass[]; + subtitleFormats: FormatClass[]; } -export interface Format { +export interface ShowOrganization { + id: string; + name: string; + createdAt: string; + qcMasterTestPlan: string; + qcMasterReportTemplate: string; + enableCreationEmail: boolean; + enableVideoReadyEmail: boolean; + enableUploadSuccessEmail: boolean; + enableAutoAccept: boolean; + enableAutoReject: boolean; + broadcaster: null; + manualDelivery: boolean; + allowDeliveryWithoutTranscoding: boolean; + logo: null; + webhooks: Webhook[]; + formats: unknown[]; + subtitleFormats: unknown[]; +} + +export interface FormatClass { id: string; name: string; } @@ -701,3 +783,288 @@ export interface NodeClass { kind: NodeKind; state: string; } + +export type DeliverableOrganization = { + id: string; + name: string; + allowDeliveryWithoutTranscoding: boolean; +}; + +export type DeliveryApi = { + nodes: NodeDelivery[]; + shows: ShowClass[]; +}; + +export type NodeDelivery = { + showId: string; + id: string; + name: string; + parent?: string; +}; + +export interface FileContainer { + fileId: string; + formatName: string; + formatLongName: string; + duration?: number; + bitRate?: number; + timecode?: string; +} + +export interface FileLike { + name: string; + mimeType?: string; +} + +export enum BroadcastableFileKind { + ProxyManifest = "ProxyManifest", + ProxyDashVideo = "ProxyDashVideo", + ProxyAudio = "ProxyAudio", + ProxySubtitle = "ProxySubtitle", + VerificationReportPdf = "VerificationReportPdf", + VerificationReportXml = "VerificationReportXml", + Video = "Video", + Audio = "Audio", + Subtitle = "Subtitle", + Extra = "Extra", +} + +export interface FileClass { + state: string; + stateExpireAt: string | null; + id: string; + createdAt: string; + name: string; + size: number; + mimeType: null | string; + bucket: string; + key: string; + kind: string; + uploaderId: null | string; + upload: Upload | null; + uploadedAt: null | string; + verification: Verification | null; + sourceId: null | string; + transcoding: Transcoding | null; + format: Formats.Format; +} + +export interface ExtraApi { + file: FileClass; + proxy: FileClass | null; + segments: FileSegment[]; + container: FileContainer | null; + streams: FileStream[]; +} + +export interface FileUploads { + uploads: FileClass[]; + parts: UploadPart[]; +} + +export interface UploadPart { + uploadId: string; + key: string; +} + +export interface FileLinkQueries { + download: boolean; +} + +export interface NewFile { + name: string; + size: number; + mimeType?: string; + bucket: string; + key: string; + kind: Kind; + uploaderId?: string; + upload: Upload | null; + sourceId?: string; + transcoding: Transcoding | null; +} + +export interface BroadcastableApi { + file: FileClass; + container: FileContainer | null; + streams: FileStream[]; + proxies: Proxies; + reportXml: FileClass | null; + reportPdf: FileClass | null; + deliveries: Delivery[]; + segments: FileSegment[]; + subtitleWarnings: { name: string; timecode: string }[]; +} + +export interface FileSegment { + id: string; + label: Formats.SegmentLabel; + creator: User; + createdAt: string; + file: string; + frameIn: number; + frameOut: number; +} + +export interface FileSegmentPayload { + label: Formats.SegmentLabel; + frameIn: number; + frameOut: number; +} + +export interface CreateFile { + name: string; + size: number; + mimeType?: string; + source?: string; + kind: BroadcastableFileKind; +} + +export interface ResumeFile { + name: string; + kind: BroadcastableFileKind; +} + +export enum FileTypeVideo { + Mxf = "Mxf", + Qtff = "Qtff", + Mp4 = "Mp4", +} + +export enum FileTypeAudio { + Mp3 = "Mp3", +} + +export type FileType = + | ["FileTypeVideo", FileTypeVideo] + | ["FileTypeAudio", FileTypeAudio] + | ["FileTypeSubtitle"]; + +export enum Phase { + Waiting = "Waiting", + Downloading = "Downloading", + Encoding = "Encoding", + Packaging = "Packaging", + Uploading = "Uploading", + Finished = "Finished", +} + +export interface TranscodeWarning { + name: string; + count: number; + firstFrameApprox: number; +} + +export interface Transcoding { + file?: string; + phase: Phase; + progress?: number; + startedAt: string; + progressedAt: string; + log?: string; + warning: TranscodeWarning[]; +} + +export interface Progress { + phase: Phase; + progress?: number; +} + +export interface Upload { + file: string; + user: string; + progress: number; + progressedAt: string; + pausedAt?: string; + completedAt?: string; + error?: string; + s3Id?: string; + speed: number; + secondsLeft?: number; + source?: string; +} + +export interface User { + id: string; + name: string; + email: string; + avatar: string; + organization?: string; + organizations: OrganizationUserWithLabel[]; + admin: boolean; + disableOrganizationEmails: boolean; +} + +export interface OrganizationUserWithLabel { + organizationId: string; + organizationName: string; + logo?: string; +} + +export interface OrganizationUser { + userId: string; + organizationId: string; +} + +export type NodeType = + | ["AudioProgram", AudioProgram] + | ["VideoProgram", VideoProgram] + | ["Container", ContainerProgram] + | ["UnknownNodeType"]; + +export interface AudioProgram { + programLoudness?: string; + loudnessRange?: string; + channels: AudioChannel[]; + isMute: boolean; +} + +export interface AudioChannel { + label: string; + truePeakLevel: string; +} + +export interface VideoProgram { + displayAspectRatio?: string; + frameRate?: string; + activePixelsArea?: string; + cadencePattern?: string; + chromaFormat?: string; + scanningType?: string; +} + +export type ContainerProgram = + | ["Mxf", MxfContainerProperties] + | ["Mov", MovContainerProperties] + | ["UnsupportedContainer"]; + +export interface MovContainerProperties { + startTimecode?: string; + duration?: string; +} + +export interface MxfContainerProperties { + operationalPattern?: string; + timeCodes: TimeCodes; + product: Product; +} + +export interface TimeCodes { + duration?: string; + systemItemStart?: string; + sourcePackageStart?: string; + materialPackageStart?: string; + hasVitc?: string; +} + +export interface Product { + name?: string; + version?: string; + issuer?: string; +} + +export enum State { + Active = "Active", + Archived = "Archived", + Restoring = "Restoring", +}