Separate VOD and channel logos into distinct tables with dedicated management UI

- Created VODLogo model for movies/series, separate from Logo (channels only)
- Added database migration to create vodlogo table and migrate existing VOD logos
- Implemented VODLogoViewSet with pagination, filtering (used/unused/movies/series), and bulk operations
- Built VODLogosTable component with server-side pagination matching channel logos styling
- Added VOD logos tab with on-demand loading to Logos page
- Fixed orphaned VOD content cleanup to always remove unused entries
- Removed redundant channel_assignable filtering from channel logos
This commit is contained in:
SergeantPanda 2025-11-07 13:19:18 -06:00
parent 871f9f953e
commit da628705df
16 changed files with 1423 additions and 242 deletions

View file

@ -1247,7 +1247,7 @@ class CleanupUnusedLogosAPIView(APIView):
return [Authenticated()]
@swagger_auto_schema(
operation_description="Delete all logos that are not used by any channels, movies, or series",
operation_description="Delete all channel logos that are not used by any channels",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
@ -1261,24 +1261,11 @@ class CleanupUnusedLogosAPIView(APIView):
responses={200: "Cleanup completed"},
)
def post(self, request):
"""Delete all logos with no channel, movie, or series associations"""
"""Delete all channel logos with no channel associations"""
delete_files = request.data.get("delete_files", False)
# Find logos that are not used by channels, movies, or series
filter_conditions = Q(channels__isnull=True)
# Add VOD conditions if models are available
try:
filter_conditions &= Q(movie__isnull=True)
except:
pass
try:
filter_conditions &= Q(series__isnull=True)
except:
pass
unused_logos = Logo.objects.filter(filter_conditions)
# Find logos that are not used by any channels
unused_logos = Logo.objects.filter(channels__isnull=True)
deleted_count = unused_logos.count()
logo_names = list(unused_logos.values_list('name', flat=True))
local_files_deleted = 0
@ -1350,13 +1337,6 @@ class LogoViewSet(viewsets.ModelViewSet):
# Start with basic prefetch for channels
queryset = Logo.objects.prefetch_related('channels').order_by('name')
# Try to prefetch VOD relations if available
try:
queryset = queryset.prefetch_related('movie', 'series')
except:
# VOD app might not be available, continue without VOD prefetch
pass
# Filter by specific IDs
ids = self.request.query_params.getlist('ids')
if ids:
@ -1369,62 +1349,14 @@ class LogoViewSet(viewsets.ModelViewSet):
pass # Invalid IDs, return empty queryset
queryset = Logo.objects.none()
# Filter by usage - now includes VOD content
# Filter by usage
used_filter = self.request.query_params.get('used', None)
if used_filter == 'true':
# Logo is used if it has any channels, movies, or series
filter_conditions = Q(channels__isnull=False)
# Add VOD conditions if models are available
try:
filter_conditions |= Q(movie__isnull=False)
except:
pass
try:
filter_conditions |= Q(series__isnull=False)
except:
pass
queryset = queryset.filter(filter_conditions).distinct()
# Logo is used if it has any channels
queryset = queryset.filter(channels__isnull=False).distinct()
elif used_filter == 'false':
# Logo is unused if it has no channels, movies, or series
filter_conditions = Q(channels__isnull=True)
# Add VOD conditions if models are available
try:
filter_conditions &= Q(movie__isnull=True)
except:
pass
try:
filter_conditions &= Q(series__isnull=True)
except:
pass
queryset = queryset.filter(filter_conditions)
# Filter for channel assignment (unused + channel-used, exclude VOD-only)
channel_assignable = self.request.query_params.get('channel_assignable', None)
if channel_assignable == 'true':
# Include logos that are either:
# 1. Completely unused, OR
# 2. Used by channels (but may also be used by VOD)
# Exclude logos that are ONLY used by VOD content
unused_condition = Q(channels__isnull=True)
channel_used_condition = Q(channels__isnull=False)
# Add VOD conditions if models are available
try:
unused_condition &= Q(movie__isnull=True) & Q(series__isnull=True)
except:
pass
# Combine: unused OR used by channels
filter_conditions = unused_condition | channel_used_condition
queryset = queryset.filter(filter_conditions).distinct()
# Logo is unused if it has no channels
queryset = queryset.filter(channels__isnull=True)
# Filter by name
name_filter = self.request.query_params.get('name', None)

View file

@ -64,47 +64,15 @@ class LogoSerializer(serializers.ModelSerializer):
return reverse("api:channels:logo-cache", args=[obj.id])
def get_channel_count(self, obj):
"""Get the number of channels, movies, and series using this logo"""
channel_count = obj.channels.count()
# Safely get movie count
try:
movie_count = obj.movie.count() if hasattr(obj, 'movie') else 0
except AttributeError:
movie_count = 0
# Safely get series count
try:
series_count = obj.series.count() if hasattr(obj, 'series') else 0
except AttributeError:
series_count = 0
return channel_count + movie_count + series_count
"""Get the number of channels using this logo"""
return obj.channels.count()
def get_is_used(self, obj):
"""Check if this logo is used by any channels, movies, or series"""
# Check if used by channels
if obj.channels.exists():
return True
# Check if used by movies (handle case where VOD app might not be available)
try:
if hasattr(obj, 'movie') and obj.movie.exists():
return True
except AttributeError:
pass
# Check if used by series (handle case where VOD app might not be available)
try:
if hasattr(obj, 'series') and obj.series.exists():
return True
except AttributeError:
pass
return False
"""Check if this logo is used by any channels"""
return obj.channels.exists()
def get_channel_names(self, obj):
"""Get the names of channels, movies, and series using this logo (limited to first 5)"""
"""Get the names of channels using this logo (limited to first 5)"""
names = []
# Get channel names
@ -112,28 +80,6 @@ class LogoSerializer(serializers.ModelSerializer):
for channel in channels:
names.append(f"Channel: {channel.name}")
# Get movie names (only if we haven't reached limit)
if len(names) < 5:
try:
if hasattr(obj, 'movie'):
remaining_slots = 5 - len(names)
movies = obj.movie.all()[:remaining_slots]
for movie in movies:
names.append(f"Movie: {movie.name}")
except AttributeError:
pass
# Get series names (only if we haven't reached limit)
if len(names) < 5:
try:
if hasattr(obj, 'series'):
remaining_slots = 5 - len(names)
series = obj.series.all()[:remaining_slots]
for series_item in series:
names.append(f"Series: {series_item.name}")
except AttributeError:
pass
# Calculate total count for "more" message
total_count = self.get_channel_count(obj)
if total_count > 5:

View file

