diff --git a/channels.json b/channels.json index 0764fc4..9d20a68 100644 --- a/channels.json +++ b/channels.json @@ -483,12 +483,12 @@ "subcategories": [] }, { - "name": "ATRESMEDIA", + "name": "Atresmedia", "logo": "", "subcategories": [] }, { - "name": "MEDIASET", + "name": "Mediaset", "logo": "", "subcategories": [] }, diff --git a/static/index.html b/static/index.html index a244d70..35b6992 100644 --- a/static/index.html +++ b/static/index.html @@ -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 ────────────────────────────────────────────────────────────────────