Skip to content

Commit 0d85d8e

Browse files
committed
feat: handle videos linked via IPFS
1 parent a07a7ff commit 0d85d8e

14 files changed

Lines changed: 528 additions & 87 deletions

File tree

‎.changeset/bright-timers-roll.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@mod-protocol/react-ui-shadcn": minor
3+
"@miniapps/livepeer-video": minor
4+
"web": minor
5+
"@miniapps/video-render": minor
6+
"api": minor
7+
---
8+
9+
feat: support videos linked via IPFS

‎.changeset/great-nails-deny.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mod-protocol/core": minor
3+
---
4+
5+
feat: add mimeType to UrlMetadata type

‎examples/api/.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Needs to be an IPFS api key
22
INFURA_API_KEY="REQUIRED"
33
INFURA_API_SECRET="REQUIRED"
4+
IPFS_DEFAULT_GATEWAY="REQUIRED"
5+
MICROLINK_API_KEY="REQUIRED"
46
GIPHY_API_KEY="REQUIRED"
57
MICROLINK_API_KEY="REQUIRED"
68
OPENSEA_API_KEY="REQUIRED"

‎examples/api/src/app/api/livepeer-video/route.ts‎

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,92 +2,88 @@ import { NextRequest, NextResponse } from "next/server";
22

