merged in main

This commit is contained in:
kappa118 2025-03-01 10:00:28 -05:00
commit a5113660bc
14 changed files with 294 additions and 194 deletions

View file

@ -4,23 +4,31 @@ from .models import Stream, Channel, ChannelGroup
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = (
'id', 'name', 'group_name', 'custom_url',
'current_viewers', 'updated_at',
'id', # Primary Key
'name',
'group_name',
'custom_url',
'current_viewers',
'updated_at',
)
list_filter = ('group_name',)
search_fields = ('name', 'custom_url', 'group_name')
search_fields = ('id', 'name', 'custom_url', 'group_name') # Added 'id' for searching by ID
ordering = ('-updated_at',)
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
list_display = (
'channel_number', 'channel_name', 'channel_group', 'tvg_name'
'id', # Primary Key
'channel_number',
'channel_name',
'channel_group',
'tvg_name'
)
list_filter = ('channel_group',)
search_fields = ('channel_name', 'channel_group__name', 'tvg_name')
search_fields = ('id', 'channel_name', 'channel_group__name', 'tvg_name') # Added 'id'
ordering = ('channel_number',)
@admin.register(ChannelGroup)
class ChannelGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ('id', 'name') # Added 'id'
search_fields = ('id', 'name') # Added 'id'

View file

@ -1,24 +0,0 @@
from django.core.management.base import BaseCommand
from apps.channels.models import Stream, Channel, ChannelGroup
from apps.m3u.models import M3UAccount
class Command(BaseCommand):
help = "Delete all Channels, Streams, M3Us from the database (example)."
def handle(self, *args, **kwargs):
# Delete all Streams
stream_count = Stream.objects.count()
Stream.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {stream_count} Streams."))
# Or delete Channels:
channel_count = Channel.objects.count()
Channel.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {channel_count} Channels."))
# If you have M3UAccount:
m3u_count = M3UAccount.objects.count()
M3UAccount.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {m3u_count} M3U accounts."))
self.stdout.write(self.style.SUCCESS("Successfully deleted the requested objects."))

View file

@ -210,7 +210,7 @@ def create_profile_for_m3u_account(sender, instance, created, **kwargs):
m3u_account=instance,
is_default=True,
)
console.log(profile)
profile.max_streams = instance.max_streams
profile.save()

View file

@ -0,0 +1,27 @@
# core/management/commands/kill_processes.py
import psutil
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Kills all processes with 'ffmpeg' or 'streamlink' in their name or command line."
def handle(self, *args, **options):
kill_count = 0
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
name = proc.info.get('name') or ''
cmdline = ' '.join(proc.info.get('cmdline') or [])
lower_name = name.lower()
lower_cmdline = cmdline.lower()
if ('ffmpeg' in lower_name or 'ffmpeg' in lower_cmdline or
'streamlink' in lower_name or 'streamlink' in lower_cmdline):
self.stdout.write(f"Killing PID {proc.pid}: {name} {cmdline}")
proc.kill()
kill_count += 1
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
self.stdout.write(self.style.SUCCESS(f"Killed {kill_count} processes."))

View file

