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
# Byte-compiled / optimized / DLL files
__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):
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
# alias wasn't created (e.g. the suffix was longer than 3 chars)
for key, path in logo_index.items():
if key.startswith(slug) and len(key) <= len(slug) + 4:
return f'/logos/{path}', 'local'
return f'logos/{path}', 'local'
return None, None
if tvg_id:
@@ -256,6 +256,7 @@ def group_channels(raw_entries: list, group_meta: dict) -> list:
mirror = {
'resolution': parsed['resolution'],
'acestream_hash': entry['acestream_hash'],
'status': 'unknown',
}
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('raw_name', None)
m.pop('quality_marker', None)
m.setdefault('status', 'unknown')
group_meta = {g['name']: g.get('logo', '') for g in data.get('groups', [])}
groups = sorted({ch['group'] for ch in channels})
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 ───────────────────────────────────────────────────────────────────
def startup() -> None:

View File

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

View File

@@ -452,6 +452,22 @@
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 ──────────────────────────────────────────────────────────────
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"
@@ -461,7 +477,7 @@
function flagHTML(code) {
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"
onerror="this.style.display='none'" loading="lazy">`;
}
@@ -685,18 +701,30 @@
// Mirrors
$('modalMirrors').innerHTML = sorted.map((m, i) => {
const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
const hashFull = escHTML(m.acestream_hash);
const hashShort = m.acestream_hash.slice(0, 16) + '…';
const safeUrl = escAttr(`acestream://${m.acestream_hash}`);
const hashFull = escHTML(m.acestream_hash);
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 `
<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>
<div class="flex-shrink-0 w-20">
${tierBadge(m.resolution) || '<span class="text-xs text-gray-700">—</span>'}
</div>
<code class="flex-1 text-xs font-mono text-gray-600 truncate hidden sm:block"
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
text-xs font-semibold transition-all w-20 justify-center"
style="background:rgba(124,58,237,0.2);color:var(--accent-light);
@@ -768,8 +796,27 @@
renderAll();
});
// Mirror open button
// Mirror open button + status selector
$('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]');
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 || ''])
);
$('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();
} catch (err) {
$('stateLoading').classList.add('hidden');