diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index f0f59f29..96b7362f 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -1038,6 +1038,31 @@ class LogoViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
+ def create(self, request, *args, **kwargs):
+ """Create a new logo entry"""
+ serializer = self.get_serializer(data=request.data)
+ if serializer.is_valid():
+ logo = serializer.save()
+ return Response(self.get_serializer(logo).data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+ def update(self, request, *args, **kwargs):
+ """Update an existing logo"""
+ return super().update(request, *args, **kwargs)
+
+ def destroy(self, request, *args, **kwargs):
+ """Delete a logo"""
+ logo = self.get_object()
+
+ # Check if logo is being used by any channels
+ if logo.channels.exists():
+ return Response(
+ {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ return super().destroy(request, *args, **kwargs)
+
@action(detail=False, methods=["post"])
def upload(self, request):
if "file" not in request.FILES:
@@ -1062,7 +1087,7 @@ class LogoViewSet(viewsets.ModelViewSet):
)
return Response(
- {"id": logo.id, "name": logo.name, "url": logo.url},
+ LogoSerializer(logo, context={'request': request}).data,
status=status.HTTP_201_CREATED,
)
@@ -1092,7 +1117,13 @@ class LogoViewSet(viewsets.ModelViewSet):
else: # Remote image
try:
- remote_response = requests.get(logo_url, stream=True)
+ # Add proper timeouts to prevent hanging
+ remote_response = requests.get(
+ logo_url,
+ stream=True,
+ timeout=(10, 30), # (connect_timeout, read_timeout)
+ headers={'User-Agent': 'Dispatcharr/1.0'}
+ )
if remote_response.status_code == 200:
# Try to get content type from response headers first
content_type = remote_response.headers.get("Content-Type")
@@ -1114,7 +1145,14 @@ class LogoViewSet(viewsets.ModelViewSet):
)
return response
raise Http404("Remote image not found")
- except requests.RequestException:
+ except requests.exceptions.Timeout:
+ logger.warning(f"Timeout fetching logo from {logo_url}")
+ raise Http404("Logo request timed out")
+ except requests.exceptions.ConnectionError:
+ logger.warning(f"Connection error fetching logo from {logo_url}")
+ raise Http404("Unable to connect to logo server")
+ except requests.RequestException as e:
+ logger.warning(f"Error fetching logo from {logo_url}: {e}")
raise Http404("Error fetching remote image")
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a057be50..4467759e 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -15,6 +15,7 @@ import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import Users from './pages/Users';
+import LogosPage from './pages/Logos';
import useAuthStore from './store/auth';
import FloatingVideo from './components/FloatingVideo';
import { WebsocketProvider } from './WebSocket';
@@ -133,6 +134,7 @@ const App = () => {
} />
} />
} />
+ } />
>
) : (
} />
diff --git a/frontend/src/api.js b/frontend/src/api.js
index e34dabe2..cbd8950a 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1253,6 +1253,50 @@ export default class API {
}
}
+ static async createLogo(values) {
+ try {
+ const response = await request(`${host}/api/channels/logos/`, {
+ method: 'POST',
+ body: values,
+ });
+
+ useChannelsStore.getState().addLogo(response);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to create logo', e);
+ }
+ }
+
+ static async updateLogo(id, values) {
+ try {
+ const response = await request(`${host}/api/channels/logos/${id}/`, {
+ method: 'PUT',
+ body: values,
+ });
+
+ useChannelsStore.getState().updateLogo(response);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to update logo', e);
+ }
+ }
+
+ static async deleteLogo(id) {
+ try {
+ await request(`${host}/api/channels/logos/${id}/`, {
+ method: 'DELETE',
+ });
+
+ useChannelsStore.getState().removeLogo(id);
+
+ return true;
+ } catch (e) {
+ errorNotification('Failed to delete logo', e);
+ }
+ }
+
static async getChannelProfiles() {
try {
const response = await request(`${host}/api/channels/profiles/`);
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 6d69e9e7..96a0746a 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -13,6 +13,7 @@ import {
Ellipsis,
LogOut,
User,
+ FileImage,
} from 'lucide-react';
import {
Avatar,
@@ -109,6 +110,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
icon: ,
path: '/users',
},
+ {
+ label: 'Logos',
+ icon: ,
+ path: '/logos',
+ },
{
label: 'Settings',
icon: ,
diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx
new file mode 100644
index 00000000..c3e48d5d
--- /dev/null
+++ b/frontend/src/components/forms/Logo.jsx
@@ -0,0 +1,225 @@
+import React, { useState, useEffect } from 'react';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import {
+ Modal,
+ TextInput,
+ Button,
+ Group,
+ Stack,
+ Image,
+ Text,
+ Center,
+ Box,
+ Divider,
+} from '@mantine/core';
+import { Dropzone } from '@mantine/dropzone';
+import { Upload, FileImage, X } from 'lucide-react';
+import { notifications } from '@mantine/notifications';
+import API from '../../api';
+
+const LogoForm = ({ logo = null, isOpen, onClose }) => {
+ const [logoPreview, setLogoPreview] = useState(null);
+ const [uploading, setUploading] = useState(false);
+
+ const formik = useFormik({
+ initialValues: {
+ name: '',
+ url: '',
+ },
+ validationSchema: Yup.object({
+ name: Yup.string().required('Name is required'),
+ url: Yup.string().url('Must be a valid URL').required('URL is required'),
+ }),
+ onSubmit: async (values, { setSubmitting }) => {
+ try {
+ if (logo) {
+ await API.updateLogo(logo.id, values);
+ notifications.show({
+ title: 'Success',
+ message: 'Logo updated successfully',
+ color: 'green',
+ });
+ } else {
+ await API.createLogo(values);
+ notifications.show({
+ title: 'Success',
+ message: 'Logo created successfully',
+ color: 'green',
+ });
+ }
+ onClose();
+ } catch (error) {
+ let errorMessage = logo ? 'Failed to update logo' : 'Failed to create logo';
+
+ // Handle specific timeout errors
+ if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
+ errorMessage = 'Request timed out. Please try again.';
+ }
+
+ notifications.show({
+ title: 'Error',
+ message: errorMessage,
+ color: 'red',
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ });
+
+ useEffect(() => {
+ if (logo) {
+ formik.setValues({
+ name: logo.name || '',
+ url: logo.url || '',
+ });
+ setLogoPreview(logo.cache_url);
+ } else {
+ formik.resetForm();
+ setLogoPreview(null);
+ }
+ }, [logo, isOpen]);
+
+ const handleFileUpload = async (files) => {
+ if (files.length === 0) return;
+
+ const file = files[0];
+ setUploading(true);
+
+ try {
+ const response = await API.uploadLogo(file);
+
+ // Update form with uploaded file info
+ formik.setFieldValue('name', response.name);
+ formik.setFieldValue('url', response.url);
+ setLogoPreview(response.cache_url);
+
+ notifications.show({
+ title: 'Success',
+ message: 'Logo uploaded successfully',
+ color: 'green',
+ });
+ } catch (error) {
+ let errorMessage = 'Failed to upload logo';
+
+ // Handle specific timeout errors
+ if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
+ errorMessage = 'Upload timed out. Please try again.';
+ }
+
+ notifications.show({
+ title: 'Error',
+ message: errorMessage,
+ color: 'red',
+ });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleUrlChange = (event) => {
+ const url = event.target.value;
+ formik.setFieldValue('url', url);
+
+ // Update preview for remote URLs
+ if (url && url.startsWith('http')) {
+ setLogoPreview(url);
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default LogoForm;
diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx
new file mode 100644
index 00000000..7ca879f6
--- /dev/null
+++ b/frontend/src/pages/Logos.jsx
@@ -0,0 +1,223 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Container,
+ Title,
+ Button,
+ Table,
+ Group,
+ ActionIcon,
+ Text,
+ Image,
+ Box,
+ Center,
+ Stack,
+ Badge,
+} from '@mantine/core';
+import { SquarePen, Trash2, Plus, ExternalLink } from 'lucide-react';
+import { notifications } from '@mantine/notifications';
+import useChannelsStore from '../store/channels';
+import API from '../api';
+import LogoForm from '../components/forms/Logo';
+import ConfirmationDialog from '../components/ConfirmationDialog';
+
+const LogosPage = () => {
+ const { logos, fetchLogos } = useChannelsStore();
+ const [logoFormOpen, setLogoFormOpen] = useState(false);
+ const [editingLogo, setEditingLogo] = useState(null);
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
+ const [logoToDelete, setLogoToDelete] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadLogos();
+ }, []);
+
+ const loadLogos = async () => {
+ setLoading(true);
+ try {
+ await fetchLogos();
+ } catch (error) {
+ notifications.show({
+ title: 'Error',
+ message: 'Failed to load logos',
+ color: 'red',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreateLogo = () => {
+ setEditingLogo(null);
+ setLogoFormOpen(true);
+ };
+
+ const handleEditLogo = (logo) => {
+ setEditingLogo(logo);
+ setLogoFormOpen(true);
+ };
+
+ const handleDeleteLogo = (logo) => {
+ setLogoToDelete(logo);
+ setDeleteConfirmOpen(true);
+ };
+
+ const confirmDeleteLogo = async () => {
+ if (!logoToDelete) return;
+
+ try {
+ await API.deleteLogo(logoToDelete.id);
+ await fetchLogos();
+ notifications.show({
+ title: 'Success',
+ message: 'Logo deleted successfully',
+ color: 'green',
+ });
+ } catch (error) {
+ notifications.show({
+ title: 'Error',
+ message: 'Failed to delete logo',
+ color: 'red',
+ });
+ } finally {
+ setDeleteConfirmOpen(false);
+ setLogoToDelete(null);
+ }
+ };
+
+ const handleFormClose = () => {
+ setLogoFormOpen(false);
+ setEditingLogo(null);
+ loadLogos(); // Refresh the logos list
+ };
+
+ const logosArray = Object.values(logos || {});
+
+ const rows = logosArray.map((logo) => (
+
+
+
+
+
+
+
+ {logo.name}
+
+
+
+
+ {logo.url}
+
+ {logo.url.startsWith('http') && (
+ window.open(logo.url, '_blank')}
+ >
+
+
+ )}
+
+
+
+
+ handleEditLogo(logo)}
+ color="blue"
+ >
+
+
+ handleDeleteLogo(logo)}
+ color="red"
+ >
+
+
+
+
+
+ ));
+
+ return (
+ <>
+
+
+ Logos
+ } onClick={handleCreateLogo}>
+ Add Logo
+
+
+
+ {loading ? (
+
+ Loading logos...
+
+ ) : logosArray.length === 0 ? (
+
+
+ No logos found
+ Click "Add Logo" to create your first logo
+
+
+ ) : (
+
+
+ Total: {logosArray.length} logo{logosArray.length !== 1 ? 's' : ''}
+
+
+
+
+
+ Preview
+ Name
+ URL
+ Actions
+
+
+ {rows}
+
+
+ )}
+
+
+
+
+ setDeleteConfirmOpen(false)}
+ onConfirm={confirmDeleteLogo}
+ title="Delete Logo"
+ message={
+ logoToDelete ? (
+
+ Are you sure you want to delete the logo "{logoToDelete.name}"?
+
+
+ This action cannot be undone.
+
+
+ ) : (
+ 'Are you sure you want to delete this logo?'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ />
+ >
+ );
+};
+
+export default LogosPage;
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index 40791cf4..a4c61149 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -21,7 +21,7 @@ const useChannelsStore = create((set, get) => ({
forceUpdate: 0,
triggerUpdate: () => {
- set({ forecUpdate: new Date() });
+ set({ forceUpdate: new Date() });
},
fetchChannels: async () => {
@@ -255,6 +255,24 @@ const useChannelsStore = create((set, get) => ({
},
})),
+ updateLogo: (logo) =>
+ set((state) => ({
+ logos: {
+ ...state.logos,
+ [logo.id]: {
+ ...logo,
+ url: logo.url.replace(/^\/data/, ''),
+ },
+ },
+ })),
+
+ removeLogo: (logoId) =>
+ set((state) => {
+ const newLogos = { ...state.logos };
+ delete newLogos[logoId];
+ return { logos: newLogos };
+ }),
+
addProfile: (profile) =>
set((state) => ({
profiles: {