funcionalidad-importar-exportar
This commit is contained in:
@@ -483,12 +483,12 @@
|
|||||||
"subcategories": []
|
"subcategories": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ATRESMEDIA",
|
"name": "Atresmedia",
|
||||||
"logo": "",
|
"logo": "",
|
||||||
"subcategories": []
|
"subcategories": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "MEDIASET",
|
"name": "Mediaset",
|
||||||
"logo": "",
|
"logo": "",
|
||||||
"subcategories": []
|
"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 ───────────────────────────────────────────────────────────────────
|
// ── Import ───────────────────────────────────────────────────────────────────
|
||||||
function triggerFileInput(type) {
|
function triggerFileInput(type) {
|
||||||
$('importMenu').classList.add('hidden');
|
$('importMenu').classList.add('hidden');
|
||||||
@@ -1002,31 +1151,21 @@
|
|||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
|
||||||
$('importMenu').classList.add('hidden');
|
$('importMenu').classList.add('hidden');
|
||||||
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
|
|
||||||
showToast(`Importando ${file.name}…`);
|
showToast(`Importando ${file.name}…`);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/import/${type}`, { method: 'POST', body: fd });
|
const text = await file.text();
|
||||||
const data = await res.json();
|
const data = type === 'json' ? parseImportJSON(text, file.name) : parseImportM3U(text, file.name);
|
||||||
if (!res.ok) throw new Error(data.error || 'Error desconocido');
|
App.channels = data.channels;
|
||||||
|
App.groups = data.groups;
|
||||||
App.channels = data.channels;
|
App.groupMeta = data.groupMeta;
|
||||||
App.groups = data.groups;
|
App.activeGroup = null; App.activeSubcategory = null;
|
||||||
App.groupMeta = data.group_meta || {};
|
App.activeTags.clear(); App.activeCountries.clear(); App.expandedGroups.clear();
|
||||||
App.activeGroup = null;
|
|
||||||
App.activeSubcategory = null;
|
|
||||||
App.activeTags.clear();
|
|
||||||
App.activeCountries.clear();
|
|
||||||
App.expandedGroups.clear();
|
|
||||||
App.search = '';
|
App.search = '';
|
||||||
$('searchInput').value = '';
|
$('searchInput').value = '';
|
||||||
$('sourceLabel').textContent = data.source || '';
|
$('sourceLabel').textContent = data.source || '';
|
||||||
renderAll();
|
renderAll();
|
||||||
showToast(`✓ ${data.total} canales cargados`, false);
|
showToast(`✓ ${data.channels.length} canales cargados`, false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`✗ ${err.message}`, true);
|
showToast(`✗ ${err.message}`, true);
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1174,8 @@
|
|||||||
// ── Export ───────────────────────────────────────────────────────────────────
|
// ── Export ───────────────────────────────────────────────────────────────────
|
||||||
function doExport(type) {
|
function doExport(type) {
|
||||||
$('exportMenu').classList.add('hidden');
|
$('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 ────────────────────────────────────────────────────────────────────
|
// ── Toast ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user