Playlist TV: subir en cola con resumen — «subí 5 y solo salieron 2»

El reproductor no se saltaba nada: en la base solo había 2 videos. El defecto
estaba en la subida: los archivos elegidos se mandaban TODOS EN PARALELO
(inp.files.forEach), peleándose el ancho de banda de subida. Los primeros
alcanzaban a entrar y el resto moría por timeout — avisando con un toast que
desaparece en segundos y con una barra de progreso que además era la misma
para todos, pisándose entre sí. El cliente veía «subí 5», la playlist tenía 2.

Ahora los archivos van de a uno (secuencial a propósito), un fallo no frena a
los que siguen, y al terminar queda un RESUMEN FIJO: «3 de 5 agregados», con
el nombre de cada rechazado y su motivo en cristiano. Los videos de iPhone
(.mov) —causa probable del resto de los faltantes— se rechazan de inmediato
con la instrucción de convertirlos a MP4, en el navegador y en el servidor.
Y los límites de PHP (INI_SIZE, FORM_SIZE, PARTIAL) dejan de caer en «error
de subida desconocido».

Verificado además que nginx acepta cuerpos de 90 MB o más: el límite del web
server no era la causa.

scripts/test_tv_playlist.js: 11 pruebas contra el código real — nunca dos
subidas a la vez, los 5 se intentan pese a fallos, el resumen cuenta y nombra,
y los pre-chequeos rechazan sin tocar la red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-27 10:19:02 -05:00
co-authored by Claude Fable 5
parent 1ba6805799
commit 587c23adcd
3 changed files with 165 additions and 18 deletions
+10 -1
View File
@@ -40,6 +40,11 @@ if ($file['error'] !== UPLOAD_ERR_OK) {
UPLOAD_ERR_NO_FILE => 'No se seleccionó ningún archivo',
UPLOAD_ERR_NO_TMP_DIR => 'Falta la carpeta temporal',
UPLOAD_ERR_CANT_WRITE => 'Error al escribir el archivo',
// Antes estos dos caían en «error desconocido», que no le dice nada a
// quien sube: son los límites de PHP, no del código.
UPLOAD_ERR_INI_SIZE => 'El archivo supera el límite de subida del servidor (php.ini)',
UPLOAD_ERR_FORM_SIZE => 'El archivo supera el límite del formulario',
UPLOAD_ERR_PARTIAL => 'La subida se cortó a medias: intente de nuevo',
];
echo json_encode(['ok' => false, 'error' => $errores[$file['error']] ?? 'Error de subida desconocido']);
exit;
@@ -63,7 +68,11 @@ $mimeMap = [
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!isset($mimeMap[$mime])) {
echo json_encode(['ok' => false, 'error' => 'Formato no admitido. Usa MP4, WebM, JPG, PNG o WEBP']);
// El caso más común es el video de iPhone: merece su propio mensaje
$msg = ($mime === 'video/quicktime')
? 'Es un video de iPhone (.mov): conviértalo a MP4 antes de subirlo'
: 'Formato no admitido (' . $mime . '). Use MP4, WebM, JPG, PNG o WEBP';
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
}
+59 -17
View File
@@ -1198,6 +1198,7 @@ $tab = $_GET['tab'] ?? 'lugares';
</button>
</div>
<input type="file" id="tv-file-input" accept="video/mp4,video/webm,image/jpeg,image/png,image/webp" class="d-none" multiple>
<div id="tv-upload-resumen" style="display:none"></div>
<!-- Barra de progreso -->
<div id="tv-progress-wrap" class="mt-3" style="display:none">
@@ -1618,7 +1619,7 @@ async function cambiarSesion(accion, sesionId) {
const inp = document.getElementById('tv-file-input');
if (!inp) return;
inp.addEventListener('change', () => {
Array.from(inp.files).forEach(subirTvMedia);
subirTvMediaEnCola(Array.from(inp.files));
inp.value = '';
});
@@ -1640,10 +1641,46 @@ async function cambiarSesion(accion, sesionId) {
});
})();
// ── Cola de subida ──
// Antes los archivos elegidos se subían TODOS EN PARALELO, peleándose el
// ancho de banda: con 5 videos entraban los 2 primeros y el resto moría por
// timeout, avisando con un toast que desaparecía en segundos. El cliente veía
// «subí 5» y la playlist mostraba 2. Ahora van de a uno, un fallo no frena a
// los demás, y al final queda un resumen fijo con qué entró y qué no.
const escHtml = x => String(x ?? '').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
async function subirTvMediaEnCola(files) {
if (!files.length) return;
const resultados = [];
for (const f of files) {
resultados.push(await subirTvMedia(f)); // secuencial a propósito
}
const ok = resultados.filter(r => r.ok);
const fallo = resultados.filter(r => !r.ok);
const res = document.getElementById('tv-upload-resumen');
if (res) {
res.style.display = '';
res.className = 'alert py-2 px-3 mt-2 mb-2 ' + (fallo.length ? 'alert-warning' : 'alert-success');
res.innerHTML = `<b>${ok.length} de ${files.length}</b> archivo(s) agregados a la playlist.`
+ (fallo.length
? '<br>No entraron:<br>' + fallo.map(r =>
`&nbsp;&nbsp;· <b>${escHtml(r.nombre)}</b> — ${escHtml(r.motivo)}`).join('<br>')
: '');
}
}
function subirTvMedia(file) {
const allowedMime = ['video/mp4', 'video/webm', 'video/ogg', 'image/jpeg', 'image/png', 'image/webp'];
if (!allowedMime.includes(file.type)) { toast(file.name + ': formato no admitido', 'error'); return; }
if (file.size > 500 * 1024 * 1024) { toast(file.name + ': supera el límite de 500 MB', 'error'); return; }
if (file.type === 'video/quicktime' || /\.mov$/i.test(file.name)) {
return Promise.resolve({ ok: false, nombre: file.name,
motivo: 'es un video de iPhone (.mov); hay que convertirlo a MP4 antes de subirlo' });
}
if (!allowedMime.includes(file.type)) {
return Promise.resolve({ ok: false, nombre: file.name, motivo: 'formato no admitido (use MP4, WebM, JPG o PNG)' });
}
if (file.size > 500 * 1024 * 1024) {
return Promise.resolve({ ok: false, nombre: file.name, motivo: 'supera el límite de 500 MB' });
}
const wrap = document.getElementById('tv-progress-wrap');
const bar = document.getElementById('tv-progress-bar');
@@ -1664,21 +1701,26 @@ function subirTvMedia(file) {
pct.textContent = p + '%';
});
xhr.addEventListener('load', () => {
wrap.style.display = 'none';
try {
const json = JSON.parse(xhr.responseText);
if (!json.ok) { toast(json.error || 'Error al subir', 'error'); return; }
toast(file.name + ' agregado a la playlist');
agregarItemTvMedia(json.item);
} catch(e) { toast('Respuesta inesperada del servidor', 'error'); }
return new Promise(resolver => {
xhr.addEventListener('load', () => {
wrap.style.display = 'none';
try {
const json = JSON.parse(xhr.responseText);
if (!json.ok) { resolver({ ok: false, nombre: file.name, motivo: json.error || 'el servidor lo rechazó' }); return; }
agregarItemTvMedia(json.item);
resolver({ ok: true, nombre: file.name });
} catch (e) {
resolver({ ok: false, nombre: file.name, motivo: 'respuesta inesperada del servidor' });
}
});
xhr.addEventListener('error', () => { wrap.style.display = 'none';
resolver({ ok: false, nombre: file.name, motivo: 'se cortó la conexión durante la subida' }); });
xhr.addEventListener('timeout', () => { wrap.style.display = 'none';
resolver({ ok: false, nombre: file.name, motivo: 'la subida tardó demasiado y se canceló' }); });
lbl.textContent = 'Subiendo ' + file.name + '…';
bar.style.width = '0%';
xhr.send(fd);
});
xhr.addEventListener('error', () => { wrap.style.display = 'none'; toast('Error de conexión', 'error'); });
lbl.textContent = 'Subiendo ' + file.name + '…';
bar.style.width = '0%';
xhr.send(fd);
}
function agregarItemTvMedia(item) {