Merge pull request #424 from Dispatcharr/dev

This commit is contained in:
SergeantPanda 2025-09-18 17:27:31 -05:00 committed by GitHub
commit 9eade91958
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3315 additions and 1062 deletions

View file

@ -134,7 +134,7 @@ class AuthViewSet(viewsets.ViewSet):
class UserViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Users"""
queryset = User.objects.all()
queryset = User.objects.all().prefetch_related('channel_profiles')
serializer_class = UserSerializer
def get_permissions(self):

View file

@ -39,7 +39,7 @@ from .serializers import (
ChannelProfileSerializer,
RecordingSerializer,
)
from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl
from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg, match_selected_channels_epg
import django_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
@ -178,6 +178,35 @@ class StreamViewSet(viewsets.ModelViewSet):
# Return the response with the list of unique group names
return Response(list(group_names))
@swagger_auto_schema(
method="post",
operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["ids"],
properties={
"ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of stream IDs to retrieve"
),
},
),
responses={200: StreamSerializer(many=True)},
)
@action(detail=False, methods=["post"], url_path="by-ids")
def get_by_ids(self, request, *args, **kwargs):
ids = request.data.get("ids", [])
if not isinstance(ids, list):
return Response(
{"error": "ids must be a list of integers"},
status=status.HTTP_400_BAD_REQUEST,
)
streams = Stream.objects.filter(id__in=ids)
serializer = self.get_serializer(streams, many=True)
return Response(serializer.data)
# ─────────────────────────────────────────────────────────
# 2) Channel Group Management (CRUD)
@ -464,6 +493,68 @@ class ChannelViewSet(viewsets.ModelViewSet):
"channels": serialized_channels
})
@action(detail=False, methods=["post"], url_path="set-names-from-epg")
def set_names_from_epg(self, request):
"""
Trigger a Celery task to set channel names from EPG data
"""
from .tasks import set_channels_names_from_epg
data = request.data
channel_ids = data.get("channel_ids", [])
if not channel_ids:
return Response(
{"error": "channel_ids is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(channel_ids, list):
return Response(
{"error": "channel_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
# Start the Celery task
task = set_channels_names_from_epg.delay(channel_ids)
return Response({
"message": f"Started EPG name setting task for {len(channel_ids)} channels",
"task_id": task.id,
"channel_count": len(channel_ids)
})
@action(detail=False, methods=["post"], url_path="set-logos-from-epg")
def set_logos_from_epg(self, request):
"""
Trigger a Celery task to set channel logos from EPG data
"""
from .tasks import set_channels_logos_from_epg
data = request.data
channel_ids = data.get("channel_ids", [])
if not channel_ids:
return Response(
{"error": "channel_ids is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(channel_ids, list):
return Response(
{"error": "channel_ids must be a list"},
status=status.HTTP_400_BAD_REQUEST,
)
# Start the Celery task
task = set_channels_logos_from_epg.delay(channel_ids)
return Response({
"message": f"Started EPG logo setting task for {len(channel_ids)} channels",
"task_id": task.id,
"channel_count": len(channel_ids)
})
@action(detail=False, methods=["get"], url_path="ids")
def get_ids(self, request, *args, **kwargs):
# Get the filtered queryset
@ -559,40 +650,42 @@ class ChannelViewSet(viewsets.ModelViewSet):
channel_group = stream.channel_group
name = request.data.get("name")
if name is None:
name = stream.name
# Check if client provided a channel_number; if not, auto-assign one.
stream_custom_props = stream.custom_properties or {}
channel_number = None
if "tvg-chno" in stream_custom_props:
channel_number = float(stream_custom_props["tvg-chno"])
elif "channel-number" in stream_custom_props:
channel_number = float(stream_custom_props["channel-number"])
elif "num" in stream_custom_props:
channel_number = float(stream_custom_props["num"])
channel_number = request.data.get("channel_number")
if channel_number is None:
provided_number = request.data.get("channel_number")
if provided_number is None:
channel_number = Channel.get_next_available_channel_number()
else:
try:
channel_number = float(provided_number)
except ValueError:
return Response(
{"error": "channel_number must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
# If the provided number is already used, return an error.
if Channel.objects.filter(channel_number=channel_number).exists():
return Response(
{
"error": f"Channel number {channel_number} is already in use. Please choose a different number."
},
status=status.HTTP_400_BAD_REQUEST,
)
# Channel number not provided by client, check stream properties or auto-assign
if "tvg-chno" in stream_custom_props:
channel_number = float(stream_custom_props["tvg-chno"])
elif "channel-number" in stream_custom_props:
channel_number = float(stream_custom_props["channel-number"])
elif "num" in stream_custom_props:
channel_number = float(stream_custom_props["num"])
elif channel_number == 0:
# Special case: 0 means ignore provider numbers and auto-assign
channel_number = None
if channel_number is None:
# Still None, auto-assign the next available channel number
channel_number = Channel.get_next_available_channel_number()
try:
channel_number = float(channel_number)
except ValueError:
return Response(
{"error": "channel_number must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
# If the provided number is already used, return an error.
if Channel.objects.filter(channel_number=channel_number).exists():
channel_number = Channel.get_next_available_channel_number(channel_number)
# Get the tvc_guide_stationid from custom properties if it exists
tvc_guide_stationid = None
if "tvc-guide-stationid" in stream_custom_props:
@ -667,283 +760,146 @@ class ChannelViewSet(viewsets.ModelViewSet):
for profile in profiles
])
# Send WebSocket notification for single channel creation
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
'type': 'channels_created',
'count': 1,
'channel_id': channel.id,
'channel_name': channel.name,
'channel_number': channel.channel_number
})
return Response(serializer.data, status=status.HTTP_201_CREATED)
@swagger_auto_schema(
method="post",
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 'name'. "
"Supports single profile ID or array of profile IDs in 'channel_profile_ids'."
"Asynchronously bulk create channels from stream IDs. "
"Returns a task ID to track progress via WebSocket. "
"This is the recommended approach for large bulk operations."
),
request_body=openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_id"],
properties={
"stream_id": openapi.Schema(
type=openapi.TYPE_INTEGER,
description="ID of the stream to link",
),
"channel_number": openapi.Schema(
type=openapi.TYPE_NUMBER,
description="(Optional) Desired channel number. Must not be in use.",
),
"name": openapi.Schema(
type=openapi.TYPE_STRING, description="Desired channel name"
),
"channel_profile_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs."
),
},
),
type=openapi.TYPE_OBJECT,
required=["stream_ids"],
properties={
"stream_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of stream IDs to create channels from"
),
"channel_profile_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="(Optional) Channel profile ID(s) to add the channels to. If not provided, channels are added to all profiles."
),
"starting_channel_number": openapi.Schema(
type=openapi.TYPE_INTEGER,
description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number"
),
},
),
responses={201: "Bulk channels created"},
responses={202: "Task started successfully"},
)
@action(detail=False, methods=["post"], url_path="from-stream/bulk")
def from_stream_bulk(self, request):
data_list = request.data
if not isinstance(data_list, list):
from .tasks import bulk_create_channels_from_streams
stream_ids = request.data.get("stream_ids", [])
channel_profile_ids = request.data.get("channel_profile_ids")
starting_channel_number = request.data.get("starting_channel_number")
if not stream_ids:
return Response(
{"error": "Expected a list of channel objects"},
{"error": "stream_ids is required and cannot be empty"},
status=status.HTTP_400_BAD_REQUEST,
)
created_channels = []
errors = []
# Gather current used numbers once.
used_numbers = set(
Channel.objects.all().values_list("channel_number", flat=True)
)
next_number = 1
def get_auto_number():
nonlocal next_number
while next_number in used_numbers:
next_number += 1
used_numbers.add(next_number)
return next_number
logos_to_create = []
channels_to_create = []
streams_map = []
logo_map = []
profile_map = [] # Track which profiles each channel should be added to
for item in data_list:
stream_id = item.get("stream_id")
if not stream_id:
errors.append(
{
"item": item,
"error": "Missing required field: stream_id is required.",
}
)
continue
try:
stream = get_object_or_404(Stream, pk=stream_id)
except Exception as e:
errors.append({"item": item, "error": str(e)})
continue
name = item.get("name")
if name is None:
name = stream.name
channel_group = stream.channel_group
stream_custom_props = stream.custom_properties or {}
channel_number = None
if "tvg-chno" in stream_custom_props:
channel_number = float(stream_custom_props["tvg-chno"])
elif "channel-number" in stream_custom_props:
channel_number = float(stream_custom_props["channel-number"])
elif "num" in stream_custom_props:
channel_number = float(stream_custom_props["num"])
# Get the tvc_guide_stationid from custom properties if it exists
tvc_guide_stationid = None
if "tvc-guide-stationid" in stream_custom_props:
tvc_guide_stationid = stream_custom_props["tvc-guide-stationid"]
# Determine channel number: if provided, use it (if free); else auto assign.
if channel_number is None:
provided_number = item.get("channel_number")
if provided_number is None:
channel_number = get_auto_number()
else:
try:
channel_number = float(provided_number)
except ValueError:
errors.append(
{
"item": item,
"error": "channel_number must be a number.",
}
)
continue
if (
channel_number in used_numbers
or Channel.objects.filter(
channel_number=channel_number
).exists()
):
errors.append(
{
"item": item,
"error": f"Channel number {channel_number} is already in use.",
}
)
continue
used_numbers.add(channel_number)
channel_data = {
"channel_number": channel_number,
"name": name,
"tvc_guide_stationid": tvc_guide_stationid,
"tvg_id": stream.tvg_id,
}
# Only add channel_group_id if the stream has a channel group
if channel_group:
channel_data["channel_group_id"] = channel_group.id
# Attempt to find existing EPGs with the same tvg-id
epgs = EPGData.objects.filter(tvg_id=stream.tvg_id)
if epgs:
channel_data["epg_data_id"] = epgs.first().id
serializer = self.get_serializer(data=channel_data)
if serializer.is_valid():
validated_data = serializer.validated_data
channel = Channel(**validated_data)
channels_to_create.append(channel)
streams_map.append([stream_id])
# Store which profiles this channel should be added to - normalize to array
channel_profile_ids = item.get("channel_profile_ids")
if channel_profile_ids is not None:
# Normalize single ID to array
if not isinstance(channel_profile_ids, list):
channel_profile_ids = [channel_profile_ids]
profile_map.append(channel_profile_ids)
if stream.logo_url:
logos_to_create.append(
Logo(
url=stream.logo_url,
name=stream.name or stream.tvg_id,
)
)
logo_map.append(stream.logo_url)
else:
logo_map.append(None)
else:
errors.append({"item": item, "error": serializer.errors})
if logos_to_create:
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
channel_logos = {
logo.url: logo
for logo in Logo.objects.filter(
url__in=[url for url in logo_map if url is not None]
if not isinstance(stream_ids, list):
return Response(
{"error": "stream_ids must be a list of integers"},
status=status.HTTP_400_BAD_REQUEST,
)
}
# Get all profiles for default assignment
all_profiles = ChannelProfile.objects.all()
channel_profile_memberships = []
# Normalize channel_profile_ids to array if single ID provided
if channel_profile_ids is not None:
if not isinstance(channel_profile_ids, list):
channel_profile_ids = [channel_profile_ids]
if channels_to_create:
with transaction.atomic():
created_channels = Channel.objects.bulk_create(channels_to_create)
# Start the async task
task = bulk_create_channels_from_streams.delay(stream_ids, channel_profile_ids, starting_channel_number)
update = []
for channel, stream_ids, logo_url, channel_profile_ids in zip(
created_channels, streams_map, logo_map, profile_map
):
if logo_url:
channel.logo = channel_logos[logo_url]
update.append(channel)
# Handle channel profile membership based on channel_profile_ids
if channel_profile_ids:
# Add channel only to the specified profiles
try:
specific_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids)
channel_profile_memberships.extend([
ChannelProfileMembership(
channel_profile=profile,
channel=channel,
enabled=True
)
for profile in specific_profiles
])
except Exception:
# If profiles don't exist, add to all profiles as fallback
channel_profile_memberships.extend([
ChannelProfileMembership(
channel_profile=profile,
channel=channel,
enabled=True
)
for profile in all_profiles
])
else:
# Default behavior: add to all profiles
channel_profile_memberships.extend([
ChannelProfileMembership(
channel_profile=profile,
channel=channel,
enabled=True
)
for profile in all_profiles
])
# Bulk create profile memberships
if channel_profile_memberships:
ChannelProfileMembership.objects.bulk_create(
channel_profile_memberships
)
# Update logos
if update:
Channel.objects.bulk_update(update, ["logo"])
# Set stream relationships
for channel, stream_ids in zip(created_channels, streams_map):
channel.streams.set(stream_ids)
response_data = {"created": ChannelSerializer(created_channels, many=True).data}
if errors:
response_data["errors"] = errors
return Response(response_data, status=status.HTTP_201_CREATED)
return Response({
"task_id": task.id,
"message": f"Bulk channel creation task started for {len(stream_ids)} streams",
"stream_count": len(stream_ids),
"status": "started"
}, status=status.HTTP_202_ACCEPTED)
# ─────────────────────────────────────────────────────────
# 6) EPG Fuzzy Matching
# ─────────────────────────────────────────────────────────
@swagger_auto_schema(
method="post",
operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data.",
operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'channel_ids': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_INTEGER),
description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.'
)
}
),
responses={202: "EPG matching task initiated"},
)
@action(detail=False, methods=["post"], url_path="match-epg")
def match_epg(self, request):
match_epg_channels.delay()
# Get channel IDs from request body if provided
channel_ids = request.data.get('channel_ids', [])
if channel_ids:
# Process only selected channels
from .tasks import match_selected_channels_epg
match_selected_channels_epg.delay(channel_ids)
message = f"EPG matching task initiated for {len(channel_ids)} selected channel(s)."
else:
# Process all channels without EPG (original behavior)
match_epg_channels.delay()
message = "EPG matching task initiated for all channels without EPG."
return Response(
{"message": "EPG matching task initiated."}, status=status.HTTP_202_ACCEPTED
{"message": message}, status=status.HTTP_202_ACCEPTED
)
@swagger_auto_schema(
method="post",
operation_description="Try to auto-match this specific channel with EPG data.",
responses={200: "EPG matching completed", 202: "EPG matching task initiated"},
)
@action(detail=True, methods=["post"], url_path="match-epg")
def match_channel_epg(self, request, pk=None):
channel = self.get_object()
# Import the matching logic
from apps.channels.tasks import match_single_channel_epg
try:
# Try to match this specific channel - call synchronously for immediate response
result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30)
# Refresh the channel from DB to get any updates
channel.refresh_from_db()
return Response({
"message": result.get("message", "Channel matching completed"),
"matched": result.get("matched", False),
"channel": self.get_serializer(channel).data
})
except Exception as e:
return Response({"error": str(e)}, status=400)
# ─────────────────────────────────────────────────────────
# 7) Set EPG and Refresh
# ─────────────────────────────────────────────────────────

View file

@ -379,14 +379,17 @@ class Channel(models.Model):
if not m3u_account:
logger.debug(f"Stream {stream.id} has no M3U account")
continue
if m3u_account.is_active == False:
logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.")
continue
m3u_profiles = m3u_account.profiles.all()
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next(
(obj for obj in m3u_profiles if obj.is_default), None
)
if not default_profile:
logger.debug(f"M3U account {m3u_account.id} has no default profile")
logger.debug(f"M3U account {m3u_account.id} has no active default profile")
continue
profiles = [default_profile] + [
@ -394,11 +397,6 @@ class Channel(models.Model):
]
for profile in profiles:
# Skip inactive profiles
if not profile.is_active:
logger.debug(f"Skipping inactive profile {profile.id}")
continue
has_active_profiles = True
profile_connections_key = f"profile_connections:{profile.id}"
@ -433,9 +431,9 @@ class Channel(models.Model):
# No available streams - determine specific reason
if has_streams_but_maxed_out:
error_reason = "All M3U profiles have reached maximum connection limits"
error_reason = "All active M3U profiles have reached maximum connection limits"
elif has_active_profiles:
error_reason = "No compatible profile found for any assigned stream"
error_reason = "No compatible active profile found for any assigned stream"
else:
error_reason = "No active profiles found for any assigned stream"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-16 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0015_alter_programdata_custom_properties'),
]
operations = [
migrations.AddField(
model_name='epgdata',
name='icon_url',
field=models.URLField(blank=True, max_length=500, null=True),
),
]

View file

@ -127,6 +127,7 @@ class EPGData(models.Model):
# 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, db_index=True)
name = models.CharField(max_length=255)
icon_url = models.URLField(max_length=500, null=True, blank=True)
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,

View file

@ -52,5 +52,6 @@ class EPGDataSerializer(serializers.ModelSerializer):
'id',
'tvg_id',
'name',
'icon_url',
'epg_source',
]

View file

@ -873,10 +873,14 @@ def parse_channels_only(source):
tvg_id = elem.get('id', '').strip()
if tvg_id:
display_name = None
icon_url = None
for child in elem:
if child.tag == 'display-name' and child.text:
display_name = child.text.strip()
break
elif child.tag == 'icon':
icon_url = child.get('src', '').strip()
if display_name and icon_url:
break # No need to continue if we have both
if not display_name:
display_name = tvg_id
@ -894,17 +898,24 @@ def parse_channels_only(source):
epgs_to_create.append(EPGData(
tvg_id=tvg_id,
name=display_name,
icon_url=icon_url,
epg_source=source,
))
logger.debug(f"[parse_channels_only] Added new channel to epgs_to_create 1: {tvg_id} - {display_name}")
processed_channels += 1
continue
# We use the cached object to check if the name has changed
# We use the cached object to check if the name or icon_url has changed
epg_obj = existing_epgs[tvg_id]
needs_update = False
if epg_obj.name != display_name:
# Only update if the name actually changed
epg_obj.name = display_name
needs_update = True
if epg_obj.icon_url != icon_url:
epg_obj.icon_url = icon_url
needs_update = True
if needs_update:
epgs_to_update.append(epg_obj)
logger.debug(f"[parse_channels_only] Added channel to update to epgs_to_update: {tvg_id} - {display_name}")
else:
@ -915,6 +926,7 @@ def parse_channels_only(source):
epgs_to_create.append(EPGData(
tvg_id=tvg_id,
name=display_name,
icon_url=icon_url,
epg_source=source,
))
logger.debug(f"[parse_channels_only] Added new channel to epgs_to_create 2: {tvg_id} - {display_name}")
@ -937,7 +949,7 @@ def parse_channels_only(source):
logger.info(f"[parse_channels_only] Bulk updating {len(epgs_to_update)} EPG entries")
if process:
logger.info(f"[parse_channels_only] Memory before bulk_update: {process.memory_info().rss / 1024 / 1024:.2f} MB")
EPGData.objects.bulk_update(epgs_to_update, ["name"])
EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"])
if process:
logger.info(f"[parse_channels_only] Memory after bulk_update: {process.memory_info().rss / 1024 / 1024:.2f} MB")
epgs_to_update = []
@ -1004,7 +1016,7 @@ def parse_channels_only(source):
logger.debug(f"[parse_channels_only] Created final batch of {len(epgs_to_create)} EPG entries")
if epgs_to_update:
EPGData.objects.bulk_update(epgs_to_update, ["name"])
EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"])
logger.debug(f"[parse_channels_only] Updated final batch of {len(epgs_to_update)} EPG entries")
if process:
logger.debug(f"[parse_channels_only] Memory after final batch creation: {process.memory_info().rss / 1024 / 1024:.2f} MB")

View file

@ -19,6 +19,7 @@ from tzlocal import get_localzone
from urllib.parse import urlparse
import base64
import logging
from django.db.models.functions import Lower
import os
from apps.m3u.utils import calculate_tuner_count
@ -859,11 +860,11 @@ def xc_get_live_categories(user):
channel_profiles
)
channel_groups = ChannelGroup.objects.filter(**filters).distinct()
channel_groups = ChannelGroup.objects.filter(**filters).distinct().order_by(Lower("name"))
else:
channel_groups = ChannelGroup.objects.filter(
channels__isnull=False, channels__user_level__lte=user.user_level
).distinct()
).distinct().order_by(Lower("name"))
for group in channel_groups:
response.append(
@ -1007,31 +1008,11 @@ def xc_get_vod_categories(user):
response = []
# Filter categories based on user's M3U accounts
if user.user_level == 0:
# For regular users, get categories from their accessible M3U accounts
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
# Get M3U accounts accessible through user's profiles
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
else:
m3u_accounts = []
# Get categories that have movie relations with these accounts
categories = VODCategory.objects.filter(
category_type='movie',
m3umovierelation__m3u_account__in=m3u_accounts
).distinct()
else:
# Admins can see all categories that have active movie relations
categories = VODCategory.objects.filter(
category_type='movie',
m3umovierelation__m3u_account__is_active=True
).distinct()
# All authenticated users get access to VOD from all active M3U accounts
categories = VODCategory.objects.filter(
category_type='movie',
m3umovierelation__m3u_account__is_active=True
).distinct().order_by(Lower("name"))
for category in categories:
response.append({
@ -1050,22 +1031,9 @@ def xc_get_vod_streams(request, user, category_id=None):
streams = []
# Build filters for movies based on user access
# All authenticated users get access to VOD from all active M3U accounts
filters = {"m3u_relations__m3u_account__is_active": True}
if user.user_level == 0:
# For regular users, filter by accessible M3U accounts
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
filters["m3u_relations__m3u_account__in"] = m3u_accounts
else:
return [] # No accessible accounts
if category_id:
filters["m3u_relations__category_id"] = category_id
@ -1126,28 +1094,11 @@ def xc_get_series_categories(user):
response = []
# Similar filtering as VOD categories but for series
if user.user_level == 0:
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
else:
m3u_accounts = []
# Get categories that have series relations with these accounts
categories = VODCategory.objects.filter(
category_type='series',
m3useriesrelation__m3u_account__in=m3u_accounts
).distinct()
else:
categories = VODCategory.objects.filter(
category_type='series',
m3useriesrelation__m3u_account__is_active=True
).distinct()
# All authenticated users get access to series from all active M3U accounts
categories = VODCategory.objects.filter(
category_type='series',
m3useriesrelation__m3u_account__is_active=True
).distinct().order_by(Lower("name"))
for category in categories:
response.append({
@ -1165,21 +1116,9 @@ def xc_get_series(request, user, category_id=None):
series_list = []
# Build filters based on user access
# All authenticated users get access to series from all active M3U accounts
filters = {"m3u_account__is_active": True}
if user.user_level == 0:
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
filters["m3u_account__in"] = m3u_accounts
else:
return []
if category_id:
filters["category_id"] = category_id
@ -1227,21 +1166,9 @@ def xc_get_series_info(request, user, series_id):
if not series_id:
raise Http404()
# Get series relation with user access filtering
# All authenticated users get access to series from all active M3U accounts
filters = {"id": series_id, "m3u_account__is_active": True}
if user.user_level == 0:
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
filters["m3u_account__in"] = m3u_accounts
else:
raise Http404()
try:
series_relation = M3USeriesRelation.objects.select_related('series', 'series__logo').get(**filters)
series = series_relation.series
@ -1438,21 +1365,9 @@ def xc_get_vod_info(request, user, vod_id):
if not vod_id:
raise Http404()
# Get movie relation with user access filtering - use movie ID instead of relation ID
# All authenticated users get access to VOD from all active M3U accounts
filters = {"movie_id": vod_id, "m3u_account__is_active": True}
if user.user_level == 0:
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
filters["m3u_account__in"] = m3u_accounts
else:
raise Http404()
try:
# Order by account priority to get the best relation when multiple exist
movie_relation = M3UMovieRelation.objects.select_related('movie', 'movie__logo').filter(**filters).order_by('-m3u_account__priority', 'id').first()
@ -1601,22 +1516,9 @@ def xc_movie_stream(request, username, password, stream_id, extension):
if custom_properties["xc_password"] != password:
return JsonResponse({"error": "Invalid credentials"}, status=401)
# Get movie relation based on user access level - use movie ID instead of relation ID
# All authenticated users get access to VOD from all active M3U accounts
filters = {"movie_id": stream_id, "m3u_account__is_active": True}
if user.user_level < 10:
# For regular users, filter by accessible M3U accounts
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
filters["m3u_account__in"] = m3u_accounts
else:
return JsonResponse({"error": "No accessible content"}, status=403)
try:
# Order by account priority to get the best relation when multiple exist
movie_relation = M3UMovieRelation.objects.select_related('movie').filter(**filters).order_by('-m3u_account__priority', 'id').first()
@ -1651,22 +1553,9 @@ def xc_series_stream(request, username, password, stream_id, extension):
if custom_properties["xc_password"] != password:
return JsonResponse({"error": "Invalid credentials"}, status=401)
# Get episode relation based on user access level - use episode ID instead of stream_id
# All authenticated users get access to series/episodes from all active M3U accounts
filters = {"episode_id": stream_id, "m3u_account__is_active": True}
if user.user_level < 10:
# For regular users, filter by accessible M3U accounts
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
from apps.m3u.models import M3UAccount
m3u_accounts = M3UAccount.objects.filter(
is_active=True,
profiles__in=channel_profiles
).distinct()
filters["m3u_account__in"] = m3u_accounts
else:
return JsonResponse({"error": "No accessible content"}, status=403)
try:
episode_relation = M3UEpisodeRelation.objects.select_related('episode').get(**filters)
except M3UEpisodeRelation.DoesNotExist:

View file

@ -142,7 +142,7 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
if not m3u_account:
return {'error': 'Stream has no M3U account'}
m3u_profiles = m3u_account.profiles.all()
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
@ -153,10 +153,6 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
selected_profile = None
for profile in profiles:
# Skip inactive profiles
if not profile.is_active:
logger.debug(f"Skipping inactive profile {profile.id}")
continue
# Check connection availability
if redis_client:
@ -281,8 +277,10 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
if not m3u_account:
logger.debug(f"Stream {stream.id} has no M3U account")
continue
m3u_profiles = m3u_account.profiles.all()
if m3u_account.is_active == False:
logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.")
continue
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
@ -294,11 +292,6 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
selected_profile = None
for profile in profiles:
# Skip inactive profiles
if not profile.is_active:
logger.debug(f"Skipping inactive profile {profile.id}")
continue
# Check connection availability
if redis_client:
profile_connections_key = f"profile_connections:{profile.id}"

View file

@ -5,6 +5,7 @@ from .api_views import (
EpisodeViewSet,
SeriesViewSet,
VODCategoryViewSet,
UnifiedContentViewSet,
)
app_name = 'vod'
@ -14,5 +15,6 @@ router.register(r'movies', MovieViewSet, basename='movie')
router.register(r'episodes', EpisodeViewSet, basename='episode')
router.register(r'series', SeriesViewSet, basename='series')
router.register(r'categories', VODCategoryViewSet, basename='vodcategory')
router.register(r'all', UnifiedContentViewSet, basename='unified-content')
urlpatterns = router.urls

View file

@ -469,3 +469,203 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet):
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet that combines Movies and Series for unified 'All' view"""
queryset = Movie.objects.none() # Empty queryset, we override list method
serializer_class = MovieSerializer # Default serializer, overridden in list
pagination_class = VODPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ['name', 'description', 'genre']
ordering_fields = ['name', 'year', 'created_at']
ordering = ['name']
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def list(self, request, *args, **kwargs):
"""Override list to handle unified content properly - database-level approach"""
import logging
from django.db import connection
logger = logging.getLogger(__name__)
logger.error("=== UnifiedContentViewSet.list() called ===")
try:
# Get pagination parameters
page_size = int(request.query_params.get('page_size', 24))
page_number = int(request.query_params.get('page', 1))
logger.error(f"Page {page_number}, page_size {page_size}")
# Calculate offset for unified pagination
offset = (page_number - 1) * page_size
# For high page numbers, use raw SQL for efficiency
# This avoids loading and sorting massive amounts of data in Python
search = request.query_params.get('search', '')
category = request.query_params.get('category', '')
# Build WHERE clauses
where_conditions = [
# Only active content
"movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id WHERE ma.is_active = true)",
"series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)"
]
params = []
if search:
where_conditions[0] += " AND LOWER(movies.name) LIKE %s"
where_conditions[1] += " AND LOWER(series.name) LIKE %s"
search_param = f"%{search.lower()}%"
params.extend([search_param, search_param])
if category:
if '|' in category:
cat_name, cat_type = category.split('|', 1)
if cat_type == 'movie':
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] = "1=0" # Exclude series
params.append(cat_name)
elif cat_type == 'series':
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
where_conditions[0] = "1=0" # Exclude movies
params.append(cat_name)
else:
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
params.extend([category, category])
# Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination
# This is much more efficient than Python sorting
sql = f"""
WITH unified_content AS (
SELECT
movies.id,
movies.uuid,
movies.name,
movies.description,
movies.year,
movies.rating,
movies.genre,
movies.duration_secs as duration,
movies.created_at,
movies.updated_at,
movies.custom_properties,
movies.logo_id,
logo.name as logo_name,
logo.url as logo_url,
'movie' as content_type
FROM vod_movie movies
LEFT JOIN dispatcharr_channels_logo logo ON movies.logo_id = logo.id
WHERE {where_conditions[0]}
UNION ALL
SELECT
series.id,
series.uuid,
series.name,
series.description,
series.year,
series.rating,
series.genre,
NULL as duration,
series.created_at,
series.updated_at,
series.custom_properties,
series.logo_id,
logo.name as logo_name,
logo.url as logo_url,
'series' as content_type
FROM vod_series series
LEFT JOIN dispatcharr_channels_logo logo ON series.logo_id = logo.id
WHERE {where_conditions[1]}
)
SELECT * FROM unified_content
ORDER BY LOWER(name), id
LIMIT %s OFFSET %s
"""
params.extend([page_size, offset])
logger.error(f"Executing SQL with LIMIT {page_size} OFFSET {offset}")
with connection.cursor() as cursor:
cursor.execute(sql, params)
columns = [col[0] for col in cursor.description]
results = []
for row in cursor.fetchall():
item_dict = dict(zip(columns, row))
# Build logo object in the format expected by frontend
logo_data = None
if item_dict['logo_id']:
logo_data = {
'id': item_dict['logo_id'],
'name': item_dict['logo_name'],
'url': item_dict['logo_url'],
'cache_url': f"/media/logo_cache/{item_dict['logo_id']}.png" if item_dict['logo_id'] else None,
'channel_count': 0, # We don't need this for VOD
'is_used': True,
'channel_names': [] # We don't need this for VOD
}
# Convert to the format expected by frontend
formatted_item = {
'id': item_dict['id'],
'uuid': str(item_dict['uuid']),
'name': item_dict['name'],
'description': item_dict['description'] or '',
'year': item_dict['year'],
'rating': float(item_dict['rating']) if item_dict['rating'] else 0.0,
'genre': item_dict['genre'] or '',
'duration': item_dict['duration'],
'created_at': item_dict['created_at'].isoformat() if item_dict['created_at'] else None,
'updated_at': item_dict['updated_at'].isoformat() if item_dict['updated_at'] else None,
'custom_properties': item_dict['custom_properties'] or {},
'logo': logo_data,
'content_type': item_dict['content_type']
}
results.append(formatted_item)
logger.error(f"Retrieved {len(results)} results via SQL")
# Get total count estimate (for pagination info)
# Use a separate efficient count query
count_sql = f"""
SELECT COUNT(*) FROM (
SELECT 1 FROM vod_movie movies WHERE {where_conditions[0]}
UNION ALL
SELECT 1 FROM vod_series series WHERE {where_conditions[1]}
) as total_count
"""
count_params = params[:-2] # Remove LIMIT and OFFSET params
with connection.cursor() as cursor:
cursor.execute(count_sql, count_params)
total_count = cursor.fetchone()[0]
response_data = {
'count': total_count,
'next': offset + page_size < total_count,
'previous': page_number > 1,
'results': results
}
return Response(response_data)
except Exception as e:
logger.error(f"Error in UnifiedContentViewSet.list(): {e}")
import traceback
logger.error(traceback.format_exc())
return Response({'error': str(e)}, status=500)

