├── .nvmrc ├── .tool-versions ├── CODEOWNERS ├── public ├── map.png ├── favicon.ico ├── stack.png ├── truck.png ├── icon-192.png ├── icon-512.png ├── truckload.png ├── apple-touch-icon.png ├── screenshots │ └── ngrok-url.png ├── manifest.webmanifest └── icon.svg ├── .env.example ├── app ├── _styles │ └── globals.css ├── components │ ├── shared │ │ ├── list-videos.tsx │ │ ├── review.tsx │ │ ├── migration-status.tsx │ │ └── import-settings.tsx │ ├── platforms │ │ ├── additional-config.tsx │ │ ├── s3 │ │ │ └── logo.svg │ │ ├── video-filter.tsx │ │ ├── mux │ │ │ └── logo.svg │ │ ├── cloudflare │ │ │ └── logo.svg │ │ ├── platform-list.tsx │ │ ├── vimeo │ │ │ └── logo.svg │ │ ├── credentials-form.tsx │ │ └── api-video │ │ │ └── logo.svg │ ├── heading.tsx │ ├── banner.tsx │ └── sidebar.tsx ├── inngest │ ├── providers │ │ ├── api-video │ │ │ ├── constants.ts │ │ │ ├── types.ts │ │ │ └── api-video.ts │ │ ├── index.ts │ │ ├── cloudflare-stream │ │ │ ├── types.ts │ │ │ └── cloudflare-stream.ts │ │ ├── s3 │ │ │ └── s3.ts │ │ ├── mux │ │ │ └── mux.ts │ │ └── vimeo │ │ │ ├── types.ts │ │ │ └── vimeo.ts │ ├── client.ts │ └── functions.ts ├── partykit.json ├── api │ ├── videos │ │ └── route.ts │ ├── job │ │ └── route.ts │ ├── inngest │ │ └── route.ts │ ├── webhooks │ │ └── mux │ │ │ └── route.ts │ └── credentials │ │ ├── api-video.ts │ │ └── route.ts ├── utils │ ├── job.tsx │ └── store.tsx ├── layout.tsx ├── party │ └── index.ts └── page.tsx ├── .prettierignore ├── .husky └── pre-commit ├── postcss.config.js ├── next.config.js ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── tsconfig.json ├── tailwind.config.js ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.17.0 -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.17.0 2 | yarn 1.22.19 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @muxinc/techops 2 | /CODEOWNERS @muxinc/platform-team -------------------------------------------------------------------------------- /public/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/map.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/stack.png -------------------------------------------------------------------------------- /public/truck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/truck.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | INNGEST_ENCRYPTION_KEY=abc 2 | NEXT_PUBLIC_PARTYKIT_URL=http://localhost:1999 -------------------------------------------------------------------------------- /public/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/icon-192.png -------------------------------------------------------------------------------- /public/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/icon-512.png -------------------------------------------------------------------------------- /public/truckload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/truckload.png -------------------------------------------------------------------------------- /app/_styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | .vscode 3 | .github 4 | .cache 5 | .vercel 6 | .husky 7 | node_modules 8 | public -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /public/screenshots/ngrok-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muxinc/truckload/HEAD/public/screenshots/ngrok-url.png -------------------------------------------------------------------------------- /app/components/shared/list-videos.tsx: -------------------------------------------------------------------------------- 1 | export default async function ListVideos() { 2 | return <>Video list!; 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /app/components/platforms/additional-config.tsx: -------------------------------------------------------------------------------- 1 | export default async function AdditionalConfig() { 2 | return <>Additional config!; 3 | } 4 | -------------------------------------------------------------------------------- /app/inngest/providers/api-video/constants.ts: -------------------------------------------------------------------------------- 1 | export const SANDBOX_ENDPOINT = `https://sandbox.api.video`; 2 | export const PRODUCTION_ENDPOINT = `https://ws.api.video`; 3 | -------------------------------------------------------------------------------- /app/partykit.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://www.partykit.io/schema.json", 3 | "name": "truckload-party", 4 | "main": "party/index.ts", 5 | "compatibilityDate": "2024-02-26" 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ['image.mux.com'], 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "plugins": ["prettier", "unused-imports"], 4 | "rules": { 5 | "unused-imports/no-unused-imports": "error" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/api/videos/route.ts: -------------------------------------------------------------------------------- 1 | export const dynamic = 'force-dynamic'; // defaults to auto 2 | export async function GET(request: Request) { 3 | return new Response('Hello, World!', { status: 200 }); 4 | } 5 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "icons": [ 4 | { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" }, 5 | { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" } 6 | ] 7 | } -------------------------------------------------------------------------------- /app/components/heading.tsx: -------------------------------------------------------------------------------- 1 | export default function Heading({ children }: { children: React.ReactNode }) { 2 | return

{children}

; 3 | } 4 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/inngest/providers/index.ts: -------------------------------------------------------------------------------- 1 | import * as ApiVideo from './api-video/api-video'; 2 | import * as CloudflareStream from './cloudflare-stream/cloudflare-stream'; 3 | import * as Mux from './mux/mux'; 4 | import * as S3 from './s3/s3'; 5 | import * as Vimeo from './vimeo/vimeo'; 6 | 7 | const providerFns = { 8 | 'api-video': ApiVideo, 9 | 'cloudflare-stream': CloudflareStream, 10 | vimeo: Vimeo, 11 | mux: Mux, 12 | s3: S3, 13 | }; 14 | 15 | export default providerFns; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # partykit 38 | app/.partykit 39 | app/node_modules -------------------------------------------------------------------------------- /app/components/platforms/s3/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "quoteProps": "as-needed", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "es5", 10 | "bracketSpacing": true, 11 | "arrowParens": "always", 12 | "importOrder": [ 13 | "^react(.*)$", 14 | "^next(.*)$", 15 | "", 16 | "^@mux/(.*)$", 17 | "^~/(.*)$", 18 | "^@/(.*)$", 19 | "^app/_(.*)$", 20 | "^app/(.*)$", 21 | "^[./]" 22 | ], 23 | "importOrderSeparation": true, 24 | "importOrderSortSpecifiers": true, 25 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 26 | } 27 | -------------------------------------------------------------------------------- /app/api/job/route.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from '@/inngest/client'; 2 | import { createJob } from '@/utils/job'; 3 | 4 | export const dynamic = 'force-dynamic'; 5 | 6 | export async function GET(request: Request) { 7 | return new Response('Hello, World!', { status: 200 }); 8 | } 9 | 10 | export async function POST(request: Request) { 11 | const body = await request.json(); 12 | 13 | const job = await inngest.send({ 14 | name: 'truckload/migration.init', 15 | data: { 16 | encrypted: body, 17 | }, 18 | }); 19 | 20 | const jobId = job.ids[0]; 21 | 22 | await createJob(jobId); 23 | 24 | return new Response(JSON.stringify({ id: jobId }), { status: 201 }); 25 | } 26 | -------------------------------------------------------------------------------- /app/utils/job.tsx: -------------------------------------------------------------------------------- 1 | export async function createJob(jobId: string): Promise { 2 | return fetch(`${process.env.NEXT_PUBLIC_PARTYKIT_URL}/party/${jobId}`, { 3 | method: 'POST', 4 | body: JSON.stringify({ id: jobId }), 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | }, 8 | }); 9 | } 10 | 11 | export async function updateJobStatus(jobId: string, eventType: string, data: any): Promise { 12 | return fetch(`${process.env.NEXT_PUBLIC_PARTYKIT_URL}/party/${jobId}`, { 13 | method: 'PUT', 14 | body: JSON.stringify({ id: jobId, type: eventType, data }), 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | }, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /app/api/inngest/route.ts: -------------------------------------------------------------------------------- 1 | import { serve } from 'inngest/next'; 2 | 3 | import { inngest } from '@/inngest/client'; 4 | import { initiateMigration, processVideo } from '@/inngest/functions'; 5 | import providerFns from '@/inngest/providers'; 6 | 7 | // pull all of the exports out of the providerFns object and create an array of them 8 | const allProviderFns = Object.values(providerFns).reduce((acc, provider) => { 9 | const providerFns = Object.values(provider); 10 | return [...acc, ...providerFns]; 11 | }, [] as any[]); 12 | 13 | // Create an API that serves zero functions 14 | export const { GET, POST, PUT } = serve({ 15 | client: inngest, 16 | functions: [initiateMigration, processVideo, ...allProviderFns], 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["app/*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./app/**/*.{js,ts,jsx,tsx,mdx}'], 4 | theme: { 5 | fontFamily: { 6 | sans: "var(--sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji')", 7 | mono: "var(--mono, 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace')", 8 | }, 9 | extend: { 10 | colors: { 11 | primary: { 12 | DEFAULT: '#fe5c39', 13 | dark: '#e94e60', 14 | }, 15 | secondary: '#ffc803', 16 | }, 17 | spacing: { 18 | 128: '32rem', 19 | }, 20 | }, 21 | }, 22 | plugins: [], 23 | }; 24 | -------------------------------------------------------------------------------- /app/components/shared/review.tsx: -------------------------------------------------------------------------------- 1 | import Heading from '@/components/heading'; 2 | 3 | export default function Review() { 4 | return ( 5 |
6 | Review 7 |

Before the big move, there's one important last step.

8 |

9 | Since your video providers likely charge for moving and storing files, make sure you understand any costs 10 | you'll incur by migrating your videos. 11 |

12 |

13 | By using Truckload, you agree that you are responsible for the actions taken on each platform as part of the 14 | migration, including any fees your providers charge. 15 |

16 |

Click “Move Videos” in the moving list to start your migration.

