diff --git a/bun.lock b/bun.lock index 965677c..90ebd4e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,8 @@ "@ai-sdk/react": "^3.0.195", "@clerk/nextjs": "^7.4.2", "@electric-sql/pglite": "^0.4.6", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.3.1", @@ -354,6 +356,12 @@ "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], + "@ffmpeg/ffmpeg": ["@ffmpeg/ffmpeg@0.12.15", "", { "dependencies": { "@ffmpeg/types": "^0.12.4" } }, "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw=="], + + "@ffmpeg/types": ["@ffmpeg/types@0.12.4", "", {}, "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A=="], + + "@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], diff --git a/package.json b/package.json index b82782a..b391f6e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "next build", + "build:ffmpeg-worker": "bun build node_modules/@ffmpeg/ffmpeg/dist/esm/worker.js --target browser --format esm --minify --outfile public/ffmpeg/worker.js", "check": "biome check .", "check:unsafe": "biome check --write --unsafe .", "check:write": "biome check --write .", @@ -23,6 +24,8 @@ "@ai-sdk/react": "^3.0.195", "@clerk/nextjs": "^7.4.2", "@electric-sql/pglite": "^0.4.6", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/react-fontawesome": "^3.3.1", diff --git a/public/ffmpeg/worker.js b/public/ffmpeg/worker.js new file mode 100644 index 0000000..5168d0a --- /dev/null +++ b/public/ffmpeg/worker.js @@ -0,0 +1 @@ +var C="https://unpkg.com/@ffmpeg/core@0.12.9/dist/umd/ffmpeg-core.js",N;(function(E){E.LOAD="LOAD",E.EXEC="EXEC",E.FFPROBE="FFPROBE",E.WRITE_FILE="WRITE_FILE",E.READ_FILE="READ_FILE",E.DELETE_FILE="DELETE_FILE",E.RENAME="RENAME",E.CREATE_DIR="CREATE_DIR",E.LIST_DIR="LIST_DIR",E.DELETE_DIR="DELETE_DIR",E.ERROR="ERROR",E.DOWNLOAD="DOWNLOAD",E.PROGRESS="PROGRESS",E.LOG="LOG",E.MOUNT="MOUNT",E.UNMOUNT="UNMOUNT"})(N||(N={}));var G=Error("unknown message type"),V=Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"),b=Error("called FFmpeg.terminate()"),v=Error("failed to import ffmpeg-core.js");var I,X=async({coreURL:E,wasmURL:D,workerURL:O})=>{let S=!I;try{if(!E)E=C;importScripts(E)}catch{if(!E||E===C)E=C.replace("/umd/","/esm/");if(self.createFFmpegCore=(await import(E)).default,!self.createFFmpegCore)throw v}let A=E,x=D?D:E.replace(/.js$/g,".wasm"),B=O?O:E.replace(/.js$/g,".worker.js");return I=await self.createFFmpegCore({mainScriptUrlOrBlob:`${A}#${btoa(JSON.stringify({wasmURL:x,workerURL:B}))}`}),I.setLogger((W)=>self.postMessage({type:N.LOG,data:W})),I.setProgress((W)=>self.postMessage({type:N.PROGRESS,data:W})),S},Y=({args:E,timeout:D=-1})=>{I.setTimeout(D),I.exec(...E);let O=I.ret;return I.reset(),O},j=({args:E,timeout:D=-1})=>{I.setTimeout(D),I.ffprobe(...E);let O=I.ret;return I.reset(),O},J=({path:E,data:D})=>{return I.FS.writeFile(E,D),!0},$=({path:E,encoding:D})=>I.FS.readFile(E,{encoding:D}),q=({path:E})=>{return I.FS.unlink(E),!0},z=({oldPath:E,newPath:D})=>{return I.FS.rename(E,D),!0},H=({path:E})=>{return I.FS.mkdir(E),!0},K=({path:E})=>{let D=I.FS.readdir(E),O=[];for(let S of D){let A=I.FS.stat(`${E}/${S}`),x=I.FS.isDir(A.mode);O.push({name:S,isDir:x})}return O},Q=({path:E})=>{return I.FS.rmdir(E),!0},R=({fsType:E,options:D,mountPoint:O})=>{let S=E,A=I.FS.filesystems[S];if(!A)return!1;return I.FS.mount(A,D,O),!0},Z=({mountPoint:E})=>{return I.FS.unmount(E),!0};self.onmessage=async({data:{id:E,type:D,data:O}})=>{let S=[],A;try{if(D!==N.LOAD&&!I)throw V;switch(D){case N.LOAD:A=await X(O);break;case N.EXEC:A=Y(O);break;case N.FFPROBE:A=j(O);break;case N.WRITE_FILE:A=J(O);break;case N.READ_FILE:A=$(O);break;case N.DELETE_FILE:A=q(O);break;case N.RENAME:A=z(O);break;case N.CREATE_DIR:A=H(O);break;case N.LIST_DIR:A=K(O);break;case N.DELETE_DIR:A=Q(O);break;case N.MOUNT:A=R(O);break;case N.UNMOUNT:A=Z(O);break;default:throw G}}catch(x){self.postMessage({id:E,type:N.ERROR,data:x.toString()});return}if(A instanceof Uint8Array)S.push(A.buffer);self.postMessage({id:E,type:D,data:A},S)}; diff --git a/src/app/admin/music/_components/ConvertToStreamButton.tsx b/src/app/admin/music/_components/ConvertToStreamButton.tsx new file mode 100644 index 0000000..6332b26 --- /dev/null +++ b/src/app/admin/music/_components/ConvertToStreamButton.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useState } from "react"; +import { AudioLines, Loader2, RefreshCw } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { trpc } from "~/app/_trpc/Client"; +import { useUploadThing } from "~/lib/uploadthing"; +import { transcodeToAac } from "~/lib/ffmpeg/transcode"; +import { toast } from "sonner"; +import type { RouterOutputs } from "~/server/routers/_app"; +import type { IterableElement } from "type-fest"; + +export default function ConvertToStreamButton(props: { + track: IterableElement; +}) { + const { track } = props; + const utils = trpc.useUtils(); + const [busy, setBusy] = useState(false); + const [stage, setStage] = useState(""); + const [progress, setProgress] = useState(0); + + const setStream = trpc.music.setStream.useMutation({ + onSuccess: () => utils.music.list.invalidate(), + }); + + const { startUpload } = useUploadThing("musicUploader"); + + async function handleConvert() { + setBusy(true); + setProgress(0); + let currentStage = "Loading ffmpeg"; + const goto = (s: string) => { + currentStage = s; + setStage(s); + }; + try { + goto("Transcoding"); + const file = await transcodeToAac({ + sourceUrl: track.fileUrl, + outputName: `${track.title || "track"}.m4a`, + onProgress: setProgress, + }); + + goto("Uploading"); + const uploaded = await startUpload([file]); + const res = uploaded?.[0]; + if (!res) throw new Error("Upload returned no file"); + + goto("Saving"); + await setStream.mutateAsync({ + id: track.id, + streamUrl: res.serverData.fileUrl, + streamKey: res.serverData.fileKey, + streamName: res.serverData.fileName, + }); + toast("Streaming version saved"); + } catch (e) { + console.error("[ConvertToStream] failed during", currentStage, e); + const detail = + e instanceof Error + ? e.message + : typeof e === "string" + ? e + : (() => { + try { + return JSON.stringify(e); + } catch { + return String(e); + } + })(); + toast(`Conversion failed (${currentStage}): ${detail || "see console for details"}`); + } finally { + setBusy(false); + setStage(""); + setProgress(0); + } + } + + return ( +
+ + {track.streamUrl && !busy && ( + Streaming version ready + )} +
+ ); +} diff --git a/src/app/admin/music/page.tsx b/src/app/admin/music/page.tsx index 3297f93..d4691e3 100644 --- a/src/app/admin/music/page.tsx +++ b/src/app/admin/music/page.tsx @@ -3,6 +3,7 @@ import { trpc } from "~/app/_trpc/Client"; import * as Card from "~/components/ui/card"; import UploadMusicForm from "./_components/UploadMusicForm"; +import ConvertToStreamButton from "./_components/ConvertToStreamButton"; import { CollapsibleForm } from "~/app/_components/Form/Components"; import { useEffect } from "react"; @@ -14,10 +15,11 @@ export default function AdminMusicPage() { {tracks && <> {tracks.map((t) => ( - + + - + ))} } diff --git a/src/app/music/_components/AudioPlayer.tsx b/src/app/music/_components/AudioPlayer.tsx new file mode 100644 index 0000000..d4cca6c --- /dev/null +++ b/src/app/music/_components/AudioPlayer.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useRef, useState } from "react"; +import { Download, Loader2, Pause, Play } from "lucide-react"; +import { Slider } from "~/components/ui/slider"; +import { Button } from "~/components/ui/button"; +import { toast } from "sonner"; + +function formatTime(seconds: number) { + if (!Number.isFinite(seconds)) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export default function AudioPlayer(props: { + /** Streaming-friendly source the player actually plays. */ + src: string; + /** Original high-quality file offered via the download button. */ + downloadUrl: string; + downloadName: string; +}) { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [current, setCurrent] = useState(0); + const [duration, setDuration] = useState(0); + const [seeking, setSeeking] = useState(false); + const [downloading, setDownloading] = useState(false); + + function togglePlay() { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + audio.play(); + } else { + audio.pause(); + } + } + + async function handleDownload() { + setDownloading(true); + try { + // The download file is cross-origin, so the attribute is + // ignored — fetch it as a blob to force a real download. + const res = await fetch(props.downloadUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = props.downloadName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + toast(`Download failed: ${e instanceof Error ? e.message : "unknown error"}`); + } finally { + setDownloading(false); + } + } + + return ( +
+
+ ); +} diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index daf19f1..ae4d441 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -7,6 +7,7 @@ import { Spinner } from "~/components/ui/spinner"; import AnimateTextIn from "../_components/Animated/AnimateIn"; import { ScrollArea } from "~/components/ui/scroll-area"; import AnimatePopUp from "../_components/Animated/AnimatePopUp"; +import AudioPlayer from "./_components/AudioPlayer"; export default function MusicPage() { const { data: tracks, isLoading } = trpc.music.list.useQuery(); useTimeLine(tracks) @@ -41,9 +42,11 @@ export default function MusicPage() {

{track.description}

)} - + diff --git a/src/lib/ffmpeg/transcode.ts b/src/lib/ffmpeg/transcode.ts new file mode 100644 index 0000000..73f9ec3 --- /dev/null +++ b/src/lib/ffmpeg/transcode.ts @@ -0,0 +1,114 @@ +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import { fetchFile, toBlobURL } from "@ffmpeg/util"; + +// Single-threaded core: works without SharedArrayBuffer, so no COOP/COEP +// cross-origin-isolation headers are required. Slower than the MT core, which +// is fine for occasional admin-side transcodes. +const CORE_VERSION = "0.12.10"; +// @ffmpeg/ffmpeg 0.12.x always spawns a `type: "module"` worker. A module +// worker can't `importScripts`, so the worker dynamically `import()`s the core — +// which means the core must be the ESM build, not UMD. +const CORE_BASE = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/esm`; +// Self-hosted worker, bundled from @ffmpeg/ffmpeg's ESM `worker.js` via the +// `build:ffmpeg-worker` package script (rerun it when bumping @ffmpeg/ffmpeg). +// The shipped UMD worker is unusable here: its +// dynamic import is webpack-compiled into a stub that throws MODULE_NOT_FOUND, +// and letting Next bundle the worker hits the same problem. Serving our own +// same-origin module worker keeps the native `import()` intact. +// Must be an absolute URL: the library resolves classWorkerURL against +// `import.meta.url`, which is a file:// base under Next, so a root-relative path +// would wrongly become `file:///ffmpeg/worker.js`. +const WORKER_PATH = "/ffmpeg/worker.js"; + +let instance: FFmpeg | null = null; +let loadPromise: Promise | null = null; + +function load(onLog?: (msg: string) => void): Promise { + if (instance) return Promise.resolve(instance); + if (!loadPromise) { + loadPromise = (async () => { + const ffmpeg = new FFmpeg(); + ffmpeg.on("log", ({ message }) => { + console.debug("[ffmpeg]", message); + onLog?.(message); + }); + const ok = await ffmpeg.load({ + coreURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.js`, "text/javascript"), + wasmURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"), + classWorkerURL: new URL(WORKER_PATH, window.location.origin).href, + }); + if (!ok) throw new Error("ffmpeg.load() returned false — core failed to initialise"); + instance = ffmpeg; + return ffmpeg; + })(); + // If loading fails, clear the cached promise so the next click retries. + loadPromise.catch(() => { + loadPromise = null; + }); + } + return loadPromise; +} + +/** + * Transcodes a (FLAC) source URL to a streaming-friendly AAC/m4a file in the + * browser. Returns a File ready to hand to UploadThing's startUpload. + */ +export async function transcodeToAac(opts: { + sourceUrl: string; + outputName?: string; + bitrate?: string; + onProgress?: (ratio: number) => void; + onLog?: (msg: string) => void; +}): Promise { + let lastLog = ""; + const onLog = (msg: string) => { + if (msg.trim()) lastLog = msg; + opts.onLog?.(msg); + }; + const ffmpeg = await load(onLog); + // `load` only wires the persisted instance's log handler on first init; make + // sure this call's logs are captured even when the instance was cached. + const logHandler = ({ message }: { message: string }) => onLog(message); + ffmpeg.on("log", logHandler); + const progressHandler = opts.onProgress + ? ({ progress }: { progress: number }) => + opts.onProgress?.(Math.min(1, Math.max(0, progress))) + : null; + if (progressHandler) ffmpeg.on("progress", progressHandler); + try { + // fetchFile pulls the source cross-origin; surface a clear message if the + // host (UploadThing) blocks it. + let sourceData: Uint8Array; + try { + sourceData = await fetchFile(opts.sourceUrl); + } catch (e) { + throw new Error( + `Could not fetch source file (CORS or network): ${e instanceof Error ? e.message : String(e)}`, + ); + } + await ffmpeg.writeFile("input.flac", sourceData); + const code = await ffmpeg.exec([ + "-i", "input.flac", + "-c:a", "aac", + "-b:a", opts.bitrate ?? "192k", + // Move the moov atom to the front so the player can start before the + // whole file has downloaded — the whole point of the stream version. + "-movflags", "+faststart", + "output.m4a", + ]); + if (code !== 0) { + throw new Error(`ffmpeg exited with code ${code}: ${lastLog || "no log output"}`); + } + const data = await ffmpeg.readFile("output.m4a"); + await ffmpeg.deleteFile("input.flac"); + await ffmpeg.deleteFile("output.m4a"); + // Copy into a fresh ArrayBuffer-backed view so the Blob typing is happy + // (readFile may be typed against a SharedArrayBuffer-backed Uint8Array). + const bytes = new Uint8Array(data as Uint8Array); + const blob = new Blob([bytes], { type: "audio/mp4" }); + return new File([blob], opts.outputName ?? "stream.m4a", { type: "audio/mp4" }); + } finally { + ffmpeg.off("log", logHandler); + if (progressHandler) ffmpeg.off("progress", progressHandler); + } +} diff --git a/src/lib/trpc/music/schemas.ts b/src/lib/trpc/music/schemas.ts index 31bfb76..8fc84f2 100644 --- a/src/lib/trpc/music/schemas.ts +++ b/src/lib/trpc/music/schemas.ts @@ -7,6 +7,9 @@ export const createMusicInputSchema = z.object({ fileUrl: z.string(), fileKey: z.string(), fileName: z.string(), + streamUrl: z.string().optional().nullable(), + streamKey: z.string().optional().nullable(), + streamName: z.string().optional().nullable(), }) export const updateMusicInputSchema = z.object({ id: z.string().uuid(), @@ -15,4 +18,14 @@ export const updateMusicInputSchema = z.object({ fileUrl: z.string().optional(), fileKey: z.string().optional(), fileName: z.string().optional(), + streamUrl: z.string().optional().nullable(), + streamKey: z.string().optional().nullable(), + streamName: z.string().optional().nullable(), + }) + +export const setStreamInputSchema = z.object({ + id: z.string().uuid(), + streamUrl: z.string(), + streamKey: z.string(), + streamName: z.string(), }) diff --git a/src/lib/uploadthing.ts b/src/lib/uploadthing.ts index ff10599..6021d84 100644 --- a/src/lib/uploadthing.ts +++ b/src/lib/uploadthing.ts @@ -1,5 +1,7 @@ -import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react"; +import { generateReactHelpers, generateUploadButton, generateUploadDropzone } from "@uploadthing/react"; import type { FileRouter } from "~/server/uploadthing"; export const UploadButton = generateUploadButton(); export const UploadDropzone = generateUploadDropzone(); + +export const { useUploadThing } = generateReactHelpers(); diff --git a/src/server/dbschema/schema.ts b/src/server/dbschema/schema.ts index 8fd2844..b586030 100644 --- a/src/server/dbschema/schema.ts +++ b/src/server/dbschema/schema.ts @@ -94,9 +94,14 @@ export const music = createTable( id: d.uuid().primaryKey().notNull(), title: d.varchar({ length: 100 }).notNull(), description: d.text(), + // Original high-quality upload (e.g. FLAC), offered as a download. fileUrl: d.varchar("file_url", { length: 500 }).notNull(), fileKey: d.varchar("file_key", { length: 200 }).notNull(), fileName: d.varchar("file_name", { length: 200 }).notNull(), + // Lighter, streaming-friendly transcode (e.g. AAC) used by the player. + streamUrl: d.varchar("stream_url", { length: 500 }), + streamKey: d.varchar("stream_key", { length: 200 }), + streamName: d.varchar("stream_name", { length: 200 }), createdAt: d .timestamp({ withTimezone: true }) .default(sql`CURRENT_TIMESTAMP`) diff --git a/src/server/routers/music.ts b/src/server/routers/music.ts index 190c3ce..359cb44 100644 --- a/src/server/routers/music.ts +++ b/src/server/routers/music.ts @@ -5,7 +5,7 @@ import { eq } from "drizzle-orm"; import { isAdmin } from "~/app/actions"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { createMusicInputSchema, updateMusicInputSchema } from "~/lib/trpc/music/schemas"; +import { createMusicInputSchema, updateMusicInputSchema, setStreamInputSchema } from "~/lib/trpc/music/schemas"; import { utapi } from "../uploadthing"; export const musicRouter = router({ list: publicProcedure.query(async () => { @@ -33,6 +33,22 @@ export const musicRouter = router({ const { id, ...data } = input; return db.update(music).set(data).where(eq(music.id, id)).returning(); }), + setStream: publicProcedure + .input(setStreamInputSchema) + .mutation(async ({ input }) => { + const admin = await isAdmin(); + if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" }); + const { id, ...data } = input; + const existing = await db.select().from(music).where(eq(music.id, id)).limit(1); + const prev = existing.at(0); + if (!prev) throw new TRPCError({ code: "NOT_FOUND", message: "Track not found" }); + const res = await db.update(music).set(data).where(eq(music.id, id)).returning(); + // Drop the previous stream transcode so we don't orphan it on UploadThing. + if (prev.streamKey && prev.streamKey !== data.streamKey) { + utapi.deleteFiles(prev.streamKey); + } + return res.at(0); + }), delete: publicProcedure .input(z.object({id:z.string().uuid()})) .mutation(async ({ input }) => { @@ -41,7 +57,8 @@ export const musicRouter = router({ let res = await db.delete(music).where(eq(music.id, input.id)).returning(); let ret = res.at(0) if (ret) { - utapi.deleteFiles(ret.fileKey) + const keys = [ret.fileKey, ret.streamKey].filter((k): k is string => !!k); + utapi.deleteFiles(keys) } return ret; }),