modified database fields for consistency, removed custom_url from streams (no longer needed)

This commit is contained in:
dekzter 2025-03-16 09:07:10 -04:00
parent 5da288b15b
commit 9711d7ab34
40 changed files with 216 additions and 172 deletions

View file

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

View file

@ -145,7 +145,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
type=openapi.TYPE_INTEGER,
description="(Optional) Desired channel number. Must not be in use."
),
"channel_name": openapi.Schema(
"name": openapi.Schema(
type=openapi.TYPE_STRING, description="Desired channel name"
)
}
@ -176,13 +176,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
channel_name = request.data.get('channel_name')
if channel_name is None:
channel_name = stream.name
name = request.data.get('name')
if name is None:
name = stream.name
channel_data = {
'channel_number': channel_number,
'channel_name': channel_name,
'name': name,
'tvg_id': stream.tvg_id,
'channel_group_id': channel_group.id,
'logo_url': stream.logo_url,
@ -199,7 +199,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
operation_description=(
"Bulk create channels from existing streams. For each object, if 'channel_number' is provided, "
"it is used (if available); otherwise, the next available number is auto-assigned. "
"Each object must include 'stream_id' and 'channel_name'."
"Each object must include 'stream_id' and 'name'."
),
request_body=openapi.Schema(
type=openapi.TYPE_ARRAY,
@ -214,7 +214,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
type=openapi.TYPE_INTEGER,
description="(Optional) Desired channel number. Must not be in use."
),
"channel_name": openapi.Schema(
"name": openapi.Schema(
type=openapi.TYPE_STRING, description="Desired channel name"
)
}
@ -245,7 +245,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
for item in data_list:
stream_id = item.get('stream_id')
if not all([stream_id]):
errors.append({"item": item, "error": "Missing required fields: stream_id and channel_name are required."})
errors.append({"item": item, "error": "Missing required fields: stream_id and name are required."})
continue
try:
@ -271,13 +271,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
continue
used_numbers.add(channel_number)
channel_name = item.get('channel_name')
if channel_name is None:
channel_name = stream.name
name = item.get('name')
if name is None:
name = stream.name
channel_data = {
"channel_number": channel_number,
"channel_name": channel_name,
"name": name,
"tvg_id": stream.tvg_id,
"channel_group_id": channel_group.id,
"logo_url": stream.logo_url,

View file

@ -25,7 +25,7 @@ class ChannelForm(forms.ModelForm):
model = Channel
fields = [
'channel_number',
'channel_name',
'name',
'channel_group',
]
@ -39,7 +39,6 @@ class StreamForm(forms.ModelForm):
fields = [
'name',
'url',
'custom_url',
'logo_url',
'tvg_id',
'local_file',

View file

@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-16 12:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='channel',
old_name='channel_name',
new_name='name',
),
migrations.RemoveField(
model_name='stream',
name='url',
),
migrations.RenameField(
model_name='stream',
old_name='custom_url',
new_name='url',
),
]

View file

@ -5,6 +5,7 @@ from django.conf import settings
from core.models import StreamProfile, CoreSettings
from core.utils import redis_client
import logging
import uuid
logger = logging.getLogger(__name__)
@ -16,8 +17,7 @@ class Stream(models.Model):
Represents a single stream (e.g. from an M3U source or custom URL).
"""
name = models.CharField(max_length=255, default="Default Stream")
url = models.URLField()
custom_url = models.URLField(max_length=2000, blank=True, null=True)
url = models.URLField(max_length=2000, blank=True, null=True)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
@ -38,14 +38,15 @@ class Stream(models.Model):
on_delete=models.SET_NULL,
related_name='streams'
)
class Meta:
# If you use m3u_account, you might do unique_together = ('name','custom_url','m3u_account')
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account')
verbose_name = "Stream"
verbose_name_plural = "Streams"
ordering = ['-updated_at']
def __str__(self):
return self.name or self.custom_url or f"Stream ID {self.id}"
return self.name or self.url or f"Stream ID {self.id}"
class ChannelManager(models.Manager):
@ -55,7 +56,7 @@ class ChannelManager(models.Manager):
class Channel(models.Model):
channel_number = models.IntegerField()
channel_name = models.CharField(max_length=255)
name = models.CharField(max_length=255)
logo_url = models.URLField(max_length=2000, blank=True, null=True)
logo_file = models.ImageField(
upload_to='logos/', # Will store in MEDIA_ROOT/logos
@ -104,7 +105,7 @@ class Channel(models.Model):
)
def __str__(self):
return f"{self.channel_number} - {self.channel_name}"
return f"{self.channel_number} - {self.name}"
def get_stream_profile(self):
stream_profile = self.stream_profile

View file

@ -19,7 +19,6 @@ class StreamSerializer(serializers.ModelSerializer):
'id',
'name',
'url',
'custom_url',
'm3u_account', # Uncomment if using M3U fields
'logo_url',
'tvg_id',
@ -84,7 +83,7 @@ class ChannelSerializer(serializers.ModelSerializer):
fields = [
'id',
'channel_number',
'channel_name',
'name',
'logo_url',
'logo_file',
'channel_group',
@ -99,9 +98,6 @@ class ChannelSerializer(serializers.ModelSerializer):
def get_streams(self, obj):
"""Retrieve ordered stream objects for GET requests."""
ordered_streams = obj.streams.all().order_by('channelstream__order')
print(f'Retrieving streams in order')
for index, stream in enumerate(ordered_streams):
print(f'Stream {stream.id}, index {index}')
return StreamSerializer(ordered_streams, many=True).data
# def get_stream_ids(self, obj):
@ -119,13 +115,11 @@ class ChannelSerializer(serializers.ModelSerializer):
return channel
def update(self, instance, validated_data):
print("Validated Data:", validated_data)
streams = validated_data.pop('stream_ids', None)
print(f'stream ids: {streams}')
# Update the actual Channel fields
instance.channel_number = validated_data.get('channel_number', instance.channel_number)
instance.channel_name = validated_data.get('channel_name', instance.channel_name)
instance.name = validated_data.get('name', instance.name)
instance.logo_url = validated_data.get('logo_url', instance.logo_url)
instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id)
instance.tvg_name = validated_data.get('tvg_name', instance.tvg_name)

View file

@ -31,7 +31,7 @@ COMMON_EXTRANEOUS_WORDS = [
"arabic", "latino", "film", "movie", "movies"
]
def normalize_channel_name(name: str) -> str:
def normalize_name(name: str) -> str:
"""
A more aggressive normalization that:
- Lowercases
@ -90,8 +90,8 @@ def match_epg_channels():
epg_rows.append({
"epg_id": e.id,
"tvg_id": e.tvg_id or "", # e.g. "Fox News.us"
"raw_name": e.channel_name,
"norm_name": normalize_channel_name(e.channel_name),
"raw_name": e.name,
"norm_name": normalize_name(e.name),
})
# 2) Pre-encode embeddings if possible
@ -115,16 +115,16 @@ def match_epg_channels():
epg_match = EPGData.objects.filter(tvg_id=chan.tvg_id).first()
if epg_match:
logger.info(
f"Channel {chan.id} '{chan.channel_name}' => found EPG by tvg_id={chan.tvg_id}"
f"Channel {chan.id} '{chan.name}' => found EPG by tvg_id={chan.tvg_id}"
)
continue
# C) No valid tvg_id => name-based matching
fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.channel_name
norm_chan = normalize_channel_name(fallback_name)
fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.name
norm_chan = normalize_name(fallback_name)
if not norm_chan:
logger.info(
f"Channel {chan.id} '{chan.channel_name}' => empty after normalization, skipping"
f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping"
)
continue

View file

@ -15,7 +15,7 @@ class StreamDashboardView(View):
"""
def get(self, request, *args, **kwargs):
streams = Stream.objects.values(
'id', 'name', 'url', 'custom_url',
'id', 'name', 'url',
'group_name', 'current_viewers'
)
return JsonResponse({'data': list(streams)}, safe=False)
@ -38,4 +38,4 @@ class StreamDashboardView(View):
@login_required
def channels_dashboard_view(request):
return render(request, 'channels/channels.html')
return render(request, 'channels/channels.html')

View file

@ -23,7 +23,7 @@ def dashboard_view(request):
# Fetch active streams and related channels
active_streams = Stream.objects.filter(current_viewers__gt=0).prefetch_related('channels')
active_streams_list = [
f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)"
f"Stream {i + 1}: {stream.url or 'Unknown'} ({stream.current_viewers} viewers)"
for i, stream in enumerate(active_streams)
]
@ -58,7 +58,7 @@ def live_dashboard_data(request):
active_streams = Stream.objects.filter(current_viewers__gt=0)
active_streams_list = [
f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)"
f"Stream {i + 1}: {stream.url or 'Unknown'} ({stream.current_viewers} viewers)"
for i, stream in enumerate(active_streams)
]
@ -77,4 +77,3 @@ def live_dashboard_data(request):
"error": str(e)
}
return JsonResponse(data)

View file

@ -11,7 +11,7 @@ class EPGSourceAdmin(admin.ModelAdmin):
class ProgramAdmin(admin.ModelAdmin):
list_display = ['title', 'get_epg_tvg_id', 'start_time', 'end_time']
list_filter = ['epg__tvg_id', 'tvg_id']
search_fields = ['title', 'epg__channel_name']
search_fields = ['title', 'epg__name']
def get_epg_tvg_id(self, obj):
return obj.epg.tvg_id if obj.epg else ''

View file

@ -17,7 +17,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('channel_name', models.CharField(max_length=255)),
('name', models.CharField(max_length=255)),
],
),
migrations.CreateModel(

View file

@ -17,12 +17,12 @@ class EPGSource(models.Model):
class EPGData(models.Model):
# Removed the Channel foreign key. We now just store the original tvg_id
# and a channel_name (which might simply be the tvg_id if no real channel exists).
# and a name (which might simply be the tvg_id if no real channel exists).
tvg_id = models.CharField(max_length=255, null=True, blank=True)
channel_name = models.CharField(max_length=255)
name = models.CharField(max_length=255)
def __str__(self):
return f"EPG Data for {self.channel_name}"
return f"EPG Data for {self.name}"
class ProgramData(models.Model):
# Each programme is associated with an EPGData record.

View file

@ -17,8 +17,8 @@ class EPGDataSerializer(serializers.ModelSerializer):
channel = serializers.SerializerMethodField()
def get_channel(self, obj):
return {"id": obj.channel.id, "name": obj.channel.channel_name} if obj.channel else None
return {"id": obj.channel.id, "name": obj.channel.name} if obj.channel else None
class Meta:
model = EPGData
fields = ['id', 'channel', 'channel_name', 'programs']
fields = ['id', 'channel', 'name', 'programs']

View file

@ -30,7 +30,7 @@ def fetch_xmltv(source):
response = requests.get(source.url, timeout=30)
response.raise_for_status()
logger.debug("XMLTV data fetched successfully.")
# If the URL ends with '.gz', decompress the response content
if source.url.lower().endswith('.gz'):
logger.debug("Detected .gz file. Decompressing...")
@ -64,7 +64,7 @@ def fetch_xmltv(source):
# Create (or get) an EPGData record using the tvg_id.
epg_data, created = EPGData.objects.get_or_create(
tvg_id=tvg_id,
defaults={'channel_name': tvg_id} # Use tvg_id as a fallback name
defaults={'name': tvg_id} # Use tvg_id as a fallback name
)
if created:
logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.")
@ -120,7 +120,7 @@ def fetch_schedules_direct(source):
# Create (or get) an EPGData record using the tvg_id.
epg_data, created = EPGData.objects.get_or_create(
tvg_id=tvg_id,
defaults={'channel_name': tvg_id}
defaults={'name': tvg_id}
)
if created:
logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.")

View file

@ -80,7 +80,7 @@ class LineupAPIView(APIView):
lineup = [
{
"GuideNumber": str(ch.channel_number),
"GuideName": ch.channel_name,
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/output/stream/{ch.id}")
}
for ch in channels
@ -128,5 +128,5 @@ class HDHRDeviceXMLAPIView(APIView):
<BaseURL>{base_url}</BaseURL>
<LineupURL>{base_url}/lineup.json</LineupURL>
</root>"""
return HttpResponse(xml_response, content_type="application/xml")

View file

@ -80,7 +80,7 @@ class LineupAPIView(APIView):
lineup = [
{
"GuideNumber": str(ch.channel_number),
"GuideName": ch.channel_name,
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/output/stream/{ch.id}")
}
for ch in channels
@ -128,5 +128,5 @@ class HDHRDeviceXMLAPIView(APIView):
<BaseURL>{base_url}</BaseURL>
<LineupURL>{base_url}/lineup.json</LineupURL>
</root>"""
return HttpResponse(xml_response, content_type="application/xml")

View file

@ -59,7 +59,7 @@ class Migration(migrations.Migration):
('m3u_account', models.ForeignKey(help_text='The M3U account this profile belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='profiles', to='m3u.m3uaccount')),
],
options={
'constraints': [models.UniqueConstraint(fields=('m3u_account', 'name'), name='unique_account_profile_name')],
'constraints': [models.UniqueConstraint(fields=('m3u_account', 'name'), name='unique_account_name')],
},
),
]

View file

@ -186,7 +186,7 @@ class M3UAccountProfile(models.Model):
class Meta:
constraints = [
models.UniqueConstraint(fields=['m3u_account', 'name'], name='unique_account_profile_name')
models.UniqueConstraint(fields=['m3u_account', 'name'], name='unique_account_name')
]
def __str__(self):
@ -210,7 +210,7 @@ def create_profile_for_m3u_account(sender, instance, created, **kwargs):
m3u_account=instance,
is_default=True,
)
profile.max_streams = instance.max_streams
profile.save()

View file

@ -21,7 +21,7 @@ def parse_extinf_line(line: str) -> dict:
Parse an EXTINF line from an M3U file.
This function removes the "#EXTINF:" prefix, then splits the remaining
string on the first comma that is not enclosed in quotes.
Returns a dictionary with:
- 'attributes': a dict of attribute key/value pairs (e.g. tvg-id, tvg-logo, group-title)
- 'display_name': the text after the comma (the fallback display name)
@ -186,7 +186,7 @@ def refresh_single_m3u_account(account_id):
try:
obj, created = Stream.objects.update_or_create(
name=current_info["name"],
custom_url=line,
url=line,
m3u_account=account,
group_name=current_info["group_title"],
defaults=defaults
@ -267,7 +267,7 @@ def parse_m3u_file(file_path, account):
try:
obj, created = Stream.objects.update_or_create(
name=current_info["name"],
custom_url=line,
url=line,
m3u_account=account,
defaults=defaults
)

View file

@ -14,13 +14,13 @@ def generate_m3u(request):
for channel in channels:
group_title = channel.channel_group.name if channel.channel_group else "Default"
tvg_id = channel.tvg_id or ""
tvg_name = channel.tvg_name or channel.channel_name
tvg_name = channel.tvg_name or channel.name
tvg_logo = channel.logo_url or ""
channel_number = channel.channel_number
extinf_line = (
f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" '
f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.channel_name}\n'
f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.name}\n'
)
stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id]))
m3u_content += extinf_line + stream_url + "\n"
@ -48,15 +48,15 @@ def generate_epg(request):
xml_lines = []
xml_lines.append('<?xml version="1.0" encoding="UTF-8"?>')
xml_lines.append('<tv generator-info-name="Dispatcharr" generator-info-url="https://example.com">')
# Output channel definitions based on EPGData.
# Use the EPGData's tvg_id (or a fallback) as the channel identifier.
for epg in epg_programs.keys():
channel_id = epg.tvg_id if epg.tvg_id else f"default-{epg.id}"
xml_lines.append(f' <channel id="{channel_id}">')
xml_lines.append(f' <display-name>{epg.channel_name}</display-name>')
xml_lines.append(f' <display-name>{epg.name}</display-name>')
xml_lines.append(' </channel>')
# Output programme entries referencing the channel id from EPGData.
for epg, progs in epg_programs.items():
channel_id = epg.tvg_id if epg.tvg_id else f"default-{epg.id}"
@ -67,7 +67,7 @@ def generate_epg(request):
xml_lines.append(f' <title>{prog.title}</title>')
xml_lines.append(f' <desc>{prog.description}</desc>')
xml_lines.append(' </programme>')
xml_lines.append('</tv>')
xml_content = "\n".join(xml_lines)

View file

@ -13,7 +13,7 @@ from .channel_status import ChannelStatus
import logging
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings
from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME
# Configure logging properly
logger = logging.getLogger("ts_proxy")
@ -43,7 +43,7 @@ def stream_ts(request, channel_id):
if not proxy_server.check_if_channel_exists(channel_id):
# Initialize the channel (but don't wait for completion)
logger.info(f"[{client_id}] Starting channel {channel_id} initialization")
# Get stream details from channel model
stream_id, profile_id = channel.get_stream()
if stream_id is None or profile_id is None:
@ -63,9 +63,9 @@ def stream_ts(request, channel_id):
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
else:
logger.debug(f"User agent found for account: {stream_user_agent}")
# Generate stream URL based on the selected profile
input_url = stream.custom_url or stream.url
input_url = stream.url
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)
@ -78,9 +78,10 @@ def stream_ts(request, channel_id):
stream_profile = channel.get_stream_profile()
if stream_profile.is_redirect():
return HttpResponseRedirect(stream_url)
# Need to check if profile is transcoded
logger.debug(f"Using profile {stream_profile} for stream {stream_id}")
if stream_profile == 'PROXY' or stream_profile is None:
if stream_profile.is_proxy() or stream_profile is None:
transcode = False
else:
transcode = True
@ -90,7 +91,7 @@ def stream_ts(request, channel_id):
if proxy_server.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
profile_value = str(stream_profile)
proxy_server.redis_client.hset(metadata_key, "profile", profile_value)
proxy_server.redis_client.hset(metadata_key, "profile", profile_value)
if not success:
return JsonResponse({'error': 'Failed to initialize channel'}, status=500)
@ -107,11 +108,11 @@ def stream_ts(request, channel_id):
proxy_server.stop_channel(channel_id)
return JsonResponse({'error': 'Failed to connect'}, status=502)
time.sleep(0.1)
logger.info(f"[{client_id}] Successfully initialized channel {channel_id}")
channel_initializing = True
logger.info(f"[{client_id}] Channel {channel_id} initialization started")
# Register client - can do this regardless of initialization state
# Create local resources if needed
if channel_id not in proxy_server.stream_buffers or channel_id not in proxy_server.client_managers:
@ -120,21 +121,21 @@ def stream_ts(request, channel_id):
# Get URL from Redis metadata
url = None
stream_user_agent = None # Initialize the variable
if proxy_server.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
url_bytes = proxy_server.redis_client.hget(metadata_key, "url")
ua_bytes = proxy_server.redis_client.hget(metadata_key, "user_agent")
profile_bytes = proxy_server.redis_client.hget(metadata_key, "profile")
if url_bytes:
url = url_bytes.decode('utf-8')
if ua_bytes:
stream_user_agent = ua_bytes.decode('utf-8')
# Extract transcode setting from Redis
profile_str = profile_bytes.decode('utf-8')
use_transcode = (profile_str == 'PROXY' or profile_str == 'None')
use_transcode = (profile_str == PROXY_PROFILE_NAME or profile_str == 'None')
# Use client_user_agent as fallback if stream_user_agent is None
success = proxy_server.initialize_channel(url, channel_id, stream_user_agent or client_user_agent, use_transcode)
if not success:
@ -142,7 +143,7 @@ def stream_ts(request, channel_id):
return JsonResponse({'error': 'Failed to initialize channel locally'}, status=500)
logger.info(f"[{client_id}] Successfully initialized channel {channel_id} locally")
# Register client
buffer = proxy_server.stream_buffers[channel_id]
client_manager = proxy_server.client_managers[channel_id]
@ -154,17 +155,17 @@ def stream_ts(request, channel_id):
stream_start_time = time.time()
bytes_sent = 0
chunks_sent = 0
# Keep track of initialization state
initialization_start = time.time()
max_init_wait = getattr(Config, 'CLIENT_WAIT_TIMEOUT', 30)
channel_ready = not channel_initializing
keepalive_interval = 0.5
last_keepalive = 0
try:
logger.info(f"[{client_id}] Stream generator started, channel_ready={channel_ready}")
# Wait for initialization to complete if needed
if not channel_ready:
# While init is happening, send keepalive packets
@ -173,7 +174,7 @@ def stream_ts(request, channel_id):
if proxy_server.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if state in ['waiting_for_clients', 'active']:
@ -199,19 +200,19 @@ def stream_ts(request, channel_id):
keepalive_packet[0] = 0x47 # Sync byte
keepalive_packet[1] = 0x1F # PID high bits (null packet)
keepalive_packet[2] = 0xFF # PID low bits (null packet)
# Add status info in packet payload (will be ignored by players)
status_msg = f"Initializing: {state}".encode('utf-8')
keepalive_packet[4:4+min(len(status_msg), 180)] = status_msg[:180]
logger.debug(f"[{client_id}] Sending keepalive packet during initialization, state={state}")
yield bytes(keepalive_packet)
bytes_sent += len(keepalive_packet)
last_keepalive = time.time()
# Wait a bit before checking again (don't send too many keepalives)
time.sleep(0.1)
# Check if we timed out waiting
if not channel_ready:
logger.warning(f"[{client_id}] Timed out waiting for initialization")
@ -223,13 +224,13 @@ def stream_ts(request, channel_id):
error_packet[4:4+min(len(error_msg), 180)] = error_msg[:180]
yield bytes(error_packet)
return
# Channel is now ready - original streaming code goes here
logger.info(f"[{client_id}] Channel {channel_id} ready, starting normal streaming")
# Reset start time for real streaming
stream_start_time = time.time()
# Get buffer - stream manager may not exist in this worker
buffer = proxy_server.stream_buffers.get(channel_id)
stream_manager = proxy_server.stream_managers.get(channel_id)
@ -558,7 +559,7 @@ def channel_status(request, channel_id=None):
try:
# Check if Redis is available
if not proxy_server.redis_client:
return JsonResponse({'error': 'Redis connection not available'}, status=500)
return JsonResponse({'error': 'Redis connection not available'}, status=500)
# Handle single channel or all channels
if channel_id:

View file

@ -6,26 +6,26 @@ from .models import UserAgent, StreamProfile, CoreSettings
@admin.register(UserAgent)
class UserAgentAdmin(admin.ModelAdmin):
list_display = (
"user_agent_name",
"name",
"user_agent",
"description",
"is_active",
"created_at",
"updated_at",
)
search_fields = ("user_agent_name", "user_agent", "description")
search_fields = ("name", "user_agent", "description")
list_filter = ("is_active",)
readonly_fields = ("created_at", "updated_at")
@admin.register(StreamProfile)
class StreamProfileAdmin(admin.ModelAdmin):
list_display = (
"profile_name",
"name",
"command",
"is_active",
"user_agent",
)
search_fields = ("profile_name", "command", "user_agent")
search_fields = ("name", "command", "user_agent")
list_filter = ("is_active",)
@admin.register(CoreSettings)

View file

@ -3,7 +3,7 @@
"model": "core.useragent",
"pk": 1,
"fields": {
"user_agent_name": "TiviMate",
"name": "TiviMate",
"user_agent": "TiviMate/5.1.6 (Android 12)",
"description": "",
"is_active": true
@ -13,7 +13,7 @@
"model": "core.useragent",
"pk": 2,
"fields": {
"user_agent_name": "VLC",
"name": "VLC",
"user_agent": "VLC/3.0.21 LibVLC 3.0.21",
"description": "",
"is_active": true
@ -23,7 +23,7 @@
"model": "core.streamprofile",
"pk": 1,
"fields": {
"profile_name": "ffmpeg",
"name": "ffmpeg",
"command": "ffmpeg",
"parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1",
"is_active": true,
@ -34,7 +34,7 @@
"model": "core.streamprofile",
"pk": 2,
"fields": {
"profile_name": "streamlink",
"name": "streamlink",
"command": "streamlink",
"parameters": "{streamUrl} best --stdout",
"is_active": true,

View file

@ -1,8 +1,11 @@
# Generated by Django 5.1.6 on 2025-03-14 17:16
from django.db import migrations
from core.models import CoreSettings
def create_proxy_stream_profile(apps, schema_editor):
default_user_agent_id = CoreSettings.get_default_user_agent_id()
StreamProfile = apps.get_model("core", "StreamProfile")
StreamProfile.objects.create(
profile_name="Proxy",
@ -10,7 +13,7 @@ def create_proxy_stream_profile(apps, schema_editor):
parameters="",
locked=True,
is_active=True,
user_agent="1",
user_agent_id=default_user_agent_id,
)
StreamProfile.objects.create(
@ -19,7 +22,7 @@ def create_proxy_stream_profile(apps, schema_editor):
parameters="",
locked=True,
is_active=True,
user_agent="1",
user_agent_id=default_user_agent_id,
)
class Migration(migrations.Migration):
@ -29,4 +32,5 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(create_proxy_stream_profile)
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-03-16 12:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0007_create_proxy_and_redirect_stream_profiles'),
]
operations = [
migrations.RenameField(
model_name='streamprofile',
old_name='profile_name',
new_name='name',
),
migrations.RenameField(
model_name='useragent',
old_name='user_agent_name',
new_name='name',
),
]

View file

@ -3,7 +3,7 @@ from django.db import models
from django.utils.text import slugify
class UserAgent(models.Model):
user_agent_name = models.CharField(
name = models.CharField(
max_length=512,
unique=True,
help_text="The User-Agent name."
@ -26,13 +26,13 @@ class UserAgent(models.Model):
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.user_agent_name
return self.name
PROXY_PROFILE = 'Proxy'
REDIRECT_PROFILE = 'Redirect'
PROXY_PROFILE_NAME = 'Proxy'
REDIRECT_PROFILE_NAME = 'Redirect'
class StreamProfile(models.Model):
profile_name = models.CharField(max_length=255, help_text="Name of the stream profile")
name = models.CharField(max_length=255, help_text="Name of the stream profile")
command = models.CharField(
max_length=255,
help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')",
@ -56,7 +56,7 @@ class StreamProfile(models.Model):
)
def __str__(self):
return self.profile_name
return self.name
def delete(self):
if self.locked():
@ -108,12 +108,12 @@ class StreamProfile(models.Model):
return instance
def is_proxy(self):
if self.locked and self.profile_name == PROXY_PROFILE:
if self.locked and self.name == PROXY_PROFILE_NAME:
return True
return False
def is_redirect(self):
if self.locked and self.profile_name == REDIRECT_PROFILE:
if self.locked and self.name == REDIRECT_PROFILE_NAME:
return True
return False
@ -149,12 +149,10 @@ class CoreSettings(models.Model):
return "Core Settings"
@classmethod
def get_default_user_agent(cls):
def get_default_user_agent_id(cls):
"""Retrieve a system profile by name (or return None if not found)."""
default_ua_id = cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value
return UserAgent.objects.get(id=default_ua_id)
return cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value
@classmethod
def get_default_stream_profile(cls):
default_sp_id = cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value
return StreamProfile.objects.get(id=default_sp_id)
def get_default_stream_profile_id(cls):
return cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value

View file

@ -6,12 +6,12 @@ from .models import UserAgent, StreamProfile, CoreSettings
class UserAgentSerializer(serializers.ModelSerializer):
class Meta:
model = UserAgent
fields = ['id', 'user_agent_name', 'user_agent', 'description', 'is_active', 'created_at', 'updated_at']
fields = ['id', 'name', 'user_agent', 'description', 'is_active', 'created_at', 'updated_at']
class StreamProfileSerializer(serializers.ModelSerializer):
class Meta:
model = StreamProfile
fields = ['id', 'profile_name', 'command', 'parameters', 'is_active', 'user_agent']
fields = ['id', 'name', 'command', 'parameters', 'is_active', 'user_agent', 'locked']
class CoreSettingsSerializer(serializers.ModelSerializer):
class Meta:

View file

@ -41,7 +41,7 @@ def stream_view(request, stream_id):
# Retrieve the channel by the provided stream_id.
channel = Channel.objects.get(channel_number=stream_id)
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.channel_name)
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name)
# Ensure the channel has at least one stream.
if not channel.streams.exists():
@ -65,7 +65,7 @@ def stream_view(request, stream_id):
logger.debug("Stream 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
input_url = stream.url
logger.debug("Input URL: %s", input_url)
# Determine which profile we can use.
@ -132,7 +132,7 @@ def stream_view(request, stream_id):
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)
logger.debug("Stream profile used: %s", stream_profile.name)
# Determine the user agent to use.
user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")

View file

@ -20,7 +20,7 @@ INSTALLED_APPS = [
'apps.hdhr',
'apps.m3u',
'apps.output',
'apps.proxy.apps.ProxyConfig',
'apps.proxy.apps.ProxyConfig',
'core',
'drf_yasg',
'daphne',

View file

@ -58,7 +58,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
channel_name: '',
name: '',
channel_number: '',
channel_group_id: '',
stream_profile_id: '0',
@ -66,7 +66,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
tvg_name: '',
},
validationSchema: Yup.object({
channel_name: Yup.string().required('Name is required'),
name: Yup.string().required('Name is required'),
channel_number: Yup.string().required('Invalid channel number').min(0),
channel_group_id: Yup.string().required('Channel group is required'),
}),
@ -102,7 +102,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
useEffect(() => {
if (channel) {
formik.setValues({
channel_name: channel.channel_name,
name: channel.name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id || '0',
@ -256,14 +256,12 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
<Grid gap={2}>
<Grid.Col span={6}>
<TextInput
id="channel_name"
name="channel_name"
id="name"
name="name"
label="Channel Name"
value={formik.values.channel_name}
value={formik.values.name}
onChange={formik.handleChange}
error={
formik.errors.channel_name ? formik.touched.channel_name : ''
}
error={formik.errors.name ? formik.touched.name : ''}
/>
<Grid>
@ -312,7 +310,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.profile_name,
label: option.name,
}))}
/>

View file

@ -138,7 +138,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
onChange={formik.handleChange}
error={formik.errors.user_agent ? formik.touched.user_agent : ''}
data={userAgents.map((ua) => ({
label: ua.user_agent_name,
label: ua.name,
value: `${ua.id}`,
}))}
/>

View file

@ -91,7 +91,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
onChange={setSelectedStreamProfile}
error={formik.errors.stream_profile_id}
data={streamProfiles.map((profile) => ({
label: profile.profile_name,
label: profile.name,
value: `${profile.id}`,
}))}
comboboxProps={{ withinPortal: false, zIndex: 1000 }}

View file

@ -11,14 +11,14 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
profile_name: '',
name: '',
command: '',
parameters: '',
is_active: true,
user_agent: '',
},
validationSchema: Yup.object({
profile_name: Yup.string().required('Name is required'),
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string().required('Parameters are is required'),
}),
@ -38,7 +38,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
useEffect(() => {
if (profile) {
formik.setValues({
profile_name: profile.profile_name,
name: profile.name,
command: profile.command,
parameters: profile.parameters,
is_active: profile.is_active,
@ -57,12 +57,13 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="profile_name"
name="profile_name"
id="name"
name="name"
label="Name"
value={formik.values.profile_name}
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.profile_name}
error={formik.errors.name}
disabled={profile ? profile.locked : false}
/>
<TextInput
id="command"
@ -71,6 +72,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
value={formik.values.command}
onChange={formik.handleChange}
error={formik.errors.command}
disabled={profile ? profile.locked : false}
/>
<TextInput
id="parameters"
@ -79,6 +81,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
value={formik.values.parameters}
onChange={formik.handleChange}
error={formik.errors.parameters}
disabled={profile ? profile.locked : false}
/>
<Select
@ -89,7 +92,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
onChange={formik.handleChange}
error={formik.errors.user_agent}
data={userAgents.map((ua) => ({
label: ua.user_agent_name,
label: ua.name,
value: `${ua.id}`,
}))}
/>

View file

@ -18,13 +18,13 @@ import {
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
user_agent_name: '',
name: '',
user_agent: '',
description: '',
is_active: true,
},
validationSchema: Yup.object({
user_agent_name: Yup.string().required('Name is required'),
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
@ -43,7 +43,7 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
useEffect(() => {
if (userAgent) {
formik.setValues({
user_agent_name: userAgent.user_agent_name,
name: userAgent.name,
user_agent: userAgent.user_agent,
description: userAgent.description,
is_active: userAgent.is_active,
@ -61,15 +61,12 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
<Modal opened={isOpen} onClose={onClose} title="User-Agent">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="user_agent_name"
name="user_agent_name"
id="name"
name="name"
label="Name"
value={formik.values.user_agent_name}
value={formik.values.name}
onChange={formik.handleChange}
error={
formik.touched.user_agent_name &&
Boolean(formik.errors.user_agent_name)
}
error={formik.touched.name && Boolean(formik.errors.name)}
/>
<TextInput

View file

@ -238,7 +238,7 @@ const ChannelsTable = ({}) => {
},
{
header: 'Name',
accessorKey: 'channel_name',
accessorKey: 'name',
mantineTableHeadCellProps: {
sx: { textAlign: 'center' },
},
@ -324,7 +324,7 @@ const ChannelsTable = ({}) => {
};
function handleWatchStream(channelNumber) {
let vidUrl = `/output/stream/${channelNumber}/`;
let vidUrl = `/proxy/ts/stream/${channelNumber}`;
if (env_mode == 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
@ -554,7 +554,7 @@ const ChannelsTable = ({}) => {
size="sm"
variant="transparent"
color="green.5"
onClick={() => handleWatchStream(row.original.channel_number)}
onClick={() => handleWatchStream(row.original.id)}
>
<CirclePlay size="18" />
</ActionIcon>

View file

@ -42,7 +42,7 @@ const StreamProfiles = () => {
() => [
{
header: 'Name',
accessorKey: 'profile_name',
accessorKey: 'name',
},
{
header: 'Command',

View file

@ -281,7 +281,7 @@ const StreamsTable = ({}) => {
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
name: stream.name,
channel_number: null,
stream_id: stream.id,
});
@ -431,7 +431,7 @@ const StreamsTable = ({}) => {
enableTopToolbar: false,
enableRowVirtualization: true,
renderTopToolbar: () => null, // Removes the entire top toolbar
renderToolbarInternalActions: () => null,
renderToolbarInternalActions: () => null,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
enableBottomToolbar: true,

View file

@ -48,7 +48,7 @@ const UserAgentsTable = () => {
() => [
{
header: 'Name',
accessorKey: 'user_agent_name',
accessorKey: 'name',
},
{
header: 'User-Agent',

View file

@ -297,7 +297,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
/>
{guideChannels.map((channel) => (
<Box
key={channel.channel_name}
key={channel.name}
style={{
display: 'flex',
height: PROGRAM_HEIGHT,
@ -318,7 +318,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
>
<img
src={channel.logo_url || logo}
alt={channel.channel_name}
alt={channel.name}
style={{
width: '100%',
height: 'auto',
@ -424,7 +424,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
);
return (
<Box
key={channel.channel_name}
key={channel.name}
style={{
display: 'flex',
position: 'relative',

View file

@ -105,7 +105,7 @@ const SettingsPage = () => {
error={formik.errors['default-user-agent']}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.user_agent_name,
label: option.name,
}))}
/>
@ -118,7 +118,7 @@ const SettingsPage = () => {
error={formik.errors['default-stream-profile']}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.profile_name,
label: option.name,
}))}
/>
{/* <Select