Add live tv feature by leveraging https://iptv345.com/

This commit is contained in:
2026-04-16 08:30:39 +10:00
parent b3b6f77c84
commit ece8f5e22c
9 changed files with 757 additions and 0 deletions
+10
View File
@@ -70,3 +70,13 @@ class CoreConfig(AppConfig):
replace_existing=True,
)
logger.info("Scheduled periodic task: flush_click_buffer (every 60s)")
# Add daily job for syncing Live TV channels
from links.livetv_tasks import sync_livetv_channels
scheduler.add_job(
sync_livetv_channels,
trigger=IntervalTrigger(hours=24),
id='sync_livetv_channels',
replace_existing=True,
)
logger.info("Scheduled periodic task: sync_livetv_channels (every 24h)")
+254
View File
@@ -0,0 +1,254 @@
import asyncio
import logging
import re
import requests
from bs4 import BeautifulSoup
from threading import Thread
from django.utils import timezone
logger = logging.getLogger(__name__)
CATEGORIES = [
('itv', '綜合'),
('ty', '體育'),
('ys', '央視'),
('ws', '衛視'),
('gt', '港澳台'),
('other', '其他'),
('movie', '電影'),
('migu', '咪咕視頻'),
('fjitv', '福建移動IPTV'),
('hlitv', '黑龍江移動IPTV'),
('ipv6', 'IPv6網絡電視'),
]
BASE_URL = 'https://iptv345.com'
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
STREAM_RESOLVE_TIMEOUT = 30 # seconds for Playwright to capture stream URL
STREAM_URL_TTL = 3600 # 1 hour cache TTL
def sync_livetv_channels():
"""Scrape all categories and upsert channels into DB. Safe to call from background thread."""
from links.models import LiveTVChannel
logger.info('Starting LiveTV channel sync...')
total_new = 0
total_updated = 0
for tid, cat_name in CATEGORIES:
try:
url = f'{BASE_URL}/?tid={tid}'
resp = requests.get(url, headers=HEADERS, timeout=15)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, 'html.parser')
links = soup.select('li a[href*="act=play"]')
logger.info(f'Category {tid} ({cat_name}): found {len(links)} channels')
for order, a_tag in enumerate(links):
href = a_tag.get('href', '')
name = a_tag.text.strip()
if not name or not href:
continue
# Parse query params from href like ?act=play&token=abc&tid=ys&id=1
token_match = re.search(r'token=([^&]+)', href)
id_match = re.search(r'id=(\d+)', href)
if not token_match or not id_match:
continue
token = token_match.group(1)
source_id = int(id_match.group(1))
obj, created = LiveTVChannel.objects.update_or_create(
category=tid,
source_id=source_id,
defaults={
'name': name,
'category_name': cat_name,
'source_token': token,
'order': order,
'last_synced': timezone.now(),
'is_active': True,
},
)
if created:
total_new += 1
else:
total_updated += 1
except Exception as e:
logger.error(f'Error syncing category {tid}: {e}', exc_info=True)
# Mark channels not seen in this sync as inactive
# (channels removed from source will have old last_synced)
cutoff = timezone.now() - timezone.timedelta(minutes=10)
deactivated = LiveTVChannel.objects.filter(
is_active=True,
last_synced__lt=cutoff,
).update(is_active=False)
logger.info(
f'LiveTV sync complete: {total_new} new, {total_updated} updated, {deactivated} deactivated'
)
return {'new': total_new, 'updated': total_updated, 'deactivated': deactivated}
def start_sync_in_background():
"""Launch sync_livetv_channels in a daemon thread. Returns immediately."""
thread = Thread(target=sync_livetv_channels, daemon=True)
thread.start()
return thread
async def _resolve_stream_url_async(play_url: str, timeout: int = STREAM_RESOLVE_TIMEOUT) -> str | None:
"""
Use Playwright to visit the channel play page, intercept the first .m3u8 network request,
and return the stream URL. Returns None if not found within timeout.
"""
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError
captured_url = None
async def _run():
nonlocal captured_url
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
)
context = await browser.new_context(
viewport={'width': 1024, 'height': 768},
user_agent=HEADERS['User-Agent'],
extra_http_headers={'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'},
ignore_https_errors=True,
)
page = await context.new_page()
# Intercept network requests to capture the .m3u8 stream URL
stream_event = asyncio.Event()
async def handle_request(request):
nonlocal captured_url
url = request.url
if '.m3u8' in url and not stream_event.is_set():
# Strip session/sign params to get the base URL, keep them for current use
captured_url = url
stream_event.set()
page.on('request', handle_request)
try:
await page.goto(play_url, wait_until='domcontentloaded', timeout=15000)
# Wait for the .m3u8 request to appear (up to timeout)
try:
await asyncio.wait_for(stream_event.wait(), timeout=timeout - 5)
except asyncio.TimeoutError:
logger.warning(f'No .m3u8 request captured for {play_url}')
except PlaywrightTimeoutError:
logger.warning(f'Page load timed out for {play_url}')
except Exception as e:
logger.error(f'Error visiting {play_url}: {e}')
finally:
await context.close()
await browser.close()
try:
await asyncio.wait_for(_run(), timeout=timeout)
except asyncio.TimeoutError:
logger.warning(f'Stream URL resolve timed out for {play_url}')
return captured_url
def resolve_stream_url(channel_id: int) -> str | None:
"""
Resolve and cache the stream URL for a channel.
Uses Playwright to visit the play page and intercept the .m3u8 network request.
Stores the result in the DB. Returns the URL or None.
"""
from links.models import LiveTVChannel
try:
channel = LiveTVChannel.objects.get(id=channel_id)
except LiveTVChannel.DoesNotExist:
logger.error(f'LiveTVChannel {channel_id} not found')
return None
play_url = channel.get_play_url()
logger.info(f'Resolving stream URL for channel {channel_id}: {channel.name}')
stream_url = asyncio.run(_resolve_stream_url_async(play_url))
if stream_url:
channel.stream_url = stream_url
channel.stream_url_cached_at = timezone.now()
channel.is_valid = True
channel.save(update_fields=['stream_url', 'stream_url_cached_at', 'is_valid'])
logger.info(f'Cached stream URL for {channel.name}: {stream_url[:80]}...')
else:
logger.warning(f'Could not resolve stream URL for {channel.name}')
return stream_url
def check_stream_validity(channel_id: int) -> bool | None:
"""
Check if a channel's cached stream URL is still accessible via HTTP HEAD.
Updates is_valid in the DB. Returns True/False/None.
"""
from links.models import LiveTVChannel
try:
channel = LiveTVChannel.objects.get(id=channel_id)
except LiveTVChannel.DoesNotExist:
return None
if not channel.stream_url:
channel.is_valid = None
channel.save(update_fields=['is_valid'])
return None
try:
resp = requests.head(
channel.stream_url,
headers=HEADERS,
timeout=8,
allow_redirects=True,
)
valid = resp.status_code < 400
except requests.RequestException:
valid = False
channel.is_valid = valid
channel.save(update_fields=['is_valid'])
logger.info(f'Channel {channel.name} validity: {valid}')
return valid
def check_all_stream_validity():
"""Check validity for all active channels that have cached stream URLs."""
from links.models import LiveTVChannel
channels = LiveTVChannel.objects.filter(is_active=True, stream_url__gt='')
logger.info(f'Checking stream validity for {channels.count()} channels...')
valid_count = 0
invalid_count = 0
for channel in channels:
result = check_stream_validity(channel.id)
if result is True:
valid_count += 1
elif result is False:
invalid_count += 1
logger.info(f'Validity check complete: {valid_count} valid, {invalid_count} invalid')
+18
View File
@@ -0,0 +1,18 @@
from django.urls import path
from links.livetv_views import (
LiveTVListView,
livetv_sync_view,
livetv_stream_view,
livetv_stream_url_api,
livetv_m3u_view,
)
app_name = 'livetv'
urlpatterns = [
path('', LiveTVListView.as_view(), name='list'),
path('sync/', livetv_sync_view, name='sync'),
path('stream/<int:channel_id>/', livetv_stream_view, name='stream'),
path('stream/<int:channel_id>/url/', livetv_stream_url_api, name='stream_url'),
path('playlist.m3u', livetv_m3u_view, name='m3u'),
]
+157
View File
@@ -0,0 +1,157 @@
import logging
import json
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse, JsonResponse, Http404
from django.views import View
from django.views.generic import TemplateView
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.contrib import messages
from links.models import LiveTVChannel
from links.livetv_tasks import (
start_sync_in_background,
resolve_stream_url,
CATEGORIES,
)
logger = logging.getLogger(__name__)
CATEGORY_MAP = dict(CATEGORIES)
class LiveTVListView(TemplateView):
template_name = 'links/livetv/list.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
selected_category = self.request.GET.get('category', '')
channels_qs = LiveTVChannel.objects.filter(is_active=True)
if selected_category:
channels_qs = channels_qs.filter(category=selected_category)
# Build category counts
from django.db.models import Count
cat_counts = (
LiveTVChannel.objects.filter(is_active=True)
.values('category', 'category_name')
.annotate(count=Count('id'))
.order_by('category')
)
# Sort by CATEGORIES order
cat_order = {tid: i for i, (tid, _) in enumerate(CATEGORIES)}
categories_with_counts = sorted(
cat_counts, key=lambda x: cat_order.get(x['category'], 99)
)
# Group channels by category for display
grouped = {}
for ch in channels_qs.select_related():
grouped.setdefault(ch.category, {'name': ch.category_name, 'channels': []})
grouped[ch.category]['channels'].append(ch)
# Sort groups by CATEGORIES order
grouped_sorted = sorted(grouped.items(), key=lambda x: cat_order.get(x[0], 99))
# Last sync time
latest = LiveTVChannel.objects.filter(last_synced__isnull=False).order_by('-last_synced').first()
last_sync = latest.last_synced if latest else None
ctx.update({
'categories': categories_with_counts,
'selected_category': selected_category,
'grouped_channels': grouped_sorted,
'total_count': LiveTVChannel.objects.filter(is_active=True).count(),
'last_sync': last_sync,
'category_map': CATEGORY_MAP,
})
return ctx
def livetv_sync_view(request):
"""POST: trigger background channel sync. Returns JSON."""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
logger.info('Manual LiveTV sync triggered by user')
start_sync_in_background()
return JsonResponse({
'status': 'started',
'message': _('Sync started in background. Refresh the page in a moment to see updated channels.'),
})
def livetv_stream_view(request, channel_id):
"""
GET: resolve a channel's stream URL and redirect to it.
Checks cache first (1-hour TTL), else uses Playwright to resolve.
Used both for in-browser playback and as .m3u stream target.
"""
channel = get_object_or_404(LiveTVChannel, id=channel_id, is_active=True)
if channel.is_stream_url_fresh():
stream_url = channel.stream_url
logger.debug(f'Using cached stream URL for channel {channel_id}')
else:
# Resolve in-process (blocking) — necessary for .m3u clients
stream_url = resolve_stream_url(channel_id)
if not stream_url:
raise Http404('Stream URL could not be resolved for this channel.')
from django.http import HttpResponseRedirect
return HttpResponseRedirect(stream_url)
def livetv_stream_url_api(request, channel_id):
"""
GET: return channel stream URL as JSON (used by the in-browser player via AJAX).
Resolves on-demand with caching.
"""
channel = get_object_or_404(LiveTVChannel, id=channel_id, is_active=True)
if channel.is_stream_url_fresh():
stream_url = channel.stream_url
else:
stream_url = resolve_stream_url(channel_id)
if stream_url:
return JsonResponse({'url': stream_url, 'name': channel.name})
return JsonResponse({'error': 'Could not resolve stream URL'}, status=503)
def livetv_m3u_view(request):
"""
GET: serve M3U playlist with all active channels.
Stream URLs point to this app's proxy endpoint /livetv/stream/<id>/.
Category grouping via group-title in #EXTINF.
Optional ?category= filter.
"""
category_filter = request.GET.get('category', '')
channels_qs = LiveTVChannel.objects.filter(is_active=True)
if category_filter:
channels_qs = channels_qs.filter(category=category_filter)
# Sort by category order then name
cat_order = {tid: i for i, (tid, _) in enumerate(CATEGORIES)}
channels = sorted(channels_qs, key=lambda c: (cat_order.get(c.category, 99), c.order, c.name))
base_url = request.build_absolute_uri('/').rstrip('/')
lines = ['#EXTM3U']
for ch in channels:
stream_proxy_url = f'{base_url}/ui/livetv/stream/{ch.id}/'
lines.append(
f'#EXTINF:-1 tvg-id="{ch.category}-{ch.source_id}" '
f'tvg-name="{ch.name}" '
f'group-title="{ch.category_name}",{ch.name}'
)
lines.append(stream_proxy_url)
content = '\n'.join(lines) + '\n'
response = HttpResponse(content, content_type='application/vnd.apple.mpegurl')
response['Content-Disposition'] = 'attachment; filename="livetv_channels.m3u"'
return response
@@ -0,0 +1,38 @@
# Generated by Django 5.2.12 on 2026-04-15 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0048_remove_knowledge_graph'),
]
operations = [
migrations.CreateModel(
name='LiveTVChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Name')),
('category', models.CharField(choices=[('itv', '綜合'), ('ty', '體育'), ('ys', '央視'), ('ws', '衛視'), ('gt', '港澳台'), ('other', '其他'), ('movie', '電影'), ('migu', '咪咕視頻'), ('fjitv', '福建移動IPTV'), ('hlitv', '黑龍江移動IPTV'), ('ipv6', 'IPv6網絡電視')], max_length=50, verbose_name='Category')),
('category_name', models.CharField(max_length=100, verbose_name='Category Name')),
('source_id', models.IntegerField(verbose_name='Source ID')),
('source_token', models.CharField(max_length=200, verbose_name='Source Token')),
('stream_url', models.TextField(blank=True, default='', verbose_name='Stream URL')),
('stream_url_cached_at', models.DateTimeField(blank=True, null=True, verbose_name='Stream URL Cached At')),
('is_active', models.BooleanField(default=True, verbose_name='Active')),
('is_valid', models.BooleanField(blank=True, null=True, verbose_name='Valid')),
('last_synced', models.DateTimeField(blank=True, null=True, verbose_name='Last Synced')),
('order', models.IntegerField(default=0, verbose_name='Order')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
],
options={
'verbose_name': 'Live TV Channel',
'verbose_name_plural': 'Live TV Channels',
'ordering': ['category', 'order', 'name'],
'unique_together': {('category', 'source_id')},
},
),
]
+48
View File
@@ -468,3 +468,51 @@ class SiteSettings(models.Model):
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
class LiveTVChannel(models.Model):
CATEGORY_CHOICES = [
('itv', '綜合'),
('ty', '體育'),
('ys', '央視'),
('ws', '衛視'),
('gt', '港澳台'),
('other', '其他'),
('movie', '電影'),
('migu', '咪咕視頻'),
('fjitv', '福建移動IPTV'),
('hlitv', '黑龍江移動IPTV'),
('ipv6', 'IPv6網絡電視'),
]
name = models.CharField(_('Name'), max_length=200)
category = models.CharField(_('Category'), max_length=50, choices=CATEGORY_CHOICES)
category_name = models.CharField(_('Category Name'), max_length=100)
source_id = models.IntegerField(_('Source ID'))
source_token = models.CharField(_('Source Token'), max_length=200)
stream_url = models.TextField(_('Stream URL'), blank=True, default='')
stream_url_cached_at = models.DateTimeField(_('Stream URL Cached At'), null=True, blank=True)
is_active = models.BooleanField(_('Active'), default=True)
is_valid = models.BooleanField(_('Valid'), null=True, blank=True)
last_synced = models.DateTimeField(_('Last Synced'), null=True, blank=True)
order = models.IntegerField(_('Order'), default=0)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated At'), auto_now=True)
class Meta:
verbose_name = _('Live TV Channel')
verbose_name_plural = _('Live TV Channels')
unique_together = [('category', 'source_id')]
ordering = ['category', 'order', 'name']
def __str__(self):
return f'{self.category_name} - {self.name}'
def get_play_url(self):
return f'https://iptv345.com/?act=play&token={self.source_token}&tid={self.category}&id={self.source_id}'
def is_stream_url_fresh(self):
if not self.stream_url or not self.stream_url_cached_at:
return False
age = timezone.now() - self.stream_url_cached_at
return age.total_seconds() < 3600 # 1-hour TTL
+2
View File
@@ -74,6 +74,8 @@ urlpatterns = [
path('', include('links.collection_urls')),
# Include tag URLs
path('', include('links.tag_urls')),
# Live TV
path('ui/livetv/', include('links.livetv_urls')),
# Aliases - these should always be last
path('<str:alias>/', views.redirect_to_original, name='redirect_to_original'),
+10
View File
@@ -161,6 +161,16 @@
</div>
</a>
<a href="{% url 'livetv:list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
{% trans "Live TV" %}
</div>
</a>
<a href="{% url 'mini-apps-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+220
View File
@@ -0,0 +1,220 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Live TV" %}{% endblock %}
{% block content %}
<div
x-data="{
selectedChannel: null,
playerVisible: false,
iframeUrl: '',
iframeTitle: '',
syncing: false,
syncMsg: '',
syncError: false,
activeCategory: '{{ selected_category }}',
selectChannel(channel) {
this.selectedChannel = channel.id;
this.iframeUrl = channel.playUrl;
this.iframeTitle = channel.name;
this.playerVisible = true;
},
closePlayer() {
this.playerVisible = false;
this.iframeUrl = '';
this.selectedChannel = null;
},
async triggerSync() {
this.syncing = true;
this.syncMsg = '';
this.syncError = false;
try {
const resp = await fetch('{% url "livetv:sync" %}', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json',
},
});
const data = await resp.json();
this.syncMsg = data.message || data.status;
this.syncError = !resp.ok;
} catch (e) {
this.syncMsg = 'Error: ' + e.message;
this.syncError = true;
} finally {
this.syncing = false;
}
}
}"
class="min-h-screen bg-gray-50"
>
<!-- Header Bar -->
<div class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-3">
<svg class="w-7 h-7 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<h1 class="text-2xl font-bold text-gray-800">{% trans "Live TV" %}</h1>
<span class="text-sm text-gray-500">{{ total_count }} {% trans "channels" %}</span>
</div>
<div class="flex items-center gap-3 flex-wrap">
<!-- Last sync info -->
{% if last_sync %}
<span class="text-xs text-gray-400">
{% trans "Last sync:" %} {{ last_sync|date:"Y-m-d H:i" }}
</span>
{% endif %}
<!-- M3U download link -->
<a href="{% url 'livetv:m3u' %}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
playlist.m3u
</a>
<!-- Sync button -->
<button
@click="triggerSync()"
:disabled="syncing"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
<svg class="w-4 h-4" :class="{ 'animate-spin': syncing }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="syncing ? '{% trans "Syncing..." %}' : '{% trans "Sync Channels" %}'"></span>
</button>
</div>
</div>
<!-- Sync message feedback -->
<div x-show="syncMsg" x-cloak
:class="syncError ? 'bg-red-50 border-red-200 text-red-700' : 'bg-blue-50 border-blue-200 text-blue-700'"
class="mt-3 px-3 py-2 text-sm border rounded-lg">
<span x-text="syncMsg"></span>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 py-4">
<!-- Category filter tabs -->
<div class="flex gap-2 flex-wrap mb-5">
<a href="{% url 'livetv:list' %}"
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors {% if not selected_category %}bg-blue-600 text-white{% else %}bg-white text-gray-600 border border-gray-200 hover:bg-gray-50{% endif %}">
{% trans "All" %} ({{ total_count }})
</a>
{% for cat in categories %}
<a href="{% url 'livetv:list' %}?category={{ cat.category }}"
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors {% if selected_category == cat.category %}bg-blue-600 text-white{% else %}bg-white text-gray-600 border border-gray-200 hover:bg-gray-50{% endif %}">
{{ cat.category_name }} ({{ cat.count }})
</a>
{% endfor %}
</div>
{% if not total_count %}
<!-- Empty state -->
<div class="text-center py-20">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<h3 class="text-lg font-semibold text-gray-500 mb-2">{% trans "No channels yet" %}</h3>
<p class="text-gray-400 mb-6">{% trans "Click \"Sync Channels\" to fetch channels from the source." %}</p>
<button
@click="triggerSync()"
:disabled="syncing"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-60 transition-colors"
>
<svg class="w-5 h-5" :class="{ 'animate-spin': syncing }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{% trans "Sync Channels Now" %}
</button>
</div>
{% else %}
<!-- Channel groups -->
{% for cat_id, group in grouped_channels %}
<div class="mb-8">
<h2 class="text-base font-semibold text-gray-700 mb-3 flex items-center gap-2">
<span class="w-1 h-5 bg-blue-500 rounded-full inline-block"></span>
{{ group.name }}
<span class="text-xs text-gray-400 font-normal">({{ group.channels|length }})</span>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
{% for channel in group.channels %}
<button
@click="selectChannel({
id: {{ channel.id }},
name: '{{ channel.name|escapejs }}',
playUrl: '{{ channel.get_play_url|escapejs }}'
})"
:class="selectedChannel === {{ channel.id }} ? 'ring-2 ring-blue-500 bg-blue-50 text-blue-700' : 'bg-white text-gray-700 hover:bg-gray-50'"
class="relative text-left px-3 py-2.5 rounded-lg border border-gray-200 text-sm font-medium transition-all shadow-sm group"
title="{{ channel.name }}"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-red-400 animate-pulse flex-shrink-0"></span>
<span class="truncate">{{ channel.name }}</span>
</div>
</button>
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
<!-- Player Modal (fixed overlay) -->
<div x-show="playerVisible" x-cloak
class="fixed inset-0 z-50 flex flex-col bg-black"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100">
<!-- Player header -->
<div class="flex items-center justify-between px-4 py-3 bg-gray-900 text-white flex-shrink-0">
<div class="flex items-center gap-3">
<span class="w-2.5 h-2.5 bg-red-500 rounded-full animate-pulse"></span>
<span class="font-semibold text-base" x-text="iframeTitle"></span>
<span class="text-xs text-gray-400">{% trans "LIVE" %}</span>
</div>
<div class="flex items-center gap-3">
<a :href="'{% url "livetv:m3u" %}'"
class="text-xs text-gray-400 hover:text-white transition-colors">
playlist.m3u
</a>
<button @click="closePlayer()" class="text-gray-400 hover:text-white transition-colors p-1">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Iframe player -->
<div class="flex-1 relative">
<iframe
:src="iframeUrl"
class="w-full h-full border-0"
allow="autoplay; fullscreen; encrypted-media"
allowfullscreen
scrolling="no"
></iframe>
</div>
</div>
</div>
{% endblock %}