@ -1,22 +1,27 @@
# core/views.py
import os
import sys
import subprocess
import logging
import re
import redis
from django.conf import settings
from django.http import StreamingHttpResponse, HttpResponseServerError
from django.db.models import F
from django.shortcuts import render
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccountProfile
from core.models import StreamProfile
# Import the persistent lock (the “real” lock)
from dispatcharr.persistent_lock import PersistentLock
# Configure logging to output to the console.
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger(__name__)
def settings_view(request):
"""
Renders the settings page.
@ -28,10 +33,11 @@ def stream_view(request, stream_id):
"""
Streams the first available stream for the given channel.
It uses the channels assigned StreamProfile.
A persistent Redis lock is used to prevent concurrent streaming on the same channel.
"""
try:
# Retrieve the channel by the provided stream_id.
channel = Channel.objects.get(id=stream_id)
channel = Channel.objects.get(channel_number=stream_id)
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.channel_name)
# Ensure the channel has at least one stream.
@ -43,50 +49,44 @@ def stream_view(request, stream_id):
stream = channel.streams.first()
logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name)
# Retrieve m3u account to determine number of streams and profiles
# Retrieve the M3U account associated with the stream.
m3u_account = stream.m3u_account
logger.debug(f"Using M3U account ID={m3u_account.id}, Name={m3u_account.name}")
logger.debug("Using M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name)
# Use the custom URL if available; otherwise, use the standard URL.
input_url = stream.custom_url or stream.url
logger.debug("Input URL: %s", input_url)
# Determine which profile we can use
# Determine which profile we can use.
m3u_profiles = m3u_account.profiles.all()
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
# Get the remaining objects
profiles = [obj for obj in m3u_profiles if not obj.is_default]
active_profile = None
# -- Loop through profiles and pick the first active one --
for profile in [default_profile] + profiles:
logger.debug(f'Checking profile {profile.name}...')
if not profile.is_active:
logger.debug(f'Profile is not active, skipping.')
continue
if profile.current_viewers < profile.max_streams:
logger.debug(f"Using M3U profile ID={profile.id}")
active_profile = M3UAccountProfile.objects.get(id=profile.id)
logger.debug("Executing the following pattern replacement:")
logger.debug(f" search: {profile.search_pattern}")
# Convert $1 to \1 for Python regex
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
logger.debug(f" replace: {profile.replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
stream_url = re.sub(profile.search_pattern, safe_replace_pattern, input_url)
logger.debug(f"Generated stream url: {stream_url}")
break
else:
logger.debug(f'Profile {profile.name} as exceeded its stream count: {profile.current_viewers} / {profile.max_streams}')
logger.debug('Profile is not active, skipping.')
continue
# *** DISABLE FAKE LOCKS: Ignore current_viewers/max_streams check ***
logger.debug(f"Using M3U profile ID={profile.id} (ignoring viewer count limits)")
active_profile = M3UAccountProfile.objects.get(id=profile.id)
# Prepare the pattern replacement.
logger.debug("Executing the following pattern replacement:")
logger.debug(f" search: {profile.search_pattern}")
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
logger.debug(f" replace: {profile.replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
stream_url = re.sub(profile.search_pattern, safe_replace_pattern, input_url)
logger.debug(f"Generated stream url: {stream_url}")
break
if active_profile is None:
logger.exception("No available profiles for the stream")
return HttpResponseServerError("No available profiles for the stream")
# Get the stream profile set on the channel.
# (Ensure your Channel model has a 'stream_profile' field.)
stream_profile = channel.stream_profile
if not stream_profile:
logger.error("No stream profile set for channel ID=%s", channel.id)
@ -105,18 +105,29 @@ def stream_view(request, stream_id):
cmd = [stream_profile.command] + parameters.split()
logger.debug("Executing command: %s", cmd)
# Increment the viewer count.
active_profile.current_viewers += 1
active_profile.save()
logger.debug("Viewer count incremented for stream ID=%s", stream.id)
# Acquire the persistent Redis lock.
redis_host = getattr(settings, "REDIS_HOST", "localhost")
redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, db=0)
lock_key = f"lock:channel:{channel.id}"
persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120)
if not persistent_lock.acquire():
logger.error("Could not acquire persistent lock for channel %s", channel.id)
return HttpResponseServerError("Resource busy, please try again later.")
try:
# Start the streaming process.
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except Exception as e:
persistent_lock.release() # Ensure the lock is released on error.
logger.exception("Error starting stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error starting stream: {e}")
# Start the streaming process.
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except Exception as e:
logger.exception("Error starting stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error starting stream: {e}")
logger.exception("Error preparing stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error preparing stream: {e}")
def stream_generator(proc, s):
def stream_generator(proc, s, persistent_lock):
try:
while True:
chunk = proc.stdout.read(8192)
@ -124,9 +135,15 @@ def stream_view(request, stream_id):
break
yield chunk
finally:
# Decrement the viewer count once streaming ends.
active_profile.current_viewers -= 1
active_profile.save()
logger.debug("Viewer count decremented for stream ID=%s", s.id)
try:
proc.terminate()
logger.debug("Streaming process terminated for stream ID=%s", s.id)
except Exception as e:
logger.error("Error terminating process for stream ID=%s: %s", s.id, e)
persistent_lock.release()
logger.debug("Persistent lock released for channel ID=%s", channel.id)
return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T")
return StreamingHttpResponse(
stream_generator(process, stream, persistent_lock),
content_type="video/MP2T"
)

View file

@ -0,0 +1,84 @@
# dispatcharr/persistent_lock.py
import uuid
import redis
class PersistentLock:
"""
A persistent, auto-expiring lock that uses Redis.
Usage:
1. Instantiate with a Redis client, a unique lock key (e.g. "lock:account:123"),
and an optional timeout (in seconds).
2. Call acquire() to try to obtain the lock.
3. Optionally, periodically call refresh() to extend the lock's lifetime.
4. When finished, call release() to free the lock.
"""
def __init__(self, redis_client: redis.Redis, lock_key: str, lock_timeout: int = 120):
"""
Initialize the lock.
:param redis_client: An instance of redis.Redis.
:param lock_key: The unique key for the lock.
:param lock_timeout: Time-to-live for the lock in seconds.
"""
self.redis_client = redis_client
self.lock_key = lock_key
self.lock_timeout = lock_timeout
self.lock_token = None
def acquire(self) -> bool:
"""
Attempt to acquire the lock. Returns True if successful.
"""
self.lock_token = str(uuid.uuid4())
# Set the lock with NX (only if not exists) and EX (expire time)
result = self.redis_client.set(self.lock_key, self.lock_token, nx=True, ex=self.lock_timeout)
return result is not None
def refresh(self) -> bool:
"""
Refresh the lock's expiration time if this instance owns the lock.
Returns True if the expiration was successfully extended.
"""
current_value = self.redis_client.get(self.lock_key)
if current_value and current_value.decode("utf-8") == self.lock_token:
self.redis_client.expire(self.lock_key, self.lock_timeout)
return True
return False
def release(self) -> bool:
"""
Release the lock only if owned by this instance.
Returns True if the lock was successfully released.
"""
# Use a Lua script for atomicity: only delete if the token matches.
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
release_lock = self.redis_client.register_script(lua_script)
result = release_lock(keys=[self.lock_key], args=[self.lock_token])
return result == 1
# Example usage (for testing purposes only):
if __name__ == "__main__":
# Connect to Redis on localhost; adjust connection parameters as needed.
client = redis.Redis(host="localhost", port=6379, db=0)
lock = PersistentLock(client, "lock:example_account", lock_timeout=120)
if lock.acquire():
print("Lock acquired successfully!")
# Do work here...
# Optionally refresh the lock periodically:
if lock.refresh():
print("Lock refreshed.")
# Finally, release the lock:
if lock.release():
print("Lock released.")
else:
print("Failed to release lock.")
else:
print("Failed to acquire lock.")

View file

@ -5,6 +5,7 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET'
REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
DEBUG = True
ALLOWED_HOSTS = ["*"]

View file

@ -45,7 +45,7 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- POSTGRES_HOST=dispatcharr_db
- POSTGRES_HOST=db
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret

View file

@ -12,7 +12,7 @@ import useStreamProfilesStore from './store/streamProfiles';
const host = '';
const getAuthToken = async () => {
export const getAuthToken = async () => {
const token = await useAuthStore.getState().getToken(); // Assuming token is stored in Zustand store
return token;
};
@ -189,24 +189,34 @@ export default class API {
return retval;
}
static async assignChannelNumbers(ids) {
const response = await fetch(`${host}/api/channels/channels/assign/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel_order: ids }),
});
static async assignChannelNumbers(channelIds) {
// Make the request
const response = await fetch(`${host}/api/channels/channels/assign/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel_order: channelIds }),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval);
}
return retval;
// The backend returns something like { "message": "Channels have been auto-assigned!" }
if (!response.ok) {
// If you want to handle errors gracefully:
const text = await response.text();
throw new Error(`Assign channels failed: ${response.status} => ${text}`);
}
// Usually it has a { message: "..."} or similar
const retval = await response.json();
// If you want to automatically refresh the channel list in Zustand:
await useChannelsStore.getState().fetchChannels();
// Return the entire JSON result (so the caller can see the "message")
return retval;
}
static async createChannelFromStream(values) {
const response = await fetch(`${host}/api/channels/channels/from-stream/`, {
method: 'POST',

View file

@ -23,31 +23,30 @@ import {
Add as AddIcon,
SwapVert as SwapVertIcon,
LiveTv as LiveTvIcon,
ContentCopy,
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
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 ChannelsTable = () => {
const [channel, setChannel] = useState(null);
const [channelModelOpen, setChannelModalOpen] = useState(false);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const { channels, isLoading: channelsLoading } = useChannelsStore();
const { showVideo } = useVideoStore.getState(); // or useVideoStore()
// Configure columns
const columns = useMemo(
//column definitions...
() => [
{
header: '#',
@ -60,7 +59,6 @@ const Example = () => {
},
{
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
},
{
@ -76,7 +74,7 @@ const Example = () => {
alignItems: 'center',
}}
>
<img src={cell.getValue() || logo} width="20" />
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid2>
),
meta: {
@ -87,18 +85,16 @@ const Example = () => {
[]
);
//optionally access the underlying virtualizer instance
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false);
};
const closeSnackbar = () => setSnackbarOpen(false);
const editChannel = async (channel = null) => {
setChannel(channel);
const editChannel = async (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
};
@ -110,7 +106,7 @@ const Example = () => {
showVideo(`/output/stream/${channelNumber}/`);
}
// @TODO: the bulk delete endpoint is currently broken
// (Optional) bulk delete, but your endpoint is @TODO
const deleteChannels = async () => {
setIsLoading(true);
const selected = table
@ -118,21 +114,34 @@ const Example = () => {
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((chan) => () => {
return deleteChannel(chan.original.id);
})
selected.map((chan) => () => deleteChannel(chan.original.id))
);
// await API.deleteChannels(selected.map((sel) => sel.id));
setIsLoading(false);
};
// ─────────────────────────────────────────────────────────
// The "Assign Channels" button logic
// ─────────────────────────────────────────────────────────
const assignChannels = async () => {
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.assignChannelNumbers(selected.map((sel) => sel.original.id));
try {
// Get row order from the table
const rowOrder = table.getRowModel().rows.map((row) => row.original.id);
// @TODO: update the channels that were assigned
// Call our custom API endpoint
const result = await API.assignChannelNumbers(rowOrder);
// We might get { message: "Channels have been auto-assigned!" }
setSnackbarMessage(result.message || 'Channels assigned');
setSnackbarOpen(true);
// Refresh the channel list
await useChannelsStore.getState().fetchChannels();
} catch (err) {
console.error(err);
setSnackbarMessage('Failed to assign channels');
setSnackbarOpen(true);
}
};
const closeChannelForm = () => {
@ -147,7 +156,7 @@ const Example = () => {
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
// Scroll to the top of the table when sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
@ -159,6 +168,7 @@ const Example = () => {
setAnchorEl(null);
setSnackbarMessage('');
};
const openPopover = Boolean(anchorEl);
const handleCopy = async () => {
try {
@ -167,33 +177,30 @@ const Example = () => {
} catch (err) {
setSnackbarMessage('Failed to copy');
}
setSnackbarOpen(true);
};
const open = Boolean(anchorEl);
const copyM3UUrl = async (event) => {
// Example copy URLs
const copyM3UUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(
`${window.location.protocol}//${window.location.host}/output/m3u`
);
};
const copyEPGUrl = async (event) => {
const copyEPGUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(
`${window.location.protocol}//${window.location.host}/output/epg`
);
};
const copyHDHRUrl = async (event) => {
const copyHDHRUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(
`${window.location.protocol}//${window.location.host}/output/hdhr`
);
};
// Configure the MaterialReactTable
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
@ -208,8 +215,8 @@ const Example = () => {
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
rowVirtualizerInstanceRef, // optional
rowVirtualizerOptions: { overscan: 5 },
initialState: {
density: 'compact',
},
@ -246,20 +253,15 @@ const Example = () => {
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
muiSearchTextFieldProps: {
variant: 'standard',
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
@ -292,11 +294,7 @@ const Example = () => {
</IconButton>
</Tooltip>
<ButtonGroup
sx={{
marginLeft: 1,
}}
>
<ButtonGroup sx={{ marginLeft: 1 }}>
<Button variant="contained" size="small" onClick={copyHDHRUrl}>
HDHR URL
</Button>
@ -314,14 +312,17 @@ const Example = () => {
return (
<Box>
<MaterialReactTable table={table} />
{/* Channel Form Modal */}
<ChannelForm
channel={channel}
isOpen={channelModelOpen}
isOpen={channelModalOpen}
onClose={closeChannelForm}
/>
{/* Popover for the "copy" URLs */}
<Popover
open={open}
open={openPopover}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{
@ -329,7 +330,7 @@ const Example = () => {
horizontal: 'left',
}}
>
<div style={{ padding: '16px', display: 'flex', alignItems: 'center' }}>
<div style={{ padding: 16, display: 'flex', alignItems: 'center' }}>
<TextField
value={textToCopy}
variant="standard"
@ -341,9 +342,9 @@ const Example = () => {
<ContentCopy />
</IconButton>
</div>
{/* {copySuccess && <Typography variant="caption" sx={{ paddingLeft: 2 }}>{copySuccess}</Typography>} */}
</Popover>
{/* Snackbar for feedback */}
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
@ -355,4 +356,4 @@ const Example = () => {
);
};
export default Example;
export default ChannelsTable;

View file

@ -12,18 +12,21 @@ import {
Button,
} from '@mui/material';
import useStreamsStore from '../../store/streams';
import useChannelsStore from '../../store/channels'; // NEW: Import channels store
import API from '../../api';
// Make sure your api.js exports getAuthToken as a named export:
// e.g. export const getAuthToken = async () => { ... }
import { getAuthToken } from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
const Example = () => {
const StreamsTable = () => {
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
@ -31,16 +34,9 @@ const Example = () => {
const { playlists } = usePlaylistsStore();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Group',
accessorKey: 'group_name',
},
{ header: 'Name', accessorKey: 'name' },
{ header: 'Group', accessorKey: 'group_name' },
{
header: 'M3U',
size: 100,
@ -51,12 +47,11 @@ const Example = () => {
[playlists]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
@ -65,9 +60,9 @@ const Example = () => {
});
};
// @TODO: bulk create is broken, returning a 404
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
setIsLoading(true);
// Get all selected streams from the table
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
@ -109,7 +104,6 @@ const Example = () => {
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
@ -119,7 +113,6 @@ const Example = () => {
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: streams,
enablePagination: false,
@ -132,14 +125,14 @@ const Example = () => {
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
size="small"
color="warning"
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account}
sx={{ p: 0 }}
@ -147,16 +140,16 @@ const Example = () => {
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
onClick={() => deleteStream(row.original.id)}
sx={{ p: 0 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
size="small"
color="success"
onClick={() => createChannelFromStream(row.original)}
sx={{ p: 0 }}
>
@ -166,46 +159,38 @@ const Example = () => {
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
size="small"
color="success"
variant="contained"
onClick={() => editStream()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Streams">
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
variant="contained"
onClick={deleteStreams}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
onClick={createChannelsFromStreams}
size="small"
// disabled={rowSelection.length === 0}
sx={{
marginLeft: 1,
}}
sx={{ marginLeft: 1 }}
>
Create Channels
</Button>
@ -214,16 +199,7 @@ const Example = () => {
});
return (
<Box
sx={
{
// paddingTop: 2,
// paddingLeft: 1,
// paddingRight: 2,
// paddingBottom: 2,
}
}
>
<Box>
<MaterialReactTable table={table} />
<StreamForm
stream={stream}
@ -234,4 +210,4 @@ const Example = () => {
);
};
export default Example;
export default StreamsTable;

View file

@ -164,7 +164,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
// Build a playable stream URL for that channel
const url = window.location.origin + '/output/stream/' + matched.id;
const url = window.location.origin + '/output/stream/' + matched.channel_number;
showVideo(url);
// Optionally close the modal