funcionalidad-importar-exportar

This commit is contained in:
2026-03-16 13:55:18 +01:00
parent e7e124527c
commit 07c0c193a7
2 changed files with 161 additions and 21 deletions

View File

@@ -483,12 +483,12 @@
"subcategories": []
},
{
"name": "ATRESMEDIA",
"name": "Atresmedia",
"logo": "",
"subcategories": []
},
{
"name": "MEDIASET",
"name": "Mediaset",
"logo": "",
"subcategories": []
},

View File

@@ -992,6 +992,155 @@
}
});
// ── Import/Export helpers ────────────────────────────────────────────────────
function slugify(text) {
return text.toLowerCase().normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function parseImportM3U(text, filename) {
const EXTINF_ATTR = /([\w-]+)="([^"]*)"/g;
const ACESTREAM_RE = /^acestream:\/\/([a-f0-9]{40})/i;
const RESOLUTION_RE = /\s+(480p|720p|1080p|1440p|2160p|4[Kk]|UHD|FHD)\s*$/i;
const QUALITY_RE = /\s*(\*+)\s*$/;
const EXTGRP_RE = /group-title="([^"]*)".*?group-logo="([^"]*)"/;
function parseChannelName(rawName) {
let name = rawName.trim();
const qm = QUALITY_RE.exec(name);
if (qm) name = name.slice(0, qm.index);
const rm = RESOLUTION_RE.exec(name);
let resolution = '';
if (rm) {
const r = rm[1].toUpperCase();
resolution = r === 'FHD' ? '1080p'
: (r === 'UHD' || r === '2160P') ? '4K'
: r.startsWith('4K') ? '4K'
: rm[1];
name = name.slice(0, rm.index);
}
return { base_name: name.trim(), resolution };
}
const rawEntries = [];
const groupMeta = {};
let pending = null;
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('#EXTGRP:')) {
const m = EXTGRP_RE.exec(line);
if (m) groupMeta[m[1]] = m[2];
continue;
}
if (line.startsWith('#EXTINF:')) {
const attrs = {};
let m;
EXTINF_ATTR.lastIndex = 0;
while ((m = EXTINF_ATTR.exec(line)) !== null) attrs[m[1]] = m[2];
const ci = line.indexOf(',');
pending = {
tvg_id: attrs['tvg-id'] || '',
tvg_logo: attrs['tvg-logo'] || '',
group_title: attrs['group-title'] || '',
raw_name: ci !== -1 ? line.slice(ci + 1).trim() : '',
};
continue;
}
if (line.startsWith('#')) continue;
const am = pending && ACESTREAM_RE.exec(line);
if (am) {
rawEntries.push({ ...pending, acestream_hash: am[1] });
}
pending = null;
}
const channelMap = new Map();
const order = [];
const idSeen = {};
for (const entry of rawEntries) {
const { base_name, resolution } = parseChannelName(entry.raw_name);
const group = entry.group_title;
const key = `${base_name.toLowerCase()}\x00${group.toLowerCase()}`;
const mirror = { resolution, acestream_hash: entry.acestream_hash, status: 'unknown' };
if (!channelMap.has(key)) {
let baseId = slugify(base_name) || slugify(group);
let channelId;
if (baseId in idSeen) { idSeen[baseId]++; channelId = `${baseId}-${idSeen[baseId]}`; }
else { idSeen[baseId] = 0; channelId = baseId; }
channelMap.set(key, {
id: channelId, name: base_name, group, subcategory: '',
country_code: '', logo_url: entry.tvg_logo || '', tags: [],
_tvg_logo: entry.tvg_logo, mirrors: [],
});
order.push(key);
} else {
const ch = channelMap.get(key);
if (!ch._tvg_logo && entry.tvg_logo) { ch._tvg_logo = entry.tvg_logo; ch.logo_url = entry.tvg_logo; }
}
channelMap.get(key).mirrors.push(mirror);
}
const channels = order.map(k => { const ch = channelMap.get(k); delete ch._tvg_logo; return ch; });
const groups = [...new Set(channels.map(c => c.group))].sort();
return { channels, groups, groupMeta, source: filename };
}
function parseImportJSON(text, filename) {
const raw = JSON.parse(text);
const channels = (raw.channels || []).map(ch => {
if ('base_name' in ch && !('name' in ch)) { ch.name = ch.base_name; delete ch.base_name; }
ch.tags = ch.tags || [];
ch.country_code = ch.country_code || '';
(ch.mirrors || []).forEach(m => {
delete m.acestream_url; delete m.raw_name; delete m.quality_marker;
m.status = m.status || 'unknown';
});
return ch;
});
const rawGroups = raw.groups || [];
const groups = rawGroups.map(g => typeof g === 'string' ? g : g.name).sort();
const groupMeta = Object.fromEntries(
rawGroups.filter(g => typeof g !== 'string').map(g => [g.name, g.logo || ''])
);
return { channels, groups, groupMeta, source: raw.source || filename };
}
function buildExportJSON() {
return JSON.stringify({
version: '1.0',
exported_at: new Date().toISOString(),
source: $('sourceLabel').textContent || 'channels.json',
channels: App.channels,
groups: App.groups.map(g => ({ name: g, logo: App.groupMeta[g] || '', subcategories: [] })),
}, null, 2);
}
function buildExportM3U() {
const lines = ['#EXTM3U'];
for (const ch of App.channels)
for (const m of ch.mirrors) {
const name = m.resolution ? `${ch.name} ${m.resolution}` : ch.name;
lines.push(`#EXTINF:-1 tvg-id="${escAttr(ch.id||'')}" tvg-logo="${escAttr(ch.logo_url||'')}" group-title="${escAttr(ch.group)}",${name}`);
lines.push(`acestream://${m.acestream_hash}`);
}
return lines.join('\n');
}
function downloadBlob(content, filename, mime) {
const url = URL.createObjectURL(new Blob([content], { type: mime }));
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// ── Import ───────────────────────────────────────────────────────────────────
function triggerFileInput(type) {
$('importMenu').classList.add('hidden');
@@ -1002,31 +1151,21 @@
const file = input.files[0];
if (!file) return;
input.value = '';
$('importMenu').classList.add('hidden');
const fd = new FormData();
fd.append('file', file);
showToast(`Importando ${file.name}`);
try {
const res = await fetch(`/api/import/${type}`, { method: 'POST', body: fd });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Error desconocido');
App.channels = data.channels;
App.groups = data.groups;
App.groupMeta = data.group_meta || {};
App.activeGroup = null;
App.activeSubcategory = null;
App.activeTags.clear();
App.activeCountries.clear();
App.expandedGroups.clear();
const text = await file.text();
const data = type === 'json' ? parseImportJSON(text, file.name) : parseImportM3U(text, file.name);
App.channels = data.channels;
App.groups = data.groups;
App.groupMeta = data.groupMeta;
App.activeGroup = null; App.activeSubcategory = null;
App.activeTags.clear(); App.activeCountries.clear(); App.expandedGroups.clear();
App.search = '';
$('searchInput').value = '';
$('sourceLabel').textContent = data.source || '';
renderAll();
showToast(`${data.total} canales cargados`, false);
showToast(`${data.channels.length} canales cargados`, false);
} catch (err) {
showToast(`${err.message}`, true);
}
@@ -1035,7 +1174,8 @@
// ── Export ───────────────────────────────────────────────────────────────────
function doExport(type) {
$('exportMenu').classList.add('hidden');
window.location.href = `/api/export/${type}`;
if (type === 'json') downloadBlob(buildExportJSON(), 'channels.json', 'application/json');
else downloadBlob(buildExportM3U(), 'channels.m3u', 'text/plain');
}
// ── Toast ────────────────────────────────────────────────────────────────────