Remove iptv related code!

This commit is contained in:
2026-01-18 10:45:06 +11:00
parent edf4f66d26
commit fca5fa05e3
13 changed files with 22 additions and 844 deletions
+1 -2
View File
@@ -31,7 +31,7 @@ This guide is designed to help AI coding agents understand and work with the URL
│ Django Application │
│ ┌──────────────┬──────────────┬──────────────────────┐ │
│ │ Links Module │ Pages Module │ Collections Module │ │
│ │ │ │ (Images, IPTV, Posts)│ │
│ │ │ │ (Images, Posts) │ │
│ └──────────────┴──────────────┴──────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ REST API (DRF ViewSets) │ │
@@ -512,7 +512,6 @@ links/
├── api_views.py # REST API viewsets (Image, Music)
├── page_views.py # Page-specific views and API
├── post_views.py # Post/blog views and API
├── iptv_views.py # IPTV functionality
├── collection_views.py # Collection management
├── search_views.py # Search functionality
├── tag_views.py # Tag management
BIN
View File
Binary file not shown.
+2 -10
View File
@@ -1,5 +1,5 @@
from django import forms
from .models import Link, Page, Post, ImageCollection, Image, IPTVChannel, Tag
from .models import Link, Page, Post, ImageCollection, Image, Tag
from simplemde.fields import SimpleMDEField
from django.utils.translation import gettext_lazy as _
from django.core.validators import URLValidator
@@ -118,12 +118,4 @@ class TagForm(forms.ModelForm):
'description': forms.Textarea(attrs={'rows': 3, 'class': 'mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'}),
}
class IPTVChannelForm(forms.ModelForm):
class Meta:
model = IPTVChannel
fields = ['title', 'url', 'collection']
widgets = {
'title': forms.TextInput(attrs={'class': 'mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'}),
'url': forms.URLInput(attrs={'class': 'mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'}),
'collection': forms.Select(attrs={'class': 'mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'}),
}
-23
View File
@@ -1,23 +0,0 @@
from django.urls import path
from .iptv_views import (
IPTVChannelListView,
IPTVChannelCreateView,
IPTVChannelUpdateView,
IPTVChannelDeleteView,
IPTVCollectionDeleteView,
export_m3u_playlist,
import_m3u_playlist,
)
urlpatterns = [
# UI routes
path('ui/iptv/', IPTVChannelListView.as_view(), name='iptv-list'),
path('ui/iptv/create/', IPTVChannelCreateView.as_view(), name='iptv-create'),
path('ui/iptv/<int:pk>/edit/', IPTVChannelUpdateView.as_view(), name='iptv-update'),
path('ui/iptv/<int:pk>/delete/', IPTVChannelDeleteView.as_view(), name='iptv-delete'),
path('ui/iptv/collection/<int:pk>/delete/', IPTVCollectionDeleteView.as_view(), name='iptv-collection-delete'),
# API routes
path('api/iptv/export/', export_m3u_playlist, name='iptv-export'),
path('api/iptv/import/', import_m3u_playlist, name='iptv-import'),
]
-112
View File
@@ -1,112 +0,0 @@
from django.urls import reverse_lazy
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from django.shortcuts import render, redirect
from django.http import HttpResponse
from django.contrib import messages
from .models import IPTVChannel, IPTVCollection
from .forms import IPTVChannelForm
class IPTVChannelListView(ListView):
model = IPTVChannel
template_name = 'links/iptv/list.html'
context_object_name = 'channels'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['collections'] = IPTVCollection.objects.all()
return context
def get_queryset(self):
collection_id = self.request.GET.get('collection')
if collection_id:
return IPTVChannel.objects.filter(collection_id=collection_id)
return IPTVChannel.objects.all()
class IPTVChannelCreateView(CreateView):
model = IPTVChannel
form_class = IPTVChannelForm
template_name = 'links/iptv/form.html'
success_url = reverse_lazy('iptv-list')
class IPTVChannelUpdateView(UpdateView):
model = IPTVChannel
form_class = IPTVChannelForm
template_name = 'links/iptv/form.html'
success_url = reverse_lazy('iptv-list')
class IPTVChannelDeleteView(DeleteView):
model = IPTVChannel
template_name = 'links/iptv/confirm_delete.html'
success_url = reverse_lazy('iptv-list')
def export_m3u_playlist(request):
"""Export all channels as M3U playlist"""
collection_id = request.GET.get('collection')
if collection_id:
channels = IPTVChannel.objects.filter(collection_id=collection_id)
collection = IPTVCollection.objects.get(id=collection_id)
filename = f"{collection.title}_channels.m3u"
else:
channels = IPTVChannel.objects.all()
filename = "all_channels.m3u"
content = ['#EXTM3U']
for channel in channels:
content.append(f'#EXTINF:-1 tvg-id="{channel.id}",{channel.title}')
content.append(channel.url)
playlist_content = '\n'.join(content)
response = HttpResponse(playlist_content, content_type='audio/x-mpegurl')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
def import_m3u_playlist(request):
"""Import channels from M3U playlist file"""
if request.method == 'POST' and request.FILES.get('playlist'):
collection_title = request.POST.get('collection_title')
if not collection_title:
messages.error(request, 'Collection title is required')
return redirect('iptv-list')
collection = IPTVCollection.objects.create(title=collection_title)
playlist_file = request.FILES['playlist']
try:
content = playlist_file.read().decode('utf-8')
lines = content.split('\n')
current_title = None
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith('#EXTINF'):
# Extract title from EXTINF line
title_part = line.split(',', 1)
if len(title_part) > 1:
current_title = title_part[1]
else:
current_title = "Untitled Channel"
elif not line.startswith('#') and current_title:
# Create channel with URL and previous title
IPTVChannel.objects.create(
title=current_title,
url=line,
collection=collection
)
current_title = None
messages.success(request, f'Successfully imported channels to collection: {collection_title}')
except Exception as e:
collection.delete()
messages.error(request, f'Error importing playlist: {str(e)}')
return redirect('iptv-list')
return redirect('iptv-list')
class IPTVCollectionDeleteView(DeleteView):
model = IPTVCollection
template_name = 'links/iptv/confirm_delete_collection.html'
success_url = reverse_lazy('iptv-list')
@@ -0,0 +1,19 @@
# Generated by Django 5.2.9 on 2026-01-17 23:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('links', '0037_linkchangelog_change_type_linkchangelog_metadata_and_more'),
]
operations = [
migrations.DeleteModel(
name='IPTVChannel',
),
migrations.DeleteModel(
name='IPTVCollection',
),
]
-22
View File
@@ -339,26 +339,4 @@ class Image(models.Model):
fit='cover'
)
class IPTVCollection(models.Model):
title = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class Meta:
ordering = ['-created_at']
class IPTVChannel(models.Model):
title = models.CharField(max_length=200)
url = models.URLField()
collection = models.ForeignKey(IPTVCollection, on_delete=models.CASCADE, related_name='channels', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class Meta:
ordering = ['title']
@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Delete IPTV Channel
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>Are you sure you want to delete "{{ object.title }}"? This action cannot be undone.</p>
</div>
<div class="mt-5">
<form method="post">
{% csrf_token %}
<div class="flex justify-end space-x-3">
<a href="{% url 'iptv-list' %}" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</a>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Delete
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Delete Collection
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>Are you sure you want to delete the collection "{{ object.title }}"? This will also delete all channels in this collection.</p>
</div>
<div class="mt-5">
<form method="post">
{% csrf_token %}
<div class="space-x-4">
<button type="submit" class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm">
Delete Collection
</button>
<a href="{% url 'iptv-list' %}" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 shadow-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
-57
View File
@@ -1,57 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{% if form.instance.pk %}Edit{% else %}Add{% endif %} IPTV Channel
</h3>
<div class="mt-5">
<form method="post" class="space-y-6">
{% csrf_token %}
<div>
<label for="{{ form.title.id_for_label }}" class="block text-sm font-medium text-gray-700">
Channel Title
</label>
{{ form.title }}
{% if form.title.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.title.errors.0 }}</p>
{% endif %}
</div>
<div>
<label for="{{ form.url.id_for_label }}" class="block text-sm font-medium text-gray-700">
Stream URL
</label>
{{ form.url }}
{% if form.url.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.url.errors.0 }}</p>
{% endif %}
</div>
<div>
<label for="{{ form.collection.id_for_label }}" class="block text-sm font-medium text-gray-700">
Collection (Optional)
</label>
{{ form.collection }}
{% if form.collection.errors %}
<p class="mt-2 text-sm text-red-600">{{ form.collection.errors.0 }}</p>
{% endif %}
</div>
<div class="flex justify-end space-x-4">
<a href="{% url 'iptv-list' %}" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</a>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
-549
View File
@@ -1,549 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="flex flex-col h-screen md:h-[calc(100vh-4rem)] md:flex-row">
<!-- Sidebar - full width on mobile, w-80 on desktop -->
<div class="w-full md:w-80 border-b md:border-r border-gray-200 bg-white overflow-y-auto md:h-full">
<div class="p-4 border-b border-gray-200 flex justify-between items-center sticky top-0 bg-white z-10">
<div class="flex items-center space-x-4">
<h2 class="text-lg font-medium text-gray-900">IPTV Channels</h2>
<a href="{% url 'iptv-export' %}{% if request.GET.collection %}?collection={{ request.GET.collection }}{% endif %}" class="text-gray-400 hover:text-gray-600" title="Export M3U Playlist">
<svg class="h-5 w-5" 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>
</a>
</div>
<div class="flex items-center space-x-4">
<button onclick="showImportModal()" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Import M3U
</button>
<button onclick="showAddModal()" class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Add Channel
</button>
</div>
</div>
<!-- Collections List -->
<div class="p-4 border-b border-gray-200">
<div class="flex items-center space-x-4 overflow-x-auto">
<a href="{% url 'iptv-list' %}" class="px-3 py-1 rounded-full {% if not request.GET.collection %}bg-indigo-100 text-indigo-800{% else %}bg-gray-100 text-gray-800{% endif %} text-sm font-medium whitespace-nowrap">
All Channels
</a>
{% for collection in collections %}
<div class="flex items-center space-x-2">
<a href="{% url 'iptv-list' %}?collection={{ collection.id }}" class="px-3 py-1 rounded-full {% if request.GET.collection == collection.id|stringformat:'i' %}bg-indigo-100 text-indigo-800{% else %}bg-gray-100 text-gray-800{% endif %} text-sm font-medium whitespace-nowrap">
{{ collection.title }}
</a>
<a href="{% url 'iptv-collection-delete' collection.id %}" class="text-gray-400 hover:text-red-600">
<svg class="h-4 w-4" 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>
</a>
</div>
{% endfor %}
</div>
</div>
<!-- Channel List -->
<div class="divide-y divide-gray-200">
{% for channel in channels %}
<div class="p-4 hover:bg-gray-50 flex justify-between items-center channel-item" data-url="{{ channel.url }}" data-title="{{ channel.title }}">
<div class="flex-1 cursor-pointer" onclick="playChannel(this)">
<h3 class="text-sm font-medium text-gray-900">{{ channel.title }}</h3>
</div>
<div class="flex items-center space-x-4">
<a href="{% url 'iptv-update' channel.id %}" class="text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<a href="{% url 'iptv-delete' channel.id %}" class="text-gray-400 hover:text-red-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</a>
</div>
</div>
{% endfor %}
</div>
<!-- Import Modal -->
<div id="importModal" class="fixed z-10 inset-0 overflow-y-auto hidden">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form action="{% url 'iptv-import' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Import M3U Playlist
</h3>
<div class="mt-4">
<label for="collection_title" class="block text-sm font-medium text-gray-700">
Collection Title
</label>
<input type="text" name="collection_title" id="collection_title" required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div class="mt-4">
<label for="playlist" class="block text-sm font-medium text-gray-700">
M3U File
</label>
<input type="file" name="playlist" id="playlist" required accept=".m3u,.m3u8"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="submit" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm">
Import
</button>
<button type="button" onclick="hideImportModal()" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add/Edit Channel Modal -->
<div id="channelModal" class="fixed inset-0 bg-gray-500 bg-opacity-75 hidden" role="dialog" aria-modal="true">
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div class="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button type="button" onclick="hideChannelModal()" class="rounded-md bg-white text-gray-400 hover:text-gray-500">
<span class="sr-only">Close</span>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full">
<h3 class="text-base font-semibold leading-6 text-gray-900" id="modalTitle">Add Channel</h3>
<div class="mt-4">
<form id="channelForm" class="space-y-4" method="POST" onsubmit="event.preventDefault(); submitChannelForm();">
{% csrf_token %}
<input type="hidden" id="channelId" name="id">
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<input type="text" name="title" id="title" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="url" class="block text-sm font-medium text-gray-700">URL</label>
<input type="url" name="url" id="url" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="collection" class="block text-sm font-medium text-gray-700">Collection (Optional)</label>
<select name="collection" id="collection" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="">No Collection</option>
{% for collection in collections %}
<option value="{{ collection.id }}">{{ collection.title }}</option>
{% endfor %}
</select>
</div>
</form>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="button" onclick="submitChannelForm()" class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 sm:ml-3 sm:w-auto">Save</button>
<button type="button" onclick="hideChannelModal()" class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto">Cancel</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="fixed inset-0 bg-gray-500 bg-opacity-75 hidden" role="dialog" aria-modal="true">
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold leading-6 text-gray-900">Delete Channel</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">Are you sure you want to delete "<span id="deleteChannelTitle"></span>"? This action cannot be undone.</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="button" onclick="confirmDelete()" class="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto">Delete</button>
<button type="button" onclick="hideDeleteModal()" class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main content - full height on mobile -->
<div class="flex-1 flex flex-col bg-gray-50 min-h-[50vh] md:min-h-0">
<div class="flex-1 flex items-center justify-center relative w-full h-full" id="player-container">
<div class="text-center p-4" id="empty-state">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<h3 class="mt-2 text-base font-medium text-gray-900">No channel selected</h3>
<p class="mt-1 text-sm text-gray-500">Select a channel from the list to start watching</p>
</div>
<!-- Play overlay -->
<div id="play-overlay" class="hidden absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center cursor-pointer">
<div class="text-center text-white p-4">
<button onclick="playVideo()" class="bg-white bg-opacity-20 rounded-full p-6 hover:bg-opacity-30 transition-all touch-manipulation">
<svg class="h-16 w-16 md:h-20 md:w-20" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<h3 class="mt-4 text-xl md:text-2xl font-medium" id="channel-title"></h3>
</div>
</div>
<video id="video-player" class="hidden w-full h-full object-contain" controls playsinline></video>
</div>
</div>
</div>
<script>
function showImportModal() {
document.getElementById('importModal').classList.remove('hidden');
}
function hideImportModal() {
document.getElementById('importModal').classList.add('hidden');
}
</script>
<script>
function showAddModal() {
lastFocusedElement = document.activeElement;
document.getElementById('modalTitle').textContent = 'Add Channel';
document.getElementById('channelId').value = '';
document.getElementById('channelForm').reset();
const modal = document.getElementById('channelModal');
modal.classList.remove('hidden');
document.getElementById('title').focus();
}
function showEditModal(id, title, url) {
lastFocusedElement = document.activeElement;
document.getElementById('modalTitle').textContent = 'Edit Channel';
document.getElementById('channelId').value = id;
document.getElementById('title').value = title;
document.getElementById('url').value = url;
const modal = document.getElementById('channelModal');
modal.classList.remove('hidden');
document.getElementById('title').focus();
}
function hideChannelModal() {
document.getElementById('channelModal').classList.add('hidden');
if (lastFocusedElement) {
lastFocusedElement.focus();
}
}
function showDeleteModal(id, title) {
lastFocusedElement = document.activeElement;
currentDeleteId = id;
document.getElementById('deleteChannelTitle').textContent = title;
document.getElementById('deleteModal').classList.remove('hidden');
}
function hideDeleteModal() {
currentDeleteId = null;
document.getElementById('deleteModal').classList.add('hidden');
if (lastFocusedElement) {
lastFocusedElement.focus();
}
}
async function submitChannelForm() {
const form = document.getElementById('channelForm');
const formData = new FormData(form);
const id = formData.get('id');
const url = id ? `/ui/iptv/${id}/edit/` : '/ui/iptv/create/';
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
},
body: formData,
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
hideChannelModal();
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert('Error saving channel. Please try again.');
}
}
async function confirmDelete() {
if (!currentDeleteId) return;
try {
const response = await fetch(`/ui/iptv/${currentDeleteId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
},
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
hideDeleteModal();
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert('Error deleting channel. Please try again.');
}
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
</script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12"></script>
<script>
const videoPlayer = document.getElementById('video-player');
const emptyState = document.getElementById('empty-state');
const playOverlay = document.getElementById('play-overlay');
const channelTitle = document.getElementById('channel-title');
let hls = null;
let activeChannelElement = null;
function loadStream(url) {
// Clean up any existing HLS instance
if (hls) {
hls.destroy();
hls = null;
}
// Show the video player and hide empty state
videoPlayer.classList.remove('hidden');
emptyState.classList.add('hidden');
// First check if the browser natively supports HLS
if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) {
// Safari on iOS has native HLS support
console.log('Using native HLS support');
videoPlayer.src = url;
// Show play overlay if user hasn't interacted yet
if (document.documentElement.hasAttribute('data-user-interacted')) {
playVideo();
} else {
playOverlay.classList.remove('hidden');
}
return true;
}
// Otherwise use hls.js if it's supported
else if (Hls.isSupported()) {
console.log('Using HLS.js');
hls = new Hls({
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90
});
try {
hls.loadSource(url);
hls.attachMedia(videoPlayer);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS manifest loaded');
if (document.documentElement.hasAttribute('data-user-interacted')) {
playVideo();
} else {
playOverlay.classList.remove('hidden');
}
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('Network error:', data);
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('Media error:', data);
hls.recoverMediaError();
break;
default:
console.error('Fatal error:', data);
hls.destroy();
alert('Error loading stream. Please try another channel.');
break;
}
}
});
return true;
} catch (error) {
console.error('Error initializing stream:', error);
alert('Error loading stream. Please try another channel.');
return false;
}
} else {
// Neither native support nor hls.js support
console.error('HLS is not supported in this browser');
alert('Your browser does not support HLS streaming');
cleanupPlayer();
return false;
}
}
function selectChannel(url, title, element = null) {
if (!url) {
cleanupPlayer();
return;
}
if (activeChannelElement) {
activeChannelElement.classList.remove('bg-indigo-50', 'border-l-4', 'border-indigo-500');
}
if (element) {
element.classList.add('bg-indigo-50', 'border-l-4', 'border-indigo-500');
activeChannelElement = element;
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (loadStream(url)) {
channelTitle.textContent = title;
} else {
cleanupPlayer();
}
}
function updatePlayOverlay() {
if (!videoPlayer.paused) {
playOverlay.classList.add('hidden');
} else {
playOverlay.classList.remove('hidden');
}
}
function playVideo() {
videoPlayer.play()
.then(() => {
document.documentElement.setAttribute('data-user-interacted', 'true');
updatePlayOverlay();
})
.catch((error) => {
console.error('Error playing stream:', error);
alert('Error playing stream. Please try another channel.');
updatePlayOverlay();
});
}
// Add video event listeners to handle play button visibility
videoPlayer.addEventListener('play', updatePlayOverlay);
videoPlayer.addEventListener('pause', updatePlayOverlay);
videoPlayer.addEventListener('ended', updatePlayOverlay);
videoPlayer.addEventListener('error', updatePlayOverlay);
function handleKeyNavigation(e) {
if (!activeChannelElement || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) {
return;
}
e.preventDefault();
const channels = Array.from(document.querySelectorAll('.channel-item'));
const currentIndex = channels.indexOf(activeChannelElement);
let nextIndex;
if (e.key === 'ArrowUp') {
nextIndex = currentIndex > 0 ? currentIndex - 1 : channels.length - 1;
} else {
nextIndex = currentIndex < channels.length - 1 ? currentIndex + 1 : 0;
}
const nextChannel = channels[nextIndex];
selectChannel(nextChannel.dataset.url, nextChannel.dataset.title, nextChannel);
}
window.addEventListener('beforeunload', cleanupPlayer);
window.addEventListener('keydown', handleKeyNavigation);
document.addEventListener('DOMContentLoaded', function() {
const firstChannel = document.querySelector('.channel-item');
if (firstChannel) {
const url = firstChannel.dataset.url;
const title = firstChannel.dataset.title;
selectChannel(url, title, firstChannel);
}
});
document.querySelectorAll('.channel-item').forEach(channel => {
channel.onclick = function() {
selectChannel(this.dataset.url, this.dataset.title, this);
};
});
function cleanupPlayer() {
if (hls) {
hls.destroy();
hls = null;
}
// Reset video source
videoPlayer.src = '';
videoPlayer.load();
if (activeChannelElement) {
activeChannelElement.classList.remove('bg-indigo-50', 'border-l-4', 'border-indigo-500');
activeChannelElement = null;
}
videoPlayer.classList.add('hidden');
emptyState.classList.remove('hidden');
playOverlay.classList.add('hidden');
}
</script>
{% endblock %}
{% block extra_js %}
{% endblock %}
-2
View File
@@ -69,8 +69,6 @@ urlpatterns = [
# Include collection URLs
path('', include('links.collection_urls')),
# Include IPTV URLs - must be before alias patterns
path('', include('links.iptv_urls')),
# Include tag URLs
path('', include('links.tag_urls')),
-9
View File
@@ -151,15 +151,6 @@
{% trans "Tags" %}
</div>
</a>
<a href="{% url 'iptv-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="M15 10l4.553-4.586A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{% trans "IPTV" %}
</div>
</a>
<a href="{% url 'api-docs' %}" 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-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">