From ecbbc1da203beb1a3f12dedd9fd7f093d3d1b875 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sun, 24 Nov 2024 16:09:03 +1100 Subject: [PATCH] add ability to export iptv channels --- data/db.sqlite3 | Bin 290816 -> 290816 bytes links/iptv_urls.py | 3 + links/iptv_views.py | 23 +++ links/templates/links/iptv/list.html | 265 +++++++++++++++++++++++---- new_theme/static/css/dist/styles.css | 130 +++++++++++++ 5 files changed, 390 insertions(+), 31 deletions(-) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index cdc815c638285db072608f45c1986ef3e10a6698..2d862a470fe9e333ddb4e9d28baba7ccdcce271d 100644 GIT binary patch delta 623 zcmZp8AlUFgaDp_W-$WT_M!$^-v-KF+C;!o_WOQgQ)8AgE&)9c?*`EE(RCcCf9*#ne z01kZ)KK8flXEqxuxUtvEJFqY~G8!2endllC>Kd6S7?@ZY8(A5d=ouSXm>XC+%G+a6 zU|?lxq-SAnVrBwVV24$Kfw7*6v7w2vv7@{#rUD~#D^n9aGXq~UBMS>hc^fP$Os$N~ z^b9R5Obm=1<*l(QFf`LMGPf`>GI5l*0x6jO(3nYd`&tgBaCT;UzM|>u0!)THd=vP( z_*(et_$v5{wp$1=>G7$sGBYwdI)F)gFlh%SZNa1sn6%zLO^&HRkg<0|+8AnFLTc)u}|9zQ(f3{1?7jM=CEHfGY^-el$4p4m1uX4jT>%4f+l74d@Ns4b=*24a^F44ZIDt z4XzEN4Vn#&3SSL=4Q>r&4O$IM4Mey7Fajw95N$yU02M(&2^9bUTWN4~c9)Sf0u~}R zG&V3YFfuePF)=MNG$1fDIy5&rGcqnXHa9acF-%ELOP6gQ11E/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 index dd7e0bb..84d98f9 100644 --- a/links/iptv_views.py +++ b/links/iptv_views.py @@ -1,6 +1,7 @@ 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 @@ -31,3 +32,25 @@ 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/templates/links/iptv/list.html b/links/templates/links/iptv/list.html index f5abb9d..9f8dbb9 100644 --- a/links/templates/links/iptv/list.html +++ b/links/templates/links/iptv/list.html @@ -2,43 +2,51 @@ {% load static %} {% block content %} -
- -
-
-

IPTV Channels

- +
+ +
+
- -
-
-
+ +
+
+
-

No channel selected

+

No channel selected

Select a channel from the list to start watching

- + +
+
+
+ + + + + + @@ -254,5 +334,128 @@ function cleanupPlayer() { emptyState.classList.remove('hidden'); playOverlay.classList.add('hidden'); } + +// Modal handling functions +let currentDeleteId = null; +let lastFocusedElement = null; + +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, description) { + lastFocusedElement = document.activeElement; + document.getElementById('modalTitle').textContent = 'Edit Channel'; + document.getElementById('channelId').value = id; + document.getElementById('title').value = title; + document.getElementById('url').value = url; + document.getElementById('description').value = description; + 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.'); + } +} + +// Add keyboard event listeners for modal accessibility +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + hideChannelModal(); + hideDeleteModal(); + } +}); + +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; +} {% endblock %} diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index 327a740..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; } @@ -888,6 +904,14 @@ video { min-height: 0px; } +.min-h-full { + min-height: 100%; +} + +.min-h-\[50vh\] { + min-height: 50vh; +} + .w-12 { width: 3rem; } @@ -1008,6 +1032,10 @@ video { cursor: pointer; } +.touch-manipulation { + touch-action: manipulation; +} + .select-all { -webkit-user-select: all; -moz-user-select: all; @@ -1580,6 +1608,11 @@ video { padding-bottom: 2rem; } +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + .pl-3 { padding-left: 0.75rem; } @@ -1600,6 +1633,14 @@ video { padding-top: 1.25rem; } +.pb-4 { + padding-bottom: 1rem; +} + +.pt-4 { + padding-top: 1rem; +} + .text-left { text-align: left; } @@ -1866,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); @@ -2045,6 +2101,16 @@ 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; } @@ -2114,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; } @@ -2143,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; @@ -2241,6 +2317,11 @@ video { margin-right: 0px; } + .sm\:my-8 { + margin-top: 2rem; + margin-bottom: 2rem; + } + .sm\:mb-6 { margin-bottom: 1.5rem; } @@ -2325,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)); } @@ -2411,6 +2500,10 @@ video { padding: 1.5rem; } + .sm\:p-0 { + padding: 0px; + } + .sm\:px-4 { padding-left: 1rem; padding-right: 1rem; @@ -2476,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)); } @@ -2484,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) {