@ -2115,7 +2115,7 @@ def xc_get_vod_streams(request, user, category_id=None):
None if not movie.logo
else build_absolute_uri_with_port(
request,
reverse("api:channels:logo-cache", args=[movie.logo.id])
reverse("api:vod:vodlogo-cache", args=[movie.logo.id])
)
),
#'stream_icon': movie.logo.url if movie.logo else '',
@ -2185,7 +2185,7 @@ def xc_get_series(request, user, category_id=None):
None if not series.logo
else build_absolute_uri_with_port(
request,
reverse("api:channels:logo-cache", args=[series.logo.id])
reverse("api:vod:vodlogo-cache", args=[series.logo.id])
)
),
"plot": series.description or "",
@ -2378,7 +2378,7 @@ def xc_get_series_info(request, user, series_id):
None if not series.logo
else build_absolute_uri_with_port(
request,
reverse("api:channels:logo-cache", args=[series.logo.id])
reverse("api:vod:vodlogo-cache", args=[series.logo.id])
)
),
"plot": series_data['description'],
@ -2506,14 +2506,14 @@ def xc_get_vod_info(request, user, vod_id):
None if not movie.logo
else build_absolute_uri_with_port(
request,
reverse("api:channels:logo-cache", args=[movie.logo.id])
reverse("api:vod:vodlogo-cache", args=[movie.logo.id])
)
),
"movie_image": (
None if not movie.logo
else build_absolute_uri_with_port(
request,
reverse("api:channels:logo-cache", args=[movie.logo.id])
reverse("api:vod:vodlogo-cache", args=[movie.logo.id])
)
),
'description': movie_data.get('description', ''),

View file

@ -6,6 +6,7 @@ from .api_views import (
SeriesViewSet,
VODCategoryViewSet,
UnifiedContentViewSet,
VODLogoViewSet,
)
app_name = 'vod'
@ -16,5 +17,6 @@ router.register(r'episodes', EpisodeViewSet, basename='episode')
router.register(r'series', SeriesViewSet, basename='series')
router.register(r'categories', VODCategoryViewSet, basename='vodcategory')
router.register(r'all', UnifiedContentViewSet, basename='unified-content')
router.register(r'vodlogos', VODLogoViewSet, basename='vodlogo')
urlpatterns = router.urls

View file