View file

@ -62,9 +62,9 @@ def refresh_vod_content(account_id):
logger.info(f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds")
# Cleanup orphaned VOD content after refresh
logger.info("Starting cleanup of orphaned VOD content")
cleanup_result = cleanup_orphaned_vod_content(scan_start_time=start_time)
# Cleanup orphaned VOD content after refresh (scoped to this account only)
logger.info(f"Starting cleanup of orphaned VOD content for account {account.name}")
cleanup_result = cleanup_orphaned_vod_content(account_id=account_id, scan_start_time=start_time)
logger.info(f"VOD cleanup completed: {cleanup_result}")
# Send completion notification
@ -135,11 +135,11 @@ def refresh_movies(client, account, categories_by_provider, relations, scan_star
# Process movies in chunks using the simple approach
chunk_size = 1000
total_movies = len(all_movies_data)
total_chunks = (total_movies + chunk_size - 1) // chunk_size if total_movies > 0 else 0
for i in range(0, total_movies, chunk_size):
chunk = all_movies_data[i:i + chunk_size]
chunk_num = (i // chunk_size) + 1
total_chunks = (total_movies + chunk_size - 1) // chunk_size
logger.info(f"Processing movie chunk {chunk_num}/{total_chunks} ({len(chunk)} movies)")
process_movie_batch(account, chunk, categories_by_provider, relations, scan_start_time)
@ -158,11 +158,11 @@ def refresh_series(client, account, categories_by_provider, relations, scan_star
# Process series in chunks using the simple approach
chunk_size = 1000
total_series = len(all_series_data)
total_chunks = (total_series + chunk_size - 1) // chunk_size if total_series > 0 else 0
for i in range(0, total_series, chunk_size):
chunk = all_series_data[i:i + chunk_size]
chunk_num = (i // chunk_size) + 1
total_chunks = (total_series + chunk_size - 1) // chunk_size
logger.info(f"Processing series chunk {chunk_num}/{total_chunks} ({len(chunk)} series)")
process_series_batch(account, chunk, categories_by_provider, relations, scan_start_time)
@ -427,17 +427,18 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
updated = True
# Handle logo assignment for existing movies
logo_updated = False
if logo_url and len(logo_url) <= 500 and logo_url in existing_logos:
new_logo = existing_logos[logo_url]
if movie.logo != new_logo:
movie.logo = new_logo
updated = True
movie._logo_to_update = new_logo
logo_updated = True
elif (not logo_url or len(logo_url) > 500) and movie.logo:
# Clear logo if no logo URL provided or URL is too long
movie.logo = None
updated = True
movie._logo_to_update = None
logo_updated = True
if updated:
if updated or logo_updated:
movies_to_update.append(movie)
else:
# Create new movie
@ -509,11 +510,18 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
# Update existing movies
if movies_to_update:
# First, update all fields except logo to avoid unsaved related object issues
Movie.objects.bulk_update(movies_to_update, [
'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id',
'duration_secs', 'custom_properties', 'logo'
'duration_secs', 'custom_properties'
])
# Handle logo updates separately to avoid bulk_update issues
for movie in movies_to_update:
if hasattr(movie, '_logo_to_update'):
movie.logo = movie._logo_to_update
movie.save(update_fields=['logo'])
# Update relations to reference the correct movie objects
for relation in relations_to_create:
if id(relation.movie) in created_movies:
@ -741,17 +749,18 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
updated = True
# Handle logo assignment for existing series
logo_updated = False
if logo_url and len(logo_url) <= 500 and logo_url in existing_logos:
new_logo = existing_logos[logo_url]
if series.logo != new_logo:
series.logo = new_logo
updated = True
series._logo_to_update = new_logo
logo_updated = True
elif (not logo_url or len(logo_url) > 500) and series.logo:
# Clear logo if no logo URL provided or URL is too long
series.logo = None
updated = True
series._logo_to_update = None
logo_updated = True
if updated:
if updated or logo_updated:
series_to_update.append(series)
else:
# Create new series
@ -823,11 +832,18 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
# Update existing series
if series_to_update:
# First, update all fields except logo to avoid unsaved related object issues
Series.objects.bulk_update(series_to_update, [
'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id',
'custom_properties', 'logo'
'custom_properties'
])
# Handle logo updates separately to avoid bulk_update issues
for series in series_to_update:
if hasattr(series, '_logo_to_update'):
series.logo = series._logo_to_update
series.save(update_fields=['logo'])
# Update relations to reference the correct series objects
for relation in relations_to_create:
if id(relation.series) in created_series:
@ -1292,7 +1308,7 @@ def batch_refresh_series_episodes(account_id, series_ids=None):
@shared_task
def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None):
def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=None):
"""Clean up VOD content that has no M3U relations or has stale relations"""
from datetime import timedelta
@ -1302,30 +1318,44 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None):
# Calculate cutoff date for stale relations
cutoff_date = reference_time - timedelta(days=stale_days)
# Build base query filters
base_filters = {'last_seen__lt': cutoff_date}
if account_id:
base_filters['m3u_account_id'] = account_id
logger.info(f"Cleaning up stale VOD content for account {account_id}")
else:
logger.info("Cleaning up stale VOD content across all accounts")
# Clean up stale movie relations (haven't been seen in the specified days)
stale_movie_relations = M3UMovieRelation.objects.filter(last_seen__lt=cutoff_date)
stale_movie_relations = M3UMovieRelation.objects.filter(**base_filters)
stale_movie_count = stale_movie_relations.count()
stale_movie_relations.delete()
# Clean up stale series relations
stale_series_relations = M3USeriesRelation.objects.filter(last_seen__lt=cutoff_date)
stale_series_relations = M3USeriesRelation.objects.filter(**base_filters)
stale_series_count = stale_series_relations.count()
stale_series_relations.delete()
# Clean up stale episode relations
stale_episode_relations = M3UEpisodeRelation.objects.filter(last_seen__lt=cutoff_date)
stale_episode_relations = M3UEpisodeRelation.objects.filter(**base_filters)
stale_episode_count = stale_episode_relations.count()
stale_episode_relations.delete()
# Clean up movies with no relations (orphaned)
orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
orphaned_movie_count = orphaned_movies.count()
orphaned_movies.delete()
# Clean up movies with no relations (orphaned) - only if no account_id specified (global cleanup)
if not account_id:
orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
orphaned_movie_count = orphaned_movies.count()
orphaned_movies.delete()
# Clean up series with no relations (orphaned)
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
orphaned_series_count = orphaned_series.count()
orphaned_series.delete()
# Clean up series with no relations (orphaned) - only if no account_id specified (global cleanup)
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
orphaned_series_count = orphaned_series.count()
orphaned_series.delete()
else:
# When cleaning up for specific account, we don't remove orphaned content
# as other accounts might still reference it
orphaned_movie_count = 0
orphaned_series_count = 0
# Episodes will be cleaned up via CASCADE when series are deleted

View file

@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet
from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, UnifiedContentViewSet
app_name = 'vod'
@ -9,7 +9,7 @@ router.register(r'movies', MovieViewSet)
router.register(r'episodes', EpisodeViewSet)
router.register(r'series', SeriesViewSet)
router.register(r'categories', VODCategoryViewSet)
router.register(r'connections', VODConnectionViewSet)
router.register(r'all', UnifiedContentViewSet, basename='unified-content')
urlpatterns = [
path('api/', include(router.urls)),

View file

@ -330,6 +330,173 @@ export const WebsocketProvider = ({ children }) => {
}
break;
case 'epg_matching_progress': {
const progress = parsedEvent.data;
const id = 'epg-matching-progress';
if (progress.stage === 'starting') {
notifications.show({
id,
title: 'EPG Matching in Progress',
message: `Starting to match ${progress.total} channels...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.stage === 'matching') {
let message = `Matched ${progress.matched} of ${progress.total} channels`;
if (progress.remaining > 0) {
message += ` (${progress.remaining} remaining)`;
}
if (progress.current_channel) {
message += `\nCurrently processing: ${progress.current_channel}`;
}
notifications.update({
id,
title: 'EPG Matching in Progress',
message,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.stage === 'completed') {
notifications.update({
id,
title: 'EPG Matching Complete',
message: `Successfully matched ${progress.matched} of ${progress.total} channels (${progress.progress_percent}%)`,
color: progress.matched > 0 ? 'green.5' : 'orange',
loading: false,
autoClose: 6000,
});
}
break;
}
case 'epg_logo_setting_progress': {
const progress = parsedEvent.data;
const id = 'epg-logo-setting-progress';
if (progress.status === 'running' && progress.progress === 0) {
// Initial message
notifications.show({
id,
title: 'Setting Logos from EPG',
message: `Processing ${progress.total} channels...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'running') {
// Progress update
let message = `Processed ${progress.progress} of ${progress.total} channels`;
if (progress.updated_count !== undefined) {
message += ` (${progress.updated_count} updated)`;
}
if (progress.created_logos_count !== undefined) {
message += `, created ${progress.created_logos_count} logos`;
}
notifications.update({
id,
title: 'Setting Logos from EPG',
message,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'completed') {
notifications.update({
id,
title: 'Logo Setting Complete',
message: `Successfully updated ${progress.updated_count || 0} channel logos${progress.created_logos_count ? `, created ${progress.created_logos_count} new logos` : ''}`,
color: progress.updated_count > 0 ? 'green.5' : 'orange',
loading: false,
autoClose: 6000,
});
// Refresh channels data and logos
try {
await API.requeryChannels();
await useChannelsStore.getState().fetchChannels();
// Get updated channel data and extract logo IDs to load
const channels = useChannelsStore.getState().channels;
const logoIds = Object.values(channels)
.filter((channel) => channel.logo_id)
.map((channel) => channel.logo_id);
// Fetch the specific logos that were just assigned
if (logoIds.length > 0) {
await useLogosStore.getState().fetchLogosByIds(logoIds);
}
} catch (e) {
console.warn(
'Failed to refresh channels after logo setting:',
e
);
}
}
break;
}
case 'epg_name_setting_progress': {
const progress = parsedEvent.data;
const id = 'epg-name-setting-progress';
if (progress.status === 'running' && progress.progress === 0) {
// Initial message
notifications.show({
id,
title: 'Setting Names from EPG',
message: `Processing ${progress.total} channels...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'running') {
// Progress update
let message = `Processed ${progress.progress} of ${progress.total} channels`;
if (progress.updated_count !== undefined) {
message += ` (${progress.updated_count} updated)`;
}
notifications.update({
id,
title: 'Setting Names from EPG',
message,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'completed') {
notifications.update({
id,
title: 'Name Setting Complete',
message: `Successfully updated ${progress.updated_count || 0} channel names from EPG data`,
color: progress.updated_count > 0 ? 'green.5' : 'orange',
loading: false,
autoClose: 6000,
});
// Refresh channels data
try {
await API.requeryChannels();
await useChannelsStore.getState().fetchChannels();
} catch (e) {
console.warn(
'Failed to refresh channels after name setting:',
e
);
}
}
break;
}
case 'm3u_profile_test':
setProfilePreview(
parsedEvent.data.search_preview,
@ -549,6 +716,87 @@ export const WebsocketProvider = ({ children }) => {
});
break;
case 'channels_created':
// General notification for channel creation
notifications.show({
title: 'Channels Created',
message: `Successfully created ${parsedEvent.data.count || 'multiple'} channel(s)`,
color: 'green',
autoClose: 4000,
});
// Refresh the channels table to show new channels
try {
await API.requeryChannels();
await useChannelsStore.getState().fetchChannels();
console.log('Channels refreshed after bulk creation');
} catch (error) {
console.error(
'Error refreshing channels after creation:',
error
);
}
break;
case 'bulk_channel_creation_progress': {
// Handle progress updates with persistent notifications like stream rehash
const data = parsedEvent.data;
if (data.status === 'starting') {
notifications.show({
id: 'bulk-channel-creation-progress', // Persistent ID
title: 'Bulk Channel Creation Started',
message: data.message || 'Starting bulk channel creation...',
color: 'blue.5',
autoClose: false, // Don't auto-close
withCloseButton: false, // No close button during processing
loading: true, // Show loading indicator
});
} else if (
data.status === 'processing' ||
data.status === 'creating_logos' ||
data.status === 'creating_channels'
) {
// Calculate progress percentage
const progressPercent =
data.total > 0
? Math.round((data.progress / data.total) * 100)
: 0;
// Update the existing notification with progress
notifications.update({
id: 'bulk-channel-creation-progress',
title: 'Bulk Channel Creation in Progress',
message: `${progressPercent}% complete - ${data.message}`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (data.status === 'completed') {
// Hide the progress notification since channels_created will show success
notifications.hide('bulk-channel-creation-progress');
} else if (data.status === 'failed') {
// Update to error state
notifications.update({
id: 'bulk-channel-creation-progress',
title: 'Bulk Channel Creation Failed',
message:
data.error ||
'An error occurred during bulk channel creation',
color: 'red.5',
autoClose: 12000, // Auto-close after longer delay for errors
withCloseButton: true, // Allow manual close
loading: false, // Remove loading indicator
});
}
// Pass through to individual components for any additional handling
setVal(parsedEvent);
break;
}
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`

View file

@ -323,14 +323,27 @@ export default class API {
static async addChannel(channel) {
try {
let body = null;
// Prepare a copy to safely mutate
const channelData = { ...channel };
// Remove channel_number if empty, null, or not a valid number
if (
channelData.channel_number === '' ||
channelData.channel_number === null ||
channelData.channel_number === undefined ||
(typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '')
) {
delete channelData.channel_number;
}
if (channel.logo_file) {
// Must send FormData for file upload
body = new FormData();
for (const prop in channel) {
body.append(prop, channel[prop]);
for (const prop in channelData) {
body.append(prop, channelData[prop]);
}
} else {
body = { ...channel };
body = { ...channelData };
delete body.logo_file;
}
@ -503,6 +516,52 @@ export default class API {
}
}
static async setChannelNamesFromEpg(channelIds) {
try {
const response = await request(
`${host}/api/channels/channels/set-names-from-epg/`,
{
method: 'POST',
body: { channel_ids: channelIds },
}
);
notifications.show({
title: 'Task Started',
message: response.message,
color: 'blue',
});
return response;
} catch (e) {
errorNotification('Failed to start EPG name setting task', e);
throw e;
}
}
static async setChannelLogosFromEpg(channelIds) {
try {
const response = await request(
`${host}/api/channels/channels/set-logos-from-epg/`,
{
method: 'POST',
body: { channel_ids: channelIds },
}
);
notifications.show({
title: 'Task Started',
message: response.message,
color: 'blue',
});
return response;
} catch (e) {
errorNotification('Failed to start EPG logo setting task', e);
throw e;
}
}
static async assignChannelNumbers(channelIds, startingNum = 1) {
try {
const response = await request(`${host}/api/channels/channels/assign/`, {
@ -536,24 +595,32 @@ export default class API {
}
}
static async createChannelsFromStreams(values) {
static async createChannelsFromStreamsAsync(streamIds, channelProfileIds = null, startingChannelNumber = null) {
try {
const requestBody = {
stream_ids: streamIds,
};
if (channelProfileIds !== null) {
requestBody.channel_profile_ids = channelProfileIds;
}
if (startingChannelNumber !== null) {
requestBody.starting_channel_number = startingChannelNumber;
}
const response = await request(
`${host}/api/channels/channels/from-stream/bulk/`,
{
method: 'POST',
body: values,
body: requestBody,
}
);
if (response.created && response.created.length > 0) {
useChannelsStore.getState().addChannels(response.created);
}
return response;
} catch (e) {
errorNotification('Failed to create channels', e);
throw e; // Re-throw to allow proper error handling in calling code
errorNotification('Failed to start bulk channel creation task', e);
throw e;
}
}
@ -1416,12 +1483,18 @@ export default class API {
}
}
static async matchEpg() {
static async matchEpg(channelIds = null) {
try {
const requestBody = channelIds ? { channel_ids: channelIds } : {};
const response = await request(
`${host}/api/channels/channels/match-epg/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
}
);
@ -1431,6 +1504,26 @@ export default class API {
}
}
static async matchChannelEpg(channelId) {
try {
const response = await request(
`${host}/api/channels/channels/${channelId}/match-epg/`,
{
method: 'POST',
}
);
// Update the channel in the store with the refreshed data if provided
if (response.channel) {
useChannelsStore.getState().updateChannel(response.channel);
}
return response;
} catch (e) {
errorNotification('Failed to run EPG auto-match for channel', e);
}
}
static async fetchActiveChannelStats() {
try {
const response = await request(`${host}/proxy/ts/status`);
@ -2023,15 +2116,28 @@ export default class API {
static async getStreamsByIds(ids) {
try {
const params = new URLSearchParams();
params.append('ids', ids.join(','));
const response = await request(
`${host}/api/channels/streams/?${params.toString()}`
);
return response.results || response;
// Use POST for large ID lists to avoid URL length limitations
if (ids.length > 50) {
const response = await request(
`${host}/api/channels/streams/by-ids/`,
{
method: 'POST',
body: { ids },
}
);
return response;
} else {
// Use GET for small ID lists for backward compatibility
const params = new URLSearchParams();
params.append('ids', ids.join(','));
const response = await request(
`${host}/api/channels/streams/?${params.toString()}`
);
return response.results || response;
}
} catch (e) {
errorNotification('Failed to retrieve streams by IDs', e);
throw e; // Re-throw to allow proper error handling in calling code
}
}
@ -2043,7 +2149,14 @@ export default class API {
);
return response;
} catch (e) {
errorNotification('Failed to retrieve movies', e);
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve movies', e);
}
throw e;
}
}
@ -2054,7 +2167,39 @@ export default class API {
);
return response;
} catch (e) {
errorNotification('Failed to retrieve series', e);
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve series', e);
}
throw e;
}
}
static async getAllContent(params = new URLSearchParams()) {
try {
console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`);
const response = await request(
`${host}/api/vod/all/?${params.toString()}`
);
console.log('getAllContent raw response:', response);
return response;
} catch (e) {
console.error('getAllContent error:', e);
console.error('Error status:', e.status);
console.error('Error body:', e.body);
console.error('Error message:', e.message);
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve content', e);
}
throw e;
}
}

View file

@ -9,6 +9,7 @@ import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
import logo from '../../images/logo.png';
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
import useLogosStore from '../../store/logos';
import LazyLogo from '../LazyLogo';
import {
Box,
@ -34,7 +35,7 @@ import {
UnstyledButton,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import { ListOrdered, SquarePlus, SquareX, X, Zap } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { Dropzone } from '@mantine/dropzone';
import { FixedSizeList as List } from 'react-window';
@ -51,11 +52,14 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
const {
logos,
logos: channelLogos,
ensureLogosLoaded,
isLoading: logosLoading,
} = useChannelLogoSelection();
// Import the full logos store for duplicate checking
const allLogos = useLogosStore((s) => s.logos);
// Ensure logos are loaded when component mounts
useEffect(() => {
ensureLogosLoaded();
@ -78,6 +82,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
const [groupFilter, setGroupFilter] = useState('');
const [autoMatchLoading, setAutoMatchLoading] = useState(false);
const groupOptions = Object.values(channelGroups);
const addStream = (stream) => {
@ -121,6 +126,163 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
}
};
const handleAutoMatchEpg = async () => {
// Only attempt auto-match for existing channels (editing mode)
if (!channel || !channel.id) {
notifications.show({
title: 'Info',
message: 'Auto-match is only available when editing existing channels.',
color: 'blue',
});
return;
}
setAutoMatchLoading(true);
try {
const response = await API.matchChannelEpg(channel.id);
if (response.matched) {
// Update the form with the new EPG data
if (response.channel && response.channel.epg_data_id) {
formik.setFieldValue('epg_data_id', response.channel.epg_data_id);
}
notifications.show({
title: 'Success',
message: response.message,
color: 'green',
});
} else {
notifications.show({
title: 'No Match Found',
message: response.message,
color: 'orange',
});
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to auto-match EPG data',
color: 'red',
});
console.error('Auto-match error:', error);
} finally {
setAutoMatchLoading(false);
}
};
const handleSetNameFromEpg = () => {
const epgDataId = formik.values.epg_data_id;
if (!epgDataId) {
notifications.show({
title: 'No EPG Selected',
message: 'Please select an EPG source first.',
color: 'orange',
});
return;
}
const tvg = tvgsById[epgDataId];
if (tvg && tvg.name) {
formik.setFieldValue('name', tvg.name);
notifications.show({
title: 'Success',
message: `Channel name set to "${tvg.name}"`,
color: 'green',
});
} else {
notifications.show({
title: 'No Name Available',
message: 'No name found in the selected EPG data.',
color: 'orange',
});
}
};
const handleSetLogoFromEpg = async () => {
const epgDataId = formik.values.epg_data_id;
if (!epgDataId) {
notifications.show({
title: 'No EPG Selected',
message: 'Please select an EPG source first.',
color: 'orange',
});
return;
}
const tvg = tvgsById[epgDataId];
if (!tvg || !tvg.icon_url) {
notifications.show({
title: 'No EPG Icon',
message: 'EPG data does not have an icon URL.',
color: 'orange',
});
return;
}
try {
// Try to find a logo that matches the EPG icon URL - check ALL logos to avoid duplicates
let matchingLogo = Object.values(allLogos).find(
(logo) => logo.url === tvg.icon_url
);
if (matchingLogo) {
formik.setFieldValue('logo_id', matchingLogo.id);
notifications.show({
title: 'Success',
message: `Logo set to "${matchingLogo.name}"`,
color: 'green',
});
} else {
// Logo doesn't exist - create it
notifications.show({
id: 'creating-logo',
title: 'Creating Logo',
message: `Creating new logo from EPG icon URL...`,
loading: true,
});
try {
const newLogoData = {
name: tvg.name || `Logo for ${tvg.icon_url}`,
url: tvg.icon_url,
};
// Create logo by calling the Logo API directly
const newLogo = await API.createLogo(newLogoData);
formik.setFieldValue('logo_id', newLogo.id);
notifications.update({
id: 'creating-logo',
title: 'Success',
message: `Created and assigned new logo "${newLogo.name}"`,
loading: false,
color: 'green',
autoClose: 5000,
});
} catch (createError) {
notifications.update({
id: 'creating-logo',
title: 'Error',
message: 'Failed to create logo from EPG icon URL',
loading: false,
color: 'red',
autoClose: 5000,
});
throw createError;
}
}
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to set logo from EPG data',
color: 'red',
});
console.error('Set logo from EPG error:', error);
}
};
const formik = useFormik({
initialValues: {
name: '',
@ -242,14 +404,17 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
formik.resetForm();
setTvgFilter('');
setLogoFilter('');
setChannelStreams([]); // Ensure streams are cleared when adding a new channel
}
}, [channel, tvgsById, channelGroups]);
// Memoize logo options to prevent infinite re-renders during background loading
const logoOptions = useMemo(() => {
const options = [{ id: '0', name: 'Default' }].concat(Object.values(logos));
const options = [{ id: '0', name: 'Default' }].concat(
Object.values(channelLogos)
);
return options;
}, [logos]); // Only depend on logos object
}, [channelLogos]); // Only depend on channelLogos object
// Update the handler for when channel group modal is closed
const handleChannelGroupModalClose = (newGroup) => {
@ -305,11 +470,28 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
<TextInput
id="name"
name="name"
label="Channel Name"
label={
<Group gap="xs">
<span>Channel Name</span>
{formik.values.epg_data_id && (
<Button
size="xs"
variant="transparent"
onClick={handleSetNameFromEpg}
title="Set channel name from EPG data"
p={0}
h="auto"
>
Use EPG Name
</Button>
)}
</Group>
}
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name ? formik.touched.name : ''}
size="xs"
style={{ flex: 1 }}
/>
<Flex gap="sm">
@ -491,9 +673,27 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
<TextInput
id="logo_id"
name="logo_id"
label="Logo"
label={
<Group gap="xs">
<span>Logo</span>
{formik.values.epg_data_id && (
<Button
size="xs"
variant="transparent"
onClick={handleSetLogoFromEpg}
title="Find matching logo based on EPG icon URL"
p={0}
h="auto"
>
Use EPG Logo
</Button>
)}
</Group>
}
readOnly
value={logos[formik.values.logo_id]?.name || 'Default'}
value={
channelLogos[formik.values.logo_id]?.name || 'Default'
}
onClick={() => {
console.log(
'Logo input clicked, setting popover opened to true'
@ -600,11 +800,13 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
</Popover.Dropdown>
</Popover>
<LazyLogo
logoId={formik.values.logo_id}
alt="channel logo"
style={{ height: 40 }}
/>
<Stack gap="xs" align="center">
<LazyLogo
logoId={formik.values.logo_id}
alt="channel logo"
style={{ height: 40 }}
/>
</Stack>
</Group>
<Group>
@ -706,6 +908,25 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
>
Use Dummy
</Button>
<Button
size="xs"
variant="transparent"
color="blue"
onClick={(e) => {
e.stopPropagation();
handleAutoMatchEpg();
}}
disabled={!channel || !channel.id}
loading={autoMatchLoading}
title={
!channel || !channel.id
? 'Auto-match is only available for existing channels'
: 'Automatically match EPG data'
}
leftSection={<Zap size="14" />}
>
Auto Match
</Button>
</Group>
}
readOnly
@ -717,8 +938,6 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
return `${epgSource.name} - ${tvgLabel}`;
} else if (tvgLabel) {
return tvgLabel;
} else if (formik.values.name) {
return formik.values.name;
} else {
return 'Dummy';
}
@ -768,6 +987,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
}
mb="xs"
size="xs"
autoFocus
/>
</Group>

View file

@ -27,6 +27,7 @@ import {
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import { FixedSizeList as List } from 'react-window';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
@ -35,7 +36,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
const groupListRef = useRef(null);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
@ -134,6 +134,74 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
}
};
const handleSetNamesFromEpg = async () => {
if (!channelIds || channelIds.length === 0) {
notifications.show({
title: 'No Channels Selected',
message: 'No channels to update.',
color: 'orange',
});
return;
}
try {
// Start the backend task
await API.setChannelNamesFromEpg(channelIds);
// The task will send WebSocket updates for progress
// Just show that it started successfully
notifications.show({
title: 'Task Started',
message: `Started setting names from EPG for ${channelIds.length} channels. Progress will be shown in notifications.`,
color: 'blue',
});
// Close the modal since the task is now running in background
onClose();
} catch (error) {
console.error('Failed to start EPG name setting task:', error);
notifications.show({
title: 'Error',
message: 'Failed to start EPG name setting task.',
color: 'red',
});
}
};
const handleSetLogosFromEpg = async () => {
if (!channelIds || channelIds.length === 0) {
notifications.show({
title: 'No Channels Selected',
message: 'No channels to update.',
color: 'orange',
});
return;
}
try {
// Start the backend task
await API.setChannelLogosFromEpg(channelIds);
// The task will send WebSocket updates for progress
// Just show that it started successfully
notifications.show({
title: 'Task Started',
message: `Started setting logos from EPG for ${channelIds.length} channels. Progress will be shown in notifications.`,
color: 'blue',
});
// Close the modal since the task is now running in background
onClose();
} catch (error) {
console.error('Failed to start EPG logo setting task:', error);
notifications.show({
title: 'Error',
message: 'Failed to start EPG logo setting task.',
color: 'red',
});
}
};
// useEffect(() => {
// // const sameStreamProfile = channels.every(
// // (channel) => channel.stream_profile_id == channels[0].stream_profile_id
@ -183,7 +251,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
<Modal
opened={isOpen}
onClose={onClose}
size={"lg"}
size={'lg'}
title={
<Group gap="5">
<ListOrdered size="20" />
@ -197,7 +265,9 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
<Stack gap="5" style={{ flex: 1 }}>
<Paper withBorder p="xs" radius="md">
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>Channel Name</Text>
<Text size="sm" fw={600}>
Channel Name
</Text>
</Group>
<Group align="end" gap="xs" wrap="nowrap">
<TextInput
@ -224,6 +294,36 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
/>
</Paper>
<Paper withBorder p="xs" radius="md">
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>
EPG Operations
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
size="xs"
variant="light"
onClick={handleSetNamesFromEpg}
style={{ flex: 1 }}
>
Set Names from EPG
</Button>
<Button
size="xs"
variant="light"
onClick={handleSetLogosFromEpg}
style={{ flex: 1 }}
>
Set Logos from EPG
</Button>
</Group>
<Text size="xs" c="dimmed" mt="xs">
Updates channel names and logos based on their assigned EPG
data
</Text>
</Paper>
<Popover
opened={groupPopoverOpened}
onChange={setGroupPopoverOpened}
@ -403,7 +503,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
export default ChannelBatchForm;
// Lightweight inline preview component to visualize rename results for a subset
const RegexPreview = ({ channelIds, find, replace}) => {
const RegexPreview = ({ channelIds, find, replace }) => {
const channelsMap = useChannelsStore((s) => s.channels);
const previewItems = useMemo(() => {
const items = [];
@ -412,7 +512,8 @@ const RegexPreview = ({ channelIds, find, replace}) => {
let re;
try {
re = new RegExp(find, flags);
} catch (e) {
} catch (error) {
console.error('Invalid regex:', error);
return [{ before: 'Invalid regex', after: '' }];
}
for (let i = 0; i < Math.min(channelIds.length, 25); i++) {
@ -431,20 +532,41 @@ const RegexPreview = ({ channelIds, find, replace}) => {
return (
<Box mt={8}>
<Text size="xs" c="dimmed" mb={4}>
Preview (first {Math.min(channelIds.length, 25)} of {channelIds.length} selected)
Preview (first {Math.min(channelIds.length, 25)} of {channelIds.length}{' '}
selected)
</Text>
<ScrollArea h={120} offsetScrollbars>
<Stack gap={4}>
{previewItems.length === 0 ? (
<Text size="xs" c="dimmed">No changes with current pattern.</Text>
<Text size="xs" c="dimmed">
No changes with current pattern.
</Text>
) : (
previewItems.map((row, idx) => (
<Group key={idx} gap={8} wrap="nowrap" align="center">
<Text size="xs" style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<Text
size="xs"
style={{
flex: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{row.before}
</Text>
<Text size="xs" c="gray.6"></Text>
<Text size="xs" style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<Text size="xs" c="gray.6">
</Text>
<Text
size="xs"
style={{
flex: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{row.after}
</Text>
</Group>

View file

@ -306,6 +306,12 @@ const ChannelsTable = ({}) => {
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [channelToDelete, setChannelToDelete] = useState(null);
// Column sizing state for resizable columns
const [columnSizing, setColumnSizing] = useLocalStorage(
'channels-table-column-sizing',
{}
);
// M3U and EPG URL configuration state
const [m3uParams, setM3uParams] = useState({
cachedlogos: true,
@ -427,7 +433,13 @@ const ChannelsTable = ({}) => {
}));
};
const editChannel = async (ch = null) => {
const editChannel = async (ch = null, opts = {}) => {
// If forceAdd is set, always open a blank form
if (opts.forceAdd) {
setChannel(null);
setChannelModalOpen(true);
return;
}
// Use table's selected state instead of store state to avoid stale selections
const currentSelection = table ? table.selectedTableIds : [];
console.log('editChannel called with:', {
@ -697,14 +709,17 @@ const ChannelsTable = ({}) => {
{
id: 'expand',
size: 20,
enableResizing: false,
},
{
id: 'select',
size: 30,
enableResizing: false,
},
{
id: 'enabled',
size: 45,
enableResizing: false,
cell: ({ row, table }) => {
return (
<ChannelEnabledSwitch
@ -718,7 +733,9 @@ const ChannelsTable = ({}) => {
{
id: 'channel_number',
accessorKey: 'channel_number',
size: 40,
size: columnSizing.channel_number || 40,
minSize: 30,
maxSize: 100,
cell: ({ getValue }) => {
const value = getValue();
// Format as integer if no decimal component
@ -739,6 +756,9 @@ const ChannelsTable = ({}) => {
{
id: 'name',
accessorKey: 'name',
size: columnSizing.name || 200,
minSize: 100,
grow: true,
cell: ({ getValue }) => (
<Box
style={{
@ -758,14 +778,15 @@ const ChannelsTable = ({}) => {
cell: ({ getValue }) => {
const epgDataId = getValue();
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
const tvgName = epgObj?.name;
const tvgId = epgObj?.tvg_id;
const epgName =
epgObj && epgObj.epg_source
? epgs[epgObj.epg_source]?.name || epgObj.epg_source
: null;
const epgDataName = epgObj?.name;
const tvgId = epgObj?.tvg_id;
const tooltip = epgObj
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgDataName ? `TVG Name: ${epgDataName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
: '';
return (
<Box
@ -783,17 +804,20 @@ const ChannelsTable = ({}) => {
withArrow
position="top"
>
<span>{epgName}</span>
<span>
{epgObj.epg_source} - {tvgId}
</span>
</Tooltip>
) : epgObj ? (
<span>{epgObj.name}</span>
) : (
<span style={{ color: '#888' }}>Not linked</span>
<span style={{ color: '#888' }}>Not Assigned</span>
)}
</Box>
);
},
size: 120,
size: columnSizing.epg || 200,
minSize: 80,
},
{
id: 'channel_group',
@ -812,7 +836,8 @@ const ChannelsTable = ({}) => {
{getValue()}
</Box>
),
size: 175,
size: columnSizing.channel_group || 175,
minSize: 100,
},
{
id: 'logo',
@ -821,6 +846,9 @@ const ChannelsTable = ({}) => {
return row.logo_id;
},
size: 75,
minSize: 50,
maxSize: 120,
enableResizing: false,
header: '',
cell: ({ getValue }) => {
const logoId = getValue();
@ -839,6 +867,7 @@ const ChannelsTable = ({}) => {
{
id: 'actions',
size: tableSize == 'compact' ? 75 : 100,
enableResizing: false,
header: '',
cell: ({ row }) => (
<ChannelRowActions
@ -853,7 +882,7 @@ const ChannelsTable = ({}) => {
),
},
],
[selectedProfileId, channelGroups, logos, theme]
[selectedProfileId, channelGroups, logos, theme, columnSizing]
);
const renderHeaderCell = (header) => {
@ -873,6 +902,7 @@ const ChannelsTable = ({}) => {
placeholder="EPG"
variant="unstyled"
data={epgOptions}
className="table-input-header"
size="xs"
searchable
clearable
@ -927,6 +957,7 @@ const ChannelsTable = ({}) => {
return (
<MultiSelect
placeholder="Group"
className="table-input-header"
variant="unstyled"
data={groupOptions}
size="xs"
@ -953,6 +984,12 @@ const ChannelsTable = ({}) => {
manualFiltering: true,
enableRowSelection: true,
onRowSelectionChange: onRowSelectionChange,
state: {
columnSizing,
pagination,
sorting,
},
onColumnSizingChange: setColumnSizing,
getExpandedRowHeight: (row) => {
return 20 + 28 * row.original.streams.length;
},
@ -1301,7 +1338,7 @@ const ChannelsTable = ({}) => {
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}

View file

@ -143,11 +143,18 @@ const ChannelTableHeader = ({
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
await API.matchEpg();
notifications.show({
title: 'EPG matching task started!',
});
// If channels are selected, only match those; otherwise match all
if (selectedTableIds.length > 0) {
await API.matchEpg(selectedTableIds);
notifications.show({
title: `EPG matching task started for ${selectedTableIds.length} selected channel(s)!`,
});
} else {
await API.matchEpg();
notifications.show({
title: 'EPG matching task started for all channels without EPG!',
});
}
} catch (err) {
notifications.show(`Error: ${err.message}`);
}
@ -259,7 +266,7 @@ const ChannelTableHeader = ({
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
onClick={() => editChannel(null, { forceAdd: true })}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
p={5}
color={theme.tailwind.green[5]}
@ -298,7 +305,11 @@ const ChannelTableHeader = ({
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={matchEpg}
>
<Text size="xs">Auto-Match</Text>
<Text size="xs">
{selectedTableIds.length > 0
? `Auto-Match (${selectedTableIds.length} selected)`
: 'Auto-Match EPG'}
</Text>
</Menu.Item>
<Menu.Item

View file

@ -1,6 +1,6 @@
import { Box, Flex } from '@mantine/core';
import CustomTableHeader from './CustomTableHeader';
import { useCallback, useState, useRef } from 'react';
import { useCallback, useState, useRef, useMemo } from 'react';
import { flexRender } from '@tanstack/react-table';
import table from '../../../helpers/table';
import CustomTableBody from './CustomTableBody';
@ -9,13 +9,32 @@ import useLocalStorage from '../../../hooks/useLocalStorage';
const CustomTable = ({ table }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
// Get column sizing state for dependency tracking
const columnSizing = table.getState().columnSizing;
// Calculate minimum table width reactively based on column sizes
const minTableWidth = useMemo(() => {
const headerGroups = table.getHeaderGroups();
if (!headerGroups || headerGroups.length === 0) return 0;
const width =
headerGroups[0]?.headers.reduce((total, header) => {
return total + header.getSize();
}, 0) || 0;
return width;
}, [table, columnSizing]);
return (
<Box
className={`divTable table-striped table-size-${tableSize}`}
style={{
width: '100%',
maxWidth: '100%',
minWidth: `${minTableWidth}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<CustomTableHeader

View file

@ -1,6 +1,7 @@
import { Box, Flex } from '@mantine/core';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useMemo } from 'react';
import table from '../../../helpers/table';
const CustomTableBody = ({
@ -23,6 +24,19 @@ const CustomTableBody = ({
const rows = getRowModel().rows;
// Calculate minimum width based only on fixed-size columns
const minTableWidth = useMemo(() => {
if (rows.length === 0) return 0;
return rows[0].getVisibleCells().reduce((total, cell) => {
// Only count columns with fixed sizes, flexible columns will expand
const columnSize = cell.column.columnDef.size
? cell.column.getSize()
: cell.column.columnDef.minSize || 150; // Default min for flexible columns
return total + columnSize;
}, 0);
}, [rows]);
const renderTableBodyContents = () => {
const virtualized = false;
@ -94,6 +108,7 @@ const CustomTableBody = ({
style={{
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
@ -101,16 +116,24 @@ const CustomTableBody = ({
}}
>
{row.getVisibleCells().map((cell) => {
const hasFixedSize = cell.column.columnDef.size;
const isFlexible = !hasFixedSize;
return (
<Box
className="td"
key={`td-${cell.id}`}
style={{
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
...(cell.column.columnDef.grow
? {
flex: '1 1 0%',
minWidth: 0,
}
: {
flex: `0 0 ${cell.column.getSize ? cell.column.getSize() : 150}px`,
width: `${cell.column.getSize ? cell.column.getSize() : 150}px`,
maxWidth: `${cell.column.getSize ? cell.column.getSize() : 150}px`,
}),
...(tableCellProps && tableCellProps({ cell })),
}}
>

View file

@ -1,6 +1,6 @@
import { Box, Center, Checkbox, Flex } from '@mantine/core';
import { flexRender } from '@tanstack/react-table';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
const CustomTableHeader = ({
getHeaderGroups,
@ -40,6 +40,25 @@ const CustomTableHeader = ({
}
};
// Get header groups for dependency tracking
const headerGroups = getHeaderGroups();
// Calculate minimum width based only on fixed-size columns
const minTableWidth = useMemo(() => {
if (!headerGroups || headerGroups.length === 0) return 0;
const width =
headerGroups[0]?.headers.reduce((total, header) => {
// Only count columns with fixed sizes, flexible columns will expand
const columnSize = header.column.columnDef.size
? header.getSize()
: header.column.columnDef.minSize || 150; // Default min for flexible columns
return total + columnSize;
}, 0) || 0;
return width;
}, [headerGroups]);
return (
<Box
className="thead"
@ -54,7 +73,11 @@ const CustomTableHeader = ({
<Box
className="tr"
key={headerGroup.id}
style={{ display: 'flex', width: '100%' }}
style={{
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
}}
>
{headerGroup.headers.map((header) => {
return (
@ -62,11 +85,17 @@ const CustomTableHeader = ({
className="th"
key={header.id}
style={{
flex: header.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: header.column.columnDef.size
? header.getSize()
: undefined,
minWidth: 0,
...(header.column.columnDef.grow
? {
flex: '1 1 0%',
minWidth: 0,
}
: {
flex: `0 0 ${header.getSize ? header.getSize() : 150}px`,
width: `${header.getSize ? header.getSize() : 150}px`,
maxWidth: `${header.getSize ? header.getSize() : 150}px`,
}),
position: 'relative',
// ...(tableCellProps && tableCellProps({ cell: header })),
}}
>
@ -76,10 +105,46 @@ const CustomTableHeader = ({
...(header.column.columnDef.style &&
header.column.columnDef.style),
height: '100%',
paddingRight: header.column.getCanResize() ? '8px' : '0px', // Add padding for resize handle
}}
>
{renderHeaderCell(header)}
</Flex>
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer ${
header.column.getIsResizing() ? 'isResizing' : ''
}`}
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: '8px', // Make it slightly wider
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none',
backgroundColor: header.column.getIsResizing()
? '#3b82f6'
: 'transparent',
opacity: header.column.getIsResizing() ? 1 : 0.3, // Make it more visible by default
transition: 'opacity 0.2s',
zIndex: 1000, // Ensure it's on top
}}
onMouseEnter={(e) => {
e.target.style.opacity = '1';
e.target.style.backgroundColor = '#6b7280';
}}
onMouseLeave={(e) => {
if (!header.column.getIsResizing()) {
e.target.style.opacity = '0.5';
e.target.style.backgroundColor = 'transparent';
}
}}
/>
)}
</Box>
);
})}

View file

@ -18,6 +18,8 @@ const useTable = ({
onRowSelectionChange = null,
getExpandedRowHeight = null,
state = [],
columnSizing,
setColumnSizing,
...options
}) => {
const [selectedTableIds, setSelectedTableIds] = useState([]);
@ -85,15 +87,21 @@ const useTable = ({
const table = useReactTable({
defaultColumn: {
size: undefined,
minSize: 0,
maxSize: Number.MAX_SAFE_INTEGER,
size: 150,
},
...options,
state: {
data: options.data,
...options.state,
selectedTableIds,
...(columnSizing && { columnSizing }),
},
onStateChange: options.onStateChange,
...(setColumnSizing && { onColumnSizingChange: setColumnSizing }),
getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(),
enableColumnResizing: true,
columnResizeMode: 'onChange',
});
const selectedTableIdsSet = useMemo(

View file

@ -187,14 +187,15 @@ const EPGsTable = () => {
size: 200,
},
{
header: 'Source Type',
header: 'Type',
accessorKey: 'source_type',
size: 150,
size: 100,
},
{
header: 'URL / API Key / File Path',
accessorKey: 'url',
enableSorting: false,
minSize: 250,
cell: ({ cell, row }) => {
const value =
cell.getValue() ||
@ -220,7 +221,7 @@ const EPGsTable = () => {
{
header: 'Status',
accessorKey: 'status',
size: 150,
size: 100,
cell: ({ row }) => {
const data = row.original;
@ -236,6 +237,8 @@ const EPGsTable = () => {
header: 'Status Message',
accessorKey: 'last_message',
enableSorting: false,
minSize: 250,
grow: true,
cell: ({ row }) => {
const data = row.original;

View file

@ -394,7 +394,7 @@ const LogosTable = () => {
{
header: 'Name',
accessorKey: 'name',
size: 200,
size: 250,
cell: ({ getValue }) => (
<Text fw={500} size="sm">
{getValue()}
@ -479,6 +479,7 @@ const LogosTable = () => {
{
header: 'URL',
accessorKey: 'url',
grow: true,
cell: ({ getValue }) => (
<Group gap={4} style={{ alignItems: 'center' }}>
<Box

View file

@ -431,10 +431,10 @@ const M3UTable = () => {
sortable: true,
},
{
header: 'Account Type',
header: 'Type',
accessorKey: 'account_type',
sortable: true,
size: 150,
size: 100,
cell: ({ cell }) => {
const value = cell.getValue();
return value === 'XC' ? 'XC' : 'M3U';
@ -443,6 +443,7 @@ const M3UTable = () => {
{
header: 'URL / File',
accessorKey: 'server_url',
size: 250,
cell: ({ cell, row }) => {
const value = cell.getValue() || row.original.file_path || '';
return (
@ -461,16 +462,10 @@ const M3UTable = () => {
);
},
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
sortable: true,
size: 150,
},
{
header: 'Status',
accessorKey: 'status',
size: 150,
size: 100,
cell: ({ cell }) => {
const value = cell.getValue();
if (!value) return null;
@ -486,6 +481,8 @@ const M3UTable = () => {
{
header: 'Status Message',
accessorKey: 'last_message',
grow: true,
minSize: 250,
cell: ({ cell, row }) => {
const value = cell.getValue();
const data = row.original;
@ -569,6 +566,12 @@ const M3UTable = () => {
);
},
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
sortable: true,
size: 125,
},
{
header: 'Updated',
accessorKey: 'updated_at',

View file

@ -72,7 +72,7 @@ const StreamProfiles = () => {
{
header: 'Name',
accessorKey: 'name',
size: 150,
size: 175,
cell: ({ cell }) => (
<div
style={{
@ -88,7 +88,7 @@ const StreamProfiles = () => {
{
header: 'Command',
accessorKey: 'command',
size: 150,
size: 100,
cell: ({ cell }) => (
<div
style={{
@ -104,7 +104,7 @@ const StreamProfiles = () => {
{
header: 'Parameters',
accessorKey: 'parameters',
// size: 200,
grow: true,
cell: ({ cell }) => (
<Tooltip label={cell.getValue()}>
<div

View file

@ -39,11 +39,16 @@ import {
UnstyledButton,
LoadingOverlay,
Skeleton,
Modal,
NumberInput,
Radio,
Checkbox,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
import useChannelsTableStore from '../../store/channelsTable';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
@ -54,6 +59,7 @@ const StreamRowActions = ({
deleteStream,
handleWatchStream,
selectedChannelIds,
createChannelFromStream,
}) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const channelSelectionStreams = useChannelsTableStore(
@ -62,23 +68,6 @@ const StreamRowActions = ({
);
const fetchLogos = useLogosStore((s) => s.fetchLogos);
const createChannelFromStream = async () => {
const selectedChannelProfileId =
useChannelsStore.getState().selectedProfileId;
await API.createChannelFromStream({
name: row.original.name,
channel_number: null,
stream_id: row.original.id,
// Only pass channel_profile_ids if a specific profile is selected (not "All")
...(selectedChannelProfileId !== '0' && {
channel_profile_ids: selectedChannelProfileId,
}),
});
await API.requeryChannels();
fetchLogos();
};
const addStreamToChannel = async () => {
await API.updateChannel({
id: selectedChannelIds[0],
@ -140,7 +129,7 @@ const StreamRowActions = ({
size={iconSize}
color={theme.tailwind.green[5]}
variant="transparent"
onClick={createChannelFromStream}
onClick={() => createChannelFromStream(row.original)}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
@ -178,7 +167,7 @@ const StreamRowActions = ({
);
};
const StreamsTable = ({}) => {
const StreamsTable = () => {
const theme = useMantineTheme();
/**
@ -196,6 +185,21 @@ const StreamsTable = ({}) => {
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
// Channel numbering modal state
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
useState(false);
const [numberingMode, setNumberingMode] = useState('provider'); // 'provider', 'auto', or 'custom'
const [customStartNumber, setCustomStartNumber] = useState(1);
const [rememberChoice, setRememberChoice] = useState(false);
// Single channel numbering modal state
const [singleChannelModalOpen, setSingleChannelModalOpen] = useState(false);
const [singleChannelMode, setSingleChannelMode] = useState('provider'); // 'provider', 'auto', or 'specific'
const [specificChannelNumber, setSpecificChannelNumber] = useState(1);
const [rememberSingleChoice, setRememberSingleChoice] = useState(false);
const [currentStreamForChannel, setCurrentStreamForChannel] = useState(null);
// const [allRowsSelected, setAllRowsSelected] = useState(false);
// Add local storage for page size
@ -212,6 +216,10 @@ const StreamsTable = ({}) => {
channel_group: '',
m3u_account: '',
});
const [columnSizing, setColumnSizing] = useLocalStorage(
'streams-table-column-sizing',
{}
);
const debouncedFilters = useDebounce(filters, 500, () => {
// Reset to first page whenever filters change to avoid "Invalid page" errors
setPagination((prev) => ({
@ -244,6 +252,10 @@ const StreamsTable = ({}) => {
const showVideo = useVideoStore((s) => s.showVideo);
const [tableSize, _] = useLocalStorage('table-size', 'default');
// Warnings store for "remember choice" functionality
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const handleSelectClick = (e) => {
e.stopPropagation();
e.preventDefault();
@ -256,15 +268,17 @@ const StreamsTable = ({}) => {
() => [
{
id: 'actions',
size: tableSize == 'compact' ? 60 : 80,
size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80),
},
{
id: 'select',
size: 30,
size: columnSizing.select || 30,
},
{
header: 'Name',
accessorKey: 'name',
grow: true,
size: columnSizing.name || 200,
cell: ({ getValue }) => (
<Box
style={{
@ -283,6 +297,7 @@ const StreamsTable = ({}) => {
channelGroups[row.channel_group]
? channelGroups[row.channel_group].name
: '',
size: columnSizing.group || 150,
cell: ({ getValue }) => (
<Box
style={{
@ -297,7 +312,7 @@ const StreamsTable = ({}) => {
},
{
id: 'm3u',
size: 150,
size: columnSizing.m3u || 150,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
cell: ({ getValue }) => (
@ -315,7 +330,7 @@ const StreamsTable = ({}) => {
),
},
],
[channelGroups, playlists]
[channelGroups, playlists, columnSizing, tableSize]
);
/**
@ -411,50 +426,84 @@ const StreamsTable = ({}) => {
fetchChannelGroups,
]);
// Bulk creation: create channels from selected streams in one API call
// Bulk creation: create channels from selected streams asynchronously
const createChannelsFromStreams = async () => {
setIsLoading(true);
if (selectedStreamIds.length === 0) return;
// Check if user has suppressed the channel numbering dialog
const actionKey = 'channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
// Use the remembered settings or default to 'provider' mode
const savedMode =
localStorage.getItem('channel-numbering-mode') || 'provider';
const savedStartNumber =
localStorage.getItem('channel-numbering-start') || '1';
const startingChannelNumberValue =
savedMode === 'provider'
? null
: savedMode === 'auto'
? 0
: Number(savedStartNumber);
await executeChannelCreation(startingChannelNumberValue);
} else {
// Show the modal to let user choose
setChannelNumberingModalOpen(true);
}
};
// Separate function to actually execute the channel creation
const executeChannelCreation = async (startingChannelNumberValue) => {
try {
const selectedChannelProfileId =
useChannelsStore.getState().selectedProfileId;
// Try to fetch the actual stream data for selected streams
let streamsData = [];
try {
streamsData = await API.getStreamsByIds(selectedStreamIds);
} catch (error) {
console.warn('Could not fetch stream details, using IDs only:', error);
}
// Use the async API for all bulk operations
const response = await API.createChannelsFromStreamsAsync(
selectedStreamIds,
selectedChannelProfileId !== '0' ? [selectedChannelProfileId] : null,
startingChannelNumberValue
);
const streamData = selectedStreamIds.map((streamId) => {
const stream = streamsData.find((s) => s.id === streamId);
return {
stream_id: streamId,
name: stream?.name || `Stream ${streamId}`,
...(selectedChannelProfileId !== '0' && {
channel_profile_ids: selectedChannelProfileId,
}),
};
});
console.log(
`Bulk creation task started: ${response.task_id} for ${response.stream_count} streams`
);
await API.createChannelsFromStreams(streamData);
await API.requeryChannels();
// Refresh channel profiles to update the membership information
await useChannelsStore.getState().fetchChannelProfiles();
fetchLogos();
// Clear selection and refresh data
// Clear selection since the task has started
setSelectedStreamIds([]);
await fetchData();
} catch (error) {
console.error('Error creating channels from streams:', error);
} finally {
setIsLoading(false);
console.error('Error starting bulk channel creation:', error);
// Error notifications will be handled by WebSocket
}
};
// Handle confirming the channel numbering modal
const handleChannelNumberingConfirm = async () => {
// Save the choice if user wants to remember it
if (rememberChoice) {
suppressWarning('channel-numbering-choice');
localStorage.setItem('channel-numbering-mode', numberingMode);
if (numberingMode === 'custom') {
localStorage.setItem(
'channel-numbering-start',
customStartNumber.toString()
);
}
}
// Convert mode to API value
const startingChannelNumberValue =
numberingMode === 'provider'
? null
: numberingMode === 'auto'
? 0
: Number(customStartNumber);
setChannelNumberingModalOpen(false);
await executeChannelCreation(startingChannelNumberValue);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
@ -476,6 +525,80 @@ const StreamsTable = ({}) => {
fetchData();
};
// Single channel creation functions
const createChannelFromStream = async (stream) => {
// Check if user has suppressed the single channel numbering dialog
const actionKey = 'single-channel-numbering-choice';
if (isWarningSuppressed(actionKey)) {
// Use the remembered settings or default to 'provider' mode
const savedMode =
localStorage.getItem('single-channel-numbering-mode') || 'provider';
const savedChannelNumber =
localStorage.getItem('single-channel-numbering-specific') || '1';
const channelNumberValue =
savedMode === 'provider'
? null
: savedMode === 'auto'
? 0
: Number(savedChannelNumber);
await executeSingleChannelCreation(stream, channelNumberValue);
} else {
// Show the modal to let user choose
setCurrentStreamForChannel(stream);
setSingleChannelModalOpen(true);
}
};
// Separate function to actually execute single channel creation
const executeSingleChannelCreation = async (stream, channelNumber = null) => {
const selectedChannelProfileId =
useChannelsStore.getState().selectedProfileId;
await API.createChannelFromStream({
name: stream.name,
channel_number: channelNumber,
stream_id: stream.id,
// Only pass channel_profile_ids if a specific profile is selected (not "All")
...(selectedChannelProfileId !== '0' && {
channel_profile_ids: selectedChannelProfileId,
}),
});
await API.requeryChannels();
const fetchLogos = useChannelsStore.getState().fetchLogos;
fetchLogos();
};
// Handle confirming the single channel numbering modal
const handleSingleChannelNumberingConfirm = async () => {
// Save the choice if user wants to remember it
if (rememberSingleChoice) {
suppressWarning('single-channel-numbering-choice');
localStorage.setItem('single-channel-numbering-mode', singleChannelMode);
if (singleChannelMode === 'specific') {
localStorage.setItem(
'single-channel-numbering-specific',
specificChannelNumber.toString()
);
}
}
// Convert mode to API value
const channelNumberValue =
singleChannelMode === 'provider'
? null
: singleChannelMode === 'auto'
? 0
: Number(specificChannelNumber);
setSingleChannelModalOpen(false);
await executeSingleChannelCreation(
currentStreamForChannel,
channelNumberValue
);
};
const addStreamsToChannel = async () => {
await API.updateChannel({
id: selectedChannelIds[0],
@ -631,6 +754,7 @@ const StreamsTable = ({}) => {
deleteStream={deleteStream}
handleWatchStream={handleWatchStream}
selectedChannelIds={selectedChannelIds}
createChannelFromStream={createChannelFromStream}
/>
);
}
@ -652,6 +776,8 @@ const StreamsTable = ({}) => {
filters,
pagination,
sorting,
columnSizing,
setColumnSizing,
onRowSelectionChange: onRowSelectionChange,
manualPagination: true,
manualSorting: true,
@ -768,7 +894,7 @@ const StreamsTable = ({}) => {
p={5}
disabled={selectedStreamIds.length == 0}
>
Create Channels
{`Create Channels (${selectedStreamIds.length})`}
</Button>
<Button
@ -855,7 +981,7 @@ const StreamsTable = ({}) => {
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
@ -907,6 +1033,148 @@ const StreamsTable = ({}) => {
isOpen={modalOpen}
onClose={closeStreamForm}
/>
{/* Channel Numbering Modal */}
<Modal
opened={channelNumberingModalOpen}
onClose={() => setChannelNumberingModalOpen(false)}
title="Channel Numbering Options"
size="md"
centered
>
<Stack spacing="md">
<Text size="sm" c="dimmed">
Choose how to assign channel numbers to the{' '}
{selectedStreamIds.length} selected streams:
</Text>
<Radio.Group
value={numberingMode}
onChange={setNumberingMode}
label="Numbering Mode"
>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
label="Use Provider Numbers"
description="Use tvg-chno or channel-number from stream metadata, auto-assign for conflicts"
/>
<Radio
value="auto"
label="Auto-Assign Sequential"
description="Start from the lowest available channel number and increment by 1"
/>
<Radio
value="custom"
label="Start from Custom Number"
description="Start sequential numbering from a specific channel number"
/>
</Stack>
</Radio.Group>
{numberingMode === 'custom' && (
<NumberInput
label="Starting Channel Number"
description="Channel numbers will be assigned starting from this number"
value={customStartNumber}
onChange={setCustomStartNumber}
min={1}
max={9999}
placeholder="Enter starting number..."
/>
)}
<Checkbox
checked={rememberChoice}
onChange={(event) => setRememberChoice(event.currentTarget.checked)}
label="Remember this choice and don't ask again"
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => setChannelNumberingModalOpen(false)}
>
Cancel
</Button>
<Button onClick={handleChannelNumberingConfirm}>
Create Channels
</Button>
</Group>
</Stack>
</Modal>
{/* Single Channel Numbering Modal */}
<Modal
opened={singleChannelModalOpen}
onClose={() => setSingleChannelModalOpen(false)}
title="Channel Number Assignment"
size="md"
centered
>
<Stack spacing="md">
<Text size="sm" c="dimmed">
Choose how to assign the channel number for "
{currentStreamForChannel?.name}":
</Text>
<Radio.Group
value={singleChannelMode}
onChange={setSingleChannelMode}
label="Number Assignment"
>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
label="Use Provider Number"
description="Use tvg-chno or channel-number from stream metadata, auto-assign if not available"
/>
<Radio
value="auto"
label="Auto-Assign Next Available"
description="Automatically assign the next available channel number"
/>
<Radio
value="specific"
label="Use Specific Number"
description="Use a specific channel number"
/>
</Stack>
</Radio.Group>
{singleChannelMode === 'specific' && (
<NumberInput
label="Channel Number"
description="The specific channel number to assign"
value={specificChannelNumber}
onChange={setSpecificChannelNumber}
min={1}
max={9999}
placeholder="Enter channel number..."
/>
)}
<Checkbox
checked={rememberSingleChoice}
onChange={(event) =>
setRememberSingleChoice(event.currentTarget.checked)
}
label="Remember this choice and don't ask again"
/>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={() => setSingleChannelModalOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSingleChannelNumberingConfirm}>
Create Channel
</Button>
</Group>
</Stack>
</Modal>
</>
);
};

View file

@ -61,11 +61,13 @@ const UserAgentsTable = () => {
{
header: 'Name',
accessorKey: 'name',
size: 125,
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
enableSorting: false,
grow: true,
cell: ({ cell }) => (
<div
style={{

View file

@ -184,6 +184,7 @@ const UsersTable = () => {
{
header: 'Email',
accessorKey: 'email',
grow: true,
cell: ({ getValue }) => (
<Box
style={{

View file

@ -8,8 +8,8 @@ html {
}
.divTable {
width: 100%;
/* border: 1px solid lightgray; */
/* width: fit-content; */
/* display: flex;
flex-direction: column; */
}
@ -180,3 +180,35 @@ html {
-ms-user-select: text !important;
cursor: text !important;
}
/* Column resize handle styles */
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
cursor: col-resize;
user-select: none;
touch-action: none;
background-color: transparent;
opacity: 0;
transition: all 0.2s ease;
}
.resizer:hover {
opacity: 1 !important;
background-color: #6b7280 !important;
}
.resizer.isResizing {
opacity: 1 !important;
background-color: #3b82f6 !important;
width: 3px;
}
/* Show resize handle on header hover */
.th:hover .resizer {
opacity: 0.5;
background-color: #6b7280;
}

View file

@ -42,6 +42,7 @@ export const useLogoSelection = () => {
* (unused + channel-used, excluding VOD-only logos)
*/
export const useChannelLogoSelection = () => {
const [isInitialized, setIsInitialized] = useState(false);
const channelLogos = useLogosStore((s) => s.channelLogos);
const hasLoadedChannelLogos = useLogosStore((s) => s.hasLoadedChannelLogos);
const backgroundLoading = useLogosStore((s) => s.backgroundLoading);
@ -56,7 +57,7 @@ export const useChannelLogoSelection = () => {
return;
}
if (hasLoadedChannelLogos && hasLogos) {
if ((hasLoadedChannelLogos && hasLogos) || isInitialized) {
return;
}
@ -71,6 +72,7 @@ export const useChannelLogoSelection = () => {
hasLoadedChannelLogos,
hasLogos,
fetchChannelAssignableLogos,
isInitialized,
]);
return {

View file

@ -5,9 +5,22 @@ import { Box } from '@mantine/core';
import { Allotment } from 'allotment';
import { USER_LEVELS } from '../constants';
import useAuthStore from '../store/auth';
import useLocalStorage from '../hooks/useLocalStorage';
const ChannelsPage = () => {
const authUser = useAuthStore((s) => s.user);
const [allotmentSizes, setAllotmentSizes] = useLocalStorage(
'channels-splitter-sizes',
[50, 50]
);
const handleSplitChange = (sizes) => {
setAllotmentSizes(sizes);
};
const handleResize = (sizes) => {
setAllotmentSizes(sizes);
};
if (!authUser.id) {
return <></>;
@ -30,10 +43,12 @@ const ChannelsPage = () => {
}}
>
<Allotment
defaultSizes={[50, 50]}
defaultSizes={allotmentSizes}
style={{ height: '100%', width: '100%', minWidth: '600px' }}
className="custom-allotment"
minSize={100}
onChange={handleSplitChange}
onResize={handleResize}
>
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
<div style={{ minWidth: '600px' }}>

View file

@ -264,8 +264,7 @@ const useCardColumns = () => {
};
const VODsPage = () => {
const movies = useVODStore((s) => s.movies);
const series = useVODStore((s) => s.series);
const currentPageContent = useVODStore((s) => s.currentPageContent); // Direct subscription
const allCategories = useVODStore((s) => s.categories);
const filters = useVODStore((s) => s.filters);
const currentPage = useVODStore((s) => s.currentPage);
@ -288,8 +287,7 @@ const VODsPage = () => {
setPageSize(Number(value));
localStorage.setItem('vodsPageSize', value);
};
const fetchMovies = useVODStore((s) => s.fetchMovies);
const fetchSeries = useVODStore((s) => s.fetchSeries);
const fetchContent = useVODStore((s) => s.fetchContent);
const fetchCategories = useVODStore((s) => s.fetchCategories);
// const showVideo = useVideoStore((s) => s.showVideo); - removed as unused
@ -307,36 +305,10 @@ const VODsPage = () => {
// Helper function to get display data based on current filters
const getDisplayData = () => {
if (filters.type === 'series') {
return Object.values(series).map((item) => ({
...item,
_vodType: 'series',
}));
} else if (filters.type === 'movies') {
return Object.values(movies).map((item) => ({
...item,
_vodType: 'movie',
}));
} else {
// 'all' - combine movies and series, tagging each with its type, then sort alphabetically by name/title
const combined = [
...Object.values(movies).map((item) => ({
...item,
_vodType: 'movie',
})),
...Object.values(series).map((item) => ({
...item,
_vodType: 'series',
})),
];
return combined.sort((a, b) => {
const nameA = (a.name || a.title || '').toLowerCase();
const nameB = (b.name || b.title || '').toLowerCase();
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
});
}
return (currentPageContent || []).map((item) => ({
...item,
_vodType: item.contentType === 'movie' ? 'movie' : 'series',
}));
};
useEffect(() => {
@ -360,17 +332,8 @@ const VODsPage = () => {
}, [fetchCategories]);
useEffect(() => {
if (filters.type === 'series') {
fetchSeries().finally(() => setInitialLoad(false));
} else if (filters.type === 'movies') {
fetchMovies().finally(() => setInitialLoad(false));
} else {
// 'all': fetch both movies and series
Promise.all([fetchMovies(), fetchSeries()]).finally(() =>
setInitialLoad(false)
);
}
}, [filters, currentPage, pageSize, fetchMovies, fetchSeries]);
fetchContent().finally(() => setInitialLoad(false));
}, [filters, currentPage, pageSize, fetchContent]);
const handleVODCardClick = (vod) => {
setSelectedVOD(vod);
@ -464,46 +427,25 @@ const VODsPage = () => {
</Flex>
) : (
<>
{filters.type === 'series' ? (
<Grid gutter="md">
{Object.values(series).map((seriesItem) => (
<Grid.Col
span={12 / columns}
key={seriesItem.id}
style={{
minWidth: MIN_CARD_WIDTH,
maxWidth: MAX_CARD_WIDTH,
margin: '0 auto',
}}
>
<SeriesCard
series={seriesItem}
onClick={handleSeriesClick}
/>
</Grid.Col>
))}
</Grid>
) : (
<Grid gutter="md">
{getDisplayData().map((item) => (
<Grid.Col
span={12 / columns}
key={item.id}
style={{
minWidth: MIN_CARD_WIDTH,
maxWidth: MAX_CARD_WIDTH,
margin: '0 auto',
}}
>
{item._vodType === 'series' ? (
<SeriesCard series={item} onClick={handleSeriesClick} />
) : (
<VODCard vod={item} onClick={handleVODCardClick} />
)}
</Grid.Col>
))}
</Grid>
)}
<Grid gutter="md">
{getDisplayData().map((item) => (
<Grid.Col
span={12 / columns}
key={`${item.contentType}_${item.id}`}
style={{
minWidth: MIN_CARD_WIDTH,
maxWidth: MAX_CARD_WIDTH,
margin: '0 auto',
}}
>
{item.contentType === 'series' ? (
<SeriesCard series={item} onClick={handleSeriesClick} />
) : (
<VODCard vod={item} onClick={handleVODCardClick} />
)}
</Grid.Col>
))}
</Grid>
{/* Pagination */}
{totalPages > 1 && (

View file

@ -3,7 +3,7 @@ import api from '../api';
const useLogosStore = create((set, get) => ({
logos: {},
channelLogos: {}, // Separate state for channel-assignable logos
channelLogos: {}, // Keep this for simplicity, but we'll be more careful about when we populate it
isLoading: false,
backgroundLoading: false,
hasLoadedAll: false, // Track if we've loaded all logos
@ -21,12 +21,29 @@ const useLogosStore = create((set, get) => ({
},
addLogo: (newLogo) =>
set((state) => ({
logos: {
set((state) => {
// Add to main logos store always
const newLogos = {
...state.logos,
[newLogo.id]: { ...newLogo },
},
})),
};
// Add to channelLogos if the user has loaded channel-assignable logos
// This means they're using channel forms and the new logo should be available there
// Newly created logos are channel-assignable (they start unused)
let newChannelLogos = state.channelLogos;
if (state.hasLoadedChannelLogos) {
newChannelLogos = {
...state.channelLogos,
[newLogo.id]: { ...newLogo },
};
}
return {
logos: newLogos,
channelLogos: newChannelLogos,
};
}),
updateLogo: (logo) =>
set((state) => ({
@ -34,13 +51,25 @@ const useLogosStore = create((set, get) => ({
...state.logos,
[logo.id]: { ...logo },
},
// Update in channelLogos if it exists there
channelLogos: state.channelLogos[logo.id]
? {
...state.channelLogos,
[logo.id]: { ...logo },
}
: state.channelLogos,
})),
removeLogo: (logoId) =>
set((state) => {
const newLogos = { ...state.logos };
const newChannelLogos = { ...state.channelLogos };
delete newLogos[logoId];
return { logos: newLogos };
delete newChannelLogos[logoId];
return {
logos: newLogos,
channelLogos: newChannelLogos,
};
}),
// Smart loading methods
@ -155,8 +184,15 @@ const useLogosStore = create((set, get) => ({
console.log(`Fetched ${logos.length} channel-assignable logos`);
// Store in separate channelLogos state
// Store in both places, but this is intentional and only when specifically requested
set({
logos: {
...get().logos, // Keep existing logos
...logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;
}, {}),
},
channelLogos: logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;

View file

@ -2,8 +2,8 @@ import { create } from 'zustand';
import api from '../api';
const useVODStore = create((set, get) => ({
movies: {},
series: {},
content: {}, // Store for individual content details (when fetching movie/series details)
currentPageContent: [], // Store the current page's results
episodes: {},
categories: {},
loading: false,
@ -34,12 +34,12 @@ const useVODStore = create((set, get) => ({
currentPage: 1, // Reset to first page when page size changes
})),
fetchMovies: async () => {
fetchContent: async () => {
try {
set({ loading: true, error: null });
const state = get();
const params = new URLSearchParams();
const params = new URLSearchParams();
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
@ -51,60 +51,62 @@ const useVODStore = create((set, get) => ({
params.append('category', state.filters.category);
}
const response = await api.getMovies(params);
let allResults = [];
let totalCount = 0;
// Handle both paginated and non-paginated responses
const results = response.results || response;
const count = response.count || results.length;
if (state.filters.type === 'movies') {
// Fetch only movies
const response = await api.getMovies(params);
const results = response.results || response;
allResults = results.map((item) => ({ ...item, contentType: 'movie' }));
totalCount = response.count || results.length;
} else if (state.filters.type === 'series') {
// Fetch only series
const response = await api.getSeries(params);
const results = response.results || response;
allResults = results.map((item) => ({
...item,
contentType: 'series',
}));
totalCount = response.count || results.length;
} else {
// Use the new unified backend endpoint for 'all' view
const response = await api.getAllContent(params);
console.log('getAllContent response:', response);
console.log('response type:', typeof response);
console.log(
'response keys:',
response ? Object.keys(response) : 'no response'
);
const results = response.results || response;
console.log('results:', results);
console.log('results type:', typeof results);
console.log('results is array:', Array.isArray(results));
// Check if results is actually an array before calling map
if (!Array.isArray(results)) {
console.error('Results is not an array:', results);
throw new Error('Invalid response format - results is not an array');
}
// The backend already provides content_type and proper sorting/pagination
allResults = results.map((item) => ({
...item,
contentType: item.content_type, // Backend provides this field
}));
totalCount = response.count || results.length;
}
// Store the current page results directly (don't accumulate all pages)
set({
movies: results.reduce((acc, movie) => {
acc[movie.id] = movie;
return acc;
}, {}),
totalCount: count,
currentPageContent: allResults, // This is the paginated data for current page
totalCount,
loading: false,
});
} catch (error) {
console.error('Failed to fetch movies:', error);
set({ error: 'Failed to load movies.', loading: false });
}
},
fetchSeries: async () => {
set({ loading: true, error: null });
try {
const state = get();
const params = new URLSearchParams();
params.append('page', state.currentPage);
params.append('page_size', state.pageSize);
if (state.filters.search) {
params.append('search', state.filters.search);
}
if (state.filters.category) {
params.append('category', state.filters.category);
}
const response = await api.getSeries(params);
// Handle both paginated and non-paginated responses
const results = response.results || response;
const count = response.count || results.length;
set({
series: results.reduce((acc, series) => {
acc[series.id] = series;
return acc;
}, {}),
totalCount: count,
loading: false,
});
} catch (error) {
console.error('Failed to fetch series:', error);
set({ error: 'Failed to load series.', loading: false });
console.error('Failed to fetch content:', error);
set({ error: 'Failed to load content.', loading: false });
}
},
@ -158,9 +160,12 @@ const useVODStore = create((set, get) => ({
};
console.log('Fetched Movie Details:', movieDetails);
set((state) => ({
movies: {
...state.movies,
[movieDetails.id]: movieDetails,
content: {
...state.content,
[`movie_${movieDetails.id}`]: {
...movieDetails,
contentType: 'movie',
},
},
loading: false,
}));
@ -261,36 +266,48 @@ const useVODStore = create((set, get) => ({
addMovie: (movie) =>
set((state) => ({
movies: { ...state.movies, [movie.id]: movie },
content: {
...state.content,
[`movie_${movie.id}`]: { ...movie, contentType: 'movie' },
},
})),
updateMovie: (movie) =>
set((state) => ({
movies: { ...state.movies, [movie.id]: movie },
content: {
...state.content,
[`movie_${movie.id}`]: { ...movie, contentType: 'movie' },
},
})),
removeMovie: (movieId) =>
set((state) => {
const updatedMovies = { ...state.movies };
delete updatedMovies[movieId];
return { movies: updatedMovies };
const updatedContent = { ...state.content };
delete updatedContent[`movie_${movieId}`];
return { content: updatedContent };
}),
addSeries: (series) =>
set((state) => ({
series: { ...state.series, [series.id]: series },
content: {
...state.content,
[`series_${series.id}`]: { ...series, contentType: 'series' },
},
})),
updateSeries: (series) =>
set((state) => ({
series: { ...state.series, [series.id]: series },
content: {
...state.content,
[`series_${series.id}`]: { ...series, contentType: 'series' },
},
})),
removeSeries: (seriesId) =>
set((state) => {
const updatedSeries = { ...state.series };
delete updatedSeries[seriesId];
return { series: updatedSeries };
const updatedContent = { ...state.content };
delete updatedContent[`series_${seriesId}`];
return { content: updatedContent };
}),
fetchSeriesInfo: async (seriesId) => {
@ -369,9 +386,9 @@ const useVODStore = create((set, get) => ({
}
set((state) => ({
series: {
...state.series,
[seriesInfo.id]: seriesInfo,
content: {
...state.content,
[`series_${seriesInfo.id}`]: { ...seriesInfo, contentType: 'series' },
},
loading: false,
}));
@ -387,6 +404,29 @@ const useVODStore = create((set, get) => ({
throw error;
}
},
// Helper methods for getting filtered content
getFilteredContent: () => {
const state = get();
// Return the current page content directly - backend handles all filtering/pagination
return state.currentPageContent;
},
getMovies: () => {
const state = get();
return Object.values(state.content).filter(
(item) => item.contentType === 'movie'
);
},
getSeries: () => {
const state = get();
return Object.values(state.content).filter(
(item) => item.contentType === 'series'
);
},
clearContent: () => set({ content: {}, totalCount: 0 }),
}));
export default useVODStore;

View file

@ -1,182 +0,0 @@
# ml_model.py
import sys
import json
import re
import os
import logging
from rapidfuzz import fuzz
from sentence_transformers import util
from sentence_transformers import SentenceTransformer as st
# Set up logger
logger = logging.getLogger(__name__)
# Load the sentence-transformers model once at the module level
SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
MODEL_PATH = os.path.join("/app", "models", "all-MiniLM-L6-v2")
# Thresholds
BEST_FUZZY_THRESHOLD = 85
LOWER_FUZZY_THRESHOLD = 40
EMBED_SIM_THRESHOLD = 0.65
def process_data(input_data):
os.makedirs(MODEL_PATH, exist_ok=True)
# If not present locally, download:
if not os.path.exists(os.path.join(MODEL_PATH, "config.json")):
logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...")
st_model = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH)
else:
logger.info(f"Loading local model from {MODEL_PATH}")
st_model = st(MODEL_PATH)
channels = input_data["channels"]
epg_data = input_data["epg_data"]
region_code = input_data.get("region_code", None)
epg_embeddings = None
if any(row["norm_name"] for row in epg_data):
epg_embeddings = st_model.encode(
[row["norm_name"] for row in epg_data],
convert_to_tensor=True
)
channels_to_update = []
matched_channels = []
for chan in channels:
normalized_tvg_id = chan.get("tvg_id", "")
fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"]
# Exact TVG ID match (direct match)
epg_by_tvg_id = next((epg for epg in epg_data if epg["tvg_id"] == normalized_tvg_id), None)
if normalized_tvg_id and epg_by_tvg_id:
chan["epg_data_id"] = epg_by_tvg_id["id"]
channels_to_update.append(chan)
# Add to matched_channels list so it's counted in the total
matched_channels.append((chan['id'], fallback_name, epg_by_tvg_id["tvg_id"]))
logger.info(f"Channel {chan['id']} '{fallback_name}' => EPG found by tvg_id={epg_by_tvg_id['tvg_id']}")
continue
# If channel has a tvg_id that doesn't exist in EPGData, do direct check.
# I don't THINK this should happen now that we assign EPG on channel creation.
if chan["tvg_id"]:
epg_match = [epg["id"] for epg in epg_data if epg["tvg_id"] == chan["tvg_id"]]
if epg_match:
chan["epg_data_id"] = epg_match[0]
logger.info(f"Channel {chan['id']} '{chan['name']}' => EPG found by tvg_id={chan['tvg_id']}")
channels_to_update.append(chan)
continue
# C) Perform name-based fuzzy matching
if not chan["norm_chan"]:
logger.debug(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping")
continue
best_score = 0
best_epg = None
for row in epg_data:
if not row["norm_name"]:
continue
base_score = fuzz.ratio(chan["norm_chan"], row["norm_name"])
bonus = 0
# Region-based bonus/penalty
combined_text = row["tvg_id"].lower() + " " + row["name"].lower()
dot_regions = re.findall(r'\.([a-z]{2})', combined_text)
if region_code:
if dot_regions:
if region_code in dot_regions:
bonus = 30 # bigger bonus if .us or .ca matches
else:
bonus = -15
elif region_code in combined_text:
bonus = 15
score = base_score + bonus
logger.debug(
f"Channel {chan['id']} '{fallback_name}' => EPG row {row['id']}: "
f"name='{row['name']}', norm_name='{row['norm_name']}', "
f"combined_text='{combined_text}', dot_regions={dot_regions}, "
f"base_score={base_score}, bonus={bonus}, total_score={score}"
)
if score > best_score:
best_score = score
best_epg = row
# If no best match was found, skip
if not best_epg:
logger.debug(f"Channel {chan['id']} '{fallback_name}' => no EPG match at all.")
continue
# If best_score is above BEST_FUZZY_THRESHOLD => direct accept
if best_score >= BEST_FUZZY_THRESHOLD:
chan["epg_data_id"] = best_epg["id"]
channels_to_update.append(chan)
matched_channels.append((chan['id'], fallback_name, best_epg["tvg_id"]))
logger.info(
f"Channel {chan['id']} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} "
f"(score={best_score})"
)
# If best_score is in the “middle range,” do embedding check
elif best_score >= LOWER_FUZZY_THRESHOLD and epg_embeddings is not None:
chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True)
sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0]
top_index = int(sim_scores.argmax())
top_value = float(sim_scores[top_index])
if top_value >= EMBED_SIM_THRESHOLD:
matched_epg = epg_data[top_index]
chan["epg_data_id"] = matched_epg["id"]
channels_to_update.append(chan)
matched_channels.append((chan['id'], fallback_name, matched_epg["tvg_id"]))
logger.info(
f"Channel {chan['id']} '{fallback_name}' => matched EPG tvg_id={matched_epg['tvg_id']} "
f"(fuzzy={best_score}, cos-sim={top_value:.2f})"
)
else:
logger.info(
f"Channel {chan['id']} '{fallback_name}' => fuzzy={best_score}, "
f"cos-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, skipping"
)
else:
# No good match found - fuzzy score is too low
logger.info(
f"Channel {chan['id']} '{fallback_name}' => best fuzzy match score={best_score} < {LOWER_FUZZY_THRESHOLD}, skipping"
)
return {
"channels_to_update": channels_to_update,
"matched_channels": matched_channels
}
def main():
# Configure logging
logging_level = os.environ.get('DISPATCHARR_LOG_LEVEL', 'INFO')
logging.basicConfig(
level=getattr(logging, logging_level),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
stream=sys.stderr
)
# Read input data from a file
input_file_path = sys.argv[1]
with open(input_file_path, 'r') as f:
input_data = json.load(f)
# Process data with the ML model (or your logic)
result = process_data(input_data)
# Output result to stdout
print(json.dumps(result))
if __name__ == "__main__":
main()