remove youtube support and add local file handling

This commit is contained in:
RandyJC
2025-12-08 15:38:23 +01:00
parent df615dc720
commit e5fd5d52f0
8 changed files with 667 additions and 263 deletions

View File

@@ -26,10 +26,9 @@ export type Quizz = {
}
export type QuestionMedia =
| { type: "image"; url: string }
| { type: "audio"; url: string }
| { type: "video"; url: string }
| { type: "youtube"; url: string }
| { type: "image"; url: string; fileName?: string }
| { type: "audio"; url: string; fileName?: string }
| { type: "video"; url: string; fileName?: string }
export type QuizzWithId = Quizz & { id: string }

View File

@@ -16,6 +16,8 @@ const getPath = (path: string = "") =>
? resolve(inContainerPath, path)
: resolve(process.cwd(), "../../config", path)
export const getConfigPath = (path: string = "") => getPath(path)
class Config {
static ensureBaseFolders() {
const isConfigFolderExists = fs.existsSync(getPath())
@@ -29,6 +31,12 @@ class Config {
if (!isQuizzExists) {
fs.mkdirSync(getPath("quizz"))
}
const isMediaExists = fs.existsSync(getPath("media"))
if (!isMediaExists) {
fs.mkdirSync(getPath("media"))
}
}
static init() {
@@ -109,19 +117,8 @@ class Config {
"Golden Gate Bridge",
],
media: {
type: "youtube",
url: "https://www.youtube.com/watch?v=jNQXAC9IVRw",
},
solution: 3,
cooldown: 5,
time: 60,
},
{
question: "What kind of animal is featured here?",
answers: ["Dolphin", "Panda", "Horse", "Penguin"],
media: {
type: "youtube",
url: "https://www.youtube.com/watch?v=2k1qW3D0q6c",
type: "video",
url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
},
solution: 2,
cooldown: 5,
@@ -216,6 +213,12 @@ class Config {
return this.getQuizz(finalId)
}
static getMediaPath(fileName: string = "") {
this.ensureBaseFolders()
return getPath(fileName ? `media/${fileName}` : "media")
}
}
export default Config

View File

@@ -0,0 +1,24 @@
import { deleteMediaFile } from "@rahoot/web/server/media"
import { NextResponse } from "next/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
type Params = {
params: { file: string }
}
export async function DELETE(_request: Request, { params }: Params) {
try {
const decoded = decodeURIComponent(params.file)
await deleteMediaFile(decoded)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete media", error)
const message = error instanceof Error ? error.message : "Failed to delete file"
const status = message.includes("not found") ? 404 : 400
return NextResponse.json({ error: message }, { status })
}
}

View File

@@ -0,0 +1,39 @@
import { listStoredMedia, storeMediaFile } from "@rahoot/web/server/media"
import { NextResponse } from "next/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export async function GET() {
try {
const media = await listStoredMedia()
return NextResponse.json({ media })
} catch (error) {
console.error("Failed to list media", error)
return NextResponse.json(
{ error: "Unable to list uploaded media" },
{ status: 500 },
)
}
}
export async function POST(request: Request) {
const formData = await request.formData()
const file = formData.get("file")
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file received" }, { status: 400 })
}
try {
const media = await storeMediaFile(file)
return NextResponse.json({ media })
} catch (error) {
console.error("Failed to store media", error)
const message = error instanceof Error ? error.message : "Failed to upload file"
return NextResponse.json({ error: message }, { status: 400 })
}
}

View File

@@ -0,0 +1,43 @@
import Config from "@rahoot/socket/services/config"
import { mimeForStoredFile } from "@rahoot/web/server/media"
import fs from "fs"
import { promises as fsp } from "fs"
import path from "path"
import { NextResponse } from "next/server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
type Params = {
params: { file: string }
}
export async function GET(_request: Request, { params }: Params) {
const safeName = path.basename(params.file)
if (safeName !== params.file) {
return NextResponse.json({ error: "Invalid file name" }, { status: 400 })
}
const filePath = Config.getMediaPath(safeName)
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 })
}
try {
const buffer = await fsp.readFile(filePath)
const mime = mimeForStoredFile(safeName)
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": mime,
"Cache-Control": "public, max-age=31536000, immutable",
},
})
} catch (error) {
console.error("Failed to read media file", error)
return NextResponse.json({ error: "Unable to read file" }, { status: 500 })
}
}

