Initial commit: Video player with HLS support
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
54
server/api/config.post.ts
Normal file
54
server/api/config.post.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const CONFIG_FILE = join(process.cwd(), '.autostart-config')
|
||||
const CORRECT_PASSWORD = '431522'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
|
||||
// Verificar contraseña
|
||||
if (body.password !== CORRECT_PASSWORD) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Contraseña incorrecta'
|
||||
})
|
||||
}
|
||||
|
||||
// Actualizar configuración
|
||||
if (body.action === 'toggle-autostart') {
|
||||
const currentConfig = await fs.readFile(CONFIG_FILE, 'utf-8').catch(() => 'enabled')
|
||||
const newConfig = currentConfig.trim() === 'enabled' ? 'disabled' : 'enabled'
|
||||
await fs.writeFile(CONFIG_FILE, newConfig, 'utf-8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
autostart: newConfig === 'enabled'
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener estado actual
|
||||
if (body.action === 'get-status') {
|
||||
const currentConfig = await fs.readFile(CONFIG_FILE, 'utf-8').catch(() => 'enabled')
|
||||
return {
|
||||
success: true,
|
||||
autostart: currentConfig.trim() === 'enabled'
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Acción inválida'
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error.statusCode) {
|
||||
throw error
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Error al actualizar configuración'
|
||||
})
|
||||
}
|
||||
})
|
||||
140
server/api/videos.get.ts
Normal file
140
server/api/videos.get.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { stat } from 'fs/promises'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const videosPath = join(process.cwd(), 'videos')
|
||||
|
||||
// Verificar si existe la carpeta
|
||||
try {
|
||||
await fs.access(videosPath)
|
||||
} catch {
|
||||
return { videos: [] }
|
||||
}
|
||||
|
||||
const files = await fs.readdir(videosPath)
|
||||
|
||||
// Detectar videos originales (archivos de video sin sufijos de calidad)
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
|
||||
const videoFiles = files.filter(file => {
|
||||
const ext = file.toLowerCase().substring(file.lastIndexOf('.'))
|
||||
const nameWithoutExt = file.substring(0, file.lastIndexOf('.'))
|
||||
const hasQualitySuffix = /_(4k|2k|1080p|720p|480p|360p)$/i.test(nameWithoutExt)
|
||||
return videoExtensions.includes(ext) && !hasQualitySuffix
|
||||
})
|
||||
|
||||
const videoMap = new Map<string, any>()
|
||||
|
||||
// Procesar cada video original
|
||||
for (const file of videoFiles) {
|
||||
const ext = file.substring(file.lastIndexOf('.'))
|
||||
const baseName = file.substring(0, file.lastIndexOf('.'))
|
||||
|
||||
// Verificar si existe carpeta HLS
|
||||
const hlsDir = join(videosPath, `${baseName}_hls`)
|
||||
let isHLS = false
|
||||
let hlsQualities: any[] = []
|
||||
|
||||
try {
|
||||
const hlsStat = await stat(hlsDir)
|
||||
if (hlsStat.isDirectory()) {
|
||||
// Buscar archivos .m3u8 para detectar calidades disponibles
|
||||
const hlsFiles = await fs.readdir(hlsDir)
|
||||
const qualityFiles = hlsFiles.filter(f =>
|
||||
f.endsWith('.m3u8') &&
|
||||
f !== 'master.m3u8' &&
|
||||
!f.includes('_vtt') // Excluir archivos de subtítulos
|
||||
)
|
||||
|
||||
qualityFiles.forEach(qFile => {
|
||||
const quality = qFile.replace('.m3u8', '')
|
||||
hlsQualities.push({
|
||||
quality,
|
||||
label: quality.toUpperCase(),
|
||||
url: `/videos/${baseName}_hls/${qFile}`,
|
||||
file: qFile,
|
||||
isHLS: true
|
||||
})
|
||||
})
|
||||
|
||||
if (hlsQualities.length > 0) {
|
||||
isHLS = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No existe HLS para este video
|
||||
}
|
||||
|
||||
if (isHLS) {
|
||||
// Video con HLS
|
||||
videoMap.set(baseName, {
|
||||
id: baseName,
|
||||
name: baseName,
|
||||
extension: ext.toLowerCase(),
|
||||
isHLS: true,
|
||||
qualities: hlsQualities,
|
||||
url: `/videos/${baseName}_hls/master.m3u8`
|
||||
})
|
||||
} else {
|
||||
// Video sin HLS, buscar versiones de calidad tradicionales
|
||||
const qualities = [
|
||||
{
|
||||
quality: 'auto',
|
||||
label: 'Original',
|
||||
url: `/videos/${file}`,
|
||||
file,
|
||||
isHLS: false
|
||||
}
|
||||
]
|
||||
|
||||
// Buscar versiones con sufijos de calidad
|
||||
for (const qualitySuffix of ['4k', '2k', '1080p', '720p', '480p', '360p']) {
|
||||
const qualityFile = `${baseName}_${qualitySuffix}${ext}`
|
||||
if (files.includes(qualityFile)) {
|
||||
qualities.push({
|
||||
quality: qualitySuffix,
|
||||
label: qualitySuffix.toUpperCase(),
|
||||
url: `/videos/${qualityFile}`,
|
||||
file: qualityFile,
|
||||
isHLS: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
videoMap.set(baseName, {
|
||||
id: baseName,
|
||||
name: baseName,
|
||||
extension: ext.toLowerCase(),
|
||||
isHLS: false,
|
||||
qualities,
|
||||
url: `/videos/${file}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir a array y ordenar calidades
|
||||
const videos = Array.from(videoMap.values()).map(video => {
|
||||
// Ordenar calidades de mayor a menor
|
||||
const qualityOrder = { '4k': 0, '2k': 1, '1080p': 2, '720p': 3, '480p': 4, '360p': 5, 'auto': 6 }
|
||||
video.qualities.sort((a: any, b: any) => {
|
||||
return (qualityOrder[a.quality as keyof typeof qualityOrder] || 999) -
|
||||
(qualityOrder[b.quality as keyof typeof qualityOrder] || 999)
|
||||
})
|
||||
|
||||
// Establecer la URL por defecto (la mejor calidad disponible)
|
||||
if (video.isHLS) {
|
||||
video.url = `/videos/${video.name}_hls/master.m3u8`
|
||||
} else {
|
||||
video.url = video.qualities[0].url
|
||||
}
|
||||
|
||||
return video
|
||||
})
|
||||
|
||||
return { videos }
|
||||
} catch (error) {
|
||||
console.error('Error reading videos:', error)
|
||||
return { videos: [], error: 'Failed to load videos' }
|
||||
}
|
||||
})
|
||||
94
server/middleware/videos.ts
Normal file
94
server/middleware/videos.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createReadStream, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const url = getRequestURL(event)
|
||||
|
||||
// Solo manejar rutas que empiecen con /videos/
|
||||
if (!url.pathname.startsWith('/videos/')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extraer el path del archivo (puede incluir subcarpetas para HLS)
|
||||
const file = url.pathname.substring('/videos/'.length)
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validar que el path solo contenga caracteres seguros (permite subcarpetas con /)
|
||||
if (!/^[a-zA-Z0-9_\-\.\/]+$/.test(file)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid file path'
|
||||
})
|
||||
}
|
||||
|
||||
const filePath = join(process.cwd(), 'videos', file)
|
||||
|
||||
try {
|
||||
// Verificar que el archivo existe
|
||||
const stats = statSync(filePath)
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'File not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Determinar el tipo MIME basado en la extensión
|
||||
const ext = file.toLowerCase().substring(file.lastIndexOf('.'))
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.ogg': 'video/ogg',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.m3u8': 'application/vnd.apple.mpegurl',
|
||||
'.ts': 'video/mp2t'
|
||||
}
|
||||
const mimeType = mimeTypes[ext] || 'application/octet-stream'
|
||||
|
||||
// Configurar headers para streaming de video
|
||||
setResponseHeaders(event, {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': String(stats.size),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'public, max-age=604800' // 7 días
|
||||
})
|
||||
|
||||
// Manejar range requests para seeking en el video
|
||||
const range = getRequestHeader(event, '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
|
||||
|
||||
setResponseStatus(event, 206) // Partial Content
|
||||
setResponseHeaders(event, {
|
||||
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
|
||||
'Content-Length': String(chunkSize)
|
||||
})
|
||||
|
||||
return createReadStream(filePath, { start, end })
|
||||
}
|
||||
|
||||
// Retornar el archivo completo si no hay range request
|
||||
return createReadStream(filePath)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'File not found'
|
||||
})
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Error reading file'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user