mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Add live tv feature by leveraging https://iptv345.com/
This commit is contained in:
@@ -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)")
|
||||
|
||||
@@ -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')
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user