valoracion-mirrors

This commit is contained in:
2026-03-10 11:04:07 +01:00
parent 0b8fe7bbec
commit 218983660c
4 changed files with 187 additions and 71 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
web/
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

33
app.py
View File

@@ -94,12 +94,12 @@ def resolve_logo(tvg_id: str, base_name: str, tvg_logo: str) -> tuple:
""" """
def lookup(slug: str): def lookup(slug: str):
if slug in logo_index: if slug in logo_index:
return f'/logos/{logo_index[slug]}', 'local' return f'logos/{logo_index[slug]}', 'local'
# Prefix match: allows "dazn-laliga" → "dazn-laliga-es" when no-suffix # Prefix match: allows "dazn-laliga" → "dazn-laliga-es" when no-suffix
# alias wasn't created (e.g. the suffix was longer than 3 chars) # alias wasn't created (e.g. the suffix was longer than 3 chars)
for key, path in logo_index.items(): for key, path in logo_index.items():
if key.startswith(slug) and len(key) <= len(slug) + 4: if key.startswith(slug) and len(key) <= len(slug) + 4:
return f'/logos/{path}', 'local' return f'logos/{path}', 'local'
return None, None return None, None
if tvg_id: if tvg_id:
@@ -256,6 +256,7 @@ def group_channels(raw_entries: list, group_meta: dict) -> list:
mirror = { mirror = {
'resolution': parsed['resolution'], 'resolution': parsed['resolution'],
'acestream_hash': entry['acestream_hash'], 'acestream_hash': entry['acestream_hash'],
'status': 'unknown',
} }
if key not in channel_map: if key not in channel_map:
@@ -329,6 +330,7 @@ def load_from_json_content(data: dict, source_file: str) -> None:
m.pop('acestream_url', None) m.pop('acestream_url', 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')
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 = {
@@ -463,6 +465,33 @@ def api_export_m3u():
) )
@app.route('/api/mirror/status', methods=['POST'])
def api_mirror_status():
data = request.get_json()
channel_id = data.get('channel_id')
mirror_idx = data.get('mirror_idx')
status = data.get('status', 'unknown')
if status not in ('ok', 'issues', 'broken', 'unknown'):
return jsonify({'error': 'invalid status'}), 400
ch = next((c for c in state['channels'] if c['id'] == channel_id), None)
if not ch or not isinstance(mirror_idx, int) or mirror_idx >= len(ch['mirrors']):
return jsonify({'error': 'not found'}), 404
ch['mirrors'][mirror_idx]['status'] = status
export = {
'version': '1.0',
'exported_at': datetime.now(timezone.utc).isoformat(),
'source': state['source_file'],
'channels': state['channels'],
'groups': [
{'name': g, 'logo': state['group_meta'].get(g, ''), 'subcategories': []}
for g in state['groups']
],
}
with open(DEFAULT_JSON, 'w', encoding='utf-8') as f:
json.dump(export, f, ensure_ascii=False, indent=2)
return jsonify({'ok': True})
# ── Startup ─────────────────────────────────────────────────────────────────── # ── Startup ───────────────────────────────────────────────────────────────────
def startup() -> None: def startup() -> None:

View File

