Merge remote-tracking branch 'upstream/dev' into tests/frontend-unit-tests

This commit is contained in:
Nick Sandstrom 2026-01-10 19:35:40 -08:00
commit e2e6f61dee
43 changed files with 2960 additions and 2583 deletions

View file

@ -184,13 +184,13 @@ jobs:
echo "Creating multi-arch manifest for ${OWNER}/${REPO}"
# GitHub Container Registry manifests
# latest tag
# Create one manifest with both latest and version tags
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=latest" \
--annotation "index:org.opencontainers.image.version=${VERSION}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
@ -200,9 +200,11 @@ jobs:
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:latest \
ghcr.io/${OWNER}/${REPO}:latest-amd64 ghcr.io/${OWNER}/${REPO}:latest-arm64
--tag ghcr.io/${OWNER}/${REPO}:${VERSION} \
ghcr.io/${OWNER}/${REPO}:${VERSION}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-arm64
# version tag
# Docker Hub manifests
# Create one manifest with both latest and version tags
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
@ -217,43 +219,7 @@ jobs:
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${VERSION} \
ghcr.io/${OWNER}/${REPO}:${VERSION}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-arm64
# Docker Hub manifests
# latest tag
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=latest" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest-arm64
# version tag
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${VERSION}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-arm64

View file

