diff --git a/modules/turnero/api/save_tv_media.php b/modules/turnero/api/save_tv_media.php
index 76c2051..c97b6ad 100644
--- a/modules/turnero/api/save_tv_media.php
+++ b/modules/turnero/api/save_tv_media.php
@@ -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;
}
diff --git a/modules/turnero/views/configuracion.php b/modules/turnero/views/configuracion.php
index a782ea1..c1e4419 100644
--- a/modules/turnero/views/configuracion.php
+++ b/modules/turnero/views/configuracion.php
@@ -1198,6 +1198,7 @@ $tab = $_GET['tab'] ?? 'lugares';
+
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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 = `${ok.length} de ${files.length} archivo(s) agregados a la playlist.`
+ + (fallo.length
+ ? ' No entraron: ' + fallo.map(r =>
+ ` · ${escHtml(r.nombre)} — ${escHtml(r.motivo)}`).join(' ')
+ : '');
+ }
+}
+
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) {
diff --git a/scripts/test_tv_playlist.js b/scripts/test_tv_playlist.js
new file mode 100644
index 0000000..75a345b
--- /dev/null
+++ b/scripts/test_tv_playlist.js
@@ -0,0 +1,96 @@
+/**
+ * scripts/test_tv_playlist.js — node scripts/test_tv_playlist.js
+ *
+ * Pruebas de la subida de la playlist del televisor, contra el código real de
+ * configuracion.php. Vigilan el defecto reportado: «subí 5 videos y solo
+ * salieron 2» — los archivos se subían todos en paralelo, los lentos morían
+ * por timeout y el aviso era un toast que desaparecía en segundos.
+ */
+const fs = require('fs');
+let fallos = 0;
+const ok = (d, c) => { console.log((c ? ' ok ' : ' FALLA ') + d); if (!c) fallos++; };
+
+const src = fs.readFileSync(__dirname + '/../modules/turnero/views/configuracion.php', 'utf8')
+ .replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
+
+// ── extracciones ────────────────────────────────────────────────────────
+const iCola = src.indexOf('const escHtml');
+const fCola = src.indexOf('function subirTvMedia(file)');
+const colaSrc = src.slice(iCola, fCola); // escHtml + subirTvMediaEnCola
+const fFin = src.indexOf('function agregarItemTvMedia');
+const subirSrc = src.slice(fCola, fFin); // subirTvMedia real
+
+ok('el selector de archivos usa la cola, no la andanada paralela',
+ src.includes('subirTvMediaEnCola(Array.from(inp.files))'));
+ok('el reproductor salta al siguiente si un video falla',
+ src.includes("addEventListener('error', siguiente)") ||
+ fs.readFileSync(__dirname + '/../modules/turnero/views/display_global.php', 'utf8')
+ .includes("addEventListener('error', siguiente)"));
+
+// ── 1. La cola sube de a UNO y no se detiene ante un fallo ──────────────
+{
+ const orden = [];
+ let simultaneos = 0, picoSimultaneos = 0;
+ const resumenDiv = { style: {}, innerHTML: '', className: '' };
+ const doc = { getElementById: id => (id === 'tv-upload-resumen' ? resumenDiv : null) };
+
+ // subirTvMedia de mentira: tarda distinto por archivo, falla el 2º y el 4º
+ const stub = async (f) => {
+ simultaneos++; picoSimultaneos = Math.max(picoSimultaneos, simultaneos);
+ orden.push('inicio:' + f.name);
+ await new Promise(x => setTimeout(x, f.ms));
+ simultaneos--;
+ orden.push('fin:' + f.name);
+ return f.falla ? { ok: false, nombre: f.name, motivo: 'timeout simulado' }
+ : { ok: true, nombre: f.name };
+ };
+
+ const enCola = new Function('subirTvMedia', 'document',
+ colaSrc + '; return subirTvMediaEnCola;')(stub, doc);
+
+ const files = [
+ { name: 'v1.mp4', ms: 30 },
+ { name: 'v2.mp4', ms: 10, falla: true },
+ { name: 'v3.mp4', ms: 20 },
+ { name: 'v4.mp4', ms: 10, falla: true },
+ { name: 'v5.mp4', ms: 15 },
+ ];
+
+ enCola(files).then(() => {
+ ok('sube estrictamente de a uno (nunca hubo dos a la vez)', picoSimultaneos === 1);
+ ok('un fallo no frena a los que siguen: los 5 se intentaron',
+ orden.filter(x => x.startsWith('inicio:')).length === 5);
+ ok('respeta el orden elegido',
+ orden.filter(x => x.startsWith('inicio:')).join(',') ===
+ 'inicio:v1.mp4,inicio:v2.mp4,inicio:v3.mp4,inicio:v4.mp4,inicio:v5.mp4');
+ ok('el resumen dice cuántos entraron: «3 de 5»',
+ resumenDiv.innerHTML.includes('3 de 5'));
+ ok('el resumen NOMBRA los que fallaron y su motivo',
+ resumenDiv.innerHTML.includes('v2.mp4') && resumenDiv.innerHTML.includes('v4.mp4')
+ && resumenDiv.innerHTML.includes('timeout simulado'));
+ ok('el resumen queda visible (no es un toast fugaz)',
+ resumenDiv.style.display === '');
+
+ // ── 2. Los pre-chequeos rechazan claro y ANTES de tocar la red ──────
+ const docTrampa = { getElementById: () => { throw new Error('tocó el DOM'); } };
+ const subir = new Function('document', 'XMLHttpRequest', 'FormData', 'API',
+ subirSrc + '; return subirTvMedia;')(
+ docTrampa, function () { throw new Error('tocó la red'); }, function () {}, '');
+
+ Promise.all([
+ subir({ name: 'clip.mov', type: 'video/quicktime', size: 1000 }),
+ subir({ name: 'doc.pdf', type: 'application/pdf', size: 1000 }),
+ subir({ name: 'giga.mp4', type: 'video/mp4', size: 600 * 1024 * 1024 }),
+ ]).then(([mov, pdf, giga]) => {
+ ok('un .mov de iPhone se rechaza con instrucción de convertir a MP4',
+ !mov.ok && /iPhone|MP4/.test(mov.motivo));
+ ok('un formato ajeno se rechaza nombrando los admitidos',
+ !pdf.ok && /MP4/.test(pdf.motivo));
+ ok('el que pasa de 500 MB se rechaza por tamaño',
+ !giga.ok && /500/.test(giga.motivo));
+ console.log();
+ console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
+ process.exit(fallos === 0 ? 0 : 1);
+ });
+ });
+}