33
export async function POST(request: NextRequest) {
44
const form = await request.formData();
5-
// https://docs.livepeer.org/reference/api#upload-an-asset
6-
const requestedUrlReq = await fetch(
7-
"https://livepeer.studio/api/asset/request-upload",
5+
6+
const controller = new AbortController();
7+
const signal = controller.signal;
8+
9+
// Cancel upload if it takes longer than 15s
10+
setTimeout(() => {
11+
controller.abort();
12+
}, 15_000);
13+
14+
const uploadRes: Response | null = await fetch(
15+
"https://ipfs.infura.io:5001/api/v0/add",
816
{
917
method: "POST",
18+
body: form,
1019
headers: {
11-
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
12-
"Content-Type": "application/json",
20+
Authorization:
21+
"Basic " +
22+
Buffer.from(
23+
process.env.INFURA_API_KEY + ":" + process.env.INFURA_API_SECRET
24+
).toString("base64"),
1325
},
14-
body: JSON.stringify({
15-
name: "video.mp4",
16-
staticMp4: true,
17-
playbackPolicy: {
18-
type: "public",
19-
},
20-
storage: {
21-
ipfs: true,
22-
},
23-
}),
26+
signal,
2427
}
2528
);
2629

27-
const requestedUrl = await requestedUrlReq.json();
30+
const { Hash: hash } = await uploadRes.json();
2831

29-
const url = requestedUrl.url;
32+
const responseData = { url: `ipfs://${hash}` };
3033

31-
const videoUpload = await fetch(url, {
32-
method: "PUT",
33-
headers: {
34-
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
35-
"Content-Type": "video/mp4",
36-
},
37-
body: form.get("file"),
38-
});
34+
return NextResponse.json({ data: responseData });
35+
}
3936

40-
if (videoUpload.status >= 400) {
41-
return NextResponse.json(
42-
{ message: "Something went wrong" },
43-
{
44-
status: videoUpload.status,
45-
}
46-
);
47-
}
37+
// needed for preflight requests to succeed
38+
export const OPTIONS = async (request: NextRequest) => {
39+
return NextResponse.json({});
40+
};
4841

49-
// simpler than webhooks, but constrained by serverless function timeout time
50-
let isUploadSuccess = false;
51-
let maxTries = 10;
52-
let tries = 0;
53-
while (!isUploadSuccess && tries < maxTries) {
54-
const details = await fetch(
55-
`https://livepeer.studio/api/asset/${requestedUrl.asset.id}`,
56-
{
57-
method: "GET",
58-
headers: {
59-
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
60-
"Content-Type": "application/json",
61-
},
62-
}
63-
);
64-
const detailsJson = await details.json();
42+
export const GET = async (request: NextRequest) => {
43+
let url = request.nextUrl.searchParams.get("url");
6544

66-
if (detailsJson.status !== "waiting") {
67-
break;
68-
}
45+
// Exchange for livepeer url
46+
const cid = url.replace("ipfs://", "");
47+
const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`;
6948

70-
// wait 1s
71-
await new Promise((resolve) => setTimeout(() => resolve(null), 1000));
72-
tries = tries + 1;
73-
}
49+
// Get HEAD to get content type
50+
const response = await fetch(gatewayUrl, { method: "HEAD" });
51+
const contentType = response.headers.get("content-type");
7452

75-
if (tries === maxTries) {
76-
return NextResponse.json(
77-
{
78-
message: "Took too long to upload. Try a smaller file",
53+
// TODO: Cache this
54+
const uploadRes = await fetch(
55+
"https://livepeer.studio/api/asset/upload/url",
56+
{
57+
method: "POST",
58+
headers: {
59+
Authorization: `Bearer ${process.env.LIVEPEER_API_SECRET}`,
60+
"Content-Type": "application/json",
7961
},
80-
{ status: 400 }
81-
);
62+
body: JSON.stringify({
63+
name: "filename.mp4",
64+
staticMp4: contentType === "video/mp4" ? true : false,
65+
playbackPolicy: {
66+
type: "public",
67+
},
68+
url: gatewayUrl,
69+
}),
70+
}
71+
);
72+
73+
if (!uploadRes.ok) {
74+
// console.error(uploadRes.status, await uploadRes.text());
75+
return NextResponse.error();
8276
}
8377

84-
// hack, wait at least 3s to make sure url doesn't error
85-
await new Promise((resolve) => setTimeout(() => resolve(null), 3000));
78+
const { asset } = await uploadRes.json();
8679

87-
return NextResponse.json({ data: requestedUrl });
88-
}
80+
const playbackUrl = `https://lp-playback.com/hls/${asset.playbackId}/index.m3u8`;
8981

90-
// needed for preflight requests to succeed
91-
export const OPTIONS = async (request: NextRequest) => {
92-
return NextResponse.json({});
82+
return NextResponse.json({
83+
url: playbackUrl,
84+
fallbackUrl: gatewayUrl,
85+
mimeType: contentType,
86+
});
9387
};
88+
89+
export const runtime = "edge";

‎examples/api/src/app/api/open-graph/lib/url-handlers/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import caip19 from "./caip-19";
33
import fallback from "./fallback";
44
import opensea from "./opensea";
55
import zora from "./zora";
6+
import ipfs from "./ipfs";
67

7-
const handlers: UrlHandler[] = [opensea, zora, caip19, fallback];
8+
const handlers: UrlHandler[] = [opensea, zora, caip19, ipfs, fallback];
89

910
export default handlers;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { UrlMetadata } from "@mod-protocol/core";
2+
import { UrlHandler } from "../../types/url-handler";
3+
4+
async function handleIpfsUrl(url: string): Promise<UrlMetadata | null> {
5+
const cid = url.replace("ipfs://", "");
6+
7+
const gatewayUrl = `${process.env.IPFS_DEFAULT_GATEWAY}/${cid}`;
8+
9+
// Get HEAD only
10+
const response = await fetch(gatewayUrl, { method: "HEAD" });
11+
12+
if (!response.ok) {
13+
return null;
14+
}
15+
16+
const contentType = response.headers.get("content-type");
17+
18+
if (!contentType) {
19+
return null;
20+
}
21+
22+
// TODO: Generate thumbnail if image/video
23+
24+
const urlMetadata: UrlMetadata = {
25+
title: `IPFS ${cid}`,
26+
mimeType: contentType,
27+
};
28+
29+
return urlMetadata;
30+
}
31+
32+
const handler: UrlHandler = {
33+
matchers: ["ipfs://.*"],
34+
handler: handleIpfsUrl,
35+
};
36+
37+
export default handler;

‎examples/api/src/app/api/open-graph/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { NextResponse, NextRequest } from "next/server";
21
import { UrlMetadata } from "@mod-protocol/core";
2+
import { NextRequest, NextResponse } from "next/server";
33
import urlHandlers from "./lib/url-handlers";
44

55
export async function GET(request: NextRequest) {

‎examples/nextjs-shadcn/src/app/dummy-casts.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,11 @@ export const dummyCastData: Array<{
115115
// video embed
116116
{
117117
url: "https://lp-playback.com/hls/3087gff2uxc09ze1/index.m3u8",
118+
// url: "ipfs://QmdeTAKogKpZVLpp2JLsjfM83QV46bnVrHTP1y89DvR57i",
118119
status: "loaded",
119-
metadata: {},
120+
metadata: {
121+
mimeType: "video/mp4",
122+
},
120123
},
121124
],
122125
},

‎miniapps/livepeer-video/src/upload.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ const upload: ModElement[] = [
1717
},
1818
onsuccess: {
1919
type: "ADDEMBED",
20-
url: "https://lp-playback.com/hls/{{refs.myFileUploadRequest.response.data.data.asset.playbackId}}/index.m3u8",
20+
// url: "https://lp-playback.com/hls/{{refs.myFileUploadRequest.response.data.data.asset.playbackId}}/index.m3u8",
21+
url: "{{refs.myFileUploadRequest.response.data.data.url}}",
2122
name: "{{refs.myOpenFileAction.files[0].name}}",
2223
mimeType: "{{refs.myOpenFileAction.files[0].mimeType}}",
2324
onsuccess: {

‎miniapps/video-render/src/manifest.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ const manifest: ModManifest = {
1818
},
1919
element: view,
2020
},
21+
{
22+
if: {
23+
value: "{{embed.metadata.mimeType}}",
24+
match: {
25+
startsWith: "video/",
26+
},
27+
},
28+
element: view,
29+
},
2130
],
2231
elements: {
2332
"#view": view,

0 commit comments

Comments
 (0)