diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 442ba71..2d862a4 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ diff --git a/links/forms.py b/links/forms.py index 4ca7f2a..4b4bb50 100644 --- a/links/forms.py +++ b/links/forms.py @@ -1,5 +1,5 @@ from django import forms -from .models import Link, Page, Newsletter, ImageCollection, Image +from .models import Link, Page, Newsletter, ImageCollection, Image, IPTVChannel from simplemde.fields import SimpleMDEField from django.utils.translation import gettext_lazy as _ from django.core.validators import URLValidator @@ -103,3 +103,11 @@ class ImageUploadForm(forms.ModelForm): class Meta: model = Image fields = ['title', 'description'] + +class IPTVChannelForm(forms.ModelForm): + class Meta: + model = IPTVChannel + fields = ['title', 'url', 'description'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 3}), + } diff --git a/links/iptv_urls.py b/links/iptv_urls.py new file mode 100644 index 0000000..4328d97 --- /dev/null +++ b/links/iptv_urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from .iptv_views import ( + IPTVChannelListView, + IPTVChannelCreateView, + IPTVChannelUpdateView, + IPTVChannelDeleteView, + export_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//edit/', IPTVChannelUpdateView.as_view(), name='iptv-update'), + path('ui/iptv//delete/', IPTVChannelDeleteView.as_view(), name='iptv-delete'), + path('api/iptv/export/', export_m3u_playlist, name='iptv-export'), +] diff --git a/links/iptv_views.py b/links/iptv_views.py new file mode 100644 index 0000000..84d98f9 --- /dev/null +++ b/links/iptv_views.py @@ -0,0 +1,56 @@ +from django.urls import reverse_lazy +from django.views.generic import ListView, CreateView, UpdateView, DeleteView +from django.shortcuts import render +from django.http import HttpResponse +from .models import IPTVChannel +from .forms import IPTVChannelForm + +class IPTVChannelListView(ListView): + model = IPTVChannel + template_name = 'links/iptv/list.html' + context_object_name = 'channels' + + def get_queryset(self): + return IPTVChannel.objects.all() + +class IPTVChannelCreateView(CreateView): + model = IPTVChannel + form_class = IPTVChannelForm + template_name = 'links/iptv/form.html' + success_url = reverse_lazy('iptv-list') + + def form_valid(self, form): + return super().form_valid(form) + +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""" + channels = IPTVChannel.objects.all() + + # Start the M3U file content + content = ['#EXTM3U'] + + for channel in channels: + # Add channel info line + content.append(f'#EXTINF:-1 tvg-id="{channel.id}",{channel.title}') + # Add URL line + content.append(channel.url) + + # Join all lines with newlines + playlist_content = '\n'.join(content) + + # Create the response with appropriate headers + response = HttpResponse(playlist_content, content_type='audio/x-mpegurl') + response['Content-Disposition'] = 'attachment; filename="iptv_channels.m3u"' + + return response diff --git a/links/migrations/0021_iptvchannel.py b/links/migrations/0021_iptvchannel.py new file mode 100644 index 0000000..852d974 --- /dev/null +++ b/links/migrations/0021_iptvchannel.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.9 on 2024-11-24 03:42 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0020_alter_image_description'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='IPTVChannel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('url', models.URLField(help_text='M3U or similar streaming URL')), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='iptv_channels', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/links/migrations/0022_alter_iptvchannel_user.py b/links/migrations/0022_alter_iptvchannel_user.py new file mode 100644 index 0000000..b8283d1 --- /dev/null +++ b/links/migrations/0022_alter_iptvchannel_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.9 on 2024-11-24 03:51 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0021_iptvchannel'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='iptvchannel', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='iptv_channels', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/links/migrations/0023_remove_iptvchannel_user.py b/links/migrations/0023_remove_iptvchannel_user.py new file mode 100644 index 0000000..520a87b --- /dev/null +++ b/links/migrations/0023_remove_iptvchannel_user.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.9 on 2024-11-24 03:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0022_alter_iptvchannel_user'), + ] + + operations = [ + migrations.RemoveField( + model_name='iptvchannel', + name='user', + ), + ] diff --git a/links/models.py b/links/models.py index 003be6d..9a4f03d 100644 --- a/links/models.py +++ b/links/models.py @@ -289,3 +289,16 @@ class Image(models.Model): height=height, fit='cover' ) + +class IPTVChannel(models.Model): + title = models.CharField(max_length=200) + url = models.URLField(help_text="M3U or similar streaming URL") + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return self.title diff --git a/links/templates/links/collection_list.html b/links/templates/links/collection_list.html index 27c747d..6a10ee8 100644 --- a/links/templates/links/collection_list.html +++ b/links/templates/links/collection_list.html @@ -30,7 +30,7 @@ {% for image in images %}
- {{ image.title }}
diff --git a/links/templates/links/iptv/confirm_delete.html b/links/templates/links/iptv/confirm_delete.html new file mode 100644 index 0000000..9d5babf --- /dev/null +++ b/links/templates/links/iptv/confirm_delete.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

+ Delete IPTV Channel +

+
+

Are you sure you want to delete "{{ object.title }}"? This action cannot be undone.

+
+
+
+ {% csrf_token %} +
+ + Cancel + + +
+
+
+
+
+
+{% endblock %} diff --git a/links/templates/links/iptv/form.html b/links/templates/links/iptv/form.html new file mode 100644 index 0000000..c9b8bc6 --- /dev/null +++ b/links/templates/links/iptv/form.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

+ {% if form.instance.pk %}Edit{% else %}Add{% endif %} IPTV Channel +

+
+
+ {% csrf_token %} + +
+ +
+ {{ form.title }} +
+ {% if form.title.errors %} +

{{ form.title.errors.0 }}

+ {% endif %} +
+ +
+ +
+ {{ form.url }} +
+ {% if form.url.errors %} +

{{ form.url.errors.0 }}

+ {% endif %} +
+ +
+ +
+ {{ form.description }} +
+ {% if form.description.errors %} +

{{ form.description.errors.0 }}

+ {% endif %} +
+ +
+ + Cancel + + +
+
+
+
+
+
+{% endblock %} diff --git a/links/templates/links/iptv/list.html b/links/templates/links/iptv/list.html new file mode 100644 index 0000000..9f8dbb9 --- /dev/null +++ b/links/templates/links/iptv/list.html @@ -0,0 +1,461 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+ +
+
+
+

IPTV Channels

+ + + + + +
+ +
+ +
+ + +
+
+
+ + + +

No channel selected

+

Select a channel from the list to start watching

+
+ + + + + +
+
+
+ + + + + + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/links/urls.py b/links/urls.py index 5189a73..0067c48 100644 --- a/links/urls.py +++ b/links/urls.py @@ -57,6 +57,9 @@ urlpatterns = [ # Include collection URLs path('', include('links.collection_urls')), + # Include IPTV URLs - must be before alias patterns + path('', include('links.iptv_urls')), + # Aliases - these should always be last path('/', views.redirect_to_original, name='redirect_to_original'), path('//', views.redirect_to_original, name='redirect_to_original_with_param'), diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index 2cd519e..5596bf9 100644 --- a/new_theme/static/css/dist/styles.css +++ b/new_theme/static/css/dist/styles.css @@ -588,6 +588,18 @@ video { } } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + .pointer-events-none { pointer-events: none; } @@ -608,6 +620,10 @@ video { position: relative; } +.sticky { + position: sticky; +} + .inset-0 { inset: 0px; } @@ -868,6 +884,10 @@ video { height: 280px; } +.h-\[calc\(100vh-4rem\)\] { + height: calc(100vh - 4rem); +} + .h-auto { height: auto; } @@ -880,6 +900,18 @@ video { height: 100vh; } +.min-h-0 { + min-height: 0px; +} + +.min-h-full { + min-height: 100%; +} + +.min-h-\[50vh\] { + min-height: 50vh; +} + .w-12 { width: 3rem; } @@ -916,6 +948,10 @@ video { width: 2rem; } +.w-80 { + width: 20rem; +} + .w-full { width: 100%; } @@ -928,6 +964,10 @@ video { min-width: 100%; } +.max-w-2xl { + max-width: 42rem; +} + .max-w-3xl { max-width: 48rem; } @@ -988,6 +1028,14 @@ video { cursor: not-allowed; } +.cursor-pointer { + cursor: pointer; +} + +.touch-manipulation { + touch-action: manipulation; +} + .select-all { -webkit-user-select: all; -moz-user-select: all; @@ -1158,6 +1206,10 @@ video { overflow-x: auto; } +.overflow-y-auto { + overflow-y: auto; +} + .truncate { overflow: hidden; text-overflow: ellipsis; @@ -1218,6 +1270,10 @@ video { border-left-width: 4px; } +.border-r { + border-right-width: 1px; +} + .border-t { border-top-width: 1px; } @@ -1265,6 +1321,11 @@ video { border-color: rgb(234 179 8 / var(--tw-border-opacity)); } +.border-indigo-500 { + --tw-border-opacity: 1; + border-color: rgb(99 102 241 / var(--tw-border-opacity)); +} + .bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity)); @@ -1325,6 +1386,11 @@ video { background-color: rgb(22 163 74 / var(--tw-bg-opacity)); } +.bg-indigo-600 { + --tw-bg-opacity: 1; + background-color: rgb(79 70 229 / var(--tw-bg-opacity)); +} + .bg-purple-100 { --tw-bg-opacity: 1; background-color: rgb(243 232 255 / var(--tw-bg-opacity)); @@ -1374,10 +1440,28 @@ video { background-color: rgb(254 249 195 / var(--tw-bg-opacity)); } +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + +.bg-indigo-50 { + --tw-bg-opacity: 1; + background-color: rgb(238 242 255 / var(--tw-bg-opacity)); +} + .bg-opacity-75 { --tw-bg-opacity: 0.75; } +.bg-opacity-20 { + --tw-bg-opacity: 0.2; +} + +.bg-opacity-50 { + --tw-bg-opacity: 0.5; +} + .bg-gradient-to-b { background-image: linear-gradient(to bottom, var(--tw-gradient-stops)); } @@ -1524,6 +1608,11 @@ video { padding-bottom: 2rem; } +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + .pl-3 { padding-left: 0.75rem; } @@ -1544,6 +1633,14 @@ video { padding-top: 1.25rem; } +.pb-4 { + padding-bottom: 1rem; +} + +.pt-4 { + padding-top: 1rem; +} + .text-left { text-align: left; } @@ -1810,6 +1907,21 @@ video { outline-style: solid; } +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-inset { + --tw-ring-inset: inset; +} + +.ring-gray-300 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); +} + .blur { --tw-blur: blur(8px); filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); @@ -1959,6 +2071,11 @@ video { background-color: rgb(21 128 61 / var(--tw-bg-opacity)); } +.hover\:bg-indigo-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(67 56 202 / var(--tw-bg-opacity)); +} + .hover\:bg-purple-600:hover { --tw-bg-opacity: 1; background-color: rgb(147 51 234 / var(--tw-bg-opacity)); @@ -1984,6 +2101,20 @@ video { background-color: rgb(185 28 28 / var(--tw-bg-opacity)); } +.hover\:bg-indigo-500:hover { + --tw-bg-opacity: 1; + background-color: rgb(99 102 241 / var(--tw-bg-opacity)); +} + +.hover\:bg-red-500:hover { + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + +.hover\:bg-opacity-30:hover { + --tw-bg-opacity: 0.3; +} + .hover\:text-blue-200:hover { --tw-text-opacity: 1; color: rgb(191 219 254 / var(--tw-text-opacity)); @@ -2049,6 +2180,11 @@ video { color: rgb(153 27 27 / var(--tw-text-opacity)); } +.hover\:text-gray-600:hover { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -2078,6 +2214,11 @@ video { border-color: transparent; } +.focus\:border-indigo-500:focus { + --tw-border-opacity: 1; + border-color: rgb(99 102 241 / var(--tw-border-opacity)); +} + .focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; @@ -2121,6 +2262,11 @@ video { --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity)); } +.focus\:ring-indigo-500:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(99 102 241 / var(--tw-ring-opacity)); +} + .focus\:ring-red-500:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity)); @@ -2171,6 +2317,11 @@ video { margin-right: 0px; } + .sm\:my-8 { + margin-top: 2rem; + margin-bottom: 2rem; + } + .sm\:mb-6 { margin-bottom: 1.5rem; } @@ -2255,6 +2406,14 @@ video { width: auto; } + .sm\:w-full { + width: 100%; + } + + .sm\:max-w-lg { + max-width: 32rem; + } + .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2313,6 +2472,10 @@ video { margin-bottom: calc(0px * var(--tw-space-y-reverse)); } + .sm\:rounded-lg { + border-radius: 0.5rem; + } + .sm\:rounded-md { border-radius: 0.375rem; } @@ -2337,6 +2500,10 @@ video { padding: 1.5rem; } + .sm\:p-0 { + padding: 0px; + } + .sm\:px-4 { padding-left: 1rem; padding-right: 1rem; @@ -2402,6 +2569,30 @@ video { display: table-cell; } + .md\:h-20 { + height: 5rem; + } + + .md\:h-\[calc\(100vh-4rem\)\] { + height: calc(100vh - 4rem); + } + + .md\:h-full { + height: 100%; + } + + .md\:min-h-0 { + min-height: 0px; + } + + .md\:w-20 { + width: 5rem; + } + + .md\:w-80 { + width: 20rem; + } + .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -2410,9 +2601,22 @@ video { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .md\:flex-row { + flex-direction: row; + } + .md\:gap-8 { gap: 2rem; } + + .md\:border-r { + border-right-width: 1px; + } + + .md\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } } @media (min-width: 1024px) { diff --git a/templates/base.html b/templates/base.html index 8e95414..e9817f0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -122,6 +122,15 @@ {% trans "Images" %} + +
+ + + + {% trans "IPTV" %} +
+