@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Group retention policy for M3U accounts: Groups now follow the same stale retention logic as streams, using the account's `stale_stream_days` setting. Groups that temporarily disappear from an M3U source are retained for the configured retention period instead of being immediately deleted, preserving user settings and preventing data loss when providers temporarily remove/re-add groups. (Closes #809)
- Visual stale indicators for streams and groups: Added `is_stale` field to Stream and both `is_stale` and `last_seen` fields to ChannelGroupM3UAccount models to track items in their retention grace period. Stale groups display with orange buttons and a warning tooltip, while stale streams show with a red background color matching the visual treatment of empty channels.
### Changed
- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution.
- VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase
- Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability
- Stats and VOD pages refactored for clearer separation of concerns: extracted Stream/VOD connection cards (StreamConnectionCard, VodConnectionCard, VODCard, SeriesCard), moved page logic into dedicated utils, and lazy-loaded heavy components with ErrorBoundary fallbacks to improve readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
### Fixed
- Fixed Channel Profile filter incorrectly applying profile membership filtering even when "Show Disabled" was enabled, preventing all channels from being displayed. Profile filter now only applies when hiding disabled channels. (Fixes #825)
- Fixed manual channel creation not adding channels to channel profiles. Manually created channels are now added to the selected profile if one is active, or to all profiles if "All" is selected, matching the behavior of channels created from streams.
- Fixed VOD streams disappearing from stats page during playback by adding `socket-timeout = 600` to production uWSGI config. The missing directive caused uWSGI to use its default 4-second timeout, triggering premature cleanup when clients buffered content. Now matches the existing `http-timeout = 600` value and prevents timeout errors during normal client buffering - Thanks [@patchy8736](https://github.com/patchy8736)
- Fixed Channels table EPG column showing "Not Assigned" on initial load for users with large EPG datasets. Added `tvgsLoaded` flag to EPG store to track when EPG data has finished loading, ensuring the table waits for EPG data before displaying. EPG cells now show animated skeleton placeholders while loading instead of incorrectly showing "Not Assigned". (Fixes #810)
- Fixed VOD profile connection count not being decremented when stream connection fails (timeout, 404, etc.), preventing profiles from reaching capacity limits and rejecting valid stream requests
- Fixed React warning in Channel form by removing invalid `removeTrailingZeros` prop from NumberInput component
- Release workflow Docker tagging: Fixed issue where `latest` and version tags (e.g., `0.16.0`) were creating separate manifests instead of pointing to the same image digest, which caused old `latest` tags to become orphaned/untagged after new releases. Now creates a single multi-arch manifest with both tags, maintaining proper tag relationships and download statistics visibility on GitHub.
- Fixed onboarding message appearing in the Channels Table when filtered results are empty. The onboarding message now only displays when there are no channels created at all, not when channels exist but are filtered out by current filters.
- Fixed `M3UMovieRelation.get_stream_url()` and `M3UEpisodeRelation.get_stream_url()` to use XC client's `_normalize_url()` method instead of simple `rstrip('/')`. This properly handles malformed M3U account URLs (e.g., containing `/player_api.php` or query parameters) before constructing VOD stream endpoints, matching behavior of live channel URL building. (Closes #722)
- Fixed bulk_create and bulk_update errors during VOD content refresh by pre-checking object existence with optimized bulk queries (3 queries total instead of N per batch) before creating new objects. This ensures all movie/series objects have primary keys before relation operations, preventing "prohibited to prevent data loss due to unsaved related object" errors. Additionally fixed duplicate key constraint violations by treating TMDB/IMDB ID values of `0` or `'0'` as invalid (some providers use this to indicate "no ID"), converting them to NULL to prevent multiple items from incorrectly sharing the same ID. (Fixes #813)
## [0.16.0] - 2026-01-04
### Added

View file

@ -236,12 +236,8 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
return [Authenticated()]
def get_queryset(self):
"""Add annotation for association counts"""
from django.db.models import Count
return ChannelGroup.objects.annotate(
channel_count=Count('channels', distinct=True),
m3u_account_count=Count('m3u_accounts', distinct=True)
)
"""Return channel groups with prefetched relations for efficient counting"""
return ChannelGroup.objects.prefetch_related('channels', 'm3u_accounts').all()
def update(self, request, *args, **kwargs):
"""Override update to check M3U associations"""
@ -277,15 +273,20 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["post"], url_path="cleanup")
def cleanup_unused_groups(self, request):
"""Delete all channel groups with no channels or M3U account associations"""
from django.db.models import Count
from django.db.models import Q, Exists, OuterRef
# Find groups with no channels and no M3U account associations using Exists subqueries
from .models import Channel, ChannelGroupM3UAccount
has_channels = Channel.objects.filter(channel_group_id=OuterRef('pk'))
has_accounts = ChannelGroupM3UAccount.objects.filter(channel_group_id=OuterRef('pk'))
# Find groups with no channels and no M3U account associations
unused_groups = ChannelGroup.objects.annotate(
channel_count=Count('channels', distinct=True),
m3u_account_count=Count('m3u_accounts', distinct=True)
has_channels=Exists(has_channels),
has_accounts=Exists(has_accounts)
).filter(
channel_count=0,
m3u_account_count=0
has_channels=False,
has_accounts=False
)
deleted_count = unused_groups.count()
@ -386,6 +387,56 @@ class ChannelViewSet(viewsets.ModelViewSet):
ordering_fields = ["channel_number", "name", "channel_group__name"]
ordering = ["-channel_number"]
def create(self, request, *args, **kwargs):
"""Override create to handle channel profile membership"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
channel = serializer.save()
# Handle channel profile membership
channel_profile_ids = request.data.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]
if channel_profile_ids:
# Add channel only to the specified profiles
try:
channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids)
if len(channel_profiles) != len(channel_profile_ids):
missing_ids = set(channel_profile_ids) - set(channel_profiles.values_list('id', flat=True))
return Response(
{"error": f"Channel profiles with IDs {list(missing_ids)} not found"},
status=status.HTTP_400_BAD_REQUEST,
)
ChannelProfileMembership.objects.bulk_create([
ChannelProfileMembership(
channel_profile=profile,
channel=channel,
enabled=True
)
for profile in channel_profiles
])
except Exception as e:
return Response(
{"error": f"Error creating profile memberships: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# Default behavior: add to all profiles
profiles = ChannelProfile.objects.all()
ChannelProfileMembership.objects.bulk_create([
ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True)
for profile in profiles
])
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_permissions(self):
if self.action in [
"edit_bulk",
@ -431,10 +482,15 @@ class ChannelViewSet(viewsets.ModelViewSet):
if channel_profile_id:
try:
profile_id_int = int(channel_profile_id)
filters["channelprofilemembership__channel_profile_id"] = profile_id_int
if show_disabled_param is None:
# Show only enabled channels: channels that have a membership
# record for this profile with enabled=True
# Default is DISABLED (channels without membership are hidden)
filters["channelprofilemembership__channel_profile_id"] = profile_id_int
filters["channelprofilemembership__enabled"] = True
# If show_disabled is True, show all channels (no filtering needed)
except (ValueError, TypeError):
# Ignore invalid profile id values
pass

View file

@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2026-01-09 18:19
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0030_alter_stream_url'),
]
operations = [
migrations.AddField(
model_name='channelgroupm3uaccount',
name='is_stale',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'),
),
migrations.AddField(
model_name='channelgroupm3uaccount',
name='last_seen',
field=models.DateTimeField(db_index=True, default=datetime.datetime.now, help_text='Last time this group was seen in the M3U source during a refresh'),
),
migrations.AddField(
model_name='stream',
name='is_stale',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream is stale (not seen in recent refresh, pending deletion)'),
),
]

View file

@ -94,6 +94,11 @@ class Stream(models.Model):
db_index=True,
)
last_seen = models.DateTimeField(db_index=True, default=datetime.now)
is_stale = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)"
)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
# Stream statistics fields
@ -589,6 +594,16 @@ class ChannelGroupM3UAccount(models.Model):
blank=True,
help_text='Starting channel number for auto-created channels in this group'
)
last_seen = models.DateTimeField(
default=datetime.now,
db_index=True,
help_text='Last time this group was seen in the M3U source during a refresh'
)
is_stale = models.BooleanField(
default=False,
db_index=True,
help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'
)
class Meta:
unique_together = ("channel_group", "m3u_account")

View file

@ -119,6 +119,7 @@ class StreamSerializer(serializers.ModelSerializer):
"current_viewers",
"updated_at",
"last_seen",
"is_stale",
"stream_profile_id",
"is_custom",
"channel_group",
@ -155,7 +156,7 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
class Meta:
model = ChannelGroupM3UAccount
fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"]
fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties", "is_stale", "last_seen"]
def to_representation(self, instance):
data = super().to_representation(instance)
@ -179,8 +180,8 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
# Channel Group
#
class ChannelGroupSerializer(serializers.ModelSerializer):
channel_count = serializers.IntegerField(read_only=True)
m3u_account_count = serializers.IntegerField(read_only=True)
channel_count = serializers.SerializerMethodField()
m3u_account_count = serializers.SerializerMethodField()
m3u_accounts = ChannelGroupM3UAccountSerializer(
many=True,
read_only=True
@ -190,6 +191,14 @@ class ChannelGroupSerializer(serializers.ModelSerializer):
model = ChannelGroup
fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"]
def get_channel_count(self, obj):
"""Get count of channels in this group"""
return obj.channels.count()
def get_m3u_account_count(self, obj):
"""Get count of M3U accounts associated with this group"""
return obj.m3u_accounts.count()
class ChannelProfileSerializer(serializers.ModelSerializer):
channels = serializers.SerializerMethodField()

View file

@ -513,7 +513,19 @@ def check_field_lengths(streams_to_create):
@shared_task
def process_groups(account, groups):
def process_groups(account, groups, scan_start_time=None):
"""Process groups and update their relationships with the M3U account.
Args:
account: M3UAccount instance
groups: Dict of group names to custom properties
scan_start_time: Timestamp when the scan started (for consistent last_seen marking)
"""
# Use scan_start_time if provided, otherwise current time
# This ensures consistency with stream processing and cleanup logic
if scan_start_time is None:
scan_start_time = timezone.now()
existing_groups = {
group.name: group
for group in ChannelGroup.objects.filter(name__in=groups.keys())
@ -553,24 +565,8 @@ def process_groups(account, groups):
).select_related('channel_group')
}
# Get ALL existing relationships for this account to identify orphaned ones
all_existing_relationships = {
rel.channel_group.name: rel
for rel in ChannelGroupM3UAccount.objects.filter(
m3u_account=account
).select_related('channel_group')
}
relations_to_create = []
relations_to_update = []
relations_to_delete = []
# Find orphaned relationships (groups that no longer exist in the source)
current_group_names = set(groups.keys())
for group_name, rel in all_existing_relationships.items():
if group_name not in current_group_names:
relations_to_delete.append(rel)
logger.debug(f"Marking relationship for deletion: group '{group_name}' no longer exists in source for account {account.id}")
for group in all_group_objs:
custom_props = groups.get(group.name, {})
@ -597,9 +593,15 @@ def process_groups(account, groups):
del updated_custom_props["xc_id"]
existing_rel.custom_properties = updated_custom_props
existing_rel.last_seen = scan_start_time
existing_rel.is_stale = False
relations_to_update.append(existing_rel)
logger.debug(f"Updated xc_id for group '{group.name}' from '{existing_xc_id}' to '{new_xc_id}' - account {account.id}")
else:
# Update last_seen even if xc_id hasn't changed
existing_rel.last_seen = scan_start_time
existing_rel.is_stale = False
relations_to_update.append(existing_rel)
logger.debug(f"xc_id unchanged for group '{group.name}' - account {account.id}")
else:
# Create new relationship - this group is new to this M3U account
@ -613,6 +615,8 @@ def process_groups(account, groups):
m3u_account=account,
custom_properties=custom_props,
enabled=auto_enable_new_groups_live,
last_seen=scan_start_time,
is_stale=False,
)
)
@ -623,15 +627,38 @@ def process_groups(account, groups):
# Bulk update existing relationships
if relations_to_update:
ChannelGroupM3UAccount.objects.bulk_update(relations_to_update, ['custom_properties'])
logger.info(f"Updated {len(relations_to_update)} existing group relationships with new xc_id values for account {account.id}")
ChannelGroupM3UAccount.objects.bulk_update(relations_to_update, ['custom_properties', 'last_seen', 'is_stale'])
logger.info(f"Updated {len(relations_to_update)} existing group relationships for account {account.id}")
# Delete orphaned relationships
if relations_to_delete:
ChannelGroupM3UAccount.objects.filter(
id__in=[rel.id for rel in relations_to_delete]
).delete()
logger.info(f"Deleted {len(relations_to_delete)} orphaned group relationships for account {account.id}: {[rel.channel_group.name for rel in relations_to_delete]}")
def cleanup_stale_group_relationships(account, scan_start_time):
"""
Remove group relationships that haven't been seen since the stale retention period.
This follows the same logic as stream cleanup for consistency.
"""
# Calculate cutoff date for stale group relationships
stale_cutoff = scan_start_time - timezone.timedelta(days=account.stale_stream_days)
logger.info(
f"Removing group relationships not seen since {stale_cutoff} for M3U account {account.id}"
)
# Find stale relationships
stale_relationships = ChannelGroupM3UAccount.objects.filter(
m3u_account=account,
last_seen__lt=stale_cutoff
).select_related('channel_group')
relations_to_delete = list(stale_relationships)
deleted_count = len(relations_to_delete)
if deleted_count > 0:
logger.info(
f"Found {deleted_count} stale group relationships for account {account.id}: "
f"{[rel.channel_group.name for rel in relations_to_delete]}"
)
# Delete the stale relationships
stale_relationships.delete()
# Check if any of the deleted relationships left groups with no remaining associations
orphaned_group_ids = []
@ -656,6 +683,10 @@ def process_groups(account, groups):
deleted_groups = list(ChannelGroup.objects.filter(id__in=orphaned_group_ids).values_list('name', flat=True))
ChannelGroup.objects.filter(id__in=orphaned_group_ids).delete()
logger.info(f"Deleted {len(orphaned_group_ids)} orphaned groups that had no remaining associations: {deleted_groups}")
else:
logger.debug(f"No stale group relationships found for account {account.id}")
return deleted_count
def collect_xc_streams(account_id, enabled_groups):
@ -803,6 +834,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
"channel_group_id": int(group_id),
"stream_hash": stream_hash,
"custom_properties": stream,
"is_stale": False,
}
if stream_hash not in stream_hashes:
@ -838,10 +870,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
setattr(obj, key, value)
obj.last_seen = timezone.now()
obj.updated_at = timezone.now() # Update timestamp only for changed streams
obj.is_stale = False
streams_to_update.append(obj)
else:
# Always update last_seen, even if nothing else changed
obj.last_seen = timezone.now()
obj.is_stale = False
# Don't update updated_at for unchanged streams
streams_to_update.append(obj)
@ -852,6 +886,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
stream_props["updated_at"] = (
timezone.now()
) # Set initial updated_at for new streams
stream_props["is_stale"] = False
streams_to_create.append(Stream(**stream_props))
try:
@ -863,7 +898,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
# Simplified bulk update for better performance
Stream.objects.bulk_update(
streams_to_update,
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'],
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'],
batch_size=150 # Smaller batch size for XC processing
)
@ -976,6 +1011,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
"channel_group_id": int(groups.get(group_title)),
"stream_hash": stream_hash,
"custom_properties": stream_info["attributes"],
"is_stale": False,
}
if stream_hash not in stream_hashes:
@ -1015,11 +1051,15 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.custom_properties = stream_props["custom_properties"]
obj.updated_at = timezone.now()
# Always mark as not stale since we saw it in this refresh
obj.is_stale = False
streams_to_update.append(obj)
else:
# New stream
stream_props["last_seen"] = timezone.now()
stream_props["updated_at"] = timezone.now()
stream_props["is_stale"] = False
streams_to_create.append(Stream(**stream_props))
try:
@ -1031,7 +1071,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
# Update all streams in a single bulk operation
Stream.objects.bulk_update(
streams_to_update,
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'],
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'],
batch_size=200
)
except Exception as e:
@ -1092,7 +1132,15 @@ def cleanup_streams(account_id, scan_start_time=timezone.now):
@shared_task
def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_start_time=None):
"""Refresh M3U groups for an account.
Args:
account_id: ID of the M3U account
use_cache: Whether to use cached M3U file
full_refresh: Whether this is part of a full refresh
scan_start_time: Timestamp when the scan started (for consistent last_seen marking)
"""
if not acquire_task_lock("refresh_m3u_account_groups", account_id):
return f"Task already running for account_id={account_id}.", None
@ -1419,7 +1467,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
send_m3u_update(account_id, "processing_groups", 0)
process_groups(account, groups)
process_groups(account, groups, scan_start_time)
release_task_lock("refresh_m3u_account_groups", account_id)
@ -2526,7 +2574,7 @@ def refresh_single_m3u_account(account_id):
if not extinf_data:
try:
logger.info(f"Calling refresh_m3u_groups for account {account_id}")
result = refresh_m3u_groups(account_id, full_refresh=True)
result = refresh_m3u_groups(account_id, full_refresh=True, scan_start_time=refresh_start_timestamp)
logger.trace(f"refresh_m3u_groups result: {result}")
# Check for completely empty result or missing groups
@ -2806,9 +2854,26 @@ def refresh_single_m3u_account(account_id):
id=-1
).exists() # This will never find anything but ensures DB sync
# Mark streams that weren't seen in this refresh as stale (pending deletion)
stale_stream_count = Stream.objects.filter(
m3u_account=account,
last_seen__lt=refresh_start_timestamp
).update(is_stale=True)
logger.info(f"Marked {stale_stream_count} streams as stale for account {account_id}")
# Mark group relationships that weren't seen in this refresh as stale (pending deletion)
stale_group_count = ChannelGroupM3UAccount.objects.filter(
m3u_account=account,
last_seen__lt=refresh_start_timestamp
).update(is_stale=True)
logger.info(f"Marked {stale_group_count} group relationships as stale for account {account_id}")
# Now run cleanup
streams_deleted = cleanup_streams(account_id, refresh_start_timestamp)
# Cleanup stale group relationships (follows same retention policy as streams)
cleanup_stale_group_relationships(account, refresh_start_timestamp)
# Run auto channel sync after successful refresh
auto_sync_message = ""
try:

View file

@ -357,12 +357,12 @@ class RedisBackedVODConnection:
logger.info(f"[{self.session_id}] Making request #{state.request_count} to {'final' if state.final_url else 'original'} URL")
# Make request
# Make request (10s connect, 10s read timeout - keeps lock time reasonable if client disconnects)
response = self.local_session.get(
target_url,
headers=headers,
stream=True,
timeout=(10, 30),
timeout=(10, 10),
allow_redirects=allow_redirects
)
response.raise_for_status()
@ -712,6 +712,10 @@ class MultiWorkerVODConnectionManager:
content_name = content_obj.name if hasattr(content_obj, 'name') else str(content_obj)
client_id = session_id
# Track whether we incremented profile connections (for cleanup on error)
profile_connections_incremented = False
redis_connection = None
logger.info(f"[{client_id}] Worker {self.worker_id} - Redis-backed streaming request for {content_type} {content_name}")
try:
@ -802,6 +806,7 @@ class MultiWorkerVODConnectionManager:
# Increment profile connections after successful connection creation
self._increment_profile_connections(m3u_profile)
profile_connections_incremented = True
logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata")
else:
@ -1024,6 +1029,19 @@ class MultiWorkerVODConnectionManager:
except Exception as e:
logger.error(f"[{client_id}] Worker {self.worker_id} - Error in Redis-backed stream_content_with_session: {e}", exc_info=True)
# Decrement profile connections if we incremented them but failed before streaming started
if profile_connections_incremented:
logger.info(f"[{client_id}] Connection error occurred after profile increment - decrementing profile connections")
self._decrement_profile_connections(m3u_profile.id)
# Also clean up the Redis connection state since we won't be using it
if redis_connection:
try:
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
except Exception as cleanup_error:
logger.error(f"[{client_id}] Error during cleanup after connection failure: {cleanup_error}")
return HttpResponse(f"Streaming error: {str(e)}", status=500)
def _apply_timeshift_parameters(self, original_url, utc_start=None, utc_end=None, offset=None):

View file

@ -245,10 +245,13 @@ class M3UMovieRelation(models.Model):
"""Get the full stream URL for this movie from this provider"""
# Build URL dynamically for XtreamCodes accounts
if self.m3u_account.account_type == 'XC':
server_url = self.m3u_account.server_url.rstrip('/')
from core.xtream_codes import Client as XCClient
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
username = self.m3u_account.username
password = self.m3u_account.password
return f"{server_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
else:
# For other account types, we would need another way to build URLs
return None
@ -285,10 +288,12 @@ class M3UEpisodeRelation(models.Model):
if self.m3u_account.account_type == 'XC':
# For XtreamCodes accounts, build the URL dynamically
server_url = self.m3u_account.server_url.rstrip('/')
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
username = self.m3u_account.username
password = self.m3u_account.password
return f"{server_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
else:
# We might support non XC accounts in the future
# For now, return None

View file

@ -410,10 +410,10 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb')
imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb')
# Clean empty string IDs
if tmdb_id == '':
# Clean empty string IDs and zero values (some providers use 0 to indicate no ID)
if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0':
tmdb_id = None
if imdb_id == '':
if imdb_id == '' or imdb_id == 0 or imdb_id == '0':
imdb_id = None
# Create a unique key for this movie (priority: TMDB > IMDB > name+year)
@ -614,26 +614,41 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
# First, create new movies and get their IDs
created_movies = {}
if movies_to_create:
Movie.objects.bulk_create(movies_to_create, ignore_conflicts=True)
# Bulk query to check which movies already exist
tmdb_ids = [m.tmdb_id for m in movies_to_create if m.tmdb_id]
imdb_ids = [m.imdb_id for m in movies_to_create if m.imdb_id]
name_year_pairs = [(m.name, m.year) for m in movies_to_create if not m.tmdb_id and not m.imdb_id]
# Get the newly created movies with their IDs
# We need to re-fetch them to get the primary keys
existing_by_tmdb = {m.tmdb_id: m for m in Movie.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {}
existing_by_imdb = {m.imdb_id: m for m in Movie.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {}
existing_by_name_year = {}
if name_year_pairs:
for movie in Movie.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True):
key = (movie.name, movie.year)
if key in name_year_pairs:
existing_by_name_year[key] = movie
# Check each movie against the bulk query results
movies_actually_created = []
for movie in movies_to_create:
# Find the movie by its unique identifiers
if movie.tmdb_id:
db_movie = Movie.objects.filter(tmdb_id=movie.tmdb_id).first()
elif movie.imdb_id:
db_movie = Movie.objects.filter(imdb_id=movie.imdb_id).first()
else:
db_movie = Movie.objects.filter(
name=movie.name,
year=movie.year,
tmdb_id__isnull=True,
imdb_id__isnull=True
).first()
existing = None
if movie.tmdb_id and movie.tmdb_id in existing_by_tmdb:
existing = existing_by_tmdb[movie.tmdb_id]
elif movie.imdb_id and movie.imdb_id in existing_by_imdb:
existing = existing_by_imdb[movie.imdb_id]
elif not movie.tmdb_id and not movie.imdb_id:
existing = existing_by_name_year.get((movie.name, movie.year))
if db_movie:
created_movies[id(movie)] = db_movie
if existing:
created_movies[id(movie)] = existing
else:
movies_actually_created.append(movie)
created_movies[id(movie)] = movie
# Bulk create only movies that don't exist
if movies_actually_created:
Movie.objects.bulk_create(movies_actually_created)
# Update existing movies
if movies_to_update:
@ -649,12 +664,16 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
movie.logo = movie._logo_to_update
movie.save(update_fields=['logo'])
# Update relations to reference the correct movie objects
# Update relations to reference the correct movie objects (with PKs)
for relation in relations_to_create:
if id(relation.movie) in created_movies:
relation.movie = created_movies[id(relation.movie)]
# Handle relations
for relation in relations_to_update:
if id(relation.movie) in created_movies:
relation.movie = created_movies[id(relation.movie)]
# All movies now have PKs, safe to bulk create/update relations
if relations_to_create:
M3UMovieRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True)
@ -724,10 +743,10 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id')
imdb_id = series_data.get('imdb') or series_data.get('imdb_id')
# Clean empty string IDs
if tmdb_id == '':
# Clean empty string IDs and zero values (some providers use 0 to indicate no ID)
if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0':
tmdb_id = None
if imdb_id == '':
if imdb_id == '' or imdb_id == 0 or imdb_id == '0':
imdb_id = None
# Create a unique key for this series (priority: TMDB > IMDB > name+year)
@ -945,26 +964,41 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
# First, create new series and get their IDs
created_series = {}
if series_to_create:
Series.objects.bulk_create(series_to_create, ignore_conflicts=True)
# Bulk query to check which series already exist
tmdb_ids = [s.tmdb_id for s in series_to_create if s.tmdb_id]
imdb_ids = [s.imdb_id for s in series_to_create if s.imdb_id]
name_year_pairs = [(s.name, s.year) for s in series_to_create if not s.tmdb_id and not s.imdb_id]
# Get the newly created series with their IDs
# We need to re-fetch them to get the primary keys
existing_by_tmdb = {s.tmdb_id: s for s in Series.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {}
existing_by_imdb = {s.imdb_id: s for s in Series.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {}
existing_by_name_year = {}
if name_year_pairs:
for series in Series.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True):
key = (series.name, series.year)
if key in name_year_pairs:
existing_by_name_year[key] = series
# Check each series against the bulk query results
series_actually_created = []
for series in series_to_create:
# Find the series by its unique identifiers
if series.tmdb_id:
db_series = Series.objects.filter(tmdb_id=series.tmdb_id).first()
elif series.imdb_id:
db_series = Series.objects.filter(imdb_id=series.imdb_id).first()
else:
db_series = Series.objects.filter(
name=series.name,
year=series.year,
tmdb_id__isnull=True,
imdb_id__isnull=True
).first()
existing = None
if series.tmdb_id and series.tmdb_id in existing_by_tmdb:
existing = existing_by_tmdb[series.tmdb_id]
elif series.imdb_id and series.imdb_id in existing_by_imdb:
existing = existing_by_imdb[series.imdb_id]
elif not series.tmdb_id and not series.imdb_id:
existing = existing_by_name_year.get((series.name, series.year))
if db_series:
created_series[id(series)] = db_series
if existing:
created_series[id(series)] = existing
else:
series_actually_created.append(series)
created_series[id(series)] = series
# Bulk create only series that don't exist
if series_actually_created:
Series.objects.bulk_create(series_actually_created)
# Update existing series
if series_to_update:
@ -980,12 +1014,16 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
series.logo = series._logo_to_update
series.save(update_fields=['logo'])
# Update relations to reference the correct series objects
# Update relations to reference the correct series objects (with PKs)
for relation in relations_to_create:
if id(relation.series) in created_series:
relation.series = created_series[id(relation.series)]
# Handle relations
for relation in relations_to_update:
if id(relation.series) in created_series:
relation.series = created_series[id(relation.series)]
# All series now have PKs, safe to bulk create/update relations
if relations_to_create:
M3USeriesRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True)

View file

@ -14,6 +14,10 @@ services:
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
- DISPATCHARR_LOG_LEVEL=info
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
# that lack support for newer baseline CPU features
#- USE_LEGACY_NUMPY=true
# Process Priority Configuration (Optional)
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
# Negative values require cap_add: SYS_NICE (uncomment below)

View file

@ -18,6 +18,10 @@ services:
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
- DISPATCHARR_LOG_LEVEL=trace
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
# that lack support for newer baseline CPU features
#- USE_LEGACY_NUMPY=true
# Process Priority Configuration (Optional)
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
# Negative values require cap_add: SYS_NICE (uncomment below)

View file

@ -17,6 +17,10 @@ services:
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
- DISPATCHARR_LOG_LEVEL=debug
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
# that lack support for newer baseline CPU features
#- USE_LEGACY_NUMPY=true
# Process Priority Configuration (Optional)
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
# Negative values require cap_add: SYS_NICE (uncomment below)

View file

@ -17,6 +17,10 @@ services:
- REDIS_HOST=redis
- CELERY_BROKER_URL=redis://redis:6379/0
- DISPATCHARR_LOG_LEVEL=info
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
# that lack support for newer baseline CPU features
#- USE_LEGACY_NUMPY=true
# Process Priority Configuration (Optional)
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
# Negative values require cap_add: SYS_NICE (uncomment below)

View file

@ -37,6 +37,7 @@ http-keepalive = 1
buffer-size = 65536 # Increase buffer for large payloads
post-buffering = 4096 # Reduce buffering for real-time streaming
http-timeout = 600 # Prevent disconnects from long streams
socket-timeout = 600 # Prevent write timeouts when client buffers
lazy-apps = true # Improve memory efficiency
# Async mode (use gevent for high concurrency)
@ -58,4 +59,4 @@ logformat-strftime = true
log-date = %%Y-%%m-%%d %%H:%%M:%%S,000
# Use formatted time with environment variable for log level
log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms
log-buffering = 1024 # Add buffer size limit for logging
log-buffering = 1024 # Add buffer size limit for logging

View file

@ -12,6 +12,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@mantine/charts": "~8.0.1",
"@mantine/core": "~8.0.1",
"@mantine/dates": "~8.0.1",
@ -22,13 +23,13 @@
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.4",
"dayjs": "^1.11.13",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.511.0",
"mpegts.js": "^1.8.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-draggable": "^4.4.6",
"react-hook-form": "^7.70.0",
"react-pro-sidebar": "^1.1.0",
"react-router-dom": "^7.3.0",
"react-virtualized": "^9.22.6",
@ -1248,6 +1249,18 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1776,6 +1789,12 @@
"win32"
]
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/core": {
"name": "@swc/wasm",
"version": "1.13.20",
@ -2008,18 +2027,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -2037,6 +2044,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -2833,15 +2841,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -3288,31 +3287,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/formik": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz",
"integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==",
"funding": [
{
"type": "individual",
"url": "https://opencollective.com/formik"
}
],
"license": "Apache-2.0",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.1",
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3751,12 +3725,6 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
"license": "MIT"
},
"node_modules/lodash.clamp": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/lodash.clamp/-/lodash.clamp-4.0.3.tgz",
@ -4334,11 +4302,21 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
"license": "MIT"
"node_modules/react-hook-form": {
"version": "7.70.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
@ -4923,12 +4901,6 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View file

@ -23,11 +23,12 @@
"@mantine/form": "~8.0.1",
"@mantine/hooks": "~8.0.1",
"@mantine/notifications": "~8.0.1",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.4",
"dayjs": "^1.11.13",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"react-hook-form": "^7.70.0",
"lucide-react": "^0.511.0",
"mpegts.js": "^1.8.0",
"react": "^19.1.0",

View file

@ -336,6 +336,15 @@ export default class API {
delete channelData.channel_number;
}
// Add channel profile IDs based on current selection
const selectedProfileId = useChannelsStore.getState().selectedProfileId;
if (selectedProfileId && selectedProfileId !== '0') {
// Specific profile selected - add only to that profile
channelData.channel_profile_ids = [parseInt(selectedProfileId)];
}
// If selectedProfileId is '0' or not set, don't include channel_profile_ids
// which will trigger the backend's default behavior of adding to all profiles
if (channel.logo_file) {
// Must send FormData for file upload
body = new FormData();

View file

@ -0,0 +1,85 @@
import {
Badge,
Box,
Card,
CardSection,
Group,
Image,
Stack,
Text,
} from '@mantine/core';
import {Calendar, Play, Star} from "lucide-react";
import React from "react";
const SeriesCard = ({ series, onClick }) => {
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
onClick={() => onClick(series)}
>
<CardSection>
<Box pos="relative" h={300}>
{series.logo?.url ? (
<Image
src={series.logo.url}
height={300}
alt={series.name}
fit="contain"
/>
) : (
<Box
style={{
backgroundColor: '#404040',
alignItems: 'center',
justifyContent: 'center',
}}
h={300}
display="flex"
>
<Play size={48} color="#666" />
</Box>
)}
{/* Add Series badge in the same position as Movie badge */}
<Badge pos="absolute" bottom={8} left={8} color="purple">
Series
</Badge>
</Box>
</CardSection>
<Stack spacing={8} mt="md">
<Text weight={500}>{series.name}</Text>
<Group spacing={16}>
{series.year && (
<Group spacing={4}>
<Calendar size={14} color="#666" />
<Text size="xs" c="dimmed">
{series.year}
</Text>
</Group>
)}
{series.rating && (
<Group spacing={4}>
<Star size={14} color="#666" />
<Text size="xs" c="dimmed">
{series.rating}
</Text>
</Group>
)}
</Group>
{series.genre && (
<Text size="xs" c="dimmed" lineClamp={1}>
{series.genre}
</Text>
)}
</Stack>
</Card>
);
};
export default SeriesCard;

View file

@ -0,0 +1,590 @@
import { useLocation } from 'react-router-dom';
import React, { useEffect, useMemo, useState } from 'react';
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
import usePlaylistsStore from '../../store/playlists.jsx';
import useSettingsStore from '../../store/settings.jsx';
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Select, Stack, Text, Tooltip } from '@mantine/core';
import { Gauge, HardDriveDownload, HardDriveUpload, SquareX, Timer, Users, Video } from 'lucide-react';
import { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
import { TableHelper } from '../../helpers/index.jsx';
import logo from '../../images/logo.png';
import { formatBytes, formatSpeed } from '../../utils/networkUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import {
connectedAccessor,
durationAccessor,
getBufferingSpeedThreshold,
getChannelStreams,
getLogoUrl,
getM3uAccountsMap,
getMatchingStreamByUrl,
getSelectedStream,
getStartDate,
getStreamOptions,
getStreamsByIds,
switchStream,
} from '../../utils/cards/StreamConnectionCardUtils.js';
// Create a separate component for each channel card to properly handle the hook
const StreamConnectionCard = ({
channel,
clients,
stopClient,
stopChannel,
logos,
channelsByUUID,
}) => {
const location = useLocation();
const [availableStreams, setAvailableStreams] = useState([]);
const [isLoadingStreams, setIsLoadingStreams] = useState(false);
const [activeStreamId, setActiveStreamId] = useState(null);
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
const [data, setData] = useState([]);
const [previewedStream, setPreviewedStream] = useState(null);
// Get M3U account data from the playlists store
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
// Get settings for speed threshold
const settings = useSettingsStore((s) => s.settings);
// Get Date-format from localStorage
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
const [tableSize] = useLocalStorage('table-size', 'default');
// Create a map of M3U account IDs to names for quick lookup
const m3uAccountsMap = useMemo(() => {
return getM3uAccountsMap(m3uAccounts);
}, [m3uAccounts]);
// Update M3U profile information when channel data changes
useEffect(() => {
// If the channel data includes M3U profile information, update our state
if (channel.m3u_profile || channel.m3u_profile_name) {
setCurrentM3UProfile({
name:
channel.m3u_profile?.name ||
channel.m3u_profile_name ||
'Default M3U',
});
}
}, [channel.m3u_profile, channel.m3u_profile_name, channel.stream_id]);
// Fetch available streams for this channel
useEffect(() => {
const fetchStreams = async () => {
setIsLoadingStreams(true);
try {
// Get channel ID from UUID
const channelId = channelsByUUID[channel.channel_id];
if (channelId) {
const streamData = await getChannelStreams(channelId);
// Use streams in the order returned by the API without sorting
setAvailableStreams(streamData);
// If we have a channel URL, try to find the matching stream
if (channel.url && streamData.length > 0) {
// Try to find matching stream based on URL
const matchingStream = getMatchingStreamByUrl(streamData, channel.url);
if (matchingStream) {
setActiveStreamId(matchingStream.id.toString());
// If the stream has M3U profile info, save it
if (matchingStream.m3u_profile) {
setCurrentM3UProfile(matchingStream.m3u_profile);
}
}
}
}
} catch (error) {
console.error('Error fetching streams:', error);
} finally {
setIsLoadingStreams(false);
}
};
fetchStreams();
}, [channel.channel_id, channel.url, channelsByUUID]);
useEffect(() => {
setData(
clients
.filter((client) => client.channel.channel_id === channel.channel_id)
.map((client) => ({
id: client.client_id,
...client,
}))
);
}, [clients, channel.channel_id]);
const renderHeaderCell = (header) => {
switch (header.id) {
default:
return (
<Group>
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
</Group>
);
}
};
const renderBodyCell = ({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<Box sx={{ justifyContent: 'right' }}>
<Center>
<Tooltip label="Disconnect client">
<ActionIcon
size="sm"
variant="transparent"
color="red.9"
onClick={() =>
stopClient(
row.original.channel.uuid,
row.original.client_id
)
}
>
<SquareX size="18" />
</ActionIcon>
</Tooltip>
</Center>
</Box>
);
}
};
const checkStreamsAfterChange = (streamId) => {
return async () => {
try {
const channelId = channelsByUUID[channel.channel_id];
if (channelId) {
const updatedStreamData = await getChannelStreams(channelId);
console.log('Channel streams after switch:', updatedStreamData);
// Update current stream information with fresh data
const updatedStream = getSelectedStream(updatedStreamData, streamId);
if (updatedStream?.m3u_profile) {
setCurrentM3UProfile(updatedStream.m3u_profile);
}
}
} catch (error) {
console.error('Error checking streams after switch:', error);
}
};
}
// Handle stream switching
const handleStreamChange = async (streamId) => {
try {
console.log('Switching to stream ID:', streamId);
// Find the selected stream in availableStreams for debugging
const selectedStream = getSelectedStream(availableStreams, streamId);
console.log('Selected stream details:', selectedStream);
// Make sure we're passing the correct ID to the API
const response = await switchStream(channel, streamId);
console.log('Stream switch API response:', response);
// Update the local active stream ID immediately
setActiveStreamId(streamId);
// Update M3U profile information if available in the response
if (response?.m3u_profile) {
setCurrentM3UProfile(response.m3u_profile);
} else if (selectedStream && selectedStream.m3u_profile) {
// Fallback to the profile from the selected stream
setCurrentM3UProfile(selectedStream.m3u_profile);
}
// Show detailed notification with stream name
showNotification({
title: 'Stream switching',
message: `Switching to "${selectedStream?.name}" for ${channel.name}`,
color: 'blue.5',
});
// After a short delay, fetch streams again to confirm the switch
setTimeout(checkStreamsAfterChange(streamId), 2000);
} catch (error) {
console.error('Stream switch error:', error);
showNotification({
title: 'Error switching stream',
message: error.toString(),
color: 'red.5',
});
}
};
const clientsColumns = useMemo(
() => [
{
id: 'expand',
size: 20,
},
{
header: 'IP Address',
accessorKey: 'ip_address',
},
// Updated Connected column with tooltip
{
id: 'connected',
header: 'Connected',
accessorFn: connectedAccessor(dateFormat),
cell: ({ cell }) => (
<Tooltip
label={
cell.getValue() !== 'Unknown'
? `Connected at ${cell.getValue()}`
: 'Unknown connection time'
}
>
<Text size="xs">{cell.getValue()}</Text>
</Tooltip>
),
},
// Update Duration column with tooltip showing exact seconds
{
id: 'duration',
header: 'Duration',
accessorFn: durationAccessor(),
cell: ({ cell, row }) => {
const exactDuration =
row.original.connected_since || row.original.connection_duration;
return (
<Tooltip
label={
exactDuration
? `${exactDuration.toFixed(1)} seconds`
: 'Unknown duration'
}
>
<Text size="xs">{cell.getValue()}</Text>
</Tooltip>
);
},
},
{
id: 'actions',
header: 'Actions',
size: tableSize == 'compact' ? 75 : 100,
},
],
[]
);
const channelClientsTable = useTable({
...TableHelper.defaultProperties,
columns: clientsColumns,
data,
allRowIds: data.map((client) => client.id),
tableCellProps: () => ({
padding: 4,
borderColor: '#444',
color: '#E0E0E0',
fontSize: '0.85rem',
}),
headerCellRenderFns: {
ip_address: renderHeaderCell,
connected: renderHeaderCell,
duration: renderHeaderCell,
actions: renderHeaderCell,
},
bodyCellRenderFns: {
actions: renderBodyCell,
},
getExpandedRowHeight: (row) => {
return 20 + 28 * row.original.streams.length;
},
expandedRowRenderer: ({ row }) => {
return (
<Box p="xs">
<Group spacing="xs" align="flex-start">
<Text size="xs" fw={500} color="dimmed">
User Agent:
</Text>
<Text size="xs">{row.original.user_agent || 'Unknown'}</Text>
</Group>
</Box>
);
},
mantineExpandButtonProps: ({ row, table }) => ({
size: 'xs',
style: {
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
},
}),
displayColumnDefOptions: {
'mrt-row-expand': {
size: 15,
header: '',
},
'mrt-row-actions': {
size: 74,
},
},
});
// Get logo URL from the logos object if available
const logoUrl = getLogoUrl(channel.logo_id , logos, previewedStream);
useEffect(() => {
let isMounted = true;
// Only fetch if we have a stream_id and NO channel.name
if (!channel.name && channel.stream_id) {
getStreamsByIds(channel.stream_id).then((streams) => {
if (isMounted && streams && streams.length > 0) {
setPreviewedStream(streams[0]);
}
});
}
return () => {
isMounted = false;
};
}, [channel.name, channel.stream_id]);
const channelName =
channel.name || previewedStream?.name || 'Unnamed Channel';
const uptime = channel.uptime || 0;
const bitrates = channel.bitrates || [];
const totalBytes = channel.total_bytes || 0;
const clientCount = channel.client_count || 0;
const avgBitrate = channel.avg_bitrate || '0 Kbps';
const streamProfileName = channel.stream_profile?.name || 'Unknown Profile';
// Use currentM3UProfile if available, otherwise fall back to channel data
const m3uProfileName =
currentM3UProfile?.name ||
channel.m3u_profile?.name ||
channel.m3u_profile_name ||
'Unknown M3U Profile';
// Create select options for available streams
const streamOptions = getStreamOptions(availableStreams, m3uAccountsMap);
if (location.pathname !== '/stats') {
return <></>;
}
// Safety check - if channel doesn't have required data, don't render
if (!channel || !channel.channel_id) {
return null;
}
return (
<Card
key={channel.channel_id}
shadow="sm"
padding="md"
radius="md"
withBorder
style={{
backgroundColor: '#27272A',
}}
color='#fff'
maw={700}
w={'100%'}
>
<Stack pos='relative' >
<Group justify="space-between">
<Box
style={{
alignItems: 'center',
justifyContent: 'center',
}}
w={100}
h={50}
display='flex'
>
<img
src={logoUrl || logo}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
alt="channel logo"
/>
</Box>
<Group>
<Box>
<Tooltip label={getStartDate(uptime)}>
<Center>
<Timer pr={5} />
{toFriendlyDuration(uptime, 'seconds')}
</Center>
</Tooltip>
</Box>
<Center>
<Tooltip label="Stop Channel">
<ActionIcon
variant="transparent"
color="red.9"
onClick={() => stopChannel(channel.channel_id)}
>
<SquareX size="24" />
</ActionIcon>
</Tooltip>
</Center>
</Group>
</Group>
<Flex justify="space-between" align="center">
<Group>
<Text fw={500}>{channelName}</Text>
</Group>
<Tooltip label="Active Stream Profile">
<Group gap={5}>
<Video size="18" />
{streamProfileName}
</Group>
</Tooltip>
</Flex>
{/* Display M3U profile information */}
<Flex justify="flex-end" align="center" mt={-8}>
<Group gap={5}>
<HardDriveUpload size="18" />
<Tooltip label="Current M3U Profile">
<Text size="xs">{m3uProfileName}</Text>
</Tooltip>
</Group>
</Flex>
{/* Add stream selection dropdown */}
{availableStreams.length > 0 && (
<Tooltip label="Switch to another stream source">
<Select
size="xs"
label="Active Stream"
placeholder={
isLoadingStreams ? 'Loading streams...' : 'Select stream'
}
data={streamOptions}
value={activeStreamId || channel.stream_id?.toString() || null}
onChange={handleStreamChange}
disabled={isLoadingStreams}
mt={8}
/>
</Tooltip>
)}
{/* Add stream information badges */}
<Group gap="xs" mt="xs">
{channel.resolution && (
<Tooltip label="Video resolution">
<Badge size="sm" variant="light" color="red">
{channel.resolution}
</Badge>
</Tooltip>
)}
{channel.source_fps && (
<Tooltip label="Source frames per second">
<Badge size="sm" variant="light" color="orange">
{channel.source_fps} FPS
</Badge>
</Tooltip>
)}
{channel.video_codec && (
<Tooltip label="Video codec">
<Badge size="sm" variant="light" color="blue">
{channel.video_codec.toUpperCase()}
</Badge>
</Tooltip>
)}
{channel.audio_codec && (
<Tooltip label="Audio codec">
<Badge size="sm" variant="light" color="pink">
{channel.audio_codec.toUpperCase()}
</Badge>
</Tooltip>
)}
{channel.audio_channels && (
<Tooltip label="Audio channel configuration">
<Badge size="sm" variant="light" color="pink">
{channel.audio_channels}
</Badge>
</Tooltip>
)}
{channel.stream_type && (
<Tooltip label="Stream type">
<Badge size="sm" variant="light" color="cyan">
{channel.stream_type.toUpperCase()}
</Badge>
</Tooltip>
)}
{channel.ffmpeg_speed && (
<Tooltip
label={`Current Speed: ${parseFloat(channel.ffmpeg_speed).toFixed(2)}x`}
>
<Badge
size="sm"
variant="light"
color={
parseFloat(channel.ffmpeg_speed) >=
getBufferingSpeedThreshold(settings['proxy-settings'])
? 'green'
: 'red'
}
>
{parseFloat(channel.ffmpeg_speed).toFixed(2)}x
</Badge>
</Tooltip>
)}
</Group>
<Group justify="space-between">
<Group gap={4}>
<Tooltip
label={`Current bitrate: ${formatSpeed(bitrates.at(-1) || 0)}`}
>
<Group gap={4} style={{ cursor: 'help' }}>
<Gauge pr={5} size="22" />
<Text size="sm">{formatSpeed(bitrates.at(-1) || 0)}</Text>
</Group>
</Tooltip>
</Group>
<Tooltip label={`Average bitrate: ${avgBitrate}`}>
<Text size="sm" style={{ cursor: 'help' }}>
Avg: {avgBitrate}
</Text>
</Tooltip>
<Group gap={4}>
<Tooltip label={`Total transferred: ${formatBytes(totalBytes)}`}>
<Group gap={4} style={{ cursor: 'help' }}>
<HardDriveDownload size="18" />
<Text size="sm">{formatBytes(totalBytes)}</Text>
</Group>
</Tooltip>
</Group>
<Group gap={5}>
<Tooltip
label={`${clientCount} active client${clientCount !== 1 ? 's' : ''}`}
>
<Group gap={4} style={{ cursor: 'help' }}>
<Users size="18" />
<Text size="sm">{clientCount}</Text>
</Group>
</Tooltip>
</Group>
</Group>
<CustomTable table={channelClientsTable} />
</Stack>
</Card>
);
};
export default StreamConnectionCard;

View file

@ -0,0 +1,143 @@
import {
ActionIcon,
Badge,
Box,
Card,
CardSection,
Group,
Image,
Stack,
Text,
} from '@mantine/core';
import { Calendar, Clock, Play, Star } from 'lucide-react';
import React from 'react';
import {
formatDuration,
getSeasonLabel,
} from '../../utils/cards/VODCardUtils.js';
const VODCard = ({ vod, onClick }) => {
const isEpisode = vod.type === 'episode';
const getDisplayTitle = () => {
if (isEpisode && vod.series) {
return (
<Stack spacing={4}>
<Text size="sm" c="dimmed">
{vod.series.name}
</Text>
<Text weight={500}>
{getSeasonLabel(vod)} - {vod.name}
</Text>
</Stack>
);
}
return <Text weight={500}>{vod.name}</Text>;
};
const handleCardClick = async () => {
// Just pass the basic vod info to the parent handler
onClick(vod);
};
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
onClick={handleCardClick}
>
<CardSection>
<Box pos="relative" h={300}>
{vod.logo?.url ? (
<Image
src={vod.logo.url}
height={300}
alt={vod.name}
fit="contain"
/>
) : (
<Box
style={{
backgroundColor: '#404040',
alignItems: 'center',
justifyContent: 'center',
}}
h={300}
display="flex"
>
<Play size={48} color="#666" />
</Box>
)}
<ActionIcon
style={{
backgroundColor: 'rgba(0,0,0,0.7)',
}}
pos="absolute"
top={8}
right={8}
onClick={(e) => {
e.stopPropagation();
onClick(vod);
}}
>
<Play size={16} color="white" />
</ActionIcon>
<Badge
pos="absolute"
bottom={8}
left={8}
color={isEpisode ? 'blue' : 'green'}
>
{isEpisode ? 'Episode' : 'Movie'}
</Badge>
</Box>
</CardSection>
<Stack spacing={8} mt="md">
{getDisplayTitle()}
<Group spacing={16}>
{vod.year && (
<Group spacing={4}>
<Calendar size={14} color="#666" />
<Text size="xs" c="dimmed">
{vod.year}
</Text>
</Group>
)}
{vod.duration && (
<Group spacing={4}>
<Clock size={14} color="#666" />
<Text size="xs" c="dimmed">
{formatDuration(vod.duration_secs)}
</Text>
</Group>
)}
{vod.rating && (
<Group spacing={4}>
<Star size={14} color="#666" />
<Text size="xs" c="dimmed">
{vod.rating}
</Text>
</Group>
)}
</Group>
{vod.genre && (
<Text size="xs" c="dimmed" lineClamp={1}>
{vod.genre}
</Text>
)}
</Stack>
</Card>
);
};
export default VODCard;

View file

@ -0,0 +1,422 @@
// Format duration for content length
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
import React, { useCallback, useEffect, useState } from 'react';
import logo from '../../images/logo.png';
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Progress, Stack, Text, Tooltip } from '@mantine/core';
import { convertToSec, fromNow, toFriendlyDuration } from '../../utils/dateTimeUtils.js';
import { ChevronDown, HardDriveUpload, SquareX, Timer, Video } from 'lucide-react';
import {
calculateConnectionDuration,
calculateConnectionStartTime,
calculateProgress,
formatDuration,
formatTime,
getEpisodeDisplayTitle,
getEpisodeSubtitle,
getMovieDisplayTitle,
getMovieSubtitle,
} from '../../utils/cards/VodConnectionCardUtils.js';
const ClientDetails = ({ connection, connectionStartTime }) => {
return (
<Stack
gap="xs"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
p={12}
bdrs={6}
bd={'1px solid rgba(255, 255, 255, 0.08)'}
>
{connection.user_agent &&
connection.user_agent !== 'Unknown' && (
<Group gap={8} align="flex-start">
<Text size="xs" fw={500} c="dimmed" miw={80}>
User Agent:
</Text>
<Text size="xs" ff={'monospace'} flex={1}>
{connection.user_agent.length > 100
? `${connection.user_agent.substring(0, 100)}...`
: connection.user_agent}
</Text>
</Group>
)}
<Group gap={8}>
<Text size="xs" fw={500} c="dimmed" miw={80}>
Client ID:
</Text>
<Text size="xs" ff={'monospace'}>
{connection.client_id || 'Unknown'}
</Text>
</Group>
{connection.connected_at && (
<Group gap={8}>
<Text size="xs" fw={500} c="dimmed" miw={80}>
Connected:
</Text>
<Text size="xs">{connectionStartTime}</Text>
</Group>
)}
{connection.duration && connection.duration > 0 && (
<Group gap={8}>
<Text size="xs" fw={500} c="dimmed" miw={80}>
Watch Duration:
</Text>
<Text size="xs">
{toFriendlyDuration(connection.duration, 'seconds')}
</Text>
</Group>
)}
{/* Seek/Position Information */}
{(connection.last_seek_percentage > 0 ||
connection.last_seek_byte > 0) && (
<>
<Group gap={8}>
<Text size="xs" fw={500} c="dimmed" miw={80}>
Last Seek:
</Text>
<Text size="xs">
{connection.last_seek_percentage?.toFixed(1)}%
{connection.total_content_size > 0 && (
<span style={{ color: 'var(--mantine-color-dimmed)' }}>
{' '}
({Math.round(connection.last_seek_byte / (1024 * 1024))}
MB /{' '}
{Math.round(
connection.total_content_size / (1024 * 1024)
)}
MB)
</span>
)}
</Text>
</Group>
{Number(connection.last_seek_timestamp) > 0 && (
<Group gap={8}>
<Text size="xs" fw={500} c="dimmed" miw={80}>
Seek Time:
</Text>
<Text size="xs">
{fromNow(convertToSec(Number(connection.last_seek_timestamp)))}
</Text>
</Group>
)}
</>
)}
{connection.bytes_sent > 0 && (
<Group gap={8}>
<Text size="xs" fw={500} c="dimmed" miw={80}>
Data Sent:
</Text>
<Text size="xs">
{(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB
</Text>
</Group>
)}
</Stack>
);
}
// Create a VOD Card component similar to ChannelCard
const VodConnectionCard = ({ vodContent, stopVODClient }) => {
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
const [isClientExpanded, setIsClientExpanded] = useState(false);
const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates
// Get metadata from the VOD content
const metadata = vodContent.content_metadata || {};
const contentType = vodContent.content_type;
const isMovie = contentType === 'movie';
const isEpisode = contentType === 'episode';
// Set up timer to update progress every second
useEffect(() => {
const interval = setInterval(() => {
setUpdateTrigger((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// Get the individual connection (since we now separate cards per connection)
const connection =
vodContent.individual_connection ||
(vodContent.connections && vodContent.connections[0]);
// Get poster/logo URL
const posterUrl = metadata.logo_url || logo;
// Get display title
const getDisplayTitle = () => {
if (isMovie) {
return getMovieDisplayTitle(vodContent);
} else if (isEpisode) {
return getEpisodeDisplayTitle(metadata);
}
return vodContent.content_name;
};
// Get subtitle info
const getSubtitle = () => {
if (isMovie) {
return getMovieSubtitle(metadata);
} else if (isEpisode) {
return getEpisodeSubtitle(metadata);
}
return [];
};
// Render subtitle
const renderSubtitle = () => {
const subtitleParts = getSubtitle();
if (subtitleParts.length === 0) return null;
return (
<Text size="sm" c="dimmed">
{subtitleParts.join(' • ')}
</Text>
);
};
// Calculate progress percentage and time
const getProgressInfo = useCallback(() => {
return calculateProgress(connection, metadata.duration_secs);
}, [connection, metadata.duration_secs]);
// Calculate duration for connection
const getConnectionDuration = useCallback((connection) => {
return calculateConnectionDuration(connection);
}, []);
// Get connection start time for tooltip
const getConnectionStartTime = useCallback(
(connection) => {
return calculateConnectionStartTime(connection, dateFormat);
},
[dateFormat]
);
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{
backgroundColor: '#27272A',
}}
color='#FFF'
maw={700}
w={'100%'}
>
<Stack pos='relative' >
{/* Header with poster and basic info */}
<Group justify="space-between">
<Box h={100} display='flex'
style={{
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={posterUrl}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
alt="content poster"
/>
</Box>
<Group>
{connection && (
<Tooltip
label={`Connected at ${getConnectionStartTime(connection)}`}
>
<Center>
<Timer pr={5} />
{getConnectionDuration(connection)}
</Center>
</Tooltip>
)}
{connection && stopVODClient && (
<Center>
<Tooltip label="Stop VOD Connection">
<ActionIcon
variant="transparent"
color="red.9"
onClick={() => stopVODClient(connection.client_id)}
>
<SquareX size="24" />
</ActionIcon>
</Tooltip>
</Center>
)}
</Group>
</Group>
{/* Title and type */}
<Flex justify="space-between" align="center">
<Group>
<Text fw={500}>{getDisplayTitle()}</Text>
</Group>
<Tooltip label="Content Type">
<Group gap={5}>
<Video size="18" />
{isMovie ? 'Movie' : 'TV Episode'}
</Group>
</Tooltip>
</Flex>
{/* Display M3U profile information - matching channel card style */}
{connection &&
connection.m3u_profile &&
(connection.m3u_profile.profile_name ||
connection.m3u_profile.account_name) && (
<Flex justify="flex-end" align="flex-start" mt={-8}>
<Group gap={5} align="flex-start">
<HardDriveUpload size="18" mt={2} />
<Stack gap={0}>
<Tooltip label="M3U Account">
<Text size="xs" fw={500}>
{connection.m3u_profile.account_name || 'Unknown Account'}
</Text>
</Tooltip>
<Tooltip label="M3U Profile">
<Text size="xs" c="dimmed">
{connection.m3u_profile.profile_name || 'Default Profile'}
</Text>
</Tooltip>
</Stack>
</Group>
</Flex>
)}
{/* Subtitle/episode info */}
{getSubtitle().length > 0 && (
<Flex justify="flex-start" align="center" mt={-12}>
{renderSubtitle()}
</Flex>
)}
{/* Content information badges - streamlined to avoid duplication */}
<Group gap="xs" mt={-4}>
{metadata.year && (
<Tooltip label="Release Year">
<Badge size="sm" variant="light" color="orange">
{metadata.year}
</Badge>
</Tooltip>
)}
{metadata.duration_secs && (
<Tooltip label="Content Duration">
<Badge size="sm" variant="light" color="blue">
{formatDuration(metadata.duration_secs)}
</Badge>
</Tooltip>
)}
{metadata.rating && (
<Tooltip label="Critic Rating (out of 10)">
<Badge size="sm" variant="light" color="yellow">
{parseFloat(metadata.rating).toFixed(1)}/10
</Badge>
</Tooltip>
)}
</Group>
{/* Progress bar - show current position in content */}
{connection &&
metadata.duration_secs &&
(() => {
const { totalTime, currentTime, percentage} = getProgressInfo();
return totalTime > 0 ? (
<Stack gap="xs" mt="sm">
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c="dimmed">
Progress
</Text>
<Text size="xs" c="dimmed">
{formatTime(currentTime)} /{' '}
{formatTime(totalTime)}
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="blue"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
<Text size="xs" c="dimmed" ta="center">
{percentage.toFixed(1)}% watched
</Text>
</Stack>
) : null;
})()}
{/* Client information section - collapsible like channel cards */}
{connection && (
<Stack gap="xs" mt="xs">
{/* Client summary header - always visible */}
<Group
justify="space-between"
align="center"
style={{
cursor: 'pointer',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
}}
p={'8px 12px'}
bdrs={6}
bd={'1px solid rgba(255, 255, 255, 0.1)'}
onClick={() => setIsClientExpanded(!isClientExpanded)}
>
<Group gap={8}>
<Text size="sm" fw={500} color="dimmed">
Client:
</Text>
<Text size="sm" ff={'monospace'}>
{connection.client_ip || 'Unknown IP'}
</Text>
</Group>
<Group gap={8}>
<Text size="xs" color="dimmed">
{isClientExpanded ? 'Hide Details' : 'Show Details'}
</Text>
<ChevronDown
size={16}
style={{
transform: isClientExpanded
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}}
/>
</Group>
</Group>
{/* Expanded client details */}
{isClientExpanded && (
<ClientDetails
connection={connection}
connectionStartTime={getConnectionStartTime(connection)} />
)}
</Stack>
)}
</Stack>
</Card>
);
};
export default VodConnectionCard;

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useFormik } from 'formik';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
@ -42,6 +43,11 @@ import useEPGsStore from '../../store/epgs';
import { FixedSizeList as List } from 'react-window';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
const validationSchema = Yup.object({
name: Yup.string().required('Name is required'),
channel_group_id: Yup.string().required('Channel group is required'),
});
const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const theme = useMantineTheme();
@ -100,7 +106,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const handleLogoSuccess = ({ logo }) => {
if (logo && logo.id) {
formik.setFieldValue('logo_id', logo.id);
setValue('logo_id', logo.id);
ensureLogosLoaded(); // Refresh logos
}
setLogoModalOpen(false);
@ -124,7 +130,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
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);
setValue('epg_data_id', response.channel.epg_data_id);
}
notifications.show({
@ -152,7 +158,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
};
const handleSetNameFromEpg = () => {
const epgDataId = formik.values.epg_data_id;
const epgDataId = watch('epg_data_id');
if (!epgDataId) {
notifications.show({
title: 'No EPG Selected',
@ -164,7 +170,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const tvg = tvgsById[epgDataId];
if (tvg && tvg.name) {
formik.setFieldValue('name', tvg.name);
setValue('name', tvg.name);
notifications.show({
title: 'Success',
message: `Channel name set to "${tvg.name}"`,
@ -180,7 +186,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
};
const handleSetLogoFromEpg = async () => {
const epgDataId = formik.values.epg_data_id;
const epgDataId = watch('epg_data_id');
if (!epgDataId) {
notifications.show({
title: 'No EPG Selected',
@ -207,7 +213,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
);
if (matchingLogo) {
formik.setFieldValue('logo_id', matchingLogo.id);
setValue('logo_id', matchingLogo.id);
notifications.show({
title: 'Success',
message: `Logo set to "${matchingLogo.name}"`,
@ -231,7 +237,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
// Create logo by calling the Logo API directly
const newLogo = await API.createLogo(newLogoData);
formik.setFieldValue('logo_id', newLogo.id);
setValue('logo_id', newLogo.id);
notifications.update({
id: 'creating-logo',
@ -264,7 +270,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
};
const handleSetTvgIdFromEpg = () => {
const epgDataId = formik.values.epg_data_id;
const epgDataId = watch('epg_data_id');
if (!epgDataId) {
notifications.show({
title: 'No EPG Selected',
@ -276,7 +282,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const tvg = tvgsById[epgDataId];
if (tvg && tvg.tvg_id) {
formik.setFieldValue('tvg_id', tvg.tvg_id);
setValue('tvg_id', tvg.tvg_id);
notifications.show({
title: 'Success',
message: `TVG-ID set to "${tvg.tvg_id}"`,
@ -291,130 +297,130 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
}
};
const formik = useFormik({
initialValues: {
name: '',
channel_number: '', // Change from 0 to empty string for consistency
channel_group_id:
Object.keys(channelGroups).length > 0
const defaultValues = useMemo(
() => ({
name: channel?.name || '',
channel_number:
channel?.channel_number !== null &&
channel?.channel_number !== undefined
? channel.channel_number
: '',
channel_group_id: channel?.channel_group_id
? `${channel.channel_group_id}`
: Object.keys(channelGroups).length > 0
? Object.keys(channelGroups)[0]
: '',
stream_profile_id: '0',
tvg_id: '',
tvc_guide_stationid: '',
epg_data_id: '',
logo_id: '',
user_level: '0',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
channel_group_id: Yup.string().required('Channel group is required'),
stream_profile_id: channel?.stream_profile_id
? `${channel.stream_profile_id}`
: '0',
tvg_id: channel?.tvg_id || '',
tvc_guide_stationid: channel?.tvc_guide_stationid || '',
epg_data_id: channel?.epg_data_id ?? '',
logo_id: channel?.logo_id ? `${channel.logo_id}` : '',
user_level: `${channel?.user_level ?? '0'}`,
}),
onSubmit: async (values, { setSubmitting }) => {
let response;
[channel, channelGroups]
);
try {
const formattedValues = { ...values };
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm({
defaultValues,
resolver: yupResolver(validationSchema),
});
// Convert empty or "0" stream_profile_id to null for the API
if (
!formattedValues.stream_profile_id ||
formattedValues.stream_profile_id === '0'
) {
formattedValues.stream_profile_id = null;
}
const onSubmit = async (values) => {
let response;
// Ensure tvg_id is properly included (no empty strings)
formattedValues.tvg_id = formattedValues.tvg_id || null;
try {
const formattedValues = { ...values };
// Ensure tvc_guide_stationid is properly included (no empty strings)
formattedValues.tvc_guide_stationid =
formattedValues.tvc_guide_stationid || null;
// Convert empty or "0" stream_profile_id to null for the API
if (
!formattedValues.stream_profile_id ||
formattedValues.stream_profile_id === '0'
) {
formattedValues.stream_profile_id = null;
}
if (channel) {
// If there's an EPG to set, use our enhanced endpoint
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
// Use the special endpoint to set EPG and trigger refresh
const epgResponse = await API.setChannelEPG(
channel.id,
values.epg_data_id
);
// Ensure tvg_id is properly included (no empty strings)
formattedValues.tvg_id = formattedValues.tvg_id || null;
// Remove epg_data_id from values since we've handled it separately
const { epg_data_id, ...otherValues } = formattedValues;
// Ensure tvc_guide_stationid is properly included (no empty strings)
formattedValues.tvc_guide_stationid =
formattedValues.tvc_guide_stationid || null;
// Update other channel fields if needed
if (Object.keys(otherValues).length > 0) {
response = await API.updateChannel({
id: channel.id,
...otherValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} else {
// No EPG change, regular update
if (channel) {
// If there's an EPG to set, use our enhanced endpoint
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
// Use the special endpoint to set EPG and trigger refresh
const epgResponse = await API.setChannelEPG(
channel.id,
values.epg_data_id
);
// Remove epg_data_id from values since we've handled it separately
const { epg_data_id, ...otherValues } = formattedValues;
// Update other channel fields if needed
if (Object.keys(otherValues).length > 0) {
response = await API.updateChannel({
id: channel.id,
...formattedValues,
...otherValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} else {
// New channel creation - use the standard method
response = await API.addChannel({
// No EPG change, regular update
response = await API.updateChannel({
id: channel.id,
...formattedValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} catch (error) {
console.error('Error saving channel:', error);
} else {
// New channel creation - use the standard method
response = await API.addChannel({
...formattedValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} catch (error) {
console.error('Error saving channel:', error);
}
formik.resetForm();
API.requeryChannels();
reset();
API.requeryChannels();
// Refresh channel profiles to update the membership information
useChannelsStore.getState().fetchChannelProfiles();
// Refresh channel profiles to update the membership information
useChannelsStore.getState().fetchChannelProfiles();
setSubmitting(false);
setTvgFilter('');
setLogoFilter('');
onClose();
},
});
setTvgFilter('');
setLogoFilter('');
onClose();
};
useEffect(() => {
if (channel) {
if (channel.epg_data_id) {
const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
setSelectedEPG(epgSource ? `${epgSource.id}` : '');
}
reset(defaultValues);
setChannelStreams(channel?.streams || []);
formik.setValues({
name: channel.name || '',
channel_number:
channel.channel_number !== null ? channel.channel_number : '',
channel_group_id: channel.channel_group_id
? `${channel.channel_group_id}`
: '',
stream_profile_id: channel.stream_profile_id
? `${channel.stream_profile_id}`
: '0',
tvg_id: channel.tvg_id || '',
tvc_guide_stationid: channel.tvc_guide_stationid || '',
epg_data_id: channel.epg_data_id ?? '',
logo_id: channel.logo_id ? `${channel.logo_id}` : '',
user_level: `${channel.user_level}`,
});
setChannelStreams(channel.streams || []);
if (channel?.epg_data_id) {
const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
setSelectedEPG(epgSource ? `${epgSource.id}` : '');
} else {
formik.resetForm();
setSelectedEPG('');
}
if (!channel) {
setTvgFilter('');
setLogoFilter('');
setChannelStreams([]); // Ensure streams are cleared when adding a new channel
}
}, [channel, tvgsById, channelGroups]);
}, [defaultValues, channel, reset, epgs, tvgsById]);
// Memoize logo options to prevent infinite re-renders during background loading
const logoOptions = useMemo(() => {
@ -431,10 +437,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
// If a new group was created and returned, update the form with it
if (newGroup && newGroup.id) {
// Preserve all current form values while updating just the channel_group_id
formik.setValues({
...formik.values,
channel_group_id: `${newGroup.id}`,
});
setValue('channel_group_id', `${newGroup.id}`);
}
};
@ -472,7 +475,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
}
styles={{ content: { '--mantine-color-body': '#27272A' } }}
>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={handleSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
@ -481,7 +484,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
label={
<Group gap="xs">
<span>Channel Name</span>
{formik.values.epg_data_id && (
{watch('epg_data_id') && (
<Button
size="xs"
variant="transparent"
@ -495,9 +498,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
)}
</Group>
}
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name ? formik.touched.name : ''}
{...register('name')}
error={errors.name?.message}
size="xs"
style={{ flex: 1 }}
/>
@ -516,8 +518,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
label="Channel Group"
readOnly
value={
channelGroups[formik.values.channel_group_id]
? channelGroups[formik.values.channel_group_id].name
channelGroups[watch('channel_group_id')]
? channelGroups[watch('channel_group_id')].name
: ''
}
onClick={() => setGroupPopoverOpened(true)}
@ -557,7 +559,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
>
<UnstyledButton
onClick={() => {
formik.setFieldValue(
setValue(
'channel_group_id',
filteredGroups[index].id
);
@ -587,16 +589,12 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
value={watch('channel_group_id')}
searchable
onChange={(value) => {
formik.setFieldValue('channel_group_id', value); // Update Formik's state with the new value
setValue('channel_group_id', value);
}}
error={
formik.errors.channel_group_id
? formik.touched.channel_group_id
: ''
}
error={errors.channel_group_id?.message}
data={Object.values(channelGroups).map((option, index) => ({
value: `${option.id}`,
label: option.name,
@ -622,15 +620,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
value={formik.values.stream_profile_id}
value={watch('stream_profile_id')}
onChange={(value) => {
formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value
setValue('stream_profile_id', value);
}}
error={
formik.errors.stream_profile_id
? formik.touched.stream_profile_id
: ''
}
error={errors.stream_profile_id?.message}
data={[{ value: '0', label: '(use default)' }].concat(
streamProfiles.map((option) => ({
value: `${option.id}`,
@ -648,13 +642,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
value: `${value}`,
};
})}
value={formik.values.user_level}
value={watch('user_level')}
onChange={(value) => {
formik.setFieldValue('user_level', value);
setValue('user_level', value);
}}
error={
formik.errors.user_level ? formik.touched.user_level : ''
}
error={errors.user_level?.message}
/>
</Stack>
@ -684,7 +676,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
label={
<Group gap="xs">
<span>Logo</span>
{formik.values.epg_data_id && (
{watch('epg_data_id') && (
<Button
size="xs"
variant="transparent"
@ -699,9 +691,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
</Group>
}
readOnly
value={
channelLogos[formik.values.logo_id]?.name || 'Default'
}
value={channelLogos[watch('logo_id')]?.name || 'Default'}
onClick={() => {
console.log(
'Logo input clicked, setting popover opened to true'
@ -756,10 +746,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
borderRadius: '4px',
}}
onClick={() => {
formik.setFieldValue(
'logo_id',
filteredLogos[index].id
);
setValue('logo_id', filteredLogos[index].id);
setLogoPopoverOpened(false);
}}
onMouseEnter={(e) => {
@ -810,7 +797,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
<Stack gap="xs" align="center">
<LazyLogo
logoId={formik.values.logo_id}
logoId={watch('logo_id')}
alt="channel logo"
style={{ height: 40 }}
/>
@ -833,19 +820,12 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
id="channel_number"
name="channel_number"
label="Channel # (blank to auto-assign)"
value={formik.values.channel_number}
onChange={(value) =>
formik.setFieldValue('channel_number', value)
}
error={
formik.errors.channel_number
? formik.touched.channel_number
: ''
}
value={watch('channel_number')}
onChange={(value) => setValue('channel_number', value)}
error={errors.channel_number?.message}
size="xs"
step={0.1} // Add step prop to allow decimal inputs
precision={1} // Specify decimal precision
removeTrailingZeros // Optional: remove trailing zeros for cleaner display
/>
<TextInput
@ -854,7 +834,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
label={
<Group gap="xs">
<span>TVG-ID</span>
{formik.values.epg_data_id && (
{watch('epg_data_id') && (
<Button
size="xs"
variant="transparent"
@ -868,9 +848,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
)}
</Group>
}
value={formik.values.tvg_id}
onChange={formik.handleChange}
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
{...register('tvg_id')}
error={errors.tvg_id?.message}
size="xs"
/>
@ -878,13 +857,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
id="tvc_guide_stationid"
name="tvc_guide_stationid"
label="Gracenote StationId"
value={formik.values.tvc_guide_stationid}
onChange={formik.handleChange}
error={
formik.errors.tvc_guide_stationid
? formik.touched.tvc_guide_stationid
: ''
}
{...register('tvc_guide_stationid')}
error={errors.tvc_guide_stationid?.message}
size="xs"
/>
@ -904,9 +878,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
<Button
size="xs"
variant="transparent"
onClick={() =>
formik.setFieldValue('epg_data_id', null)
}
onClick={() => setValue('epg_data_id', null)}
>
Use Dummy
</Button>
@ -933,7 +905,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
}
readOnly
value={(() => {
const tvg = tvgsById[formik.values.epg_data_id];
const tvg = tvgsById[watch('epg_data_id')];
const epgSource = tvg && epgs[tvg.epg_source];
const tvgLabel = tvg ? tvg.name || tvg.id : '';
if (epgSource && tvgLabel) {
@ -953,7 +925,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
color="white"
onClick={(e) => {
e.stopPropagation();
formik.setFieldValue('epg_data_id', null);
setValue('epg_data_id', null);
}}
title="Create new group"
size="small"
@ -1012,12 +984,9 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
size="xs"
onClick={() => {
if (filteredTvgs[index].id == '0') {
formik.setFieldValue('epg_data_id', null);
setValue('epg_data_id', null);
} else {
formik.setFieldValue(
'epg_data_id',
filteredTvgs[index].id
);
setValue('epg_data_id', filteredTvgs[index].id);
// Also update selectedEPG to match the EPG source of the selected tvg
if (filteredTvgs[index].epg_source) {
setSelectedEPG(
@ -1047,11 +1016,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
<Button
type="submit"
variant="default"
disabled={formik.isSubmitting}
loading={formik.isSubmitting}
disabled={isSubmitting}
loading={isSubmitting}
loaderProps={{ type: 'dots' }}
>
{formik.isSubmitting ? 'Saving...' : 'Submit'}
{isSubmitting ? 'Saving...' : 'Submit'}
</Button>
</Flex>
</form>

View file

@ -263,25 +263,42 @@ const LiveGroupFilter = ({
}}
>
{/* Group Enable/Disable Button */}
<Button
color={group.enabled ? 'green' : 'gray'}
variant="filled"
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="md"
size="xs"
leftSection={
group.enabled ? (
<CircleCheck size={14} />
) : (
<CircleX size={14} />
)
<Tooltip
label={
group.enabled && group.is_stale
? 'This group was not seen in the last M3U refresh and will be deleted after the retention period expires'
: ''
}
fullWidth
disabled={!group.enabled || !group.is_stale}
multiline
w={220}
>
<Text size="xs" truncate>
{group.name}
</Text>
</Button>
<Button
color={
group.enabled
? group.is_stale
? 'orange'
: 'green'
: 'gray'
}
variant="filled"
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="md"
size="xs"
leftSection={
group.enabled ? (
<CircleCheck size={14} />
) : (
<CircleX size={14} />
)
}
fullWidth
>
<Text size="xs" truncate>
{group.name}
</Text>
</Button>
</Tooltip>
{/* Auto Sync Controls */}
<Stack spacing="xs" style={{ '--stack-gap': '4px' }}>

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import React, { useState, useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import {
Modal,
@ -18,143 +19,148 @@ import { Upload, FileImage, X } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import API from '../../api';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string()
.required('URL is required')
.test(
'valid-url-or-path',
'Must be a valid URL or local file path',
(value) => {
if (!value) return false;
// Allow local file paths starting with /data/logos/
if (value.startsWith('/data/logos/')) return true;
// Allow valid URLs
try {
new URL(value);
return true;
} catch {
return false;
}
}
),
});
const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
const [logoPreview, setLogoPreview] = useState(null);
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); // Store selected file
const formik = useFormik({
initialValues: {
name: '',
url: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string()
.required('URL is required')
.test(
'valid-url-or-path',
'Must be a valid URL or local file path',
(value) => {
if (!value) return false;
// Allow local file paths starting with /data/logos/
if (value.startsWith('/data/logos/')) return true;
// Allow valid URLs
try {
new URL(value);
return true;
} catch {
return false;
}
}
),
const defaultValues = useMemo(
() => ({
name: logo?.name || '',
url: logo?.url || '',
}),
onSubmit: async (values, { setSubmitting }) => {
try {
setUploading(true);
let uploadResponse = null; // Store upload response for later use
[logo]
);
// If we have a selected file, upload it first
if (selectedFile) {
try {
uploadResponse = await API.uploadLogo(selectedFile, values.name);
// Use the uploaded file data instead of form values
values.name = uploadResponse.name;
values.url = uploadResponse.url;
} catch (uploadError) {
let errorMessage = 'Failed to upload logo file';
if (
uploadError.code === 'NETWORK_ERROR' ||
uploadError.message?.includes('timeout')
) {
errorMessage = 'Upload timed out. Please try again.';
} else if (uploadError.status === 413) {
errorMessage = 'File too large. Please choose a smaller file.';
} else if (uploadError.body?.error) {
errorMessage = uploadError.body.error;
}
notifications.show({
title: 'Upload Error',
message: errorMessage,
color: 'red',
});
return; // Don't proceed with creation if upload fails
}
}
// Now create or update the logo with the final values
// Only proceed if we don't already have a logo from file upload
if (logo) {
const updatedLogo = await API.updateLogo(logo.id, values);
notifications.show({
title: 'Success',
message: 'Logo updated successfully',
color: 'green',
});
onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
} else if (!selectedFile) {
// Only create a new logo entry if we're not uploading a file
// (file upload already created the logo entry)
const newLogo = await API.createLogo(values);
notifications.show({
title: 'Success',
message: 'Logo created successfully',
color: 'green',
});
onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
} else {
// File was uploaded and logo was already created
notifications.show({
title: 'Success',
message: 'Logo uploaded successfully',
color: 'green',
});
onSuccess?.({ type: 'create', logo: uploadResponse });
}
onClose();
} catch (error) {
let errorMessage = logo
? 'Failed to update logo'
: 'Failed to create logo';
// Handle specific timeout errors
if (
error.code === 'NETWORK_ERROR' ||
error.message?.includes('timeout')
) {
errorMessage = 'Request timed out. Please try again.';
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
} finally {
setSubmitting(false);
setUploading(false);
}
},
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
});
useEffect(() => {
if (logo) {
formik.setValues({
name: logo.name || '',
url: logo.url || '',
const onSubmit = async (values) => {
try {
setUploading(true);
let uploadResponse = null; // Store upload response for later use
// If we have a selected file, upload it first
if (selectedFile) {
try {
uploadResponse = await API.uploadLogo(selectedFile, values.name);
// Use the uploaded file data instead of form values
values.name = uploadResponse.name;
values.url = uploadResponse.url;
} catch (uploadError) {
let errorMessage = 'Failed to upload logo file';
if (
uploadError.code === 'NETWORK_ERROR' ||
uploadError.message?.includes('timeout')
) {
errorMessage = 'Upload timed out. Please try again.';
} else if (uploadError.status === 413) {
errorMessage = 'File too large. Please choose a smaller file.';
} else if (uploadError.body?.error) {
errorMessage = uploadError.body.error;
}
notifications.show({
title: 'Upload Error',
message: errorMessage,
color: 'red',
});
return; // Don't proceed with creation if upload fails
}
}
// Now create or update the logo with the final values
// Only proceed if we don't already have a logo from file upload
if (logo) {
const updatedLogo = await API.updateLogo(logo.id, values);
notifications.show({
title: 'Success',
message: 'Logo updated successfully',
color: 'green',
});
onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
} else if (!selectedFile) {
// Only create a new logo entry if we're not uploading a file
// (file upload already created the logo entry)
const newLogo = await API.createLogo(values);
notifications.show({
title: 'Success',
message: 'Logo created successfully',
color: 'green',
});
onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
} else {
// File was uploaded and logo was already created
notifications.show({
title: 'Success',
message: 'Logo uploaded successfully',
color: 'green',
});
onSuccess?.({ type: 'create', logo: uploadResponse });
}
onClose();
} catch (error) {
let errorMessage = logo
? 'Failed to update logo'
: 'Failed to create logo';
// Handle specific timeout errors
if (
error.code === 'NETWORK_ERROR' ||
error.message?.includes('timeout')
) {
errorMessage = 'Request timed out. Please try again.';
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
setLogoPreview(logo.cache_url);
} else {
formik.resetForm();
setLogoPreview(null);
} finally {
setUploading(false);
}
// Clear any selected file when logo changes
};
useEffect(() => {
reset(defaultValues);
setLogoPreview(logo?.cache_url || null);
setSelectedFile(null);
}, [logo, isOpen]);
}, [defaultValues, logo, reset]);
const handleFileSelect = (files) => {
if (files.length === 0) return;
@ -180,18 +186,19 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
setLogoPreview(previewUrl);
// Auto-fill the name field if empty
if (!formik.values.name) {
const currentName = watch('name');
if (!currentName) {
const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
formik.setFieldValue('name', nameWithoutExtension);
setValue('name', nameWithoutExtension);
}
// Set a placeholder URL (will be replaced after upload)
formik.setFieldValue('url', 'file://pending-upload');
setValue('url', 'file://pending-upload');
};
const handleUrlChange = (event) => {
const url = event.target.value;
formik.setFieldValue('url', url);
setValue('url', url);
// Clear any selected file when manually entering URL
if (selectedFile) {
@ -219,7 +226,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '');
if (nameWithoutExtension) {
formik.setFieldValue('name', nameWithoutExtension);
setValue('name', nameWithoutExtension);
}
} catch (error) {
// If the URL is invalid, do nothing.
@ -244,7 +251,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
title={logo ? 'Edit Logo' : 'Add Logo'}
size="md"
>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack spacing="md">
{/* Logo Preview */}
{logoPreview && (
@ -338,18 +345,18 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
<TextInput
label="Logo URL"
placeholder="https://example.com/logo.png"
{...formik.getFieldProps('url')}
{...register('url')}
onChange={handleUrlChange}
onBlur={handleUrlBlur}
error={formik.touched.url && formik.errors.url}
error={errors.url?.message}
disabled={!!selectedFile} // Disable when file is selected
/>
<TextInput
label="Name"
placeholder="Enter logo name"
{...formik.getFieldProps('name')}
error={formik.touched.name && formik.errors.name}
{...register('name')}
error={errors.name?.message}
/>
{selectedFile && (
@ -363,7 +370,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={formik.isSubmitting || uploading}>
<Button type="submit" loading={isSubmitting || uploading}>
{logo ? 'Update' : 'Create'}
</Button>
</Group>

View file

@ -1,6 +1,5 @@
// Modal.js
import React, { useState, useEffect, forwardRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import M3UProfiles from './M3UProfiles';

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import React, { useState, useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import {
@ -31,6 +32,89 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [sampleInput, setSampleInput] = useState('');
const isDefaultProfile = profile?.is_default;
const defaultValues = useMemo(
() => ({
name: profile?.name || '',
max_streams: profile?.max_streams || 0,
search_pattern: profile?.search_pattern || '',
replace_pattern: profile?.replace_pattern || '',
notes: profile?.custom_properties?.notes || '',
}),
[profile]
);
const schema = Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
then: (schema) => schema.required('Search pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
replace_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
then: (schema) => schema.required('Replace pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
notes: Yup.string(), // Optional field
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
});
const onSubmit = async (values) => {
console.log('submiting');
// For default profiles, only send name and custom_properties (notes)
let submitValues;
if (isDefaultProfile) {
submitValues = {
name: values.name,
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
} else {
// For regular profiles, send all fields
submitValues = {
name: values.name,
max_streams: values.max_streams,
search_pattern: values.search_pattern,
replace_pattern: values.replace_pattern,
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
}
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...submitValues,
});
} else {
await API.addM3UProfile(m3u.id, submitValues);
}
reset();
// Reset local state to sync with form reset
setSearchPattern('');
setReplacePattern('');
onClose();
};
useEffect(() => {
async function fetchStreamUrl() {
try {
@ -79,99 +163,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
}, [searchPattern, replacePattern]);
const onSearchPatternUpdate = (e) => {
formik.handleChange(e);
setSearchPattern(e.target.value);
const value = e.target.value;
setSearchPattern(value);
setValue('search_pattern', value);
};
const onReplacePatternUpdate = (e) => {
formik.handleChange(e);
setReplacePattern(e.target.value);
const value = e.target.value;
setReplacePattern(value);
setValue('replace_pattern', value);
};
const formik = useFormik({
initialValues: {
name: '',
max_streams: 0,
search_pattern: '',
replace_pattern: '',
notes: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
then: (schema) => schema.required('Search pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
replace_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
then: (schema) => schema.required('Replace pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
notes: Yup.string(), // Optional field
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log('submiting');
// For default profiles, only send name and custom_properties (notes)
let submitValues;
if (isDefaultProfile) {
submitValues = {
name: values.name,
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
} else {
// For regular profiles, send all fields
submitValues = {
name: values.name,
max_streams: values.max_streams,
search_pattern: values.search_pattern,
replace_pattern: values.replace_pattern,
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
}
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...submitValues,
});
} else {
await API.addM3UProfile(m3u.id, submitValues);
}
resetForm();
// Reset local state to sync with formik reset
setSearchPattern('');
setReplacePattern('');
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
setSearchPattern(profile.search_pattern);
setReplacePattern(profile.replace_pattern);
formik.setValues({
name: profile.name,
max_streams: profile.max_streams,
search_pattern: profile.search_pattern,
replace_pattern: profile.replace_pattern,
notes: profile.custom_properties?.notes || '',
});
} else {
formik.resetForm();
}
}, [profile]); // eslint-disable-line react-hooks/exhaustive-deps
reset(defaultValues);
setSearchPattern(profile?.search_pattern || '');
setReplacePattern(profile?.replace_pattern || '');
}, [defaultValues, profile, reset]);
const handleSampleInputChange = (e) => {
setSampleInput(e.target.value);
@ -212,27 +219,21 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
}
size="lg"
>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name ? formik.touched.name : ''}
{...register('name')}
error={errors.name?.message}
/>
{/* Only show max streams field for non-default profiles */}
{!isDefaultProfile && (
<NumberInput
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={(value) =>
formik.setFieldValue('max_streams', value || 0)
}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
{...register('max_streams')}
value={watch('max_streams')}
onChange={(value) => setValue('max_streams', value || 0)}
error={errors.max_streams?.message}
min={0}
placeholder="0 = unlimited"
/>
@ -242,40 +243,25 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
{!isDefaultProfile && (
<>
<TextInput
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={
formik.errors.search_pattern
? formik.touched.search_pattern
: ''
}
error={errors.search_pattern?.message}
/>
<TextInput
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={
formik.errors.replace_pattern
? formik.touched.replace_pattern
: ''
}
error={errors.replace_pattern?.message}
/>
</>
)}
<Textarea
id="notes"
name="notes"
label="Notes"
placeholder="Add any notes or comments about this profile..."
value={formik.values.notes}
onChange={formik.handleChange}
error={formik.errors.notes ? formik.touched.notes : ''}
{...register('notes')}
error={errors.notes?.message}
minRows={2}
maxRows={4}
autosize
@ -290,9 +276,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
>
<Button
type="submit"
disabled={formik.isSubmitting}
disabled={isSubmitting}
size="xs"
style={{ width: formik.isSubmitting ? 'auto' : 'auto' }}
style={{ width: isSubmitting ? 'auto' : 'auto' }}
>
Submit
</Button>

View file

@ -1,108 +1,104 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
import useChannelsStore from '../../store/channels';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
});
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const formik = useFormik({
initialValues: {
name: '',
url: '',
channel_group: null,
stream_profile_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
// stream_profile_id: Yup.string().required('Stream profile is required'),
const defaultValues = useMemo(
() => ({
name: stream?.name || '',
url: stream?.url || '',
channel_group: stream?.channel_group
? String(stream.channel_group)
: null,
stream_profile_id: stream?.stream_profile_id
? String(stream.stream_profile_id)
: '',
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log(values);
[stream]
);
// Convert string IDs back to integers for the API
const payload = {
...values,
channel_group: values.channel_group
? parseInt(values.channel_group, 10)
: null,
stream_profile_id: values.stream_profile_id
? parseInt(values.stream_profile_id, 10)
: null,
};
if (stream?.id) {
await API.updateStream({ id: stream.id, ...payload });
} else {
await API.addStream(payload);
}
resetForm();
setSubmitting(false);
onClose();
},
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
});
useEffect(() => {
if (stream) {
formik.setValues({
name: stream.name,
url: stream.url,
// Convert IDs to strings to match Select component values
channel_group: stream.channel_group
? String(stream.channel_group)
: null,
stream_profile_id: stream.stream_profile_id
? String(stream.stream_profile_id)
: '',
});
const onSubmit = async (values) => {
console.log(values);
// Convert string IDs back to integers for the API
const payload = {
...values,
channel_group: values.channel_group
? parseInt(values.channel_group, 10)
: null,
stream_profile_id: values.stream_profile_id
? parseInt(values.stream_profile_id, 10)
: null,
};
if (stream?.id) {
await API.updateStream({ id: stream.id, ...payload });
} else {
formik.resetForm();
await API.addStream(payload);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stream]);
reset();
onClose();
};
useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset]);
if (!isOpen) {
return <></>;
}
const channelGroupValue = watch('channel_group');
const streamProfileValue = watch('stream_profile_id');
return (
<Modal opened={isOpen} onClose={onClose} title="Stream" zIndex={10}>
<form onSubmit={formik.handleSubmit}>
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
id="name"
name="name"
label="Stream Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name}
{...register('name')}
error={errors.name?.message}
/>
<TextInput
id="url"
name="url"
label="Stream URL"
value={formik.values.url}
onChange={formik.handleChange}
error={formik.errors.url}
{...register('url')}
error={errors.url?.message}
/>
<Select
id="channel_group"
name="channel_group"
label="Group"
searchable
value={formik.values.channel_group}
onChange={(value) => {
formik.setFieldValue('channel_group', value); // Update Formik's state with the new value
}}
error={formik.errors.channel_group}
value={channelGroupValue}
onChange={(value) => setValue('channel_group', value)}
error={errors.channel_group?.message}
data={Object.values(channelGroups).map((group) => ({
label: group.name,
value: `${group.id}`,
@ -110,16 +106,12 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
/>
<Select
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile"
placeholder="Optional"
searchable
value={formik.values.stream_profile_id}
onChange={(value) => {
formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value
}}
error={formik.errors.stream_profile_id}
value={streamProfileValue}
onChange={(value) => setValue('stream_profile_id', value)}
error={errors.stream_profile_id?.message}
data={streamProfiles.map((profile) => ({
label: profile.name,
value: `${profile.id}`,
@ -132,7 +124,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
disabled={isSubmitting}
>
Submit
</Button>

View file

@ -1,96 +1,91 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string().required('Parameters are is required'),
});
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const formik = useFormik({
initialValues: {
name: '',
command: '',
parameters: '',
is_active: true,
user_agent: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string().required('Parameters are is required'),
const defaultValues = useMemo(
() => ({
name: profile?.name || '',
command: profile?.command || '',
parameters: profile?.parameters || '',
is_active: profile?.is_active ?? true,
user_agent: profile?.user_agent || '',
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
await API.addStreamProfile(values);
}
[profile]
);
resetForm();
setSubmitting(false);
onClose();
},
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
});
useEffect(() => {
if (profile) {
formik.setValues({
name: profile.name,
command: profile.command,
parameters: profile.parameters,
is_active: profile.is_active,
user_agent: profile.user_agent,
});
const onSubmit = async (values) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
formik.resetForm();
await API.addStreamProfile(values);
}
}, [profile]);
reset();
onClose();
};
useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset]);
if (!isOpen) {
return <></>;
}
const userAgentValue = watch('user_agent');
return (
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
<form onSubmit={formik.handleSubmit}>
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name}
{...register('name')}
error={errors.name?.message}
disabled={profile ? profile.locked : false}
/>
<TextInput
id="command"
name="command"
label="Command"
value={formik.values.command}
onChange={formik.handleChange}
error={formik.errors.command}
{...register('command')}
error={errors.command?.message}
disabled={profile ? profile.locked : false}
/>
<TextInput
id="parameters"
name="parameters"
label="Parameters"
value={formik.values.parameters}
onChange={formik.handleChange}
error={formik.errors.parameters}
{...register('parameters')}
error={errors.parameters?.message}
disabled={profile ? profile.locked : false}
/>
<Select
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.errors.user_agent}
{...register('user_agent')}
value={userAgentValue}
error={errors.user_agent?.message}
data={userAgents.map((ua) => ({
label: ua.name,
value: `${ua.id}`,
@ -102,7 +97,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
disabled={isSubmitting}
size="small"
>
Submit

View file

@ -1,6 +1,7 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import {
@ -16,87 +17,82 @@ import {
} from '@mantine/core';
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: '',
user_agent: '',
description: '',
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
await API.addUserAgent(values);
}
const schema = Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
});
resetForm();
setSubmitting(false);
onClose();
},
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const defaultValues = useMemo(
() => ({
name: userAgent?.name || '',
user_agent: userAgent?.user_agent || '',
description: userAgent?.description || '',
is_active: userAgent?.is_active ?? true,
}),
[userAgent]
);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
});
useEffect(() => {
if (userAgent) {
formik.setValues({
name: userAgent.name,
user_agent: userAgent.user_agent,
description: userAgent.description,
is_active: userAgent.is_active,
});
const onSubmit = async (values) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
formik.resetForm();
await API.addUserAgent(values);
}
}, [userAgent]);
reset();
onClose();
};
useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset]);
if (!isOpen) {
return <></>;
}
const isActive = watch('is_active');
return (
<Modal opened={isOpen} onClose={onClose} title="User-Agent">
<form onSubmit={formik.handleSubmit}>
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
{...register('name')}
error={errors.name?.message}
/>
<TextInput
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
{...register('user_agent')}
error={errors.user_agent?.message}
/>
<TextInput
id="description"
name="description"
label="Description"
value={formik.values.description}
onChange={formik.handleChange}
error={
formik.touched.description && Boolean(formik.errors.description)
}
{...register('description')}
error={errors.description?.message}
/>
<Space h="md" />
<Checkbox
name="is_active"
label="Is Active"
checked={formik.values.is_active}
onChange={formik.handleChange}
checked={isActive}
onChange={(e) => setValue('is_active', e.currentTarget.checked)}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
@ -104,7 +100,7 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
size="small"
type="submit"
variant="contained"
disabled={formik.isSubmitting}
disabled={isSubmitting}
>
Submit
</Button>

View file

@ -105,6 +105,7 @@ const DraggableRow = ({ row, index }) => {
}}
>
{row.getVisibleCells().map((cell) => {
const isStale = row.original.is_stale;
return (
<Box
className="td"
@ -115,6 +116,9 @@ const DraggableRow = ({ row, index }) => {
? cell.column.getSize()
: undefined,
minWidth: 0,
...(isStale && {
backgroundColor: 'rgba(239, 68, 68, 0.15)',
}),
}}
>
<Flex align="center" style={{ height: '100%' }}>

View file

@ -52,6 +52,7 @@ import {
Select,
NumberInput,
Tooltip,
Skeleton,
} from '@mantine/core';
import { getCoreRowModel, flexRender } from '@tanstack/react-table';
import './table.css';
@ -228,6 +229,7 @@ const ChannelsTable = ({ onReady }) => {
// EPG data lookup
const tvgsById = useEPGsStore((s) => s.tvgsById);
const epgs = useEPGsStore((s) => s.epgs);
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
@ -431,9 +433,9 @@ const ChannelsTable = ({ onReady }) => {
});
setAllRowIds(ids);
// Signal ready after first successful data fetch
// EPG data is already loaded in initData before this component mounts
if (!hasSignaledReady.current && onReady) {
// Signal ready after first successful data fetch AND EPG data is loaded
// This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
hasSignaledReady.current = true;
onReady();
}
@ -445,6 +447,7 @@ const ChannelsTable = ({ onReady }) => {
showDisabled,
selectedProfileId,
showOnlyStreamlessChannels,
tvgsLoaded,
]);
const stopPropagation = useCallback((e) => {
@ -750,6 +753,19 @@ const ChannelsTable = ({ onReady }) => {
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
}, [pagination.pageIndex, pagination.pageSize, totalCount]);
// Signal ready when EPG data finishes loading (if channels were already fetched)
useEffect(() => {
if (
hasFetchedData.current &&
!hasSignaledReady.current &&
onReady &&
tvgsLoaded
) {
hasSignaledReady.current = true;
onReady();
}
}, [tvgsLoaded, onReady]);
const columns = useMemo(
() => [
{
@ -834,6 +850,10 @@ const ChannelsTable = ({ onReady }) => {
const tooltip = epgObj
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
: '';
// If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
return (
<Box
style={{
@ -856,6 +876,12 @@ const ChannelsTable = ({ onReady }) => {
</Tooltip>
) : epgObj ? (
<span>{epgObj.name}</span>
) : isEpgDataPending ? (
<Skeleton
height={14}
width={(columnSizing.epg || 200) * 0.7}
style={{ borderRadius: 4 }}
/>
) : (
<span style={{ color: '#888' }}>Not Assigned</span>
)}
@ -935,7 +961,7 @@ const ChannelsTable = ({ onReady }) => {
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
// from the store, so we don't need to recreate columns when logos load.
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedProfileId, channelGroups, theme]
[selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded]
);
const renderHeaderCell = (header) => {
@ -1380,12 +1406,13 @@ const ChannelsTable = ({ onReady }) => {
{/* Table or ghost empty state inside Paper */}
<Box>
{channelsTableLength === 0 && (
<ChannelsTableOnboarding editChannel={editChannel} />
)}
{channelsTableLength === 0 &&
Object.keys(channels).length === 0 && (
<ChannelsTableOnboarding editChannel={editChannel} />
)}
</Box>
{channelsTableLength > 0 && (
{(channelsTableLength > 0 || Object.keys(channels).length > 0) && (
<Box
style={{
display: 'flex',

View file

@ -885,6 +885,14 @@ const StreamsTable = ({ onReady }) => {
bodyCellRenderFns: {
actions: renderBodyCell,
},
getRowStyles: (row) => {
if (row.original.is_stale) {
return {
backgroundColor: 'rgba(239, 68, 68, 0.15)',
};
}
return {};
},
});
/**

File diff suppressed because it is too large Load diff

View file

@ -1,244 +1,31 @@
import React, { useState, useEffect } from 'react';
import React, { Suspense, useEffect, useState } from 'react';
import {
Box,
Button,
Card,
Flex,
Group,
Image,
Text,
Title,
Select,
TextInput,
Pagination,
Badge,
Grid,
GridCol,
Group,
Loader,
Stack,
LoadingOverlay,
Pagination,
SegmentedControl,
ActionIcon,
Select,
Stack,
TextInput,
Title,
} from '@mantine/core';
import { Search, Play, Calendar, Clock, Star } from 'lucide-react';
import { Search } from 'lucide-react';
import { useDisclosure } from '@mantine/hooks';
import useVODStore from '../store/useVODStore';
import SeriesModal from '../components/SeriesModal';
import VODModal from '../components/VODModal';
const formatDuration = (seconds) => {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
};
const VODCard = ({ vod, onClick }) => {
const isEpisode = vod.type === 'episode';
const getDisplayTitle = () => {
if (isEpisode && vod.series) {
const seasonEp =
vod.season_number && vod.episode_number
? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
: '';
return (
<Stack spacing={4}>
<Text size="sm" color="dimmed">
{vod.series.name}
</Text>
<Text weight={500}>
{seasonEp} - {vod.name}
</Text>
</Stack>
);
}
return <Text weight={500}>{vod.name}</Text>;
};
const handleCardClick = async () => {
// Just pass the basic vod info to the parent handler
onClick(vod);
};
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
onClick={handleCardClick}
>
<Card.Section>
<Box style={{ position: 'relative', height: 300 }}>
{vod.logo?.url ? (
<Image
src={vod.logo.url}
height={300}
alt={vod.name}
fit="contain"
/>
) : (
<Box
style={{
height: 300,
backgroundColor: '#404040',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Play size={48} color="#666" />
</Box>
)}
<ActionIcon
style={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
}}
onClick={(e) => {
e.stopPropagation();
onClick(vod);
}}
>
<Play size={16} color="white" />
</ActionIcon>
<Badge
style={{
position: 'absolute',
bottom: 8,
left: 8,
}}
color={isEpisode ? 'blue' : 'green'}
>
{isEpisode ? 'Episode' : 'Movie'}
</Badge>
</Box>
</Card.Section>
<Stack spacing={8} mt="md">
{getDisplayTitle()}
<Group spacing={16}>
{vod.year && (
<Group spacing={4}>
<Calendar size={14} color="#666" />
<Text size="xs" color="dimmed">
{vod.year}
</Text>
</Group>
)}
{vod.duration && (
<Group spacing={4}>
<Clock size={14} color="#666" />
<Text size="xs" color="dimmed">
{formatDuration(vod.duration_secs)}
</Text>
</Group>
)}
{vod.rating && (
<Group spacing={4}>
<Star size={14} color="#666" />
<Text size="xs" color="dimmed">
{vod.rating}
</Text>
</Group>
)}
</Group>
{vod.genre && (
<Text size="xs" color="dimmed" lineClamp={1}>
{vod.genre}
</Text>
)}
</Stack>
</Card>
);
};
const SeriesCard = ({ series, onClick }) => {
return (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
onClick={() => onClick(series)}
>
<Card.Section>
<Box style={{ position: 'relative', height: 300 }}>
{series.logo?.url ? (
<Image
src={series.logo.url}
height={300}
alt={series.name}
fit="contain"
/>
) : (
<Box
style={{
height: 300,
backgroundColor: '#404040',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Play size={48} color="#666" />
</Box>
)}
{/* Add Series badge in the same position as Movie badge */}
<Badge
style={{
position: 'absolute',
bottom: 8,
left: 8,
}}
color="purple"
>
Series
</Badge>
</Box>
</Card.Section>
<Stack spacing={8} mt="md">
<Text weight={500}>{series.name}</Text>
<Group spacing={16}>
{series.year && (
<Group spacing={4}>
<Calendar size={14} color="#666" />
<Text size="xs" color="dimmed">
{series.year}
</Text>
</Group>
)}
{series.rating && (
<Group spacing={4}>
<Star size={14} color="#666" />
<Text size="xs" color="dimmed">
{series.rating}
</Text>
</Group>
)}
</Group>
{series.genre && (
<Text size="xs" color="dimmed" lineClamp={1}>
{series.genre}
</Text>
)}
</Stack>
</Card>
);
};
import ErrorBoundary from '../components/ErrorBoundary.jsx';
import {
filterCategoriesToEnabled,
getCategoryOptions,
} from '../utils/pages/VODsUtils.js';
const SeriesModal = React.lazy(() => import('../components/SeriesModal'));
const VODModal = React.lazy(() => import('../components/VODModal'));
const VODCard = React.lazy(() => import('../components/cards/VODCard'));
const SeriesCard = React.lazy(() => import('../components/cards/SeriesCard'));
const MIN_CARD_WIDTH = 260;
const MAX_CARD_WIDTH = 320;
@ -312,19 +99,7 @@ const VODsPage = () => {
};
useEffect(() => {
// setCategories(allCategories)
setCategories(
Object.keys(allCategories).reduce((acc, key) => {
const enabled = allCategories[key].m3u_accounts.find(
(account) => account.enabled === true
);
if (enabled) {
acc[key] = allCategories[key];
}
return acc;
}, {})
);
setCategories(filterCategoriesToEnabled(allCategories));
}, [allCategories]);
useEffect(() => {
@ -356,19 +131,7 @@ const VODsPage = () => {
setPage(1);
};
const categoryOptions = [
{ value: '', label: 'All Categories' },
...Object.values(categories)
.filter((cat) => {
if (filters.type === 'movies') return cat.category_type === 'movie';
if (filters.type === 'series') return cat.category_type === 'series';
return true; // 'all' shows all
})
.map((cat) => ({
value: `${cat.name}|${cat.category_type}`,
label: `${cat.name} (${cat.category_type})`,
})),
];
const categoryOptions = getCategoryOptions(categories, filters);
const totalPages = Math.ceil(totalCount / pageSize);
@ -396,7 +159,7 @@ const VODsPage = () => {
icon={<Search size={16} />}
value={filters.search}
onChange={(e) => setFilters({ search: e.target.value })}
style={{ minWidth: 200 }}
miw={200}
/>
<Select
@ -405,7 +168,7 @@ const VODsPage = () => {
value={filters.category}
onChange={onCategoryChange}
clearable
style={{ minWidth: 150 }}
miw={150}
/>
<Select
@ -416,7 +179,7 @@ const VODsPage = () => {
value: v,
label: v,
}))}
style={{ width: 110 }}
w={110}
/>
</Group>
@ -428,23 +191,25 @@ const VODsPage = () => {
) : (
<>
<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>
))}
<ErrorBoundary>
<Suspense fallback={<Loader />}>
{getDisplayData().map((item) => (
<GridCol
span={12 / columns}
key={`${item.contentType}_${item.id}`}
miw={MIN_CARD_WIDTH}
maw={MAX_CARD_WIDTH}
m={'0 auto'}
>
{item.contentType === 'series' ? (
<SeriesCard series={item} onClick={handleSeriesClick} />
) : (
<VODCard vod={item} onClick={handleVODCardClick} />
)}
</GridCol>
))}
</Suspense>
</ErrorBoundary>
</Grid>
{/* Pagination */}
@ -462,18 +227,26 @@ const VODsPage = () => {
</Stack>
{/* Series Episodes Modal */}
<SeriesModal
series={selectedSeries}
opened={seriesModalOpened}
onClose={closeSeriesModal}
/>
<ErrorBoundary>
<Suspense fallback={<LoadingOverlay />}>
<SeriesModal
series={selectedSeries}
opened={seriesModalOpened}
onClose={closeSeriesModal}
/>
</Suspense>
</ErrorBoundary>
{/* VOD Details Modal */}
<VODModal
vod={selectedVOD}
opened={vodModalOpened}
onClose={closeVODModal}
/>
<ErrorBoundary>
<Suspense fallback={<LoadingOverlay />}>
<VODModal
vod={selectedVOD}
opened={vodModalOpened}
onClose={closeVODModal}
/>
</Suspense>
</ErrorBoundary>
</Box>
);
};

View file

@ -5,6 +5,7 @@ const useEPGsStore = create((set) => ({
epgs: {},
tvgs: [],
tvgsById: {},
tvgsLoaded: false,
isLoading: false,
error: null,
refreshProgress: {},
@ -36,11 +37,16 @@ const useEPGsStore = create((set) => ({
acc[tvg.id] = tvg;
return acc;
}, {}),
tvgsLoaded: true,
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch tvgs:', error);
set({ error: 'Failed to load tvgs.', isLoading: false });
set({
error: 'Failed to load tvgs.',
tvgsLoaded: true,
isLoading: false,
});
}
},

View file

@ -0,0 +1,132 @@
import API from '../../api.js';
import {
format,
getNow,
initializeTime,
subtract,
toFriendlyDuration,
} from '../dateTimeUtils.js';
// Parse proxy settings to get buffering_speed
export const getBufferingSpeedThreshold = (proxySetting) => {
try {
if (proxySetting?.value) {
const proxySettings = JSON.parse(proxySetting.value);
return parseFloat(proxySettings.buffering_speed) || 1.0;
}
} catch (error) {
console.error('Error parsing proxy settings:', error);
}
return 1.0; // Default fallback
};
export const getStartDate = (uptime) => {
// Get the current date and time
const currentDate = new Date();
// Calculate the start date by subtracting uptime (in milliseconds)
const startDate = new Date(currentDate.getTime() - uptime * 1000);
// Format the date as a string (you can adjust the format as needed)
return startDate.toLocaleString({
weekday: 'short', // optional, adds day of the week
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true, // 12-hour format with AM/PM
});
};
export const getM3uAccountsMap = (m3uAccounts) => {
const map = {};
if (m3uAccounts && Array.isArray(m3uAccounts)) {
m3uAccounts.forEach((account) => {
if (account.id) {
map[account.id] = account.name;
}
});
}
return map;
};
export const getChannelStreams = async (channelId) => {
return await API.getChannelStreams(channelId);
};
export const getMatchingStreamByUrl = (streamData, channelUrl) => {
return streamData.find(
(stream) =>
channelUrl.includes(stream.url) || stream.url.includes(channelUrl)
);
};
export const getSelectedStream = (availableStreams, streamId) => {
return availableStreams.find((s) => s.id.toString() === streamId);
};
export const switchStream = (channel, streamId) => {
return API.switchStream(channel.channel_id, streamId);
};
export const connectedAccessor = (dateFormat) => {
return (row) => {
// Check for connected_since (which is seconds since connection)
if (row.connected_since) {
// Calculate the actual connection time by subtracting the seconds from current time
const connectedTime = subtract(getNow(), row.connected_since, 'second');
return format(connectedTime, `${dateFormat} HH:mm:ss`);
}
// Fallback to connected_at if it exists
if (row.connected_at) {
const connectedTime = initializeTime(row.connected_at * 1000);
return format(connectedTime, `${dateFormat} HH:mm:ss`);
}
return 'Unknown';
};
};
export const durationAccessor = () => {
return (row) => {
if (row.connected_since) {
return toFriendlyDuration(row.connected_since, 'seconds');
}
if (row.connection_duration) {
return toFriendlyDuration(row.connection_duration, 'seconds');
}
return '-';
};
};
export const getLogoUrl = (logoId, logos, previewedStream) => {
return (
(logoId && logos && logos[logoId] ? logos[logoId].cache_url : null) ||
previewedStream?.logo_url ||
null
);
};
export const getStreamsByIds = (streamId) => {
return API.getStreamsByIds([streamId]);
};
export const getStreamOptions = (availableStreams, m3uAccountsMap) => {
return availableStreams.map((stream) => {
// Get account name from our mapping if it exists
const accountName =
stream.m3u_account && m3uAccountsMap[stream.m3u_account]
? m3uAccountsMap[stream.m3u_account]
: stream.m3u_account
? `M3U #${stream.m3u_account}`
: 'Unknown M3U';
return {
value: stream.id.toString(),
label: `${stream.name || `Stream #${stream.id}`} [${accountName}]`,
};
});
};

View file

@ -0,0 +1,13 @@
export const formatDuration = (seconds) => {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
};
export const getSeasonLabel = (vod) => {
return vod.season_number && vod.episode_number
? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
: '';
};

View file

@ -0,0 +1,139 @@
import { format, getNowMs, toFriendlyDuration } from '../dateTimeUtils.js';
export const formatDuration = (seconds) => {
if (!seconds) return 'Unknown';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
};
// Format time for display (e.g., "1:23:45" or "23:45")
export const formatTime = (seconds) => {
if (!seconds || seconds === 0) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
};
export const getMovieDisplayTitle = (vodContent) => {
return vodContent.content_name;
}
export const getEpisodeDisplayTitle = (metadata) => {
const season = metadata.season_number
? `S${metadata.season_number.toString().padStart(2, '0')}`
: 'S??';
const episode = metadata.episode_number
? `E${metadata.episode_number.toString().padStart(2, '0')}`
: 'E??';
return `${metadata.series_name} - ${season}${episode}`;
}
export const getMovieSubtitle = (metadata) => {
const parts = [];
if (metadata.genre) parts.push(metadata.genre);
// We'll handle rating separately as a badge now
return parts;
}
export const getEpisodeSubtitle = (metadata) => {
return [metadata.episode_name || 'Episode'];
}
export const calculateProgress = (connection, duration_secs) => {
if (!connection || !duration_secs) {
return {
percentage: 0,
currentTime: 0,
totalTime: duration_secs || 0,
};
}
const totalSeconds = duration_secs;
let percentage = 0;
let currentTime = 0;
const now = getNowMs() / 1000; // Current time in seconds
// Priority 1: Use last_seek_percentage if available (most accurate from range requests)
if (
connection.last_seek_percentage &&
connection.last_seek_percentage > 0 &&
connection.last_seek_timestamp
) {
// Calculate the position at the time of seek
const seekPosition = Math.round(
(connection.last_seek_percentage / 100) * totalSeconds
);
// Add elapsed time since the seek
const elapsedSinceSeek = now - connection.last_seek_timestamp;
currentTime = seekPosition + Math.floor(elapsedSinceSeek);
// Don't exceed the total duration
currentTime = Math.min(currentTime, totalSeconds);
percentage = (currentTime / totalSeconds) * 100;
}
// Priority 2: Use position_seconds if available
else if (connection.position_seconds && connection.position_seconds > 0) {
currentTime = connection.position_seconds;
percentage = (currentTime / totalSeconds) * 100;
}
return {
percentage: Math.min(percentage, 100), // Cap at 100%
currentTime: Math.max(0, currentTime), // Don't go negative
totalTime: totalSeconds,
};
}
export const calculateConnectionDuration = (connection) => {
// If duration is provided by API, use it
if (connection.duration && connection.duration > 0) {
return toFriendlyDuration(connection.duration, 'seconds');
}
// Fallback: try to extract from client_id timestamp
if (connection.client_id && connection.client_id.startsWith('vod_')) {
try {
const parts = connection.client_id.split('_');
if (parts.length >= 2) {
const clientStartTime = parseInt(parts[1]) / 1000; // Convert ms to seconds
const currentTime = getNowMs() / 1000;
return toFriendlyDuration(currentTime - clientStartTime, 'seconds');
}
} catch {
// Ignore parsing errors
}
}
return 'Unknown duration';
}
export const calculateConnectionStartTime = (connection, dateFormat) => {
if (connection.connected_at) {
return format(connection.connected_at * 1000, `${dateFormat} HH:mm:ss`);
}
// Fallback: calculate from client_id timestamp
if (connection.client_id && connection.client_id.startsWith('vod_')) {
try {
const parts = connection.client_id.split('_');
if (parts.length >= 2) {
const clientStartTime = parseInt(parts[1]);
return format(clientStartTime, `${dateFormat} HH:mm:ss`);
}
} catch {
// Ignore parsing errors
}
}
return 'Unknown';
}

View file

@ -14,6 +14,8 @@ dayjs.extend(timezone);
export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
export const convertToSec = (dateTime) => dayjs(dateTime).unix();
export const initializeTime = (dateTime) => dayjs(dateTime);
export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
@ -27,6 +29,9 @@ export const isSame = (date1, date2, unit = 'day') =>
export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit);
export const subtract = (dateTime, value, unit) =>
dayjs(dateTime).subtract(value, unit);
export const diff = (date1, date2, unit = 'millisecond') =>
dayjs(date1).diff(date2, unit);
@ -35,6 +40,10 @@ export const format = (dateTime, formatStr) =>
export const getNow = () => dayjs();
export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize();
export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
export const getNowMs = () => Date.now();
export const roundToNearest = (dateTime, minutes) => {

View file

@ -2,3 +2,21 @@ export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
export const IPV6_CIDR_REGEX =
/(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
export function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
export function formatSpeed(bytes) {
if (bytes === 0) return '0 Bytes';
const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}

View file

@ -0,0 +1,133 @@
import API from '../../api.js';
export const stopChannel = async (id) => {
await API.stopChannel(id);
};
export const stopClient = async (channelId, clientId) => {
await API.stopClient(channelId, clientId);
};
export const stopVODClient = async (clientId) => {
await API.stopVODClient(clientId);
};
export const fetchActiveChannelStats = async () => {
return await API.fetchActiveChannelStats();
};
export const getVODStats = async () => {
return await API.getVODStats();
};
export const getCombinedConnections = (channelHistory, vodConnections) => {
const activeStreams = Object.values(channelHistory).map((channel) => ({
type: 'stream',
data: channel,
id: channel.channel_id,
sortKey: channel.uptime || 0, // Use uptime for sorting streams
}));
// Flatten VOD connections so each individual client gets its own card
const vodItems = vodConnections.flatMap((vodContent) => {
return (vodContent.connections || []).map((connection, index) => ({
type: 'vod',
data: {
...vodContent,
// Override the connections array to contain only this specific connection
connections: [connection],
connection_count: 1, // Each card now represents a single connection
// Add individual connection details at the top level for easier access
individual_connection: connection,
},
id: `${vodContent.content_type}-${vodContent.content_uuid}-${connection.client_id}-${index}`,
sortKey: connection.connected_at || Date.now() / 1000, // Use connection time for sorting
}));
});
// Combine and sort by newest connections first (higher sortKey = more recent)
return [...activeStreams, ...vodItems].sort((a, b) => b.sortKey - a.sortKey);
};
const getChannelWithMetadata = (
prevChannelHistory,
ch,
channelsByUUID,
channels,
streamProfiles
) => {
let bitrates = [];
if (prevChannelHistory[ch.channel_id]) {
bitrates = [...(prevChannelHistory[ch.channel_id].bitrates || [])];
const bitrate =
ch.total_bytes - prevChannelHistory[ch.channel_id].total_bytes;
if (bitrate > 0) {
bitrates.push(bitrate);
}
if (bitrates.length > 15) {
bitrates = bitrates.slice(1);
}
}
// Find corresponding channel data
const channelData =
channelsByUUID && ch.channel_id
? channels[channelsByUUID[ch.channel_id]]
: null;
// Find stream profile
const streamProfile = streamProfiles.find(
(profile) => profile.id == parseInt(ch.stream_profile)
);
return {
...ch,
...(channelData || {}), // Safely merge channel data if available
bitrates,
stream_profile: streamProfile || { name: 'Unknown' },
// Make sure stream_id is set from the active stream info
stream_id: ch.stream_id || null,
};
};
export const getClientStats = (stats) => {
return Object.values(stats).reduce((acc, ch) => {
if (ch.clients && Array.isArray(ch.clients)) {
return acc.concat(
ch.clients.map((client) => ({
...client,
channel: ch,
}))
);
}
return acc;
}, []);
};
export const getStatsByChannelId = (
channelStats,
prevChannelHistory,
channelsByUUID,
channels,
streamProfiles
) => {
const stats = {};
channelStats.channels.forEach((ch) => {
// Make sure we have a valid channel_id
if (!ch.channel_id) {
console.warn('Found channel without channel_id:', ch);
return;
}
stats[ch.channel_id] = getChannelWithMetadata(
prevChannelHistory,
ch,
channelsByUUID,
channels,
streamProfiles
);
});
return stats;
};

View file

@ -0,0 +1,28 @@
export const getCategoryOptions = (categories, filters) => {
return [
{ value: '', label: 'All Categories' },
...Object.values(categories)
.filter((cat) => {
if (filters.type === 'movies') return cat.category_type === 'movie';
if (filters.type === 'series') return cat.category_type === 'series';
return true; // 'all' shows all
})
.map((cat) => ({
value: `${cat.name}|${cat.category_type}`,
label: `${cat.name} (${cat.category_type})`,
})),
];
};
export const filterCategoriesToEnabled = (allCategories) => {
return Object.keys(allCategories).reduce((acc, key) => {
const enabled = allCategories[key].m3u_accounts.find(
(account) => account.enabled === true
);
if (enabled) {
acc[key] = allCategories[key];
}
return acc;
}, {});
};