View File

@@ -2,86 +2,6 @@
import type { QuestionMedia as QuestionMediaType } from "@rahoot/common/types/game"
import clsx from "clsx"
import { useEffect, useMemo, useRef, useState } from "react"
type YoutubeAPI = {
Player: new (_element: string, _options: any) => {
destroy: () => void
}
PlayerState: Record<string, number>
}
type YoutubeStateChangeEvent = {
data: number
}
let youtubeApiPromise: Promise<YoutubeAPI | null> | null = null
const loadYoutubeApi = () => {
if (typeof window === "undefined") {
return Promise.resolve(null)
}
const existingApi = (window as any).YT as YoutubeAPI | undefined
if (existingApi && existingApi.Player) {
return Promise.resolve(existingApi)
}
if (!youtubeApiPromise) {
youtubeApiPromise = new Promise((resolve) => {
const tag = document.createElement("script")
tag.src = "https://www.youtube.com/iframe_api"
tag.async = true
const handleError = () => resolve(null)
tag.onerror = handleError
const existing = document.querySelector(
'script[src="https://www.youtube.com/iframe_api"]',
)
if (existing) {
existing.addEventListener("error", handleError)
}
document.head.appendChild(tag)
const win = window as any
const prevOnReady = win.onYouTubeIframeAPIReady
win.onYouTubeIframeAPIReady = () => {
prevOnReady?.()
resolve(win.YT as YoutubeAPI)
}
})
}
return youtubeApiPromise
}
const extractYoutubeId = (url: string) => {
try {
const parsed = new URL(url)
if (parsed.hostname.includes("youtu.be")) {
return parsed.pathname.replace("/", "")
}
if (parsed.searchParams.get("v")) {
return parsed.searchParams.get("v")
}
const parts = parsed.pathname.split("/")
const embedIndex = parts.indexOf("embed")
if (embedIndex !== -1 && parts[embedIndex + 1]) {
return parts[embedIndex + 1]
}
} catch (error) {
console.error("Invalid youtube url", error)
}
return null
}
type Props = {
media?: QuestionMediaType
@@ -90,84 +10,6 @@ type Props = {
}
const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
const youtubeContainerId = useMemo(
() => `yt-${Math.random().toString(36).slice(2, 10)}`,
[],
)
const youtubePlayerRef = useRef<any | null>(null)
const youtubeMounted = useRef(false)
const [youtubeReady, setYoutubeReady] = useState(false)
const [youtubePlaying, setYoutubePlaying] = useState(false)
useEffect(() => {
if (media?.type !== "youtube") {
return
}
youtubeMounted.current = true
const videoId = extractYoutubeId(media.url)
if (!videoId) {
return
}
loadYoutubeApi().then((YT) => {
if (!YT || !youtubeMounted.current) {
return
}
youtubePlayerRef.current = new YT.Player(youtubeContainerId, {
videoId,
playerVars: {
modestbranding: 1,
rel: 0,
iv_load_policy: 3,
playsinline: 1,
controls: 0,
disablekb: 1,
fs: 0,
enablejsapi: 1,
origin:
typeof window !== "undefined" ? window.location.origin : undefined,
showinfo: 0,
},
host: "https://www.youtube.com",
events: {
onReady: () => {
setYoutubeReady(true)
},
onStateChange: (event: YoutubeStateChangeEvent) => {
const { data } = event
const isPlaying =
data === YT.PlayerState.PLAYING ||
data === YT.PlayerState.BUFFERING
const isStopped =
data === YT.PlayerState.PAUSED ||
data === YT.PlayerState.ENDED ||
data === YT.PlayerState.UNSTARTED
if (isPlaying) {
setYoutubePlaying(true)
onPlayChange?.(true)
} else if (isStopped) {
setYoutubePlaying(false)
onPlayChange?.(false)
}
},
},
})
})
return () => {
youtubeMounted.current = false
youtubePlayerRef.current?.destroy()
youtubePlayerRef.current = null
setYoutubeReady(false)
setYoutubePlaying(false)
onPlayChange?.(false)
}
}, [media?.type, media?.url, onPlayChange, youtubeContainerId])
if (!media) {
return null
}
@@ -216,60 +58,9 @@ const QuestionMedia = ({ media, alt, onPlayChange }: Props) => {
</div>
)
case "youtube": {
return (
<div className={clsx(containerClass, "px-4")}>
<div
className="relative mt-4 w-full overflow-hidden rounded-md shadow-lg bg-black"
style={{ paddingTop: "56.25%" }}
>
<div
id={youtubeContainerId}
className={clsx(
"absolute left-0 top-0 h-full w-full",
youtubeReady ? "opacity-100" : "opacity-0",
)}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/5 via-black/10 to-black/20" />
<div className="absolute inset-0 flex items-center justify-center gap-3">
<button
className={clsx(
"pointer-events-auto rounded-full bg-white/80 px-4 py-2 text-sm font-semibold shadow",
(!youtubeReady) && "opacity-50",
)}
disabled={!youtubeReady}
onClick={() => {
const player = youtubePlayerRef.current
if (player && typeof player.playVideo === "function") {
player.playVideo()
}
}}
>
{youtubePlaying ? "Playing" : "Play"}
</button>
<button
className={clsx(
"pointer-events-auto rounded-full bg-white/80 px-4 py-2 text-sm font-semibold shadow",
(!youtubeReady) && "opacity-50",
)}
disabled={!youtubeReady}
onClick={() => {
const player = youtubePlayerRef.current
if (player && typeof player.pauseVideo === "function") {
player.pauseVideo()
}
}}
>
Pause
</button>
</div>
</div>
</div>
)
}
}
default:
return null
}
}
export default QuestionMedia

