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 (
-
{
- if (!hasError) {
- setHasError(true);
- e.target.src = fallbackSrc;
- }
- }}
- {...props}
- />
+
);
+ }
+
+ // Show image (will use fallback if logo fails to load)
+ return (
+
{
+ 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,