Fix cyclic error with logos loading.

This commit is contained in:
SergeantPanda 2025-08-28 10:42:42 -05:00
parent 73065ed319
commit d2b852c9a2
6 changed files with 144 additions and 102 deletions

View file

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

View file

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

View file

@ -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 (
<Skeleton
height={style.maxHeight || 18}
width={style.maxWidth || 55}
style={{ ...style, borderRadius: 4 }}
/>
);
// 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 (
<img
src={logoSrc}
alt={alt}
style={style}
onError={(e) => {
if (!hasError) {
setHasError(true);
e.target.src = fallbackSrc;
}
}}
{...props}
/>
<Skeleton
height={style.maxHeight || 18}
width={style.maxWidth || 55}
style={{ ...style, borderRadius: 4 }}
/>
);
}
// Show image (will use fallback if logo fails to load)
return (
<img
src={logoSrc}
alt={alt}
style={style}
onError={(e) => {
if (!hasError) {
setHasError(true);
e.target.src = fallbackSrc;
}
}}
{...props}
/>
);
};
export default LazyLogo;

View file

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

View file

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

View file

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