@ -3,16 +3,21 @@ from rest_framework.response import Response
from rest_framework.decorators import action
from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny
from django_filters.rest_framework import DjangoFilterBackend
from django.shortcuts import get_object_or_404
from django.http import StreamingHttpResponse, HttpResponse, FileResponse
from django.db.models import Q
import django_filters
import logging
import os
import requests
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
)
from .models import (
Series, VODCategory, Movie, Episode,
Series, VODCategory, Movie, Episode, VODLogo,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation
)
from .serializers import (
@ -20,6 +25,7 @@ from .serializers import (
EpisodeSerializer,
SeriesSerializer,
VODCategorySerializer,
VODLogoSerializer,
M3UMovieRelationSerializer,
M3USeriesRelationSerializer,
M3UEpisodeRelationSerializer
@ -564,7 +570,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
logo.url as logo_url,
'movie' as content_type
FROM vod_movie movies
LEFT JOIN dispatcharr_channels_logo logo ON movies.logo_id = logo.id
LEFT JOIN vod_vodlogo logo ON movies.logo_id = logo.id
WHERE {where_conditions[0]}
UNION ALL
@ -586,7 +592,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
logo.url as logo_url,
'series' as content_type
FROM vod_series series
LEFT JOIN dispatcharr_channels_logo logo ON series.logo_id = logo.id
LEFT JOIN vod_vodlogo logo ON series.logo_id = logo.id
WHERE {where_conditions[1]}
)
SELECT * FROM unified_content
@ -613,10 +619,10 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
'id': item_dict['logo_id'],
'name': item_dict['logo_name'],
'url': item_dict['logo_url'],
'cache_url': f"/media/logo_cache/{item_dict['logo_id']}.png" if item_dict['logo_id'] else None,
'channel_count': 0, # We don't need this for VOD
'is_used': True,
'channel_names': [] # We don't need this for VOD
'cache_url': f"/api/vod/vodlogos/{item_dict['logo_id']}/cache/",
'movie_count': 0, # We don't calculate this in raw SQL
'series_count': 0, # We don't calculate this in raw SQL
'is_used': True
}
# Convert to the format expected by frontend
@ -668,4 +674,173 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
logger.error(f"Error in UnifiedContentViewSet.list(): {e}")
import traceback
logger.error(traceback.format_exc())
return Response({'error': str(e)}, status=500)
return Response({'error': str(e)}, status=500)
class VODLogoPagination(PageNumberPagination):
page_size = 100
page_size_query_param = "page_size"
max_page_size = 1000
class VODLogoViewSet(viewsets.ModelViewSet):
"""ViewSet for VOD Logo management"""
queryset = VODLogo.objects.all()
serializer_class = VODLogoSerializer
pagination_class = VODLogoPagination
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ['name', 'url']
ordering_fields = ['name', 'id']
ordering = ['name']
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
if self.action == 'cache':
return [AllowAny()]
return [Authenticated()]
def get_queryset(self):
"""Optimize queryset with prefetch and add filtering"""
queryset = VODLogo.objects.prefetch_related('movie', 'series').order_by('name')
# Filter by specific IDs
ids = self.request.query_params.getlist('ids')
if ids:
try:
id_list = [int(id_str) for id_str in ids if id_str.isdigit()]
if id_list:
queryset = queryset.filter(id__in=id_list)
except (ValueError, TypeError):
queryset = VODLogo.objects.none()
# Filter by usage
used_filter = self.request.query_params.get('used', None)
if used_filter == 'true':
# Return logos that are used by movies OR series
queryset = queryset.filter(
Q(movie__isnull=False) | Q(series__isnull=False)
).distinct()
elif used_filter == 'false':
# Return logos that are NOT used by either
queryset = queryset.filter(
movie__isnull=True,
series__isnull=True
)
elif used_filter == 'movies':
# Return logos that are used by movies (may also be used by series)
queryset = queryset.filter(movie__isnull=False).distinct()
elif used_filter == 'series':
# Return logos that are used by series (may also be used by movies)
queryset = queryset.filter(series__isnull=False).distinct()
# Filter by name
name_query = self.request.query_params.get('name', None)
if name_query:
queryset = queryset.filter(name__icontains=name_query)
# No pagination mode
if self.request.query_params.get('no_pagination', 'false').lower() == 'true':
self.pagination_class = None
return queryset
@action(detail=True, methods=["get"], permission_classes=[AllowAny])
def cache(self, request, pk=None):
"""Streams the VOD logo file, whether it's local or remote."""
logo = self.get_object()
if not logo.url:
return HttpResponse(status=404)
# Check if this is a local file path
if logo.url.startswith('/data/'):
# It's a local file
file_path = logo.url
if not os.path.exists(file_path):
logger.error(f"VOD logo file not found: {file_path}")
return HttpResponse(status=404)
try:
return FileResponse(open(file_path, 'rb'), content_type='image/png')
except Exception as e:
logger.error(f"Error serving VOD logo file {file_path}: {str(e)}")
return HttpResponse(status=500)
else:
# It's a remote URL - proxy it
try:
response = requests.get(logo.url, stream=True, timeout=10)
response.raise_for_status()
content_type = response.headers.get('Content-Type', 'image/png')
return StreamingHttpResponse(
response.iter_content(chunk_size=8192),
content_type=content_type
)
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching remote VOD logo {logo.url}: {str(e)}")
return HttpResponse(status=404)
@action(detail=False, methods=["delete"], url_path="bulk-delete")
def bulk_delete(self, request):
"""Delete multiple VOD logos at once"""
logo_ids = request.data.get('logo_ids', [])
if not logo_ids:
return Response(
{"error": "No logo IDs provided"},
status=status.HTTP_400_BAD_REQUEST
)
try:
# Get logos to delete
logos = VODLogo.objects.filter(id__in=logo_ids)
deleted_count = logos.count()
# Delete them
logos.delete()
return Response({
"deleted_count": deleted_count,
"message": f"Successfully deleted {deleted_count} VOD logo(s)"
})
except Exception as e:
logger.error(f"Error during bulk VOD logo deletion: {str(e)}")
return Response(
{"error": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=False, methods=["post"])
def cleanup(self, request):
"""Delete all VOD logos that are not used by any movies or series"""
try:
# Find unused logos
unused_logos = VODLogo.objects.filter(
movie__isnull=True,
series__isnull=True
)
deleted_count = unused_logos.count()
logo_names = list(unused_logos.values_list('name', flat=True))
# Delete them
unused_logos.delete()
logger.info(f"Cleaned up {deleted_count} unused VOD logos: {logo_names}")
return Response({
"deleted_count": deleted_count,
"deleted_logos": logo_names,
"message": f"Successfully deleted {deleted_count} unused VOD logo(s)"
})
except Exception as e:
logger.error(f"Error during VOD logo cleanup: {str(e)}")
return Response(
{"error": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View file

@ -0,0 +1,264 @@
# Generated by Django 5.2.4 on 2025-11-06 23:01
import django.db.models.deletion
from django.db import migrations, models
def migrate_vod_logos_forward(apps, schema_editor):
"""
Migrate VOD logos from the Logo table to the new VODLogo table.
This copies all logos referenced by movies or series to VODLogo.
Uses pure SQL for maximum performance.
"""
from django.db import connection
print("\n" + "="*80)
print("Starting VOD logo migration...")
print("="*80)
with connection.cursor() as cursor:
# Step 1: Copy unique logos from Logo table to VODLogo table
# Only copy logos that are used by movies or series
print("Copying logos to VODLogo table...")
cursor.execute("""
INSERT INTO vod_vodlogo (name, url)
SELECT DISTINCT l.name, l.url
FROM dispatcharr_channels_logo l
WHERE l.id IN (
SELECT DISTINCT logo_id FROM vod_movie WHERE logo_id IS NOT NULL
UNION
SELECT DISTINCT logo_id FROM vod_series WHERE logo_id IS NOT NULL
)
ON CONFLICT (url) DO NOTHING
""")
print(f"Created VODLogo entries")
# Step 2: Update movies to point to VODLogo IDs using JOIN
print("Updating movie references...")
cursor.execute("""
UPDATE vod_movie m
SET logo_id = v.id
FROM dispatcharr_channels_logo l
INNER JOIN vod_vodlogo v ON l.url = v.url
WHERE m.logo_id = l.id
AND m.logo_id IS NOT NULL
""")
movie_count = cursor.rowcount
print(f"Updated {movie_count} movies with new VOD logo references")
# Step 3: Update series to point to VODLogo IDs using JOIN
print("Updating series references...")
cursor.execute("""
UPDATE vod_series s
SET logo_id = v.id
FROM dispatcharr_channels_logo l
INNER JOIN vod_vodlogo v ON l.url = v.url
WHERE s.logo_id = l.id
AND s.logo_id IS NOT NULL
""")
series_count = cursor.rowcount
print(f"Updated {series_count} series with new VOD logo references")
print("="*80)
print("VOD logo migration completed successfully!")
print(f"Summary: Updated {movie_count} movies and {series_count} series")
print("="*80 + "\n")
def migrate_vod_logos_backward(apps, schema_editor):
"""
Reverse migration - moves VODLogos back to Logo table.
This recreates Logo entries for all VODLogos and updates Movie/Series references.
"""
Logo = apps.get_model('dispatcharr_channels', 'Logo')
VODLogo = apps.get_model('vod', 'VODLogo')
Movie = apps.get_model('vod', 'Movie')
Series = apps.get_model('vod', 'Series')
print("\n" + "="*80)
print("REVERSE: Moving VOD logos back to Logo table...")
print("="*80)
# Get all VODLogos
vod_logos = VODLogo.objects.all()
print(f"Found {vod_logos.count()} VOD logos to reverse migrate")
# Create Logo entries for each VODLogo
logos_to_create = []
vod_to_logo_mapping = {} # VODLogo ID -> Logo ID
for vod_logo in vod_logos:
# Check if a Logo with this URL already exists
existing_logo = Logo.objects.filter(url=vod_logo.url).first()
if existing_logo:
# Logo already exists, just map to it
vod_to_logo_mapping[vod_logo.id] = existing_logo.id
print(f"Logo already exists for URL: {vod_logo.url[:50]}... (using existing)")
else:
# Create new Logo entry
new_logo = Logo(name=vod_logo.name, url=vod_logo.url)
logos_to_create.append(new_logo)
# Bulk create new Logo entries
if logos_to_create:
print(f"Creating {len(logos_to_create)} new Logo entries...")
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
print("Logo entries created")
# Get the created Logo instances with their IDs
for vod_logo in vod_logos:
if vod_logo.id not in vod_to_logo_mapping:
try:
logo = Logo.objects.get(url=vod_logo.url)
vod_to_logo_mapping[vod_logo.id] = logo.id
except Logo.DoesNotExist:
print(f"Warning: Could not find Logo for URL: {vod_logo.url[:100]}...")
print(f"Created mapping for {len(vod_to_logo_mapping)} VOD logos -> Logos")
# Update movies to point back to Logo table
movie_count = 0
for movie in Movie.objects.exclude(logo__isnull=True):
if movie.logo_id in vod_to_logo_mapping:
movie.logo_id = vod_to_logo_mapping[movie.logo_id]
movie.save(update_fields=['logo_id'])
movie_count += 1
print(f"Updated {movie_count} movies to use Logo table")
# Update series to point back to Logo table
series_count = 0
for series in Series.objects.exclude(logo__isnull=True):
if series.logo_id in vod_to_logo_mapping:
series.logo_id = vod_to_logo_mapping[series.logo_id]
series.save(update_fields=['logo_id'])
series_count += 1
print(f"Updated {series_count} series to use Logo table")
# Delete VODLogos (they're now redundant)
vod_logo_count = vod_logos.count()
vod_logos.delete()
print(f"Deleted {vod_logo_count} VOD logos")
print("="*80)
print("Reverse migration completed!")
print(f"Summary: Created/reused {len(vod_to_logo_mapping)} logos, updated {movie_count} movies and {series_count} series")
print("="*80 + "\n")
def cleanup_migrated_logos(apps, schema_editor):
"""
Delete Logo entries that were successfully migrated to VODLogo.
Uses efficient JOIN-based approach with LEFT JOIN to exclude channel usage.
"""
from django.db import connection
print("\n" + "="*80)
print("Cleaning up migrated Logo entries...")
print("="*80)
with connection.cursor() as cursor:
# Single efficient query using JOINs:
# - JOIN with vod_vodlogo to find migrated logos
# - LEFT JOIN with channels to find which aren't used
cursor.execute("""
DELETE FROM dispatcharr_channels_logo
WHERE id IN (
SELECT l.id
FROM dispatcharr_channels_logo l
INNER JOIN vod_vodlogo v ON l.url = v.url
LEFT JOIN dispatcharr_channels_channel c ON c.logo_id = l.id
WHERE c.id IS NULL
)
""")
deleted_count = cursor.rowcount
print(f"✓ Deleted {deleted_count} migrated Logo entries (not used by channels)")
print("="*80 + "\n")
class Migration(migrations.Migration):
dependencies = [
('vod', '0002_add_last_seen_with_default'),
('dispatcharr_channels', '0013_alter_logo_url'), # Ensure Logo table exists
]
operations = [
# Step 1: Create the VODLogo model
migrations.CreateModel(
name='VODLogo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('url', models.TextField(unique=True)),
],
options={
'verbose_name': 'VOD Logo',
'verbose_name_plural': 'VOD Logos',
},
),
# Step 2: Remove foreign key constraints temporarily (so we can change the IDs)
# We need to find and drop the actual constraint names dynamically
migrations.RunSQL(
sql=[
# Drop movie logo constraint (find it dynamically)
"""
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT conname INTO constraint_name
FROM pg_constraint
WHERE conrelid = 'vod_movie'::regclass
AND conname LIKE '%logo_id%fk%';
IF constraint_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE vod_movie DROP CONSTRAINT ' || constraint_name;
END IF;
END $$;
""",
# Drop series logo constraint (find it dynamically)
"""
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT conname INTO constraint_name
FROM pg_constraint
WHERE conrelid = 'vod_series'::regclass
AND conname LIKE '%logo_id%fk%';
IF constraint_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE vod_series DROP CONSTRAINT ' || constraint_name;
END IF;
END $$;
""",
],
reverse_sql=[
# The AlterField operations will recreate the constraints pointing to VODLogo,
# so we don't need to manually recreate them in reverse
migrations.RunSQL.noop,
],
),
# Step 3: Migrate the data (this copies logos and updates references)
migrations.RunPython(migrate_vod_logos_forward, migrate_vod_logos_backward),
# Step 4: Now we can safely alter the foreign keys to point to VODLogo
migrations.AlterField(
model_name='movie',
name='logo',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='movie', to='vod.vodlogo'),
),
migrations.AlterField(
model_name='series',
name='logo',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='series', to='vod.vodlogo'),
),
# Step 5: Clean up migrated Logo entries
migrations.RunPython(cleanup_migrated_logos, migrations.RunPython.noop),
]

