Merge branch 'stream-previews' into epg-refactor

This commit is contained in:
dekzter 2025-03-27 13:02:35 -04:00
commit 723ec36ade
8 changed files with 73 additions and 21 deletions

View file

@ -133,15 +133,21 @@ class Stream(models.Model):
stream = cls.objects.create(**fields_to_update)
return stream, True # True means it was created
# @TODO: honor stream's stream profile
def get_stream_profile(self):
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
return stream_profile
def get_stream(self):
"""
Finds an available stream for the requested channel and returns the selected stream and profile.
"""
profile_id = redis_client.get(f"stream_profile:{stream_id}")
profile_id = redis_client.get(f"stream_profile:{self.id}")
if profile_id:
profile_id = int(profile_id)
return profile_id
return self.id, profile_id
# Retrieve the M3U account associated with the stream.
m3u_account = self.m3u_account
@ -168,10 +174,10 @@ class Stream(models.Model):
if profile.max_streams > 0:
redis_client.incr(profile_connections_key)
return profile.id # Return newly assigned stream and matched profile
return self.id, profile.id # Return newly assigned stream and matched profile
# 4. No available streams
return None
return None, None
def release_stream(self):
"""
@ -260,6 +266,7 @@ class Channel(models.Model):
def __str__(self):
return f"{self.channel_number} - {self.name}"
# @TODO: honor stream's stream profile
def get_stream_profile(self):
stream_profile = self.stream_profile
if not stream_profile:

View file

@ -14,7 +14,7 @@ class StreamSerializer(serializers.ModelSerializer):
allow_null=True,
required=False
)
read_only_fields = ['is_custom', 'm3u_account']
read_only_fields = ['is_custom', 'm3u_account', 'stream_hash']
class Meta:
model = Stream
@ -31,6 +31,7 @@ class StreamSerializer(serializers.ModelSerializer):
'stream_profile_id',
'is_custom',
'channel_group',
'stream_hash',
]
def get_fields(self):

View file

@ -17,7 +17,7 @@ import os
import json
from typing import Dict, Optional, Set
from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel
from apps.channels.models import Channel, Stream
from core.utils import redis_client as global_redis_client, redis_pubsub_client as global_redis_pubsub_client # Import both global Redis clients
from redis.exceptions import ConnectionError, TimeoutError
from .stream_manager import StreamManager
@ -740,12 +740,16 @@ class ProxyServer:
# Force release resources in the Channel model
try:
from apps.channels.models import Channel
channel = Channel.objects.get(uuid=channel_id)
channel.release_stream()
logger.info(f"Released stream allocation for zombie channel {channel_id}")
except Exception as e:
logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}")
try:
stream = Stream.objects.get(stream_hash=channel_id)
stream.release_stream()
logger.info(f"Released stream allocation for zombie channel {channel_id}")
except Exception as e:
logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}")
return True
except Exception as e:
@ -1067,8 +1071,12 @@ class ProxyServer:
def _clean_redis_keys(self, channel_id):
"""Clean up all Redis keys for a channel more efficiently"""
# Release the channel, stream, and profile keys from the channel
channel = Channel.objects.get(uuid=channel_id)
channel.release_stream()
try:
channel = Channel.objects.get(uuid=channel_id)
channel.release_stream()
except:
stream = Stream.objects.get(stream_hash=channel_id)
stream.release_stream()
if not self.redis_client:
return 0

View file

@ -248,8 +248,11 @@ class ChannelService:
logger.info(f"Released channel {channel_id} stream allocation")
model_released = True
except Channel.DoesNotExist:
logger.warning(f"Could not find Channel model for UUID {channel_id}")
model_released = False
logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash")
stream = Stream.objects.get(stream_hash=channel_id)
stream.release_stream()
logger.info(f"Released stream {channel_id} stream allocation")
model_released = True
except Exception as e:
logger.error(f"Error releasing channel stream: {e}")
model_released = False

View file

@ -17,7 +17,7 @@ from .utils import detect_stream_type, get_logger
from .redis_keys import RedisKeys
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField, TS_PACKET_SIZE
from .config_helper import ConfigHelper
from .url_utils import get_alternate_streams, get_stream_info_for_switch
from .url_utils import get_alternate_streams, get_stream_info_for_switch, get_stream_object
logger = get_logger()
@ -304,7 +304,7 @@ class StreamManager:
"""Establish a connection using transcoding"""
try:
logger.debug(f"Building transcode command for channel {self.channel_id}")
channel = get_object_or_404(Channel, uuid=self.channel_id)
channel = get_stream_object(self.channel_id)
# Use FFmpeg specifically for HLS streams
if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg:
@ -909,5 +909,3 @@ class StreamManager:
except Exception as e:
logger.error(f"Error trying next stream for channel {self.channel_id}: {e}", exc_info=True)
return False

View file

@ -10,9 +10,20 @@ from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings
from .utils import get_logger
from uuid import UUID
logger = get_logger()
def get_stream_object(id: str):
try:
uuid_obj = UUID(id, version=4)
logger.info(f"Fetching channel ID {id}")
return get_object_or_404(Channel, uuid=id)
except:
# UUID check failed, assume stream hash
logger.info(f"Fetching stream hash {id}")
return get_object_or_404(Stream, stream_hash=id)
def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]:
"""
Generate the appropriate stream URL for a channel based on its profile settings.
@ -24,7 +35,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]:
Tuple[str, str, bool]: (stream_url, user_agent, transcode_flag)
"""
# Get channel and related objects
channel = get_object_or_404(Channel, uuid=channel_id)
channel = get_stream_object(channel_id)
stream_id, profile_id = channel.get_stream()
if stream_id is None or profile_id is None:
@ -178,7 +189,11 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
"""
try:
# Get channel object
channel = get_object_or_404(Channel, uuid=channel_id)
channel = get_stream_object(channel_id)
if isinstance(channel, Stream):
logger.error(f"Stream is not a channel")
return []
logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}")
# Get all assigned streams for this channel

View file

@ -21,8 +21,9 @@ from rest_framework.permissions import IsAuthenticated
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField
from .config_helper import ConfigHelper
from .services.channel_service import ChannelService
from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch
from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch, get_stream_object
from .utils import get_logger
from uuid import UUID
logger = get_logger()
@ -30,9 +31,9 @@ logger = get_logger()
@api_view(['GET'])
def stream_ts(request, channel_id):
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
channel = get_stream_object(channel_id)
client_user_agent = None
logger.info(f"Fetching channel ID {channel_id}")
channel = get_object_or_404(Channel, uuid=channel_id)
try:
# Generate a unique client ID

View file

@ -37,6 +37,8 @@ import {
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
const StreamsTable = ({}) => {
const theme = useMantineTheme();
@ -84,6 +86,10 @@ const StreamsTable = ({}) => {
const channelSelectionStreams = useChannelsStore(
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
);
const {
environment: { env_mode },
} = useSettingsStore();
const { showVideo } = useVideoStore();
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
@ -429,6 +435,14 @@ const StreamsTable = ({}) => {
setPagination(updater);
};
function handleWatchStream(streamHash) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode == 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
}
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
@ -562,6 +576,11 @@ const StreamsTable = ({}) => {
>
Delete Stream
</Menu.Item>
<Menu.Item
onClick={() => handleWatchStream(row.original.stream_hash)}
>
Preview Stream
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>