Skip to content

Commit

Permalink
feat: decoupled video config (#306)
Browse files Browse the repository at this point in the history
* example: try setting callback / hook in config

* fix: add setup config decoupled from Next

* fix: config tests

* fix: post request for pages router

* fix: allow asset create on GET in dev mode

* fix: pages route handler, use body url

* fix: useInterval stuck running in CI

* docs: add metadata storage hooks docs

* docs: add ESM config

* docs: clean up
  • Loading branch information
luwes authored Oct 28, 2024
1 parent fb7b28b commit e0b426c
Show file tree
Hide file tree
Showing 14 changed files with 234 additions and 82 deletions.
103 changes: 101 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,35 @@ yarn add next-video
pnpm add next-video
```

#### Add Next Video to `next.config.js`
#### Add Next Video to your Next.js config

**`next.config.js`**

If you're using CommonJS modules:

```js
/** @type {import('next').NextConfig} */
const { withNextVideo } = require('next-video/process');

/** @type {import('next').NextConfig} */
const nextConfig = {}; // Your current Next Config object

module.exports = withNextVideo(nextConfig);
```

**`next.config.mjs`**

If you're using ES modules:

```js
import { withNextVideo } from 'next-video/process';

/** @type {import('next').NextConfig} */
const nextConfig = {};

export default withNextVideo(nextConfig);
```


#### Add video import types to `tsconfig.json`

This is only required if you're using TypeScript, and makes sure your video file imports don't yell at you for missing types. `video.d.ts` should have been created in your project root when you ran `npx next-video init`, if not you can create it manually:
Expand Down Expand Up @@ -420,6 +438,87 @@ Supported providers with their required environment variables:
```
</details>


### Asset metadata storage hooks (callbacks)

By default the asset metadata is stored in a JSON file in the `/videos` directory.
If you want to store the metadata in a database or elsewhere you can customize
the storage hooks in a separate next-video config file.

The below example config shows the default storage hooks for the JSON file storage.

These hooks can be customized to fit your needs by changing the body of the
`loadAsset`, `saveAsset`, and `updateAsset` functions.

```js
// next-video.mjs
import { NextVideo } from 'next-video/process';
import path from 'node:path';
import { mkdir, readFile, writeFile } from 'node:fs/promises';

export const { GET, POST, handler, withNextVideo } = NextVideo({
// Other next-video config options should be added here if using a next-video config file.
// folder: 'videos',
// path: '/api/video',

loadAsset: async function (assetPath) {
const file = await readFile(assetPath);
const asset = JSON.parse(file.toString());
return asset;
},
saveAsset: async function (assetPath, asset) {
try {
await mkdir(path.dirname(assetPath), { recursive: true });
await writeFile(assetPath, JSON.stringify(asset), {
flag: 'wx',
});
} catch (err) {
if (err.code === 'EEXIST') {
// The file already exists, and that's ok in this case. Ignore the error.
return;
}
throw err;
}
},
updateAsset: async function (assetPath, asset) {
await writeFile(assetPath, JSON.stringify(asset));
}
});
```

Then import the `withNextVideo` function in your `next.config.mjs` file.

```js
// next.config.mjs
import { withNextVideo } from './next-video.mjs';

/** @type {import('next').NextConfig} */
const nextConfig = {};

export default withNextVideo(nextConfig);
```

Lastly import the `GET` and `POST`, or `handler` functions in your API routes as you see fit.
The handlers expect a `url` query or body parameter with the video source URL.

These are the most minimal examples for the handlers, typically you would add
more error handling and validation, authentication and authorization.

**App router (Next.js >=13)**

```js
// app/api/video/route.js
export { GET, POST } from '@/next-video';
```

**Pages router (Next.js)**

```js
// pages/api/video/[[...handler]].js
export { handler as default } from '@/next-video';
```


## Roadmap

### v0
Expand Down
3 changes: 2 additions & 1 deletion examples/default-provider/app/api/video/route.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { GET } from 'next-video/request-handler';
//export { GET } from 'next-video/request-handler';
export { GET, POST } from '@/next-video';
11 changes: 11 additions & 0 deletions examples/default-provider/next-video.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NextVideo } from 'next-video/process';
import { readFile } from 'fs/promises';

export const { GET, POST, handler, withNextVideo } = NextVideo({
loadAsset: async function (assetPath) {
console.warn(99, assetPath);
const file = await readFile(assetPath);
const asset = JSON.parse(file.toString());
return asset;
},
});
2 changes: 1 addition & 1 deletion examples/default-provider/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { withNextVideo } from 'next-video/process';
import { withNextVideo } from './next-video.mjs';

