Major rework of how logos are handle. We will no longer load logos on login and they will only be loaded into the store when needed.

This commit is contained in:
SergeantPanda 2025-08-26 13:19:29 -05:00
parent 85a0b584a5
commit b92d7c2c21
9 changed files with 409 additions and 187 deletions

View file

@ -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)

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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")

View file

@ -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()}`

View file

@ -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={
<div>
<Text size="xs" fw={600}>
Used by {count} channel{count !== 1 ? 's' : ''}:
Used by {count} item{count !== 1 ? 's' : ''}:
</Text>
{channelNames.map((name, index) => (
<Text key={index} size="xs">
@ -429,7 +429,7 @@ const LogosTable = () => {
width={220}
>
<Badge size="sm" variant="light" color="blue">
{count} channel{count !== 1 ? 's' : ''}
{count} item{count !== 1 ? 's' : ''}
</Badge>
</Tooltip>
);
@ -689,7 +689,7 @@ const LogosTable = () => {
}}
>
<div>
<LoadingOverlay visible={isLoading} />
<LoadingOverlay visible={isLoading || storeLoading} />
<CustomTable table={table} />
</div>
</Box>
@ -758,7 +758,8 @@ const LogosTable = () => {
Are you sure you want to delete {selectedRows.size} selected
logos?
<Text size="sm" c="dimmed" mt="xs">
Any channels using these logos will have their logo removed.
Any channels, movies, or series using these logos will have
their logo removed.
</Text>
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
@ -770,8 +771,8 @@ const LogosTable = () => {
{logoToDelete.channel_count > 0 && (
<Text size="sm" c="orange" mt="xs">
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.
</Text>
)}
<Text size="sm" c="dimmed" mt="xs">

View file

@ -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 (
<Box style={{ padding: 10 }}>
<LogosTable />
</Box>
);
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...
</Text>
</Stack>
</Center>
)}
<LogosTable />
</Box>
);
};
export default LogosPage;

View file

@ -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);
}

View file

@ -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;