diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 849a0c9b..0a4ae81e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -44,6 +44,7 @@ import django_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter from apps.epg.models import EPGData +from apps.vod.models import Movie, Series from django.db.models import Q from django.http import StreamingHttpResponse, FileResponse, Http404 import mimetypes @@ -1206,7 +1207,7 @@ class CleanupUnusedLogosAPIView(APIView): return [Authenticated()] @swagger_auto_schema( - operation_description="Delete all logos that are not used by any channels", + operation_description="Delete all logos that are not used by any channels, movies, or series", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ @@ -1220,10 +1221,24 @@ class CleanupUnusedLogosAPIView(APIView): responses={200: "Cleanup completed"}, ) def post(self, request): - """Delete all logos with no channel associations""" + """Delete all logos with no channel, movie, or series associations""" delete_files = request.data.get("delete_files", False) - unused_logos = Logo.objects.filter(channels__isnull=True) + # 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_set__isnull=True) + except: + pass + + try: + filter_conditions &= Q(series_set__isnull=True) + except: + pass + + unused_logos = Logo.objects.filter(filter_conditions) deleted_count = unused_logos.count() logo_names = list(unused_logos.values_list('name', flat=True)) local_files_deleted = 0 @@ -1259,9 +1274,23 @@ class CleanupUnusedLogosAPIView(APIView): }) +class LogoPagination(PageNumberPagination): + page_size = 50 # Default page size to match frontend default + page_size_query_param = "page_size" # Allow clients to specify page size + max_page_size = 1000 # Prevent excessive page sizes + + def paginate_queryset(self, queryset, request, view=None): + # Check if pagination should be disabled for specific requests + if request.query_params.get('no_pagination') == 'true': + return None # disables pagination, returns full queryset + + return super().paginate_queryset(queryset, request, view) + + class LogoViewSet(viewsets.ModelViewSet): queryset = Logo.objects.all() serializer_class = LogoSerializer + pagination_class = LogoPagination parser_classes = (MultiPartParser, FormParser, JSONParser) def get_permissions(self): @@ -1278,8 +1307,16 @@ class LogoViewSet(viewsets.ModelViewSet): def get_queryset(self): """Optimize queryset with prefetch and add filtering""" + # 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: @@ -1292,12 +1329,41 @@ class LogoViewSet(viewsets.ModelViewSet): pass # Invalid IDs, return empty queryset queryset = Logo.objects.none() - # Filter by usage + # Filter by usage - now includes VOD content used_filter = self.request.query_params.get('used', None) if used_filter == 'true': - queryset = queryset.filter(channels__isnull=False).distinct() + # 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() + elif used_filter == 'false': - queryset = queryset.filter(channels__isnull=True) + # 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 by name name_filter = self.request.query_params.get('name', None) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 72320840..0b29353e 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -61,19 +61,81 @@ class LogoSerializer(serializers.ModelSerializer): return reverse("api:channels:logo-cache", args=[obj.id]) def get_channel_count(self, obj): - """Get the number of channels using this logo""" - return obj.channels.count() + """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 def get_is_used(self, obj): - """Check if this logo is used by any channels""" - return obj.channels.exists() + """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 def get_channel_names(self, obj): - """Get the names of channels using this logo (limited to first 5)""" + """Get the names of channels, movies, and series using this logo (limited to first 5)""" + names = [] + + # Get channel names channels = obj.channels.all()[:5] - names = [channel.name for channel in channels] - if obj.channels.count() > 5: - names.append(f"...and {obj.channels.count() - 5} more") + 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: + names.append(f"...and {total_count - 5} more") + return names diff --git a/apps/vod/migrations/0002_alter_movie_logo_alter_series_logo.py b/apps/vod/migrations/0002_alter_movie_logo_alter_series_logo.py new file mode 100644 index 00000000..f3dbf7ea --- /dev/null +++ b/apps/vod/migrations/0002_alter_movie_logo_alter_series_logo.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.4 on 2025-08-26 16:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0024_alter_channelgroupm3uaccount_channel_group'), + ('vod', '0001_initial'), + ] + + operations = [ + 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='dispatcharr_channels.logo'), + ), + 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='dispatcharr_channels.logo'), + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index eccef94f..0e799144 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -69,7 +69,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) + logo = models.ForeignKey(Logo, 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 +108,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) + logo = models.ForeignKey(Logo, 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") diff --git a/frontend/src/api.js b/frontend/src/api.js index a197ae4e..982eae78 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1310,6 +1310,8 @@ export default class API { const params = new URLSearchParams(); logoIds.forEach(id => params.append('ids', id)); + // Disable pagination for ID-based queries to get all matching logos + params.append('no_pagination', 'true'); const response = await request( `${host}/api/channels/logos/?${params.toString()}` diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index ae48af1f..70cbaeb6 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -83,7 +83,7 @@ const LogosTable = () => { /** * STORES */ - const { logos, fetchLogos } = useLogosStore(); + const { logos, fetchLogos, isLoading: storeLoading } = useLogosStore(); /** * useState @@ -416,7 +416,7 @@ const LogosTable = () => { label={
- Used by {count} channel{count !== 1 ? 's' : ''}: + Used by {count} item{count !== 1 ? 's' : ''}: {channelNames.map((name, index) => ( @@ -429,7 +429,7 @@ const LogosTable = () => { width={220} > - {count} channel{count !== 1 ? 's' : ''} + {count} item{count !== 1 ? 's' : ''} ); @@ -689,7 +689,7 @@ const LogosTable = () => { }} >
- +
@@ -758,7 +758,8 @@ const LogosTable = () => { Are you sure you want to delete {selectedRows.size} selected logos? - Any channels using these logos will have their logo removed. + Any channels, movies, or series using these logos will have + their logo removed. This action cannot be undone. @@ -770,8 +771,8 @@ const LogosTable = () => { {logoToDelete.channel_count > 0 && ( This logo is currently used by {logoToDelete.channel_count}{' '} - channel{logoToDelete.channel_count !== 1 ? 's' : ''}. They - will have their logo removed. + item{logoToDelete.channel_count !== 1 ? 's' : ''}. They will + have their logo removed. )} diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx index 220e9bab..92a4aab8 100644 --- a/frontend/src/pages/Logos.jsx +++ b/frontend/src/pages/Logos.jsx @@ -1,37 +1,47 @@ import React, { useEffect, useCallback } from 'react'; -import { Box } from '@mantine/core'; +import { Box, Loader, Center, Text, Stack } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import useLogosStore from '../store/logos'; import LogosTable from '../components/tables/LogosTable'; const LogosPage = () => { - const { fetchLogos, logos } = useLogosStore(); + const { fetchAllLogos, isLoading, needsAllLogos } = useLogosStore(); - const loadLogos = useCallback(async () => { - try { - // Only fetch all logos if we don't have any yet - if (Object.keys(logos).length === 0) { - await fetchLogos(); - } - } catch (err) { - notifications.show({ - title: 'Error', - message: 'Failed to load logos', - color: 'red', - }); - console.error('Failed to load logos:', err); - } - }, [fetchLogos, logos]); + const loadLogos = useCallback(async () => { + try { + // Only fetch all logos if we haven't loaded them yet + if (needsAllLogos()) { + await fetchAllLogos(); + } + } catch (err) { + notifications.show({ + title: 'Error', + message: 'Failed to load logos', + color: 'red', + }); + console.error('Failed to load logos:', err); + } + }, [fetchAllLogos, needsAllLogos]); - useEffect(() => { - loadLogos(); - }, [loadLogos]); + useEffect(() => { + loadLogos(); + }, [loadLogos]); - return ( - - - - ); + return ( + + {isLoading && ( +
+ + + + Loading all logos... + + +
+ )} + +
+ ); }; export default LogosPage; diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 70bf929c..e3fc7f57 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -2,7 +2,6 @@ import { create } from 'zustand'; import api from '../api'; import useSettingsStore from './settings'; import useChannelsStore from './channels'; -import useLogosStore from './logos'; import usePlaylistsStore from './playlists'; import useEPGsStore from './epgs'; import useStreamProfilesStore from './streamProfiles'; @@ -10,7 +9,6 @@ import useUserAgentsStore from './userAgents'; import useUsersStore from './users'; import API from '../api'; import { USER_LEVELS } from '../constants'; -import useVODStore from './useVODStore'; const decodeToken = (token) => { if (!token) return null; @@ -48,7 +46,7 @@ const useAuthStore = create((set, get) => ({ await useSettingsStore.getState().fetchSettings(); try { - // Load essential data first (without all logos) + // Only after settings are loaded, fetch the essential data await Promise.all([ useChannelsStore.getState().fetchChannels(), useChannelsStore.getState().fetchChannelGroups(), @@ -58,23 +56,13 @@ const useAuthStore = create((set, get) => ({ useEPGsStore.getState().fetchEPGData(), useStreamProfilesStore.getState().fetchProfiles(), useUserAgentsStore.getState().fetchUserAgents(), - useVODStore.getState().fetchCategories(), // Add VOD categories ]); - // Load only logos that are currently used by channels (much faster) - await useLogosStore.getState().fetchUsedLogos(); - if (user.user_level >= USER_LEVELS.ADMIN) { await Promise.all([useUsersStore.getState().fetchUsers()]); } set({ user, isAuthenticated: true }); - - // Start background loading of remaining logos after login is complete - setTimeout(() => { - useLogosStore.getState().fetchLogosInBackground(); - }, 2000); // 2 second delay to let UI settle - } catch (error) { console.error('Error initializing data:', error); } diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 04e56099..441997d6 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -2,141 +2,209 @@ import { create } from 'zustand'; import api from '../api'; const useLogosStore = create((set, get) => ({ - logos: {}, - isLoading: false, - error: null, + logos: {}, + isLoading: false, + backgroundLoading: false, + hasLoadedAll: false, // Track if we've loaded all logos + error: null, - // Basic CRUD operations - setLogos: (logos) => { - set({ - logos: logos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; + // Basic CRUD operations + setLogos: (logos) => { + set({ + logos: logos.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; + }, {}), + }); + }, + + addLogo: (newLogo) => + set((state) => ({ + logos: { + ...state.logos, + [newLogo.id]: { ...newLogo }, + }, + })), + + updateLogo: (logo) => + set((state) => ({ + logos: { + ...state.logos, + [logo.id]: { ...logo }, + }, + })), + + removeLogo: (logoId) => + set((state) => { + const newLogos = { ...state.logos }; + delete newLogos[logoId]; + return { logos: newLogos }; + }), + + // Smart loading methods + fetchLogos: async (pageSize = 100) => { + set({ isLoading: true, error: null }); + try { + const response = await api.getLogos({ page_size: pageSize }); + + // Handle both paginated and non-paginated responses + const logos = Array.isArray(response) ? response : response.results || []; + + set({ + logos: logos.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; + }, {}), + isLoading: false, + }); + return response; + } catch (error) { + console.error('Failed to fetch logos:', error); + set({ error: 'Failed to load logos.', isLoading: false }); + throw error; + } + }, + + fetchAllLogos: async () => { + set({ isLoading: true, error: null }); + try { + // Disable pagination to get all logos for management interface + const response = await api.getLogos({ no_pagination: 'true' }); + + // Handle both paginated and non-paginated responses + const logos = Array.isArray(response) ? response : response.results || []; + + set({ + logos: logos.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; + }, {}), + hasLoadedAll: true, // Mark that we've loaded all logos + isLoading: false, + }); + return logos; + } catch (error) { + console.error('Failed to fetch all logos:', error); + set({ error: 'Failed to load all logos.', isLoading: false }); + throw error; + } + }, + + fetchUsedLogos: async (pageSize = 100) => { + set({ isLoading: true, error: null }); + try { + // Load used logos with pagination for better performance + const response = await api.getLogos({ + used: 'true', + page_size: pageSize, + }); + + // Handle both paginated and non-paginated responses + const logos = Array.isArray(response) ? response : response.results || []; + + set((state) => ({ + logos: { + ...state.logos, + ...logos.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; + }, {}), + }, + isLoading: false, + })); + return response; + } catch (error) { + console.error('Failed to fetch used logos:', error); + set({ error: 'Failed to load used logos.', isLoading: false }); + throw error; + } + }, + + fetchLogosByIds: async (logoIds) => { + if (!logoIds || logoIds.length === 0) return []; + + try { + // Filter out logos we already have + const missingIds = logoIds.filter((id) => !get().logos[id]); + if (missingIds.length === 0) return []; + + const response = await api.getLogosByIds(missingIds); + + // Handle both paginated and non-paginated responses + const logos = Array.isArray(response) ? response : response.results || []; + + set((state) => ({ + logos: { + ...state.logos, + ...logos.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; + }, {}), + }, + })); + return logos; + } catch (error) { + console.error('Failed to fetch logos by IDs:', error); + throw error; + } + }, + + fetchLogosInBackground: async () => { + set({ backgroundLoading: true }); + try { + // Load logos in chunks using pagination for better performance + let page = 1; + const pageSize = 200; + let hasMore = true; + + while (hasMore) { + const response = await api.getLogos({ page, page_size: pageSize }); + + set((state) => ({ + logos: { + ...state.logos, + ...response.results.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; }, {}), - }); - }, + }, + })); - addLogo: (newLogo) => - set((state) => ({ - logos: { - ...state.logos, - [newLogo.id]: { ...newLogo }, - }, - })), + // Check if there are more pages + hasMore = !!response.next; + page++; - updateLogo: (logo) => - set((state) => ({ - logos: { - ...state.logos, - [logo.id]: { ...logo }, - }, - })), - - removeLogo: (logoId) => - set((state) => { - const newLogos = { ...state.logos }; - delete newLogos[logoId]; - return { logos: newLogos }; - }), - - // Smart loading methods - fetchLogos: async () => { - set({ isLoading: true, error: null }); - try { - const logos = await api.getLogos(); - set({ - logos: logos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; - }, {}), - isLoading: false, - }); - return logos; - } catch (error) { - console.error('Failed to fetch logos:', error); - set({ error: 'Failed to load logos.', isLoading: false }); - throw error; + // Add a small delay between chunks to avoid overwhelming the server + if (hasMore) { + await new Promise((resolve) => setTimeout(resolve, 100)); } - }, + } + } catch (error) { + console.error('Background logo loading failed:', error); + // Don't throw error for background loading + } finally { + set({ backgroundLoading: false }); + } + }, - fetchUsedLogos: async () => { - set({ isLoading: true, error: null }); - try { - const logos = await api.getLogos({ used: 'true' }); - set((state) => ({ - logos: { - ...state.logos, - ...logos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; - }, {}), - }, - isLoading: false, - })); - return logos; - } catch (error) { - console.error('Failed to fetch used logos:', error); - set({ error: 'Failed to load used logos.', isLoading: false }); - throw error; - } - }, + // Helper methods + getLogoById: (logoId) => { + return get().logos[logoId] || null; + }, - fetchLogosByIds: async (logoIds) => { - if (!logoIds || logoIds.length === 0) return []; + hasLogo: (logoId) => { + return !!get().logos[logoId]; + }, - try { - // Filter out logos we already have - const missingIds = logoIds.filter(id => !get().logos[id]); - if (missingIds.length === 0) return []; + getLogosCount: () => { + return Object.keys(get().logos).length; + }, - const logos = await api.getLogosByIds(missingIds); - set((state) => ({ - logos: { - ...state.logos, - ...logos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; - }, {}), - }, - })); - return logos; - } catch (error) { - console.error('Failed to fetch logos by IDs:', error); - throw error; - } - }, - - fetchLogosInBackground: async () => { - try { - // Load all remaining logos in background - const allLogos = await api.getLogos(); - set((state) => ({ - logos: { - ...state.logos, - ...allLogos.reduce((acc, logo) => { - acc[logo.id] = { ...logo }; - return acc; - }, {}), - }, - })); - } catch (error) { - console.error('Background logo loading failed:', error); - // Don't throw error for background loading - } - }, - - // Helper methods - getLogoById: (logoId) => { - return get().logos[logoId] || null; - }, - - hasLogo: (logoId) => { - return !!get().logos[logoId]; - }, - - getLogosCount: () => { - return Object.keys(get().logos).length; - }, + // Check if we need to fetch all logos (haven't loaded them yet or store is empty) + needsAllLogos: () => { + const state = get(); + return !state.hasLoadedAll || Object.keys(state.logos).length === 0; + }, })); export default useLogosStore;