convert flacs to aac for streaming
This commit is contained in:
8
bun.lock
8
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
public/ffmpeg/worker.js
Normal file
1
public/ffmpeg/worker.js
Normal 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)};
|
||||
100
src/app/admin/music/_components/ConvertToStreamButton.tsx
Normal file
100
src/app/admin/music/_components/ConvertToStreamButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,8 +15,9 @@ export default function AdminMusicPage() {
|
||||
{tracks && <>
|
||||
{tracks.map((t) => (
|
||||
<Card.Card key={t.id}>
|
||||
<Card.CardContent>
|
||||
<Card.CardContent className="flex flex-col gap-4">
|
||||
<UploadMusicForm entity={t} className="w-full"/>
|
||||
<ConvertToStreamButton track={t} />
|
||||
</Card.CardContent>
|
||||
</Card.Card>
|
||||
))}
|
||||
|
||||
125
src/app/music/_components/AudioPlayer.tsx
Normal file
125
src/app/music/_components/AudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
|
||||
)}
|
||||
<AnimatePopUp position={i + 1.3}>
|
||||
<audio controls className="w-full player" src={track.fileUrl}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<AudioPlayer
|
||||
src={track.streamUrl ?? track.fileUrl}
|
||||
downloadUrl={track.fileUrl}
|
||||
downloadName={track.fileName}
|
||||
/>
|
||||
</AnimatePopUp>
|
||||
</Card.CardContent>
|
||||
</Card.AnimatedCard>
|
||||
|
||||
114
src/lib/ffmpeg/transcode.ts
Normal file
114
src/lib/ffmpeg/transcode.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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<FileRouter>();
|
||||
export const UploadDropzone = generateUploadDropzone<FileRouter>();
|
||||
|
||||
export const { useUploadThing } = generateReactHelpers<FileRouter>();
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user