View file

@ -4,10 +4,22 @@ from django.utils import timezone
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from apps.m3u.models import M3UAccount
from apps.channels.models import Logo
import uuid
class VODLogo(models.Model):
"""Logo model specifically for VOD content (movies and series)"""
name = models.CharField(max_length=255)
url = models.TextField(unique=True)
def __str__(self):
return self.name
class Meta:
verbose_name = 'VOD Logo'
verbose_name_plural = 'VOD Logos'
class VODCategory(models.Model):
"""Categories for organizing VODs (e.g., Action, Comedy, Drama)"""
@ -69,7 +81,7 @@ class Series(models.Model):
year = models.IntegerField(blank=True, null=True)
rating = models.CharField(max_length=10, blank=True, null=True)
genre = models.CharField(max_length=255, blank=True, null=True)
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True, related_name='series')
logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='series')
# Metadata IDs for deduplication - these should be globally unique when present
tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata")
@ -108,7 +120,7 @@ class Movie(models.Model):
rating = models.CharField(max_length=10, blank=True, null=True)
genre = models.CharField(max_length=255, blank=True, null=True)
duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds")
logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True, related_name='movie')
logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='movie')
# Metadata IDs for deduplication - these should be globally unique when present
tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata")

View file

@ -1,12 +1,79 @@
from rest_framework import serializers
from django.urls import reverse
from .models import (
Series, VODCategory, Movie, Episode,
Series, VODCategory, Movie, Episode, VODLogo,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation
)
from apps.channels.serializers import LogoSerializer
from apps.m3u.serializers import M3UAccountSerializer
class VODLogoSerializer(serializers.ModelSerializer):
cache_url = serializers.SerializerMethodField()
movie_count = serializers.SerializerMethodField()
series_count = serializers.SerializerMethodField()
is_used = serializers.SerializerMethodField()
item_names = serializers.SerializerMethodField()
class Meta:
model = VODLogo
fields = ["id", "name", "url", "cache_url", "movie_count", "series_count", "is_used", "item_names"]
def validate_url(self, value):
"""Validate that the URL is unique for creation or update"""
if self.instance and self.instance.url == value:
return value
if VODLogo.objects.filter(url=value).exists():
raise serializers.ValidationError("A VOD logo with this URL already exists.")
return value
def create(self, validated_data):
"""Handle logo creation with proper URL validation"""
return VODLogo.objects.create(**validated_data)
def update(self, instance, validated_data):
"""Handle logo updates"""
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
def get_cache_url(self, obj):
request = self.context.get("request")
if request:
return request.build_absolute_uri(
reverse("api:vod:vodlogo-cache", args=[obj.id])
)
return reverse("api:vod:vodlogo-cache", args=[obj.id])
def get_movie_count(self, obj):
"""Get the number of movies using this logo"""
return obj.movie.count() if hasattr(obj, 'movie') else 0
def get_series_count(self, obj):
"""Get the number of series using this logo"""
return obj.series.count() if hasattr(obj, 'series') else 0
def get_is_used(self, obj):
"""Check if this logo is used by any movies or series"""
return (hasattr(obj, 'movie') and obj.movie.exists()) or (hasattr(obj, 'series') and obj.series.exists())
def get_item_names(self, obj):
"""Get the list of movies and series using this logo"""
names = []
if hasattr(obj, 'movie'):
for movie in obj.movie.all()[:10]: # Limit to 10 items for performance
names.append(f"Movie: {movie.name}")
if hasattr(obj, 'series'):
for series in obj.series.all()[:10]: # Limit to 10 items for performance
names.append(f"Series: {series.name}")
return names
class M3UVODCategoryRelationSerializer(serializers.ModelSerializer):
category = serializers.IntegerField(source="category.id")
m3u_account = serializers.IntegerField(source="m3u_account.id")
@ -31,7 +98,7 @@ class VODCategorySerializer(serializers.ModelSerializer):
]
class SeriesSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
logo = VODLogoSerializer(read_only=True)
episode_count = serializers.SerializerMethodField()
class Meta:
@ -43,7 +110,7 @@ class SeriesSerializer(serializers.ModelSerializer):
class MovieSerializer(serializers.ModelSerializer):
logo = LogoSerializer(read_only=True)
logo = VODLogoSerializer(read_only=True)
class Meta:
model = Movie
@ -225,7 +292,7 @@ class M3UEpisodeRelationSerializer(serializers.ModelSerializer):
class EnhancedSeriesSerializer(serializers.ModelSerializer):
"""Enhanced serializer for series with provider information"""
logo = LogoSerializer(read_only=True)
logo = VODLogoSerializer(read_only=True)
providers = M3USeriesRelationSerializer(source='m3u_relations', many=True, read_only=True)
episode_count = serializers.SerializerMethodField()

