mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
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:
parent
871f9f953e
commit
da628705df
16 changed files with 1423 additions and 242 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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', ''),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
556
frontend/src/components/tables/VODLogosTable.jsx
Normal file
556
frontend/src/components/tables/VODLogosTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
128
frontend/src/store/vodLogos.jsx
Normal file
128
frontend/src/store/vodLogos.jsx
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue