convert flacs to aac for streaming

This commit is contained in:
2026-06-16 12:49:30 +02:00
parent f5e8b87846
commit 7aa1746f97
12 changed files with 401 additions and 8 deletions

View File

@@ -9,6 +9,8 @@
"@ai-sdk/react": "^3.0.195", "@ai-sdk/react": "^3.0.195",
"@clerk/nextjs": "^7.4.2", "@clerk/nextjs": "^7.4.2",
"@electric-sql/pglite": "^0.4.6", "@electric-sql/pglite": "^0.4.6",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1", "@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=="], "@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/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=="], "@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=="],

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build", "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": "biome check .",
"check:unsafe": "biome check --write --unsafe .", "check:unsafe": "biome check --write --unsafe .",
"check:write": "biome check --write .", "check:write": "biome check --write .",
@@ -23,6 +24,8 @@
"@ai-sdk/react": "^3.0.195", "@ai-sdk/react": "^3.0.195",
"@clerk/nextjs": "^7.4.2", "@clerk/nextjs": "^7.4.2",
"@electric-sql/pglite": "^0.4.6", "@electric-sql/pglite": "^0.4.6",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1", "@fortawesome/react-fontawesome": "^3.3.1",

1
public/ffmpeg/worker.js Normal file
View File

@@ -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)};

View File

@@ -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<RouterOutputs['music']['list']>;
}) {
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 (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={handleConvert}>
{busy ? (
<Loader2 className="animate-spin" />
) : track.streamUrl ? (
<RefreshCw />
) : (
<AudioLines />
)}
{busy
? `${stage}${stage === "Transcoding" && progress ? ` ${Math.round(progress * 100)}%` : "…"}`
: track.streamUrl
? "Re-generate stream"
: "Generate stream (AAC)"}
</Button>
{track.streamUrl && !busy && (
<span className="text-xs text-muted-foreground">Streaming version ready</span>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { trpc } from "~/app/_trpc/Client"; import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card"; import * as Card from "~/components/ui/card";
import UploadMusicForm from "./_components/UploadMusicForm"; import UploadMusicForm from "./_components/UploadMusicForm";
import ConvertToStreamButton from "./_components/ConvertToStreamButton";
import { CollapsibleForm } from "~/app/_components/Form/Components"; import { CollapsibleForm } from "~/app/_components/Form/Components";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -14,10 +15,11 @@ export default function AdminMusicPage() {
{tracks && <> {tracks && <>
{tracks.map((t) => ( {tracks.map((t) => (
<Card.Card key={t.id}> <Card.Card key={t.id}>
<Card.CardContent> <Card.CardContent className="flex flex-col gap-4">
<UploadMusicForm entity={t} className="w-full"/> <UploadMusicForm entity={t} className="w-full"/>
<ConvertToStreamButton track={t} />
</Card.CardContent> </Card.CardContent>
</Card.Card> </Card.Card>
))} ))}
</>} </>}
<CollapsibleForm entityName="Track" form={UploadMusicForm}/> <CollapsibleForm entityName="Track" form={UploadMusicForm}/>

View File

@@ -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<HTMLAudioElement>(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 <a download> 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 (
<div className="flex items-center gap-3 rounded-lg border bg-transparent px-3 py-2">
<audio
ref={audioRef}
src={props.src}
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onTimeUpdate={(e) => {
if (!seeking) setCurrent(e.currentTarget.currentTime);
}}
onEnded={() => setPlaying(false)}
/>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={playing ? "Pause" : "Play"}
onClick={togglePlay}
>
{playing ? <Pause /> : <Play />}
</Button>
<span className="w-10 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(current)}
</span>
<Slider
className="flex-1"
min={0}
max={duration || 1}
step={0.1}
value={[current]}
onValueChange={([v]) => {
setSeeking(true);
setCurrent(v ?? 0);
}}
onValueCommit={([v]) => {
if (audioRef.current) audioRef.current.currentTime = v ?? 0;
setSeeking(false);
}}
/>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatTime(duration)}
</span>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Download lossless file"
title="Download lossless file"
disabled={downloading}
onClick={handleDownload}
>
{downloading ? <Loader2 className="animate-spin" /> : <Download />}
</Button>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { Spinner } from "~/components/ui/spinner";
import AnimateTextIn from "../_components/Animated/AnimateIn"; import AnimateTextIn from "../_components/Animated/AnimateIn";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import AnimatePopUp from "../_components/Animated/AnimatePopUp"; import AnimatePopUp from "../_components/Animated/AnimatePopUp";
import AudioPlayer from "./_components/AudioPlayer";
export default function MusicPage() { export default function MusicPage() {
const { data: tracks, isLoading } = trpc.music.list.useQuery(); const { data: tracks, isLoading } = trpc.music.list.useQuery();
useTimeLine(tracks) useTimeLine(tracks)
@@ -41,9 +42,11 @@ export default function MusicPage() {
<p className="text-sm text-muted-foreground gsapant">{track.description}</p> <p className="text-sm text-muted-foreground gsapant">{track.description}</p>
)} )}
<AnimatePopUp position={i + 1.3}> <AnimatePopUp position={i + 1.3}>
<audio controls className="w-full player" src={track.fileUrl}> <AudioPlayer
Your browser does not support the audio element. src={track.streamUrl ?? track.fileUrl}
</audio> downloadUrl={track.fileUrl}
downloadName={track.fileName}
/>
</AnimatePopUp> </AnimatePopUp>
</Card.CardContent> </Card.CardContent>
</Card.AnimatedCard> </Card.AnimatedCard>

114
src/lib/ffmpeg/transcode.ts Normal file
View File

@@ -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<FFmpeg> | null = null;
function load(onLog?: (msg: string) => void): Promise<FFmpeg> {
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<File> {
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);
}
}

View File

@@ -7,6 +7,9 @@ export const createMusicInputSchema = z.object({
fileUrl: z.string(), fileUrl: z.string(),
fileKey: z.string(), fileKey: z.string(),
fileName: 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({ export const updateMusicInputSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
@@ -15,4 +18,14 @@ export const updateMusicInputSchema = z.object({
fileUrl: z.string().optional(), fileUrl: z.string().optional(),
fileKey: z.string().optional(), fileKey: z.string().optional(),
fileName: 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(),
}) })

View File

@@ -1,5 +1,7 @@
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react"; import { generateReactHelpers, generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
import type { FileRouter } from "~/server/uploadthing"; import type { FileRouter } from "~/server/uploadthing";
export const UploadButton = generateUploadButton<FileRouter>(); export const UploadButton = generateUploadButton<FileRouter>();
export const UploadDropzone = generateUploadDropzone<FileRouter>(); export const UploadDropzone = generateUploadDropzone<FileRouter>();
export const { useUploadThing } = generateReactHelpers<FileRouter>();

View File

@@ -94,9 +94,14 @@ export const music = createTable(
id: d.uuid().primaryKey().notNull(), id: d.uuid().primaryKey().notNull(),
title: d.varchar({ length: 100 }).notNull(), title: d.varchar({ length: 100 }).notNull(),
description: d.text(), description: d.text(),
// Original high-quality upload (e.g. FLAC), offered as a download.
fileUrl: d.varchar("file_url", { length: 500 }).notNull(), fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
fileKey: d.varchar("file_key", { length: 200 }).notNull(), fileKey: d.varchar("file_key", { length: 200 }).notNull(),
fileName: d.varchar("file_name", { 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 createdAt: d
.timestamp({ withTimezone: true }) .timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`) .default(sql`CURRENT_TIMESTAMP`)

View File

@@ -5,7 +5,7 @@ import { eq } from "drizzle-orm";
import { isAdmin } from "~/app/actions"; import { isAdmin } from "~/app/actions";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; 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"; import { utapi } from "../uploadthing";
export const musicRouter = router({ export const musicRouter = router({
list: publicProcedure.query(async () => { list: publicProcedure.query(async () => {
@@ -33,6 +33,22 @@ export const musicRouter = router({
const { id, ...data } = input; const { id, ...data } = input;
return db.update(music).set(data).where(eq(music.id, id)).returning(); 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 delete: publicProcedure
.input(z.object({id:z.string().uuid()})) .input(z.object({id:z.string().uuid()}))
.mutation(async ({ input }) => { .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 res = await db.delete(music).where(eq(music.id, input.id)).returning();
let ret = res.at(0) let ret = res.at(0)
if (ret) { if (ret) {
utapi.deleteFiles(ret.fileKey) const keys = [ret.fileKey, ret.streamKey].filter((k): k is string => !!k);
utapi.deleteFiles(keys)
} }
return ret; return ret;
}), }),