From c82391f57b13cef8db9eed1f5701b5f43ea4a759 Mon Sep 17 00:00:00 2001 From: kappa118 Date: Sat, 1 Mar 2025 09:25:45 -0500 Subject: [PATCH 1/3] merged in from main --- apps/channels/api_urls.py | 6 +-- apps/channels/api_views.py | 2 +- apps/epg/api_views.py | 2 +- docker/DockerfileAIO | 2 + docker/docker-compose-aio.yml | 1 - frontend/package-lock.json | 3 -- frontend/src/api.js | 43 ++++++++++++++----- .../src/components/tables/ChannelsTable.js | 15 +++++-- .../src/components/tables/StreamsTable.js | 11 ++--- frontend/src/pages/Guide.js | 36 +++++++++------- frontend/src/store/channels.js | 23 ++++++---- frontend/src/store/useVideoStore.js | 18 ++++---- 12 files changed, 100 insertions(+), 62 deletions(-) diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 78046dce..110b4369 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -5,7 +5,7 @@ from .api_views import ( ChannelViewSet, ChannelGroupViewSet, BulkDeleteStreamsAPIView, - BulkDeleteChannelsViewSet + BulkDeleteChannelsAPIView ) app_name = 'channels' # for DRF routing @@ -14,11 +14,11 @@ router = DefaultRouter() router.register(r'streams', StreamViewSet, basename='stream') router.register(r'groups', ChannelGroupViewSet, basename='channel-group') router.register(r'channels', ChannelViewSet, basename='channel') -router.register(r'bulk-delete-channels', BulkDeleteChannelsViewSet, basename='bulk-delete-channels') urlpatterns = [ - # Bulk delete for streams is a single APIView, not a ViewSet + # Bulk delete is a single APIView, not a ViewSet path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'), + path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'), ] urlpatterns += router.urls diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 02440799..ea55e3e6 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -265,7 +265,7 @@ class BulkDeleteStreamsAPIView(APIView): # ───────────────────────────────────────────────────────── # 5) Bulk Delete Channels # ───────────────────────────────────────────────────────── -class BulkDeleteChannelsViewSet(viewsets.ViewSet): +class BulkDeleteChannelsAPIView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 4b324a5f..f59f63e2 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -59,7 +59,7 @@ class EPGGridAPIView(APIView): start_time__gte=now, start_time__lte=twelve_hours_later ) count = programs.count() - logger.debug(f"EPGGridAPIView: Found {count} program(s).") + logger.debug(f"EPG`Grid`APIView: Found {count} program(s).") serializer = ProgramDataSerializer(programs, many=True) return Response({'data': serializer.data}, status=status.HTTP_200_OK) diff --git a/docker/DockerfileAIO b/docker/DockerfileAIO index fa9fa40a..86c9d33b 100755 --- a/docker/DockerfileAIO +++ b/docker/DockerfileAIO @@ -1,5 +1,7 @@ FROM python:3.10-slim +ENV API_PORT=5656 + # Add PostgreSQL repository RUN apt-get update && apt-get install -y wget gnupg2 && \ echo "deb http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ diff --git a/docker/docker-compose-aio.yml b/docker/docker-compose-aio.yml index 217005df..1bae29fc 100644 --- a/docker/docker-compose-aio.yml +++ b/docker/docker-compose-aio.yml @@ -21,4 +21,3 @@ services: - DJANGO_SUPERUSER_EMAIL=admin@dispatcharr.local volumes: - ./data:/app/data - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9344c259..816be251 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11475,7 +11475,6 @@ "mkdirp": "bin/cmd.js" } }, -<<<<<<< HEAD "node_modules/mpd-parser": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz", @@ -11491,8 +11490,6 @@ "mpd-to-m3u8-json": "bin/parse.js" } }, -======= ->>>>>>> origin/main "node_modules/mpegts.js": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz", diff --git a/frontend/src/api.js b/frontend/src/api.js index efce4c38..671cda3e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -157,18 +157,18 @@ export default class API { } // @TODO: the bulk delete endpoint is currently broken - // static async deleteChannels(channel_ids) { - // const response = await fetch(`${host}/api/channels/bulk-delete-channels/0/`, { - // method: 'DELETE', - // headers: { - // Authorization: `Bearer ${await getAuthToken()}`, - // 'Content-Type': 'application/json', - // }, - // body: JSON.stringify({ channel_ids }), - // }); + static async deleteChannels(channel_ids) { + const response = await fetch(`${host}/api/channels/channels/bulk-delete/`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${await getAuthToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ channel_ids }), + }); - // useChannelsStore.getState().removeChannels(channel_ids) - // } + useChannelsStore.getState().removeChannels(channel_ids); + } static async updateChannel(values) { const { id, ...payload } = values; @@ -225,6 +225,27 @@ export default class API { return retval; } + static async createChannelsFromStreams(values) { + const response = await fetch( + `${host}/api/channels/channels/from-stream/bulk/`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await getAuthToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + } + ); + + const retval = await response.json(); + if (retval.created.length > 0) { + useChannelsStore.getState().addChannels(retval.created); + } + + return retval; + } + static async getStreams() { const response = await fetch(`${host}/api/channels/streams/`, { headers: { diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js index 95476e0c..b151b941 100644 --- a/frontend/src/components/tables/ChannelsTable.js +++ b/frontend/src/components/tables/ChannelsTable.js @@ -30,6 +30,7 @@ import { TableHelper } from '../../helpers'; import utils from '../../utils'; import { ContentCopy } from '@mui/icons-material'; import logo from '../../images/logo.png'; +import useVideoStore from '../../store/useVideoStore'; // NEW import const Example = () => { const [channel, setChannel] = useState(null); @@ -43,6 +44,7 @@ const Example = () => { const [snackbarOpen, setSnackbarOpen] = useState(false); const { channels, isLoading: channelsLoading } = useChannelsStore(); + const { showVideo } = useVideoStore.getState(); // or useVideoStore() const columns = useMemo( //column definitions... @@ -104,6 +106,10 @@ const Example = () => { await API.deleteChannel(id); }; + function handleWatchStream(channelNumber) { + showVideo(`/output/stream/${channelNumber}/`); + } + // @TODO: the bulk delete endpoint is currently broken const deleteChannels = async () => { setIsLoading(true); @@ -116,6 +122,7 @@ const Example = () => { return deleteChannel(chan.original.id); }) ); + // await API.deleteChannels(selected.map((sel) => sel.id)); setIsLoading(false); }; @@ -227,14 +234,14 @@ const Example = () => { > - {/* previewChannel(row.original.id)} + color="info" + onClick={() => handleWatchStream(row.original.channel_number)} sx={{ p: 0 }} > - */} + ), muiTableContainerProps: { diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js index 954432a8..ba7766c1 100644 --- a/frontend/src/components/tables/StreamsTable.js +++ b/frontend/src/components/tables/StreamsTable.js @@ -71,11 +71,12 @@ const Example = () => { const selected = table .getRowModel() .rows.filter((row) => row.getIsSelected()); - await utils.Limiter( - 4, - selected.map((stream) => () => { - return createChannelFromStream(stream.original); - }) + + await API.createChannelsFromStreams( + selected.map((sel) => ({ + stream_id: sel.original.id, + channel_name: sel.original.name, + })) ); setIsLoading(false); }; diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js index ad5eded3..c7de50b8 100644 --- a/frontend/src/pages/Guide.js +++ b/frontend/src/pages/Guide.js @@ -19,10 +19,10 @@ import logo from '../images/logo.png'; import useVideoStore from '../store/useVideoStore'; // NEW import /** Layout constants */ -const CHANNEL_WIDTH = 120; // Width of the channel/logo column -const PROGRAM_HEIGHT = 90; // Height of each channel row -const HOUR_WIDTH = 300; // The width for a 1-hour block -const MINUTE_INCREMENT = 15; // For positioning programs every 15 min +const CHANNEL_WIDTH = 120; // Width of the channel/logo column +const PROGRAM_HEIGHT = 90; // Height of each channel row +const HOUR_WIDTH = 300; // The width for a 1-hour block +const MINUTE_INCREMENT = 15; // For positioning programs every 15 min const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT); // Modal size constants @@ -72,7 +72,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }; fetchPrograms(); - }, [channels, activeChannels]); + }, [channels]); // Use start/end from props or default to "today at midnight" +24h const defaultStart = dayjs(startDate || dayjs().startOf('day')); @@ -129,7 +129,8 @@ export default function TVChannelGuide({ startDate, endDate }) { if (guideRef.current) { const nowOffset = dayjs().diff(start, 'minute'); const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; + (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - + MINUTE_BLOCK_WIDTH; guideRef.current.scrollLeft = Math.max(scrollPosition, 0); } }, [programs, start]); @@ -173,7 +174,10 @@ export default function TVChannelGuide({ startDate, endDate }) { // On program click, open the details modal function handleProgramClick(program, event) { // Optionally scroll that element into view or do something else - event.currentTarget.scrollIntoView({ behavior: 'smooth', inline: 'center' }); + event.currentTarget.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + }); setSelectedProgram(program); } @@ -383,7 +387,7 @@ export default function TVChannelGuide({ startDate, endDate }) { width: '1px', height: '10px', backgroundColor: '#718096', - marginRight: i < 3 ? (HOUR_WIDTH / 4 - 1) + 'px' : 0, + marginRight: i < 3 ? HOUR_WIDTH / 4 - 1 + 'px' : 0, }} /> ))} @@ -473,14 +477,14 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Only show the Watch button if currently live */} {now.isAfter(dayjs(selectedProgram.start_time)) && - now.isBefore(dayjs(selectedProgram.end_time)) && ( - - )} + now.isBefore(dayjs(selectedProgram.end_time)) && ( + + )} diff --git a/frontend/src/store/channels.js b/frontend/src/store/channels.js index 10338c7d..5542e26c 100644 --- a/frontend/src/store/channels.js +++ b/frontend/src/store/channels.js @@ -1,5 +1,5 @@ -import { create } from "zustand"; -import api from "../api"; +import { create } from 'zustand'; +import api from '../api'; const useChannelsStore = create((set) => ({ channels: [], @@ -13,8 +13,8 @@ const useChannelsStore = create((set) => ({ const channels = await api.getChannels(); set({ channels: channels, isLoading: false }); } catch (error) { - console.error("Failed to fetch channels:", error); - set({ error: "Failed to load channels.", isLoading: false }); + console.error('Failed to fetch channels:', error); + set({ error: 'Failed to load channels.', isLoading: false }); } }, @@ -24,8 +24,8 @@ const useChannelsStore = create((set) => ({ const channelGroups = await api.getChannelGroups(); set({ channelGroups: channelGroups, isLoading: false }); } catch (error) { - console.error("Failed to fetch channel groups:", error); - set({ error: "Failed to load channel groups.", isLoading: false }); + console.error('Failed to fetch channel groups:', error); + set({ error: 'Failed to load channel groups.', isLoading: false }); } }, @@ -34,17 +34,22 @@ const useChannelsStore = create((set) => ({ channels: [...state.channels, newChannel], })), + addChannels: (newChannels) => + set((state) => ({ + channels: state.channels.concat(newChannels), + })), + updateChannel: (userAgent) => set((state) => ({ channels: state.channels.map((chan) => - chan.id === userAgent.id ? userAgent : chan, + chan.id === userAgent.id ? userAgent : chan ), })), removeChannels: (channelIds) => set((state) => ({ channels: state.channels.filter( - (channel) => !channelIds.includes(channel.id), + (channel) => !channelIds.includes(channel.id) ), })), @@ -56,7 +61,7 @@ const useChannelsStore = create((set) => ({ updateChannelGroup: (channelGroup) => set((state) => ({ channelGroups: state.channelGroups.map((group) => - group.id === channelGroup.id ? channelGroup : group, + group.id === channelGroup.id ? channelGroup : group ), })), })); diff --git a/frontend/src/store/useVideoStore.js b/frontend/src/store/useVideoStore.js index 5667922d..0229552a 100644 --- a/frontend/src/store/useVideoStore.js +++ b/frontend/src/store/useVideoStore.js @@ -8,15 +8,17 @@ const useVideoStore = create((set) => ({ isVisible: false, streamUrl: null, - showVideo: (url) => set({ - isVisible: true, - streamUrl: url, - }), + showVideo: (url) => + set({ + isVisible: true, + streamUrl: url, + }), - hideVideo: () => set({ - isVisible: false, - streamUrl: null, - }), + hideVideo: () => + set({ + isVisible: false, + streamUrl: null, + }), })); export default useVideoStore; From d58f33fe319a35dfdcf240c5e4966c16a80f5bff Mon Sep 17 00:00:00 2001 From: kappa118 Date: Sat, 1 Mar 2025 10:18:59 -0500 Subject: [PATCH 2/3] updated core settings, use default stream profile --- core/admin.py | 32 ++---------------------- core/fixtures/initial_data.json | 44 +++++++++++++++++++++++++++++++++ core/models.py | 38 +++------------------------- core/views.py | 7 +++--- 4 files changed, 54 insertions(+), 67 deletions(-) create mode 100644 core/fixtures/initial_data.json diff --git a/core/admin.py b/core/admin.py index 4ff53406..823e6a5a 100644 --- a/core/admin.py +++ b/core/admin.py @@ -36,34 +36,6 @@ class CoreSettingsAdmin(admin.ModelAdmin): just list and allow editing of any instance. """ list_display = ( - "default_user_agent", - "default_stream_profile", - "stream_command_timeout", - "enable_stream_logging", - "useragent_cache_timeout", - "streamprofile_cache_timeout", - "streamlink_path", - "vlc_path", - ) - fieldsets = ( - (None, { - "fields": ( - "default_user_agent", - "default_stream_profile", - "stream_command_timeout", - "enable_stream_logging", - ) - }), - ("Caching", { - "fields": ( - "useragent_cache_timeout", - "streamprofile_cache_timeout", - ) - }), - ("Paths", { - "fields": ( - "streamlink_path", - "vlc_path", - ) - }), + "key", + "value", ) diff --git a/core/fixtures/initial_data.json b/core/fixtures/initial_data.json new file mode 100644 index 00000000..57bafd05 --- /dev/null +++ b/core/fixtures/initial_data.json @@ -0,0 +1,44 @@ +[ + { + "model": "core.useragent", + "pk": 1, + "fields": { + "user_agent_name": "TiviMate", + "user_agent": "TiviMate/5.1.6 (Android 12)", + "description": "", + "is_active": true + } + }, + { + "model": "core.useragent", + "pk": 2, + "fields": { + "user_agent_name": "VLC", + "user_agent": "VLC/3.0.21 LibVLC 3.0.21", + "description": "", + "is_active": true + } + }, + { + "model": "core.streamprofile", + "pk": 1, + "fields": { + "profile_name": "ffmpeg", + "command": "ffmpeg", + "parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", + "is_active": true, + "user_agent": "1" + } + }, + { + "model": "core.streamprofile", + "pk": 2, + "fields": { + "profile_name": "streamlink", + "command": "streamlink", + "parameters": "{streamUrl} best --stdout", + "is_active": true, + "user_agent": "1" + } + } +] diff --git a/core/models.py b/core/models.py index df43f94a..6f353126 100644 --- a/core/models.py +++ b/core/models.py @@ -49,46 +49,16 @@ class StreamProfile(models.Model): class CoreSettings(models.Model): - default_user_agent = models.CharField( - max_length=512, - default="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/112.0.0.0 Safari/537.36", - help_text="The default User-Agent string to use if none is provided." - ) - default_stream_profile = models.CharField( + key = models.CharField( max_length=255, - default="default_profile", - help_text="Name or identifier for the default stream profile." + unique=True, ) - stream_command_timeout = models.PositiveIntegerField( - default=300, - help_text="Timeout in seconds for running stream commands." - ) - enable_stream_logging = models.BooleanField( - default=True, - help_text="Toggle verbose logging for stream commands." - ) - useragent_cache_timeout = models.PositiveIntegerField( - default=300, - help_text="Cache timeout in seconds for user agent data." - ) - streamprofile_cache_timeout = models.PositiveIntegerField( - default=300, - help_text="Cache timeout in seconds for stream profile data." - ) - streamlink_path = models.CharField( + name = models.CharField( max_length=255, - default="/usr/bin/streamlink", - help_text="Override path for the streamlink command." ) - vlc_path = models.CharField( + value = models.CharField( max_length=255, - default="/usr/bin/vlc", - help_text="Override path for the VLC command." ) def __str__(self): return "Core Settings" - - class Meta: - verbose_name = "Core Setting" - verbose_name_plural = "Core Settings" diff --git a/core/views.py b/core/views.py index 91aeb600..0ec84bde 100644 --- a/core/views.py +++ b/core/views.py @@ -12,7 +12,7 @@ from django.shortcuts import render from apps.channels.models import Channel, Stream from apps.m3u.models import M3UAccountProfile -from core.models import StreamProfile +from core.models import StreamProfile, CoreSettings # Import the persistent lock (the “real” lock) from dispatcharr.persistent_lock import PersistentLock @@ -89,8 +89,9 @@ def stream_view(request, stream_id): # Get the stream profile set on the channel. stream_profile = channel.stream_profile if not stream_profile: - logger.error("No stream profile set for channel ID=%s", channel.id) - return HttpResponseServerError("No stream profile set for this channel.") + logger.error("No stream profile set for channel ID=%s, using default", channel.id) + stream_profile = StreamProfile.objects.get(id=CoreSettings.objects.get(key="default-stream-profile").value) + logger.debug("Stream profile used: %s", stream_profile.profile_name) # Determine the user agent to use. From 8094cb788627e9cf9e1a79e3c4d398a48391fa2c Mon Sep 17 00:00:00 2001 From: kappa118 Date: Sat, 1 Mar 2025 10:26:47 -0500 Subject: [PATCH 3/3] initial commit of fixtures for some default data --- fixtures.json | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 fixtures.json diff --git a/fixtures.json b/fixtures.json new file mode 100644 index 00000000..422628db --- /dev/null +++ b/fixtures.json @@ -0,0 +1,72 @@ +[ + { + "model": "core.useragent", + "fields": { + "user_agent_name": "TiviMate", + "user_agent": "TiviMate/5.16 (Android 12)", + "description": "", + "is_active": true, + "created_at": "2025-02-28T20:35:14.668Z", + "updated_at": "2025-02-28T20:35:14.668Z" + } + }, + { + "model": "core.useragent", + "fields": { + "user_agent_name": "VLC", + "user_agent": "VLC/3.0.21 LibVLC/3.0.21", + "description": "", + "is_active": true, + "created_at": "2025-02-28T20:35:14.668Z", + "updated_at": "2025-02-28T20:35:14.668Z" + } + }, + { + "model": "core.useragent", + "fields": { + "user_agent_name": "Chrome", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.3", + "description": "", + "is_active": true, + "created_at": "2025-02-28T20:35:14.668Z", + "updated_at": "2025-02-28T20:35:14.668Z" + } + }, + { + "model": "core.streamprofile", + "pk": 1, + "fields": { + "profile_name": "ffmpeg", + "command": "ffmpeg", + "parameters": "-i {streamUrl} -c:a copy -c:v copy -f mpegts pipe:1", + "is_active": true, + "user_agent": "1" + } + }, + { + "model": "core.streamprofile", + "fields": { + "profile_name": "streamlink", + "command": "streamlink", + "parameters": "{streamUrl} best --stdout", + "is_active": true, + "user_agent": "1" + } + }, + { + "model": "core.coresettings", + "fields": { + "key": "default-user-agent", + "name": "Default User-Agent", + "value": "1" + } + }, + { + "model": "core.coresettings", + "fields": { + "key": "default-stream-profile", + "name": "Default Stream Profile", + "value": "1" + } + } +]