From d2b852c9a285ca170bf3c0c6d9f3ed90a1121f87 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 28 Aug 2025 10:42:42 -0500 Subject: [PATCH] Fix cyclic error with logos loading. --- apps/vod/migrations/0001_initial.py | 6 +- ...0002_alter_movie_logo_alter_series_logo.py | 25 --- frontend/src/components/LazyLogo.jsx | 154 ++++++++++++------ frontend/src/components/forms/Channel.jsx | 10 +- frontend/src/components/forms/Channels.jsx | 10 +- frontend/src/hooks/useSmartLogos.jsx | 41 +++-- 6 files changed, 144 insertions(+), 102 deletions(-) delete mode 100644 apps/vod/migrations/0002_alter_movie_logo_alter_series_logo.py diff --git a/apps/vod/migrations/0001_initial.py b/apps/vod/migrations/0001_initial.py index 86cc441f..c6edf9de 100644 --- a/apps/vod/migrations/0001_initial.py +++ b/apps/vod/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.4 on 2025-08-25 22:26 +# Generated by Django 5.2.4 on 2025-08-28 14:46 import django.db.models.deletion import uuid @@ -31,7 +31,7 @@ class Migration(migrations.Migration): ('custom_properties', models.JSONField(blank=True, help_text='Additional metadata and properties for the movie', null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='movie', to='dispatcharr_channels.logo')), ], options={ 'verbose_name': 'Movie', @@ -54,7 +54,7 @@ class Migration(migrations.Migration): ('custom_properties', models.JSONField(blank=True, help_text='Additional metadata and properties for the series', null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='series', to='dispatcharr_channels.logo')), ], options={ 'verbose_name': 'Series', 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 deleted file mode 100644 index f3dbf7ea..00000000 --- a/apps/vod/migrations/0002_alter_movie_logo_alter_series_logo.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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/frontend/src/components/LazyLogo.jsx b/frontend/src/components/LazyLogo.jsx index 6ba15f7c..2b7ae5c9 100644 --- a/frontend/src/components/LazyLogo.jsx +++ b/frontend/src/components/LazyLogo.jsx @@ -1,66 +1,122 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Skeleton } from '@mantine/core'; import useLogosStore from '../store/logos'; import logo from '../images/logo.png'; // Default logo +// Global request queue to batch logo requests +const logoRequestQueue = new Set(); +let logoRequestTimer = null; + const LazyLogo = ({ - logoId, - alt = 'logo', - style = { maxHeight: 18, maxWidth: 55 }, - fallbackSrc = logo, - ...props + logoId, + alt = 'logo', + style = { maxHeight: 18, maxWidth: 55 }, + fallbackSrc = logo, + ...props }) => { - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); - const logos = useLogosStore((s) => s.logos); - const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const fetchAttempted = useRef(new Set()); // Track which IDs we've already tried to fetch + const isMountedRef = useRef(true); - // Determine the logo source - const logoData = logoId && logos[logoId]; - const logoSrc = logoData?.cache_url || (logoId ? `/api/channels/logos/${logoId}/cache/` : fallbackSrc); + const logos = useLogosStore((s) => s.logos); + const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds); - useEffect(() => { - // If we have a logoId but no logo data, try to fetch it - if (logoId && !logoData && !isLoading && !hasError) { - setIsLoading(true); - fetchLogosByIds([logoId]) - .then(() => { - setIsLoading(false); - }) - .catch((error) => { - console.warn(`Failed to load logo ${logoId}:`, error); - setIsLoading(false); - setHasError(true); - }); + // Determine the logo source + const logoData = logoId && logos[logoId]; + const logoSrc = logoData?.cache_url || fallbackSrc; // Only use cache URL if we have logo data + + // Cleanup on unmount + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + // If we have a logoId but no logo data, add it to the batch request queue + if ( + logoId && + !logoData && + !isLoading && + !hasError && + !fetchAttempted.current.has(logoId) && + isMountedRef.current + ) { + setIsLoading(true); + fetchAttempted.current.add(logoId); // Mark this ID as attempted + logoRequestQueue.add(logoId); + + // Clear existing timer and set new one to batch requests + if (logoRequestTimer) { + clearTimeout(logoRequestTimer); + } + + logoRequestTimer = setTimeout(async () => { + if (logoRequestQueue.size > 0) { + const idsToFetch = Array.from(logoRequestQueue); + logoRequestQueue.clear(); + + try { + await fetchLogosByIds(idsToFetch); + } catch (error) { + console.warn(`Failed to load logos:`, error); + // Mark failed IDs so they can be retried + idsToFetch.forEach((id) => { + if (fetchAttempted.current.has(id)) { + fetchAttempted.current.delete(id); + } + }); + } } - }, [logoId, logoData, fetchLogosByIds, isLoading, hasError]); - // Show skeleton while loading - if (isLoading) { - return ( - - ); + // Update loading state for all components + if (isMountedRef.current) { + setIsLoading(false); + } + }, 100); // Batch requests for 100ms } - // Show image (will use fallback if logo fails to load) + // If we now have the logo data, stop loading + if (logoData && isLoading && isMountedRef.current) { + setIsLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logoId, fetchLogosByIds, logoData]); // Include logoData to detect when it becomes available + + // Reset error state when logoId changes + useEffect(() => { + if (logoId) { + setHasError(false); + } + }, [logoId]); + + // Show skeleton while loading + if (isLoading && !logoData) { return ( - {alt} { - if (!hasError) { - setHasError(true); - e.target.src = fallbackSrc; - } - }} - {...props} - /> + ); + } + + // Show image (will use fallback if logo fails to load) + return ( + {alt} { + if (!hasError) { + setHasError(true); + e.target.src = fallbackSrc; + } + }} + {...props} + /> + ); }; export default LazyLogo; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index bc2edea2..d9d148e2 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import useChannelsStore from '../../store/channels'; @@ -70,7 +70,6 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const [selectedEPG, setSelectedEPG] = useState(''); const [tvgFilter, setTvgFilter] = useState(''); const [logoFilter, setLogoFilter] = useState(''); - const [logoOptions, setLogoOptions] = useState([]); const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); const [groupFilter, setGroupFilter] = useState(''); @@ -241,9 +240,10 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } }, [channel, tvgsById, channelGroups]); - useEffect(() => { - setLogoOptions([{ id: '0', name: 'Default' }].concat(Object.values(logos))); - }, [logos]); + // Memoize logo options to prevent infinite re-renders during background loading + const logoOptions = useMemo(() => { + return [{ id: '0', name: 'Default' }].concat(Object.values(logos)); + }, [logos]); // Only depend on logos object const renderLogoOption = ({ option, checked }) => { return ( diff --git a/frontend/src/components/forms/Channels.jsx b/frontend/src/components/forms/Channels.jsx index 25ac678a..97efea54 100644 --- a/frontend/src/components/forms/Channels.jsx +++ b/frontend/src/components/forms/Channels.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import useChannelsStore from '../../store/channels'; @@ -63,7 +63,6 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => { const [selectedEPG, setSelectedEPG] = useState(''); const [tvgFilter, setTvgFilter] = useState(''); const [logoFilter, setLogoFilter] = useState(''); - const [logoOptions, setLogoOptions] = useState([]); const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); const [groupFilter, setGroupFilter] = useState(''); @@ -232,9 +231,10 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => { } }, [channel, tvgsById, channelGroups]); - useEffect(() => { - setLogoOptions([{ id: '0', name: 'Default' }].concat(Object.values(logos))); - }, [logos]); + // Memoize logo options to prevent infinite re-renders during background loading + const logoOptions = useMemo(() => { + return [{ id: '0', name: 'Default' }].concat(Object.values(logos)); + }, [logos]); // Only depend on logos object const renderLogoOption = ({ option, checked }) => { return ( diff --git a/frontend/src/hooks/useSmartLogos.jsx b/frontend/src/hooks/useSmartLogos.jsx index 8d7b99b1..9ab81dbd 100644 --- a/frontend/src/hooks/useSmartLogos.jsx +++ b/frontend/src/hooks/useSmartLogos.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import useLogosStore from '../store/logos'; /** @@ -46,16 +46,14 @@ export const useChannelLogoSelection = () => { const channelLogos = useLogosStore((s) => s.channelLogos); const hasLoadedChannelLogos = useLogosStore((s) => s.hasLoadedChannelLogos); - const backgroundLoading = useLogosStore((s) => s.backgroundLoading); // Use global loading state + const backgroundLoading = useLogosStore((s) => s.backgroundLoading); const fetchChannelAssignableLogos = useLogosStore( (s) => s.fetchChannelAssignableLogos ); - // Check if we have channel-assignable logos loaded const hasLogos = Object.keys(channelLogos).length > 0; const ensureLogosLoaded = useCallback(async () => { - // Use global loading state instead of local state if (backgroundLoading || (hasLoadedChannelLogos && isInitialized)) { return; } @@ -67,20 +65,15 @@ export const useChannelLogoSelection = () => { console.error('Failed to load channel-assignable logos:', error); } }, [ - backgroundLoading, // Use global loading state + backgroundLoading, hasLoadedChannelLogos, isInitialized, fetchChannelAssignableLogos, ]); - // Auto-load logos when hook is first used - useEffect(() => { - ensureLogosLoaded(); - }, [ensureLogosLoaded]); - return { - logos: channelLogos, // Return channelLogos instead of all logos - isLoading: backgroundLoading, // Use global loading state + logos: channelLogos, + isLoading: backgroundLoading, ensureLogosLoaded, hasLogos, }; @@ -91,22 +84,40 @@ export const useChannelLogoSelection = () => { */ export const useLogosById = (logoIds = []) => { const [isLoading, setIsLoading] = useState(false); + const [loadedIds, setLoadedIds] = useState(new Set()); const logos = useLogosStore((s) => s.logos); - const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds); // Find missing logos - const missingIds = logoIds.filter((id) => id && !logos[id]); + const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds); + + // Memoize missing IDs calculation to prevent infinite loops + const missingIds = useMemo(() => { + return logoIds.filter((id) => id && !logos[id] && !loadedIds.has(id)); + }, [logoIds, logos, loadedIds]); + + // Stringify logoIds to prevent array reference issues + const logoIdsString = logoIds.join(','); useEffect(() => { if (missingIds.length > 0 && !isLoading) { setIsLoading(true); + + // Track that we're loading these IDs to prevent re-requests + setLoadedIds((prev) => new Set([...prev, ...missingIds])); + fetchLogosByIds(missingIds) .then(() => setIsLoading(false)) .catch((error) => { console.error('Failed to load logos by IDs:', error); + // Remove failed IDs from loaded set so they can be retried + setLoadedIds((prev) => { + const newSet = new Set(prev); + missingIds.forEach((id) => newSet.delete(id)); + return newSet; + }); setIsLoading(false); }); } - }, [missingIds, isLoading, fetchLogosByIds]); + }, [logoIdsString, missingIds, isLoading, fetchLogosByIds]); return { logos,