mirrors-multidioma
This commit is contained in:
2
app.py
2
app.py
@@ -257,6 +257,7 @@ def group_channels(raw_entries: list, group_meta: dict) -> list:
|
|||||||
'resolution': parsed['resolution'],
|
'resolution': parsed['resolution'],
|
||||||
'acestream_hash': entry['acestream_hash'],
|
'acestream_hash': entry['acestream_hash'],
|
||||||
'status': 'unknown',
|
'status': 'unknown',
|
||||||
|
'country_code': country_code_from_logo_url('', entry['tvg_logo']),
|
||||||
}
|
}
|
||||||
|
|
||||||
if key not in channel_map:
|
if key not in channel_map:
|
||||||
@@ -331,6 +332,7 @@ def load_from_json_content(data: dict, source_file: str) -> None:
|
|||||||
m.pop('raw_name', None)
|
m.pop('raw_name', None)
|
||||||
m.pop('quality_marker', None)
|
m.pop('quality_marker', None)
|
||||||
m.setdefault('status', 'unknown')
|
m.setdefault('status', 'unknown')
|
||||||
|
m.setdefault('country_code', '')
|
||||||
group_meta = {g['name']: g.get('logo', '') for g in data.get('groups', [])}
|
group_meta = {g['name']: g.get('logo', '') for g in data.get('groups', [])}
|
||||||
groups = sorted({ch['group'] for ch in channels})
|
groups = sorted({ch['group'] for ch in channels})
|
||||||
state = {
|
state = {
|
||||||
|
|||||||
@@ -279,6 +279,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Exportar M3U
|
Exportar M3U
|
||||||
</button>
|
</button>
|
||||||
|
<div style="height:1px;background:rgba(255,255,255,0.06);margin:0 12px"></div>
|
||||||
|
<button class="flex items-center gap-2.5 w-full px-4 py-3 text-sm text-gray-300
|
||||||
|
hover:text-white hover:bg-white/5 text-left transition-colors"
|
||||||
|
onclick="doExportTemplate()">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0 text-gray-500"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586
|
||||||
|
a1 1 0 01.707.293l5.414 5.414A1 1 0 0121 8.414V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
Exportar plantilla JSON
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -441,11 +453,12 @@
|
|||||||
if (this.activeGroup) ch = ch.filter(c => c.group === this.activeGroup);
|
if (this.activeGroup) ch = ch.filter(c => c.group === this.activeGroup);
|
||||||
if (this.activeSubcategory) ch = ch.filter(c => c.subcategory === this.activeSubcategory);
|
if (this.activeSubcategory) ch = ch.filter(c => c.subcategory === this.activeSubcategory);
|
||||||
if (this.activeTags.size) ch = ch.filter(c => c.tags && c.tags.some(t => this.activeTags.has(t)));
|
if (this.activeTags.size) ch = ch.filter(c => c.tags && c.tags.some(t => this.activeTags.has(t)));
|
||||||
if (this.activeCountries.size) ch = ch.filter(c => this.activeCountries.has(c.country_code));
|
if (this.activeCountries.size) ch = ch.filter(c => c.mirrors.some(m => this.activeCountries.has(m.country_code)));
|
||||||
const q = this.search.toLowerCase().trim();
|
const q = this.search.toLowerCase().trim();
|
||||||
if (q) ch = ch.filter(c =>
|
if (q) ch = ch.filter(c =>
|
||||||
c.name.toLowerCase().includes(q) ||
|
c.name.toLowerCase().includes(q) ||
|
||||||
c.group.toLowerCase().includes(q)
|
c.group.toLowerCase().includes(q) ||
|
||||||
|
c.mirrors.some(m => m.acestream_hash.includes(q))
|
||||||
);
|
);
|
||||||
return ch;
|
return ch;
|
||||||
}
|
}
|
||||||
@@ -539,7 +552,7 @@
|
|||||||
const CHEVRON_RIGHT = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>`;
|
const CHEVRON_RIGHT = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/></svg>`;
|
||||||
|
|
||||||
function renderTopCountries() {
|
function renderTopCountries() {
|
||||||
const codes = [...new Set(App.channels.map(c => c.country_code).filter(Boolean))].sort();
|
const codes = [...new Set(App.channels.flatMap(c => c.mirrors.map(m => m.country_code)).filter(Boolean))].sort();
|
||||||
const wrap = $('countryWrap');
|
const wrap = $('countryWrap');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
if (codes.length === 0) { wrap.classList.add('hidden'); return; }
|
if (codes.length === 0) { wrap.classList.add('hidden'); return; }
|
||||||
@@ -767,7 +780,10 @@
|
|||||||
|
|
||||||
// Flag in modal header (next to group badge)
|
// Flag in modal header (next to group badge)
|
||||||
const flagEl = $('modalFlag');
|
const flagEl = $('modalFlag');
|
||||||
if (flagEl) flagEl.innerHTML = flagHTML(ch.country_code || '');
|
if (flagEl) {
|
||||||
|
const mirrorCodes = [...new Set(ch.mirrors.map(m => m.country_code).filter(Boolean))].sort();
|
||||||
|
flagEl.innerHTML = mirrorCodes.map(c => flagHTML(c)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// Sort mirrors best → worst quality
|
// Sort mirrors best → worst quality
|
||||||
const TIER_ORDER = ['diamond', 'gold', 'silver', 'bronze'];
|
const TIER_ORDER = ['diamond', 'gold', 'silver', 'bronze'];
|
||||||
@@ -791,6 +807,9 @@
|
|||||||
<div class="mirror-row flex items-center gap-3 px-4 py-2.5 rounded-lg mx-2 mb-0.5"
|
<div class="mirror-row flex items-center gap-3 px-4 py-2.5 rounded-lg mx-2 mb-0.5"
|
||||||
style="background:${bg}">
|
style="background:${bg}">
|
||||||
<span class="text-xs text-gray-700 font-mono w-5 flex-shrink-0 text-right">${i + 1}</span>
|
<span class="text-xs text-gray-700 font-mono w-5 flex-shrink-0 text-right">${i + 1}</span>
|
||||||
|
<div class="flex-shrink-0 w-5 flex items-center justify-center">
|
||||||
|
${m.country_code ? flagHTML(m.country_code) : ''}
|
||||||
|
</div>
|
||||||
<div class="flex-shrink-0 w-20">
|
<div class="flex-shrink-0 w-20">
|
||||||
${tierBadge(m.resolution) || '<span class="text-xs text-gray-700">—</span>'}
|
${tierBadge(m.resolution) || '<span class="text-xs text-gray-700">—</span>'}
|
||||||
</div>
|
</div>
|
||||||
@@ -1000,6 +1019,27 @@
|
|||||||
.replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
.replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COUNTRY_FOLDER_MAP = {
|
||||||
|
'albania':'al','argentina':'ar','australia':'au','austria':'at','azerbaijan':'az',
|
||||||
|
'belgium':'be','brazil':'br','bulgaria':'bg','canada':'ca','chile':'cl',
|
||||||
|
'costa-rica':'cr','croatia':'hr','czech-republic':'cz','france':'fr','germany':'de',
|
||||||
|
'greece':'gr','hong-kong':'hk','hungary':'hu','india':'in','indonesia':'id',
|
||||||
|
'ireland':'ie','israel':'il','italy':'it','lebanon':'lb','lithuania':'lt',
|
||||||
|
'luxembourg':'lu','malaysia':'my','malta':'mt','mexico':'mx','netherlands':'nl',
|
||||||
|
'new-zealand':'nz','philippines':'ph','poland':'pl','portugal':'pt','romania':'ro',
|
||||||
|
'russia':'ru','serbia':'rs','singapore':'sg','slovakia':'sk','slovenia':'si',
|
||||||
|
'spain':'es','switzerland':'ch','turkey':'tr','ukraine':'ua',
|
||||||
|
'united-arab-emirates':'ae','united-kingdom':'gb','united-states':'us',
|
||||||
|
};
|
||||||
|
|
||||||
|
function countryFromUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
const m1 = url.match(/\/countries\/([^/]+)\//);
|
||||||
|
if (m1) return COUNTRY_FOLDER_MAP[m1[1]] || '';
|
||||||
|
const m2 = url.match(/-([a-z]{2})(?:-[a-z]+)?\.(?:png|svg|jpg)$/i);
|
||||||
|
return m2 ? m2[1].toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function parseImportM3U(text, filename) {
|
function parseImportM3U(text, filename) {
|
||||||
const EXTINF_ATTR = /([\w-]+)="([^"]*)"/g;
|
const EXTINF_ATTR = /([\w-]+)="([^"]*)"/g;
|
||||||
const ACESTREAM_RE = /^acestream:\/\/([a-f0-9]{40})/i;
|
const ACESTREAM_RE = /^acestream:\/\/([a-f0-9]{40})/i;
|
||||||
@@ -1068,7 +1108,10 @@
|
|||||||
const { base_name, resolution } = parseChannelName(entry.raw_name);
|
const { base_name, resolution } = parseChannelName(entry.raw_name);
|
||||||
const group = entry.group_title;
|
const group = entry.group_title;
|
||||||
const key = `${base_name.toLowerCase()}\x00${group.toLowerCase()}`;
|
const key = `${base_name.toLowerCase()}\x00${group.toLowerCase()}`;
|
||||||
const mirror = { resolution, acestream_hash: entry.acestream_hash, status: 'unknown' };
|
const mirror = {
|
||||||
|
resolution, acestream_hash: entry.acestream_hash, status: 'unknown',
|
||||||
|
country_code: countryFromUrl(entry.tvg_logo),
|
||||||
|
};
|
||||||
|
|
||||||
if (!channelMap.has(key)) {
|
if (!channelMap.has(key)) {
|
||||||
let baseId = slugify(base_name) || slugify(group);
|
let baseId = slugify(base_name) || slugify(group);
|
||||||
@@ -1077,7 +1120,7 @@
|
|||||||
else { idSeen[baseId] = 0; channelId = baseId; }
|
else { idSeen[baseId] = 0; channelId = baseId; }
|
||||||
channelMap.set(key, {
|
channelMap.set(key, {
|
||||||
id: channelId, name: base_name, group, subcategory: '',
|
id: channelId, name: base_name, group, subcategory: '',
|
||||||
country_code: '', logo_url: entry.tvg_logo || '', tags: [],
|
country_code: countryFromUrl(entry.tvg_logo), logo_url: entry.tvg_logo || '', tags: [],
|
||||||
_tvg_logo: entry.tvg_logo, mirrors: [],
|
_tvg_logo: entry.tvg_logo, mirrors: [],
|
||||||
});
|
});
|
||||||
order.push(key);
|
order.push(key);
|
||||||
@@ -1089,6 +1132,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const channels = order.map(k => { const ch = channelMap.get(k); delete ch._tvg_logo; return ch; });
|
const channels = order.map(k => { const ch = channelMap.get(k); delete ch._tvg_logo; return ch; });
|
||||||
|
// Fallback: mirrors without country_code inherit the channel's country_code
|
||||||
|
channels.forEach(ch =>
|
||||||
|
ch.mirrors.forEach(m => { if (!m.country_code) m.country_code = ch.country_code || ''; })
|
||||||
|
);
|
||||||
const groups = [...new Set(channels.map(c => c.group))].sort();
|
const groups = [...new Set(channels.map(c => c.group))].sort();
|
||||||
return { channels, groups, groupMeta, source: filename };
|
return { channels, groups, groupMeta, source: filename };
|
||||||
}
|
}
|
||||||
@@ -1101,7 +1148,8 @@
|
|||||||
ch.country_code = ch.country_code || '';
|
ch.country_code = ch.country_code || '';
|
||||||
(ch.mirrors || []).forEach(m => {
|
(ch.mirrors || []).forEach(m => {
|
||||||
delete m.acestream_url; delete m.raw_name; delete m.quality_marker;
|
delete m.acestream_url; delete m.raw_name; delete m.quality_marker;
|
||||||
m.status = m.status || 'unknown';
|
m.status = m.status || 'unknown';
|
||||||
|
m.country_code = m.country_code || ch.country_code || '';
|
||||||
});
|
});
|
||||||
return ch;
|
return ch;
|
||||||
});
|
});
|
||||||
@@ -1178,6 +1226,48 @@
|
|||||||
else downloadBlob(buildExportM3U(), 'channels.m3u', 'text/plain');
|
else downloadBlob(buildExportM3U(), 'channels.m3u', 'text/plain');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function doExportTemplate() {
|
||||||
|
$('exportMenu').classList.add('hidden');
|
||||||
|
const template = JSON.stringify({
|
||||||
|
version: '1.0',
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
source: 'plantilla',
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'nombre-canal',
|
||||||
|
name: 'Nombre del Canal',
|
||||||
|
group: 'Nombre del Grupo',
|
||||||
|
subcategory: '',
|
||||||
|
country_code: 'es',
|
||||||
|
logo_url: 'https://ejemplo.com/logo.png',
|
||||||
|
tags: ['🔴', 'HD'],
|
||||||
|
mirrors: [
|
||||||
|
{
|
||||||
|
resolution: '1080p',
|
||||||
|
acestream_hash: '0000000000000000000000000000000000000001',
|
||||||
|
status: 'unknown',
|
||||||
|
country_code: 'es',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolution: '720p',
|
||||||
|
acestream_hash: '0000000000000000000000000000000000000002',
|
||||||
|
status: 'ok',
|
||||||
|
country_code: 'gb',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: 'Nombre del Grupo',
|
||||||
|
logo: 'https://ejemplo.com/group-logo.png',
|
||||||
|
subcategories: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, null, 2);
|
||||||
|
downloadBlob(template, 'channels-plantilla.json', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Toast ────────────────────────────────────────────────────────────────────
|
// ── Toast ────────────────────────────────────────────────────────────────────
|
||||||
let _toastTimer;
|
let _toastTimer;
|
||||||
function showToast(msg, isError = false) {
|
function showToast(msg, isError = false) {
|
||||||
@@ -1207,11 +1297,12 @@
|
|||||||
(data.groups || []).filter(g => typeof g !== 'string').map(g => [g.name, g.logo || ''])
|
(data.groups || []).filter(g => typeof g !== 'string').map(g => [g.name, g.logo || ''])
|
||||||
);
|
);
|
||||||
$('sourceLabel').textContent = data.source || '';
|
$('sourceLabel').textContent = data.source || '';
|
||||||
// Superponer estados guardados en localStorage
|
// Superponer estados y country_code guardados en localStorage / fallback
|
||||||
App.channels.forEach(ch =>
|
App.channels.forEach(ch =>
|
||||||
ch.mirrors.forEach(m => {
|
ch.mirrors.forEach(m => {
|
||||||
const saved = localStorage.getItem(`ms_${m.acestream_hash}`);
|
const saved = localStorage.getItem(`ms_${m.acestream_hash}`);
|
||||||
if (saved) m.status = saved;
|
if (saved) m.status = saved;
|
||||||
|
if (!m.country_code) m.country_code = ch.country_code || '';
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|||||||
Reference in New Issue
Block a user