View File

@@ -5,7 +5,7 @@ import Button from "@rahoot/web/components/Button"
import Input from "@rahoot/web/components/Input"
import { useEvent, useSocket } from "@rahoot/web/contexts/socketProvider"
import clsx from "clsx"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import toast from "react-hot-toast"
type Props = {
@@ -16,6 +16,20 @@ type Props = {
type EditableQuestion = QuizzWithId["questions"][number]
type MediaLibraryItem = {
fileName: string
url: string
size: number
mime: string
type: QuestionMedia["type"]
usedBy: {
quizzId: string
subject: string
questionIndex: number
question: string
}[]
}
const blankQuestion = (): EditableQuestion => ({
question: "",
answers: ["", ""],
@@ -24,12 +38,22 @@ const blankQuestion = (): EditableQuestion => ({
time: 20,
})
const mediaTypes: QuestionMedia["type"][] = [
"image",
"audio",
"video",
"youtube",
]
const mediaTypes: QuestionMedia["type"][] = ["image", "audio", "video"]
const acceptByType: Record<QuestionMedia["type"], string> = {
image: "image/*",
audio: "audio/*",
video: "video/*",
}
const formatBytes = (bytes: number) => {
if (!bytes) return "0 B"
const units = ["B", "KB", "MB", "GB"]
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** i
return `${value.toFixed(value >= 10 || value % 1 === 0 ? 0 : 1)} ${units[i]}`
}
const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
const { socket } = useSocket()
@@ -37,6 +61,10 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
const [draft, setDraft] = useState<QuizzWithId | null>(null)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [mediaLibrary, setMediaLibrary] = useState<MediaLibraryItem[]>([])
const [uploading, setUploading] = useState<Record<number, boolean>>({})
const [deleting, setDeleting] = useState<Record<number, boolean>>({})
const [refreshingLibrary, setRefreshingLibrary] = useState(false)
useEvent("manager:quizzLoaded", (quizz) => {
setDraft(quizz)
@@ -48,6 +76,7 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
setDraft(quizz)
setSelectedId(quizz.id)
setSaving(false)
refreshMediaLibrary()
})
useEvent("manager:quizzList", (list) => {
@@ -60,6 +89,31 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
setLoading(false)
})
const refreshMediaLibrary = useCallback(async () => {
setRefreshingLibrary(true)
try {
const res = await fetch("/api/media", { cache: "no-store" })
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Failed to load media library")
}
setMediaLibrary(data.media || [])
} catch (error) {
console.error("Failed to fetch media library", error)
toast.error(
error instanceof Error ? error.message : "Failed to load media library",
)
} finally {
setRefreshingLibrary(false)
}
}, [])
useEffect(() => {
refreshMediaLibrary()
}, [refreshMediaLibrary])
const handleLoad = (id: string) => {
setSelectedId(id)
setLoading(true)
@@ -138,12 +192,151 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
setDraft({ ...draft, questions: nextQuestions })
}
const setQuestionMedia = (qIndex: number, media?: QuestionMedia) => {
if (!draft) return
updateQuestion(qIndex, {
media,
image: media?.type === "image" ? media.url : undefined,
})
}
const getMediaFileName = (media?: QuestionMedia | null) => {
if (!media) return null
if (media.fileName) return media.fileName
if (media.url?.startsWith("/media/")) {
return decodeURIComponent(media.url.split("/").pop() || "")
}
return null
}
const getLibraryEntry = (media?: QuestionMedia | null) => {
const fileName = getMediaFileName(media)
if (!fileName) return null
return mediaLibrary.find((item) => item.fileName === fileName) || null
}
const handleMediaType = (qIndex: number, type: QuestionMedia["type"] | "") => {
if (!draft) return
const question = draft.questions[qIndex]
if (type === "") {
setQuestionMedia(qIndex, undefined)
return
}
const nextMedia =
type === "" ? undefined : { type, url: question.media?.url || "" }
updateQuestion(qIndex, { media: nextMedia })
question.media?.type === type
? { ...question.media, type }
: { type, url: "" }
setQuestionMedia(qIndex, nextMedia)
}
const handleMediaUrlChange = (qIndex: number, url: string) => {
if (!draft) return
const question = draft.questions[qIndex]
if (!question.media?.type) {
toast.error("Select a media type before setting a URL")
return
}
if (!url) {
setQuestionMedia(qIndex, undefined)
return
}
const nextMedia: QuestionMedia = {
type: question.media.type,
url,
}
if (question.media.fileName && url.includes(question.media.fileName)) {
nextMedia.fileName = question.media.fileName
}
setQuestionMedia(qIndex, nextMedia)
}
const clearQuestionMedia = (qIndex: number) => {
setQuestionMedia(qIndex, undefined)
}
const handleMediaUpload = async (qIndex: number, file: File) => {
if (!draft) return
const question = draft.questions[qIndex]
if (!question.media?.type) {
toast.error("Select a media type before uploading")
return
}
setUploading((prev) => ({ ...prev, [qIndex]: true }))
try {
const formData = new FormData()
formData.append("file", file)
const res = await fetch("/api/media", {
method: "POST",
body: formData,
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Failed to upload media")
}
const uploaded = data.media as MediaLibraryItem
const type = uploaded.type
setQuestionMedia(qIndex, {
type,
url: uploaded.url,
fileName: uploaded.fileName,
})
toast.success("Media uploaded")
refreshMediaLibrary()
} catch (error) {
console.error("Upload failed", error)
toast.error(error instanceof Error ? error.message : "Upload failed")
} finally {
setUploading((prev) => ({ ...prev, [qIndex]: false }))
}
}
const handleDeleteMediaFile = async (qIndex: number) => {
if (!draft) return
const question = draft.questions[qIndex]
const fileName = getMediaFileName(question.media)
if (!fileName) {
toast.error("No stored file to delete")
return
}
setDeleting((prev) => ({ ...prev, [qIndex]: true }))
try {
const res = await fetch(`/api/media/${encodeURIComponent(fileName)}`, {
method: "DELETE",
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || "Failed to delete file")
}
toast.success("File deleted")
clearQuestionMedia(qIndex)
refreshMediaLibrary()
} catch (error) {
console.error("Failed to delete file", error)
toast.error(error instanceof Error ? error.message : "Failed to delete file")
} finally {
setDeleting((prev) => ({ ...prev, [qIndex]: false }))
}
}
const handleSave = () => {
@@ -225,7 +418,13 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
</label>
</div>
{draft.questions.map((question, qIndex) => (
{draft.questions.map((question, qIndex) => {
const libraryEntry = getLibraryEntry(question.media)
const mediaFileName = getMediaFileName(question.media)
const isUploading = uploading[qIndex]
const isDeleting = deleting[qIndex]
return (
<div
key={qIndex}
className="rounded-md border border-gray-200 p-4 shadow-sm"
@@ -306,29 +505,94 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
</select>
</label>
<div className="flex flex-col gap-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between text-sm font-semibold text-gray-600">
<span>Media upload</span>
<span className="text-xs text-gray-500">
{isUploading
? "Uploading..."
: refreshingLibrary
? "Refreshing..."
: mediaFileName
? "Stored"
: "Not saved"}
</span>
</div>
<input
type="file"
accept={
question.media?.type ? acceptByType[question.media.type] : undefined
}
disabled={!question.media?.type || isUploading}
className="rounded-sm border border-dashed border-gray-300 p-2 text-sm"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
handleMediaUpload(qIndex, file)
e.target.value = ""
}
}}
/>
<p className="text-xs text-gray-500">
Files are stored locally and served from /media. Pick a type first.
</p>
{question.media && (
<div className="rounded-md border border-gray-200 bg-gray-50 p-2">
<div className="flex items-center justify-between text-sm font-semibold text-gray-700">
<span>
{mediaFileName || question.media.url || "No file yet"}
</span>
{libraryEntry && (
<span className="text-xs text-gray-500">
{formatBytes(libraryEntry.size)}
</span>
)}
</div>
<div className="text-xs text-gray-500">
{libraryEntry
? `Used in ${libraryEntry.usedBy.length} question${
libraryEntry.usedBy.length === 1 ? "" : "s"
}`
: question.media.url
? "External media URL"
: "Upload a file or paste a URL"}
</div>
</div>
)}
<label className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-600">
Media URL
<span className="text-xs font-semibold text-gray-600">
Or paste an external URL
</span>
<Input
value={question.media?.url || question.image || ""}
onChange={(e) =>
updateQuestion(qIndex, {
media: question.media
? { ...question.media, url: e.target.value }
: undefined,
image:
!question.media || question.media.type === "image"
? e.target.value
: question.image,
})
}
onChange={(e) => handleMediaUrlChange(qIndex, e.target.value)}
placeholder="https://..."
disabled={!question.media?.type}
/>
<span className="text-xs text-gray-500">
Tip: set answer time longer than the clip duration.
</span>
</label>
<div className="flex flex-wrap gap-2">
<Button
className="bg-gray-700"
onClick={() => clearQuestionMedia(qIndex)}
disabled={!question.media}
>
Clear from question
</Button>
<Button
className="bg-red-500"
onClick={() => handleDeleteMediaFile(qIndex)}
disabled={!mediaFileName || isDeleting}
>
{isDeleting ? "Deleting..." : "Delete file"}
</Button>
</div>
</div>
</div>
<div className="mt-4 space-y-3">

View File

@@ -0,0 +1,241 @@
import type { QuestionMedia, QuizzWithId } from "@rahoot/common/types/game"
import Config from "@rahoot/socket/services/config"
import fs from "fs"
import { promises as fsp } from "fs"
import path from "path"
export type StoredMedia = {
fileName: string
url: string
size: number
mime: string
type: QuestionMedia["type"]
usedBy: {
quizzId: string
subject: string
questionIndex: number
question: string
}[]
}
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
const ensureMediaFolder = () => {
Config.ensureBaseFolders()
const folder = Config.getMediaPath()
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder, { recursive: true })
}
return folder
}
const inferMimeFromName = (fileName: string) => {
const ext = path.extname(fileName).toLowerCase()
if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"].includes(ext)) {
return `image/${ext.replace(".", "") || "jpeg"}`
}
if ([".mp3", ".wav", ".ogg", ".aac", ".m4a", ".flac"].includes(ext)) {
return `audio/${ext.replace(".", "") || "mpeg"}`
}
if ([".mp4", ".webm", ".mov", ".ogv", ".mkv"].includes(ext)) {
return `video/${ext.replace(".", "") || "mp4"}`
}
return "application/octet-stream"
}
const inferMediaType = (mime: string): QuestionMedia["type"] | null => {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
return null
}
const sanitizeFileName = (name: string) => {
const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "_")
return safeName || `media-${Date.now()}`
}
const resolveStoredFileName = (fileName: string) => {
const safeName = path.basename(fileName)
if (safeName !== fileName) {
throw new Error("Invalid file name")
}
return safeName
}
const usageIndex = (quizzList: QuizzWithId[]) => {
const usage = new Map<string, StoredMedia["usedBy"]>()
const recordUsage = (
fileName: string | null,
quizz: QuizzWithId,
questionIndex: number,
questionTitle: string,
) => {
if (!fileName) return
try {
const safeName = resolveStoredFileName(fileName)
const entries = usage.get(safeName) || []
entries.push({
quizzId: quizz.id,
subject: quizz.subject,
questionIndex,
question: questionTitle,
})
usage.set(safeName, entries)
} catch (error) {
console.warn("Skipped invalid media reference", { fileName, error })
}
}
quizzList.forEach((quizz) => {
quizz.questions.forEach((question, idx) => {
const mediaFile = (() => {
if (question.media?.fileName) return question.media.fileName
if (question.media?.url?.startsWith("/media/")) {
try {
return resolveStoredFileName(
decodeURIComponent(question.media.url.split("/").pop() || ""),
)
} catch (error) {
console.warn("Skipped invalid media url reference", {
url: question.media.url,
error,
})
return null
}
}
return null
})()
const imageFile = (() => {
if (!question.image?.startsWith("/media/")) return null
try {
return resolveStoredFileName(
decodeURIComponent(question.image.split("/").pop() || ""),
)
} catch (error) {
console.warn("Skipped invalid image url reference", {
url: question.image,
error,
})
return null
}
})()
recordUsage(mediaFile, quizz, idx, question.question)
recordUsage(imageFile, quizz, idx, question.question)
})
})
return usage
}
export const listStoredMedia = async (): Promise<StoredMedia[]> => {
const folder = ensureMediaFolder()
const files = await fsp.readdir(folder)
const quizz = Config.quizz()
const usage = usageIndex(quizz)
const entries = await Promise.all(
files.map(async (fileName) => {
const stats = await fsp.stat(path.join(folder, fileName))
const mime = inferMimeFromName(fileName)
const type = inferMediaType(mime) || "video"
return {
fileName,
url: `/media/${encodeURIComponent(fileName)}`,
size: stats.size,
mime,
type,
usedBy: usage.get(fileName) || [],
}
}),
)
// Keep a stable order for repeatable responses
return entries.sort((a, b) => a.fileName.localeCompare(b.fileName))
}
export const storeMediaFile = async (file: File): Promise<StoredMedia> => {
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
if (buffer.byteLength > MAX_UPLOAD_SIZE) {
throw new Error("File is too large. Max 50MB.")
}
const targetFolder = ensureMediaFolder()
const incomingMime = file.type || "application/octet-stream"
const mediaType = inferMediaType(incomingMime)
if (!mediaType) {
throw new Error("Unsupported media type")
}
const incomingName = file.name || `${mediaType}-upload`
const safeName = sanitizeFileName(incomingName)
const ext = path.extname(safeName) || `.${incomingMime.split("/")[1] || "bin"}`
const baseName = path.basename(safeName, ext)
let finalName = `${baseName}${ext}`
let finalPath = path.join(targetFolder, finalName)
let counter = 1
while (fs.existsSync(finalPath)) {
finalName = `${baseName}-${counter}${ext}`
finalPath = path.join(targetFolder, finalName)
counter += 1
}
await fsp.writeFile(finalPath, buffer)
const mime = incomingMime || inferMimeFromName(finalName)
return {
fileName: finalName,
url: `/media/${encodeURIComponent(finalName)}`,
size: buffer.byteLength,
mime,
type: mediaType,
usedBy: [],
}
}
export const deleteMediaFile = async (fileName: string) => {
const folder = ensureMediaFolder()
const safeName = resolveStoredFileName(fileName)
const filePath = path.join(folder, safeName)
if (!fs.existsSync(filePath)) {
throw new Error("File not found")
}
const usage = usageIndex(Config.quizz())
const usedBy = usage.get(safeName) || []
if (usedBy.length > 0) {
const details = usedBy
.map(
(entry) =>
`${entry.subject || entry.quizzId} (question ${entry.questionIndex + 1})`,
)
.join(", ")
throw new Error(`File is still used by: ${details}`)
}
await fsp.unlink(filePath)
}
export const mimeForStoredFile = (fileName: string) => inferMimeFromName(fileName)