diff --git a/core/apps.py b/core/apps.py index ea6bcce..34d4ac1 100644 --- a/core/apps.py +++ b/core/apps.py @@ -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)") diff --git a/links/livetv_tasks.py b/links/livetv_tasks.py new file mode 100644 index 0000000..fa240f3 --- /dev/null +++ b/links/livetv_tasks.py @@ -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') diff --git a/links/livetv_urls.py b/links/livetv_urls.py new file mode 100644 index 0000000..a365458 --- /dev/null +++ b/links/livetv_urls.py @@ -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//', livetv_stream_view, name='stream'), + path('stream//url/', livetv_stream_url_api, name='stream_url'), + path('playlist.m3u', livetv_m3u_view, name='m3u'), +] diff --git a/links/livetv_views.py b/links/livetv_views.py new file mode 100644 index 0000000..3b6d9bb --- /dev/null +++ b/links/livetv_views.py @@ -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//. + 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 diff --git a/links/migrations/0049_add_livetvchannel.py b/links/migrations/0049_add_livetvchannel.py new file mode 100644 index 0000000..7fa141c --- /dev/null +++ b/links/migrations/0049_add_livetvchannel.py @@ -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')}, + }, + ), + ] diff --git a/links/models.py b/links/models.py index f2b6a7f..a186296 100644 --- a/links/models.py +++ b/links/models.py @@ -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 diff --git a/links/urls.py b/links/urls.py index a499227..4be3985 100644 --- a/links/urls.py +++ b/links/urls.py @@ -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('/', views.redirect_to_original, name='redirect_to_original'), diff --git a/templates/base.html b/templates/base.html index f081645..8394900 100644 --- a/templates/base.html +++ b/templates/base.html @@ -161,6 +161,16 @@ + +
+ + + + {% trans "Live TV" %} +
+
+
diff --git a/templates/links/livetv/list.html b/templates/links/livetv/list.html new file mode 100644 index 0000000..7c61982 --- /dev/null +++ b/templates/links/livetv/list.html @@ -0,0 +1,220 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}{% trans "Live TV" %}{% endblock %} + +{% block content %} +
+ + + +
+ + + + {% if not total_count %} + +
+ + + +

{% trans "No channels yet" %}

+

{% trans "Click \"Sync Channels\" to fetch channels from the source." %}

+ +
+ {% else %} + + + {% for cat_id, group in grouped_channels %} +
+

+ + {{ group.name }} + ({{ group.channels|length }}) +

+
+ {% for channel in group.channels %} + + {% endfor %} +
+
+ {% endfor %} + {% endif %} +
+ + +
+ + +
+
+ + + {% trans "LIVE" %} +
+
+ + playlist.m3u + + +
+
+ + +
+ +
+
+ +
+{% endblock %}