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/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.
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/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"
+ }
+ }
+]
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 84a3d836..fdd68e63 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;
@@ -235,6 +235,27 @@ static async assignChannelNumbers(channelIds) {
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 ff9e67c8..e271511c 100644
--- a/frontend/src/components/tables/ChannelsTable.js
+++ b/frontend/src/components/tables/ChannelsTable.js
@@ -30,6 +30,7 @@ import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import logo from '../../images/logo.png';
+import useVideoStore from '../../store/useVideoStore'; // NEW import
const ChannelsTable = () => {
const [channel, setChannel] = useState(null);
@@ -42,6 +43,7 @@ const ChannelsTable = () => {
const [snackbarOpen, setSnackbarOpen] = useState(false);
const { channels, isLoading: channelsLoading } = useChannelsStore();
+ const { showVideo } = useVideoStore.getState(); // or useVideoStore()
// Configure columns
const columns = useMemo(
@@ -100,6 +102,10 @@ const ChannelsTable = () => {
await API.deleteChannel(id);
};
+ function handleWatchStream(channelNumber) {
+ showVideo(`/output/stream/${channelNumber}/`);
+ }
+
// (Optional) bulk delete, but your endpoint is @TODO
const deleteChannels = async () => {
setIsLoading(true);
@@ -110,6 +116,7 @@ const ChannelsTable = () => {
4,
selected.map((chan) => () => deleteChannel(chan.original.id))
);
+ // await API.deleteChannels(selected.map((sel) => sel.id));
setIsLoading(false);
};
@@ -176,15 +183,21 @@ const ChannelsTable = () => {
// Example copy URLs
const copyM3UUrl = (event) => {
setAnchorEl(event.currentTarget);
- setTextToCopy(`${window.location.protocol}//${window.location.host}/output/m3u`);
+ setTextToCopy(
+ `${window.location.protocol}//${window.location.host}/output/m3u`
+ );
};
const copyEPGUrl = (event) => {
setAnchorEl(event.currentTarget);
- setTextToCopy(`${window.location.protocol}//${window.location.host}/output/epg`);
+ setTextToCopy(
+ `${window.location.protocol}//${window.location.host}/output/epg`
+ );
};
const copyHDHRUrl = (event) => {
setAnchorEl(event.currentTarget);
- setTextToCopy(`${window.location.protocol}//${window.location.host}/output/hdhr`);
+ setTextToCopy(
+ `${window.location.protocol}//${window.location.host}/output/hdhr`
+ );
};
// Configure the MaterialReactTable
@@ -228,11 +241,14 @@ const ChannelsTable = () => {
>
- {/* If you had a channel preview:
- previewChannel(row.original.id)} sx={{ p: 0 }}>
-
-
- */}
+ 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 0f868813..a3591fcf 100644
--- a/frontend/src/components/tables/StreamsTable.js
+++ b/frontend/src/components/tables/StreamsTable.js
@@ -65,33 +65,15 @@ const StreamsTable = () => {
// Get all selected streams from the table
const selected = table
.getRowModel()
- .rows.filter((row) => row.getIsSelected())
- .map((row) => row.original);
- if (!selected.length) return;
+ .rows.filter((row) => row.getIsSelected());
- // Build payload: an array of objects with stream_id and channel_name
- const payload = selected.map((stream) => ({
- stream_id: stream.id,
- channel_name: stream.name || `Channel from ${stream.id}`,
- }));
-
- // Retrieve the auth token (make sure getAuthToken is exported from your API file)
- const token = await getAuthToken();
-
- // Send a POST request to your bulk endpoint
- const response = await fetch(`/api/channels/channels/from-stream/bulk/`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`,
- },
- body: JSON.stringify(payload),
- });
- const result = await response.json();
- console.log("Bulk create result:", result);
-
- // Refresh the channels store so new channels appear in your UI.
- await useChannelsStore.getState().fetchChannels();
+ await API.createChannelsFromStreams(
+ selected.map((sel) => ({
+ stream_id: sel.original.id,
+ channel_name: sel.original.name,
+ }))
+ );
+ setIsLoading(false);
};
const editStream = async (stream = null) => {
@@ -219,7 +201,11 @@ const StreamsTable = () => {
return (
-
+
);
};
diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js
index b471a434..fca295c4 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
@@ -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;