funcionalidad-importar-exportar
This commit is contained in:
@@ -483,12 +483,12 @@
|
||||
"subcategories": []
|
||||
},
|
||||
{
|
||||
"name": "ATRESMEDIA",
|
||||
"name": "Atresmedia",
|
||||
"logo": "",
|
||||
"subcategories": []
|
||||
},
|
||||
{
|
||||
"name": "MEDIASET",
|
||||
"name": "Mediaset",
|
||||
"logo": "",
|
||||
"subcategories": []
|
||||
},
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user