17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/components/banner.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | export default function Banner({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
')`, 11 | backgroundSize: '200px 41px', 12 | width: '200px', 13 | height: '41px', 14 | }} 15 | > 16 | {children} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/api/webhooks/mux/route.ts: -------------------------------------------------------------------------------- 1 | import { updateJobStatus } from '@/utils/job'; 2 | import type { MigrationStatus } from '@/utils/store'; 3 | 4 | export const dynamic = 'force-dynamic'; 5 | 6 | export async function POST(request: Request) { 7 | const body = await request.json(); 8 | 9 | if (body.type !== 'video.asset.ready') { 10 | return new Response(JSON.stringify({ ok: true }), { status: 200 }); 11 | } 12 | 13 | const assetId = body.data.id; 14 | const passthrough = body.data.passthrough; 15 | const meta = JSON.parse(passthrough); 16 | const jobId = meta.jobId; 17 | const sourceVideoId = meta.sourceVideoId; 18 | const status: MigrationStatus['status'] = body.data.status === 'ready' ? 'completed' : 'failed'; 19 | 20 | await updateJobStatus(jobId, 'migration.video.progress', { 21 | video: { 22 | id: sourceVideoId, 23 | status, 24 | progress: 100, 25 | }, 26 | }); 27 | 28 | return new Response(JSON.stringify({ ok: true }), { status: 200 }); 29 | } 30 | -------------------------------------------------------------------------------- /app/inngest/providers/api-video/types.ts: -------------------------------------------------------------------------------- 1 | export interface ListVideosRoot { 2 | data: ApiVideoVideo[]; 3 | pagination: Pagination; 4 | } 5 | 6 | export interface ApiVideoVideo { 7 | videoId: string; 8 | title: string; 9 | description: string; 10 | public: boolean; 11 | panoramic: boolean; 12 | mp4Support: boolean; 13 | publishedAt: string; 14 | createdAt: string; 15 | updatedAt: string; 16 | tags: any[]; 17 | metadata: any[]; 18 | source: Source; 19 | assets: Assets; 20 | } 21 | 22 | export interface Source { 23 | type: string; 24 | uri: string; 25 | } 26 | 27 | export interface Assets { 28 | iframe: string; 29 | player: string; 30 | hls: string; 31 | thumbnail: string; 32 | mp4: string; 33 | } 34 | 35 | export interface Pagination { 36 | currentPage: number; 37 | currentPageItems: number; 38 | pageSize: number; 39 | pagesTotal: number; 40 | itemsTotal: number; 41 | links: Link[]; 42 | } 43 | 44 | export interface Link { 45 | rel: string; 46 | uri: string; 47 | } 48 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { DM_Sans, JetBrains_Mono } from 'next/font/google'; 3 | 4 | import clsx from 'clsx'; 5 | 6 | import '@/_styles/globals.css'; 7 | 8 | export const metadata: Metadata = { 9 | title: 'Truckload - move your videos with ease', 10 | }; 11 | 12 | const dmSans = DM_Sans({ subsets: ['latin'], variable: '--sans' }); 13 | const jetBrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--mono' }); 14 | 15 | export default function RootLayout({ children }: { children: React.ReactNode }) { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {children} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/api/credentials/api-video.ts: -------------------------------------------------------------------------------- 1 | import { PRODUCTION_ENDPOINT, SANDBOX_ENDPOINT } from '@/inngest/providers/api-video/constants'; 2 | import type { PlatformCredentials } from '@/utils/store'; 3 | 4 | export default async function validate(data: PlatformCredentials) { 5 | const endpoint = data.additionalMetadata?.environment === 'sandbox' ? SANDBOX_ENDPOINT : PRODUCTION_ENDPOINT; 6 | 7 | try { 8 | const response = await fetch(`${endpoint}/videos`, { 9 | method: 'GET', 10 | headers: { 11 | Authorization: `Basic ${btoa(data.secretKey as string)}`, 12 | 'Content-Type': 'application/json', 13 | }, 14 | }); 15 | 16 | const result = await response.json(); 17 | if (result.data) { 18 | return new Response('ok', { status: 200 }); 19 | } else { 20 | return Response.json({ error: 'Invalid credentials' }, { status: 401 }); 21 | } 22 | } catch (error) { 23 | console.error('Error:', error); // Catching and logging any errors 24 | return Response.json({ error: 'Invalid credentials' }, { status: 401 }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/party/index.ts: -------------------------------------------------------------------------------- 1 | import type * as Party from 'partykit/server'; 2 | 3 | import type { MigrationJob } from '@/utils/store'; 4 | 5 | export default class Server implements Party.Server { 6 | constructor(readonly room: Party.Room) {} 7 | 8 | job: MigrationJob | undefined; 9 | 10 | async onRequest(req: Party.Request) { 11 | if (req.method === 'POST') { 12 | const job = (await req.json()) as MigrationJob; 13 | this.job = { ...job, status: 'pending' }; 14 | } 15 | 16 | if (req.method === 'PUT') { 17 | const payload = await req.json<{ id: string; type: string; data: any }>(); 18 | // this.job.messages.push(payload.message); 19 | this.room.broadcast(JSON.stringify(payload)); 20 | return new Response('OK'); 21 | } 22 | 23 | if (this.job) { 24 | return new Response(JSON.stringify(this.job), { 25 | status: 200, 26 | headers: { 'Content-Type': 'application/json' }, 27 | }); 28 | } 29 | 30 | return new Response('Not found', { status: 404 }); 31 | } 32 | } 33 | 34 | Server satisfies Party.Worker; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mux, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /app/inngest/providers/cloudflare-stream/types.ts: -------------------------------------------------------------------------------- 1 | export interface CloudflareVideo { 2 | uid: string; 3 | creator: any; 4 | thumbnail: string; 5 | thumbnailTimestampPct: number; 6 | readyToStream: boolean; 7 | readyToStreamAt: string; 8 | status: Status; 9 | meta: Meta; 10 | created: string; 11 | modified: string; 12 | scheduledDeletion: any; 13 | size: number; 14 | preview: string; 15 | allowedOrigins: any[]; 16 | requireSignedURLs: boolean; 17 | uploaded: string; 18 | uploadExpiry: any; 19 | maxSizeBytes: any; 20 | maxDurationSeconds: any; 21 | duration: number; 22 | input: Input; 23 | playback: Playback; 24 | watermark: any; 25 | liveInput: string; 26 | clippedFrom: any; 27 | publicDetails: PublicDetails; 28 | } 29 | 30 | export interface Status { 31 | state: string; 32 | pctComplete: string; 33 | errorReasonCode: string; 34 | errorReasonText: string; 35 | } 36 | 37 | export interface Meta { 38 | name: string; 39 | } 40 | 41 | export interface Input { 42 | width: number; 43 | height: number; 44 | } 45 | 46 | export interface Playback { 47 | hls: string; 48 | dash: string; 49 | } 50 | 51 | export interface PublicDetails { 52 | title: string; 53 | share_link: string; 54 | channel_link: string; 55 | logo: string; 56 | } 57 | -------------------------------------------------------------------------------- /app/components/platforms/video-filter.tsx: -------------------------------------------------------------------------------- 1 | import useMigrationStore from '@/utils/store'; 2 | 3 | import Heading from '../heading'; 4 | 5 | export default function VideoFilter() { 6 | const setCurrentStep = useMigrationStore((state) => state.setCurrentStep); 7 | const setAssetFilter = useMigrationStore((state) => state.setAssetFilter); 8 | return ( 9 |
10 |
11 | Select your videos 12 |

What exactly do you want to transfer?

13 |
14 | 15 |
16 | 25 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/inngest/client.ts: -------------------------------------------------------------------------------- 1 | import { encryptionMiddleware } from '@inngest/middleware-encryption'; 2 | import { EventSchemas, Inngest } from 'inngest'; 3 | 4 | import type { AssetFilter, DestinationPlatform, PlatformCredentials, SourcePlatform, Video } from '@/utils/store'; 5 | 6 | type FetchVideo = { 7 | data: { 8 | jobId: string; 9 | encrypted: { 10 | credentials: PlatformCredentials; 11 | video: Video; 12 | }; 13 | }; 14 | }; 15 | 16 | type FetchPage = { 17 | data: { 18 | jobId: string; 19 | encrypted: PlatformCredentials; 20 | page?: number; 21 | }; 22 | }; 23 | 24 | type ProcessVideo = { 25 | data: { 26 | jobId: string; 27 | encrypted: { 28 | sourcePlatform: SourcePlatform; 29 | destinationPlatform: DestinationPlatform; 30 | video: Video; 31 | }; 32 | }; 33 | }; 34 | 35 | type InitMigration = { 36 | data: { 37 | encrypted: { 38 | sourcePlatform: SourcePlatform; 39 | destinationPlatform: DestinationPlatform; 40 | assetFilter: AssetFilter; 41 | }; 42 | }; 43 | }; 44 | 45 | type Events = { 46 | 'truckload/migration.init': InitMigration; 47 | 'truckload/migration.fetch-page': FetchPage; 48 | 'truckload/video.process': ProcessVideo; 49 | 'truckload/video.fetch': FetchVideo; 50 | 'truckload/video.transfer': ProcessVideo; 51 | 'truckload/cloudflare-stream.check-source-status': FetchVideo; 52 | }; 53 | 54 | const mw = encryptionMiddleware({ 55 | key: process.env.INNGEST_ENCRYPTION_KEY as string, 56 | }); 57 | 58 | export const inngest = new Inngest({ 59 | id: 'truckload-video', 60 | middleware: [mw], 61 | schemas: new EventSchemas().fromRecord(), 62 | }); 63 | -------------------------------------------------------------------------------- /app/components/platforms/mux/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template-mux-nextjs", 3 | "description": "Wanna build a Next.js app at Mux? Starting here's not a bad idea.", 4 | "version": "1.0.0", 5 | "author": "Mux DevEx ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "next dev", 9 | "build": "next build", 10 | "start": "next start", 11 | "lint": "prettier --check ./ && next lint", 12 | "format": "prettier --write ./ && next lint --fix", 13 | "prepare": "husky install", 14 | "inngest": "npx --yes inngest-cli@latest dev", 15 | "partykit": "cd app && npx partykit dev", 16 | "ngrok": "ngrok http http://localhost:3000", 17 | "start:dev": "concurrently -c \"white,#6366f1,red,#022384\" --names \"NEXT.JS,INNGEST,PARTYKIT,NGROK\" \"npm run dev\" \"npm run inngest\" \"npm run partykit\" \"npm run ngrok\"" 18 | }, 19 | "dependencies": { 20 | "@aws-sdk/client-s3": "^3.515.0", 21 | "@aws-sdk/s3-request-presigner": "^3.515.0", 22 | "@inngest/middleware-encryption": "^0.1.3", 23 | "@mux/mux-node": "^10.1.0", 24 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 25 | "@types/node": "20.4.2", 26 | "@types/react": "18.2.15", 27 | "@types/react-dom": "18.2.7", 28 | "clsx": "^2.1.0", 29 | "eslint": "8.45.0", 30 | "eslint-config-next": "^14.0.2", 31 | "eslint-config-prettier": "^8.8.0", 32 | "eslint-plugin-prettier": "^5.1.0", 33 | "eslint-plugin-unused-imports": "^3.0.0", 34 | "husky": "^8.0.3", 35 | "immer": "^10.0.4", 36 | "inngest": "^3.15.2-pr-503.2", 37 | "next": "^14.0.2", 38 | "partysocket": "1.0.0", 39 | "prettier": "^3.2.5", 40 | "pretty-quick": "^4.0.0", 41 | "react": "^18.2.0", 42 | "react-dom": "^18.2.0", 43 | "react-hot-toast": "^2.4.1", 44 | "typescript": "5.4.5", 45 | "zod": "^3.22.4", 46 | "zustand": "^4.5.1" 47 | }, 48 | "devDependencies": { 49 | "@redux-devtools/extension": "^3.3.0", 50 | "@tailwindcss/forms": "^0.5.7", 51 | "autoprefixer": "^10.4.17", 52 | "concurrently": "^8.2.2", 53 | "partykit": "0.0.93", 54 | "postcss": "^8.4.35", 55 | "tailwindcss": "^3.4.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/inngest/providers/s3/s3.ts: -------------------------------------------------------------------------------- 1 | import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; 2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 3 | 4 | import { inngest } from '@/inngest/client'; 5 | import type { Video } from '@/utils/store'; 6 | 7 | export const fetchPage = inngest.createFunction( 8 | { id: 'fetch-page-s3', name: 'Fetch page - Amazon S3', concurrency: 1 }, 9 | { event: 'truckload/migration.fetch-page' }, 10 | async ({ event, step }) => { 11 | const client = new S3Client({ 12 | credentials: { 13 | accessKeyId: event.data.encrypted.publicKey, 14 | secretAccessKey: event.data.encrypted.secretKey!, 15 | }, 16 | region: event.data.encrypted.additionalMetadata!.region, 17 | }); 18 | 19 | const listObjects = new ListObjectsV2Command({ Bucket: event.data.encrypted.additionalMetadata!.bucket }); 20 | const results = await client.send(listObjects); 21 | 22 | const isTruncated = results.IsTruncated; 23 | const cursor = results.NextContinuationToken; 24 | const videos = 25 | results.Contents?.map((object) => ({ id: object.Key })).filter( 26 | (item): item is Video => !!item.id && /\.(mp4|mov|mp3)$/i.test(item.id) 27 | ) || []; 28 | 29 | const payload = { isTruncated, cursor, videos }; 30 | return payload; 31 | } 32 | ); 33 | 34 | export const fetchVideo = inngest.createFunction( 35 | { id: 'fetch-video-s3', name: 'Fetch video - Amazon S3', concurrency: 10 }, 36 | { event: 'truckload/video.fetch' }, 37 | async ({ event, step }) => { 38 | const client = new S3Client({ 39 | credentials: { 40 | accessKeyId: event.data.encrypted.credentials.publicKey, 41 | secretAccessKey: event.data.encrypted.credentials.secretKey!, 42 | }, 43 | region: event.data.encrypted.credentials.additionalMetadata!.region, 44 | }); 45 | 46 | const object = new GetObjectCommand({ 47 | Bucket: event.data.encrypted.credentials.additionalMetadata!.bucket, 48 | Key: event.data.encrypted.video.id, //'hackweek-mux-video-ad-final.mp4' 49 | }); 50 | 51 | const url = await getSignedUrl(client, object, { expiresIn: 3600 }); 52 | const video = { 53 | id: event.data.encrypted.video.id, 54 | url, 55 | }; 56 | 57 | return video; 58 | } 59 | ); 60 | -------------------------------------------------------------------------------- /app/components/platforms/cloudflare/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 8 | 10 | 11 | -------------------------------------------------------------------------------- /app/inngest/providers/mux/mux.ts: -------------------------------------------------------------------------------- 1 | import Mux from '@mux/mux-node'; 2 | import { PlaybackPolicy } from '@mux/mux-node/resources'; 3 | 4 | import { inngest } from '@/inngest/client'; 5 | import { updateJobStatus } from '@/utils/job'; 6 | 7 | // We decouple the logic of the copy video function from the run migration function 8 | // to allow this to be re-used and tested independently. 9 | // This also allows the system to define different configuration for each part, 10 | // like limits on concurrency for how many videos should be copied in parallel 11 | // A separate function could be created for each destination platform 12 | export const transferVideo = inngest.createFunction( 13 | { id: 'transfer-video', name: 'Transfer video - Mux', concurrency: 10 }, 14 | { event: 'truckload/video.transfer' }, 15 | async ({ event, step }) => { 16 | const mux = new Mux({ 17 | tokenId: event.data.encrypted.destinationPlatform.credentials!.publicKey, 18 | tokenSecret: event.data.encrypted.destinationPlatform.credentials!.secretKey, 19 | }); 20 | 21 | const config = event.data.encrypted.destinationPlatform.config; 22 | 23 | let input: Mux.Video.Assets.AssetCreateParams.Input[] = [{ url: event.data.encrypted.video.url }]; 24 | 25 | if (config?.autoGenerateCaptions) { 26 | input[0].generated_subtitles = [{ name: 'English', language_code: 'en' }]; 27 | } 28 | 29 | let payload: Mux.Video.Assets.AssetCreateParams = { 30 | input, 31 | meta: { 32 | external_id: event.data.encrypted.video.id, 33 | title: event.data.encrypted.video.title || event.data.encrypted.video.id, 34 | }, 35 | passthrough: JSON.stringify({ jobId: event.data.jobId, sourceVideoId: event.data.encrypted.video.id }), 36 | }; 37 | 38 | if (config?.maxResolutionTier) { 39 | payload = { ...payload, max_resolution_tier: config.maxResolutionTier as any }; 40 | } 41 | 42 | if (config?.playbackPolicy) { 43 | payload = { 44 | ...payload, 45 | playback_policy: Array.isArray(config.playbackPolicy) 46 | ? (config.playbackPolicy as PlaybackPolicy[]) 47 | : ([config.playbackPolicy] as PlaybackPolicy[]), 48 | }; 49 | } 50 | 51 | if (config?.videoQuality) { 52 | payload = { ...payload, video_quality: config.videoQuality as any }; 53 | } 54 | 55 | if (config?.testMode) { 56 | payload = { ...payload, test: true }; 57 | } 58 | 59 | const result = await mux.video.assets.create(payload); 60 | 61 | await updateJobStatus(event.data.jobId, 'migration.video.progress', { 62 | video: { 63 | id: event.data.encrypted.video.id, 64 | status: 'in-progress', 65 | progress: 0, 66 | }, 67 | }); 68 | 69 | return { status: 'success', result }; 70 | } 71 | ); 72 | -------------------------------------------------------------------------------- /app/inngest/providers/api-video/api-video.ts: -------------------------------------------------------------------------------- 1 | import { NonRetriableError } from 'inngest'; 2 | 3 | import { inngest } from '@/inngest/client'; 4 | import type { Video } from '@/utils/store'; 5 | 6 | import { PRODUCTION_ENDPOINT, SANDBOX_ENDPOINT } from './constants'; 7 | import type { ApiVideoVideo, ListVideosRoot } from './types'; 8 | 9 | export const fetchPage = inngest.createFunction( 10 | { id: 'fetch-page-api-video', name: 'Fetch page - Api.video', concurrency: 1 }, 11 | { event: 'truckload/migration.fetch-page' }, 12 | async ({ event, step }) => { 13 | const environment = event.data.encrypted.additionalMetadata?.environment; 14 | const secretKey = event.data.encrypted.secretKey; 15 | const endpoint = environment === 'sandbox' ? SANDBOX_ENDPOINT : PRODUCTION_ENDPOINT; 16 | 17 | const response = await fetch(`${endpoint}/videos`, { 18 | method: 'GET', 19 | headers: { 20 | Authorization: `Basic ${btoa(secretKey as string)}`, 21 | 'Content-Type': 'application/json', 22 | }, 23 | }); 24 | 25 | const result = (await response.json()) as ListVideosRoot; 26 | const isTruncated = result.pagination.currentPage < result.pagination.pagesTotal; 27 | const cursor = result.pagination.links.find((link) => link.rel === 'next')?.uri; 28 | 29 | const videos = 30 | result.data 31 | ?.map((object: ApiVideoVideo) => ({ id: object.videoId, title: object.title })) 32 | .filter((item: Video): item is Video => !!item.id) || []; 33 | 34 | const payload = { isTruncated, videos, cursor }; 35 | return payload; 36 | } 37 | ); 38 | 39 | export const fetchVideo = inngest.createFunction( 40 | { id: 'fetch-video-api-video', name: 'Fetch video - Api.video', concurrency: 10 }, 41 | { event: 'truckload/video.fetch' }, 42 | async ({ event, step }) => { 43 | const environment = event.data.encrypted.credentials.additionalMetadata?.environment; 44 | const secretKey = event.data.encrypted.credentials.secretKey; 45 | const endpoint = environment === 'sandbox' ? SANDBOX_ENDPOINT : PRODUCTION_ENDPOINT; 46 | 47 | const response = await fetch(`${endpoint}/videos/${event.data.encrypted.video.id}`, { 48 | method: 'GET', 49 | headers: { 50 | Authorization: `Basic ${btoa(secretKey as string)}`, 51 | 'Content-Type': 'application/json', 52 | }, 53 | }); 54 | 55 | const result = (await response.json()) as ApiVideoVideo; 56 | 57 | if (result.mp4Support) { 58 | return { 59 | id: event.data.encrypted.video.id, 60 | url: result.assets.mp4, 61 | }; 62 | } 63 | 64 | // todo: enable mp4 support if it isn't enabled on the asset 65 | // similar to how cloudflare stream works 66 | throw new NonRetriableError('Only videos with MP4s enabled are supported at this time'); 67 | } 68 | ); 69 | -------------------------------------------------------------------------------- /app/inngest/providers/vimeo/types.ts: -------------------------------------------------------------------------------- 1 | export interface ListVideosRoot { 2 | total: number; 3 | page: number; 4 | per_page: number; 5 | paging: { 6 | next: string | null; 7 | previous: string | null; 8 | first: string; 9 | last: string; 10 | }; 11 | data: VimeoVideo[]; 12 | } 13 | 14 | export type VideoStatus = 15 | | 'available' 16 | | 'failed' 17 | | 'processing' 18 | | 'quota_exceeded' 19 | | 'total_cap_exceeded' 20 | | 'transcode_starting' 21 | | 'transcoding' 22 | | 'transcoding_error' 23 | | 'unavailable' 24 | | 'uploading' 25 | | 'uploading_error'; 26 | 27 | export interface VimeoVideo { 28 | uri: string; 29 | name: string; 30 | description: string | null; 31 | type: string; // e.g., "video" 32 | link: string; 33 | duration: number; 34 | width: number; 35 | language: string; 36 | height: number; 37 | created_time: string; 38 | modified_time: string; 39 | release_time: string; 40 | content_rating: string[]; 41 | license: string | null; 42 | status: VideoStatus; 43 | privacy: { 44 | view: string; 45 | embed: string; 46 | download: boolean; 47 | add: boolean; 48 | comments: string; 49 | }; 50 | pictures: { 51 | uri: string; 52 | active: boolean; 53 | type: string; 54 | base_link: string; 55 | sizes: { 56 | width: number; 57 | height: number; 58 | link: string; 59 | link_with_play_button: string; 60 | }[]; 61 | resource_key: string; 62 | default_picture: boolean; 63 | }; 64 | stats: { 65 | plays: number | null; 66 | }; 67 | metadata: { 68 | connections: { 69 | comments: { 70 | uri: string; 71 | total: number; 72 | }; 73 | likes: { 74 | uri: string; 75 | total: number; 76 | }; 77 | pictures: { 78 | uri: string; 79 | total: number; 80 | }; 81 | texttracks: { 82 | uri: string; 83 | total: number; 84 | }; 85 | }; 86 | }; 87 | user: { 88 | uri: string; 89 | name: string; 90 | link: string; 91 | location: string | null; 92 | pictures: { 93 | uri: string; 94 | active: boolean; 95 | type: string; 96 | base_link: string; 97 | sizes: { 98 | width: number; 99 | height: number; 100 | link: string; 101 | link_with_play_button?: string; 102 | }[]; 103 | resource_key: string; 104 | default_picture: boolean; 105 | }; 106 | account: string; // e.g., "basic", "plus", "pro" 107 | }; 108 | download?: { 109 | quality: string; 110 | type: string; 111 | width: number; 112 | height: number; 113 | expires: string; 114 | link: string; 115 | rendition: string; 116 | created_time: string; 117 | }[]; 118 | files?: { 119 | quality: string; 120 | type: string; 121 | width: number; 122 | height: number; 123 | link: string; 124 | size: number; 125 | created_time: string; 126 | fps?: number; 127 | }[]; 128 | } 129 | -------------------------------------------------------------------------------- /app/api/credentials/route.ts: -------------------------------------------------------------------------------- 1 | import { HeadBucketCommand, S3Client } from '@aws-sdk/client-s3'; 2 | 3 | import Mux from '@mux/mux-node'; 4 | 5 | import type { PlatformCredentials } from '@/utils/store'; 6 | 7 | import validateApiVideoCredentials from './api-video'; 8 | 9 | export const dynamic = 'force-dynamic'; 10 | 11 | export async function POST(request: Request) { 12 | const data: PlatformCredentials = await request.json(); 13 | 14 | switch (data.additionalMetadata?.platformId) { 15 | case 'api-video': { 16 | const result = await validateApiVideoCredentials(data); 17 | return result; 18 | } 19 | case 'vimeo': { 20 | const response = await fetch(`https://api.vimeo.com`, { 21 | method: 'GET', 22 | headers: { 23 | Authorization: `Bearer ${data.secretKey}`, 24 | 'Content-Type': 'application/json', 25 | }, 26 | }); 27 | const result = await response.json(); 28 | if (result.endpoints) { 29 | return new Response('ok', { status: 200 }); 30 | } else { 31 | return Response.json({ error: 'Invalid credentials' }, { status: 401 }); 32 | } 33 | } 34 | case 'cloudflare-stream': 35 | try { 36 | const response = await fetch('https://api.cloudflare.com/client/v4/user/tokens/verify', { 37 | headers: { 38 | Authorization: `Bearer ${data.secretKey}`, 39 | 'Content-Type': 'application/json', 40 | }, 41 | }); 42 | 43 | const result = await response.json(); 44 | if (result.success) { 45 | return new Response('ok', { status: 200 }); 46 | } else { 47 | return Response.json({ error: 'Invalid credentials' }, { status: 401 }); 48 | } 49 | } catch (error) { 50 | console.error('Error:', error); // Catching and logging any errors 51 | return Response.json({ error: 'Invalid credentials' }, { status: 401 }); 52 | } 53 | case 's3': 54 | const client = new S3Client({ 55 | credentials: { 56 | accessKeyId: data.publicKey, 57 | secretAccessKey: data.secretKey!, 58 | }, 59 | region: data.additionalMetadata.region, 60 | }); 61 | 62 | const input = { 63 | Bucket: data.additionalMetadata.bucket, 64 | }; 65 | 66 | const command = new HeadBucketCommand(input); 67 | 68 | try { 69 | await client.send(command); 70 | return new Response('ok', { status: 200 }); 71 | } catch (error) { 72 | console.error(error); 73 | return Response.json({ error: 'Invalid credentials' }, { status: 401 }); 74 | } 75 | case 'mux': 76 | const mux = new Mux({ 77 | tokenId: data.publicKey as string, 78 | tokenSecret: data.secretKey as string, 79 | }); 80 | 81 | try { 82 | await mux.video.assets.list(); 83 | return new Response('ok', { status: 200 }); 84 | } catch (error) { 85 | return Response.json({ error: 'Invalid credentials' }, { status: 401 }); 86 | } 87 | default: 88 | return Response.json({ error: 'Invalid platform provided' }, { status: 404 }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/inngest/providers/vimeo/vimeo.ts: -------------------------------------------------------------------------------- 1 | import { NonRetriableError } from 'inngest/components/NonRetriableError'; 2 | 3 | import { inngest } from '@/inngest/client'; 4 | import type { Video } from '@/utils/store'; 5 | 6 | import type { ListVideosRoot, VimeoVideo } from './types'; 7 | 8 | export const fetchPage = inngest.createFunction( 9 | { id: 'fetch-page-vimeo', name: 'Fetch page - Vimeo', concurrency: 1 }, 10 | { event: 'truckload/migration.fetch-page' }, 11 | async ({ event }: { event: any }) => { 12 | const secretKey = event.data.encrypted.secretKey; 13 | const page = event.data.page || 1; 14 | const response = await fetch(`https://api.vimeo.com/me/videos?page=${page}`, { 15 | method: 'GET', 16 | headers: { 17 | Authorization: `Bearer ${secretKey as string}`, 18 | 'Content-Type': 'application/json', 19 | }, 20 | }); 21 | 22 | const result = (await response.json()) as ListVideosRoot; 23 | const isTruncated = result.page < Math.ceil(result.total / result.per_page); 24 | 25 | const videos = 26 | result.data 27 | ?.map((object: VimeoVideo) => ({ 28 | id: object.uri, 29 | title: object.name, 30 | status: object.status, 31 | type: object.type, 32 | })) 33 | .filter( 34 | (item: Video & { status?: string; type?: string }): item is Video => 35 | !!item.id && !(item.status !== 'available' && item.type !== 'video') 36 | ) || []; 37 | 38 | const payload = { isTruncated, videos, cursor: null }; 39 | return payload; 40 | } 41 | ); 42 | 43 | export const fetchVideo = inngest.createFunction( 44 | { id: 'fetch-video-vimeo', name: 'Fetch video - Vimeo', concurrency: 10 }, 45 | { event: 'truckload/video.fetch' }, 46 | async ({ event, step }) => { 47 | const secretKey = event.data.encrypted.credentials.secretKey; 48 | 49 | const response = await fetch(`https://api.vimeo.com${event.data.encrypted.video.id}`, { 50 | method: 'GET', 51 | headers: { 52 | Authorization: `Bearer ${secretKey as string}`, 53 | 'Content-Type': 'application/json', 54 | }, 55 | }); 56 | 57 | const result = (await response.json()) as VimeoVideo; 58 | 59 | if (!result) { 60 | throw new NonRetriableError('Error fetching video from Vimeo'); 61 | } 62 | 63 | if (result.download && result.status === 'available' && result.type === 'video') { 64 | // Find highest quality MP4 download link if source is not available 65 | const download = result.download.find( 66 | (file) => 67 | file.rendition === 'source' || 68 | file.rendition === '8k' || 69 | file.rendition === '7k' || 70 | file.rendition === '6k' || 71 | file.rendition === '5k' || 72 | file.rendition === '4k' || 73 | file.rendition === '2k' || 74 | file.rendition === '1080p' || 75 | file.rendition === '720p' || 76 | file.rendition === '540p' || 77 | file.rendition === '480p' || 78 | file.rendition === '360p' || 79 | file.rendition === '240p' 80 | ); 81 | 82 | return { 83 | id: event.data.encrypted.video.id, 84 | url: download?.link, 85 | title: result?.name, 86 | }; 87 | } 88 | return; 89 | } 90 | ); 91 | -------------------------------------------------------------------------------- /app/components/platforms/platform-list.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | import clsx from 'clsx'; 4 | 5 | import LogoApiVideo from '@/components/platforms/api-video/logo.svg'; 6 | import LogoCloudflare from '@/components/platforms/cloudflare/logo.svg'; 7 | import LogoMux from '@/components/platforms/mux/logo.svg'; 8 | import LogoS3 from '@/components/platforms/s3/logo.svg'; 9 | import LogoVimeo from '@/components/platforms/vimeo/logo.svg'; 10 | import useMigrationStore from '@/utils/store'; 11 | import { DestinationPlatform, SourcePlatform } from '@/utils/store'; 12 | 13 | import Heading from '../heading'; 14 | 15 | type Platforms = { 16 | source: Array>; 17 | destination: Array>; 18 | }; 19 | 20 | const PLATFORMS: Platforms = { 21 | source: [ 22 | { 23 | id: 's3', 24 | name: 'Amazon S3', 25 | logo: LogoS3, 26 | }, 27 | { 28 | id: 'api-video', 29 | name: 'Api.video', 30 | logo: LogoApiVideo, 31 | }, 32 | { 33 | id: 'vimeo', 34 | name: 'Vimeo', 35 | logo: LogoVimeo, 36 | }, 37 | { 38 | id: 'cloudflare-stream', 39 | name: 'Cloudflare Stream', 40 | logo: LogoCloudflare, 41 | }, 42 | ], 43 | destination: [ 44 | { 45 | id: 'mux', 46 | name: 'Mux', 47 | logo: LogoMux, 48 | }, 49 | ], 50 | }; 51 | 52 | export default function PlatformList({ type }: { type: 'source' | 'destination' }) { 53 | const setPlatform = useMigrationStore((state) => state.setPlatform); 54 | const setCurrentStep = useMigrationStore((state) => state.setCurrentStep); 55 | const sourcePlatform = useMigrationStore((state) => state.sourcePlatform); 56 | 57 | const platforms = PLATFORMS[type]; 58 | const isSource = type === 'source'; 59 | const title = isSource ? 'Select a source' : 'Select a destination'; 60 | const description = isSource 61 | ? 'Select the platform where your videos are currently stored' 62 | : 'Select the platform to where you are migrating your videos'; 63 | 64 | return ( 65 |
66 | {title} 67 |

{description}

68 | 69 |
70 | {platforms.map((platform) => ( 71 |
{ 79 | const platformWithType = { ...platform, type }; 80 | if (type === 'source') { 81 | setPlatform('source', platformWithType as SourcePlatform); 82 | } else { 83 | setPlatform('destination', platformWithType as DestinationPlatform); 84 | } 85 | setCurrentStep(isSource ? 'set-source-credentials' : 'set-destination-credentials'); 86 | }} 87 | > 88 |
89 | {`${platform.name} 90 |
91 |

{platform.name}

92 | {/*

{platform.description}

*/} 93 |
94 | ))} 95 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /app/inngest/providers/cloudflare-stream/cloudflare-stream.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from '@/inngest/client'; 2 | import type { Video } from '@/utils/store'; 3 | 4 | import type { CloudflareVideo } from './types'; 5 | 6 | export const fetchPage = inngest.createFunction( 7 | { id: 'fetch-page-cloudflare-stream', name: 'Fetch page - Cloudflare Stream', concurrency: 1 }, 8 | { event: 'truckload/migration.fetch-page' }, 9 | async ({ event, step }) => { 10 | const response = await fetch( 11 | `https://api.cloudflare.com/client/v4/accounts/${event.data.encrypted.publicKey}/stream`, 12 | { 13 | headers: { 14 | Authorization: `Bearer ${event.data.encrypted.secretKey}`, 15 | 'Content-Type': 'application/json', 16 | }, 17 | } 18 | ); 19 | 20 | const result = await response.json(); 21 | const isTruncated = result.range && result.range > 0; 22 | 23 | const videos = 24 | result.result 25 | ?.map((object: CloudflareVideo) => ({ id: object.uid })) 26 | .filter((item: Video): item is Video => !!item.id) || []; 27 | 28 | const payload = { isTruncated, videos, cursor: null }; 29 | return payload; 30 | } 31 | ); 32 | 33 | export const checkSourceStatus = inngest.createFunction( 34 | { id: 'check-source-status-cloudflare-stream', name: 'Check source status Cloudflare Steam', concurrency: 10 }, 35 | { event: 'truckload/cloudflare-stream.check-source-status' }, 36 | async ({ event, step }): Promise<{ url: string }> => { 37 | let isReady = false; 38 | let url = ''; 39 | 40 | while (!isReady) { 41 | const result = await step.run('check-cloudflare-stream-status', async () => { 42 | const response = await fetch( 43 | `https://api.cloudflare.com/client/v4/accounts/${event.data.encrypted.credentials.publicKey}/stream/${event.data.encrypted.video.id}/downloads`, 44 | { 45 | method: 'GET', 46 | headers: { 47 | Authorization: `Bearer ${event.data.encrypted.credentials.secretKey}`, 48 | 'Content-Type': 'application/json', 49 | }, 50 | } 51 | ); 52 | 53 | return response.json(); 54 | }); 55 | 56 | isReady = result.result.default.status === 'ready'; 57 | url = result.result.default.url as string; 58 | 59 | // sleep for 1 second 60 | await step.sleep('wait-before-rechecking-cloudflare-stream', '5s'); 61 | } 62 | 63 | const payload = { url }; 64 | return payload; 65 | } 66 | ); 67 | 68 | export const fetchVideo = inngest.createFunction( 69 | { id: 'fetch-video-cloudflare-stream', name: 'Fetch video - Cloudflare Stream', concurrency: 10 }, 70 | { event: 'truckload/video.fetch' }, 71 | async ({ event, step }) => { 72 | const response = await fetch( 73 | `https://api.cloudflare.com/client/v4/accounts/${event.data.encrypted.credentials.publicKey}/stream/${event.data.encrypted.video.id}/downloads`, 74 | { 75 | method: 'POST', 76 | headers: { 77 | Authorization: `Bearer ${event.data.encrypted.credentials.secretKey}`, 78 | 'Content-Type': 'application/json', 79 | }, 80 | } 81 | ); 82 | 83 | const result = await response.json(); 84 | 85 | if (result.result.default.status === 'ready') { 86 | return { 87 | id: event.data.encrypted.video.id, 88 | url: result.result.default.url, 89 | }; 90 | } 91 | 92 | const { url } = await step.invoke(`check-source-status-cloudflare-stream`, { 93 | function: checkSourceStatus, 94 | data: { 95 | jobId: event.data.jobId, 96 | encrypted: event.data.encrypted, 97 | }, 98 | }); 99 | 100 | return { 101 | id: event.data.encrypted.video.id, 102 | url, 103 | }; 104 | } 105 | ); 106 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { Toaster } from 'react-hot-toast'; 5 | 6 | import Image from 'next/image'; 7 | 8 | import PlatformCredentialsForm from './components/platforms/credentials-form'; 9 | import PlatformList from './components/platforms/platform-list'; 10 | import VideoFilter from './components/platforms/video-filter'; 11 | import ImportSettings from './components/shared/import-settings'; 12 | import MigrationStatus from './components/shared/migration-status'; 13 | import Review from './components/shared/review'; 14 | import Sidebar from './components/sidebar'; 15 | import useMigrationStore from './utils/store'; 16 | 17 | export default function Page() { 18 | const sourcePlatform = useMigrationStore((state) => state.sourcePlatform); 19 | const destinationPlatform = useMigrationStore((state) => state.destinationPlatform); 20 | const job = useMigrationStore((state) => state.job); 21 | const currentStep = useMigrationStore((state) => state.currentStep); 22 | 23 | // determine what the current step should be on initial load 24 | useEffect(() => { 25 | if (!sourcePlatform) { 26 | useMigrationStore.setState({ currentStep: 'select-source' }); 27 | } else if (!sourcePlatform?.credentials) { 28 | useMigrationStore.setState({ currentStep: 'set-source-credentials' }); 29 | } else if (sourcePlatform?.credentials && !useMigrationStore.getState().assetFilter) { 30 | useMigrationStore.setState({ currentStep: 'set-video-filter' }); 31 | } else if (!destinationPlatform) { 32 | useMigrationStore.setState({ currentStep: 'select-destination' }); 33 | } else if (!destinationPlatform?.credentials) { 34 | useMigrationStore.setState({ currentStep: 'set-destination-credentials' }); 35 | } else if (destinationPlatform?.credentials && !destinationPlatform?.config) { 36 | useMigrationStore.setState({ currentStep: 'set-import-settings' }); 37 | } else if (!job) { 38 | useMigrationStore.setState({ currentStep: 'review' }); 39 | } else { 40 | useMigrationStore.setState({ currentStep: 'migration-status' }); 41 | } 42 | }, [sourcePlatform, destinationPlatform, job]); 43 | 44 | return ( 45 | <> 46 |
47 |
48 |

49 | Truckload Video 50 | Truckload Video Migration 51 |

52 |
53 | 54 |
55 | 56 | 57 |
58 | {currentStep === 'select-source' && } 59 | {currentStep === 'set-source-credentials' && sourcePlatform && } 60 | {currentStep === 'set-video-filter' && } 61 | {currentStep === 'select-destination' && } 62 | {currentStep === 'set-destination-credentials' && destinationPlatform && } 63 | {currentStep === 'set-import-settings' && } 64 | {currentStep === 'review' && } 65 | {currentStep === 'migration-status' && } 66 |
67 |
68 | 69 |
70 | 71 | An{' '} 72 | 73 | open-source 74 | {' '} 75 | project by{' '} 76 | 77 | Mux 78 | 79 | 80 |
81 |
82 | 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /app/components/platforms/vimeo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/components/shared/migration-status.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import clsx from 'clsx'; 4 | import usePartySocket from 'partysocket/react'; 5 | 6 | import useMigrationStore from '@/utils/store'; 7 | 8 | import Heading from '../heading'; 9 | 10 | // import type { VideoWithMigrationStatus } from '@/utils/store'; 11 | 12 | export default function MigrationStatus() { 13 | const job = useMigrationStore((state) => state.job); 14 | const setVideoMigrationProgress = useMigrationStore((state) => state.setVideoMigrationProgress); 15 | 16 | const socket = usePartySocket({ 17 | host: process.env.NEXT_PUBLIC_PARTYKIT_URL, 18 | room: job!.id, 19 | onMessage(event) { 20 | const payload = JSON.parse(event.data); 21 | if (!payload) return; 22 | // todo: set the state appropriately based on the event type 23 | console.log(`payload received!`); 24 | console.log(payload); 25 | 26 | switch (payload.type) { 27 | case 'migration.videos.fetched': 28 | const { hasMorePages, pageNumber, videos } = payload.data; 29 | useMigrationStore.setState({ job: { ...job, ...payload.data } }); 30 | break; 31 | case 'migration.video.progress': 32 | const { video } = payload.data; 33 | 34 | setVideoMigrationProgress(video.id, video); 35 | break; 36 | default: 37 | break; 38 | } 39 | }, 40 | }); 41 | 42 | const clearJob = () => { 43 | useMigrationStore.setState({ job: undefined, currentStep: 'review' }); 44 | }; 45 | 46 | const videoIds = Object.keys(job?.videos || {}); 47 | 48 | return ( 49 |
50 | Migration Status 51 | {/* {job?.status} */} 52 |

53 | Note: this page will only update if you've correctly configured status webhooks. 54 |

55 | 56 |
57 | 58 | 59 | 60 | 63 | 66 | 69 | 70 | 71 | 72 | {videoIds.map((id) => { 73 | const video = job?.videos[id]; 74 | if (!video) return null; 75 | return ( 76 | 80 | 83 | 84 | 97 | 98 | ); 99 | })} 100 | 101 |
61 | Video 62 | 64 | Progress 65 | 67 | Status 68 |
81 | {video.title || video.id} 82 | {video.progress}% 85 | 94 | {video.status} 95 | 96 |
102 |
103 | 104 | 107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /app/inngest/functions.ts: -------------------------------------------------------------------------------- 1 | import type { GetEvents } from 'inngest'; 2 | 3 | import { updateJobStatus } from '@/utils/job'; 4 | import type { Video } from '@/utils/store'; 5 | import type { VideoWithMigrationStatus } from '@/utils/store'; 6 | 7 | import { inngest } from './client'; 8 | import providerFns from './providers'; 9 | 10 | type Events = GetEvents; 11 | 12 | export const processVideo = inngest.createFunction( 13 | { 14 | id: 'process-video', 15 | name: 'Process video', 16 | throttle: { 17 | limit: 1, 18 | period: '2s', 19 | burst: 2, 20 | }, 21 | }, 22 | { event: 'truckload/video.process' }, 23 | async ({ event, step }) => { 24 | const videoData = event.data.encrypted.video; 25 | // use the source platform id to conditionally set the fetch page function 26 | const sourcePlatformId = event.data.encrypted.sourcePlatform.id; 27 | const fetchVideoFn = providerFns[sourcePlatformId].fetchVideo; 28 | 29 | // use the destination platform id to conditionally set the transfer video function 30 | const destinationPlatformId = event.data.encrypted.destinationPlatform.id; 31 | const transferVideoFn = providerFns[destinationPlatformId].transferVideo; 32 | 33 | const video = await step.invoke(`fetch-video-${videoData.id}`, { 34 | function: fetchVideoFn, 35 | data: { 36 | jobId: event.data.jobId, 37 | encrypted: { 38 | credentials: event.data.encrypted.sourcePlatform.credentials!, 39 | video: videoData, 40 | }, 41 | }, 42 | }); 43 | 44 | const transfer = await step.invoke(`transfer-video-${videoData.id}`, { 45 | function: transferVideoFn, 46 | data: { 47 | jobId: event.data.jobId, 48 | encrypted: { 49 | sourcePlatform: event.data.encrypted.sourcePlatform, 50 | destinationPlatform: event.data.encrypted.destinationPlatform, 51 | video, 52 | }, 53 | }, 54 | }); 55 | 56 | return { status: 'success', transfer }; 57 | } 58 | ); 59 | 60 | export const initiateMigration = inngest.createFunction( 61 | { id: 'initiate-migration' }, 62 | { event: 'truckload/migration.init' }, 63 | async ({ event, step, logger }) => { 64 | let jobId = event.id; 65 | let hasMorePages = true; 66 | let page = 1; 67 | let nextPageNumber: number | undefined = undefined; 68 | let nextPageToken: string | null | undefined = undefined; 69 | let videoList: Video[] = []; 70 | 71 | logger.info('jobId: ' + jobId); 72 | 73 | // use the source platform id to conditionally set the fetch page function 74 | const sourcePlatformId = event.data.encrypted.sourcePlatform.id; 75 | const fetchPageFn = providerFns[sourcePlatformId].fetchPage; 76 | 77 | while (hasMorePages && event.data.encrypted.sourcePlatform.credentials) { 78 | const { cursor, isTruncated, videos } = await step.invoke(`fetch-page-${page}`, { 79 | function: fetchPageFn, 80 | data: { 81 | jobId: jobId!, 82 | encrypted: event.data.encrypted.sourcePlatform.credentials, 83 | page: page, 84 | }, 85 | }); 86 | 87 | videoList = videoList.concat(videos); 88 | nextPageToken = cursor; 89 | 90 | logger.info('page: ' + page); 91 | logger.info('cursor: ' + cursor); 92 | logger.info('isTruncated: ' + isTruncated); 93 | logger.info('videos: ' + JSON.stringify(videos)); 94 | 95 | await step.run('update-job-status-with-videos-fetched', async () => { 96 | await updateJobStatus(jobId!, 'migration.videos.fetched', { 97 | pageNumber: page, 98 | videos: videoList.reduce>((acc, video) => { 99 | acc[video.id] = { ...video, status: 'pending', progress: 0 }; 100 | return acc; 101 | }, {}), 102 | hasMorePages: isTruncated, 103 | }); 104 | }); 105 | 106 | if (!isTruncated) { 107 | hasMorePages = false; 108 | } else { 109 | page++; 110 | } 111 | } 112 | 113 | const videoEvents = videoList.map((video): Events['truckload/video.process'] => ({ 114 | name: 'truckload/video.process', 115 | data: { 116 | jobId: jobId!, 117 | encrypted: { 118 | sourcePlatform: event.data.encrypted.sourcePlatform, 119 | destinationPlatform: event.data.encrypted.destinationPlatform, 120 | video, 121 | }, 122 | }, 123 | })); 124 | 125 | await step.sendEvent('process-videos', videoEvents); 126 | 127 | return { message: 'migration initiated', videosMigrated: videoList.length }; 128 | } 129 | ); 130 | -------------------------------------------------------------------------------- /app/utils/store.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ComponentPropsWithoutRef } from 'react'; 4 | 5 | import type Image from 'next/image'; 6 | 7 | import type {} from '@redux-devtools/extension'; 8 | import { create } from 'zustand'; 9 | import { devtools, persist } from 'zustand/middleware'; 10 | import { immer } from 'zustand/middleware/immer'; 11 | 12 | export type MigrationVideoProgressEvent = { 13 | type: 'migration.video.progress'; 14 | data: { 15 | video: VideoWithMigrationStatus[]; 16 | }; 17 | }; 18 | 19 | export type MigrationVideosFetchedEvent = { 20 | type: 'migration.videos.fetched'; 21 | data: { 22 | pageNumber: number; 23 | videos: VideoWithMigrationStatus[]; 24 | hasMorePages: boolean; 25 | }; 26 | }; 27 | 28 | export type MigrationStatus = { 29 | status: 'pending' | 'in-progress' | 'retrying' | 'completed' | 'failed'; 30 | progress: number; 31 | }; 32 | 33 | export type Video = { 34 | id: string; 35 | url?: string | undefined; 36 | title?: string | undefined; 37 | thumbnailUrl?: string | undefined; 38 | }; 39 | 40 | export type VideoWithMigrationStatus = Video & MigrationStatus; 41 | 42 | type PlatformCredentialsMetadata = { 43 | [key: string]: string; 44 | }; 45 | 46 | export type PlatformCredentials = { 47 | publicKey: string; 48 | secretKey?: string | undefined; 49 | additionalMetadata?: PlatformCredentialsMetadata | undefined; 50 | }; 51 | 52 | export interface SourcePlatform extends Platform { 53 | id: 's3' | 'cloudflare-stream' | 'api-video' | 'vimeo'; 54 | type: 'source'; 55 | } 56 | 57 | export interface DestinationPlatform extends Platform { 58 | id: 'mux'; 59 | type: 'destination'; 60 | } 61 | 62 | type PlatformType = 'source' | 'destination'; 63 | export type PlatformConfig = { 64 | [key: string]: string | string[]; 65 | }; 66 | 67 | interface Platform { 68 | name: string; 69 | logo: ComponentPropsWithoutRef['src']; 70 | credentials?: PlatformCredentials | undefined; 71 | config?: PlatformConfig | undefined; 72 | } 73 | 74 | export type AssetFilter = { 75 | url: string; 76 | }; 77 | 78 | export type MigrationJob = { 79 | id: string; 80 | status: 'pending' | 'in-progress' | 'completed' | 'failed'; 81 | progress: number; 82 | videos: Record; 83 | }; 84 | 85 | type MigrationActions = { 86 | setAssetFilter: (filter: AssetFilter[] | null) => void; 87 | setPlatform: ( 88 | type: T, 89 | platform: T extends 'source' ? SourcePlatform | null : DestinationPlatform | null 90 | ) => void; 91 | setCurrentStep: (step: MigrationStep) => void; 92 | setVideoMigrationProgress: (id: string, status: VideoWithMigrationStatus) => void; 93 | }; 94 | 95 | interface MigrationState { 96 | sourcePlatform: SourcePlatform | null; 97 | destinationPlatform: DestinationPlatform | null; 98 | assetFilter: AssetFilter[] | null; 99 | job: MigrationJob | null; 100 | currentStep: MigrationStep; 101 | } 102 | 103 | type MigrationStep = 104 | | 'select-source' 105 | | 'set-source-credentials' 106 | | 'set-video-filter' 107 | | 'select-videos' 108 | | 'select-destination' 109 | | 'set-destination-credentials' 110 | | 'set-import-settings' 111 | | 'review' 112 | | 'migration-status'; 113 | 114 | // required for devtools typing 115 | const useMigrationStore = create()( 116 | devtools( 117 | persist( 118 | immer((set) => ({ 119 | sourcePlatform: null, 120 | destinationPlatform: null, 121 | assetFilter: null, 122 | job: null, 123 | currentStep: 'select-source', 124 | setCurrentStep: (step: MigrationStep) => { 125 | set({ currentStep: step }); 126 | }, 127 | setAssetFilter: (filter: AssetFilter[] | null) => { 128 | set({ assetFilter: filter }); 129 | }, 130 | setPlatform: ( 131 | type: T, 132 | platform: T extends 'source' ? SourcePlatform | null : DestinationPlatform | null 133 | ) => { 134 | if (type === 'source') { 135 | set({ sourcePlatform: platform as SourcePlatform | null }); 136 | } else if (type === 'destination') { 137 | set({ destinationPlatform: platform as DestinationPlatform | null }); 138 | } 139 | }, 140 | setVideoMigrationProgress: (id: string, status: VideoWithMigrationStatus) => { 141 | set((state) => { 142 | if (state.job) { 143 | state.job.videos[id] = status; 144 | } 145 | }); 146 | }, 147 | })), 148 | { 149 | name: 'truckload-migration-storage', 150 | } 151 | ) 152 | ) 153 | ); 154 | 155 | export default useMigrationStore; 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Truckload 2 | 3 | # Truckload 4 | 5 | Migrate your video collection to a new platform with ease. 6 | 7 | ## Getting started 8 | 9 | First, clone the repository and install dependencies: 10 | 11 | ```bash 12 | git clone https://github.com/muxinc/truckload.git 13 | cd truckload 14 | npm install 15 | ``` 16 | 17 | Next, create a `.env.local` file in the root directory with your API keys and other configuration settings: 18 | 19 | ```bash 20 | cp .env.example .env.local 21 | ``` 22 | 23 | Finally, start the app: 24 | 25 | ```bash 26 | npm run start:dev 27 | ``` 28 | 29 | This will start server instances for the Next.js app, Inngest, PartyKit, and ngrok. 30 | 31 | Truckload stack 32 | 33 | ### About the Inngest server 34 | 35 | [Inngest](https://www.inngest.com) makes serverless queues, background jobs, and workflows effortless. Truckload uses a [local Inngest development server](https://www.inngest.com/docs/local-development) to facilitate the loading and migrating of each video. 36 | 37 | ### About the PartyKit server 38 | 39 | [PartyKit](https://www.partykit.io/) is a comprehensive solution for real-time sync within your application. 40 | 41 | In this app, we're really only using it to receive status updates from the video migration background jobs and destination webhooks. Truckload uses a local PartyKit server on port `1999` to receive these notifications and pipe them back to the front-end for status updates. 42 | 43 | ## How it works 44 | 45 | Truckload uses a simple workflow to migrate videos from one platform to another. Here's a high-level overview of the process: 46 | 47 | Truckload map 48 | 49 | ## Authentication requirements 50 | 51 | When using this app to migrate videos to a new platform, you'll need to authenticate with both the source and destination services to ensure that you have the necessary permissions to perform the desired actions (e.g. fetching video metadata, creating master files, uploading videos, etc.) 52 | 53 | Here's a list of the authentication requirements for each service: 54 | 55 | | Provider | Requirements | Resources | 56 | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | 57 | | Amazon S3 | [Access Key and Secret](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys), bucket name, region | [AWS SDK v3 API docs](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3/) | 58 | | Api.video | [API Key](https://docs.api.video/reference/basic-authentication) | [API docs](https://docs.api.video/reference) | 59 | | Cloudflare Stream | [API Token](https://dash.cloudflare.com/profile/api-tokens), Account ID | [API docs](https://developers.cloudflare.com/stream/) | 60 | | Vimeo | [Access Token](https://developer.vimeo.com/api/authentication#obtaining-an-access-token) | [API docs](https://developer.vimeo.com/api/) | 61 | | Mux | [Token ID and Secret](https://docs.mux.com/core/make-api-requests#http-basic-auth) | [API docs](https://docs.mux.com/api-reference) | 62 | 63 | ## Handling webhooks 64 | 65 | Some destinations (like [Mux](https://mux.com?utm_source=github&utm_medium=readme&utm_campaign=truckload)) use webhooks to communicate migration progress to your application. 66 | 67 | This presents a challenge when you're running this app locally, as you'll need a public URL that can 68 | be reached by an HTTP request issued by your destination service. 69 | 70 | To solve this, you can stand up a free, publicly-accessible tunnel URL using ngrok. Here's how: 71 | 72 | 1. Visit https://ngrok.com 73 | 2. Sign in with your existing account or with GitHub 74 | 3. Follow the instructions to install and authenticate `ngrok` on your machine 75 | 4. Create an `ngrok` endpoint for your local app by running `ngrok http http://localhost:3000` 76 | 5. Grab the resulting URL for use as your webhook destination, and append `/api/webhooks/mux`: 77 | 78 | Ngrok URL 79 | -------------------------------------------------------------------------------- /app/components/platforms/credentials-form.tsx: -------------------------------------------------------------------------------- 1 | import toast from 'react-hot-toast'; 2 | 3 | import useMigrationStore from '@/utils/store'; 4 | import type { PlatformCredentials } from '@/utils/store'; 5 | 6 | import Heading from '../heading'; 7 | 8 | const PLATFORM_CREDENTIALS = [ 9 | { 10 | name: 'Amazon S3', 11 | id: 's3', 12 | values: [ 13 | { 14 | label: 'Access Key ID', 15 | name: 'publicKey', 16 | type: 'text', 17 | }, 18 | { 19 | label: 'Secret Access Key', 20 | name: 'secretKey', 21 | type: 'text', 22 | }, 23 | { 24 | label: 'Region', 25 | name: 'region', 26 | type: 'select', 27 | values: [ 28 | 'us-east-1', 29 | 'us-east-2', 30 | 'us-west-1', 31 | 'us-west-2', 32 | 'eu-west-1', 33 | 'eu-central-1', 34 | 'ap-southeast-1', 35 | 'ap-southeast-2', 36 | 'ap-northeast-1', 37 | 'sa-east-1', 38 | ], 39 | }, 40 | { 41 | label: 'Bucket name', 42 | name: 'bucket', 43 | type: 'text', 44 | }, 45 | ], 46 | }, 47 | { 48 | name: 'Api.video', 49 | id: 'api-video', 50 | values: [ 51 | { 52 | label: 'Account email', 53 | name: 'publicKey', 54 | type: 'text', 55 | }, 56 | { 57 | label: 'API Key', 58 | name: 'secretKey', 59 | type: 'text', 60 | }, 61 | { 62 | label: 'Environment', 63 | name: 'environment', 64 | type: 'select', 65 | values: ['sandbox', 'production'], 66 | }, 67 | ], 68 | }, 69 | { 70 | name: 'Cloudflare Stream', 71 | id: 'cloudflare-stream', 72 | values: [ 73 | { 74 | label: 'Account ID', 75 | name: 'publicKey', 76 | type: 'text', 77 | }, 78 | { 79 | label: 'API Token', 80 | name: 'secretKey', 81 | type: 'text', 82 | }, 83 | ], 84 | }, 85 | { 86 | name: 'Vimeo', 87 | id: 'vimeo', 88 | values: [ 89 | { 90 | label: 'Access Token', 91 | name: 'secretKey', 92 | type: 'text', 93 | }, 94 | ], 95 | }, 96 | { 97 | name: 'Mux', 98 | id: 'mux', 99 | values: [ 100 | { 101 | label: 'Access Token ID', 102 | name: 'publicKey', 103 | type: 'text', 104 | }, 105 | { 106 | label: 'Secret Key', 107 | name: 'secretKey', 108 | type: 'text', 109 | }, 110 | ], 111 | }, 112 | ]; 113 | 114 | export default function PlatformCredentialsForm() { 115 | const { currentStep, setCurrentStep, platform, setPlatform } = useMigrationStore((state) => ({ 116 | currentStep: state.currentStep, 117 | setCurrentStep: state.setCurrentStep, 118 | setPlatform: state.setPlatform, 119 | platform: state.currentStep === 'set-source-credentials' ? state.sourcePlatform : state.destinationPlatform, 120 | })); 121 | 122 | if (!platform) { 123 | return null; 124 | } 125 | 126 | const platformCreds = PLATFORM_CREDENTIALS.find((p) => p.id === platform?.id); 127 | if (!platformCreds) { 128 | return null; 129 | } 130 | 131 | const onSubmit = async (e: React.FormEvent) => { 132 | e.preventDefault(); 133 | const formData = new FormData(e.currentTarget); 134 | const rawData = Object.fromEntries(formData.entries()); 135 | const { publicKey, secretKey, ...additionalMetadata } = rawData as unknown as Record; 136 | const data: PlatformCredentials = { publicKey, secretKey, additionalMetadata }; 137 | 138 | const result = await fetch('/api/credentials', { 139 | method: 'POST', 140 | body: JSON.stringify(data), 141 | }); 142 | 143 | if (result.status === 401) { 144 | toast('Invalid credentials, try again', { icon: '❌' }); 145 | } 146 | 147 | if (result.status === 200) { 148 | setPlatform(platform.type, { ...platform, credentials: data }); 149 | if (currentStep === 'set-source-credentials') { 150 | setCurrentStep('set-video-filter'); 151 | } else { 152 | setCurrentStep('set-import-settings'); 153 | } 154 | 155 | toast('Credentials validated', { icon: '👍' }); 156 | } 157 | }; 158 | 159 | return ( 160 |
161 | Add your {platform.name} credentials 162 |

Only stored locally. Encrypted in transit.

163 | 164 |
165 | 166 | 167 | {platformCreds.values.map((value) => ( 168 |
169 | 172 | 173 |
174 | {value.type === 'text' && ( 175 | 182 | )} 183 | 184 | {value.type === 'select' && ( 185 | 197 | )} 198 |
199 |
200 | ))} 201 | 202 |
203 | 209 |
210 |
211 |
212 | ); 213 | } 214 | -------------------------------------------------------------------------------- /app/components/platforms/api-video/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 15 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import toast from 'react-hot-toast'; 2 | 3 | import useMigrationStore from '@/utils/store'; 4 | 5 | import Heading from './heading'; 6 | 7 | export default function Sidebar() { 8 | const sourcePlatform = useMigrationStore((state) => state.sourcePlatform); 9 | const setPlatform = useMigrationStore((state) => state.setPlatform); 10 | const destinationPlatform = useMigrationStore((state) => state.destinationPlatform); 11 | const setCurrentStep = useMigrationStore((state) => state.setCurrentStep); 12 | const currentStep = useMigrationStore((state) => state.currentStep); 13 | const assetFilter = useMigrationStore((state) => state.assetFilter); 14 | const setAssetFilter = useMigrationStore((state) => state.setAssetFilter); 15 | 16 | const state = useMigrationStore.getState(); 17 | 18 | console.dir(state); 19 | 20 | const onSubmit = async () => { 21 | console.log({ sourcePlatform, destinationPlatform, assetFilter }); 22 | const result = await fetch('/api/job', { 23 | method: 'POST', 24 | body: JSON.stringify({ sourcePlatform, destinationPlatform, assetFilter }), 25 | }); 26 | 27 | if (result.status === 201) { 28 | const { id } = await result.json(); 29 | useMigrationStore.setState({ job: { id, status: 'pending', progress: 0, videos: {} } }); 30 | 31 | setCurrentStep('migration-status'); 32 | toast('Migration initiated', { icon: '👍' }); 33 | } else { 34 | toast.error('Error initiating migration'); 35 | } 36 | }; 37 | 38 | return ( 39 |
40 | Moving list 41 | 42 |
43 | {!sourcePlatform && ( 44 |

45 | This list will grow as you prepare your move. 46 |
47 | Start over there ➡️ 48 | ⬇️ 49 |

50 | )} 51 | 52 | {sourcePlatform && ( 53 |
54 |
55 |

Source platform

56 |

{sourcePlatform?.name}

57 |
58 | 59 | 69 |
70 | )} 71 | 72 | {sourcePlatform?.credentials && ( 73 |
74 |
75 |

Source credentials

76 |

Credentials added

77 |
78 | 79 | 88 |
89 | )} 90 | 91 | {assetFilter !== null && ( 92 |
93 |
94 |

Video selection

95 |

Moving all videos

96 |
97 | 98 | 107 |
108 | )} 109 | 110 | {destinationPlatform && ( 111 |
112 |
113 |

Destination platform

114 |

{destinationPlatform?.name}

115 |
116 | 117 | 126 |
127 | )} 128 | 129 | {destinationPlatform?.credentials && ( 130 |
131 |
132 |

Destination credentials

133 |

Credentials added

134 |
135 | 136 | 145 |
146 | )} 147 | 148 | {destinationPlatform?.config && ( 149 |
150 |
151 |

Import settings

152 |

Settings added

153 |
154 | 155 | 164 |
165 | )} 166 |
167 | 168 | {currentStep === 'review' && ( 169 | 176 | )} 177 |
178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /app/components/shared/import-settings.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import useMigrationStore from '@/utils/store'; 4 | import type { PlatformConfig } from '@/utils/store'; 5 | 6 | import Heading from '../heading'; 7 | 8 | interface MuxPlatformConfig { 9 | videoQuality: 'basic' | 'plus' | 'premium'; 10 | maxResolutionTier?: '1080p' | '1440p' | '2160p'; // Conditionally available based on videoQuality 11 | autoGenerateCaptions?: boolean; 12 | playbackPolicy?: ('public' | 'signed')[]; // Optional array for multi-checkboxes 13 | } 14 | 15 | interface Field { 16 | label: string; 17 | description: string; 18 | docsUrl: string; 19 | name: string; 20 | type: 'select' | 'checkbox' | 'multi-checkbox' | 'text'; 21 | values?: string[] | ((config: any) => string[]); 22 | visible?: (config: PlatformConfig) => boolean; 23 | } 24 | 25 | const PLATFORM_METADATA_FIELDS: { id: string; fields: Field[] }[] = [ 26 | { 27 | id: 'mux', 28 | fields: [ 29 | { 30 | label: 'Video quality', 31 | description: 32 | 'The video quality level informs the cost, quality, and available platform features for the asset.', 33 | docsUrl: 'https://www.mux.com/docs/guides/use-video-quality-levels', 34 | name: 'videoQuality', 35 | type: 'select', 36 | values: ['basic', 'plus', 'premium'], 37 | }, 38 | { 39 | label: 'Max resolution tier', 40 | description: 41 | 'This field controls the maximum resolution that Mux will encode, store, and deliver your media in. Mux does not to automatically ingest content at 4K so that you can avoid unexpectedly high ingest bills', 42 | docsUrl: 'https://docs.mux.com/guides/stream-videos-in-4k', 43 | name: 'maxResolutionTier', 44 | type: 'select', 45 | values: (config) => (config.videoQuality === 'basic' ? ['1080p'] : ['1080p', '1440p', '2160p']), 46 | }, 47 | { 48 | label: 'Auto-generate captions', 49 | description: 'Automatically generate captions for your videos', 50 | docsUrl: 'https://docs.mux.com/guides/add-autogenerated-captions-and-use-transcripts', 51 | name: 'autoGenerateCaptions', 52 | type: 'checkbox', 53 | }, 54 | { 55 | label: 'Test mode', 56 | description: 'For testing only. Limits asset duration to 10 seconds and adds a watermark to the video', 57 | docsUrl: 'https://www.mux.com/blog/new-test-mux-video-features-for-free', 58 | name: 'testMode', 59 | type: 'checkbox', 60 | }, 61 | { 62 | label: 'Playback policy', 63 | description: 64 | 'Playback policies allow you to control the different ways users can view and interact with your content.', 65 | docsUrl: 'https://docs.mux.com/guides/secure-video-playback', 66 | name: 'playbackPolicy', 67 | type: 'multi-checkbox', 68 | values: ['public', 'signed'], 69 | }, 70 | ], 71 | }, 72 | ]; 73 | 74 | export default function DestinationMetadata() { 75 | const destinationPlatform = useMigrationStore((state) => state.destinationPlatform); 76 | const setPlatform = useMigrationStore((state) => state.setPlatform); 77 | const setCurrentStep = useMigrationStore((state) => state.setCurrentStep); 78 | const platform = useMigrationStore((state) => 79 | state.currentStep === 'set-import-settings' ? state.destinationPlatform : state.sourcePlatform 80 | ); 81 | const [config, setConfig] = useState({ videoQuality: 'basic' }); 82 | 83 | if (!platform) { 84 | return null; 85 | } 86 | 87 | const platformFields = PLATFORM_METADATA_FIELDS.find((field) => field.id === destinationPlatform?.id); 88 | 89 | const handleFieldChange = (field: Field, value: any) => { 90 | if (field.type === 'checkbox') { 91 | // Handle single checkbox 92 | console.log(field); 93 | console.log(value); 94 | const updatedConfig = value ? { ...config, [field.name]: '1' } : { ...config }; 95 | if (!value) { 96 | delete updatedConfig[field.name]; 97 | } 98 | setConfig(updatedConfig); 99 | } else if (field.type === 'multi-checkbox') { 100 | // Handle multi-checkbox 101 | let newArray = (config[field.name] as string[]) || []; 102 | if (value.checked) { 103 | // Add value if checked 104 | newArray = [...newArray, value.optionValue]; 105 | } else { 106 | // Remove value if unchecked 107 | newArray = newArray.filter((item) => item !== value.optionValue); 108 | } 109 | // Update or delete key based on array contents 110 | const updatedConfig = newArray.length > 0 ? { ...config, [field.name]: newArray } : { ...config }; 111 | if (newArray.length === 0) { 112 | delete updatedConfig[field.name]; 113 | } 114 | setConfig(updatedConfig); 115 | } else { 116 | // Handle select and text inputs 117 | setConfig({ ...config, [field.name]: value }); 118 | } 119 | }; 120 | 121 | const onSubmit = async (e: React.FormEvent) => { 122 | e.preventDefault(); 123 | setPlatform(platform.type, { ...platform, config }); 124 | setCurrentStep('review'); 125 | }; 126 | 127 | return ( 128 |
129 | Choose your import settings 130 | 131 |
132 | {platformFields?.fields.map((field) => { 133 | if (field.visible && !field.visible(config)) { 134 | return null; 135 | } 136 | 137 | const fieldId = `field-${field.name}`; 138 | return ( 139 |
140 | {field.type === 'select' && ( 141 |
142 | 145 | 159 | {(field.description || field.docsUrl) && ( 160 |

161 | {field.description} 162 | {field.docsUrl && ( 163 | <> 164 | {' '} 165 | 170 | Read more 171 | 172 | 173 | )} 174 |

175 | )} 176 |
177 | )} 178 | 179 | {field.type === 'checkbox' && ( 180 |
181 |
182 |
183 | handleFieldChange(field, e.target.checked)} 189 | className="h-4 w-4 rounded border border-gray-300 text-indigo-600 focus:ring-indigo-600" 190 | /> 191 |
192 |
193 | 196 |
197 |
198 | {(field.description || field.docsUrl) && ( 199 |

200 | {field.description} 201 | {field.docsUrl && ( 202 | <> 203 | {' '} 204 | 209 | Read more 210 | 211 | 212 | )} 213 |

214 | )} 215 |
216 | )} 217 | 218 | {field.type === 'multi-checkbox' && ( 219 |
220 |
221 | {field.label} 222 |
223 | {field.values && 224 | (typeof field.values === 'function' ? field.values(config) : field.values).map((value) => ( 225 |
226 | 233 | handleFieldChange(field, { optionValue: e.target.value, checked: e.target.checked }) 234 | } 235 | className="h-4 w-4 rounded border border-gray-300 text-indigo-600 focus:ring-indigo-600" 236 | /> 237 | 240 |
241 | ))} 242 |
243 |
244 |

245 | {field.description} 246 | {field.docsUrl && ( 247 | <> 248 | {' '} 249 | 254 | Read more 255 | 256 | 257 | )} 258 |

259 |
260 | )} 261 | 262 | {field.type === 'text' && ( 263 |
264 | 267 | handleFieldChange(field, e.target.value)} 273 | className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50" 274 | /> 275 |

276 | {field.description}{' '} 277 | 278 | Read more 279 | 280 |

281 |
282 | )} 283 |
284 | ); 285 | })} 286 |
287 | 288 | 294 |
295 | ); 296 | } 297 | --------------------------------------------------------------------------------