/** @type {import('next').NextConfig} */
const nextConfig = (phase, { defaultConfig }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"status":"ready","originalFilePath":"https://storage.googleapis.com/muxdemofiles/mux.mp4","provider":"mux","providerMetadata":{"mux":{"assetId":"EploFGgmKULMpiyDFwsy5c6lmGcg8dkObaVvnPMcdkQ","playbackId":"jxEf6XiJs6JY017pSzpv8Hd6tTbdAOecHTq4FiFAn564"}},"createdAt":1710979438730,"updatedAt":1710979441038,"sources":[{"src":"https://stream.mux.com/jxEf6XiJs6JY017pSzpv8Hd6tTbdAOecHTq4FiFAn564.m3u8","type":"application/x-mpegURL"}],"poster":"https://image.mux.com//thumbnail.webp","blurDataURL":"data:image/webp;base64,UklGRlAAAABXRUJQVlA4IEQAAACwAQCdASoQAAkAAQAcJZwAAueBHFYwAP7+sPJ01xp5AM+XuhDsRQ67ZYXXhHDkrqsIkUGjQSCMuENc5y3Qg0o9pZgAAA=="}
{"status":"ready","originalFilePath":"https://storage.googleapis.com/muxdemofiles/mux.mp4","provider":"mux","providerMetadata":{"mux":{"assetId":"z5Uxnqzcjrpjx01eea00RCl5DjWXcgj8EWW0100F1Iw9id8","playbackId":"lC32n4JjLfdFWmfCPTSvF2DR1D016VuhDovLyFgWDddE"}},"createdAt":1728945484374,"updatedAt":1728945487203,"sources":[{"src":"https://stream.mux.com/lC32n4JjLfdFWmfCPTSvF2DR1D016VuhDovLyFgWDddE.m3u8","type":"application/x-mpegURL"}],"poster":"https://image.mux.com/lC32n4JjLfdFWmfCPTSvF2DR1D016VuhDovLyFgWDddE/thumbnail.webp","blurDataURL":"data:image/webp;base64,UklGRlAAAABXRUJQVlA4IEQAAACwAQCdASoQAAkAAQAcJZwAAueBHFYwAP7+sPJ01xp5AM+XuhDsRQ67ZYXXhHDkrqsIkUGjQSCMuENc5y3Qg0o9pZgAAA=="}
2 changes: 1 addition & 1 deletion src/assets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as path from 'node:path';
import { cwd } from 'node:process';
import { stat, readFile, writeFile, mkdir } from 'node:fs/promises';
import { stat } from 'node:fs/promises';
import { getVideoConfig } from './config.js';
import { deepMerge, camelCase, isRemote, toSafePath } from './utils/utils.js';
import * as transformers from './providers/transformers.js';
Expand Down
13 changes: 3 additions & 10 deletions src/components/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,17 @@ export function useInterval(callback: () => any, delay: number | null) {
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
});

