Upload processed audio tracks to S3
This commit is contained in:
1 parent
0d09198e57
commit
492a54dd0d
6 files changed
+93
-163
No files matched your search
@@ -2,7 +2,7 @@ import { getQuery, setResponseStatus } from "h3"
|
|||||||
import { Track } from "../../models/track"
|
import { Track } from "../../models/track"
|
||||||
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
|
||||||
import { parseMongoId, validationResponse } from "../../utils/api-validation"
|
import { parseMongoId, validationResponse } from "../../utils/api-validation"
|
||||||
import { tryCleanUploadFileOrDirectory } from "../../utils/upload-files"
|
import { tryDeleteObjectPrefix } from "../../utils/object-storage"
|
||||||
|
|
||||||
export default defineAuthenticatedEventHandler(async (event) => {
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
await requireAuthenticatedUser(event, true)
|
await requireAuthenticatedUser(event, true)
|
||||||
@@ -14,6 +14,6 @@ export default defineAuthenticatedEventHandler(async (event) => {
|
|||||||
setResponseStatus(event, 404)
|
setResponseStatus(event, 404)
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
void tryCleanUploadFileOrDirectory(event, track.fileDir)
|
await tryDeleteObjectPrefix(event, `${track.fileDir}/`)
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import path from "node:path"
|
|
||||||
import mongoose from "mongoose"
|
import mongoose from "mongoose"
|
||||||
import { getQuery } from "h3"
|
import { getQuery } from "h3"
|
||||||
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, parseOptionalJson, splitIds } from "../../utils/admin-form"
|
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, parseOptionalJson, splitIds } from "../../utils/admin-form"
|
||||||
import { saveUploadFile, tryCleanUploadFileOrDirectory } from "../../utils/upload-files"
|
import { prepareAudioUpload } from "../../utils/audio-files"
|
||||||
import { getAudioDuration, processAudioFile } from "../../utils/audio-files"
|
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
|
||||||
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
|
||||||
import { parseMongoId, validationResponse } from "../../utils/api-validation"
|
import { parseMongoId, validationResponse } from "../../utils/api-validation"
|
||||||
import { Track } from "../../models/track"
|
import { Track } from "../../models/track"
|
||||||
@@ -28,29 +27,42 @@ export default defineAuthenticatedEventHandler(async (event) => {
|
|||||||
setResponseStatus(event, 400)
|
setResponseStatus(event, 400)
|
||||||
return { message: "One or more ID(s) are invalid" }
|
return { message: "One or more ID(s) are invalid" }
|
||||||
}
|
}
|
||||||
|
const parsedLyrics = lyrics ? parseOptionalJson(lyrics) : undefined
|
||||||
|
if (parsedLyrics && !parsedLyrics.ok) throw new Error("Invalid lyrics JSON")
|
||||||
|
|
||||||
const track = await Track.findById(parsedId.value)
|
const track = await Track.findById(parsedId.value)
|
||||||
if (!track) {
|
if (!track) {
|
||||||
setResponseStatus(event, 404)
|
setResponseStatus(event, 404)
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
let replacementDirectory: string | undefined
|
||||||
|
let previousDirectory: string | undefined
|
||||||
if (file) {
|
if (file) {
|
||||||
void tryCleanUploadFileOrDirectory(event, track.fileDir)
|
replacementDirectory = `tracks/${crypto.randomUUID()}`
|
||||||
const relativeFilePath = `tracks/${crypto.randomUUID()}/original.${safeFileExtension(file.name)}`
|
const extension = safeFileExtension(file.name)
|
||||||
await saveUploadFile(event, relativeFilePath, file.data)
|
const processed = await prepareAudioUpload(file.data, extension)
|
||||||
const duration = await getAudioDuration(event, relativeFilePath)
|
await putObjects(event, [
|
||||||
void processAudioFile(event, relativeFilePath).catch((error) => console.error(error))
|
{ key: `${replacementDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
|
||||||
track.fileDir = path.dirname(relativeFilePath)
|
...processed.renditions.map((rendition) => ({
|
||||||
track.durationInSeconds = Math.round(duration)
|
key: `${replacementDirectory}/${rendition.name}`,
|
||||||
|
body: rendition.body,
|
||||||
|
contentType: rendition.contentType,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
previousDirectory = track.fileDir
|
||||||
|
track.fileDir = replacementDirectory
|
||||||
|
track.durationInSeconds = Math.round(processed.duration)
|
||||||
}
|
}
|
||||||
if (name) track.name = name
|
if (name) track.name = name
|
||||||
if (album) track.album = new mongoose.Types.ObjectId(album)
|
if (album) track.album = new mongoose.Types.ObjectId(album)
|
||||||
if (artists) track.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
|
if (artists) track.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
|
||||||
if (lyrics) {
|
if (parsedLyrics?.ok && parsedLyrics.value !== undefined) track.lyrics = parsedLyrics.value as any
|
||||||
const parsedLyrics = parseOptionalJson(lyrics)
|
try {
|
||||||
if (!parsedLyrics.ok) throw new Error("Invalid lyrics JSON")
|
|
||||||
track.lyrics = parsedLyrics.value as any
|
|
||||||
}
|
|
||||||
await track.save()
|
await track.save()
|
||||||
|
} catch (error) {
|
||||||
|
if (replacementDirectory) await tryDeleteObjectPrefix(event, `${replacementDirectory}/`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (previousDirectory) await tryDeleteObjectPrefix(event, `${previousDirectory}/`)
|
||||||
return track
|
return track
|
||||||
})
|
})
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import path from "node:path"
|
|
||||||
import mongoose from "mongoose"
|
import mongoose from "mongoose"
|
||||||
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, parseOptionalJson, splitIds } from "../../utils/admin-form"
|
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, parseOptionalJson, splitIds } from "../../utils/admin-form"
|
||||||
import { saveUploadFile, tryCleanUploadFileOrDirectory } from "../../utils/upload-files"
|
import { prepareAudioUpload } from "../../utils/audio-files"
|
||||||
import { getAudioDuration, processAudioFile } from "../../utils/audio-files"
|
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
|
||||||
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
|
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
|
||||||
import { Track } from "../../models/track"
|
import { Track } from "../../models/track"
|
||||||
|
|
||||||
@@ -37,27 +36,36 @@ export default defineAuthenticatedEventHandler(async (event) => {
|
|||||||
return { message: "Only audio files are accepted" }
|
return { message: "Only audio files are accepted" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativeFilePath = `tracks/${crypto.randomUUID()}/original`
|
const fileDirectory = `tracks/${crypto.randomUUID()}`
|
||||||
const fileDirectory = path.dirname(relativeFilePath)
|
|
||||||
try {
|
try {
|
||||||
console.log("Processing audio")
|
const extension = safeFileExtension(file.name)
|
||||||
await saveUploadFile(event, relativeFilePath, file.data)
|
const processed = await prepareAudioUpload(file.data, extension)
|
||||||
const duration = await getAudioDuration(event, relativeFilePath)
|
await putObjects(event, [
|
||||||
void processAudioFile(event, relativeFilePath).catch((error) => console.error(error))
|
{ key: `${fileDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
|
||||||
|
...processed.renditions.map((rendition) => ({
|
||||||
|
key: `${fileDirectory}/${rendition.name}`,
|
||||||
|
body: rendition.body,
|
||||||
|
contentType: rendition.contentType,
|
||||||
|
})),
|
||||||
|
])
|
||||||
const track = new Track({
|
const track = new Track({
|
||||||
name,
|
name,
|
||||||
album: new mongoose.Types.ObjectId(album),
|
album: new mongoose.Types.ObjectId(album),
|
||||||
artists: artists.map((id) => new mongoose.Types.ObjectId(id)),
|
artists: artists.map((id) => new mongoose.Types.ObjectId(id)),
|
||||||
fileDir: fileDirectory,
|
fileDir: fileDirectory,
|
||||||
durationInSeconds: Math.round(duration),
|
durationInSeconds: Math.round(processed.duration),
|
||||||
lyrics: parsedLyrics.value || undefined,
|
lyrics: parsedLyrics.value || undefined,
|
||||||
})
|
})
|
||||||
|
try {
|
||||||
await track.save()
|
await track.save()
|
||||||
|
} catch (error) {
|
||||||
|
await tryDeleteObjectPrefix(event, `${fileDirectory}/`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
setResponseStatus(event, 201)
|
setResponseStatus(event, 201)
|
||||||
return track
|
return track
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
void tryCleanUploadFileOrDirectory(event, fileDirectory)
|
console.error(error)
|
||||||
console.log(error)
|
|
||||||
setResponseStatus(event, 500)
|
setResponseStatus(event, 500)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export function splitIds(value: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function safeFileExtension(fileName: string) {
|
export function safeFileExtension(fileName: string) {
|
||||||
return fileName.split(".")[1]?.replace(/[^A-Za-z0-9]/g, "") ?? ""
|
if (!fileName.includes(".")) return ""
|
||||||
|
return fileName.split(".").pop()?.replace(/[^A-Za-z0-9]/g, "") ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOptionalJson(value: string | undefined) {
|
export function parseOptionalJson(value: string | undefined) {
|
||||||
|
|||||||
+39
-29
@@ -1,37 +1,28 @@
|
|||||||
import { unlink } from "node:fs/promises"
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import Ffmpeg from "fluent-ffmpeg"
|
import Ffmpeg from "fluent-ffmpeg"
|
||||||
import type { H3Event } from "h3"
|
|
||||||
import { resolveUploadFile } from "./upload-files"
|
|
||||||
|
|
||||||
export async function getAudioDuration(event: H3Event, relativePath: string): Promise<number> {
|
const jobs: Array<[string, string, string, string, string]> = [
|
||||||
if (!relativePath) throw new Error("filePath is undefined")
|
["128k", "mp3", "libmp3lame", "low.mp3", "audio/mpeg"],
|
||||||
const { path: sourcePath } = await resolveUploadFile(event, relativePath)
|
["256k", "mp3", "libmp3lame", "mid.mp3", "audio/mpeg"],
|
||||||
|
["96k", "mp4", "aac", "low.m4a", "audio/mp4"],
|
||||||
|
["192k", "mp4", "aac", "mid.m4a", "audio/mp4"],
|
||||||
|
["320k", "mp4", "aac", "high.m4a", "audio/mp4"],
|
||||||
|
]
|
||||||
|
|
||||||
|
function getDuration(sourcePath: string): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
Ffmpeg.ffprobe(sourcePath, (error, metadata) => {
|
Ffmpeg.ffprobe(sourcePath, (error, metadata) => {
|
||||||
if (error) return reject(0)
|
if (error) return reject(error)
|
||||||
if (metadata.format.duration) return resolve(metadata.format.duration)
|
if (metadata.format.duration) return resolve(metadata.format.duration)
|
||||||
reject(0)
|
reject(new Error("Unable to read audio duration"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processAudioFile(event: H3Event, relativePath: string) {
|
function transcode(sourcePath: string, outputPath: string, bitrate: string, format: string, codec: string) {
|
||||||
if (!relativePath) throw new Error("filePath is undefined")
|
return new Promise<void>((resolve, reject) => {
|
||||||
const { path: sourcePath } = await resolveUploadFile(event, relativePath)
|
|
||||||
const directory = path.dirname(sourcePath)
|
|
||||||
const jobs: Array<[string, string, string]> = [
|
|
||||||
["128k", "mp3", "libmp3lame"],
|
|
||||||
["256k", "mp3", "libmp3lame"],
|
|
||||||
["96k", "mp4", "aac"],
|
|
||||||
["192k", "mp4", "aac"],
|
|
||||||
["320k", "mp4", "aac"],
|
|
||||||
]
|
|
||||||
const names = ["low.mp3", "mid.mp3", "low.m4a", "mid.m4a", "high.m4a"]
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
jobs.map(([bitrate, format, codec], index) =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
Ffmpeg(sourcePath)
|
Ffmpeg(sourcePath)
|
||||||
.noVideo()
|
.noVideo()
|
||||||
.audioBitrate(bitrate)
|
.audioBitrate(bitrate)
|
||||||
@@ -39,9 +30,28 @@ export async function processAudioFile(event: H3Event, relativePath: string) {
|
|||||||
.audioCodec(codec)
|
.audioCodec(codec)
|
||||||
.on("end", () => resolve())
|
.on("end", () => resolve())
|
||||||
.on("error", reject)
|
.on("error", reject)
|
||||||
.save(path.join(directory, names[index]!))
|
.save(outputPath)
|
||||||
}),
|
})
|
||||||
),
|
}
|
||||||
)
|
|
||||||
await unlink(sourcePath)
|
export async function prepareAudioUpload(data: Buffer, extension: string) {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "music-v2-audio-"))
|
||||||
|
const sourcePath = path.join(directory, `original${extension ? `.${extension}` : ""}`)
|
||||||
|
try {
|
||||||
|
await writeFile(sourcePath, data)
|
||||||
|
const duration = await getDuration(sourcePath)
|
||||||
|
const completed = await Promise.allSettled(jobs.map(([bitrate, format, codec, name]) =>
|
||||||
|
transcode(sourcePath, path.join(directory, name), bitrate, format, codec),
|
||||||
|
))
|
||||||
|
const failedJob = completed.find((result) => result.status === "rejected")
|
||||||
|
if (failedJob?.status === "rejected") throw failedJob.reason
|
||||||
|
const renditions = await Promise.all(jobs.map(async ([, , , name, contentType]) => ({
|
||||||
|
name,
|
||||||
|
contentType,
|
||||||
|
body: await readFile(path.join(directory, name)),
|
||||||
|
})))
|
||||||
|
return { duration, original: data, renditions }
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,108 +1,7 @@
|
|||||||
import { mkdir, realpath, rm, writeFile } from "node:fs/promises"
|
import type { MultiPartData } from "h3"
|
||||||
import path from "node:path"
|
|
||||||
import type { H3Event, MultiPartData } from "h3"
|
|
||||||
import { getBackendRuntimeConfig } from "./backend-config"
|
|
||||||
|
|
||||||
export class InvalidUploadPathError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("filePath is invalid")
|
|
||||||
this.name = "InvalidUploadPathError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UploadFileNotFoundError extends Error {
|
|
||||||
code = "ENOENT"
|
|
||||||
constructor() {
|
|
||||||
super("File or directory doesn't exist")
|
|
||||||
this.name = "UploadFileNotFoundError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rootDirectory(event: H3Event) {
|
|
||||||
const configured = getBackendRuntimeConfig(event).uploadDir
|
|
||||||
if (!configured) throw new Error("Environment variable UPLOAD_DIR not found")
|
|
||||||
return path.resolve(configured)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithin(root: string, target: string) {
|
|
||||||
const relative = path.relative(root, target)
|
|
||||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLexically(root: string, relativePath: string) {
|
|
||||||
if (!relativePath || path.isAbsolute(relativePath)) throw new InvalidUploadPathError()
|
|
||||||
const target = path.resolve(root, relativePath)
|
|
||||||
if (!isWithin(root, target)) throw new InvalidUploadPathError()
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveUploadFile(event: H3Event, relativePath: string) {
|
|
||||||
const root = rootDirectory(event)
|
|
||||||
const target = resolveLexically(root, relativePath)
|
|
||||||
// Resolve the configured root separately: a missing/unreadable upload root is
|
|
||||||
// an operational failure, while a missing requested file is a normal 404.
|
|
||||||
const realRoot = await realpath(root)
|
|
||||||
let realTarget: string
|
|
||||||
try {
|
|
||||||
realTarget = await realpath(target)
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new UploadFileNotFoundError()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
if (!isWithin(realRoot, realTarget)) throw new InvalidUploadPathError()
|
|
||||||
return { root: realRoot, path: realTarget }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveUploadFile(event: H3Event, relativePath: string, data: Buffer) {
|
|
||||||
const root = rootDirectory(event)
|
|
||||||
const target = resolveLexically(root, relativePath)
|
|
||||||
await mkdir(path.dirname(target), { recursive: true })
|
|
||||||
const realRoot = await realpath(root)
|
|
||||||
const realParent = await realpath(path.dirname(target))
|
|
||||||
if (!isWithin(realRoot, realParent)) throw new InvalidUploadPathError()
|
|
||||||
try {
|
|
||||||
const existing = await realpath(target)
|
|
||||||
if (!isWithin(realRoot, existing)) throw new InvalidUploadPathError()
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
||||||
}
|
|
||||||
await writeFile(target, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cleanUploadFileOrDirectory(event: H3Event, relativePath: string) {
|
|
||||||
const root = rootDirectory(event)
|
|
||||||
const target = resolveLexically(root, relativePath)
|
|
||||||
let realTarget: string
|
|
||||||
try {
|
|
||||||
const [realRoot, resolved] = await Promise.all([realpath(root), realpath(target)])
|
|
||||||
if (!isWithin(realRoot, resolved)) throw new InvalidUploadPathError()
|
|
||||||
realTarget = resolved
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
||||||
console.error("File or directory doesn't exist")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await rm(realTarget, { recursive: true })
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
||||||
console.error("File or directory doesn't exist")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tryCleanUploadFileOrDirectory(event: H3Event, relativePath: string) {
|
|
||||||
try {
|
|
||||||
await cleanUploadFileOrDirectory(event, relativePath)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Unable to clean uploaded file or directory", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Keep the established utility module path for Nitro's generated auto-imports.
|
||||||
|
// File persistence is handled by object-storage.ts.
|
||||||
export function getFormField(parts: MultiPartData[] | undefined, name: string) {
|
export function getFormField(parts: MultiPartData[] | undefined, name: string) {
|
||||||
return parts?.find((part) => part.name === name)
|
return parts?.find((part) => part.name === name)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user