-
Notifications
You must be signed in to change notification settings - Fork 9
feat: IPFS-based video support #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
87b11d6
f626dfd
6b6d9f4
ed0f0d0
38d9420
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
--- | ||
"@mod-protocol/react-ui-shadcn": minor | ||
"@miniapps/livepeer-video": minor | ||
"web": minor | ||
"@miniapps/video-render": minor | ||
"api": minor | ||
--- | ||
|
||
feat: support videos linked via IPFS |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@mod-protocol/core": minor | ||
--- | ||
|
||
feat: add mimeType to UrlMetadata type |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { NextRequest, NextResponse } from "next/server"; | ||
|
||
export async function POST(request: NextRequest) { | ||
const form = await request.formData(); | ||
|
||
const controller = new AbortController(); | ||
const signal = controller.signal; | ||
|
||
// Cancel upload if it takes longer than 15s | ||
setTimeout(() => { | ||
controller.abort(); | ||
}, 15_000); | ||
|
||
const uploadRes: Response | null = await fetch( | ||
"https://ipfs.infura.io:5001/api/v0/add", | ||
{ | ||
method: "POST", | ||
body: form, | ||
headers: { | ||
Authorization: | ||
"Basic " + | ||
Buffer.from( | ||
process.env.INFURA_API_KEY + ":" + process.env.INFURA_API_SECRET | ||
).toString("base64"), | ||
}, | ||
signal, | ||
} | ||
); | ||
|
||
const { Hash: hash } = await uploadRes.json(); | ||
|
||
const responseData = { url: `ipfs://${hash}` }; | ||
|
||
return NextResponse.json({ data: responseData }); | ||
} | ||
|
||
// needed for preflight requests to succeed | ||
export const OPTIONS = async (request: NextRequest) => { | ||
return NextResponse.json({}); | ||
}; | ||
|
||
export const GET = async ( | ||
req: NextRequest, | ||
{ params }: { params: { assetId: string } } | ||
) => { | ||
const assetRequest = await fetch( | ||
`https://livepeer.studio/api/asset/${params.assetId}`, | ||
{ | ||
method: "GET", | ||
headers: { | ||
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`, | ||
}, | ||
} | ||
); | ||
|
||
const assetResponseJson = await assetRequest.json(); | ||
const { playbackUrl } = assetResponseJson; | ||
|
||
if (!playbackUrl) { | ||
return NextResponse.json({}, { status: 404 }); | ||
} | ||
|
||
return NextResponse.json({ | ||
url: playbackUrl, | ||
}); | ||
}; | ||
|
||
export const runtime = "edge"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,92 +2,86 @@ import { NextRequest, NextResponse } from "next/server"; | |
|
||
export async function POST(request: NextRequest) { | ||
const form = await request.formData(); | ||
// https://docs.livepeer.org/reference/api#upload-an-asset | ||
const requestedUrlReq = await fetch( | ||
"https://livepeer.studio/api/asset/request-upload", | ||
|
||
const controller = new AbortController(); | ||
const signal = controller.signal; | ||
|
||
// Cancel upload if it takes longer than 15s | ||
setTimeout(() => { | ||
controller.abort(); | ||
}, 15_000); | ||
|
||
const uploadRes: Response | null = await fetch( | ||
"https://ipfs.infura.io:5001/api/v0/add", | ||
{ | ||
method: "POST", | ||
body: form, | ||
headers: { | ||
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`, | ||
"Content-Type": "application/json", | ||
Authorization: | ||
"Basic " + | ||
Buffer.from( | ||
process.env.INFURA_API_KEY + ":" + process.env.INFURA_API_SECRET | ||
).toString("base64"), | ||
}, | ||
body: JSON.stringify({ | ||
name: "video.mp4", | ||
staticMp4: true, | ||
playbackPolicy: { | ||
type: "public", | ||
}, | ||
storage: { | ||
ipfs: true, | ||
}, | ||
}), | ||
signal, | ||
} | ||
); | ||
|
||
const requestedUrl = await requestedUrlReq.json(); | ||
const { Hash: hash } = await uploadRes.json(); | ||
|
||
const url = requestedUrl.url; | ||
const responseData = { url: `ipfs://${hash}` }; | ||
|
||
const videoUpload = await fetch(url, { | ||
method: "PUT", | ||
headers: { | ||
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`, | ||
"Content-Type": "video/mp4", | ||
}, | ||
body: form.get("file"), | ||
}); | ||
return NextResponse.json({ data: responseData }); | ||
} | ||
|
||
if (videoUpload.status >= 400) { | ||
return NextResponse.json( | ||
{ message: "Something went wrong" }, | ||
{ | ||
status: videoUpload.status, | ||
} | ||
); | ||
} | ||
// needed for preflight requests to succeed | ||
export const OPTIONS = async (request: NextRequest) => { | ||
return NextResponse.json({}); | ||
}; | ||
|
||
// simpler than webhooks, but constrained by serverless function timeout time | ||
let isUploadSuccess = false; | ||
let maxTries = 10; | ||
let tries = 0; | ||
while (!isUploadSuccess && tries < maxTries) { | ||
const details = await fetch( | ||
`https://livepeer.studio/api/asset/${requestedUrl.asset.id}`, | ||
{ | ||
method: "GET", | ||
headers: { | ||
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`, | ||
"Content-Type": "application/json", | ||
}, | ||
} | ||
); | ||
const detailsJson = await details.json(); | ||
export const GET = async (request: NextRequest) => { | ||
let url = request.nextUrl.searchParams.get("url"); | ||
|
||
if (detailsJson.status !== "waiting") { | ||
break; | ||
} | ||
// Exchange for livepeer url | ||
const cid = url.replace("ipfs://", ""); | ||
const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`; | ||
|
||
// wait 1s | ||
await new Promise((resolve) => setTimeout(() => resolve(null), 1000)); | ||
tries = tries + 1; | ||
} | ||
// Get HEAD to get content type | ||
const response = await fetch(gatewayUrl, { method: "HEAD" }); | ||
const contentType = response.headers.get("content-type"); | ||
|
||
if (tries === maxTries) { | ||
return NextResponse.json( | ||
{ | ||
message: "Took too long to upload. Try a smaller file", | ||
// TODO: Cache this | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think livepeer automatically caches this - if you request via an IPFS cid it doesn't re-upload if it's been uploaded to livepeer previously. Worth confirming we're doing it correctly to get this benefit. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just noticing that their API is returning a different There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok will do There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could it be this:
https://docs.livepeer.org/reference/api#upload-an-asset I think the IPFS url uses our subdomain gateway as it stands There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (I haven't asked them yet) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. try upload to web3.storage instead of infura for our gateway |
||
const uploadRes = await fetch( | ||
"https://livepeer.studio/api/asset/upload/url", | ||
{ | ||
method: "POST", | ||
headers: { | ||
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`, | ||
"Content-Type": "application/json", | ||
}, | ||
{ status: 400 } | ||
); | ||
} | ||
body: JSON.stringify({ | ||
name: "filename.mp4", | ||
staticMp4: contentType === "video/mp4" ? true : false, | ||
playbackPolicy: { | ||
type: "public", | ||
}, | ||
url: url, | ||
}), | ||
} | ||
); | ||
|
||
// hack, wait at least 3s to make sure url doesn't error | ||
await new Promise((resolve) => setTimeout(() => resolve(null), 3000)); | ||
if (!uploadRes.ok) { | ||
// console.error(uploadRes.status, await uploadRes.text()); | ||
return NextResponse.error(); | ||
} | ||
|
||
return NextResponse.json({ data: requestedUrl }); | ||
} | ||
const { asset } = await uploadRes.json(); | ||
|
||
// needed for preflight requests to succeed | ||
export const OPTIONS = async (request: NextRequest) => { | ||
return NextResponse.json({}); | ||
return NextResponse.json({ | ||
id: asset.id, | ||
fallbackUrl: gatewayUrl, | ||
mimeType: contentType, | ||
}); | ||
}; | ||
|
||
export const runtime = "edge"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { UrlMetadata } from "@mod-protocol/core"; | ||
import { UrlHandler } from "../../types/url-handler"; | ||
|
||
async function handleIpfsUrl(url: string): Promise<UrlMetadata | null> { | ||
const cid = url.replace("ipfs://", ""); | ||
|
||
const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`; | ||
|
||
// Get HEAD only | ||
const response = await fetch(gatewayUrl, { method: "HEAD" }); | ||
|
||
if (!response.ok) { | ||
return null; | ||
} | ||
|
||
const contentType = response.headers.get("content-type"); | ||
|
||
if (!contentType) { | ||
return null; | ||
} | ||
|
||
// TODO: Generate thumbnail if image/video | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would be nice to include this |
||
|
||
const urlMetadata: UrlMetadata = { | ||
title: `IPFS ${cid}`, | ||
mimeType: contentType, | ||
}; | ||
|
||
return urlMetadata; | ||
} | ||
|
||
const handler: UrlHandler = { | ||
name: "IPFS", | ||
matchers: ["ipfs://.*"], | ||
handler: handleIpfsUrl, | ||
}; | ||
|
||
export default handler; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import type { Config } from 'jest'; | ||
|
||
const jestConfig: Config = { | ||
testEnvironment: 'node', | ||
moduleNameMapper: { | ||
'^~/(.*)$': '<rootDir>/src/$1', | ||
'^(.+)_generated.js$': '$1_generated', // Support flatc generated files | ||
}, | ||
coveragePathIgnorePatterns: ['<rootDir>/build/', '<rootDir>/node_modules/'], | ||
testPathIgnorePatterns: ['<rootDir>/build', '<rootDir>/node_modules'], | ||
extensionsToTreatAsEsm: ['.ts'], | ||
/** | ||
* For high performance with minimal configuration transform with TS with swc. | ||
* @see https://github.com/farcasterxyz/hubble/issues/314 | ||
*/ | ||
transform: { | ||
'^.+\\.(t|j)sx?$': '@swc/jest', | ||
}, | ||
}; | ||
|
||
export default jestConfig; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
try: I think this needs to be "https://w3s.link/ipfs/${cid}" https://github.com/livepeer/livepeer-react/blob/main/packages/core-react/src/components/media/player/useSourceMimeTyped.tsx
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried it, still no caching
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok will try emailing them again
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stephancill can you try
ipfs://{CID}
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting - it uploads fine without specifying a gateway, but still no caching (returns a different asset id each time it uploads)