View file

@ -5,10 +5,9 @@ from django.db.models import Q
from apps.m3u.models import M3UAccount
from core.xtream_codes import Client as XtreamCodesClient
from .models import (
VODCategory, Series, Movie, Episode,
VODCategory, Series, Movie, Episode, VODLogo,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation
)
from apps.channels.models import Logo
from datetime import datetime
import logging
import json
@ -403,7 +402,7 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
# Get existing logos
existing_logos = {
logo.url: logo for logo in Logo.objects.filter(url__in=logo_urls)
logo.url: logo for logo in VODLogo.objects.filter(url__in=logo_urls)
} if logo_urls else {}
# Create missing logos
@ -411,20 +410,20 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
for logo_url in logo_urls:
if logo_url not in existing_logos:
movie_name = logo_url_to_name.get(logo_url, 'Unknown Movie')
logos_to_create.append(Logo(url=logo_url, name=movie_name))
logos_to_create.append(VODLogo(url=logo_url, name=movie_name))
if logos_to_create:
try:
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
VODLogo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
# Refresh existing_logos with newly created ones
new_logo_urls = [logo.url for logo in logos_to_create]
newly_created = {
logo.url: logo for logo in Logo.objects.filter(url__in=new_logo_urls)
logo.url: logo for logo in VODLogo.objects.filter(url__in=new_logo_urls)
}
existing_logos.update(newly_created)
logger.info(f"Created {len(newly_created)} new logos for movies")
logger.info(f"Created {len(newly_created)} new VOD logos for movies")
except Exception as e:
logger.warning(f"Failed to create logos: {e}")
logger.warning(f"Failed to create VOD logos: {e}")
# Get existing movies based on our keys
existing_movies = {}
@ -725,7 +724,7 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
# Get existing logos
existing_logos = {
logo.url: logo for logo in Logo.objects.filter(url__in=logo_urls)
logo.url: logo for logo in VODLogo.objects.filter(url__in=logo_urls)
} if logo_urls else {}
# Create missing logos
@ -733,20 +732,20 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
for logo_url in logo_urls:
if logo_url not in existing_logos:
series_name = logo_url_to_name.get(logo_url, 'Unknown Series')
logos_to_create.append(Logo(url=logo_url, name=series_name))
logos_to_create.append(VODLogo(url=logo_url, name=series_name))
if logos_to_create:
try:
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
VODLogo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
# Refresh existing_logos with newly created ones
new_logo_urls = [logo.url for logo in logos_to_create]
newly_created = {
logo.url: logo for logo in Logo.objects.filter(url__in=new_logo_urls)
logo.url: logo for logo in VODLogo.objects.filter(url__in=new_logo_urls)
}
existing_logos.update(newly_created)
logger.info(f"Created {len(newly_created)} new logos for series")
logger.info(f"Created {len(newly_created)} new VOD logos for series")
except Exception as e:
logger.warning(f"Failed to create logos: {e}")
logger.warning(f"Failed to create VOD logos: {e}")
# Get existing series based on our keys - same pattern as movies
existing_series = {}
@ -1424,21 +1423,21 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=
stale_episode_count = stale_episode_relations.count()
stale_episode_relations.delete()
# Clean up movies with no relations (orphaned) - only if no account_id specified (global cleanup)
if not account_id:
orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
orphaned_movie_count = orphaned_movies.count()
# Clean up movies with no relations (orphaned)
# Safe to delete even during account-specific cleanup because if ANY account
# has a relation, m3u_relations will not be null
orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True)
orphaned_movie_count = orphaned_movies.count()
if orphaned_movie_count > 0:
logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations")
orphaned_movies.delete()
# Clean up series with no relations (orphaned) - only if no account_id specified (global cleanup)
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
orphaned_series_count = orphaned_series.count()
# Clean up series with no relations (orphaned)
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
orphaned_series_count = orphaned_series.count()
if orphaned_series_count > 0:
logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations")
orphaned_series.delete()
else:
# When cleaning up for specific account, we don't remove orphaned content
# as other accounts might still reference it
orphaned_movie_count = 0
orphaned_series_count = 0
# Episodes will be cleaned up via CASCADE when series are deleted
@ -1999,7 +1998,7 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False):
def validate_logo_reference(obj, obj_type="object"):
"""
Validate that a logo reference exists in the database.
Validate that a VOD logo reference exists in the database.
If not, set it to None to prevent foreign key constraint violations.
Args:
@ -2019,9 +2018,9 @@ def validate_logo_reference(obj, obj_type="object"):
try:
# Verify the logo exists in the database
Logo.objects.get(pk=obj.logo.pk)
VODLogo.objects.get(pk=obj.logo.pk)
return True
except Logo.DoesNotExist:
logger.warning(f"Logo with ID {obj.logo.pk} does not exist in database for {obj_type} '{getattr(obj, 'name', 'Unknown')}', setting to None")
except VODLogo.DoesNotExist:
logger.warning(f"VOD Logo with ID {obj.logo.pk} does not exist in database for {obj_type} '{getattr(obj, 'name', 'Unknown')}', setting to None")
obj.logo = None
return False

View file

@ -1788,6 +1788,77 @@ export default class API {
}
}
// VOD Logo Methods
static async getVODLogos(params = {}) {
try {
// Transform usage filter to match backend expectations
const apiParams = { ...params };
if (apiParams.usage === 'used') {
apiParams.used = 'true';
delete apiParams.usage;
} else if (apiParams.usage === 'unused') {
apiParams.used = 'false';
delete apiParams.usage;
} else if (apiParams.usage === 'movies') {
apiParams.used = 'movies';
delete apiParams.usage;
} else if (apiParams.usage === 'series') {
apiParams.used = 'series';
delete apiParams.usage;
}
const queryParams = new URLSearchParams(apiParams);
const response = await request(
`${host}/api/vod/vodlogos/?${queryParams.toString()}`
);
return response;
} catch (e) {
errorNotification('Failed to retrieve VOD logos', e);
throw e;
}
}
static async deleteVODLogo(id) {
try {
await request(`${host}/api/vod/vodlogos/${id}/`, {
method: 'DELETE',
});
return true;
} catch (e) {
errorNotification('Failed to delete VOD logo', e);
throw e;
}
}
static async deleteVODLogos(ids) {
try {
await request(`${host}/api/vod/vodlogos/bulk-delete/`, {
method: 'DELETE',
body: { logo_ids: ids },
});
return true;
} catch (e) {
errorNotification('Failed to delete VOD logos', e);
throw e;
}
}
static async cleanupUnusedVODLogos() {
try {
const response = await request(`${host}/api/vod/vodlogos/cleanup/`, {
method: 'POST',
});
return response;
} catch (e) {
errorNotification('Failed to cleanup unused VOD logos', e);
throw e;
}
}
static async getChannelProfiles() {
try {
const response = await request(`${host}/api/channels/profiles/`);

View file

@ -626,25 +626,6 @@ const LogosTable = () => {
}}
>
<Stack gap="md" style={{ maxWidth: '1200px', width: '100%' }}>
<Flex style={{ alignItems: 'center', paddingBottom: 10 }} gap={15}>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6',
marginBottom: 0,
}}
>
Logos
</Text>
<Text size="sm" c="dimmed">
({data.length} logo{data.length !== 1 ? 's' : ''})
</Text>
</Flex>
<Paper
style={{
backgroundColor: '#27272A',

View file

@ -0,0 +1,556 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Center,
Checkbox,
Flex,
Group,
Image,
LoadingOverlay,
NativeSelect,
Pagination,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { ExternalLink, Search, Trash2, Trash, SquareMinus } from 'lucide-react';
import useVODLogosStore from '../../store/vodLogos';
import useLocalStorage from '../../hooks/useLocalStorage';
import { CustomTable, useTable } from './CustomTable';
import ConfirmationDialog from '../ConfirmationDialog';
import { notifications } from '@mantine/notifications';
const VODLogoRowActions = ({ theme, row, deleteLogo }) => {
const [tableSize] = useLocalStorage('table-size', 'default');
const onDelete = useCallback(() => {
deleteLogo(row.original.id);
}, [row.original.id, deleteLogo]);
const iconSize =
tableSize === 'default' ? 'sm' : tableSize === 'compact' ? 'xs' : 'md';
return (
<Box style={{ width: '100%', justifyContent: 'left' }}>
<Group gap={2} justify="center">
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
</Box>
);
};
export default function VODLogosTable() {
const theme = useMantineTheme();
const {
logos,
totalCount,
isLoading,
fetchVODLogos,
deleteVODLogo,
deleteVODLogos,
cleanupUnusedVODLogos,
} = useVODLogosStore();
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [nameFilter, setNameFilter] = useState('');
const [usageFilter, setUsageFilter] = useState('all');
const [selectedRows, setSelectedRows] = useState(new Set());
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
const [paginationString, setPaginationString] = useState('');
const [isCleaningUp, setIsCleaningUp] = useState(false);
// Calculate unused logos count
const unusedLogosCount = useMemo(() => {
return logos.filter(
(logo) => logo.movie_count === 0 && logo.series_count === 0
).length;
}, [logos]);
useEffect(() => {
fetchVODLogos({
page: currentPage,
page_size: pageSize,
name: nameFilter,
usage: usageFilter === 'all' ? undefined : usageFilter,
});
}, [currentPage, pageSize, nameFilter, usageFilter, fetchVODLogos]);
const handleSelectAll = useCallback(
(checked) => {
if (checked) {
setSelectedRows(new Set(logos.map((logo) => logo.id)));
} else {
setSelectedRows(new Set());
}
},
[logos]
);
const handleSelectRow = useCallback((id, checked) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(id);
} else {
newSet.delete(id);
}
return newSet;
});
}, []);
const deleteLogo = useCallback((id) => {
setDeleteTarget([id]);
setConfirmDeleteOpen(true);
}, []);
const handleDeleteSelected = useCallback(() => {
setDeleteTarget(Array.from(selectedRows));
setConfirmDeleteOpen(true);
}, [selectedRows]);
const handleConfirmDelete = async () => {
try {
if (deleteTarget.length === 1) {
await deleteVODLogo(deleteTarget[0]);
notifications.show({
title: 'Success',
message: 'VOD logo deleted successfully',
color: 'green',
});
} else {
await deleteVODLogos(deleteTarget);
notifications.show({
title: 'Success',
message: `${deleteTarget.length} VOD logos deleted successfully`,
color: 'green',
});
}
setSelectedRows(new Set());
setConfirmDeleteOpen(false);
setDeleteTarget(null);
} catch (error) {
notifications.show({
title: 'Error',
message: error.message || 'Failed to delete VOD logos',
color: 'red',
});
}
};
const handleCleanupUnused = useCallback(() => {
setConfirmCleanupOpen(true);
}, []);
const handleConfirmCleanup = async () => {
setIsCleaningUp(true);
try {
const result = await cleanupUnusedVODLogos();
notifications.show({
title: 'Success',
message: `Cleaned up ${result.deleted_count} unused VOD logos`,
color: 'green',
});
setConfirmCleanupOpen(false);
} catch (error) {
notifications.show({
title: 'Error',
message: error.message || 'Failed to cleanup unused VOD logos',
color: 'red',
});
} finally {
setIsCleaningUp(false);
}
};
useEffect(() => {
setSelectedRows(new Set());
}, [logos.length]);
useEffect(() => {
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalCount);
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
}, [currentPage, pageSize, totalCount]);
const pageCount = useMemo(() => {
return Math.ceil(totalCount / pageSize);
}, [totalCount, pageSize]);
const columns = useMemo(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
selectedRows.size > 0 && selectedRows.size === logos.length
}
indeterminate={
selectedRows.size > 0 && selectedRows.size < logos.length
}
onChange={(event) => handleSelectAll(event.currentTarget.checked)}
size="sm"
/>
),
cell: ({ row }) => (
<Checkbox
checked={selectedRows.has(row.original.id)}
onChange={(event) =>
handleSelectRow(row.original.id, event.currentTarget.checked)
}
size="sm"
/>
),
size: 50,
enableSorting: false,
},
{
header: 'Preview',
accessorKey: 'cache_url',
size: 80,
enableSorting: false,
cell: ({ getValue, row }) => (
<Center style={{ width: '100%', padding: '4px' }}>
<Image
src={getValue()}
alt={row.original.name}
width={40}
height={30}
fit="contain"
fallbackSrc="/logo.png"
style={{
transition: 'transform 0.3s ease',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.target.style.transform = 'scale(1.5)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'scale(1)';
}}
/>
</Center>
),
},
{
header: 'Name',
accessorKey: 'name',
size: 250,
cell: ({ getValue }) => (
<Text fw={500} size="sm">
{getValue()}
</Text>
),
},
{
header: 'Usage',
accessorKey: 'usage',
size: 120,
cell: ({ row }) => {
const { movie_count, series_count, item_names } = row.original;
const totalUsage = movie_count + series_count;
if (totalUsage === 0) {
return (
<Badge size="sm" variant="light" color="gray">
Unused
</Badge>
);
}
// Build usage description
const usageParts = [];
if (movie_count > 0) {
usageParts.push(
`${movie_count} movie${movie_count !== 1 ? 's' : ''}`
);
}
if (series_count > 0) {
usageParts.push(`${series_count} series`);
}
const label =
usageParts.length === 1
? usageParts[0]
: `${totalUsage} item${totalUsage !== 1 ? 's' : ''}`;
return (
<Tooltip
label={
<div>
<Text size="xs" fw={600}>
Used by {usageParts.join(' & ')}:
</Text>
{item_names &&
item_names.map((name, index) => (
<Text key={index} size="xs">
{name}
</Text>
))}
</div>
}
multiline
width={220}
>
<Badge size="sm" variant="light" color="blue">
{label}
</Badge>
</Tooltip>
);
},
},
{
header: 'URL',
accessorKey: 'url',
grow: true,
cell: ({ getValue }) => (
<Group gap={4} style={{ alignItems: 'center' }}>
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 300,
}}
>
<Text size="sm" c="dimmed">
{getValue()}
</Text>
</Box>
{getValue()?.startsWith('http') && (
<ActionIcon
size="xs"
variant="transparent"
color="gray"
onClick={() => window.open(getValue(), '_blank')}
>
<ExternalLink size={12} />
</ActionIcon>
)}
</Group>
),
},
{
id: 'actions',
size: 80,
header: 'Actions',
enableSorting: false,
cell: ({ row }) => (
<VODLogoRowActions theme={theme} row={row} deleteLogo={deleteLogo} />
),
},
],
[theme, deleteLogo, selectedRows, handleSelectAll, handleSelectRow, logos]
);
const renderHeaderCell = (header) => {
return (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
};
const table = useTable({
data: logos,
columns,
manualPagination: true,
pageCount: pageCount,
allRowIds: logos.map((logo) => logo.id),
enablePagination: false,
enableRowSelection: true,
enableRowVirtualization: false,
renderTopToolbar: false,
manualSorting: false,
manualFiltering: false,
headerCellRenderFns: {
actions: renderHeaderCell,
cache_url: renderHeaderCell,
name: renderHeaderCell,
url: renderHeaderCell,
usage: renderHeaderCell,
},
});
return (
<Box
style={{
display: 'flex',
justifyContent: 'center',
padding: '0px',
minHeight: 'calc(100vh - 200px)',
minWidth: '900px',
}}
>
<Stack gap="md" style={{ maxWidth: '1200px', width: '100%' }}>
<Paper
style={{
backgroundColor: '#27272A',
border: '1px solid #3f3f46',
borderRadius: 'var(--mantine-radius-md)',
}}
>
{/* Top toolbar */}
<Box
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
borderBottom: '1px solid #3f3f46',
}}
>
<Group gap="sm">
<TextInput
placeholder="Filter by name..."
value={nameFilter}
onChange={(event) => {
const value = event.target.value;
setNameFilter(value);
}}
size="xs"
style={{ width: 200 }}
/>
<Select
placeholder="All"
value={usageFilter}
onChange={(value) => setUsageFilter(value)}
data={[
{ value: 'all', label: 'All logos' },
{ value: 'used', label: 'Used only' },
{ value: 'unused', label: 'Unused only' },
{ value: 'movies', label: 'Movies logos' },
{ value: 'series', label: 'Series logos' },
]}
size="xs"
style={{ width: 120 }}
/>
</Group>
<Group gap="sm">
<Button
leftSection={<Trash size={16} />}
variant="light"
size="xs"
color="orange"
onClick={handleCleanupUnused}
loading={isCleaningUp}
disabled={unusedLogosCount === 0}
>
Cleanup Unused{' '}
{unusedLogosCount > 0 ? `(${unusedLogosCount})` : ''}
</Button>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={handleDeleteSelected}
disabled={selectedRows.size === 0}
>
Delete {selectedRows.size > 0 ? `(${selectedRows.size})` : ''}
</Button>
</Group>
</Box>
{/* Table container */}
<Box
style={{
position: 'relative',
borderRadius:
'0 0 var(--mantine-radius-md) var(--mantine-radius-md)',
}}
>
<Box
style={{
overflow: 'auto',
height: 'calc(100vh - 200px)',
}}
>
<div>
<LoadingOverlay visible={isLoading} />
<CustomTable table={table} />
</div>
</Box>
{/* Pagination Controls */}
<Box
style={{
position: 'sticky',
bottom: 0,
zIndex: 3,
backgroundColor: '#27272A',
borderTop: '1px solid #3f3f46',
}}
>
<Group
gap={5}
justify="center"
style={{
padding: 8,
}}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={String(pageSize)}
data={['25', '50', '100', '250']}
onChange={(event) => {
setPageSize(Number(event.target.value));
setCurrentPage(1);
}}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={currentPage}
onChange={setCurrentPage}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
</Box>
</Box>
</Paper>
</Stack>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => {
setConfirmDeleteOpen(false);
setDeleteTarget(null);
}}
onConfirm={handleConfirmDelete}
title="Confirm Deletion"
message={`Are you sure you want to delete ${deleteTarget?.length || 0} VOD logo${deleteTarget?.length !== 1 ? 's' : ''}?`}
/>
<ConfirmationDialog
opened={confirmCleanupOpen}
onClose={() => setConfirmCleanupOpen(false)}
onConfirm={handleConfirmCleanup}
title="Confirm Cleanup"
message={`Are you sure you want to cleanup ${unusedLogosCount} unused logo${unusedLogosCount !== 1 ? 's' : ''}? This action cannot be undone.`}
/>
</Box>
);
}

View file

@ -38,8 +38,7 @@ export const useLogoSelection = () => {
};
/**
* Hook for channel forms that need only channel-assignable logos
* (unused + channel-used, excluding VOD-only logos)
* Hook for channel forms that need channel logos
*/
export const useChannelLogoSelection = () => {
const [isInitialized, setIsInitialized] = useState(false);
@ -65,7 +64,7 @@ export const useChannelLogoSelection = () => {
await fetchChannelAssignableLogos();
setIsInitialized(true);
} catch (error) {
console.error('Failed to load channel-assignable logos:', error);
console.error('Failed to load channel logos:', error);
}
}, [
backgroundLoading,

View file

@ -1,13 +1,20 @@
import React, { useEffect, useCallback } from 'react';
import { Box, Loader, Center, Text, Stack } from '@mantine/core';
import React, { useEffect, useCallback, useState } from 'react';
import { Box, Tabs, Flex, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import useLogosStore from '../store/logos';
import useVODLogosStore from '../store/vodLogos';
import LogosTable from '../components/tables/LogosTable';
import VODLogosTable from '../components/tables/VODLogosTable';
const LogosPage = () => {
const { fetchAllLogos, isLoading, needsAllLogos } = useLogosStore();
const { fetchAllLogos, needsAllLogos, logos } = useLogosStore();
const { totalCount } = useVODLogosStore();
const [activeTab, setActiveTab] = useState('channel');
const loadLogos = useCallback(async () => {
const channelLogosCount = Object.keys(logos).length;
const vodLogosCount = totalCount;
const loadChannelLogos = useCallback(async () => {
try {
// Only fetch all logos if we haven't loaded them yet
if (needsAllLogos()) {
@ -16,30 +23,74 @@ const LogosPage = () => {
} catch (err) {
notifications.show({
title: 'Error',
message: 'Failed to load logos',
message: 'Failed to load channel logos',
color: 'red',
});
console.error('Failed to load logos:', err);
console.error('Failed to load channel logos:', err);
}
}, [fetchAllLogos, needsAllLogos]);
useEffect(() => {
loadLogos();
}, [loadLogos]);
// Always load channel logos on mount
loadChannelLogos();
}, [loadChannelLogos]);
return (
<Box style={{ padding: 10 }}>
{isLoading && (
<Center style={{ marginBottom: 20 }}>
<Stack align="center" spacing="sm">
<Loader size="sm" />
<Text size="sm" color="dimmed">
Loading all logos...
<Box>
{/* Header with title and tabs */}
<Box
style={{
display: 'flex',
justifyContent: 'center',
padding: '10px 0',
}}
>
<Flex
style={{
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
maxWidth: '1200px',
paddingBottom: 10,
}}
>
<Flex gap={8} align="center">
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6',
marginBottom: 0,
}}
>
Logos
</Text>
</Stack>
</Center>
)}
<LogosTable />
<Text size="sm" c="dimmed">
({activeTab === 'channel' ? channelLogosCount : vodLogosCount}{' '}
logo
{(activeTab === 'channel' ? channelLogosCount : vodLogosCount) !==
1
? 's'
: ''}
)
</Text>
</Flex>
<Tabs value={activeTab} onChange={setActiveTab} variant="pills">
<Tabs.List>
<Tabs.Tab value="channel">Channel Logos</Tabs.Tab>
<Tabs.Tab value="vod">VOD Logos</Tabs.Tab>
</Tabs.List>
</Tabs>
</Flex>
</Box>
{/* Content based on active tab */}
{activeTab === 'channel' && <LogosTable />}
{activeTab === 'vod' && <VODLogosTable />}
</Box>
);
};

View file

@ -3,11 +3,11 @@ import api from '../api';
const useLogosStore = create((set, get) => ({
logos: {},
channelLogos: {}, // Keep this for simplicity, but we'll be more careful about when we populate it
channelLogos: {}, // Separate cache for channel forms to avoid reloading
isLoading: false,
backgroundLoading: false,
hasLoadedAll: false, // Track if we've loaded all logos
hasLoadedChannelLogos: false, // Track if we've loaded channel-assignable logos
hasLoadedChannelLogos: false, // Track if we've loaded channel logos
error: null,
// Basic CRUD operations
@ -27,10 +27,9 @@ const useLogosStore = create((set, get) => ({
...state.logos,
[newLogo.id]: { ...newLogo },
};
// Add to channelLogos if the user has loaded channel-assignable logos
// Add to channelLogos if the user has loaded channel logos
// This means they're using channel forms and the new logo should be available there
// Newly created logos are channel-assignable (they start unused)
let newChannelLogos = state.channelLogos;
if (state.hasLoadedChannelLogos) {
newChannelLogos = {
@ -173,16 +172,15 @@ const useLogosStore = create((set, get) => ({
set({ backgroundLoading: true, error: null });
try {
// Load logos suitable for channel assignment (unused + channel-used, exclude VOD-only)
// Load all channel logos (no special filtering needed - all Logo entries are for channels)
const response = await api.getLogos({
channel_assignable: 'true',
no_pagination: 'true', // Get all channel-assignable logos
no_pagination: 'true', // Get all channel logos
});
// Handle both paginated and non-paginated responses
const logos = Array.isArray(response) ? response : response.results || [];
console.log(`Fetched ${logos.length} channel-assignable logos`);
console.log(`Fetched ${logos.length} channel logos`);
// Store in both places, but this is intentional and only when specifically requested
set({
@ -203,9 +201,9 @@ const useLogosStore = create((set, get) => ({
return logos;
} catch (error) {
console.error('Failed to fetch channel-assignable logos:', error);
console.error('Failed to fetch channel logos:', error);
set({
error: 'Failed to load channel-assignable logos.',
error: 'Failed to load channel logos.',
backgroundLoading: false,
});
throw error;
@ -327,7 +325,7 @@ const useLogosStore = create((set, get) => ({
}, 0); // Execute immediately but asynchronously
},
// Background loading specifically for channel-assignable logos after login
// Background loading for channel logos after login
backgroundLoadChannelLogos: async () => {
const { backgroundLoading, channelLogos, hasLoadedChannelLogos } = get();
@ -342,10 +340,10 @@ const useLogosStore = create((set, get) => ({
set({ backgroundLoading: true });
try {
console.log('Background loading channel-assignable logos...');
console.log('Background loading channel logos...');
await get().fetchChannelAssignableLogos();
console.log(
`Background loaded ${Object.keys(get().channelLogos).length} channel-assignable logos`
`Background loaded ${Object.keys(get().channelLogos).length} channel logos`
);
} catch (error) {
console.error('Background channel logo loading failed:', error);

View file

@ -0,0 +1,128 @@
import { create } from 'zustand';
import api from '../api';
const useVODLogosStore = create((set) => ({
vodLogos: {},
logos: [],
isLoading: false,
hasLoaded: false,
error: null,
totalCount: 0,
currentPage: 1,
pageSize: 25,
setVODLogos: (logos, totalCount = 0) => {
set({
vodLogos: logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;
}, {}),
totalCount,
hasLoaded: true,
});
},
removeVODLogo: (logoId) =>
set((state) => {
const newVODLogos = { ...state.vodLogos };
delete newVODLogos[logoId];
return {
vodLogos: newVODLogos,
totalCount: Math.max(0, state.totalCount - 1),
};
}),
fetchVODLogos: async (params = {}) => {
set({ isLoading: true, error: null });
try {
const response = await api.getVODLogos(params);
// Handle both paginated and non-paginated responses
const logos = Array.isArray(response) ? response : response.results || [];
const total = response.count || logos.length;
set({
vodLogos: logos.reduce((acc, logo) => {
acc[logo.id] = { ...logo };
return acc;
}, {}),
logos: logos,
totalCount: total,
isLoading: false,
hasLoaded: true,
});
return response;
} catch (error) {
console.error('Failed to fetch VOD logos:', error);
set({ error: 'Failed to load VOD logos.', isLoading: false });
throw error;
}
},
deleteVODLogo: async (logoId) => {
try {
await api.deleteVODLogo(logoId);
set((state) => {
const newVODLogos = { ...state.vodLogos };
delete newVODLogos[logoId];
const newLogos = state.logos.filter((logo) => logo.id !== logoId);
return {
vodLogos: newVODLogos,
logos: newLogos,
totalCount: Math.max(0, state.totalCount - 1),
};
});
} catch (error) {
console.error('Failed to delete VOD logo:', error);
throw error;
}
},
deleteVODLogos: async (logoIds) => {
try {
await api.deleteVODLogos(logoIds);
set((state) => {
const newVODLogos = { ...state.vodLogos };
logoIds.forEach((id) => delete newVODLogos[id]);
const logoIdSet = new Set(logoIds);
const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id));
return {
vodLogos: newVODLogos,
logos: newLogos,
totalCount: Math.max(0, state.totalCount - logoIds.length),
};
});
} catch (error) {
console.error('Failed to delete VOD logos:', error);
throw error;
}
},
cleanupUnusedVODLogos: async () => {
try {
const result = await api.cleanupUnusedVODLogos();
// Refresh the logos after cleanup
const state = useVODLogosStore.getState();
await state.fetchVODLogos({
page: state.currentPage,
page_size: state.pageSize,
});
return result;
} catch (error) {
console.error('Failed to cleanup unused VOD logos:', error);
throw error;
}
},
clearVODLogos: () => {
set({
vodLogos: {},
logos: [],
hasLoaded: false,
totalCount: 0,
error: null,
});
},
}));
export default useVODLogosStore;