Files
RepoDructor/server/api/music/[filename].get.ts
josedario87 81330de97e
All checks were successful
build-and-deploy / build (push) Successful in 40s
build-and-deploy / deploy (push) Successful in 4s
feat(pwa-offline): Pinia store + IndexedDB; contexto para cache/eliminación; toasts; compatibilidad PWA offline
- Agrega @pinia/nuxt, idb y store central (stores/music.ts)
- Cacheo manual desde menú contextual y borrado (TrackContextMenu)
- Ícono verde para canciones cacheadas, sin auto-cache al reproducir
- Toasts de feedback (stores/toast.ts, ToastContainer)
- Fallback offline de listado a IndexedDB; fix MUSIC_DIR absoluto en preview/prod
- Ajustes PWA: navigateFallback '/', devOptions, workbox condicional
- Estilos y animación del context menu (tema light/dark, blur fuerte)
- Correcciones de sintaxis y posicionamiento exacto al cursor
2025-08-10 02:51:38 -06:00

120 lines
3.6 KiB
TypeScript

import { promises as fs } from 'fs'
import { join, resolve, sep } from 'path'
import { createReadStream } from 'fs'
export default defineEventHandler(async (event) => {
let filename = getRouterParam(event, 'filename')
if (!filename) {
throw createError({
statusCode: 400,
statusMessage: 'Filename is required'
})
}
// Log incoming request
const headers = getHeaders(event)
const realIP = headers['x-real-ip'] || headers['x-forwarded-for'] || 'unknown'
console.log(`[MUSIC API] Request from ${realIP} for file: ${filename}`)
// Decode the filename
try {
filename = decodeURIComponent(filename)
} catch (error) {
console.error('Error decoding filename:', error)
// If decoding fails, use original filename
}
try {
const config = useRuntimeConfig()
// Determine the music directory path
let musicDir: string
if (config.musicDirAbs || process.env.MUSIC_DIR) {
// Prefer absolute dir from runtimeConfig; fallback to env (resolved relative to repo root at build time)
musicDir = (config.musicDirAbs as string) || resolve(process.cwd(), process.env.MUSIC_DIR!)
} else {
// Fallback to public/music
const defaultPublicPath = config.public?.musicPath || '/music'
const publicRel = defaultPublicPath.replace(/^\//, '')
musicDir = resolve(process.cwd(), 'public', publicRel)
}
// Resolve the full file path
const filePath = resolve(musicDir, filename)
// Security check: ensure the file is within the music directory
// Normalize both paths and add separator to ensure exact directory match
const normalizedMusicDir = musicDir.endsWith(sep) ? musicDir : musicDir + sep
if (!filePath.startsWith(normalizedMusicDir)) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied'
})
}
// Check if file exists
try {
await fs.access(filePath)
} catch (error) {
throw createError({
statusCode: 404,
statusMessage: 'File not found'
})
}
// Get file stats
const stats = await fs.stat(filePath)
// Set appropriate headers
const headers = getHeaders(event)
// Handle range requests for audio streaming
const range = headers.range
if (range) {
const parts = range.replace(/bytes=/, "").split("-")
const start = parseInt(parts[0], 10)
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1
const chunksize = (end - start) + 1
setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`)
setHeader(event, 'Accept-Ranges', 'bytes')
setHeader(event, 'Content-Length', chunksize.toString())
setResponseStatus(event, 206)
return sendStream(event, createReadStream(filePath, { start, end }))
} else {
// Send entire file
setHeader(event, 'Content-Length', stats.size.toString())
setHeader(event, 'Accept-Ranges', 'bytes')
// Set content type based on file extension
const ext = filename.toLowerCase().split('.').pop()
const contentTypes = {
'mp3': 'audio/mpeg',
'wav': 'audio/wav',
'flac': 'audio/flac',
'm4a': 'audio/mp4',
'ogg': 'audio/ogg',
'aac': 'audio/aac'
}
setHeader(event, 'Content-Type', contentTypes[ext] || 'audio/mpeg')
return sendStream(event, createReadStream(filePath))
}
} catch (error) {
if (error.statusCode) {
throw error
}
console.error('Error serving music file:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to serve music file'
})
}
})