You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/src/lib/server/medios.ts

229 lines
6.7 KiB

/**
* Entrega de los archivos de audio completos.
*
* Los masters no estan en `static/`: viven en un directorio privado que el
* servidor web no publica. La unica forma de obtenerlos es esta, y siempre
* despues de comprobar que quien pide el archivo lo ha comprado.
*
* Configuracion:
* MEDIA_DIR directorio raiz del audio privado (por defecto ./media/audio)
*
* Si algun dia el catalogo se mueve a almacenamiento de objetos (S3, R2), lo
* que cambia es este archivo: en vez de leer del disco, se firmaria una URL
* temporal. El resto de la aplicacion no se entera.
*/
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname, join, resolve, sep } from 'node:path';
import { Readable } from 'node:stream';
import { env } from '$env/dynamic/private';
const TIPOS: Record<string, string> = {
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg',
'.wav': 'audio/wav',
'.flac': 'audio/flac'
};
/** Raiz del almacen privado, como ruta absoluta. */
export function directorioDeMedios(): string {
return resolve(env.MEDIA_DIR ?? join('media', 'audio'));
}
/** Las copias de escucha también son privadas, aunque sean de menor calidad. */
export function directorioDeEscuchas(): string {
return resolve(env.ESCUCHA_DIR ?? join('media', 'escucha'));
}
/** Derivados visuales públicos. El panel y el sitio deben compartir esta raíz. */
export function directorioDeOndas(): string {
return resolve(env.ONDA_DIR ?? join('static', 'ondas'));
}
/**
* Resuelve una clave de contenido a una ruta absoluta dentro del almacen.
*
* Devuelve `null` si la clave se sale del directorio. La validacion del
* frontmatter ya descarta las rutas raras, pero esta comprobacion no depende
* de ella: es la ultima linea antes de abrir un archivo del disco.
*/
export function rutaDeAudio(clave: string): string | null {
const base = directorioDeMedios();
const destino = resolve(base, clave);
return destino === base || destino.startsWith(base + sep) ? destino : null;
}
export function rutaDeEscucha(clave: string): string | null {
const base = directorioDeEscuchas();
const destino = resolve(base, clave);
return destino === base || destino.startsWith(base + sep) ? destino : null;
}
export function rutaDeOnda(clave: string): string | null {
const base = directorioDeOndas();
const destino = resolve(base, clave);
return destino === base || destino.startsWith(base + sep) ? destino : null;
}
/**
* Sirve las ondas creadas después del último build.
*
* Las que ya estaban al compilar siguen saliendo como estáticos. Esta vía
* evita que una canción subida desde el panel necesite reconstruir la web para
* que aparezca su forma de onda.
*/
export async function respuestaDeOnda(clave: string): Promise<Response | null> {
const ruta = rutaDeOnda(clave);
if (!ruta || extname(ruta).toLowerCase() !== '.json') return null;
let tamano: number;
try {
const info = await stat(ruta);
if (!info.isFile()) return null;
tamano = info.size;
} catch {
return null;
}
return new Response(Readable.toWeb(createReadStream(ruta)) as ReadableStream<Uint8Array>, {
headers: {
'content-type': 'application/json; charset=utf-8',
'content-length': String(tamano),
'cache-control': 'public, max-age=86400'
}
});
}
/**
* Transmite una escucha con soporte de `Range`, imprescindible para buscar en
* la canción sin descargarla entera y para que el navegador mida su duración.
*/
export async function respuestaDeEscucha(
clave: string,
rango: string | null
): Promise<Response | null> {
const ruta = rutaDeEscucha(clave);
if (!ruta) return null;
let tamano: number;
try {
const info = await stat(ruta);
if (!info.isFile()) return null;
tamano = info.size;
} catch {
return null;
}
const cabeceras: Record<string, string> = {
'accept-ranges': 'bytes',
'content-type': TIPOS[extname(ruta).toLowerCase()] ?? 'application/octet-stream',
'cache-control': 'private, no-store'
};
if (!rango) {
cabeceras['content-length'] = String(tamano);
return new Response(Readable.toWeb(createReadStream(ruta)) as ReadableStream<Uint8Array>, {
headers: cabeceras
});
}
const pedido = /^bytes=(\d*)-(\d*)$/.exec(rango.trim());
if (!pedido || (!pedido[1] && !pedido[2])) {
return new Response(null, {
status: 416,
headers: { ...cabeceras, 'content-range': `bytes */${tamano}` }
});
}
let inicio: number;
let fin: number;
if (!pedido[1]) {
const ultimos = Number(pedido[2]);
if (!Number.isSafeInteger(ultimos) || ultimos <= 0) inicio = tamano;
else inicio = Math.max(0, tamano - ultimos);
fin = tamano - 1;
} else {
inicio = Number(pedido[1]);
fin = pedido[2] ? Number(pedido[2]) : tamano - 1;
}
if (
!Number.isSafeInteger(inicio) ||
!Number.isSafeInteger(fin) ||
inicio < 0 ||
inicio >= tamano ||
fin < inicio
) {
return new Response(null, {
status: 416,
headers: { ...cabeceras, 'content-range': `bytes */${tamano}` }
});
}
fin = Math.min(fin, tamano - 1);
cabeceras['content-length'] = String(fin - inicio + 1);
cabeceras['content-range'] = `bytes ${inicio}-${fin}/${tamano}`;
return new Response(
Readable.toWeb(
createReadStream(ruta, { start: inicio, end: fin })
) as ReadableStream<Uint8Array>,
{ status: 206, headers: cabeceras }
);
}
/** `true` si el archivo existe en el almacen privado. */
/**
* Respuesta de descarga del archivo completo.
*
* Se transmite en flujo, sin cargarlo entero en memoria, y con
* `Content-Disposition: attachment` para que el navegador lo guarde en vez de
* reproducirlo. `private, no-store` evita que quede cacheado en un proxy
* compartido: es contenido de pago.
*/
export async function respuestaDeDescarga(
clave: string,
nombreDeArchivo: string
): Promise<Response | null> {
const ruta = rutaDeAudio(clave);
if (!ruta) return null;
let tamano: number;
try {
const info = await stat(ruta);
if (!info.isFile()) return null;
tamano = info.size;
} catch {
return null;
}
const flujo = Readable.toWeb(createReadStream(ruta)) as ReadableStream<Uint8Array>;
return new Response(flujo, {
headers: {
'content-type': TIPOS[extname(ruta).toLowerCase()] ?? 'application/octet-stream',
'content-length': String(tamano),
'content-disposition': `attachment; filename="${nombreSeguro(nombreDeArchivo)}"; filename*=UTF-8''${encodeURIComponent(nombreDeArchivo)}`,
'cache-control': 'private, no-store'
}
});
}
/**
* Version del nombre apta para el parametro `filename` clasico: solo ASCII y
* sin comillas, que romperian la cabecera. Los clientes modernos usan el
* `filename*`, que sí lleva el nombre completo.
*/
function nombreSeguro(nombre: string): string {
return (
nombre
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^\w.\- ]+/g, '_')
.trim() || 'audio'
);
}

Powered by TurnKey Linux.