From f72f9649fe9a4e0284299d2bbee5cbb05d3b149e Mon Sep 17 00:00:00 2001 From: gioelecerati <50955448+gioelecerati@users.noreply.github.com> Date: Fri, 11 Feb 2022 00:01:29 +0100 Subject: [PATCH] api: Add export and transcode to Asset and Task (#893) * added export * added generic handler to handle export * added scheduleTask - moved pub msgs to scheduleTask * changed ipfs - removed waiting from outside scheduler * parentAssetId to inputAssetId - outputAssetId * typo * fix playbackId length and prefix * removed task name - remove originTaskId - changed scheduler to support both output and input assets * schema: Update export task params+output schema * api: Fixes to the export asset API E2E tested, it works!! https://ipfs.io/ipfs/QmRpyvxmxWPUUNSDXodjqDVutsq1ubH9sc85B57Eo7tB75 * api: Add download URL to assets * api: Add dynamic IPFS URLs to export task outputs * added ingest to webhook * added live-to-vod-asset * same shardKey as stream - better types on shard schedule * same shardKey as stream - better types on shard schedule * api/task: Use our dedicated IPFS gateway * fixed signed url encoding * added origin to ingests * check if asset is ready before export * check if asset is ready before export * api/cannon: Remove redundant stream/session fetch * removed redundant mp4 record url from webhook cannon * removed baseIngest from cannon * baseIngest remove * api/store: Make cleanWriteOnlyResponse recursive * api: Avoid creating fields that dont exist * api: Remove transcode task type for now * api: Fix asset creation on request upload * api: Clean asset responses as well * api: Create type for new asset * api/scheduler: Fix task not getting updated * api: Do some oneOf magic on export-task-params * api: Fix asset subfield clean up * api: Improve task error handling - Set asset as failed in case upload fails - Do not allow upload if asset has already been uploaded - Handle corner cases on task event result procecssing * api: List assets and tasks by updatedAt desc * api: Some final nits * api/task: Set IPFS URLs on regular list as well * prettier Co-authored-by: Victor Elias Co-authored-by: Victor Elias --- packages/api/.env | 2 +- packages/api/src/app-router.ts | 18 +- packages/api/src/controllers/asset.ts | 389 +++++++++--------- packages/api/src/controllers/helpers.ts | 24 +- packages/api/src/controllers/task.ts | 61 ++- .../api/src/middleware/hardcoded-nodes.ts | 1 + packages/api/src/schema/schema.yaml | 172 +++++++- packages/api/src/store/table.ts | 50 +-- packages/api/src/store/types.ts | 2 + packages/api/src/task/scheduler.ts | 151 +++++++ packages/api/src/task/task.ts | 104 ----- packages/api/src/types/common.d.ts | 2 + packages/api/src/webhooks/cannon.ts | 52 +++ packages/www/public/sitemap.xml | 228 +++++----- 14 files changed, 785 insertions(+), 471 deletions(-) create mode 100644 packages/api/src/task/scheduler.ts delete mode 100644 packages/api/src/task/task.ts diff --git a/packages/api/.env b/packages/api/.env index a426b22cff..c65ddfa7e7 100644 --- a/packages/api/.env +++ b/packages/api/.env @@ -3,7 +3,7 @@ LP_FALLBACK_PROXY=http://localhost:3008 LP_JWT_SECRET=secretsecret LP_JWT_AUDIENCE=livepeer LP_CLIENT_ID=211814034878-r7iugl9pa9fo2shd2pvmmadjatd5o4af.apps.googleusercontent.com -LP_INGEST=[{"ingest":"rtmp://localhost/live","playback":"http://localhost/hls","base":"https://localhost"}] +LP_INGEST=[{"ingest":"rtmp://localhost/live","playback":"http://localhost/hls","base":"http://localhost","origin":"http://localhost:3004"}] LP_POSTGRES_URL=postgresql://postgres@127.0.0.1:5432/livepeerapi LP_AMQP_URL=amqp://localhost:5672/livepeer LP_RECAPTCHA_SECRET_KEY=secretsecret diff --git a/packages/api/src/app-router.ts b/packages/api/src/app-router.ts index b68da4e945..186adef66b 100644 --- a/packages/api/src/app-router.ts +++ b/packages/api/src/app-router.ts @@ -16,7 +16,7 @@ import apiProxy from "./controllers/api-proxy"; import proxy from "http-proxy-middleware"; import { getBroadcasterHandler } from "./controllers/broadcaster"; import WebhookCannon from "./webhooks/cannon"; -import TaskScheduler from "./task/task"; +import TaskScheduler from "./task/scheduler"; import Queue, { NoopQueue, RabbitQueue } from "./store/queue"; import Stripe from "stripe"; import { CliArgs } from "./parse-cli"; @@ -84,6 +84,12 @@ export default async function makeApp(params: CliArgs) { ? await RabbitQueue.connect(amqpUrl) : new NoopQueue(); + // Task Scheduler + const taskScheduler = new TaskScheduler({ + queue, + }); + await taskScheduler.start(); + // Webhooks Cannon const webhookCannon = new WebhookCannon({ db, @@ -91,6 +97,8 @@ export default async function makeApp(params: CliArgs) { frontendDomain, sendgridTemplateId, sendgridApiKey, + taskScheduler, + vodObjectStoreId, supportAddr, verifyUrls: true, queue, @@ -102,12 +110,6 @@ export default async function makeApp(params: CliArgs) { webhookCannon.stop(); }); - // Task Scheduler - const taskScheduler = new TaskScheduler({ - queue, - }); - await taskScheduler.start(); - process.on("beforeExit", (code) => { queue.close(); taskScheduler.stop(); @@ -137,6 +139,7 @@ export default async function makeApp(params: CliArgs) { req.config = params; req.frontendDomain = frontendDomain; // defaults to livepeer.com req.queue = queue; + req.taskScheduler = taskScheduler; req.stripe = stripe; next(); }); @@ -216,7 +219,6 @@ export default async function makeApp(params: CliArgs) { return { router: app, webhookCannon, - taskScheduler, store, db, queue, diff --git a/packages/api/src/controllers/asset.ts b/packages/api/src/controllers/asset.ts index c1772522a3..df3d976b80 100644 --- a/packages/api/src/controllers/asset.ts +++ b/packages/api/src/controllers/asset.ts @@ -2,6 +2,7 @@ import { authMiddleware } from "../middleware"; import { validatePost } from "../middleware"; import { Router } from "express"; import { v4 as uuid } from "uuid"; +import mung from "express-mung"; import { makeNextHREF, parseFilters, @@ -9,22 +10,26 @@ import { getS3PresignedUrl, FieldsMap, toStringValues, + pathJoin, } from "./helpers"; import { db } from "../store"; import sql from "sql-template-strings"; -import { ForbiddenError, UnprocessableEntityError } from "../store/errors"; +import { + ForbiddenError, + UnprocessableEntityError, + NotFoundError, +} from "../store/errors"; import httpProxy from "http-proxy"; import { generateStreamKey } from "./generate-stream-key"; -import { IStore } from "../types/common"; -import { Asset } from "../schema/types"; +import { Asset, NewAssetPayload } from "../schema/types"; import { WithID } from "../store/types"; const app = Router(); const META_MAX_SIZE = 1024; -async function generateUniquePlaybackId(store: IStore, assetId: string) { - const shardKey = assetId.slice(4); +export async function generateUniquePlaybackId(store: any, assetId: string) { + const shardKey = assetId.substring(0, 4); while (true) { const playbackId: string = await generateStreamKey(); const qres = await store.query({ @@ -44,8 +49,7 @@ async function validateAssetPayload( userId: string, createdAt: number, defaultObjectStoreId: string, - // TODO: This could be just a new schema like `import-asset-payload` - payload: any + payload: NewAssetPayload ): Promise> { try { if (payload.meta && JSON.stringify(payload.meta).length > META_MAX_SIZE) { @@ -81,11 +85,43 @@ async function validateAssetPayload( }; } +function withDownloadUrl(asset: WithID, ingest: string): WithID { + if (asset.status !== "ready") { + return asset; + } + return { + ...asset, + downloadUrl: pathJoin(ingest, "asset", asset.playbackId, "video"), + }; +} + +app.use( + mung.json(function cleanWriteOnlyResponses( + data: WithID[] | WithID | { asset: WithID }, + req + ) { + if (req.user.admin) { + return data; + } + if (Array.isArray(data)) { + return db.asset.cleanWriteOnlyResponses(data); + } + if ("id" in data) { + return db.asset.cleanWriteOnlyResponse(data); + } + if ("asset" in data) { + return { ...data, asset: db.asset.cleanWriteOnlyResponse(data.asset) }; + } + return data; + }) +); + const fieldsMap: FieldsMap = { id: `asset.ID`, name: { val: `asset.data->>'name'`, type: "full-text" }, objectStoreId: `asset.data->>'objectStoreId'`, - createdAt: `asset.data->'createdAt'`, + createdAt: { val: `asset.data->'createdAt'`, type: "int" }, + updatedAt: { val: `asset.data->'updatedAt'`, type: "int" }, userId: `asset.data->>'userId'`, playbackId: `asset.data->>'playbackId'`, "user.email": { val: `users.data->>'email'`, type: "full-text" }, @@ -98,6 +134,15 @@ app.get("/", authMiddleware({}), async (req, res) => { if (isNaN(parseInt(limit))) { limit = undefined; } + if (!order) { + order = "updatedAt-true,createdAt-true"; + } + const ingests = await req.getIngest(); + if (!ingests.length) { + res.status(501); + return res.json({ errors: ["Ingest not configured"] }); + } + const ingest = ingests[0].base; if (req.user.admin && allUsers && allUsers !== "false") { const query = parseFilters(fieldsMap, filters); @@ -121,7 +166,10 @@ app.get("/", authMiddleware({}), async (req, res) => { if (count) { res.set("X-Total-Count", c); } - return { ...data, user: db.user.cleanWriteOnlyResponse(usersdata) }; + return { + ...withDownloadUrl(data, ingest), + user: db.user.cleanWriteOnlyResponse(usersdata), + }; }, }); @@ -155,7 +203,7 @@ app.get("/", authMiddleware({}), async (req, res) => { if (count) { res.set("X-Total-Count", c); } - return { ...data }; + return withDownloadUrl(data, ingest); }, }); @@ -169,33 +217,65 @@ app.get("/", authMiddleware({}), async (req, res) => { }); app.get("/:id", authMiddleware({}), async (req, res) => { - const os = await db.asset.get(req.params.id); - if (!os) { - res.status(404); - return res.json({ - errors: ["not found"], - }); + const ingests = await req.getIngest(); + if (!ingests.length) { + res.status(501); + return res.json({ errors: ["Ingest not configured"] }); } - if (req.user.admin !== true && req.user.id !== os.userId) { - res.status(403); - return res.json({ - errors: ["user can only request information on their own assets"], - }); + const ingest = ingests[0].base; + const asset = await db.asset.get(req.params.id); + if (!asset) { + throw new NotFoundError(`Asset not found`); } - res.json(os); + if (req.user.admin !== true && req.user.id !== asset.userId) { + throw new ForbiddenError( + "user can only request information on their own assets" + ); + } + + res.json(withDownloadUrl(asset, ingest)); }); -// TODO: Delete this API? Assets will only be created by task result events. app.post( - "/", - authMiddleware({ anyAdmin: true }), - validatePost("asset"), + "/:id/export", + validatePost("export-task-params"), + authMiddleware({}), + async (req, res) => { + const assetId = req.params.id; + const asset = await db.asset.get(assetId); + if (!asset) { + throw new NotFoundError(`Asset not found with id ${assetId}`); + } + if (asset.status !== "ready") { + res.status(412); + return res.json({ errors: ["asset is not ready to be exported"] }); + } + if (req.user.id !== asset.userId) { + throw new ForbiddenError(`User can only export their own assets`); + } + const task = await req.taskScheduler.scheduleTask( + "export", + { + export: req.body, + }, + asset + ); + + res.status(201); + res.json({ task }); + } +); + +app.post( + "/import", + validatePost("new-asset-payload"), + authMiddleware({}), async (req, res) => { const id = uuid(); const playbackId = await generateUniquePlaybackId(req.store, id); - const doc = await validateAssetPayload( + let asset = await validateAssetPayload( id, playbackId, req.user.id, @@ -203,98 +283,72 @@ app.post( req.config.vodObjectStoreId, req.body ); - if (!req.user.admin) { - res.status(403); - return res.json({ errors: ["Forbidden"] }); + if (!req.body.url) { + return res.status(422).json({ + errors: ["You must provide a url from which import an asset"], + }); } - await db.asset.create(doc); + + asset = await db.asset.create(asset); + + const task = await req.taskScheduler.scheduleTask( + "import", + { + import: { + url: req.body.url, + }, + }, + undefined, + asset + ); + res.status(201); - res.json(doc); + res.json({ asset, task }); } ); -app.post("/import", authMiddleware({}), async (req, res) => { - const id = uuid(); - const playbackId = await generateUniquePlaybackId(req.store, id); - const asset = await validateAssetPayload( - id, - playbackId, - req.user.id, - Date.now(), - req.config.vodObjectStoreId, - req.body - ); - if (!req.body.url) { - return res.status(422).json({ - errors: ["You must provide a url from which import an asset"], - }); - } - - await db.asset.create(asset); - - // TODO: move the task creation and spawn into task scheduler - const task = await db.task.create({ - id: uuid(), - name: `asset-import-${asset.name}-${asset.createdAt}`, - createdAt: asset.createdAt, - type: "import", - parentAssetId: asset.id, - userId: asset.userId, - params: { - import: { - url: req.body.url, - }, - }, - status: { - phase: "pending", - updatedAt: asset.createdAt, - }, - }); - await req.queue.publish("task", `task.trigger.${task.type}.${task.id}`, { - type: "task_trigger", - id: uuid(), - timestamp: Date.now(), - task: { - id: task.id, - type: task.type, - snapshot: task, - }, - }); - await db.task.update(task.id, { - status: { phase: "waiting", updatedAt: Date.now() }, - }); +app.post( + "/request-upload", + validatePost("new-asset-payload"), + authMiddleware({}), + async (req, res) => { + const id = uuid(); + let playbackId = await generateUniquePlaybackId(req.store, id); - res.status(201); - res.end(); -}); + let asset = await validateAssetPayload( + id, + playbackId, + req.user.id, + Date.now(), + req.config.vodObjectStoreId, + { name: `asset-upload-${id}`, ...req.body } + ); + const presignedUrl = await getS3PresignedUrl( + asset.objectStoreId, + `directUpload/${playbackId}/source` + ); -app.post("/request-upload", authMiddleware({}), async (req, res) => { - const id = uuid(); - let playbackId = await generateUniquePlaybackId(req.store, id); + const b64SignedUrl = encodeURIComponent( + Buffer.from(presignedUrl).toString("base64") + ); - const { vodObjectStoreId } = req.config; - const presignedUrl = await getS3PresignedUrl({ - objectKey: `directUpload/${playbackId}/source`, - vodObjectStoreId, - }); + const ingests = await req.getIngest(); + if (!ingests.length) { + res.status(501); + return res.json({ errors: ["Ingest not configured"] }); + } + const baseUrl = ingests[0].origin; + const url = `${baseUrl}/api/asset/upload/${b64SignedUrl}`; - const b64SignedUrl = Buffer.from(presignedUrl).toString("base64"); - const lpSignedUrl = `https://${req.frontendDomain}/api/asset/upload/${b64SignedUrl}`; + asset = await db.asset.create(asset); - // TODO: use the same function as the one used in import - await db.asset.create({ - id, - name: `asset-upload-${id}`, - playbackId, - userId: req.user.id, - objectStoreId: vodObjectStoreId, - }); - res.json({ url: lpSignedUrl, playbackId: playbackId }); -}); + res.json({ url, asset }); + } +); app.put("/upload/:url", async (req, res) => { const { url } = req.params; - let uploadUrl = Buffer.from(url, "base64").toString(); + let uploadUrl = decodeURIComponent(Buffer.from(url, "base64").toString()); // get playbackId from s3 url let playbackId; @@ -308,83 +362,51 @@ app.put("/upload/:url", async (req, res) => { `the provided url for the upload is not valid or not supported: ${uploadUrl}` ); } - const obj = await db.asset.find({ playbackId: playbackId }); - - if (obj?.length) { - let asset = obj[0][0]; - var proxy = httpProxy.createProxyServer({}); - - proxy.on("end", async function (proxyReq, _, res) { - if (res.statusCode == 200) { - // TODO: move the task creation and spawn into task scheduler - const createdAt = Date.now(); - let task = await db.task.create({ - id: uuid(), - name: `asset-upload-${asset.name}-${asset.createdAt}`, - createdAt, - type: "import", - parentAssetId: asset.id, - userId: asset.userId, - params: { - import: { - uploadedObjectKey: `directUpload/${playbackId}/source`, - }, - }, - status: { - phase: "pending", - updatedAt: createdAt, + const assets = await db.asset.find({ playbackId: playbackId }); + if (!assets?.length) { + throw new NotFoundError(`asset not found`); + } + let asset = assets[0][0]; + if (asset.status !== "waiting") { + throw new UnprocessableEntityError(`asset has already been processed`); + } + var proxy = httpProxy.createProxyServer({}); + + proxy.on("end", async function (proxyReq, _, res) { + if (res.statusCode == 200) { + // TODO: Find a way to return the task in the response + await req.taskScheduler.scheduleTask( + "import", + { + import: { + uploadedObjectKey: `directUpload/${playbackId}/source`, }, - }); - await req.queue.publish( - "task", - `task.trigger.${task.type}.${task.id}`, - { - type: "task_trigger", - id: uuid(), - timestamp: Date.now(), - task: { - id: task.id, - type: task.type, - snapshot: task, - }, - } - ); - await db.task.update(task.id, { - status: { phase: "waiting", updatedAt: Date.now() }, - }); - } else { - console.log( - `assetUpload: Proxy upload to s3 on url ${uploadUrl} failed with status code: ${res.statusCode}` - ); - } - }); + }, + undefined, + asset + ); + } else { + console.log( + `assetUpload: Proxy upload to s3 on url ${uploadUrl} failed with status code: ${res.statusCode}` + ); + } + }); - proxy.web(req, res, { - target: uploadUrl, - changeOrigin: true, - ignorePath: true, - }); - } else { - // we expect an existing asset to be found - res.status(404); - return res.json({ - errors: ["related asset not found"], - }); - } + proxy.web(req, res, { + target: uploadUrl, + changeOrigin: true, + ignorePath: true, + }); }); app.delete("/:id", authMiddleware({}), async (req, res) => { const { id } = req.params; const asset = await db.asset.get(id); if (!asset) { - res.status(404); - return res.json({ errors: ["not found"] }); + throw new NotFoundError(`Asset not found`); } if (!req.user.admin && req.user.id !== asset.userId) { - res.status(403); - return res.json({ - errors: ["users may only delete their own assets"], - }); + throw new ForbiddenError(`users may only delete their own assets`); } await db.asset.delete(id); res.status(204); @@ -399,24 +421,21 @@ app.patch( async (req, res) => { // update a specific asset const asset = await db.asset.get(req.body.id); - if (!req.user.admin) { - // do not reveal that asset exists - res.status(403); - return res.json({ errors: ["Forbidden"] }); + if (!asset) { + throw new NotFoundError(`asset not found`); } - const { id, userId, createdAt } = asset; - const playbackId = await generateUniquePlaybackId(req.store, id); - const doc = await validateAssetPayload( + const { id, playbackId, userId, createdAt, objectStoreId } = asset; + await db.asset.update(req.body.id, { + ...req.body, + // these fields are not updateable id, playbackId, userId, createdAt, - req.config.vodObjectStoreId, - req.body - ); - - await db.asset.update(req.body.id, doc); + updatedAt: Date.now(), + objectStoreId, + }); res.status(200); res.json({ id: req.body.id }); diff --git a/packages/api/src/controllers/helpers.ts b/packages/api/src/controllers/helpers.ts index e8cf4332fa..9068aa2aeb 100644 --- a/packages/api/src/controllers/helpers.ts +++ b/packages/api/src/controllers/helpers.ts @@ -117,21 +117,23 @@ export function makeNextHREF(req: express.Request, nextCursor: string) { return next.href; } -export async function getS3PresignedUrl({ objectKey, vodObjectStoreId }) { +const s3urlRegex = + /s3\+https:\/\/([a-zA-Z0-9-_]*):([a-zA-Z0-9-_]*)@([a-zA-Z0-9-.-_]*)\/([a-zA-Z0-9-_]*)\/([a-zA-Z0-9-_]*)/; + +export async function getS3PresignedUrl( + vodObjectStoreId: string, + objectKey: string +) { const store = await db.objectStore.get(vodObjectStoreId); - let s3urlRegex = - /s3\+https:\/\/([a-zA-Z0-9-_]*):([a-zA-Z0-9-_]*)@([a-zA-Z0-9-.-_]*)\/([a-zA-Z0-9-_]*)\/([a-zA-Z0-9-_]*)/; let match = s3urlRegex.exec(store.url); - - if (match) { - var vodAccessKey = match[1]; - var vodSecretAccessKey = match[2]; - var publicUrl = match[3]; - var vodRegion = match[4]; - var vodBucket = match[5]; - } else { + if (!match) { throw new Error("Invalid S3 URL"); } + var vodAccessKey = match[1]; + var vodSecretAccessKey = match[2]; + var publicUrl = match[3]; + var vodRegion = match[4]; + var vodBucket = match[5]; const s3Configuration = { credentials: { diff --git a/packages/api/src/controllers/task.ts b/packages/api/src/controllers/task.ts index 67a74867be..9e3e3accc7 100644 --- a/packages/api/src/controllers/task.ts +++ b/packages/api/src/controllers/task.ts @@ -1,6 +1,7 @@ import { authMiddleware } from "../middleware"; import { validatePost } from "../middleware"; import { Router } from "express"; +import mung from "express-mung"; import { v4 as uuid } from "uuid"; import { makeNextHREF, @@ -8,10 +9,14 @@ import { parseOrder, toStringValues, FieldsMap, + pathJoin, } from "./helpers"; import { db } from "../store"; import sql from "sql-template-strings"; import { Task } from "../schema/types"; +import { WithID } from "../store/types"; + +const ipfsGateway = "https://ipfs.livepeer.com/ipfs/"; const app = Router(); @@ -30,21 +35,66 @@ function validateTaskPayload( }; } +function withIpfsUrls(task: WithID): WithID { + if (task.type !== "export" || !task?.output?.export?.ipfs?.videoFileCid) { + return task; + } + return { + ...task, + output: { + ...task.output, + export: { + ...task.output.export, + ipfs: { + ...task.output.export.ipfs, + videoFileUrl: pathJoin( + ipfsGateway, + task.output.export.ipfs.videoFileCid + ), + erc1155MetadataUrl: pathJoin( + ipfsGateway, + task.output.export.ipfs.erc1155MetadataCid + ), + }, + }, + }, + }; +} + const fieldsMap: FieldsMap = { id: `task.ID`, name: { val: `task.data->>'name'`, type: "full-text" }, - createdAt: `task.data->'createdAt'`, + createdAt: { val: `task.data->'createdAt'`, type: "int" }, + updatedAt: { val: `task.data->'status'->'updatedAt'`, type: "int" }, userId: `task.data->>'userId'`, "user.email": { val: `users.data->>'email'`, type: "full-text" }, type: `task.data->>'type'`, }; +app.use( + mung.json(function cleanWriteOnlyResponses(data, req) { + if (req.user.admin) { + return data; + } + if (Array.isArray(data)) { + return db.task.cleanWriteOnlyResponses(data); + } + if ("id" in data) { + return db.task.cleanWriteOnlyResponse(data as WithID); + } + return data; + }) +); + app.get("/", authMiddleware({}), async (req, res) => { let { limit, cursor, all, event, allUsers, order, filters, count } = toStringValues(req.query); if (isNaN(parseInt(limit))) { limit = undefined; } + if (!order) { + order = "updatedAt-true,createdAt-true"; + } if (req.user.admin && allUsers && allUsers !== "false") { const query = parseFilters(fieldsMap, filters); @@ -68,7 +118,10 @@ app.get("/", authMiddleware({}), async (req, res) => { if (count) { res.set("X-Total-Count", c); } - return { ...data, user: db.user.cleanWriteOnlyResponse(usersdata) }; + return { + ...withIpfsUrls(data), + user: db.user.cleanWriteOnlyResponse(usersdata), + }; }, }); @@ -102,7 +155,7 @@ app.get("/", authMiddleware({}), async (req, res) => { if (count) { res.set("X-Total-Count", c); } - return { ...data }; + return withIpfsUrls(data); }, }); @@ -131,7 +184,7 @@ app.get("/:id", authMiddleware({}), async (req, res) => { }); } - res.json(task); + res.json(withIpfsUrls(task)); }); app.post( diff --git a/packages/api/src/middleware/hardcoded-nodes.ts b/packages/api/src/middleware/hardcoded-nodes.ts index 5136722125..ab6ea30b88 100644 --- a/packages/api/src/middleware/hardcoded-nodes.ts +++ b/packages/api/src/middleware/hardcoded-nodes.ts @@ -7,6 +7,7 @@ import { RequestHandler } from "express"; import { NodeAddress, OrchestratorNodeAddress } from "../types/common"; export interface Ingest { + origin?: string; base?: string; ingest: string; playback: string; diff --git a/packages/api/src/schema/schema.yaml b/packages/api/src/schema/schema.yaml index 918666e40a..232b314658 100644 --- a/packages/api/src/schema/schema.yaml +++ b/packages/api/src/schema/schema.yaml @@ -883,6 +883,11 @@ components: example: eaw4nk06ts2d0mzb index: true description: Used to form playback URL and storage folder + downloadUrl: + type: string + readOnly: true + example: https://cdn.livepeer.com/asset/eaw4nk06ts2d0mzb/video + description: URL to manually download the asset if desired userId: type: string readOnly: true @@ -906,6 +911,8 @@ components: meta: type: object description: User input metadata associated with the asset + additionalProperties: + type: string example: { "title": "My awesome video", @@ -1033,12 +1040,6 @@ components: - waiting - ready - failed - originTaskId: - type: string - index: true - readOnly: true - description: Task ID of the task that created the asset - example: 09F8B46C-61A0-4254-9875-F71F4C605BC7 sourceAssetId: type: string index: true @@ -1047,6 +1048,34 @@ components: ID of the source asset (root) - If missing, this is a root asset example: 09F8B46C-61A0-4254-9875-F71F4C605BC7 + new-asset-payload: + additionalProperties: false + required: + - name + properties: + objectStoreId: + type: string + description: Object store ID where the asset is stored + writeOnly: true + example: 09F8B46C-61A0-4254-9875-F71F4C605BC7 + name: + type: string + description: + Name of the asset. This is not necessarily the filename, can be a + custom name or title + example: filename.mp4 + meta: + type: object + description: User input metadata associated with the asset + additionalProperties: + type: string + example: + { + "title": "My awesome video", + "description": "This is a video of my awesome life", + "tags": ["awesome", "life", "video"], + } + task: type: object table: task @@ -1058,10 +1087,6 @@ components: index: true description: Task ID example: 09F8B46C-61A0-4254-9875-F71F4C605BC7 - name: - type: string - description: Name of the task - example: Import video file userId: type: string readOnly: true @@ -1074,21 +1099,23 @@ components: enum: - import - export + # - transcode createdAt: type: number readOnly: true description: Timestamp (in milliseconds) at which task was created example: 1587667174725 - updatedAt: - type: number - description: Timestamp (in milliseconds) at which task was updated - example: 1587667174725 deleted: type: boolean description: Set to true when the task is deleted - parentAssetId: + inputAssetId: + type: string + description: ID of the input asset + index: true + example: 09F8B46C-61A0-4254-9875-F71F4C605BC7 + outputAssetId: type: string - description: ID of the source asset + description: ID of the output asset index: true example: 09F8B46C-61A0-4254-9875-F71F4C605BC7 params: @@ -1109,6 +1136,15 @@ components: type: string description: S3 object key of the uploaded asset example: ABC123/filename.mp4 + export: + $ref: "#/components/schemas/export-task-params" + transcode: + type: object + additionalProperties: false + description: Parameters for the transcode task + properties: + params: + type: object status: type: object additionalProperties: false @@ -1151,6 +1187,110 @@ components: assetSpec: type: object $ref: "#/components/schemas/asset" + export: + type: object + additionalProperties: false + description: Output of the export task + properties: + internal: + writeOnly: true + description: | + Internal data of the export task that should not be returned + to users. Contains internal tracking information like which + service was used for the export in case it is maintained by + us (e.g. the first-party piñata service). + ipfs: + type: object + additionalProperties: false + required: [videoFileCid] + properties: + videoFileCid: + type: string + description: IPFS CID of the exported video file + videoFileUrl: + type: string + readOnly: true + description: + URL to access file through HTTP on an IPFS gateway + erc1155MetadataCid: + type: string + description: + IPFS CID of the default metadata exported for the video + erc1155MetadataUrl: + type: string + readOnly: true + description: + URL to access metadata file through HTTP on an IPFS + gateway + + export-task-params: + description: Parameters for the export task + oneOf: + - type: object + additionalProperties: false + required: [custom] + properties: + custom: + type: object + description: custom URL parameters for the export task + additionalProperties: false + required: [url] + properties: + url: + type: string + description: URL where to export the asset + example: https://s3.amazonaws.com/my-bucket/path/filename.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=LLMMB + method: + type: string + description: Method to use on the export request + default: PUT + headers: + type: object + description: Headers to add to the export request + additionalProperties: + type: string + - type: object + additionalProperties: false + required: [ipfs] + properties: + ipfs: + type: object + additionalProperties: false + properties: + pinata: + description: + Custom credentials for the Piñata service. Must have either + a JWT or an API key and an API secret. + oneOf: + - type: object + additionalProperties: false + required: [jwt] + properties: + jwt: + type: string + writeOnly: true + description: + Will be added to the Authorization header as a + Bearer token. + - type: object + additionalProperties: false + required: [apiKey, apiSecret] + properties: + apiKey: + type: string + description: + Will be added to the pinata_api_key header. + apiSecret: + type: string + writeOnly: true + description: + Will be added to the pinata_secret_api_key header. + erc1155Metadata: + type: object + description: + Additional data to add to the automatic ERC-1155 default + export. Will be deep merged with the default metadata + exported. api-token: type: object diff --git a/packages/api/src/store/table.ts b/packages/api/src/store/table.ts index 379cf64f4f..5622b5fd8b 100644 --- a/packages/api/src/store/table.ts +++ b/packages/api/src/store/table.ts @@ -12,6 +12,7 @@ import { FindOptions, UpdateOptions, DBLegacyObject, + FieldSpec, } from "./types"; const DEFAULT_SORT = "id ASC"; @@ -274,41 +275,34 @@ export default class Table { } } - cleanWriteOnlyResponse(doc: T): T { - // obfuscate writeOnly fields in objects returned + // obfuscates writeOnly fields in objects returned + cleanWriteOnlyResponse(doc: T, schema: FieldSpec = this.schema): T { + if (schema.oneOf?.length) { + for (const oneSchema of schema.oneOf) { + doc = this.cleanWriteOnlyResponse(doc, oneSchema); + } + } + if (!schema.properties) { + return doc; + } const res = { ...doc }; - if (this.schema.properties) { - for (const [fieldName, fieldArray] of Object.entries( - this.schema.properties - )) { - if (fieldArray.writeOnly) { - delete res[fieldName]; - } + for (const fieldName in schema.properties) { + const fieldSpec = schema.properties[fieldName]; + if (fieldSpec.writeOnly) { + delete res[fieldName]; + } else if (fieldSpec.properties && res[fieldName]) { + res[fieldName] = this.cleanWriteOnlyResponse( + res[fieldName] as any, + fieldSpec + ); } } return res; } + // obfuscates writeOnly fields in array of objects returned cleanWriteOnlyResponses(docs: Array): Array { - // obfuscate writeOnly fields in objects returned - const writeOnlyFields = []; - if (this.schema.properties) { - for (const [fieldName, fieldArray] of Object.entries( - this.schema.properties - )) { - if (fieldArray.writeOnly) { - writeOnlyFields.push(fieldName); - } - } - } - - return docs.map((doc) => { - const cleaned = { ...doc }; - for (const field of writeOnlyFields) { - delete cleaned[field]; - } - return cleaned; - }); + return docs.map((doc) => this.cleanWriteOnlyResponse(doc)); } // on startup: auto-create table if it doesn't exist diff --git a/packages/api/src/store/types.ts b/packages/api/src/store/types.ts index 6bee01f6e7..82cecaa5bf 100644 --- a/packages/api/src/store/types.ts +++ b/packages/api/src/store/types.ts @@ -1,6 +1,8 @@ export interface FieldSpec { [key: string]: any; writeOnly?: boolean; + oneOf?: FieldSpec[]; + properties?: Record; } export interface TableSchema { diff --git a/packages/api/src/task/scheduler.ts b/packages/api/src/task/scheduler.ts new file mode 100644 index 0000000000..c0bb10495e --- /dev/null +++ b/packages/api/src/task/scheduler.ts @@ -0,0 +1,151 @@ +import { ConsumeMessage } from "amqplib"; +import { db } from "../store"; +import messages from "../store/messages"; +import Queue from "../store/queue"; +import { Asset, Task } from "../schema/types"; +import { v4 as uuid } from "uuid"; +import { WithID } from "../store/types"; +export default class TaskScheduler { + queue: Queue; + running: boolean; + constructor({ queue }) { + this.running = true; + this.queue = queue; + } + async start() { + await this.queue.consume("task", this.handleTaskQueue.bind(this)); + } + + stop() { + // this.db.queue.unsetMsgHandler(); + this.running = false; + } + + async handleTaskQueue(data: ConsumeMessage) { + let event: messages.TaskResult; + try { + event = JSON.parse(data.content.toString()); + console.log("events: got task result message", event); + } catch (err) { + console.log("events: error parsing task message", err); + this.queue.ack(data); + return; + } + + let ack: boolean; + try { + ack = await this.processTaskEvent(event); + } catch (err) { + ack = true; + console.log("handleTaskQueue Error ", err); + } finally { + if (ack) { + this.queue.ack(data); + } else { + setTimeout(() => this.queue.nack(data), 1000); + } + } + } + + async processTaskEvent(event: messages.TaskResult): Promise { + const tasks = await db.task.find({ id: event.task.id }); + if (!tasks?.length || !tasks[0].length) { + console.log(`task event process error: task ${event.task.id} not found`); + return true; + } + const task = tasks[0][0]; + + // TODO: bundle all db updates in a single transaction + if (event.error) { + await this.failTask(task, event.error.message); + // TODO: retry task + console.log( + `task event process error: err="${event.error.message}" unretriable=${event.error.unretriable}` + ); + return true; + } + + if (event.task.type === "import") { + const assetSpec = event.output?.import?.assetSpec; + if (!assetSpec) { + const error = "bad task output: missing assetSpec"; + console.log( + `task event process error: err=${error} taskId=${event.task.id}` + ); + await this.failTask(task, error, event.output); + return true; + } + await db.asset.update(task.outputAssetId, { + size: assetSpec.size, + hash: assetSpec.hash, + videoSpec: assetSpec.videoSpec, + status: "ready", + updatedAt: Date.now(), + }); + } + await db.task.update(task.id, { + status: { + phase: "completed", + updatedAt: Date.now(), + }, + output: event.output, + }); + return true; + } + + private async failTask(task: Task, error: string, output?: Task["output"]) { + await db.task.update(task.id, { + output, + status: { + phase: "failed", + updatedAt: Date.now(), + errorMessage: error, + }, + }); + if (task.outputAssetId) { + await db.asset.update(task.outputAssetId, { + status: "failed", + updatedAt: Date.now(), + }); + } + } + + async scheduleTask( + type: Task["type"], + params: Task["params"], + inputAsset?: Asset, + outputAsset?: Asset + ) { + let task: WithID = { + id: uuid(), + createdAt: Date.now(), + type: type, + outputAssetId: outputAsset?.id, + inputAssetId: inputAsset?.id, + userId: inputAsset?.userId || outputAsset?.userId, + params, + status: { + phase: "pending", + updatedAt: Date.now(), + }, + }; + + task = await db.task.create(task); + await this.queue.publish("task", `task.trigger.${task.type}.${task.id}`, { + type: "task_trigger", + id: uuid(), + timestamp: Date.now(), + task: { + id: task.id, + type: task.type, + snapshot: task, + }, + }); + + await db.task.update(task.id, { + status: { phase: "waiting", updatedAt: Date.now() }, + }); + + return task; + } +} diff --git a/packages/api/src/task/task.ts b/packages/api/src/task/task.ts deleted file mode 100644 index f69fa8fc75..0000000000 --- a/packages/api/src/task/task.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { ConsumeMessage } from "amqplib"; -import { db } from "../store"; -import messages from "../store/messages"; -import Queue from "../store/queue"; - -export default class TaskScheduler { - queue: Queue; - running: boolean; - constructor({ queue }) { - this.running = true; - this.queue = queue; - } - async start() { - await this.queue.consume("task", this.handleTaskQueue.bind(this)); - } - - stop() { - // this.db.queue.unsetMsgHandler(); - this.running = false; - } - - async handleTaskQueue(data: ConsumeMessage) { - let event: messages.TaskResult; - try { - event = JSON.parse(data.content.toString()); - console.log("events: got task result message", event); - } catch (err) { - console.log("events: error parsing task message", err); - this.queue.ack(data); - return; - } - - let ack: boolean; - try { - ack = await this.processTaskEvent(event); - } catch (err) { - ack = true; - console.log("handleTaskQueue Error ", err); - } finally { - if (ack) { - this.queue.ack(data); - } else { - setTimeout(() => this.queue.nack(data), 1000); - } - } - } - - async processTaskEvent(event: messages.TaskResult): Promise { - let obj = await db.task.find({ id: event.task.id }); - if (obj?.length) { - let task = obj[0][0]; - if (event.error) { - db.task.update(task.id, { - status: { - errorMessage: event.error.message, - phase: "failed", - }, - updatedAt: Date.now(), - }); - if (!event.error.unretriable) { - console.log(`task event process error: ${event.error.message}`); - return true; - } - // TODO: retry task - return true; - } - - if (event.task.type == "import") { - if (event.output) { - let assetSpec; - try { - assetSpec = event.output.import.assetSpec; - } catch (e) { - console.log( - `task event process error: assetSpec not found in TaskResult for task ${event.task.id}` - ); - } - // TODO: bundle them in a single transaction - await db.asset.update(task.parentAssetId, { - hash: assetSpec.hash, - videoSpec: assetSpec.videoSpec, - size: assetSpec.size, - originTaskId: task.id, - status: "ready", - }); - - await db.task.update(task.id, { - status: { - phase: "completed", - updatedAt: Date.now(), - }, - output: event.output, - }); - return true; - } - } - console.log(`task type unknown: ${event.task.type}`); - } else { - console.log(`task event process error: task ${event.task.id} not found`); - return true; - } - return false; - } -} diff --git a/packages/api/src/types/common.d.ts b/packages/api/src/types/common.d.ts index 40ba648413..ff6248a20a 100644 --- a/packages/api/src/types/common.d.ts +++ b/packages/api/src/types/common.d.ts @@ -1,6 +1,7 @@ import { Ingest, Price } from "../middleware/hardcoded-nodes"; import { Stream, User, ApiToken } from "../schema/types"; import Queue from "../store/queue"; +import TaskScheduler from "../task/scheduler"; import { CliArgs } from "../parse-cli"; import Stripe from "stripe"; @@ -20,6 +21,7 @@ declare global { config?: CliArgs; store?: IStore; queue?: Queue; + taskScheduler?: TaskScheduler; stripe?: Stripe; frontendDomain: string; diff --git a/packages/api/src/webhooks/cannon.ts b/packages/api/src/webhooks/cannon.ts index 93313a6eec..a92e2dbda6 100644 --- a/packages/api/src/webhooks/cannon.ts +++ b/packages/api/src/webhooks/cannon.ts @@ -12,6 +12,8 @@ import { DBWebhook } from "../store/webhook-table"; import { fetchWithTimeout, RequestInitWithTimeout } from "../util"; import logger from "../logger"; import { sign, sendgridEmail } from "../controllers/helpers"; +import TaskScheduler from "../task/scheduler"; +import { generateUniquePlaybackId } from "../controllers/asset"; const WEBHOOK_TIMEOUT = 5 * 1000; const MAX_BACKOFF = 60 * 60 * 1000; @@ -29,6 +31,8 @@ export default class WebhookCannon { sendgridTemplateId: string; sendgridApiKey: string; supportAddr: string; + taskScheduler: TaskScheduler; + vodObjectStoreId: string; resolver: any; queue: Queue; constructor({ @@ -38,6 +42,8 @@ export default class WebhookCannon { sendgridTemplateId, sendgridApiKey, supportAddr, + taskScheduler, + vodObjectStoreId, verifyUrls, queue, }) { @@ -49,6 +55,8 @@ export default class WebhookCannon { this.sendgridTemplateId = sendgridTemplateId; this.sendgridApiKey = sendgridApiKey; this.supportAddr = supportAddr; + this.taskScheduler = taskScheduler; + this.vodObjectStoreId = vodObjectStoreId; this.resolver = new dns.Resolver(); this.queue = queue; // this.start(); @@ -107,6 +115,20 @@ export default class WebhookCannon { // new session was started, so recording is not ready yet return true; } + try { + let mp4RecordingUrl = event.payload?.mp4RecordingUrl; + + await this.recordingToVodAsset( + mp4RecordingUrl, + session.userId, + session.id + ); + } catch (e) { + console.log( + `Unable to make vod asset from recording with session id ${sessionId}`, + e + ); + } } const { data: webhooks } = await this.db.webhook.listSubscribed( @@ -483,4 +505,34 @@ export default class WebhookCannon { ); } } + + async recordingToVodAsset( + mp4RecordingUrl: string, + userId: string, + sessionId: string + ) { + const id = uuid(); + const playbackId = await generateUniquePlaybackId(this.store, sessionId); + + const asset = await this.db.asset.create({ + id, + playbackId, + userId, + createdAt: Date.now(), + status: "waiting", + name: `live-to-vod-${sessionId}`, + objectStoreId: this.vodObjectStoreId, + }); + + const task = await this.taskScheduler.scheduleTask( + "import", + { + import: { + url: mp4RecordingUrl, + }, + }, + undefined, + asset + ); + } } diff --git a/packages/www/public/sitemap.xml b/packages/www/public/sitemap.xml index a0e178326d..01661e8d30 100644 --- a/packages/www/public/sitemap.xml +++ b/packages/www/public/sitemap.xml @@ -1,117 +1,117 @@ -https://livepeer.comdaily0.72022-02-08T18:27:53.186Z -https://livepeer.com/blogdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/contactdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboarddaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboard/billingdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboard/billing/plansdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboard/developers/api-keysdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboard/developers/webhooksdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboard/sessionsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboard/stream-healthdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/dashboard/streamsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/forgot-passworddaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/logindaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/pricingdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/registerdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/reset-passworddaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/teamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/verifydaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/category/opiniondaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/category/customersdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/category/engineeringdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/category/productdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/category/educationdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/use-cases/music-streaming-platformsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/use-cases/ecommercedaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/use-cases/game-streaming-platformsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/use-cases/creator-platformsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/use-cases/24x7-channelsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/privacy-policydaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/terms-of-servicedaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/api-keydaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/apidaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/authenticationdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/check-webhook-signaturesdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/broadcastingdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/create-a-streamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/create-paywalldaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/stream/delete-streamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/multistream-target/delete-targetdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/debug-live-stream-issuesdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/errorsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/session/list-recorded-sessionsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/session/list-sessionsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/stream/listdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/multistream-target/list-targetsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/session/get-sessiondaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/stream/get-streamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/multistream-target/get-targetdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/api-keydaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/tutorialdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/handling-disconnectsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/ingestdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/monitoring-stream-healthdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/multistreamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/multistream-targetdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guidesdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-referencedaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/multistream-target/overviewdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/stream/overviewdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/session/overviewdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/stream/record-on-offdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/stream/update-streamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/multistream-target/update-targetdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/stream/post-streamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/multistream-target/create-targetdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/playbackdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/recorddaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/back-up-transcodingdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/srt-supportdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/sessiondaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streamingdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/api-reference/streamdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/cdndaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/support-matrixdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/reducing-latencydaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/usage-and-billingdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/webhookdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/docs/guides/start-live-streaming/verifydaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/integrate-livepeer-theoplayerdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/7-tips-to-take-your-live-streaming-to-the-next-leveldaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/new-stream-recording-and-mp4-download-featuresdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/New-Dashboarddaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/livepeer-lets-vimm-make-streaming-a-team-sportdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/video-tutorial-stream-into-livepeer-for-the-first-timedaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/first-livepeer-stream-in-five-minutesdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/integrating-with-livepeer-streaming-pipeline-software-suggestionsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/scaling-the-livepeer-networkdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/debugging-playback-issuesdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/live-streaming-on-social-media-platformsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/playdj-bringing-dj-s-into-people-s-homes-during-lockdowndaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/livepeer-helps-classicspark-give-musicians-a-pandemic-ready-stream-of-incomedaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/livepeer-helps-korkuma-bring-immersive-commerce-to-the-massesdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/the-future-of-online-fitnessdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/build-multistreaming-into-your-appsdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/intro-to-transcodingdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/live-streaming-basic-workflowdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/livestreaming-ecommerce-chinadaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/livepeer-always-on-transcoding-networkdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/live-streaming-in-a-world-emerging-from-a-global-pandemicdaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/blog/streaming-with-rtmp-apidaily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1529822daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1496262daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1496214daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1491881daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1476609daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1476601daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1466562daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1454194daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1414584daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1412804daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1412803daily0.72022-02-08T18:27:53.187Z -https://livepeer.com/jobs/1412799daily0.72022-02-08T18:27:53.187Z +https://livepeer.comdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blogdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/contactdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboarddaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboard/billingdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboard/billing/plansdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboard/developers/api-keysdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboard/developers/webhooksdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboard/sessionsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboard/stream-healthdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/dashboard/streamsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/forgot-passworddaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/logindaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/pricingdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/registerdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/reset-passworddaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/teamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/verifydaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/category/opiniondaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/category/customersdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/category/engineeringdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/category/productdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/category/educationdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/privacy-policydaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/terms-of-servicedaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/use-cases/music-streaming-platformsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/use-cases/ecommercedaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/use-cases/game-streaming-platformsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/use-cases/creator-platformsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/use-cases/24x7-channelsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/api-keydaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/apidaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/authenticationdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/check-webhook-signaturesdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/broadcastingdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/create-a-streamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/create-paywalldaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/stream/delete-streamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/multistream-target/delete-targetdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/debug-live-stream-issuesdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/errorsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/session/list-recorded-sessionsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/session/list-sessionsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/stream/listdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/multistream-target/list-targetsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/session/get-sessiondaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/stream/get-streamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/multistream-target/get-targetdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/api-keydaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/tutorialdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/handling-disconnectsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/ingestdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/monitoring-stream-healthdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/multistreamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/multistream-targetdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-referencedaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guidesdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/multistream-target/overviewdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/session/overviewdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/stream/overviewdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/stream/record-on-offdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/stream/update-streamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/multistream-target/update-targetdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/stream/post-streamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/multistream-target/create-targetdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/playbackdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/recorddaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/back-up-transcodingdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/srt-supportdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/sessiondaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streamingdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/api-reference/streamdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/cdndaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/support-matrixdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/reducing-latencydaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/usage-and-billingdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/webhookdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/docs/guides/start-live-streaming/verifydaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/integrate-livepeer-theoplayerdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/7-tips-to-take-your-live-streaming-to-the-next-leveldaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/new-stream-recording-and-mp4-download-featuresdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/New-Dashboarddaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/livepeer-lets-vimm-make-streaming-a-team-sportdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/video-tutorial-stream-into-livepeer-for-the-first-timedaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/first-livepeer-stream-in-five-minutesdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/integrating-with-livepeer-streaming-pipeline-software-suggestionsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/scaling-the-livepeer-networkdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/debugging-playback-issuesdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/live-streaming-on-social-media-platformsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/playdj-bringing-dj-s-into-people-s-homes-during-lockdowndaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/livepeer-helps-classicspark-give-musicians-a-pandemic-ready-stream-of-incomedaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/livepeer-helps-korkuma-bring-immersive-commerce-to-the-massesdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/the-future-of-online-fitnessdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/build-multistreaming-into-your-appsdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/intro-to-transcodingdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/live-streaming-basic-workflowdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/livestreaming-ecommerce-chinadaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/livepeer-always-on-transcoding-networkdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/live-streaming-in-a-world-emerging-from-a-global-pandemicdaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/blog/streaming-with-rtmp-apidaily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1529822daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1496262daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1496214daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1491881daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1476609daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1476601daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1466562daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1454194daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1414584daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1412804daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1412803daily0.72022-02-09T14:28:24.457Z +https://livepeer.com/jobs/1412799daily0.72022-02-09T14:28:24.457Z \ No newline at end of file