From 7e5be6094f6fb9bc00ff3eee9e4e4b799c98ce3c Mon Sep 17 00:00:00 2001 From: Marlon Alkan Date: Sun, 8 Jun 2025 16:45:34 +0200 Subject: [PATCH 001/782] docker: init: 02-postgres.sh: allow DB user to create new DB (for tests) --- docker/init/02-postgres.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 69a81dd4..7bb90671 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -57,13 +57,14 @@ if [ -z "$(ls -A $POSTGRES_DIR)" ]; then echo "Creating PostgreSQL database..." su - postgres -c "createdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" - # Create user, set ownership, and grant privileges + # Create user, set ownership, and grant privileges, including privileges to create new databases echo "Creating PostgreSQL user..." su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" < Date: Sun, 8 Jun 2025 16:47:00 +0200 Subject: [PATCH 002/782] apps: output: change body detection logic and add tests --- apps/output/tests.py | 23 +++++++++++++++++++++++ apps/output/views.py | 5 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/output/tests.py b/apps/output/tests.py index e1e857ee..f87c8340 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -14,3 +14,26 @@ class OutputM3UTest(TestCase): self.assertEqual(response.status_code, 200) content = response.content.decode() self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_empty_body(self): + """ + Test that a POST request with an empty body returns 200 OK. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded') + content = response.content.decode() + + self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK") + self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_with_body(self): + """ + Test that a POST request with a non-empty body returns 403 Forbidden. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data={'evilstring': 'muhahaha'}) + + self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") + self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) diff --git a/apps/output/views.py b/apps/output/views.py index 2b18d185..ff02560c 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -18,9 +18,10 @@ def generate_m3u(request, profile_name=None): The stream URL now points to the new stream_view that uses StreamProfile. Supports both GET and POST methods for compatibility with IPTVSmarters. """ - # Check if this is a POST request with data (which we don't want to allow) + # Check if this is a POST request and the body is not empty (which we don't want to allow) if request.method == "POST" and request.body: - return HttpResponseForbidden("POST requests with content are not allowed") + if request.body.decode() != '{}': + return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode())) if profile_name is not None: channel_profile = ChannelProfile.objects.get(name=profile_name) From 5148a5a79bd7f568de935e9ecaba047029c61180 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 20 Jul 2025 20:01:51 -0500 Subject: [PATCH 003/782] Use channel name for display name in EPG output. --- apps/output/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/output/views.py b/apps/output/views.py index 67d72bd2..8d58a1b3 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -378,8 +378,7 @@ def generate_epg(request, profile_name=None, user=None): tvg_logo = direct_logo else: tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) - - display_name = channel.epg_data.name if channel.epg_data else channel.name + display_name = channel.name xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') xml_lines.append(f' ') From 7eef45f1c04ab7ee79f179da110649ba9a7a32ea Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 20 Jul 2025 20:13:59 -0500 Subject: [PATCH 004/782] Only use whole numbers when looking for a number not in use. --- apps/m3u/tasks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index e3893dc1..7c67b404 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -918,7 +918,6 @@ def sync_auto_channels(account_id, scan_start_time=None): # --- FILTER STREAMS BY NAME MATCH REGEX IF SPECIFIED --- if name_match_regex: try: - compiled_name_match_regex = re.compile(name_match_regex, re.IGNORECASE) current_streams = current_streams.filter( name__iregex=name_match_regex ) @@ -1042,7 +1041,7 @@ def sync_auto_channels(account_id, scan_start_time=None): # Create new channel # Find next available channel number while Channel.objects.filter(channel_number=current_channel_number).exists(): - current_channel_number += 0.1 + current_channel_number += 1 # Create the channel with auto-created tracking in the target group channel = Channel.objects.create( From 1475ca70ab479318ead164ccdc45aad406a5f6e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 27 Jul 2025 14:18:48 -0500 Subject: [PATCH 005/782] Fixes being unable to add a new logo via URL. --- apps/channels/api_views.py | 10 ++++++++-- apps/channels/serializers.py | 11 +++++++++++ dispatcharr/utils.py | 6 +++--- frontend/src/api.js | 22 ++++++++++------------ frontend/src/components/forms/Logo.jsx | 7 ++++--- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 0973dce0..73c6412e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1122,7 +1122,7 @@ class CleanupUnusedLogosAPIView(APIView): def post(self, request): """Delete all logos with no channel associations""" delete_files = request.data.get("delete_files", False) - + unused_logos = Logo.objects.filter(channels__isnull=True) deleted_count = unused_logos.count() logo_names = list(unused_logos.values_list('name', flat=True)) @@ -1204,7 +1204,13 @@ class LogoViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): """Update an existing logo""" - return super().update(request, *args, **kwargs) + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + if serializer.is_valid(): + logo = serializer.save() + return Response(self.get_serializer(logo).data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): """Delete a logo and remove it from any channels using it""" diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 82b5f808..9273b265 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -38,6 +38,17 @@ class LogoSerializer(serializers.ModelSerializer): return value + def create(self, validated_data): + """Handle logo creation with proper URL validation""" + return Logo.objects.create(**validated_data) + + def update(self, instance, validated_data): + """Handle logo updates""" + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + def get_cache_url(self, obj): # return f"/api/channels/logos/{obj.id}/cache/" request = self.context.get("request") diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 5e1ad087..260515fc 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -21,10 +21,10 @@ def json_success_response(data=None, status=200): def validate_logo_file(file): """Validate uploaded logo file size and MIME type.""" - valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] + valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"] if file.content_type not in valid_mime_types: - raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP.") - if file.size > 5 * 1024 * 1024: # Increased to 5MB + raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, SVG.") + if file.size > 5 * 1024 * 1024: # 5MB raise ValidationError("File too large. Max 5MB.") diff --git a/frontend/src/api.js b/frontend/src/api.js index 7cbb6214..154a1341 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1299,9 +1299,17 @@ export default class API { static async createLogo(values) { try { + // Use FormData for logo creation to match backend expectations + const formData = new FormData(); + for (const [key, value] of Object.entries(values)) { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + } + const response = await request(`${host}/api/channels/logos/`, { method: 'POST', - body: values, + body: formData, }); useChannelsStore.getState().addLogo(response); @@ -1314,19 +1322,9 @@ export default class API { static async updateLogo(id, values) { try { - // Convert values to FormData for the multipart/form-data content type - const formData = new FormData(); - - // Add each field to the form data - Object.keys(values).forEach(key => { - if (values[key] !== null && values[key] !== undefined) { - formData.append(key, values[key]); - } - }); - const response = await request(`${host}/api/channels/logos/${id}/`, { method: 'PUT', - body: formData, // Send as FormData instead of JSON + body: values, // This will be converted to JSON in the request function }); useChannelsStore.getState().updateLogo(response); diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index e209659c..1aef1e66 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -206,9 +206,10 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { @@ -226,7 +227,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { Drag image here or click to select - Supports PNG, JPEG, GIF, WebP files + Supports PNG, JPEG, GIF, WebP, SVG files From a7b9d278c27f81051d53cf850fa125084628aab1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 27 Jul 2025 14:44:20 -0500 Subject: [PATCH 006/782] In the logo manager, don't save when file is selected, wait for create button to be clicked. --- frontend/src/components/forms/Logo.jsx | 125 +++++++++++++++++-------- 1 file changed, 88 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index 1aef1e66..6b8877bf 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -21,6 +21,7 @@ import API from '../../api'; const LogoForm = ({ logo = null, isOpen, onClose }) => { const [logoPreview, setLogoPreview] = useState(null); const [uploading, setUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); // Store selected file const formik = useFormik({ initialValues: { @@ -46,6 +47,37 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { }), onSubmit: async (values, { setSubmitting }) => { try { + setUploading(true); + + // If we have a selected file, upload it first + if (selectedFile) { + try { + const uploadResponse = await API.uploadLogo(selectedFile); + // Use the uploaded file data instead of form values + values.name = uploadResponse.name; + values.url = uploadResponse.url; + } catch (uploadError) { + let errorMessage = 'Failed to upload logo file'; + + if (uploadError.code === 'NETWORK_ERROR' || uploadError.message?.includes('timeout')) { + errorMessage = 'Upload timed out. Please try again.'; + } else if (uploadError.status === 413) { + errorMessage = 'File too large. Please choose a smaller file.'; + } else if (uploadError.body?.error) { + errorMessage = uploadError.body.error; + } + + notifications.show({ + title: 'Upload Error', + message: errorMessage, + color: 'red', + }); + return; // Don't proceed with creation if upload fails + } + } + + // Now create or update the logo with the final values + // Only proceed if we don't already have a logo from file upload if (logo) { await API.updateLogo(logo.id, values); notifications.show({ @@ -53,13 +85,22 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { message: 'Logo updated successfully', color: 'green', }); - } else { + } else if (!selectedFile) { + // Only create a new logo entry if we're not uploading a file + // (file upload already created the logo entry) await API.createLogo(values); notifications.show({ title: 'Success', message: 'Logo created successfully', color: 'green', }); + } else { + // File was uploaded and logo was already created + notifications.show({ + title: 'Success', + message: 'Logo uploaded successfully', + color: 'green', + }); } onClose(); } catch (error) { @@ -79,6 +120,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { }); } finally { setSubmitting(false); + setUploading(false); } }, }); @@ -94,9 +136,11 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { formik.resetForm(); setLogoPreview(null); } + // Clear any selected file when logo changes + setSelectedFile(null); }, [logo, isOpen]); - const handleFileUpload = async (files) => { + const handleFileSelect = (files) => { if (files.length === 0) return; const file = files[0]; @@ -111,53 +155,53 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { return; } - setUploading(true); + // Store the file for later upload and create preview + setSelectedFile(file); - try { - const response = await API.uploadLogo(file); + // Generate a local preview URL + const previewUrl = URL.createObjectURL(file); + setLogoPreview(previewUrl); - // Update form with uploaded file info - formik.setFieldValue('name', response.name); - formik.setFieldValue('url', response.url); - setLogoPreview(response.cache_url); - - notifications.show({ - title: 'Success', - message: 'Logo uploaded successfully', - color: 'green', - }); - } catch (error) { - let errorMessage = 'Failed to upload logo'; - - // Handle specific timeout errors - if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { - errorMessage = 'Upload timed out. Please try again.'; - } else if (error.status === 413) { - errorMessage = 'File too large. Please choose a smaller file.'; - } else if (error.body?.error) { - errorMessage = error.body.error; - } - - notifications.show({ - title: 'Error', - message: errorMessage, - color: 'red', - }); - } finally { - setUploading(false); + // Auto-fill the name field if empty + if (!formik.values.name) { + const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, ""); + formik.setFieldValue('name', nameWithoutExtension); } + + // Set a placeholder URL (will be replaced after upload) + formik.setFieldValue('url', 'file://pending-upload'); }; const handleUrlChange = (event) => { const url = event.target.value; formik.setFieldValue('url', url); + // Clear any selected file when manually entering URL + if (selectedFile) { + setSelectedFile(null); + // Revoke the object URL to free memory + if (logoPreview && logoPreview.startsWith('blob:')) { + URL.revokeObjectURL(logoPreview); + } + } + // Update preview for remote URLs if (url && url.startsWith('http')) { setLogoPreview(url); + } else if (!url) { + setLogoPreview(null); } }; + // Clean up object URLs when component unmounts or preview changes + useEffect(() => { + return () => { + if (logoPreview && logoPreview.startsWith('blob:')) { + URL.revokeObjectURL(logoPreview); + } + }; + }, [logoPreview]); + return ( { Upload Logo File {
- Drag image here or click to select + {selectedFile ? `Selected: ${selectedFile.name}` : 'Drag image here or click to select'} - Supports PNG, JPEG, GIF, WebP, SVG files + {selectedFile ? 'File will be uploaded when you click Create/Update' : 'Supports PNG, JPEG, GIF, WebP, SVG files'}
@@ -243,6 +287,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { {...formik.getFieldProps('url')} onChange={handleUrlChange} error={formik.touched.url && formik.errors.url} + disabled={!!selectedFile} // Disable when file is selected /> { error={formik.touched.name && formik.errors.name} /> + {selectedFile && ( + + Selected file: {selectedFile.name} - will be uploaded when you submit + + )} + + + +
+ ); +}; + +export default M3UFilter; diff --git a/frontend/src/components/forms/M3UFilters.jsx b/frontend/src/components/forms/M3UFilters.jsx new file mode 100644 index 00000000..a8b89ebe --- /dev/null +++ b/frontend/src/components/forms/M3UFilters.jsx @@ -0,0 +1,327 @@ +import React, { useState, useEffect } from 'react'; +import API from '../../api'; +import M3UProfile from './M3UProfile'; +import usePlaylistsStore from '../../store/playlists'; +import ConfirmationDialog from '../ConfirmationDialog'; +import useWarningsStore from '../../store/warnings'; +import { + Card, + Checkbox, + Flex, + Modal, + Button, + Box, + ActionIcon, + Text, + NumberInput, + useMantineTheme, + Center, + Group, + Switch, + Stack, +} from '@mantine/core'; +import { GripHorizontal, SquareMinus, SquarePen } from 'lucide-react'; +import M3UFilter from './M3UFilter'; +import { M3U_FILTER_TYPES } from '../../constants'; +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useDraggable, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; + +const RowDragHandleCell = ({ rowId }) => { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: rowId, + }); + + return ( +
+ + + +
+ ); +}; + +// Row Component +const DraggableRow = ({ filter, onDelete }) => { + const theme = useMantineTheme(); + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: filter.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), //let dnd-kit do its thing + transition: transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1 : 0, + position: 'relative', + }; + + return ( + + + + + + {filter.exclude ? 'Exclude' : 'Include'} + + + { + M3U_FILTER_TYPES.find((type) => type.value == filter.filter_type) + .label + } + + matching + + "{filter.regex_pattern}" + + + + + editFilter(filter)} + > + + + + onDelete(filter.id)} + size="small" + variant="transparent" + > + + + + + + ); +}; + +const M3UFilters = ({ playlist, isOpen, onClose }) => { + const theme = useMantineTheme(); + + const [editorOpen, setEditorOpen] = useState(false); + const [filter, setFilter] = useState(null); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [filterToDelete, setFilterToDelete] = useState(null); + const [filters, setFilters] = useState([]); + + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); + const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist); + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + useEffect(() => { + setFilters(playlist.filters || []); + }, [playlist]); + + const editFilter = (filter = null) => { + if (filter) { + setFilter(filter); + } + + setEditorOpen(true); + }; + + const onDelete = async (id) => { + if (!playlist || !playlist.id) return; + + // Get profile details for the confirmation dialog + const filterObj = playlist.filters.find((p) => p.id === id); + setFilterToDelete(filterObj); + setDeleteTarget(id); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-filter')) { + return deleteFilter(id); + } + + fetchPlaylist(playlist.id); + setConfirmDeleteOpen(true); + }; + + const deleteFilter = async (id) => { + if (!playlist || !playlist.id) return; + try { + await API.deleteM3UFilter(playlist.id, id); + setConfirmDeleteOpen(false); + } catch (error) { + console.error('Error deleting profile:', error); + setConfirmDeleteOpen(false); + } + + fetchPlaylist(playlist.id); + }; + + const closeEditor = () => { + setFilter(null); + setEditorOpen(false); + }; + + const handleDragEnd = async ({ active, over }) => { + if (!over || active.id === over.id) return; + + const originalFilters = [...filters]; + + const oldIndex = filters.findIndex((f) => f.id === active.id); + const newIndex = filters.findIndex((f) => f.id === over.id); + const newFilters = arrayMove(filters, oldIndex, newIndex); + + setFilters(newFilters); + + // Recalculate and compare order + const updatedFilters = newFilters.map((filter, index) => ({ + ...filter, + newOrder: index, + })); + + // Filter only those whose order actually changed + const changedFilters = updatedFilters.filter((f) => f.order !== f.newOrder); + + // Send updates + try { + await Promise.all( + changedFilters.map((f) => + API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder }) + ) + ); + await fetchPlaylist(playlist.id); + } catch (e) { + setFilters(originalFilters); + } + }; + + // Don't render if modal is not open, or if playlist data is invalid + if (!isOpen || !playlist || !playlist.id) { + return <>; + } + + return ( + <> + + + id)} + strategy={verticalListSortingStrategy} + > + {filters.map((filter) => ( + + ))} + + + + + + + + + + + setConfirmDeleteOpen(false)} + onConfirm={() => deleteFilter(deleteTarget)} + title="Confirm Filter Deletion" + message={ + filterToDelete ? ( +
+ {`Are you sure you want to delete the following filter? + +Type: ${filterToDelete.type} +Patter: ${filterToDelete.regex_pattern} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this filter? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-filter" + onSuppressChange={suppressWarning} + size="md" + /> + + ); +}; + +export default M3UFilters; diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 19f9955f..959edf3e 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -33,19 +33,23 @@ export const NETWORK_ACCESS_OPTIONS = { export const PROXY_SETTINGS_OPTIONS = { buffering_timeout: { label: 'Buffering Timeout', - description: 'Maximum time (in seconds) to wait for buffering before switching streams', + description: + 'Maximum time (in seconds) to wait for buffering before switching streams', }, buffering_speed: { label: 'Buffering Speed', - description: 'Speed threshold below which buffering is detected (1.0 = normal speed)', + description: + 'Speed threshold below which buffering is detected (1.0 = normal speed)', }, redis_chunk_ttl: { label: 'Buffer Chunk TTL', - description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)', + description: + 'Time-to-live for buffer chunks in seconds (how long stream data is cached)', }, channel_shutdown_delay: { label: 'Channel Shutdown Delay', - description: 'Delay in seconds before shutting down a channel after last client disconnects', + description: + 'Delay in seconds before shutting down a channel after last client disconnects', }, channel_init_grace_period: { label: 'Channel Initialization Grace Period', @@ -53,6 +57,21 @@ export const PROXY_SETTINGS_OPTIONS = { }, }; +export const M3U_FILTER_TYPES = [ + { + label: 'Group', + value: 'group', + }, + { + label: 'Stream Name', + value: 'name', + }, + { + label: 'Stream URL', + value: 'url', + }, +]; + export const REGION_CHOICES = [ { value: 'ad', label: 'AD' }, { value: 'ae', label: 'AE' }, diff --git a/frontend/src/store/playlists.jsx b/frontend/src/store/playlists.jsx index 87d8f3b8..1041c036 100644 --- a/frontend/src/store/playlists.jsx +++ b/frontend/src/store/playlists.jsx @@ -19,6 +19,24 @@ const usePlaylistsStore = create((set) => ({ editPlaylistId: id, })), + fetchPlaylist: async (id) => { + set({ isLoading: true, error: null }); + try { + const playlist = await api.getPlaylist(id); + set((state) => ({ + playlists: state.playlists.map((p) => (p.id == id ? playlist : p)), + isLoading: false, + profiles: { + ...state.profiles, + [id]: playlist.profiles, + }, + })); + } catch (error) { + console.error('Failed to fetch playlists:', error); + set({ error: 'Failed to load playlists.', isLoading: false }); + } + }, + fetchPlaylists: async () => { set({ isLoading: true, error: null }); try { @@ -91,9 +109,11 @@ const usePlaylistsStore = create((set) => ({ const existingProgress = state.refreshProgress[accountId]; // Don't replace 'initializing' status with empty/early server messages - if (existingProgress && + if ( + existingProgress && existingProgress.action === 'initializing' && - accountIdOrData.progress === 0) { + accountIdOrData.progress === 0 + ) { return state; // Keep showing 'initializing' until real progress comes } From 1612df14c14ca42f952db5aa35de346d06933a91 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 1 Aug 2025 15:12:19 -0400 Subject: [PATCH 037/782] fixed deleting filter not removing it from the ui --- frontend/src/components/forms/M3UFilters.jsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/forms/M3UFilters.jsx b/frontend/src/components/forms/M3UFilters.jsx index a8b89ebe..4610f88a 100644 --- a/frontend/src/components/forms/M3UFilters.jsx +++ b/frontend/src/components/forms/M3UFilters.jsx @@ -19,8 +19,9 @@ import { Group, Switch, Stack, + Alert, } from '@mantine/core'; -import { GripHorizontal, SquareMinus, SquarePen } from 'lucide-react'; +import { GripHorizontal, Info, SquareMinus, SquarePen } from 'lucide-react'; import M3UFilter from './M3UFilter'; import { M3U_FILTER_TYPES } from '../../constants'; import { @@ -192,7 +193,6 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { return deleteFilter(id); } - fetchPlaylist(playlist.id); setConfirmDeleteOpen(true); }; @@ -207,6 +207,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { } fetchPlaylist(playlist.id); + setFilters(filters.filter((f) => f.id !== id)); }; const closeEditor = () => { @@ -255,6 +256,19 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { return ( <> + } + color="blue" + variant="light" + style={{ marginBottom: 5 }} + > + + Order Matters! Rules are processed in the order + below. Once a stream matches a given rule, no other rules are + checked. + + + Date: Fri, 1 Aug 2025 17:12:06 -0400 Subject: [PATCH 038/782] fixed editing --- frontend/src/components/forms/M3UFilters.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/forms/M3UFilters.jsx b/frontend/src/components/forms/M3UFilters.jsx index 4610f88a..f684165f 100644 --- a/frontend/src/components/forms/M3UFilters.jsx +++ b/frontend/src/components/forms/M3UFilters.jsx @@ -67,7 +67,7 @@ const RowDragHandleCell = ({ rowId }) => { }; // Row Component -const DraggableRow = ({ filter, onDelete }) => { +const DraggableRow = ({ filter, editFilter, onDelete }) => { const theme = useMantineTheme(); const { transform, transition, setNodeRef, isDragging } = useSortable({ id: filter.id, @@ -283,6 +283,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { ))} From 1c47b7f84ac121280551f576a666f29477547a36 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 1 Aug 2025 16:42:42 -0500 Subject: [PATCH 039/782] Adds ability to reverse the sort order for auto channel sync. --- apps/m3u/tasks.py | 19 ++-- .../src/components/forms/M3UGroupFilter.jsx | 94 +++++++++++++------ 2 files changed, 77 insertions(+), 36 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 40a395ce..7598211a 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -884,6 +884,7 @@ def sync_auto_channels(account_id, scan_start_time=None): name_match_regex = None channel_profile_ids = None channel_sort_order = None + channel_sort_reverse = False if group_relation.custom_properties: try: group_custom_props = json.loads(group_relation.custom_properties) @@ -894,6 +895,7 @@ def sync_auto_channels(account_id, scan_start_time=None): name_match_regex = group_custom_props.get("name_match_regex") channel_profile_ids = group_custom_props.get("channel_profile_ids") channel_sort_order = group_custom_props.get("channel_sort_order") + channel_sort_reverse = group_custom_props.get("channel_sort_reverse", False) except Exception: force_dummy_epg = False override_group_id = None @@ -902,6 +904,7 @@ def sync_auto_channels(account_id, scan_start_time=None): name_match_regex = None channel_profile_ids = None channel_sort_order = None + channel_sort_reverse = False # Determine which group to use for created channels target_group = channel_group @@ -936,18 +939,22 @@ def sync_auto_channels(account_id, scan_start_time=None): if channel_sort_order == 'name': # Use natural sorting for names to handle numbers correctly current_streams = list(current_streams) - current_streams.sort(key=lambda stream: natural_sort_key(stream.name)) + current_streams.sort(key=lambda stream: natural_sort_key(stream.name), reverse=channel_sort_reverse) streams_is_list = True elif channel_sort_order == 'tvg_id': - current_streams = current_streams.order_by('tvg_id') + order_prefix = '-' if channel_sort_reverse else '' + current_streams = current_streams.order_by(f'{order_prefix}tvg_id') elif channel_sort_order == 'updated_at': - current_streams = current_streams.order_by('updated_at') + order_prefix = '-' if channel_sort_reverse else '' + current_streams = current_streams.order_by(f'{order_prefix}updated_at') else: logger.warning(f"Unknown channel_sort_order '{channel_sort_order}' for group '{channel_group.name}'. Using provider order.") - current_streams = current_streams.order_by('id') + order_prefix = '-' if channel_sort_reverse else '' + current_streams = current_streams.order_by(f'{order_prefix}id') else: - current_streams = current_streams.order_by('id') - # If channel_sort_order is empty or None, use provider order (no additional sorting) + # Provider order (default) - can still be reversed + order_prefix = '-' if channel_sort_reverse else '' + current_streams = current_streams.order_by(f'{order_prefix}id') # Get existing auto-created channels for this account (regardless of current group) # We'll find them by their stream associations instead of just group location diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index e5918375..64c1d356 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -410,8 +410,13 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { if (newCustomProps.channel_sort_order === undefined) { newCustomProps.channel_sort_order = ''; } + // Keep channel_sort_reverse if it exists + if (newCustomProps.channel_sort_reverse === undefined) { + newCustomProps.channel_sort_reverse = false; + } } else { delete newCustomProps.channel_sort_order; + delete newCustomProps.channel_sort_reverse; // Remove reverse when sort is removed } return { @@ -428,36 +433,65 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { /> {/* Show only channel_sort_order if selected */} {group.custom_properties?.channel_sort_order !== undefined && ( - { + setGroupStates( + groupStates.map((state) => { + if (state.channel_group === group.channel_group) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + channel_sort_order: value || '', + }, + }; + } + return state; + }) + ); + }} + data={[ + { value: '', label: 'Provider Order (Default)' }, + { value: 'name', label: 'Name' }, + { value: 'tvg_id', label: 'TVG ID' }, + { value: 'updated_at', label: 'Updated At' }, + ]} + clearable + searchable + size="xs" + /> + + {/* Add reverse sort checkbox when sort order is selected (including default) */} + {group.custom_properties?.channel_sort_order !== undefined && ( + + { + setGroupStates( + groupStates.map((state) => { + if (state.channel_group === group.channel_group) { + return { + ...state, + custom_properties: { + ...state.custom_properties, + channel_sort_reverse: event.target.checked, + }, + }; + } + return state; + }) + ); + }} + size="xs" + /> + + )} + )} {/* Show profile selection only if profile_assignment is selected */} From 84aa6311966e917e595cce5f0f7408d632c45672 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 2 Aug 2025 10:42:36 -0500 Subject: [PATCH 040/782] Initial backend commit for vod --- README.md | 2 + apps/output/views.py | 374 +++++++++++++++++++++++++++- apps/proxy/vod_proxy/__init__.py | 0 apps/proxy/vod_proxy/urls.py | 9 + apps/proxy/vod_proxy/views.py | 194 +++++++++++++++ apps/vod/__init__.py | 0 apps/vod/admin.py | 39 +++ apps/vod/api_views.py | 154 ++++++++++++ apps/vod/apps.py | 12 + apps/vod/migrations/0001_initial.py | 121 +++++++++ apps/vod/migrations/__init__.py | 0 apps/vod/models.py | 155 ++++++++++++ apps/vod/serializers.py | 47 ++++ apps/vod/tasks.py | 268 ++++++++++++++++++++ apps/vod/urls.py | 15 ++ dispatcharr/settings.py | 1 + dispatcharr/urls.py | 3 + 17 files changed, 1393 insertions(+), 1 deletion(-) create mode 100644 apps/proxy/vod_proxy/__init__.py create mode 100644 apps/proxy/vod_proxy/urls.py create mode 100644 apps/proxy/vod_proxy/views.py create mode 100644 apps/vod/__init__.py create mode 100644 apps/vod/admin.py create mode 100644 apps/vod/api_views.py create mode 100644 apps/vod/apps.py create mode 100644 apps/vod/migrations/0001_initial.py create mode 100644 apps/vod/migrations/__init__.py create mode 100644 apps/vod/models.py create mode 100644 apps/vod/serializers.py create mode 100644 apps/vod/tasks.py create mode 100644 apps/vod/urls.py diff --git a/README.md b/README.md index 5216663f..9b359e25 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and 📊 **Real-Time Stats Dashboard** — Live insights into stream health and client activity\ 🧠 **EPG Auto-Match** — Match program data to channels automatically\ ⚙️ **Streamlink + FFmpeg Support** — Flexible backend options for streaming and recording\ +🎬 **VOD Management** — Full Video on Demand support with movies and TV series\ 🧼 **UI & UX Enhancements** — Smoother, faster, more responsive interface\ 🛁 **Output Compatibility** — HDHomeRun, M3U, and XMLTV EPG support for Plex, Jellyfin, and more @@ -31,6 +32,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and ✅ **Full IPTV Control** — Import, organize, proxy, and monitor IPTV streams on your own terms\ ✅ **Smart Playlist Handling** — M3U import, filtering, grouping, and failover support\ +✅ **VOD Content Management** — Organize movies and TV series with metadata and streaming\ ✅ **Reliable EPG Integration** — Match and manage TV guide data with ease\ ✅ **Clean & Responsive Interface** — Modern design that gets out of your way\ ✅ **Fully Self-Hosted** — Total control, zero reliance on third-party services diff --git a/apps/output/views.py b/apps/output/views.py index 3fcd512b..2e51fa01 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -789,7 +789,20 @@ def xc_player_api(request, full=False): "get_series_info", "get_vod_info", ]: - return JsonResponse([], safe=False) + if action == "get_vod_categories": + return JsonResponse(xc_get_vod_categories(user), safe=False) + elif action == "get_vod_streams": + return JsonResponse(xc_get_vod_streams(request, user, request.GET.get("category_id")), safe=False) + elif action == "get_series_categories": + return JsonResponse(xc_get_series_categories(user), safe=False) + elif action == "get_series": + return JsonResponse(xc_get_series(request, user, request.GET.get("category_id")), safe=False) + elif action == "get_series_info": + return JsonResponse(xc_get_series_info(request, user, request.GET.get("series_id")), safe=False) + elif action == "get_vod_info": + return JsonResponse(xc_get_vod_info(request, user, request.GET.get("vod_id")), safe=False) + else: + return JsonResponse([], safe=False) raise Http404() @@ -986,3 +999,362 @@ def xc_get_epg(request, user, short=False): output['epg_listings'].append(program_output) return output + + +def xc_get_vod_categories(user): + """Get VOD categories for XtreamCodes API""" + from apps.vod.models import VODCategory + + response = [] + + # Filter categories based on user's M3U accounts + if user.user_level == 0: + # For regular users, get categories from their accessible M3U accounts + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + # Get M3U accounts accessible through user's profiles + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + else: + m3u_accounts = [] + + categories = VODCategory.objects.filter( + m3u_account__in=m3u_accounts + ).distinct() + else: + # Admins can see all categories + categories = VODCategory.objects.filter( + m3u_account__is_active=True + ).distinct() + + for category in categories: + response.append({ + "category_id": str(category.id), + "category_name": category.name, + "parent_id": 0, + }) + + return response + + +def xc_get_vod_streams(request, user, category_id=None): + """Get VOD streams (movies) for XtreamCodes API""" + from apps.vod.models import VOD + + streams = [] + + # Build filters based on user access + filters = {"type": "movie", "m3u_account__is_active": True} + + if user.user_level == 0: + # For regular users, filter by accessible M3U accounts + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + return [] # No accessible accounts + + if category_id: + filters["category_id"] = category_id + + vods = VOD.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') + + for vod in vods: + streams.append({ + "num": vod.id, + "name": vod.name, + "stream_type": "movie", + "stream_id": vod.id, + "stream_icon": ( + None if not vod.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[vod.logo.id]) + ) + ), + "rating": vod.rating or "0", + "rating_5based": float(vod.rating or 0) / 2 if vod.rating else 0, + "added": int(time.time()), # TODO: use actual created date + "is_adult": 0, + "category_id": str(vod.category.id) if vod.category else "0", + "container_extension": vod.container_extension or "mp4", + "custom_sid": None, + "direct_source": vod.url, + }) + + return streams + + +def xc_get_series_categories(user): + """Get series categories for XtreamCodes API""" + from apps.vod.models import VODCategory + + response = [] + + # Similar filtering as VOD categories but for series + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + else: + m3u_accounts = [] + + categories = VODCategory.objects.filter( + m3u_account__in=m3u_accounts, + series__isnull=False # Only categories that have series + ).distinct() + else: + categories = VODCategory.objects.filter( + m3u_account__is_active=True, + series__isnull=False + ).distinct() + + for category in categories: + response.append({ + "category_id": str(category.id), + "category_name": category.name, + "parent_id": 0, + }) + + return response + + +def xc_get_series(request, user, category_id=None): + """Get series list for XtreamCodes API""" + from apps.vod.models import Series + + series_list = [] + + # Build filters based on user access + filters = {"m3u_account__is_active": True} + + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + return [] + + if category_id: + filters["category_id"] = category_id + + series = Series.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') + + for serie in series: + series_list.append({ + "num": serie.id, + "name": serie.name, + "series_id": serie.id, + "cover": ( + None if not serie.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[serie.logo.id]) + ) + ), + "plot": serie.description or "", + "cast": "", + "director": "", + "genre": serie.genre or "", + "release_date": str(serie.year) if serie.year else "", + "last_modified": int(time.time()), + "rating": serie.rating or "0", + "rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0, + "backdrop_path": [], + "youtube_trailer": "", + "episode_run_time": "", + "category_id": str(serie.category.id) if serie.category else "0", + }) + + return series_list + + +def xc_get_series_info(request, user, series_id): + """Get detailed series information including episodes""" + from apps.vod.models import Series, VOD + + if not series_id: + raise Http404() + + # Get series with user access filtering + filters = {"id": series_id, "m3u_account__is_active": True} + + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + raise Http404() + + try: + serie = Series.objects.get(**filters) + except Series.DoesNotExist: + raise Http404() + + # Get episodes grouped by season + episodes = VOD.objects.filter( + series=serie, + type="episode" + ).order_by('season_number', 'episode_number') + + # Group episodes by season + seasons = {} + for episode in episodes: + season_num = episode.season_number or 1 + if season_num not in seasons: + seasons[season_num] = [] + + seasons[season_num].append({ + "id": episode.stream_id, + "episode_num": episode.episode_number or 0, + "title": episode.name, + "container_extension": episode.container_extension or "mp4", + "info": { + "air_date": f"{episode.year}-01-01" if episode.year else "", + "crew": "", + "directed_by": "", + "episode_num": episode.episode_number or 0, + "id": episode.stream_id, + "imdb_id": episode.imdb_id or "", + "name": episode.name, + "overview": episode.description or "", + "production_code": "", + "season_number": episode.season_number or 1, + "still_path": "", + "vote_average": float(episode.rating or 0), + "vote_count": 0, + "writer": "", + "release_date": f"{episode.year}-01-01" if episode.year else "", + "duration_secs": (episode.duration or 0) * 60, + "duration": f"{episode.duration or 0} min", + "video": {}, + "audio": {}, + "bitrate": 0, + } + }) + + # Build response + info = { + "seasons": list(seasons.keys()), + "info": { + "name": serie.name, + "cover": ( + None if not serie.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[serie.logo.id]) + ) + ), + "plot": serie.description or "", + "cast": "", + "director": "", + "genre": serie.genre or "", + "release_date": str(serie.year) if serie.year else "", + "last_modified": int(time.time()), + "rating": serie.rating or "0", + "rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0, + "backdrop_path": [], + "youtube_trailer": "", + "episode_run_time": "", + "category_id": str(serie.category.id) if serie.category else "0", + }, + "episodes": dict(seasons) + } + + return info + + +def xc_get_vod_info(request, user, vod_id): + """Get detailed VOD (movie) information""" + from apps.vod.models import VOD + + if not vod_id: + raise Http404() + + # Get VOD with user access filtering + filters = {"id": vod_id, "type": "movie", "m3u_account__is_active": True} + + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + raise Http404() + + try: + vod = VOD.objects.get(**filters) + except VOD.DoesNotExist: + raise Http404() + + info = { + "info": { + "tmdb_id": vod.tmdb_id or "", + "name": vod.name, + "o_name": vod.name, + "cover_big": ( + None if not vod.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[vod.logo.id]) + ) + ), + "movie_image": ( + None if not vod.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[vod.logo.id]) + ) + ), + "releasedate": f"{vod.year}-01-01" if vod.year else "", + "episode_run_time": (vod.duration or 0) * 60, + "youtube_trailer": "", + "director": "", + "actors": "", + "cast": "", + "description": vod.description or "", + "plot": vod.description or "", + "age": "", + "country": "", + "genre": vod.genre or "", + "backdrop_path": [], + "duration_secs": (vod.duration or 0) * 60, + "duration": f"{vod.duration or 0} min", + "video": {}, + "audio": {}, + "bitrate": 0, + "rating": float(vod.rating or 0), + }, + "movie_data": { + "stream_id": vod.id, + "name": vod.name, + "added": int(time.time()), + "category_id": str(vod.category.id) if vod.category else "0", + "container_extension": vod.container_extension or "mp4", + "custom_sid": "", + "direct_source": vod.url, + } + } + + return info diff --git a/apps/proxy/vod_proxy/__init__.py b/apps/proxy/vod_proxy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/proxy/vod_proxy/urls.py b/apps/proxy/vod_proxy/urls.py new file mode 100644 index 00000000..41a27600 --- /dev/null +++ b/apps/proxy/vod_proxy/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = 'vod_proxy' + +urlpatterns = [ + path('stream/', views.stream_vod, name='stream_vod'), + path('stream//position', views.update_position, name='update_position'), +] diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py new file mode 100644 index 00000000..75d50ca0 --- /dev/null +++ b/apps/proxy/vod_proxy/views.py @@ -0,0 +1,194 @@ +import time +import random +import logging +import requests +from django.http import StreamingHttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view + +from apps.vod.models import VOD, VODConnection + +from apps.m3u.models import M3UAccountProfile +from dispatcharr.utils import network_access_allowed, get_client_ip +from core.models import UserAgent, CoreSettings + +logger = logging.getLogger(__name__) + + +@csrf_exempt +@api_view(["GET"]) +def stream_vod(request, vod_uuid): + """Stream VOD content with connection tracking and range support""" + + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + # Get VOD object + vod = get_object_or_404(VOD, uuid=vod_uuid) + + # Generate client ID and get client info + client_id = f"vod_client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + client_ip = get_client_ip(request) + client_user_agent = request.META.get('HTTP_USER_AGENT', '') + + logger.info(f"[{client_id}] VOD stream request for: {vod.name}") + + try: + # Get available M3U profile for connection management + m3u_account = vod.m3u_account + available_profile = None + + for profile in m3u_account.profiles.filter(is_active=True): + current_connections = VODConnection.objects.filter(m3u_profile=profile).count() + if profile.max_streams == 0 or current_connections < profile.max_streams: + available_profile = profile + break + + if not available_profile: + return JsonResponse( + {"error": "No available connections for this VOD"}, + status=503 + ) + + # Create connection tracking record + connection = VODConnection.objects.create( + vod=vod, + m3u_profile=available_profile, + client_id=client_id, + client_ip=client_ip, + user_agent=client_user_agent + ) + + # Get user agent for upstream request + try: + user_agent_obj = m3u_account.get_user_agent() + upstream_user_agent = user_agent_obj.user_agent + except: + default_ua_id = CoreSettings.get_default_user_agent_id() + user_agent_obj = UserAgent.objects.get(id=default_ua_id) + upstream_user_agent = user_agent_obj.user_agent + + # Handle range requests for seeking + range_header = request.META.get('HTTP_RANGE') + headers = { + 'User-Agent': upstream_user_agent, + 'Connection': 'keep-alive' + } + + if range_header: + headers['Range'] = range_header + logger.debug(f"[{client_id}] Range request: {range_header}") + + # Stream the VOD content + try: + response = requests.get( + vod.url, + headers=headers, + stream=True, + timeout=(10, 60) + ) + + if response.status_code not in [200, 206]: + logger.error(f"[{client_id}] Upstream error: {response.status_code}") + connection.delete() + return JsonResponse( + {"error": f"Upstream server error: {response.status_code}"}, + status=response.status_code + ) + + # Determine content type + content_type = response.headers.get('Content-Type', 'video/mp4') + content_length = response.headers.get('Content-Length') + content_range = response.headers.get('Content-Range') + + # Create streaming response + def stream_generator(): + bytes_sent = 0 + try: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + bytes_sent += len(chunk) + yield chunk + + # Update connection activity periodically + if bytes_sent % (8192 * 10) == 0: # Every ~80KB + try: + connection.update_activity(bytes_sent=len(chunk)) + except VODConnection.DoesNotExist: + # Connection was cleaned up, stop streaming + break + + except Exception as e: + logger.error(f"[{client_id}] Streaming error: {e}") + finally: + # Clean up connection when streaming ends + try: + connection.delete() + logger.info(f"[{client_id}] Connection cleaned up") + except VODConnection.DoesNotExist: + pass + + # Build response with appropriate headers + streaming_response = StreamingHttpResponse( + stream_generator(), + content_type=content_type, + status=response.status_code + ) + + # Copy important headers + if content_length: + streaming_response['Content-Length'] = content_length + if content_range: + streaming_response['Content-Range'] = content_range + + # Add CORS and caching headers + streaming_response['Accept-Ranges'] = 'bytes' + streaming_response['Access-Control-Allow-Origin'] = '*' + streaming_response['Cache-Control'] = 'no-cache' + + logger.info(f"[{client_id}] Started streaming VOD: {vod.name}") + return streaming_response + + except requests.RequestException as e: + logger.error(f"[{client_id}] Request error: {e}") + connection.delete() + return JsonResponse( + {"error": "Failed to connect to upstream server"}, + status=502 + ) + + except Exception as e: + logger.error(f"[{client_id}] Unexpected error: {e}") + return JsonResponse( + {"error": "Internal server error"}, + status=500 + ) + + +@csrf_exempt +@api_view(["POST"]) +def update_position(request, vod_uuid): + """Update playback position for a VOD""" + + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + client_id = request.data.get('client_id') + position = request.data.get('position', 0) + + if not client_id: + return JsonResponse({"error": "Client ID required"}, status=400) + + try: + vod = get_object_or_404(VOD, uuid=vod_uuid) + connection = VODConnection.objects.get(vod=vod, client_id=client_id) + connection.update_activity(position=position) + + return JsonResponse({"status": "success"}) + + except VODConnection.DoesNotExist: + return JsonResponse({"error": "Connection not found"}, status=404) + except Exception as e: + logger.error(f"Position update error: {e}") + return JsonResponse({"error": "Internal server error"}, status=500) diff --git a/apps/vod/__init__.py b/apps/vod/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/vod/admin.py b/apps/vod/admin.py new file mode 100644 index 00000000..6aa8cd3d --- /dev/null +++ b/apps/vod/admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin +from .models import VOD, Series, VODCategory, VODConnection + + +@admin.register(VODCategory) +class VODCategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'm3u_account', 'created_at'] + list_filter = ['m3u_account', 'created_at'] + search_fields = ['name'] + + +@admin.register(Series) +class SeriesAdmin(admin.ModelAdmin): + list_display = ['name', 'year', 'genre', 'm3u_account', 'created_at'] + list_filter = ['m3u_account', 'category', 'year', 'created_at'] + search_fields = ['name', 'description', 'series_id'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + +@admin.register(VOD) +class VODAdmin(admin.ModelAdmin): + list_display = ['name', 'type', 'series', 'season_number', 'episode_number', 'year', 'm3u_account'] + list_filter = ['type', 'm3u_account', 'category', 'year', 'created_at'] + search_fields = ['name', 'description', 'stream_id'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('series', 'm3u_account', 'category') + + +@admin.register(VODConnection) +class VODConnectionAdmin(admin.ModelAdmin): + list_display = ['vod', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds'] + list_filter = ['connected_at', 'last_activity'] + search_fields = ['client_ip', 'client_id', 'vod__name'] + readonly_fields = ['connected_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('vod', 'm3u_profile') diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py new file mode 100644 index 00000000..142dfad3 --- /dev/null +++ b/apps/vod/api_views.py @@ -0,0 +1,154 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.decorators import action +from rest_framework.filters import SearchFilter, OrderingFilter +from django_filters.rest_framework import DjangoFilterBackend +from django.shortcuts import get_object_or_404 +import django_filters +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, +) +from .models import VOD, Series, VODCategory, VODConnection +from .serializers import ( + VODSerializer, + SeriesSerializer, + VODCategorySerializer, + VODConnectionSerializer +) + + +class VODFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + type = django_filters.ChoiceFilter(choices=VOD.TYPE_CHOICES) + category = django_filters.CharFilter(field_name="category__name", lookup_expr="icontains") + series = django_filters.NumberFilter(field_name="series__id") + m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") + year = django_filters.NumberFilter() + year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte") + year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte") + + class Meta: + model = VOD + fields = ['name', 'type', 'category', 'series', 'm3u_account', 'year'] + + +class VODViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for VOD content (Movies and Episodes)""" + queryset = VOD.objects.all() + serializer_class = VODSerializer + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = VODFilter + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at', 'season_number', 'episode_number'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + return VOD.objects.select_related( + 'series', 'category', 'logo', 'm3u_account' + ).filter(m3u_account__is_active=True) + + @action(detail=False, methods=['get']) + def movies(self, request): + """Get only movie content""" + movies = self.get_queryset().filter(type='movie') + movies = self.filter_queryset(movies) + + page = self.paginate_queryset(movies) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(movies, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def episodes(self, request): + """Get only episode content""" + episodes = self.get_queryset().filter(type='episode') + episodes = self.filter_queryset(episodes) + + page = self.paginate_queryset(episodes) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(episodes, many=True) + return Response(serializer.data) + + +class SeriesViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for Series management""" + queryset = Series.objects.all() + serializer_class = SeriesSerializer + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + return Series.objects.select_related( + 'category', 'logo', 'm3u_account' + ).prefetch_related('episodes').filter(m3u_account__is_active=True) + + @action(detail=True, methods=['get']) + def episodes(self, request, pk=None): + """Get episodes for a specific series""" + series = self.get_object() + episodes = series.episodes.all().order_by('season_number', 'episode_number') + + page = self.paginate_queryset(episodes) + if page is not None: + serializer = VODSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = VODSerializer(episodes, many=True) + return Response(serializer.data) + + +class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for VOD Categories""" + queryset = VODCategory.objects.all() + serializer_class = VODCategorySerializer + + filter_backends = [SearchFilter, OrderingFilter] + search_fields = ['name'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +class VODConnectionViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for monitoring VOD connections""" + queryset = VODConnection.objects.all() + serializer_class = VODConnectionSerializer + + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering = ['-connected_at'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + return VODConnection.objects.select_related('vod', 'm3u_profile') diff --git a/apps/vod/apps.py b/apps/vod/apps.py new file mode 100644 index 00000000..0e2af56d --- /dev/null +++ b/apps/vod/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class VODConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.vod' + verbose_name = 'Video on Demand' + + def ready(self): + """Initialize VOD app when Django is ready""" + # Import models to ensure they're registered + from . import models diff --git a/apps/vod/migrations/0001_initial.py b/apps/vod/migrations/0001_initial.py new file mode 100644 index 00000000..af882079 --- /dev/null +++ b/apps/vod/migrations/0001_initial.py @@ -0,0 +1,121 @@ +# Generated by Django 5.2.4 on 2025-08-02 15:33 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'), + ('m3u', '0012_alter_m3uaccount_refresh_interval'), + ] + + operations = [ + migrations.CreateModel( + name='Series', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('year', models.IntegerField(blank=True, null=True)), + ('rating', models.CharField(blank=True, max_length=10, null=True)), + ('genre', models.CharField(blank=True, max_length=255, null=True)), + ('series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)), + ('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)), + ('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)), + ('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series', to='m3u.m3uaccount')), + ], + options={ + 'verbose_name': 'Series', + 'verbose_name_plural': 'Series', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='VODCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vod_categories', to='m3u.m3uaccount')), + ], + options={ + 'verbose_name': 'VOD Category', + 'verbose_name_plural': 'VOD Categories', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='VOD', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('year', models.IntegerField(blank=True, null=True)), + ('rating', models.CharField(blank=True, max_length=10, null=True)), + ('genre', models.CharField(blank=True, max_length=255, null=True)), + ('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)), + ('type', models.CharField(choices=[('movie', 'Movie'), ('episode', 'Episode')], default='movie', max_length=10)), + ('season_number', models.IntegerField(blank=True, null=True)), + ('episode_number', models.IntegerField(blank=True, null=True)), + ('url', models.URLField(max_length=2048)), + ('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), + ('container_extension', models.CharField(blank=True, max_length=10, null=True)), + ('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)), + ('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)), + ('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vods', to='m3u.m3uaccount')), + ('series', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), + ], + options={ + 'verbose_name': 'VOD', + 'verbose_name_plural': 'VODs', + 'ordering': ['name', 'season_number', 'episode_number'], + 'unique_together': {('stream_id', 'm3u_account')}, + }, + ), + migrations.AddField( + model_name='series', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory'), + ), + migrations.AlterUniqueTogether( + name='series', + unique_together={('series_id', 'm3u_account')}, + ), + migrations.CreateModel( + name='VODConnection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('client_id', models.CharField(max_length=255)), + ('client_ip', models.GenericIPAddressField()), + ('user_agent', models.TextField(blank=True, null=True)), + ('connected_at', models.DateTimeField(auto_now_add=True)), + ('last_activity', models.DateTimeField(auto_now=True)), + ('bytes_sent', models.BigIntegerField(default=0)), + ('position_seconds', models.IntegerField(default=0, help_text='Current playback position')), + ('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')), + ('vod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connections', to='vod.vod')), + ], + options={ + 'verbose_name': 'VOD Connection', + 'verbose_name_plural': 'VOD Connections', + 'unique_together': {('vod', 'client_id')}, + }, + ), + ] diff --git a/apps/vod/migrations/__init__.py b/apps/vod/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/vod/models.py b/apps/vod/models.py new file mode 100644 index 00000000..7302bfcd --- /dev/null +++ b/apps/vod/models.py @@ -0,0 +1,155 @@ +from django.db import models +from django.utils import timezone +from apps.m3u.models import M3UAccount +from apps.channels.models import Logo +import uuid + + +class VODCategory(models.Model): + """Categories for organizing VODs (e.g., Action, Comedy, Drama)""" + name = models.CharField(max_length=255, unique=True) + m3u_account = models.ForeignKey( + M3UAccount, + on_delete=models.CASCADE, + related_name='vod_categories', + null=True, + blank=True + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "VOD Category" + verbose_name_plural = "VOD Categories" + ordering = ['name'] + + def __str__(self): + return self.name + + +class Series(models.Model): + """Series information for TV shows""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + year = models.IntegerField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + genre = models.CharField(max_length=255, blank=True, null=True) + logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True) + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + m3u_account = models.ForeignKey( + M3UAccount, + on_delete=models.CASCADE, + related_name='series' + ) + series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider") + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata") + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata") + custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Series" + verbose_name_plural = "Series" + ordering = ['name'] + unique_together = ['series_id', 'm3u_account'] + + def __str__(self): + return f"{self.name} ({self.year or 'Unknown'})" + + +class VOD(models.Model): + """Video on Demand content (Movies and Episodes)""" + TYPE_CHOICES = [ + ('movie', 'Movie'), + ('episode', 'Episode'), + ] + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + year = models.IntegerField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + genre = models.CharField(max_length=255, blank=True, null=True) + duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes") + type = models.CharField(max_length=10, choices=TYPE_CHOICES, default='movie') + + # Episode specific fields + series = models.ForeignKey(Series, on_delete=models.CASCADE, null=True, blank=True, related_name='episodes') + season_number = models.IntegerField(blank=True, null=True) + episode_number = models.IntegerField(blank=True, null=True) + + # Streaming information + url = models.URLField(max_length=2048) + logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True) + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + + # M3U relationship + m3u_account = models.ForeignKey( + M3UAccount, + on_delete=models.CASCADE, + related_name='vods' + ) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Metadata IDs + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata") + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata") + + # Additional properties + custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "VOD" + verbose_name_plural = "VODs" + ordering = ['name', 'season_number', 'episode_number'] + unique_together = ['stream_id', 'm3u_account'] + + def __str__(self): + if self.type == 'episode' and self.series: + season_ep = f"S{self.season_number:02d}E{self.episode_number:02d}" if self.season_number and self.episode_number else "" + return f"{self.series.name} {season_ep} - {self.name}" + return f"{self.name} ({self.year or 'Unknown'})" + + def get_stream_url(self): + """Generate the proxied stream URL for this VOD""" + return f"/proxy/vod/stream/{self.uuid}" + + +class VODConnection(models.Model): + """Track active VOD connections for connection limit management""" + vod = models.ForeignKey(VOD, on_delete=models.CASCADE, related_name='connections') + m3u_profile = models.ForeignKey( + 'm3u.M3UAccountProfile', + on_delete=models.CASCADE, + related_name='vod_connections' + ) + client_id = models.CharField(max_length=255) + client_ip = models.GenericIPAddressField() + user_agent = models.TextField(blank=True, null=True) + connected_at = models.DateTimeField(auto_now_add=True) + last_activity = models.DateTimeField(auto_now=True) + bytes_sent = models.BigIntegerField(default=0) + position_seconds = models.IntegerField(default=0, help_text="Current playback position") + + class Meta: + verbose_name = "VOD Connection" + verbose_name_plural = "VOD Connections" + unique_together = ['vod', 'client_id'] + + def __str__(self): + return f"{self.vod.name} - {self.client_ip} ({self.client_id})" + + def update_activity(self, bytes_sent=0, position=0): + """Update connection activity""" + self.last_activity = timezone.now() + if bytes_sent: + self.bytes_sent += bytes_sent + if position: + self.position_seconds = position + self.save(update_fields=['last_activity', 'bytes_sent', 'position_seconds']) diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py new file mode 100644 index 00000000..1e070e9c --- /dev/null +++ b/apps/vod/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers +from .models import VOD, Series, VODCategory, VODConnection +from apps.channels.serializers import LogoSerializer +from apps.m3u.serializers import M3UAccountSerializer + + +class VODCategorySerializer(serializers.ModelSerializer): + class Meta: + model = VODCategory + fields = '__all__' + + +class SeriesSerializer(serializers.ModelSerializer): + logo = LogoSerializer(read_only=True) + category = VODCategorySerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + episode_count = serializers.SerializerMethodField() + + class Meta: + model = Series + fields = '__all__' + + def get_episode_count(self, obj): + return obj.episodes.count() + + +class VODSerializer(serializers.ModelSerializer): + logo = LogoSerializer(read_only=True) + category = VODCategorySerializer(read_only=True) + series = SeriesSerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + stream_url = serializers.SerializerMethodField() + + class Meta: + model = VOD + fields = '__all__' + + def get_stream_url(self, obj): + return obj.get_stream_url() + + +class VODConnectionSerializer(serializers.ModelSerializer): + vod = VODSerializer(read_only=True) + + class Meta: + model = VODConnection + fields = '__all__' diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py new file mode 100644 index 00000000..89c08b11 --- /dev/null +++ b/apps/vod/tasks.py @@ -0,0 +1,268 @@ +import logging +import requests +import json +from celery import shared_task +from django.utils import timezone +from datetime import timedelta +from .models import VOD, Series, VODCategory, VODConnection +from apps.m3u.models import M3UAccount +from apps.channels.models import Logo + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True) +def refresh_vod_content(self, account_id): + """Refresh VOD content from XtreamCodes API""" + try: + account = M3UAccount.objects.get(id=account_id) + if account.account_type != M3UAccount.Types.XC: + logger.warning(f"Account {account_id} is not XtreamCodes type") + return + + # Get movies and series + refresh_movies(account) + refresh_series(account) + + logger.info(f"Successfully refreshed VOD content for account {account_id}") + + except M3UAccount.DoesNotExist: + logger.error(f"M3U Account {account_id} not found") + except Exception as e: + logger.error(f"Error refreshing VOD content for account {account_id}: {e}") + + +def refresh_movies(account): + """Refresh movie content""" + try: + # Get movie categories + categories_url = f"{account.server_url}/player_api.php" + params = { + 'username': account.username, + 'password': account.password, + 'action': 'get_vod_categories' + } + + response = requests.get(categories_url, params=params, timeout=30) + response.raise_for_status() + categories_data = response.json() + + # Create/update categories + for cat_data in categories_data: + VODCategory.objects.get_or_create( + name=cat_data['category_name'], + m3u_account=account, + defaults={'name': cat_data['category_name']} + ) + + # Get movies + movies_url = f"{account.server_url}/player_api.php" + params['action'] = 'get_vod_streams' + + response = requests.get(movies_url, params=params, timeout=30) + response.raise_for_status() + movies_data = response.json() + + for movie_data in movies_data: + try: + # Get category + category = None + if movie_data.get('category_id'): + try: + category = VODCategory.objects.get( + name__icontains=movie_data.get('category_name', ''), + m3u_account=account + ) + except VODCategory.DoesNotExist: + pass + + # Create/update movie + stream_url = f"{account.server_url}/movie/{account.username}/{account.password}/{movie_data['stream_id']}.{movie_data.get('container_extension', 'mp4')}" + + vod_data = { + 'name': movie_data['name'], + 'type': 'movie', + 'url': stream_url, + 'category': category, + 'year': movie_data.get('year'), + 'rating': movie_data.get('rating'), + 'genre': movie_data.get('genre'), + 'duration': movie_data.get('duration_secs', 0) // 60 if movie_data.get('duration_secs') else None, + 'container_extension': movie_data.get('container_extension'), + 'tmdb_id': movie_data.get('tmdb_id'), + 'imdb_id': movie_data.get('imdb_id'), + 'custom_properties': json.dumps(movie_data) if movie_data else None + } + + vod, created = VOD.objects.update_or_create( + stream_id=movie_data['stream_id'], + m3u_account=account, + defaults=vod_data + ) + + # Handle logo + if movie_data.get('stream_icon'): + logo, _ = Logo.objects.get_or_create( + url=movie_data['stream_icon'], + defaults={'name': movie_data['name']} + ) + vod.logo = logo + vod.save() + + except Exception as e: + logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {e}") + continue + + except Exception as e: + logger.error(f"Error refreshing movies for account {account.id}: {e}") + + +def refresh_series(account): + """Refresh series and episodes content""" + try: + # Get series categories + categories_url = f"{account.server_url}/player_api.php" + params = { + 'username': account.username, + 'password': account.password, + 'action': 'get_series_categories' + } + + response = requests.get(categories_url, params=params, timeout=30) + response.raise_for_status() + categories_data = response.json() + + # Create/update series categories + for cat_data in categories_data: + VODCategory.objects.get_or_create( + name=cat_data['category_name'], + m3u_account=account, + defaults={'name': cat_data['category_name']} + ) + + # Get series list + series_url = f"{account.server_url}/player_api.php" + params['action'] = 'get_series' + + response = requests.get(series_url, params=params, timeout=30) + response.raise_for_status() + series_data = response.json() + + for series_item in series_data: + try: + # Get category + category = None + if series_item.get('category_id'): + try: + category = VODCategory.objects.get( + name__icontains=series_item.get('category_name', ''), + m3u_account=account + ) + except VODCategory.DoesNotExist: + pass + + # Create/update series + series_data_dict = { + 'name': series_item['name'], + 'description': series_item.get('plot'), + 'year': series_item.get('year'), + 'rating': series_item.get('rating'), + 'genre': series_item.get('genre'), + 'category': category, + 'tmdb_id': series_item.get('tmdb_id'), + 'imdb_id': series_item.get('imdb_id'), + 'custom_properties': json.dumps(series_item) if series_item else None + } + + series, created = Series.objects.update_or_create( + series_id=series_item['series_id'], + m3u_account=account, + defaults=series_data_dict + ) + + # Handle series logo + if series_item.get('cover'): + logo, _ = Logo.objects.get_or_create( + url=series_item['cover'], + defaults={'name': series_item['name']} + ) + series.logo = logo + series.save() + + # Get series episodes + refresh_series_episodes(account, series, series_item['series_id']) + + except Exception as e: + logger.error(f"Error processing series {series_item.get('name', 'Unknown')}: {e}") + continue + + except Exception as e: + logger.error(f"Error refreshing series for account {account.id}: {e}") + + +def refresh_series_episodes(account, series, series_id): + """Refresh episodes for a specific series""" + try: + episodes_url = f"{account.server_url}/player_api.php" + params = { + 'username': account.username, + 'password': account.password, + 'action': 'get_series_info', + 'series_id': series_id + } + + response = requests.get(episodes_url, params=params, timeout=30) + response.raise_for_status() + series_info = response.json() + + # Process episodes by season + if 'episodes' in series_info: + for season_num, episodes in series_info['episodes'].items(): + for episode_data in episodes: + try: + # Build episode stream URL + stream_url = f"{account.server_url}/series/{account.username}/{account.password}/{episode_data['id']}.{episode_data.get('container_extension', 'mp4')}" + + episode_dict = { + 'name': episode_data.get('title', f"Episode {episode_data.get('episode_num', '')}"), + 'type': 'episode', + 'series': series, + 'season_number': int(season_num) if season_num.isdigit() else None, + 'episode_number': episode_data.get('episode_num'), + 'url': stream_url, + 'description': episode_data.get('plot'), + 'year': episode_data.get('air_date', '').split('-')[0] if episode_data.get('air_date') else None, + 'rating': episode_data.get('rating'), + 'duration': episode_data.get('duration_secs', 0) // 60 if episode_data.get('duration_secs') else None, + 'container_extension': episode_data.get('container_extension'), + 'tmdb_id': episode_data.get('tmdb_id'), + 'imdb_id': episode_data.get('imdb_id'), + 'custom_properties': json.dumps(episode_data) if episode_data else None + } + + VOD.objects.update_or_create( + stream_id=episode_data['id'], + m3u_account=account, + defaults=episode_dict + ) + + except Exception as e: + logger.error(f"Error processing episode {episode_data.get('title', 'Unknown')}: {e}") + continue + + except Exception as e: + logger.error(f"Error refreshing episodes for series {series_id}: {e}") + + +@shared_task +def cleanup_inactive_vod_connections(): + """Clean up inactive VOD connections""" + cutoff_time = timezone.now() - timedelta(minutes=30) + inactive_connections = VODConnection.objects.filter(last_activity__lt=cutoff_time) + + count = inactive_connections.count() + if count > 0: + inactive_connections.delete() + logger.info(f"Cleaned up {count} inactive VOD connections") + + return count diff --git a/apps/vod/urls.py b/apps/vod/urls.py new file mode 100644 index 00000000..4a00172a --- /dev/null +++ b/apps/vod/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import VODViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet + +app_name = 'vod' + +router = DefaultRouter() +router.register(r'vods', VODViewSet) +router.register(r'series', SeriesViewSet) +router.register(r'categories', VODCategoryViewSet) +router.register(r'connections', VODConnectionViewSet) + +urlpatterns = [ + path('api/', include(router.urls)), +] diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index acac4c1a..040e9156 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -28,6 +28,7 @@ INSTALLED_APPS = [ "apps.output", "apps.proxy.apps.ProxyConfig", "apps.proxy.ts_proxy", + "apps.vod.apps.VODConfig", "core", "daphne", "drf_yasg", diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 3e891314..143b6e4c 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -65,6 +65,9 @@ urlpatterns = [ path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), # Optionally, serve the raw Swagger JSON path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"), + # VOD + path("api/vod/", include("apps.vod.urls")), + path("proxy/vod/", include("apps.proxy.vod_proxy.urls")), # Catch-all routes should always be last path("", TemplateView.as_view(template_name="index.html")), # React entry point path("", TemplateView.as_view(template_name="index.html")), From 386a03381c53ae14eb82f2e753616cca417837b2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 2 Aug 2025 10:48:48 -0500 Subject: [PATCH 041/782] Initial frontend commit for vods. --- frontend/src/App.jsx | 2 + frontend/src/api.js | 72 +++++ frontend/src/components/Sidebar.jsx | 7 +- frontend/src/constants.js | 22 ++ frontend/src/pages/VODs.jsx | 418 ++++++++++++++++++++++++++++ frontend/src/store/useVODStore.jsx | 131 +++++++++ 6 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/VODs.jsx create mode 100644 frontend/src/store/useVODStore.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4467759e..749a53c1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -16,6 +16,7 @@ import DVR from './pages/DVR'; import Settings from './pages/Settings'; import Users from './pages/Users'; import LogosPage from './pages/Logos'; +import VODsPage from './pages/VODs'; import useAuthStore from './store/auth'; import FloatingVideo from './components/FloatingVideo'; import { WebsocketProvider } from './WebSocket'; @@ -135,6 +136,7 @@ const App = () => { } /> } /> } /> + } /> ) : ( } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index ddaccbc7..cfdf1a90 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1689,4 +1689,76 @@ export default class API { errorNotification('Failed to retrieve streams by IDs', e); } } + + // VOD Methods + static async getVODs(params = {}) { + try { + const searchParams = new URLSearchParams(params); + const response = await request(`${host}/api/vod/vods/?${searchParams.toString()}`); + return response; + } catch (e) { + errorNotification('Failed to retrieve VODs', e); + } + } + + static async getVODCategories() { + try { + const response = await request(`${host}/api/vod/categories/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve VOD categories', e); + } + } + + static async getSeries(params = {}) { + try { + const searchParams = new URLSearchParams(params); + const response = await request(`${host}/api/vod/series/?${searchParams.toString()}`); + return response; + } catch (e) { + errorNotification('Failed to retrieve series', e); + } + } + + static async getSeriesEpisodes(seriesId, params = {}) { + try { + const searchParams = new URLSearchParams(params); + const response = await request(`${host}/api/vod/series/${seriesId}/episodes/?${searchParams.toString()}`); + return response; + } catch (e) { + errorNotification('Failed to retrieve series episodes', e); + } + } + + static async getVODConnections() { + try { + const response = await request(`${host}/api/vod/connections/`); + return response; + } catch (e) { + errorNotification('Failed to retrieve VOD connections', e); + } + } + + static async refreshVODContent(accountId) { + try { + const response = await request(`${host}/api/m3u/accounts/${accountId}/refresh-vod/`, { + method: 'POST' + }); + return response; + } catch (e) { + errorNotification('Failed to refresh VOD content', e); + } + } + + static async updateVODPosition(vodUuid, clientId, position) { + try { + const response = await request(`${host}/proxy/vod/stream/${vodUuid}/position/`, { + method: 'POST', + body: { client_id: clientId, position } + }); + return response; + } catch (e) { + errorNotification('Failed to update playback position', e); + } + } } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 03ad831e..998bc768 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -99,13 +99,18 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { path: '/channels', badge: `(${Object.keys(channels).length})`, }, + { + label: 'VODs', + path: '/vods', + icon: