mirrors-multidioma

This commit is contained in:
2026-03-17 14:03:14 +01:00
parent 07c0c193a7
commit 73d8c2f623
2 changed files with 101 additions and 8 deletions

2
app.py
View File

@@ -257,6 +257,7 @@ def group_channels(raw_entries: list, group_meta: dict) -> list:
'resolution': parsed['resolution'],
'acestream_hash': entry['acestream_hash'],
'status': 'unknown',
'country_code': country_code_from_logo_url('', entry['tvg_logo']),
}
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('quality_marker', None)
m.setdefault('status', 'unknown')
m.setdefault('country_code', '')
group_meta = {g['name']: g.get('logo', '') for g in data.get('groups', [])}
groups = sorted({ch['group'] for ch in channels})
state = {

View File

@@ -279,6 +279,18 @@
</svg>
Exportar M3U
</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>
@@ -441,11 +453,12 @@
if (this.activeGroup) ch = ch.filter(c => c.group === this.activeGroup);
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.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();
if (q) ch = ch.filter(c =>
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;
}
@@ -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>`;
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');
if (!wrap) return;
if (codes.length === 0) { wrap.classList.add('hidden'); return; }
@@ -767,7 +780,10 @@
// Flag in modal header (next to group badge)
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
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"
style="background:${bg}">
<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">
${tierBadge(m.resolution) || '<span class="text-xs text-gray-700">—</span>'}
</div>
@@ -1000,6 +1019,27 @@
.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) {
const EXTINF_ATTR = /([\w-]+)="([^"]*)"/g;
const ACESTREAM_RE = /^acestream:\/\/([a-f0-9]{40})/i;
@@ -1068,7 +1108,10 @@
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' };
const mirror = {
resolution, acestream_hash: entry.acestream_hash, status: 'unknown',
country_code: countryFromUrl(entry.tvg_logo),
};
if (!channelMap.has(key)) {
let baseId = slugify(base_name) || slugify(group);
@@ -1077,7 +1120,7 @@
else { idSeen[baseId] = 0; channelId = baseId; }
channelMap.set(key, {
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: [],
});
order.push(key);
@@ -1089,6 +1132,10 @@
}
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();
return { channels, groups, groupMeta, source: filename };
}
@@ -1101,7 +1148,8 @@
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';
m.status = m.status || 'unknown';
m.country_code = m.country_code || ch.country_code || '';
});
return ch;
});
@@ -1178,6 +1226,48 @@
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 ────────────────────────────────────────────────────────────────────
let _toastTimer;
function showToast(msg, isError = false) {
@@ -1207,11 +1297,12 @@
(data.groups || []).filter(g => typeof g !== 'string').map(g => [g.name, g.logo || ''])
);
$('sourceLabel').textContent = data.source || '';
// Superponer estados guardados en localStorage
// Superponer estados y country_code guardados en localStorage / fallback
App.channels.forEach(ch =>
ch.mirrors.forEach(m => {
const saved = localStorage.getItem(`ms_${m.acestream_hash}`);
if (saved) m.status = saved;
if (!m.country_code) m.country_code = ch.country_code || '';
})
);
renderAll();