diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx
index 4a473afe..68f4db8c 100644
--- a/frontend/src/components/forms/LiveGroupFilter.jsx
+++ b/frontend/src/components/forms/LiveGroupFilter.jsx
@@ -16,11 +16,20 @@ import {
Box,
MultiSelect,
Tooltip,
+ Popover,
+ ScrollArea,
+ Center,
} from '@mantine/core';
import { Info } from 'lucide-react';
import useChannelsStore from '../../store/channels';
import useStreamProfilesStore from '../../store/streamProfiles';
import { CircleCheck, CircleX } from 'lucide-react';
+import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
+import { FixedSizeList as List } from 'react-window';
+import LazyLogo from '../LazyLogo';
+import LogoForm from './Logo';
+import logo from '../../images/logo.png';
+import API from '../../api';
// Custom item component for MultiSelect with tooltip
const OptionWithTooltip = forwardRef(
@@ -33,12 +42,33 @@ const OptionWithTooltip = forwardRef(
)
);
-const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
+const LiveGroupFilter = ({
+ playlist,
+ groupStates,
+ setGroupStates,
+ autoEnableNewGroupsLive,
+ setAutoEnableNewGroupsLive,
+}) => {
const channelGroups = useChannelsStore((s) => s.channelGroups);
const profiles = useChannelsStore((s) => s.profiles);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const fetchStreamProfiles = useStreamProfilesStore((s) => s.fetchProfiles);
const [groupFilter, setGroupFilter] = useState('');
+ const [epgSources, setEpgSources] = useState([]);
+
+ // Logo selection functionality
+ const {
+ logos: channelLogos,
+ ensureLogosLoaded,
+ isLoading: logosLoading,
+ } = useChannelLogoSelection();
+ const [logoModalOpen, setLogoModalOpen] = useState(false);
+ const [currentEditingGroupId, setCurrentEditingGroupId] = useState(null);
+
+ // Ensure logos are loaded when component mounts
+ useEffect(() => {
+ ensureLogosLoaded();
+ }, [ensureLogosLoaded]);
// Fetch stream profiles when component mounts
useEffect(() => {
@@ -47,34 +77,49 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
}
}, [streamProfiles.length, fetchStreamProfiles]);
+ // Fetch EPG sources when component mounts
+ useEffect(() => {
+ const fetchEPGSources = async () => {
+ try {
+ const sources = await API.getEPGs();
+ setEpgSources(sources || []);
+ } catch (error) {
+ console.error('Failed to fetch EPG sources:', error);
+ }
+ };
+ fetchEPGSources();
+ }, []);
+
useEffect(() => {
if (Object.keys(channelGroups).length === 0) {
return;
}
setGroupStates(
- playlist.channel_groups.map((group) => {
- // Parse custom_properties if present
- let customProps = {};
- if (group.custom_properties) {
- try {
- customProps =
- typeof group.custom_properties === 'string'
- ? JSON.parse(group.custom_properties)
- : group.custom_properties;
- } catch (e) {
- customProps = {};
+ playlist.channel_groups
+ .filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist
+ .map((group) => {
+ // Parse custom_properties if present
+ let customProps = {};
+ if (group.custom_properties) {
+ try {
+ customProps =
+ typeof group.custom_properties === 'string'
+ ? JSON.parse(group.custom_properties)
+ : group.custom_properties;
+ } catch {
+ customProps = {};
+ }
}
- }
- return {
- ...group,
- name: channelGroups[group.channel_group].name,
- auto_channel_sync: group.auto_channel_sync || false,
- auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
- custom_properties: customProps,
- original_enabled: group.enabled,
- };
- })
+ return {
+ ...group,
+ name: channelGroups[group.channel_group].name,
+ auto_channel_sync: group.auto_channel_sync || false,
+ auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
+ custom_properties: customProps,
+ original_enabled: group.enabled,
+ };
+ })
);
}, [playlist, channelGroups]);
@@ -109,21 +154,27 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
);
};
- // Toggle force_dummy_epg in custom_properties for a group
- const toggleForceDummyEPG = (id) => {
- setGroupStates(
- groupStates.map((state) => {
- if (state.channel_group == id) {
- const customProps = { ...(state.custom_properties || {}) };
- customProps.force_dummy_epg = !customProps.force_dummy_epg;
- return {
- ...state,
- custom_properties: customProps,
- };
- }
- return state;
- })
- );
+ // Handle logo selection from LogoForm
+ const handleLogoSuccess = ({ logo }) => {
+ if (logo && logo.id && currentEditingGroupId !== null) {
+ setGroupStates(
+ groupStates.map((state) => {
+ if (state.channel_group === currentEditingGroupId) {
+ return {
+ ...state,
+ custom_properties: {
+ ...state.custom_properties,
+ custom_logo_id: logo.id,
+ },
+ };
+ }
+ return state;
+ })
+ );
+ ensureLogosLoaded(); // Refresh logos
+ }
+ setLogoModalOpen(false);
+ setCurrentEditingGroupId(null);
};
const selectAll = () => {
@@ -159,6 +210,16 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
+
+ setAutoEnableNewGroupsLive(event.currentTarget.checked)
+ }
+ size="sm"
+ description="When disabled, new groups from the M3U source will be created but disabled by default. You can enable them manually later."
+ />
+
{
}}
>
{/* Group Enable/Disable Button */}
-
+
+
{/* Auto Sync Controls */}
@@ -254,10 +332,10 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
placeholder="Select options..."
data={[
{
- value: 'force_dummy_epg',
- label: 'Force Dummy EPG',
+ value: 'force_epg',
+ label: 'Force EPG Source',
description:
- 'Assign a dummy EPG to all channels in this group if no EPG is matched',
+ 'Force a specific EPG source for all auto-synced channels, or disable EPG assignment entirely',
},
{
value: 'group_override',
@@ -295,12 +373,23 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
description:
'Assign a specific stream profile to all channels in this group during auto sync',
},
+ {
+ value: 'custom_logo',
+ label: 'Custom Logo',
+ description:
+ 'Assign a custom logo to all auto-synced channels in this group',
+ },
]}
itemComponent={OptionWithTooltip}
value={(() => {
const selectedValues = [];
- if (group.custom_properties?.force_dummy_epg) {
- selectedValues.push('force_dummy_epg');
+ if (
+ group.custom_properties?.custom_epg_id !==
+ undefined ||
+ group.custom_properties?.force_dummy_epg ||
+ group.custom_properties?.force_epg_selected
+ ) {
+ selectedValues.push('force_epg');
}
if (
group.custom_properties?.group_override !==
@@ -340,6 +429,12 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
) {
selectedValues.push('stream_profile_assignment');
}
+ if (
+ group.custom_properties?.custom_logo_id !==
+ undefined
+ ) {
+ selectedValues.push('custom_logo');
+ }
return selectedValues;
})()}
onChange={(values) => {
@@ -353,13 +448,22 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
...(state.custom_properties || {}),
};
- // Handle force_dummy_epg
- if (
- selectedOptions.includes('force_dummy_epg')
- ) {
- newCustomProps.force_dummy_epg = true;
+ // Handle force_epg
+ if (selectedOptions.includes('force_epg')) {
+ // Set default to force_dummy_epg if no EPG settings exist yet
+ if (
+ newCustomProps.custom_epg_id ===
+ undefined &&
+ !newCustomProps.force_dummy_epg
+ ) {
+ // Default to "No EPG (Disabled)"
+ newCustomProps.force_dummy_epg = true;
+ }
} else {
+ // Remove all EPG settings when deselected
+ delete newCustomProps.custom_epg_id;
delete newCustomProps.force_dummy_epg;
+ delete newCustomProps.force_epg_selected;
}
// Handle group_override
@@ -459,6 +563,17 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
delete newCustomProps.stream_profile_id;
}
+ // Handle custom_logo
+ if (selectedOptions.includes('custom_logo')) {
+ if (
+ newCustomProps.custom_logo_id === undefined
+ ) {
+ newCustomProps.custom_logo_id = null;
+ }
+ } else {
+ delete newCustomProps.custom_logo_id;
+ }
+
return {
...state,
custom_properties: newCustomProps,
@@ -785,6 +900,364 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
/>
)}
+
+ {/* Show logo selector only if custom_logo is selected */}
+ {group.custom_properties?.custom_logo_id !==
+ undefined && (
+
+
+ {
+ setGroupStates(
+ groupStates.map((state) => {
+ if (
+ state.channel_group ===
+ group.channel_group
+ ) {
+ return {
+ ...state,
+ logoPopoverOpened: opened,
+ };
+ }
+ return state;
+ })
+ );
+ if (opened) {
+ ensureLogosLoaded();
+ }
+ }}
+ withArrow
+ >
+
+ {
+ setGroupStates(
+ groupStates.map((state) => {
+ if (
+ state.channel_group ===
+ group.channel_group
+ ) {
+ return {
+ ...state,
+ logoPopoverOpened: true,
+ };
+ }
+ return {
+ ...state,
+ logoPopoverOpened: false,
+ };
+ })
+ );
+ }}
+ size="xs"
+ />
+
+
+ e.stopPropagation()}
+ >
+
+ {
+ const val = e.currentTarget.value;
+ setGroupStates(
+ groupStates.map((state) =>
+ state.channel_group ===
+ group.channel_group
+ ? {
+ ...state,
+ logoFilter: val,
+ }
+ : state
+ )
+ );
+ }}
+ />
+ {logosLoading && (
+
+ Loading...
+
+ )}
+
+
+
+ {(() => {
+ const logoOptions = [
+ { id: '0', name: 'Default' },
+ ...Object.values(channelLogos),
+ ];
+ const filteredLogos = logoOptions.filter(
+ (logo) =>
+ logo.name
+ .toLowerCase()
+ .includes(
+ (
+ group.logoFilter || ''
+ ).toLowerCase()
+ )
+ );
+
+ if (filteredLogos.length === 0) {
+ return (
+
+
+ {group.logoFilter
+ ? 'No logos match your filter'
+ : 'No logos available'}
+
+
+ );
+ }
+
+ return (
+
+ {({ index, style }) => {
+ const logoItem = filteredLogos[index];
+ return (
+ {
+ setGroupStates(
+ groupStates.map((state) => {
+ if (
+ state.channel_group ===
+ group.channel_group
+ ) {
+ return {
+ ...state,
+ custom_properties: {
+ ...state.custom_properties,
+ custom_logo_id:
+ logoItem.id,
+ },
+ logoPopoverOpened: false,
+ };
+ }
+ return state;
+ })
+ );
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor =
+ 'rgb(68, 68, 68)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor =
+ 'transparent';
+ }}
+ >
+
+
{
+ if (e.target.src !== logo) {
+ e.target.src = logo;
+ }
+ }}
+ />
+
+ {logoItem.name || 'Default'}
+
+
+
+ );
+ }}
+
+ );
+ })()}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Show EPG selector when force_epg is selected */}
+ {(group.custom_properties?.custom_epg_id !== undefined ||
+ group.custom_properties?.force_dummy_epg ||
+ group.custom_properties?.force_epg_selected) && (
+
+
+ )}
>
)}
@@ -792,6 +1265,16 @@ const LiveGroupFilter = ({ playlist, groupStates, setGroupStates }) => {
))}
+
+ {/* Logo Upload Modal */}
+ {
+ setLogoModalOpen(false);
+ setCurrentEditingGroupId(null);
+ }}
+ onSuccess={handleLogoSuccess}
+ />
);
};
diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx
index 916a2c30..353cd50e 100644
--- a/frontend/src/components/forms/LoginForm.jsx
+++ b/frontend/src/components/forms/LoginForm.jsx
@@ -1,7 +1,24 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
-import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core';
+import API from '../../api';
+import {
+ Paper,
+ Title,
+ TextInput,
+ Button,
+ Center,
+ Stack,
+ Text,
+ Image,
+ Group,
+ Divider,
+ Modal,
+ Anchor,
+ Code,
+ Checkbox,
+} from '@mantine/core';
+import logo from '../../assets/logo.png';
const LoginForm = () => {
const login = useAuthStore((s) => s.login);
@@ -11,12 +28,69 @@ const LoginForm = () => {
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
+ const [rememberMe, setRememberMe] = useState(false);
+ const [savePassword, setSavePassword] = useState(false);
+ const [forgotPasswordOpened, setForgotPasswordOpened] = useState(false);
+ const [version, setVersion] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
- // useEffect(() => {
- // if (isAuthenticated) {
- // navigate('/channels');
- // }
- // }, [isAuthenticated, navigate]);
+ // Simple base64 encoding/decoding for localStorage
+ // Note: This is obfuscation, not encryption. Use browser's password manager for real security.
+ const encodePassword = (password) => {
+ try {
+ return btoa(password);
+ } catch (error) {
+ console.error('Encoding error:', error);
+ return null;
+ }
+ };
+
+ const decodePassword = (encoded) => {
+ try {
+ return atob(encoded);
+ } catch (error) {
+ console.error('Decoding error:', error);
+ return '';
+ }
+ };
+
+ useEffect(() => {
+ // Fetch version info
+ API.getVersion().then((data) => {
+ setVersion(data?.version);
+ });
+ }, []);
+
+ useEffect(() => {
+ // Load saved username if it exists
+ const savedUsername = localStorage.getItem(
+ 'dispatcharr_remembered_username'
+ );
+ const savedPassword = localStorage.getItem('dispatcharr_saved_password');
+
+ if (savedUsername) {
+ setFormData((prev) => ({ ...prev, username: savedUsername }));
+ setRememberMe(true);
+
+ if (savedPassword) {
+ try {
+ const decrypted = decodePassword(savedPassword);
+ if (decrypted) {
+ setFormData((prev) => ({ ...prev, password: decrypted }));
+ setSavePassword(true);
+ }
+ } catch {
+ // If decoding fails, just skip
+ }
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ navigate('/channels');
+ }
+ }, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
@@ -27,13 +101,38 @@ const LoginForm = () => {
const handleSubmit = async (e) => {
e.preventDefault();
- await login(formData);
+ setIsLoading(true);
try {
+ await login(formData);
+
+ // Save username if remember me is checked
+ if (rememberMe) {
+ localStorage.setItem(
+ 'dispatcharr_remembered_username',
+ formData.username
+ );
+
+ // Save password if save password is checked
+ if (savePassword) {
+ const encoded = encodePassword(formData.password);
+ if (encoded) {
+ localStorage.setItem('dispatcharr_saved_password', encoded);
+ }
+ } else {
+ localStorage.removeItem('dispatcharr_saved_password');
+ }
+ } else {
+ localStorage.removeItem('dispatcharr_remembered_username');
+ localStorage.removeItem('dispatcharr_saved_password');
+ }
+
await initData();
- navigate('/channels');
+ // Navigation will happen automatically via the useEffect or route protection
} catch (e) {
console.log(`Failed to login: ${e}`);
+ await logout();
+ setIsLoading(false);
}
};
@@ -45,11 +144,29 @@ const LoginForm = () => {
>
-
- Login
-
+
+
+
+ Dispatcharr
+
+
+ Welcome back! Please log in to continue.
+
+
+
+
+ {version && (
+
+ v{version}
+
+ )}
+
+ setForgotPasswordOpened(false)}
+ title="Reset Your Password"
+ centered
+ >
+
+
+ To reset your password, your administrator needs to run a Django
+ management command:
+
+
+
+ If running with Docker:
+
+
+ docker exec <container_name> python manage.py changepassword
+ <username>
+
+
+
+
+ If running locally:
+
+ python manage.py changepassword <username>
+
+
+ The command will prompt for a new password. Replace
+ <container_name> with your Docker container name
+ and <username> with the account username.
+
+
+ Please contact your system administrator to perform a password
+ reset.
+
+
+
);
};
diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx
index 25847ce8..c6e63ba6 100644
--- a/frontend/src/components/forms/Logo.jsx
+++ b/frontend/src/components/forms/Logo.jsx
@@ -1,5 +1,6 @@
-import React, { useState, useEffect } from 'react';
-import { useFormik } from 'formik';
+import React, { useState, useEffect, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import {
Modal,
@@ -18,144 +19,148 @@ import { Upload, FileImage, X } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import API from '../../api';
+const schema = Yup.object({
+ name: Yup.string().required('Name is required'),
+ url: Yup.string()
+ .required('URL is required')
+ .test(
+ 'valid-url-or-path',
+ 'Must be a valid URL or local file path',
+ (value) => {
+ if (!value) return false;
+ // Allow local file paths starting with /data/logos/
+ if (value.startsWith('/data/logos/')) return true;
+ // Allow valid URLs
+ try {
+ new URL(value);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ ),
+});
+
const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
const [logoPreview, setLogoPreview] = useState(null);
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null); // Store selected file
- const formik = useFormik({
- initialValues: {
- name: '',
- url: '',
- },
- validationSchema: Yup.object({
- name: Yup.string().required('Name is required'),
- url: Yup.string()
- .required('URL is required')
- .test(
- 'valid-url-or-path',
- 'Must be a valid URL or local file path',
- (value) => {
- if (!value) return false;
- // Allow local file paths starting with /data/logos/
- if (value.startsWith('/data/logos/')) return true;
- // Allow valid URLs
- try {
- new URL(value);
- return true;
- } catch {
- return false;
- }
- }
- ),
+ const defaultValues = useMemo(
+ () => ({
+ name: logo?.name || '',
+ url: logo?.url || '',
}),
- onSubmit: async (values, { setSubmitting }) => {
- try {
- setUploading(true);
- let uploadResponse = null; // Store upload response for later use
+ [logo]
+ );
- // If we have a selected file, upload it first
- if (selectedFile) {
- try {
- uploadResponse = await API.uploadLogo(selectedFile, values.name);
- // Use the uploaded file data instead of form values
- values.name = uploadResponse.name;
- values.url = uploadResponse.url;
- } catch (uploadError) {
- let errorMessage = 'Failed to upload logo file';
-
- if (
- uploadError.code === 'NETWORK_ERROR' ||
- uploadError.message?.includes('timeout')
- ) {
- errorMessage = 'Upload timed out. Please try again.';
- } else if (uploadError.status === 413) {
- errorMessage = 'File too large. Please choose a smaller file.';
- } else if (uploadError.body?.error) {
- errorMessage = uploadError.body.error;
- }
-
- notifications.show({
- title: 'Upload Error',
- message: errorMessage,
- color: 'red',
- });
- return; // Don't proceed with creation if upload fails
- }
- }
-
- // Now create or update the logo with the final values
- // Only proceed if we don't already have a logo from file upload
- if (logo) {
- const updatedLogo = await API.updateLogo(logo.id, values);
- notifications.show({
- title: 'Success',
- message: 'Logo updated successfully',
- color: 'green',
- });
- onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
- } else if (!selectedFile) {
- // Only create a new logo entry if we're not uploading a file
- // (file upload already created the logo entry)
- const newLogo = await API.createLogo(values);
- notifications.show({
- title: 'Success',
- message: 'Logo created successfully',
- color: 'green',
- });
- onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
- } else {
- // File was uploaded and logo was already created
- // Note: API.uploadLogo already calls addLogo() in the store, so no need to call onSuccess
- notifications.show({
- title: 'Success',
- message: 'Logo uploaded successfully',
- color: 'green',
- });
- // No onSuccess call needed - API.uploadLogo already updated the store
- }
- 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.';
- } else if (error.response?.data?.error) {
- errorMessage = error.response.data.error;
- }
-
- notifications.show({
- title: 'Error',
- message: errorMessage,
- color: 'red',
- });
- } finally {
- setSubmitting(false);
- setUploading(false);
- }
- },
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ reset,
+ setValue,
+ watch,
+ } = useForm({
+ defaultValues,
+ resolver: yupResolver(schema),
});
- useEffect(() => {
- if (logo) {
- formik.setValues({
- name: logo.name || '',
- url: logo.url || '',
+ const onSubmit = async (values) => {
+ try {
+ setUploading(true);
+ let uploadResponse = null; // Store upload response for later use
+
+ // If we have a selected file, upload it first
+ if (selectedFile) {
+ try {
+ uploadResponse = await API.uploadLogo(selectedFile, values.name);
+ // Use the uploaded file data instead of form values
+ values.name = uploadResponse.name;
+ values.url = uploadResponse.url;
+ } catch (uploadError) {
+ let errorMessage = 'Failed to upload logo file';
+
+ if (
+ uploadError.code === 'NETWORK_ERROR' ||
+ uploadError.message?.includes('timeout')
+ ) {
+ errorMessage = 'Upload timed out. Please try again.';
+ } else if (uploadError.status === 413) {
+ errorMessage = 'File too large. Please choose a smaller file.';
+ } else if (uploadError.body?.error) {
+ errorMessage = uploadError.body.error;
+ }
+
+ notifications.show({
+ title: 'Upload Error',
+ message: errorMessage,
+ color: 'red',
+ });
+ return; // Don't proceed with creation if upload fails
+ }
+ }
+
+ // Now create or update the logo with the final values
+ // Only proceed if we don't already have a logo from file upload
+ if (logo) {
+ const updatedLogo = await API.updateLogo(logo.id, values);
+ notifications.show({
+ title: 'Success',
+ message: 'Logo updated successfully',
+ color: 'green',
+ });
+ onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
+ } else if (!selectedFile) {
+ // Only create a new logo entry if we're not uploading a file
+ // (file upload already created the logo entry)
+ const newLogo = await API.createLogo(values);
+ notifications.show({
+ title: 'Success',
+ message: 'Logo created successfully',
+ color: 'green',
+ });
+ onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
+ } else {
+ // File was uploaded and logo was already created
+ notifications.show({
+ title: 'Success',
+ message: 'Logo uploaded successfully',
+ color: 'green',
+ });
+ onSuccess?.({ type: 'create', logo: uploadResponse });
+ }
+ 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.';
+ } else if (error.response?.data?.error) {
+ errorMessage = error.response.data.error;
+ }
+
+ notifications.show({
+ title: 'Error',
+ message: errorMessage,
+ color: 'red',
});
- setLogoPreview(logo.cache_url);
- } else {
- formik.resetForm();
- setLogoPreview(null);
+ } finally {
+ setUploading(false);
}
- // Clear any selected file when logo changes
+ };
+
+ useEffect(() => {
+ reset(defaultValues);
+ setLogoPreview(logo?.cache_url || null);
setSelectedFile(null);
- }, [logo, isOpen]);
+ }, [defaultValues, logo, reset]);
const handleFileSelect = (files) => {
if (files.length === 0) return;
@@ -181,18 +186,19 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
setLogoPreview(previewUrl);
// Auto-fill the name field if empty
- if (!formik.values.name) {
+ const currentName = watch('name');
+ if (!currentName) {
const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
- formik.setFieldValue('name', nameWithoutExtension);
+ setValue('name', nameWithoutExtension);
}
// Set a placeholder URL (will be replaced after upload)
- formik.setFieldValue('url', 'file://pending-upload');
+ setValue('url', 'file://pending-upload');
};
const handleUrlChange = (event) => {
const url = event.target.value;
- formik.setFieldValue('url', url);
+ setValue('url', url);
// Clear any selected file when manually entering URL
if (selectedFile) {
@@ -211,6 +217,24 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
}
};
+ const handleUrlBlur = (event) => {
+ const urlValue = event.target.value;
+ if (urlValue) {
+ try {
+ const url = new URL(urlValue);
+ const pathname = url.pathname;
+ const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
+ const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '');
+ if (nameWithoutExtension) {
+ setValue('name', nameWithoutExtension);
+ }
+ } catch (error) {
+ // If the URL is invalid, do nothing.
+ // The validation schema will catch this.
+ }
+ }
+ };
+
// Clean up object URLs when component unmounts or preview changes
useEffect(() => {
return () => {
@@ -227,7 +251,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
title={logo ? 'Edit Logo' : 'Add Logo'}
size="md"
>
-
{initialDataCount === 0 && (
@@ -1034,147 +1203,75 @@ const StreamsTable = () => {
onClose={closeStreamForm}
/>
- {/* Channel Numbering Modal */}
- setChannelNumberingModalOpen(false)}
- title="Channel Numbering Options"
- size="md"
- centered
- >
-
-
- Choose how to assign channel numbers to the{' '}
- {selectedStreamIds.length} selected streams:
-
+ mode={numberingMode}
+ onModeChange={setNumberingMode}
+ numberValue={customStartNumber}
+ onNumberValueChange={setCustomStartNumber}
+ rememberChoice={rememberChoice}
+ onRememberChoiceChange={setRememberChoice}
+ onConfirm={handleChannelNumberingConfirm}
+ isBulk={true}
+ streamCount={selectedStreamIds.length}
+ selectedProfileIds={bulkSelectedProfileIds}
+ onProfileIdsChange={setBulkSelectedProfileIds}
+ channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
+ />
-
-
-
-
-
-
-
-
- {numberingMode === 'custom' && (
-
- )}
-
- setRememberChoice(event.currentTarget.checked)}
- label="Remember this choice and don't ask again"
- />
-
-
-
-
-
-
-
-
- {/* Single Channel Numbering Modal */}
- setSingleChannelModalOpen(false)}
- title="Channel Number Assignment"
+ mode={singleChannelMode}
+ onModeChange={setSingleChannelMode}
+ numberValue={specificChannelNumber}
+ onNumberValueChange={setSpecificChannelNumber}
+ rememberChoice={rememberSingleChoice}
+ onRememberChoiceChange={setRememberSingleChoice}
+ onConfirm={handleSingleChannelNumberingConfirm}
+ isBulk={false}
+ streamName={currentStreamForChannel?.name}
+ selectedProfileIds={singleSelectedProfileIds}
+ onProfileIdsChange={setSingleSelectedProfileIds}
+ channelProfiles={channelProfiles ? Object.values(channelProfiles) : []}
+ />
+
+ setConfirmDeleteOpen(false)}
+ onConfirm={() =>
+ isBulkDelete
+ ? executeDeleteStreams()
+ : executeDeleteStream(deleteTarget)
+ }
+ title={`Confirm ${isBulkDelete ? 'Bulk ' : ''}Stream Deletion`}
+ message={
+ isBulkDelete ? (
+ `Are you sure you want to delete ${selectedStreamIds.length} stream${selectedStreamIds.length !== 1 ? 's' : ''}? This action cannot be undone.`
+ ) : streamToDelete ? (
+
+ {`Are you sure you want to delete the following stream?
+
+Name: ${streamToDelete.name}
+${streamToDelete.channel_group ? `Group: ${channelGroups[streamToDelete.channel_group]?.name || 'Unknown'}` : ''}
+${streamToDelete.m3u_account ? `M3U Account: ${playlists.find((p) => p.id === streamToDelete.m3u_account)?.name || 'Unknown'}` : ''}
+
+This action cannot be undone.`}
+
+ ) : (
+ 'Are you sure you want to delete this stream? This action cannot be undone.'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ actionKey={isBulkDelete ? 'delete-streams' : 'delete-stream'}
+ onSuppressChange={suppressWarning}
+ loading={deleting}
size="md"
- centered
- >
-
-
- Choose how to assign the channel number for "
- {currentStreamForChannel?.name}":
-
-
-
-
-
-
-
-
-
-
- {singleChannelMode === 'specific' && (
-
- )}
-
-
- setRememberSingleChoice(event.currentTarget.checked)
- }
- label="Remember this choice and don't ask again"
- />
-
-
-
-
-
-
-
+ />
>
);
};
diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx
index 48573926..ffd47719 100644
--- a/frontend/src/components/tables/UserAgentsTable.jsx
+++ b/frontend/src/components/tables/UserAgentsTable.jsx
@@ -127,7 +127,7 @@ const UserAgentsTable = () => {
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
- if (ids.includes(settings['default-user-agent'].value)) {
+ if (ids.includes(settings.default_user_agent)) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
@@ -137,7 +137,7 @@ const UserAgentsTable = () => {
await API.deleteUserAgents(ids);
} else {
- if (ids == settings['default-user-agent'].value) {
+ if (ids == settings.default_user_agent) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx
index 3e9e4971..467423dc 100644
--- a/frontend/src/components/tables/UsersTable.jsx
+++ b/frontend/src/components/tables/UsersTable.jsx
@@ -96,6 +96,7 @@ const UsersTable = () => {
const [deleteTarget, setDeleteTarget] = useState(null);
const [userToDelete, setUserToDelete] = useState(null);
const [isLoading, setIsLoading] = useState(false);
+ const [deleting, setDeleting] = useState(false);
const [visiblePasswords, setVisiblePasswords] = useState({});
/**
@@ -110,9 +111,14 @@ const UsersTable = () => {
const executeDeleteUser = useCallback(async (id) => {
setIsLoading(true);
- await API.deleteUser(id);
- setIsLoading(false);
- setConfirmDeleteOpen(false);
+ setDeleting(true);
+ try {
+ await API.deleteUser(id);
+ } finally {
+ setDeleting(false);
+ setIsLoading(false);
+ setConfirmDeleteOpen(false);
+ }
}, []);
const editUser = useCallback(async (user = null) => {
@@ -406,6 +412,7 @@ const UsersTable = () => {
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => executeDeleteUser(deleteTarget)}
+ loading={deleting}
title="Confirm User Deletion"
message={
userToDelete ? (
diff --git a/frontend/src/components/tables/VODLogosTable.jsx b/frontend/src/components/tables/VODLogosTable.jsx
new file mode 100644
index 00000000..75b322f5
--- /dev/null
+++ b/frontend/src/components/tables/VODLogosTable.jsx
@@ -0,0 +1,664 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Center,
+ Checkbox,
+ Flex,
+ Group,
+ Image,
+ LoadingOverlay,
+ NativeSelect,
+ Pagination,
+ Paper,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+ Tooltip,
+ useMantineTheme,
+} from '@mantine/core';
+import { ExternalLink, Search, Trash2, Trash, SquareMinus } from 'lucide-react';
+import useVODLogosStore from '../../store/vodLogos';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import { CustomTable, useTable } from './CustomTable';
+import ConfirmationDialog from '../ConfirmationDialog';
+import { notifications } from '@mantine/notifications';
+
+const VODLogoRowActions = ({ theme, row, deleteLogo }) => {
+ const [tableSize] = useLocalStorage('table-size', 'default');
+
+ const onDelete = useCallback(() => {
+ deleteLogo(row.original.id);
+ }, [row.original.id, deleteLogo]);
+
+ const iconSize =
+ tableSize === 'default' ? 'sm' : tableSize === 'compact' ? 'xs' : 'md';
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default function VODLogosTable() {
+ const theme = useMantineTheme();
+
+ const {
+ logos,
+ totalCount,
+ isLoading,
+ fetchVODLogos,
+ deleteVODLogo,
+ deleteVODLogos,
+ cleanupUnusedVODLogos,
+ } = useVODLogosStore();
+
+ const [currentPage, setCurrentPage] = useState(1);
+ const [pageSize, setPageSize] = useState(25);
+ const [nameFilter, setNameFilter] = useState('');
+ const [usageFilter, setUsageFilter] = useState('all');
+ const [selectedRows, setSelectedRows] = useState(new Set());
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [paginationString, setPaginationString] = useState('');
+ const [isCleaningUp, setIsCleaningUp] = useState(false);
+ const tableRef = React.useRef(null);
+
+ // Calculate unused logos count
+ const unusedLogosCount = useMemo(() => {
+ return logos.filter(
+ (logo) => logo.movie_count === 0 && logo.series_count === 0
+ ).length;
+ }, [logos]);
+ useEffect(() => {
+ fetchVODLogos({
+ page: currentPage,
+ page_size: pageSize,
+ name: nameFilter,
+ usage: usageFilter === 'all' ? undefined : usageFilter,
+ });
+ }, [currentPage, pageSize, nameFilter, usageFilter, fetchVODLogos]);
+
+ const handleSelectAll = useCallback(
+ (checked) => {
+ if (checked) {
+ setSelectedRows(new Set(logos.map((logo) => logo.id)));
+ } else {
+ setSelectedRows(new Set());
+ }
+ },
+ [logos]
+ );
+
+ const handleSelectRow = useCallback((id, checked) => {
+ setSelectedRows((prev) => {
+ const newSet = new Set(prev);
+ if (checked) {
+ newSet.add(id);
+ } else {
+ newSet.delete(id);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const deleteLogo = useCallback((id) => {
+ setDeleteTarget([id]);
+ setConfirmDeleteOpen(true);
+ }, []);
+
+ const handleDeleteSelected = useCallback(() => {
+ setDeleteTarget(Array.from(selectedRows));
+ setConfirmDeleteOpen(true);
+ }, [selectedRows]);
+
+ const onRowSelectionChange = useCallback((newSelection) => {
+ setSelectedRows(new Set(newSelection));
+ }, []);
+
+ const clearSelections = useCallback(() => {
+ setSelectedRows(new Set());
+ // Clear table's internal selection state if table is initialized
+ if (tableRef.current?.setSelectedTableIds) {
+ tableRef.current.setSelectedTableIds([]);
+ }
+ }, []);
+
+ const handleConfirmDelete = async () => {
+ setDeleting(true);
+ try {
+ if (deleteTarget.length === 1) {
+ await deleteVODLogo(deleteTarget[0]);
+ notifications.show({
+ title: 'Success',
+ message: 'VOD logo deleted successfully',
+ color: 'green',
+ });
+ } else {
+ await deleteVODLogos(deleteTarget);
+ notifications.show({
+ title: 'Success',
+ message: `${deleteTarget.length} VOD logos deleted successfully`,
+ color: 'green',
+ });
+ }
+ } catch (error) {
+ notifications.show({
+ title: 'Error',
+ message: error.message || 'Failed to delete VOD logos',
+ color: 'red',
+ });
+ } finally {
+ setDeleting(false);
+ // Always clear selections and close dialog, even on error
+ clearSelections();
+ setConfirmDeleteOpen(false);
+ setDeleteTarget(null);
+ }
+ };
+
+ const handleCleanupUnused = useCallback(() => {
+ setConfirmCleanupOpen(true);
+ }, []);
+
+ const handleConfirmCleanup = async () => {
+ setIsCleaningUp(true);
+ try {
+ const result = await cleanupUnusedVODLogos();
+ notifications.show({
+ title: 'Success',
+ message: `Cleaned up ${result.deleted_count} unused VOD logos`,
+ color: 'green',
+ });
+ } catch (error) {
+ notifications.show({
+ title: 'Error',
+ message: error.message || 'Failed to cleanup unused VOD logos',
+ color: 'red',
+ });
+ } finally {
+ setIsCleaningUp(false);
+ setConfirmCleanupOpen(false);
+ clearSelections(); // Clear selections after cleanup
+ }
+ };
+
+ // Clear selections only when filters change (not on every data fetch)
+ useEffect(() => {
+ clearSelections();
+ }, [nameFilter, usageFilter, clearSelections]);
+
+ useEffect(() => {
+ const startItem = (currentPage - 1) * pageSize + 1;
+ const endItem = Math.min(currentPage * pageSize, totalCount);
+ setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
+ }, [currentPage, pageSize, totalCount]);
+
+ const pageCount = useMemo(() => {
+ return Math.ceil(totalCount / pageSize);
+ }, [totalCount, pageSize]);
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'select',
+ header: () => (
+ 0 && selectedRows.size === logos.length
+ }
+ indeterminate={
+ selectedRows.size > 0 && selectedRows.size < logos.length
+ }
+ onChange={(event) => handleSelectAll(event.currentTarget.checked)}
+ size="sm"
+ />
+ ),
+ cell: ({ row }) => (
+
+ handleSelectRow(row.original.id, event.currentTarget.checked)
+ }
+ size="sm"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ },
+ {
+ header: 'Preview',
+ accessorKey: 'cache_url',
+ size: 80,
+ enableSorting: false,
+ cell: ({ getValue, row }) => (
+
+ {
+ e.target.style.transform = 'scale(1.5)';
+ }}
+ onMouseLeave={(e) => {
+ e.target.style.transform = 'scale(1)';
+ }}
+ />
+
+ ),
+ },
+ {
+ header: 'Name',
+ accessorKey: 'name',
+ size: 250,
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ },
+ {
+ header: 'Usage',
+ accessorKey: 'usage',
+ size: 120,
+ cell: ({ row }) => {
+ const { movie_count, series_count, item_names } = row.original;
+ const totalUsage = movie_count + series_count;
+
+ if (totalUsage === 0) {
+ return (
+
+ Unused
+
+ );
+ }
+
+ // Build usage description
+ const usageParts = [];
+ if (movie_count > 0) {
+ usageParts.push(
+ `${movie_count} movie${movie_count !== 1 ? 's' : ''}`
+ );
+ }
+ if (series_count > 0) {
+ usageParts.push(`${series_count} series`);
+ }
+
+ const label =
+ usageParts.length === 1
+ ? usageParts[0]
+ : `${totalUsage} item${totalUsage !== 1 ? 's' : ''}`;
+
+ return (
+
+
+ Used by {usageParts.join(' & ')}:
+
+ {item_names &&
+ item_names.map((name, index) => (
+
+ • {name}
+
+ ))}
+
+ }
+ multiline
+ width={220}
+ >
+