@@ -9,7 +9,7 @@
"group": "DAZN", "group": "DAZN",
"subcategory": "", "subcategory": "",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/dazn-1-es.png", "logo_url": "logos/countries/spain/dazn-1-es.png",
"tags": [], "tags": [],
"mirrors": [ "mirrors": [
{ {
@@ -32,7 +32,7 @@
"group": "DAZN", "group": "DAZN",
"subcategory": "", "subcategory": "",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/dazn-2-es.png", "logo_url": "logos/countries/spain/dazn-2-es.png",
"tags": [], "tags": [],
"mirrors": [ "mirrors": [
{ {
@@ -47,7 +47,7 @@
"group": "DAZN", "group": "DAZN",
"subcategory": "LaLiga", "subcategory": "LaLiga",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/dazn-laliga-es.png", "logo_url": "logos/countries/spain/dazn-laliga-es.png",
"tags": [], "tags": [],
"mirrors": [ "mirrors": [
{ {
@@ -70,7 +70,7 @@
"group": "DAZN", "group": "DAZN",
"subcategory": "LaLiga", "subcategory": "LaLiga",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/dazn-laliga-2-es.png", "logo_url": "logos/countries/spain/dazn-laliga-2-es.png",
"tags": [], "tags": [],
"mirrors": [ "mirrors": [
{ {
@@ -89,7 +89,7 @@
"group": "DAZN", "group": "DAZN",
"subcategory": "F1", "subcategory": "F1",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/dazn-f1-es.png", "logo_url": "logos/countries/spain/dazn-f1-es.png",
"tags": [], "tags": [],
"mirrors": [ "mirrors": [
{ {
@@ -116,7 +116,7 @@
"group": "LaLiga", "group": "LaLiga",
"subcategory": "Hypermotion", "subcategory": "Hypermotion",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/laliga-tv-hypermotion-es.png", "logo_url": "logos/countries/spain/laliga-tv-hypermotion-es.png",
"tags": [], "tags": [],
"mirrors": [ "mirrors": [
{ {
@@ -162,7 +162,7 @@
"group": "Movistar+", "group": "Movistar+",
"subcategory": "", "subcategory": "",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/movistar-plus-es.png", "logo_url": "logos/countries/spain/movistar-plus-es.png",
"tags": [], "tags": [],
"mirrors": [ "mirrors": [
{ {
@@ -186,7 +186,9 @@
"subcategory": "Deportes", "subcategory": "Deportes",
"country_code": "es", "country_code": "es",
"logo_url": "logos/countries/spain/deportes-por-movistar-plus-es.png", "logo_url": "logos/countries/spain/deportes-por-movistar-plus-es.png",
"tags": ["\u26BD"], "tags": [
"⚽"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "720p", "resolution": "720p",
@@ -205,7 +207,9 @@
"subcategory": "Deportes", "subcategory": "Deportes",
"country_code": "es", "country_code": "es",
"logo_url": "logos/countries/spain/deportes-2-por-movistar-plus-es.png", "logo_url": "logos/countries/spain/deportes-2-por-movistar-plus-es.png",
"tags": ["\u26BD"], "tags": [
"⚽"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "720p", "resolution": "720p",
@@ -232,7 +236,9 @@
"subcategory": "Deportes", "subcategory": "Deportes",
"country_code": "es", "country_code": "es",
"logo_url": "logos/countries/spain/deportes-3-por-movistar-plus-es.png", "logo_url": "logos/countries/spain/deportes-3-por-movistar-plus-es.png",
"tags": ["\u26BD"], "tags": [
"⚽"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "1080p", "resolution": "1080p",
@@ -250,16 +256,30 @@
"group": "Movistar+", "group": "Movistar+",
"subcategory": "Golf", "subcategory": "Golf",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/golf-por-movistar-plus-es.png", "logo_url": "logos/countries/spain/golf-por-movistar-plus-es.png",
"tags": ["\u26F3"], "tags": [
"⛳"
],
"mirrors": [ "mirrors": [
{
"resolution": "1080p",
"acestream_hash": "9bb2a7dc6c6a296609df8cc50e742a78194f7530"
},
{
"resolution": "1080p",
"acestream_hash": "97df5b7824948972d041d8ca2a4d29c90b641bc9"
},
{
"resolution": "1080p",
"acestream_hash": "9db029dff6a9c637d1f670e78dbc1a479b9b406e"
},
{ {
"resolution": "720p", "resolution": "720p",
"acestream_hash": "f60535a458b3c114283e2f433379144345beddd6" "acestream_hash": "f60535a458b3c114283e2f433379144345beddd6"
}, },
{ {
"resolution": "1080p", "resolution": "720p",
"acestream_hash": "9bb2a7dc6c6a296609df8cc50e742a78194f7530" "acestream_hash": "63e4713e8379f2ebf5e6ed9aa00fbf78ebae7439"
} }
] ]
}, },
@@ -269,11 +289,13 @@
"group": "Movistar+", "group": "Movistar+",
"subcategory": "Golf", "subcategory": "Golf",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/golf-2-por-movistar-plus-es.png", "logo_url": "logos/countries/spain/golf-2-por-movistar-plus-es.png",
"tags": ["\u26F3"], "tags": [
"⛳"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "4K", "resolution": "1080p",
"acestream_hash": "ac321484243571f59e077f2a2521e648dc0c7b03" "acestream_hash": "ac321484243571f59e077f2a2521e648dc0c7b03"
} }
] ]
@@ -285,16 +307,10 @@
"subcategory": "Vamos", "subcategory": "Vamos",
"country_code": "es", "country_code": "es",
"logo_url": "logos/countries/spain/vamos-por-movistar-plus-es.png", "logo_url": "logos/countries/spain/vamos-por-movistar-plus-es.png",
"tags": ["\u26BD"], "tags": [
"⚽"
],
"mirrors": [ "mirrors": [
{
"resolution": "720p",
"acestream_hash": "866a8af8faacf8fc9eb997ab5a68b4dfee4edc77"
},
{
"resolution": "720p",
"acestream_hash": "0e5d8c9724fa9163f49096b70484e315251eb785"
},
{ {
"resolution": "1080p", "resolution": "1080p",
"acestream_hash": "ad5ca6e24f87f47a9c3c5bc10c7e8e5af46767fc" "acestream_hash": "ad5ca6e24f87f47a9c3c5bc10c7e8e5af46767fc"
@@ -302,6 +318,18 @@
{ {
"resolution": "1080p", "resolution": "1080p",
"acestream_hash": "476b6f6583517bd75c15c4663bf45fab7c8da9cf" "acestream_hash": "476b6f6583517bd75c15c4663bf45fab7c8da9cf"
},
{
"resolution": "1080p",
"acestream_hash": "c7c81acdd1a03ecc418c94c2f28e2adb0556c40b"
},
{
"resolution": "720p",
"acestream_hash": "866a8af8faacf8fc9eb997ab5a68b4dfee4edc77"
},
{
"resolution": "720p",
"acestream_hash": "0e5d8c9724fa9163f49096b70484e315251eb785"
} }
] ]
}, },
@@ -312,7 +340,9 @@
"subcategory": "Vamos", "subcategory": "Vamos",
"country_code": "es", "country_code": "es",
"logo_url": "logos/countries/spain/vamos-2-por-movistar-plus-es.png", "logo_url": "logos/countries/spain/vamos-2-por-movistar-plus-es.png",
"tags": ["\u26BD"], "tags": [
"⚽"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "720p", "resolution": "720p",
@@ -338,8 +368,10 @@
"group": "RFEF", "group": "RFEF",
"subcategory": "", "subcategory": "",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/rfef-tv.png", "logo_url": "logos/countries/spain/rfef-tv.png",
"tags": ["\u26BD"], "tags": [
"⚽"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "1080p", "resolution": "1080p",
@@ -361,8 +393,11 @@
"group": "3Cat", "group": "3Cat",
"subcategory": "", "subcategory": "",
"country_code": "cat", "country_code": "cat",
"logo_url": "/logos/countries/spain/esport-3-es.png", "logo_url": "logos/countries/spain/esport-3-es.png",
"tags": ["\u26BD", "\uD83C\uDFC0"], "tags": [
"⚽",
"🏀"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "720p", "resolution": "720p",
@@ -384,8 +419,12 @@
"group": "RTVE", "group": "RTVE",
"subcategory": "", "subcategory": "",
"country_code": "es", "country_code": "es",
"logo_url": "/logos/countries/spain/tdp-es.png", "logo_url": "logos/countries/spain/tdp-es.png",
"tags": ["\u26BD","\uD83C\uDFC0", "\uD83E\uDD47"], "tags": [
"⚽",
"🏀",
"🥇"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "720p", "resolution": "720p",
@@ -412,7 +451,9 @@
"subcategory": "MotoGP", "subcategory": "MotoGP",
"country_code": "es", "country_code": "es",
"logo_url": "https://hips.hearstapps.com/vader-prod.s3.amazonaws.com/1772006365-dazn-motogp-699eaba85423e.jpg", "logo_url": "https://hips.hearstapps.com/vader-prod.s3.amazonaws.com/1772006365-dazn-motogp-699eaba85423e.jpg",
"tags": ["\uD83C\uDFCD\uFE0F"], "tags": [
"🏍️"
],
"mirrors": [ "mirrors": [
{ {
"resolution": "1080p", "resolution": "1080p",
@@ -432,13 +473,23 @@
"subcategories": [] "subcategories": []
}, },
{ {
"name": "DEPORTES", "name": "MOVISTAR+",
"logo": "https://upload.wikimedia.org/wikipedia/commons/d/d9/Movistar%2B_Logo.png",
"subcategories": []
},
{
"name": "RTVE",
"logo": "", "logo": "",
"subcategories": [] "subcategories": []
}, },
{ {
"name": "HYPERMOTION", "name": "ATRESMEDIA",
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/LaLiga_Hypermotion_2023_Vertical_Logo.svg/2335px-LaLiga_Hypermotion_2023_Vertical_Logo.svg.png", "logo": "",
"subcategories": []
},
{
"name": "MEDIASET",
"logo": "",
"subcategories": [] "subcategories": []
}, },
{ {
@@ -447,27 +498,7 @@
"subcategories": [] "subcategories": []
}, },
{ {
"name": "LIGA DE CAMPEONES", "name": "RFEF",
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Liga_de_Campeones_por_Movistar_Plus%2B_2023_Logo.svg/2560px-Liga_de_Campeones_por_Movistar_Plus%2B_2023_Logo.svg.png",
"subcategories": []
},
{
"name": "MOTOGP",
"logo": "",
"subcategories": []
},
{
"name": "MOVISTAR",
"logo": "https://upload.wikimedia.org/wikipedia/commons/d/d9/Movistar%2B_Logo.png",
"subcategories": []
},
{
"name": "MOVISTAR DEPORTES",
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Deportes_por_Movistar_Plus%2B_2022_logo.svg/2560px-Deportes_por_Movistar_Plus%2B_2022_logo.svg.png",
"subcategories": []
},
{
"name": "RFEF TV",
"logo": "https://rfef.es/themes/custom/rfef/img/rfef-share.png", "logo": "https://rfef.es/themes/custom/rfef/img/rfef-share.png",
"subcategories": [] "subcategories": []
} }

View File

@@ -452,6 +452,22 @@
return null; return null;
} }
// ── Mirror status ────────────────────────────────────────────────────────────
const STATUS_CONFIG = {
unknown: { emoji: '❔', label: 'Por comprobar', bg: 'transparent' },
ok: { emoji: '✅', label: 'Funciona', bg: 'rgba(34,197,94,0.08)' },
issues: { emoji: '⚠️', label: 'Problemas', bg: 'rgba(245,158,11,0.10)' },
broken: { emoji: '⛔️', label: 'No funciona', bg: 'rgba(239,68,68,0.10)' },
};
const STATUS_CYCLE = ['unknown', 'ok', 'issues', 'broken'];
function getMirrorStatus(hash) {
return localStorage.getItem(`ms_${hash}`) || 'unknown';
}
function setMirrorStatus(hash, status) {
localStorage.setItem(`ms_${hash}`, status);
}
// ── Flag helper ────────────────────────────────────────────────────────────── // ── Flag helper ──────────────────────────────────────────────────────────────
const TV_ICON = `<svg class="w-7 h-7 opacity-30" fill="none" viewBox="0 0 24 24" stroke="white" stroke-width="1.2"> const TV_ICON = `<svg class="w-7 h-7 opacity-30" fill="none" viewBox="0 0 24 24" stroke="white" stroke-width="1.2">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
@@ -461,7 +477,7 @@
function flagHTML(code) { function flagHTML(code) {
if (!code) return ''; if (!code) return '';
return `<img src="/flags/${escAttr(code)}.svg" alt="${escAttr(code)}" return `<img src="flags/${escAttr(code)}.svg" alt="${escAttr(code)}"
class="h-3.5 w-auto rounded-sm opacity-90" class="h-3.5 w-auto rounded-sm opacity-90"
onerror="this.style.display='none'" loading="lazy">`; onerror="this.style.display='none'" loading="lazy">`;
} }
@@ -688,15 +704,27 @@
const safeUrl = escAttr(`acestream://${m.acestream_hash}`); const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
const hashFull = escHTML(m.acestream_hash); const hashFull = escHTML(m.acestream_hash);
const hashShort = m.acestream_hash.slice(0, 16) + '…'; const hashShort = m.acestream_hash.slice(0, 16) + '…';
const status = getMirrorStatus(m.acestream_hash);
const { emoji, label, bg } = STATUS_CONFIG[status] || STATUS_CONFIG.unknown;
const safeHash = escAttr(m.acestream_hash);
const safeChId = escAttr(ch.id);
return ` return `
<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}">
<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-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>
<code class="flex-1 text-xs font-mono text-gray-600 truncate hidden sm:block" <code class="flex-1 text-xs font-mono text-gray-600 truncate hidden sm:block"
title="${hashFull}">${hashShort}</code> title="${hashFull}">${hashShort}</code>
<button class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg
text-base transition-all cursor-pointer"
title="${escAttr(label)}"
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08)"
data-status-hash="${safeHash}"
data-channel-id="${safeChId}"
data-mirror-idx="${i}">${emoji}</button>
<button class="flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg <button class="flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg
text-xs font-semibold transition-all w-20 justify-center" text-xs font-semibold transition-all w-20 justify-center"
style="background:rgba(124,58,237,0.2);color:var(--accent-light); style="background:rgba(124,58,237,0.2);color:var(--accent-light);
@@ -768,8 +796,27 @@
renderAll(); renderAll();
}); });
// Mirror open button // Mirror open button + status selector
$('modalMirrors').addEventListener('click', e => { $('modalMirrors').addEventListener('click', e => {
const statusBtn = e.target.closest('[data-status-hash]');
if (statusBtn) {
const hash = statusBtn.dataset.statusHash;
const cur = getMirrorStatus(hash);
const next = STATUS_CYCLE[(STATUS_CYCLE.indexOf(cur) + 1) % STATUS_CYCLE.length];
setMirrorStatus(hash, next);
statusBtn.textContent = STATUS_CONFIG[next].emoji;
statusBtn.title = STATUS_CONFIG[next].label;
fetch('/api/mirror/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: statusBtn.dataset.channelId,
mirror_idx: parseInt(statusBtn.dataset.mirrorIdx),
status: next,
}),
}).catch(() => {});
return;
}
const btn = e.target.closest('[data-url]'); const btn = e.target.closest('[data-url]');
if (btn) window.location.href = btn.dataset.url; if (btn) window.location.href = btn.dataset.url;
}); });
@@ -882,6 +929,13 @@
(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
App.channels.forEach(ch =>
ch.mirrors.forEach(m => {
const saved = localStorage.getItem(`ms_${m.acestream_hash}`);
if (saved) m.status = saved;
})
);
renderAll(); renderAll();
} catch (err) { } catch (err) {
$('stateLoading').classList.add('hidden'); $('stateLoading').classList.add('hidden');