// Set up the interval.
useEffect(() => {
let id: any;

const tick = async () => {
// Wait to kick off another async callback until the current one is finished.
await savedCallback.current?.();

if (delay != null) {
id = setTimeout(tick, delay);
}
};

if (delay != null) {
id = setTimeout(tick, delay);
return () => clearTimeout(id);
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
Expand Down
24 changes: 13 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { cwd } from 'node:process';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import nextConfig from 'next/config.js';
import type { NextConfig } from 'next';
import { Asset } from './assets';
import { mkdir, readFile, writeFile } from 'node:fs/promises';

// @ts-ignore
const getConfig = nextConfig.default;

/**
* Video configurations
*/
Expand Down Expand Up @@ -98,25 +94,31 @@ export const videoConfigDefault: VideoConfigComplete = {
}
};

let videoConfigComplete: VideoConfigComplete = videoConfigDefault;

export function setVideoConfig(videoConfig?: VideoConfig): VideoConfigComplete {
videoConfigComplete = Object.assign({}, videoConfigDefault, videoConfig);
return videoConfigComplete;
}

/**
* The video config is set in `next.config.js` and passed to the `withNextVideo` function.
* The video config is then stored in `serverRuntimeConfig`.
* The video config is then stored via the `setVideoConfig` function.
*/
export async function getVideoConfig(): Promise<VideoConfigComplete> {
let nextConfig: NextConfig | undefined = getConfig();
if (!nextConfig?.serverRuntimeConfig?.nextVideo) {
let videoConfig: NextConfig | undefined = videoConfigComplete;
if (!videoConfig) {
try {
nextConfig = await importConfig('next.config.js');
await importConfig('next.config.js');
} catch (err) {
try {
nextConfig = await importConfig('next.config.mjs');
await importConfig('next.config.mjs');
} catch {
console.error('Failed to load next-video config.');
}
}
}

return nextConfig?.serverRuntimeConfig?.nextVideo;
return videoConfigComplete;
}

async function importConfig(file: string) {
Expand Down
3 changes: 2 additions & 1 deletion src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { videoHandler, callHandler } from './video-handler.js';
import { uploadLocalFile } from './handlers/local-upload.js';
import { uploadRequestedFile } from './handlers/api-request.js';
import log from './utils/logger.js';
import { NextVideo } from './setup-next-video.js';
import { withNextVideo } from './with-next-video.js';

try {
Expand All @@ -17,4 +18,4 @@ try {
console.error(err);
}

export { videoHandler, withNextVideo, callHandler };
export { videoHandler, callHandler, NextVideo, withNextVideo };
70 changes: 55 additions & 15 deletions src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,93 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { callHandler } from './process.js';
import { createAsset, getAsset } from './assets.js';
import { getVideoConfig } from './config.js';
import { isRemote } from './utils/utils.js';

// App Router
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const url = searchParams.get('url');
const { status, data } = await handleRequest(url);
const { status, data } = await getRequest(url);
// @ts-ignore - Response.json() is only valid from TypeScript 5.2
return Response.json(data, { status });
}

// App Router
export async function POST(request: Request) {
const { url } = await request.json();
const { status, data } = await postRequest(url);
// @ts-ignore - Response.json() is only valid from TypeScript 5.2
return Response.json(data, { status });
}

// Pages Router
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { status, data } = await handleRequest(String(req.query.url));

if (req.method === 'POST') {
const { status, data } = await postRequest(String(req.body.url));
res.status(status).json(data);
return;
}

const { status, data } = await getRequest(String(req.query.url));
res.status(status).json(data);
}

async function handleRequest(url?: string | null) {
async function getRequest(url?: string | null) {
if (!url) {
return {
status: 400,
data: { error: 'url parameter is required' }
};
}

if (!isRemote(url)) {
// todo: handle local files via string src
let asset;
try {
asset = await getAsset(url);
} catch {

// In dev mode we try to create the asset if it doesn't exist on a GET request.
const isDevMode = process.env.NODE_ENV === 'development';

if (isDevMode) {
asset = await createAsset(url);

if (asset) {
const videoConfig = await getVideoConfig();
await callHandler('request.video.added', asset, videoConfig);

return { status: 200, data: asset };
} else {
return { status: 500, data: { error: 'could not create asset' } };
}
}

return { status: 404, data: { error: 'asset not found' } };
}

return { status: 200, data: asset };
}

async function postRequest(url?: string | null) {
if (!url) {
return {
status: 400,
data: { error: 'local files should be imported as a module' }
data: { error: 'url parameter is required' }
};
}

let asset;
try {
asset = await getAsset(url);
} catch {
// todo: does this require auth?
asset = await createAsset(url);

if (asset) {
const videoConfig = await getVideoConfig();
await callHandler('request.video.added', asset, videoConfig);
if (!asset) {
return { status: 500, data: { error: 'could not create asset' } };
}

const videoConfig = await getVideoConfig();
await callHandler('request.video.added', asset, videoConfig);

return { status: 200, data: asset };
} catch {
return { status: 500, data: { error: 'could not create asset' } };
}

return { status: 200, data: asset };
}
20 changes: 20 additions & 0 deletions src/setup-next-video.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { setVideoConfig } from './config.js';
import handler, { GET, POST } from './request-handler.js';
import { withNextVideo as withNextVideoInternal } from './with-next-video.js';
import type { VideoConfig } from './config.js';
import type { NextConfig } from 'next';

export function NextVideo(config?: VideoConfig) {
setVideoConfig(config);

const withNextVideo = (nextConfig: NextConfig) => {
return withNextVideoInternal(nextConfig, config);
};

return {
GET,
POST,
handler,
withNextVideo,
};
}
Loading

0 comments on commit e0b426c

Please sign in to comment.