From 6a8a94101a8b0f0861ca696984ff0391a48e1fd9 Mon Sep 17 00:00:00 2001
From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
Date: Mon, 12 Jan 2026 10:59:52 -0600
Subject: [PATCH 001/125] Add filter to show unassociated streams in Streams
table
Adds a filter button with 'Only Unassociated' toggle option to the Streams table, allowing users to quickly identify streams from providers that are not associated with any channel. The filter button follows the existing UI pattern from ChannelTableHeader, positioned on the right side of the toolbar. Leverages existing backend support for the unassigned query parameter.
Resolves #667
---
.../src/components/tables/StreamsTable.jsx | 35 ++++++++++++++++++-
1 file changed, 34 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 21b13baf..8c931a65 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -20,6 +20,9 @@ import {
ArrowUpNarrowWide,
ArrowDownWideNarrow,
Search,
+ Filter,
+ Square,
+ SquareCheck,
} from 'lucide-react';
import {
TextInput,
@@ -48,7 +51,6 @@ import {
Modal,
NumberInput,
Radio,
- Checkbox,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
@@ -232,6 +234,7 @@ const StreamsTable = ({ onReady }) => {
name: '',
channel_group: '',
m3u_account: '',
+ unassigned: '',
});
const [columnSizing, setColumnSizing] = useLocalStorage(
'streams-table-column-sizing',
@@ -382,6 +385,13 @@ const StreamsTable = ({ onReady }) => {
}));
};
+ const toggleUnassignedOnly = () => {
+ setFilters((prev) => ({
+ ...prev,
+ unassigned: prev.unassigned === '1' ? '' : '1',
+ }));
+ };
+
const fetchData = useCallback(async () => {
setIsLoading(true);
@@ -1057,6 +1067,29 @@ const StreamsTable = ({ onReady }) => {
+
+
}
variant="light"
From cbde9297ea47b64709007b376bf534a57ff45669 Mon Sep 17 00:00:00 2001
From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
Date: Mon, 12 Jan 2026 12:03:14 -0600
Subject: [PATCH 002/125] Fix StreamsTable Group column header overflow and
align with ChannelsTable
The Group column header was overflowing vertically when multiple groups were selected in the MultiSelect filter. Initial attempts to preserve sorting functionality while fixing the overflow were unsuccessful due to how Mantine's MultiSelect rightSection positioning works.
Final solution removes the sorting icon from the Group column to match ChannelsTable's implementation, where channel_group also uses a MultiSelect filter without sorting. This allows the header to properly expand vertically to accommodate selected filter values without overflow.
Resolves #613
---
.../src/components/tables/StreamsTable.jsx | 36 +++++++------------
1 file changed, 12 insertions(+), 24 deletions(-)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 8c931a65..25b7e330 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -856,30 +856,18 @@ const StreamsTable = ({ onReady }) => {
case 'group':
return (
-
- {
- e.stopPropagation();
- onSortingChange('group');
- },
- size: 14,
- style: { cursor: 'pointer' },
- })}
- />
-
+
);
case 'm3u':
From 367ee26f9aee5f8456f0cda95f59a1838849fd86 Mon Sep 17 00:00:00 2001
From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
Date: Mon, 12 Jan 2026 17:00:04 -0600
Subject: [PATCH 003/125] Fix unassigned filter not updating after adding
streams to channels
When a stream was added to a channel (via "Add to Channel" button, "Create New Channel", or bulk "Add Streams to Channel"), the StreamsTable data wasn't being refetched. This caused streams to remain visible even when the "Only Unassociated" filter was active, despite no longer being unassociated.
Fixed by calling fetchData() after channel updates in:
- StreamRowActions.addStreamToChannel (single stream to channel)
- addStreamsToChannel (bulk add to channel)
- executeSingleChannelCreation (create new channel from stream)
Now streams immediately disappear from the filtered view when associated with a channel.
---
frontend/src/components/tables/StreamsTable.jsx | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 25b7e330..8be3bb6d 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -70,6 +70,7 @@ const StreamRowActions = ({
handleWatchStream,
selectedChannelIds,
createChannelFromStream,
+ fetchData,
}) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const channelSelectionStreams = useChannelsTableStore(
@@ -87,6 +88,7 @@ const StreamRowActions = ({
],
});
await API.requeryChannels();
+ await fetchData();
};
const onEdit = useCallback(() => {
@@ -545,6 +547,10 @@ const StreamsTable = ({ onReady }) => {
// Clear selection since the task has started
setSelectedStreamIds([]);
+
+ // Note: This is a background task, so fetchData may not show updated data immediately
+ // The actual update will occur when the WebSocket event fires on task completion
+ await fetchData();
} catch (error) {
console.error('Error starting bulk channel creation:', error);
// Error notifications will be handled by WebSocket
@@ -714,6 +720,7 @@ const StreamsTable = ({ onReady }) => {
await API.requeryChannels();
const fetchLogos = useChannelsStore.getState().fetchLogos;
fetchLogos();
+ await fetchData();
};
// Handle confirming the single channel numbering modal
@@ -756,6 +763,7 @@ const StreamsTable = ({ onReady }) => {
],
});
await API.requeryChannels();
+ await fetchData();
};
const onRowSelectionChange = (updatedIds) => {
@@ -916,6 +924,7 @@ const StreamsTable = ({ onReady }) => {
handleWatchStream={handleWatchStream}
selectedChannelIds={selectedChannelIds}
createChannelFromStream={createChannelFromStream}
+ fetchData={fetchData}
/>
);
}
@@ -927,6 +936,7 @@ const StreamsTable = ({ onReady }) => {
editStream,
deleteStream,
handleWatchStream,
+ fetchData,
]
);
From 77067d4a1d0eb8eb56dc670e1ac3dd22621e0c5f Mon Sep 17 00:00:00 2001
From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
Date: Wed, 14 Jan 2026 04:47:47 -0600
Subject: [PATCH 004/125] Fix: refresh streams after bulk channel creation
Signed-off-by: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
---
.../src/components/tables/StreamsTable.jsx | 43 +++++++++++++------
1 file changed, 29 insertions(+), 14 deletions(-)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 8be3bb6d..bc10e904 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -61,6 +61,7 @@ import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../ConfirmationDialog';
import CreateChannelModal from '../modals/CreateChannelModal';
+import { useWebSocket } from '../../WebSocket';
const StreamRowActions = ({
theme,
@@ -88,7 +89,7 @@ const StreamRowActions = ({
],
});
await API.requeryChannels();
- await fetchData();
+ await fetchData({ showLoader: false });
};
const onEdit = useCallback(() => {
@@ -181,6 +182,7 @@ const StreamRowActions = ({
const StreamsTable = ({ onReady }) => {
const theme = useMantineTheme();
const hasSignaledReady = useRef(false);
+ const [, , websocketEvent] = useWebSocket();
/**
* useState
@@ -394,8 +396,10 @@ const StreamsTable = ({ onReady }) => {
}));
};
- const fetchData = useCallback(async () => {
- setIsLoading(true);
+ const fetchData = useCallback(async ({ showLoader = true } = {}) => {
+ if (showLoader) {
+ setIsLoading(true);
+ }
// Ensure we have channel groups first (if not already loaded)
if (!groupsLoaded && Object.keys(channelGroups).length === 0) {
@@ -465,7 +469,9 @@ const StreamsTable = ({ onReady }) => {
console.error('Error fetching data:', error);
}
- setIsLoading(false);
+ if (showLoader) {
+ setIsLoading(false);
+ }
}, [
pagination,
sorting,
@@ -550,7 +556,7 @@ const StreamsTable = ({ onReady }) => {
// Note: This is a background task, so fetchData may not show updated data immediately
// The actual update will occur when the WebSocket event fires on task completion
- await fetchData();
+ await fetchData({ showLoader: false });
} catch (error) {
console.error('Error starting bulk channel creation:', error);
// Error notifications will be handled by WebSocket
@@ -610,7 +616,7 @@ const StreamsTable = ({ onReady }) => {
setDeleting(true);
try {
await API.deleteStream(id);
- fetchData();
+ fetchData({ showLoader: false });
// Clear the selection for the deleted stream
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
@@ -633,16 +639,14 @@ const StreamsTable = ({ onReady }) => {
};
const executeDeleteStreams = async () => {
- setIsLoading(true);
setDeleting(true);
try {
await API.deleteStreams(selectedStreamIds);
- fetchData();
+ fetchData({ showLoader: false });
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
} finally {
setDeleting(false);
- setIsLoading(false);
setConfirmDeleteOpen(false);
}
};
@@ -650,7 +654,7 @@ const StreamsTable = ({ onReady }) => {
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
- fetchData();
+ fetchData({ showLoader: false });
};
// Single channel creation functions
@@ -718,9 +722,9 @@ const StreamsTable = ({ onReady }) => {
channel_profile_ids: channelProfileIds,
});
await API.requeryChannels();
- const fetchLogos = useChannelsStore.getState().fetchLogos;
- fetchLogos();
- await fetchData();
+ // const fetchLogos = useChannelsStore.getState().fetchLogos;
+ // fetchLogos();
+ await fetchData({ showLoader: false });
};
// Handle confirming the single channel numbering modal
@@ -763,7 +767,7 @@ const StreamsTable = ({ onReady }) => {
],
});
await API.requeryChannels();
- await fetchData();
+ await fetchData({ showLoader: false });
};
const onRowSelectionChange = (updatedIds) => {
@@ -980,6 +984,17 @@ const StreamsTable = ({ onReady }) => {
fetchData();
}, [fetchData]);
+ useEffect(() => {
+ const eventType = websocketEvent?.data?.type;
+ if (
+ eventType === 'channels_created' ||
+ (eventType === 'bulk_channel_creation_progress' &&
+ websocketEvent?.data?.status === 'completed')
+ ) {
+ fetchData({ showLoader: false });
+ }
+ }, [websocketEvent, fetchData]);
+
return (
<>
Date: Wed, 14 Jan 2026 09:48:40 -0600
Subject: [PATCH 005/125] unify StreamsTable refresh with channels requery
---
frontend/src/WebSocket.jsx | 1 +
frontend/src/api.js | 51 ++++++++
.../components/tables/ChannelTableStreams.jsx | 1 +
.../src/components/tables/StreamsTable.jsx | 121 ++++++++----------
frontend/src/store/streamsTable.jsx | 49 +++++++
5 files changed, 152 insertions(+), 71 deletions(-)
create mode 100644 frontend/src/store/streamsTable.jsx
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 87d80953..72bbb39a 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -755,6 +755,7 @@ export const WebsocketProvider = ({ children }) => {
// Refresh the channels table to show new channels
try {
await API.requeryChannels();
+ await API.requeryStreams();
await useChannelsStore.getState().fetchChannels();
await fetchChannelProfiles();
console.log('Channels refreshed after bulk creation');
diff --git a/frontend/src/api.js b/frontend/src/api.js
index c33ff1ee..f8b1f1c8 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -10,6 +10,7 @@ import useStreamProfilesStore from './store/streamProfiles';
import useSettingsStore from './store/settings';
import { notifications } from '@mantine/notifications';
import useChannelsTableStore from './store/channelsTable';
+import useStreamsTableStore from './store/streamsTable';
import useUsersStore from './store/users';
// If needed, you can set a base host or keep it empty if relative requests
@@ -380,6 +381,7 @@ export default class API {
});
useChannelsStore.getState().removeChannels([id]);
+ await API.requeryStreams();
} catch (e) {
errorNotification('Failed to delete channel', e);
}
@@ -394,6 +396,7 @@ export default class API {
});
useChannelsStore.getState().removeChannels(channel_ids);
+ await API.requeryStreams();
} catch (e) {
errorNotification('Failed to delete channels', e);
}
@@ -447,6 +450,9 @@ export default class API {
);
useChannelsStore.getState().updateChannel(response);
+ if (Object.prototype.hasOwnProperty.call(payload, 'streams')) {
+ await API.requeryStreams();
+ }
return response;
} catch (e) {
errorNotification('Failed to update channel', e);
@@ -630,6 +636,7 @@ export default class API {
useChannelsStore.getState().addChannel(response);
}
+ await API.requeryStreams();
return response;
} catch (e) {
errorNotification('Failed to create channel', e);
@@ -705,6 +712,46 @@ export default class API {
}
}
+ static async queryStreamsTable(params) {
+ try {
+ API.lastStreamQueryParams = params;
+
+ const response = await request(
+ `${host}/api/channels/streams/?${params.toString()}`
+ );
+
+ useStreamsTableStore.getState().queryStreams(response, params);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to fetch streams', e);
+ }
+ }
+
+ static async requeryStreams() {
+ if (!API.lastStreamQueryParams) {
+ return null;
+ }
+
+ try {
+ const [response, ids] = await Promise.all([
+ request(
+ `${host}/api/channels/streams/?${API.lastStreamQueryParams.toString()}`
+ ),
+ API.getAllStreamIds(API.lastStreamQueryParams),
+ ]);
+
+ useStreamsTableStore
+ .getState()
+ .queryStreams(response, API.lastStreamQueryParams);
+ useStreamsTableStore.getState().setAllQueryIds(ids);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to fetch streams', e);
+ }
+ }
+
static async getAllStreamIds(params) {
try {
const response = await request(
@@ -738,6 +785,7 @@ export default class API {
useStreamsStore.getState().addStream(response);
}
+ await API.requeryStreams();
return response;
} catch (e) {
errorNotification('Failed to add stream', e);
@@ -756,6 +804,7 @@ export default class API {
useStreamsStore.getState().updateStream(response);
}
+ await API.requeryStreams();
return response;
} catch (e) {
errorNotification('Failed to update stream', e);
@@ -769,6 +818,7 @@ export default class API {
});
useStreamsStore.getState().removeStreams([id]);
+ await API.requeryStreams();
} catch (e) {
errorNotification('Failed to delete stream', e);
}
@@ -782,6 +832,7 @@ export default class API {
});
useStreamsStore.getState().removeStreams(ids);
+ await API.requeryStreams();
} catch (e) {
errorNotification('Failed to delete streams', e);
}
diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx
index 4fb62009..0443b3ca 100644
--- a/frontend/src/components/tables/ChannelTableStreams.jsx
+++ b/frontend/src/components/tables/ChannelTableStreams.jsx
@@ -167,6 +167,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
streams: newStreamList.map((s) => s.id),
});
await API.requeryChannels();
+ await API.requeryStreams();
};
// Create M3U account map for quick lookup
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index bc10e904..2d7de36e 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -46,7 +46,6 @@ import {
MultiSelect,
useMantineTheme,
UnstyledButton,
- LoadingOverlay,
Skeleton,
Modal,
NumberInput,
@@ -61,7 +60,7 @@ import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../ConfirmationDialog';
import CreateChannelModal from '../modals/CreateChannelModal';
-import { useWebSocket } from '../../WebSocket';
+import useStreamsTableStore from '../../store/streamsTable';
const StreamRowActions = ({
theme,
@@ -71,7 +70,6 @@ const StreamRowActions = ({
handleWatchStream,
selectedChannelIds,
createChannelFromStream,
- fetchData,
}) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const channelSelectionStreams = useChannelsTableStore(
@@ -89,7 +87,6 @@ const StreamRowActions = ({
],
});
await API.requeryChannels();
- await fetchData({ showLoader: false });
};
const onEdit = useCallback(() => {
@@ -182,23 +179,18 @@ const StreamRowActions = ({
const StreamsTable = ({ onReady }) => {
const theme = useMantineTheme();
const hasSignaledReady = useRef(false);
- const [, , websocketEvent] = useWebSocket();
+ const hasFetchedOnce = useRef(false);
/**
* useState
*/
- const [allRowIds, setAllRowIds] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
const [initialDataCount, setInitialDataCount] = useState(null);
- const [data, setData] = useState([]); // Holds fetched data
- const [pageCount, setPageCount] = useState(0);
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
- const [sorting, setSorting] = useState([{ id: 'name', desc: false }]);
- const [selectedStreamIds, setSelectedStreamIds] = useState([]);
// Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
@@ -230,10 +222,6 @@ const StreamsTable = ({ onReady }) => {
'streams-page-size',
50
);
- const [pagination, setPagination] = useState({
- pageIndex: 0,
- pageSize: storedPageSize,
- });
const [filters, setFilters] = useState({
name: '',
channel_group: '',
@@ -246,15 +234,12 @@ const StreamsTable = ({ onReady }) => {
);
const debouncedFilters = useDebounce(filters, 500, () => {
// Reset to first page whenever filters change to avoid "Invalid page" errors
- setPagination((prev) => ({
- ...prev,
+ setPagination({
+ ...pagination,
pageIndex: 0,
- }));
+ });
});
- // Add state to track if stream groups are loaded
- const [groupsLoaded, setGroupsLoaded] = useState(false);
-
const navigate = useNavigate();
/**
@@ -277,6 +262,20 @@ const StreamsTable = ({ onReady }) => {
const showVideo = useVideoStore((s) => s.showVideo);
const [tableSize, _] = useLocalStorage('table-size', 'default');
+ const data = useStreamsTableStore((s) => s.streams);
+ const pageCount = useStreamsTableStore((s) => s.pageCount);
+ const totalCount = useStreamsTableStore((s) => s.totalCount);
+ const allRowIds = useStreamsTableStore((s) => s.allQueryIds);
+ const setAllRowIds = useStreamsTableStore((s) => s.setAllQueryIds);
+ const pagination = useStreamsTableStore((s) => s.pagination);
+ const setPagination = useStreamsTableStore((s) => s.setPagination);
+ const sorting = useStreamsTableStore((s) => s.sorting);
+ const setSorting = useStreamsTableStore((s) => s.setSorting);
+ const selectedStreamIds = useStreamsTableStore((s) => s.selectedStreamIds);
+ const setSelectedStreamIds = useStreamsTableStore(
+ (s) => s.setSelectedStreamIds
+ );
+
// Warnings store for "remember choice" functionality
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
@@ -401,16 +400,6 @@ const StreamsTable = ({ onReady }) => {
setIsLoading(true);
}
- // Ensure we have channel groups first (if not already loaded)
- if (!groupsLoaded && Object.keys(channelGroups).length === 0) {
- try {
- await fetchChannelGroups();
- setGroupsLoaded(true);
- } catch (error) {
- console.error('Error fetching channel groups:', error);
- }
- }
-
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
@@ -436,30 +425,18 @@ const StreamsTable = ({ onReady }) => {
try {
const [result, ids, groups] = await Promise.all([
- API.queryStreams(params),
+ API.queryStreamsTable(params),
API.getAllStreamIds(params),
API.getStreamGroups(),
]);
setAllRowIds(ids);
- setData(result.results);
- setPageCount(Math.ceil(result.count / pagination.pageSize));
setGroupOptions(groups);
- // Calculate the starting and ending item indexes
- const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
- const endItem = Math.min(
- (pagination.pageIndex + 1) * pagination.pageSize,
- result.count
- );
-
if (initialDataCount === null) {
setInitialDataCount(result.count);
}
- // Generate the string
- setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
-
// Signal that initial data load is complete
if (!hasSignaledReady.current && onReady) {
hasSignaledReady.current = true;
@@ -469,18 +446,11 @@ const StreamsTable = ({ onReady }) => {
console.error('Error fetching data:', error);
}
+ hasFetchedOnce.current = true;
if (showLoader) {
setIsLoading(false);
}
- }, [
- pagination,
- sorting,
- debouncedFilters,
- groupsLoaded,
- channelGroups,
- fetchChannelGroups,
- onReady,
- ]);
+ }, [pagination, sorting, debouncedFilters, onReady]);
// Bulk creation: create channels from selected streams asynchronously
const createChannelsFromStreams = async () => {
@@ -554,9 +524,7 @@ const StreamsTable = ({ onReady }) => {
// Clear selection since the task has started
setSelectedStreamIds([]);
- // Note: This is a background task, so fetchData may not show updated data immediately
- // The actual update will occur when the WebSocket event fires on task completion
- await fetchData({ showLoader: false });
+ // Note: This is a background task, so the update happens on WebSocket completion
} catch (error) {
console.error('Error starting bulk channel creation:', error);
// Error notifications will be handled by WebSocket
@@ -616,7 +584,6 @@ const StreamsTable = ({ onReady }) => {
setDeleting(true);
try {
await API.deleteStream(id);
- fetchData({ showLoader: false });
// Clear the selection for the deleted stream
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
@@ -642,7 +609,6 @@ const StreamsTable = ({ onReady }) => {
setDeleting(true);
try {
await API.deleteStreams(selectedStreamIds);
- fetchData({ showLoader: false });
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
} finally {
@@ -654,7 +620,7 @@ const StreamsTable = ({ onReady }) => {
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
- fetchData({ showLoader: false });
+ API.requeryStreams();
};
// Single channel creation functions
@@ -724,7 +690,6 @@ const StreamsTable = ({ onReady }) => {
await API.requeryChannels();
// const fetchLogos = useChannelsStore.getState().fetchLogos;
// fetchLogos();
- await fetchData({ showLoader: false });
};
// Handle confirming the single channel numbering modal
@@ -767,7 +732,6 @@ const StreamsTable = ({ onReady }) => {
],
});
await API.requeryChannels();
- await fetchData({ showLoader: false });
};
const onRowSelectionChange = (updatedIds) => {
@@ -928,7 +892,6 @@ const StreamsTable = ({ onReady }) => {
handleWatchStream={handleWatchStream}
selectedChannelIds={selectedChannelIds}
createChannelFromStream={createChannelFromStream}
- fetchData={fetchData}
/>
);
}
@@ -940,7 +903,6 @@ const StreamsTable = ({ onReady }) => {
editStream,
deleteStream,
handleWatchStream,
- fetchData,
]
);
@@ -958,6 +920,10 @@ const StreamsTable = ({ onReady }) => {
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
+ state: {
+ pagination,
+ sorting,
+ },
headerCellRenderFns: {
name: renderHeaderCell,
group: renderHeaderCell,
@@ -985,15 +951,29 @@ const StreamsTable = ({ onReady }) => {
}, [fetchData]);
useEffect(() => {
- const eventType = websocketEvent?.data?.type;
- if (
- eventType === 'channels_created' ||
- (eventType === 'bulk_channel_creation_progress' &&
- websocketEvent?.data?.status === 'completed')
- ) {
- fetchData({ showLoader: false });
+ if (Object.keys(channelGroups).length > 0) {
+ return;
}
- }, [websocketEvent, fetchData]);
+
+ const loadGroups = async () => {
+ try {
+ await fetchChannelGroups();
+ } catch (error) {
+ console.error('Error fetching channel groups:', error);
+ }
+ };
+
+ loadGroups();
+ }, [channelGroups, fetchChannelGroups]);
+
+ useEffect(() => {
+ const startItem = pagination.pageIndex * pagination.pageSize + 1;
+ const endItem = Math.min(
+ (pagination.pageIndex + 1) * pagination.pageSize,
+ totalCount
+ );
+ setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
+ }, [pagination.pageIndex, pagination.pageSize, totalCount]);
return (
<>
@@ -1201,7 +1181,6 @@ const StreamsTable = ({ onReady }) => {
borderRadius: 'var(--mantine-radius-default)',
}}
>
-
diff --git a/frontend/src/store/streamsTable.jsx b/frontend/src/store/streamsTable.jsx
new file mode 100644
index 00000000..f353acf7
--- /dev/null
+++ b/frontend/src/store/streamsTable.jsx
@@ -0,0 +1,49 @@
+import { create } from 'zustand';
+
+const useStreamsTableStore = create((set) => ({
+ streams: [],
+ pageCount: 0,
+ totalCount: 0,
+ sorting: [{ id: 'name', desc: false }],
+ pagination: {
+ pageIndex: 0,
+ pageSize:
+ JSON.parse(localStorage.getItem('streams-page-size')) || 50,
+ },
+ selectedStreamIds: [],
+ allQueryIds: [],
+
+ queryStreams: ({ results, count }, params) => {
+ set(() => ({
+ streams: results,
+ totalCount: count,
+ pageCount: Math.ceil(count / params.get('page_size')),
+ }));
+ },
+
+ setAllQueryIds: (allQueryIds) => {
+ set(() => ({
+ allQueryIds,
+ }));
+ },
+
+ setSelectedStreamIds: (selectedStreamIds) => {
+ set(() => ({
+ selectedStreamIds,
+ }));
+ },
+
+ setPagination: (pagination) => {
+ set(() => ({
+ pagination,
+ }));
+ },
+
+ setSorting: (sorting) => {
+ set(() => ({
+ sorting,
+ }));
+ },
+}));
+
+export default useStreamsTableStore;
From 3ddc6d196b6d5e7a32c1f44b1b057922e669dd7b Mon Sep 17 00:00:00 2001
From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
Date: Thu, 15 Jan 2026 10:23:20 -0600
Subject: [PATCH 006/125] Fix: restore StreamsTable loading feedback
---
frontend/src/components/tables/StreamsTable.jsx | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 2d7de36e..64bf425a 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -50,6 +50,7 @@ import {
Modal,
NumberInput,
Radio,
+ LoadingOverlay,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
@@ -582,6 +583,7 @@ const StreamsTable = ({ onReady }) => {
const executeDeleteStream = async (id) => {
setDeleting(true);
+ setIsLoading(true);
try {
await API.deleteStream(id);
// Clear the selection for the deleted stream
@@ -589,6 +591,7 @@ const StreamsTable = ({ onReady }) => {
table.setSelectedTableIds([]);
} finally {
setDeleting(false);
+ setIsLoading(false);
setConfirmDeleteOpen(false);
}
};
@@ -607,20 +610,27 @@ const StreamsTable = ({ onReady }) => {
const executeDeleteStreams = async () => {
setDeleting(true);
+ setIsLoading(true);
try {
await API.deleteStreams(selectedStreamIds);
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
} finally {
setDeleting(false);
+ setIsLoading(false);
setConfirmDeleteOpen(false);
}
};
- const closeStreamForm = () => {
+ const closeStreamForm = async () => {
setStream(null);
setModalOpen(false);
- API.requeryStreams();
+ setIsLoading(true);
+ try {
+ await API.requeryStreams();
+ } finally {
+ setIsLoading(false);
+ }
};
// Single channel creation functions
@@ -1181,6 +1191,7 @@ const StreamsTable = ({ onReady }) => {
borderRadius: 'var(--mantine-radius-default)',
}}
>
+
From d80da75795e6595311db9089ee11bcaa62a2a211 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 16 Jan 2026 13:50:43 -0600
Subject: [PATCH 007/125] Bug Fix: Changed logo comparisons to use `logo_id`
(raw FK integer) instead of `logo` (related object) to avoid Django's lazy
loading, which triggers a database fetch that fails if the referenced logo no
longer exists.
---
CHANGELOG.md | 2 +-
apps/vod/tasks.py | 48 ++++++++---------------------------------------
2 files changed, 9 insertions(+), 41 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 985aab37..7f273a20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
-- Fixed VOD refresh failures caused by orphaned logo references: Added validation to detect and clear logo foreign key references when the logo no longer exists in the database, preventing "VODLogo matching query does not exist" errors. Also improved logo assignment logic to properly handle cases where logo URLs exist but logo creation fails, ensuring VOD content updates successfully even when logos are deleted or unavailable.
+- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database.
- Fixed channel profile filtering to properly restrict content based on assigned channel profiles for all non-admin users (user_level < 10) instead of only streamers (user_level == 0). This corrects the XtreamCodes API endpoints (`get_live_categories` and `get_live_streams`) along with M3U and EPG generation, ensuring standard users (level 1) are properly restricted by their assigned channel profiles. Previously, "Standard" users with channel profiles assigned would see all channels instead of only those in their assigned profiles.
- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output.
- Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access.
diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py
index b0c66434..c24023d6 100644
--- a/apps/vod/tasks.py
+++ b/apps/vod/tasks.py
@@ -73,7 +73,9 @@ def refresh_vod_content(account_id):
return f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds"
except Exception as e:
+ import traceback
logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}")
+ logger.error(f"Full traceback:\n{traceback.format_exc()}")
# Send error notification
send_m3u_update(account_id, "vod_refresh", 100, status="error",
@@ -558,16 +560,16 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
if logo_url and len(logo_url) <= 500:
if logo_url in existing_logos:
new_logo = existing_logos[logo_url]
- if movie.logo != new_logo:
+ if movie.logo_id != new_logo.id:
movie._logo_to_update = new_logo
logo_updated = True
- elif movie.logo:
+ elif movie.logo_id:
# Logo URL exists but logo creation failed or logo not found
# Clear the orphaned logo reference
logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference")
movie._logo_to_update = None
logo_updated = True
- elif (not logo_url or len(logo_url) > 500) and movie.logo:
+ elif (not logo_url or len(logo_url) > 500) and movie.logo_id:
# Clear logo if no logo URL provided or URL is too long
movie._logo_to_update = None
logo_updated = True
@@ -669,8 +671,6 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
for movie in movies_to_update:
if hasattr(movie, '_logo_to_update'):
movie.logo = movie._logo_to_update
- # Validate logo reference exists before saving
- validate_logo_reference(movie, "Movie")
movie.save(update_fields=['logo'])
# Update relations to reference the correct movie objects (with PKs)
@@ -917,16 +917,16 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
if logo_url and len(logo_url) <= 500:
if logo_url in existing_logos:
new_logo = existing_logos[logo_url]
- if series.logo != new_logo:
+ if series.logo_id != new_logo.id:
series._logo_to_update = new_logo
logo_updated = True
- elif series.logo:
+ elif series.logo_id:
# Logo URL exists but logo creation failed or logo not found
# Clear the orphaned logo reference
logger.warning(f"Logo URL provided but logo not found in database for series '{series.name}', clearing logo reference")
series._logo_to_update = None
logo_updated = True
- elif (not logo_url or len(logo_url) > 500) and series.logo:
+ elif (not logo_url or len(logo_url) > 500) and series.logo_id:
# Clear logo if no logo URL provided or URL is too long
series._logo_to_update = None
logo_updated = True
@@ -1028,8 +1028,6 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
for series in series_to_update:
if hasattr(series, '_logo_to_update'):
series.logo = series._logo_to_update
- # Validate logo reference exists before saving
- validate_logo_reference(series, "Series")
series.save(update_fields=['logo'])
# Update relations to reference the correct series objects (with PKs)
@@ -2194,33 +2192,3 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False):
except Exception as e:
logger.error(f"Error refreshing advanced movie data for relation {m3u_movie_relation_id}: {str(e)}")
return f"Error: {str(e)}"
-
-
-def validate_logo_reference(obj, obj_type="object"):
- """
- Validate that a VOD logo reference exists in the database.
- If not, set it to None to prevent foreign key constraint violations.
-
- Args:
- obj: Object with a logo attribute
- obj_type: String description of the object type for logging
-
- Returns:
- bool: True if logo was valid or None, False if logo was invalid and cleared
- """
- if not hasattr(obj, 'logo') or not obj.logo:
- return True
-
- if not obj.logo.pk:
- # Logo doesn't have a primary key, so it's not saved
- obj.logo = None
- return False
-
- try:
- # Verify the logo exists in the database
- VODLogo.objects.get(pk=obj.logo.pk)
- return True
- except VODLogo.DoesNotExist:
- logger.warning(f"VOD Logo with ID {obj.logo.pk} does not exist in database for {obj_type} '{getattr(obj, 'name', 'Unknown')}', setting to None")
- obj.logo = None
- return False
From 19d25f37c6dcb49aedd76aa02b89d0e734dbd26f Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 16 Jan 2026 14:03:48 -0600
Subject: [PATCH 008/125] Bug Fix: Fixed VOD logo cleanup button count: The
"Cleanup Unused" button now displays the total count of all unused logos
across all pages instead of only counting unused logos on the current page.
---
CHANGELOG.md | 1 +
.../src/components/tables/VODLogosTable.jsx | 30 ++++++++++++++-----
frontend/src/store/vodLogos.jsx | 15 ++++++++++
3 files changed, 39 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f273a20..c636e281 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page.
- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database.
- Fixed channel profile filtering to properly restrict content based on assigned channel profiles for all non-admin users (user_level < 10) instead of only streamers (user_level == 0). This corrects the XtreamCodes API endpoints (`get_live_categories` and `get_live_streams`) along with M3U and EPG generation, ensuring standard users (level 1) are properly restricted by their assigned channel profiles. Previously, "Standard" users with channel profiles assigned would see all channels instead of only those in their assigned profiles.
- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output.
diff --git a/frontend/src/components/tables/VODLogosTable.jsx b/frontend/src/components/tables/VODLogosTable.jsx
index 75b322f5..b5529a57 100644
--- a/frontend/src/components/tables/VODLogosTable.jsx
+++ b/frontend/src/components/tables/VODLogosTable.jsx
@@ -64,6 +64,7 @@ export default function VODLogosTable() {
deleteVODLogo,
deleteVODLogos,
cleanupUnusedVODLogos,
+ getUnusedLogosCount,
} = useVODLogosStore();
const [currentPage, setCurrentPage] = useState(1);
@@ -77,14 +78,9 @@ export default function VODLogosTable() {
const [deleting, setDeleting] = useState(false);
const [paginationString, setPaginationString] = useState('');
const [isCleaningUp, setIsCleaningUp] = useState(false);
+ const [unusedLogosCount, setUnusedLogosCount] = useState(0);
+ const [loadingUnusedCount, setLoadingUnusedCount] = 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,
@@ -94,6 +90,23 @@ export default function VODLogosTable() {
});
}, [currentPage, pageSize, nameFilter, usageFilter, fetchVODLogos]);
+ // Fetch the total count of unused logos
+ useEffect(() => {
+ const fetchUnusedCount = async () => {
+ setLoadingUnusedCount(true);
+ try {
+ const count = await getUnusedLogosCount();
+ setUnusedLogosCount(count);
+ } catch (error) {
+ console.error('Failed to fetch unused logos count:', error);
+ } finally {
+ setLoadingUnusedCount(false);
+ }
+ };
+
+ fetchUnusedCount();
+ }, [getUnusedLogosCount]);
+
const handleSelectAll = useCallback(
(checked) => {
if (checked) {
@@ -185,6 +198,9 @@ export default function VODLogosTable() {
message: `Cleaned up ${result.deleted_count} unused VOD logos`,
color: 'green',
});
+ // Refresh the unused count after cleanup
+ const newCount = await getUnusedLogosCount();
+ setUnusedLogosCount(newCount);
} catch (error) {
notifications.show({
title: 'Error',
diff --git a/frontend/src/store/vodLogos.jsx b/frontend/src/store/vodLogos.jsx
index 4df2dd17..70c81293 100644
--- a/frontend/src/store/vodLogos.jsx
+++ b/frontend/src/store/vodLogos.jsx
@@ -116,6 +116,21 @@ const useVODLogosStore = create((set) => ({
}
},
+ getUnusedLogosCount: async () => {
+ try {
+ const response = await api.getVODLogos({
+ used: 'false',
+ page_size: 1, // Fetch only 1 item to minimize data transfer
+ });
+
+ // Return the count from the paginated response
+ return response.count || 0;
+ } catch (error) {
+ console.error('Failed to fetch unused logos count:', error);
+ throw error;
+ }
+ },
+
clearVODLogos: () => {
set({
vodLogos: {},
From 91d73a1fe8cbc3fd015b71efe6c19b6ec00230e5 Mon Sep 17 00:00:00 2001
From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
Date: Sat, 17 Jan 2026 15:00:09 -0600
Subject: [PATCH 009/125] fix: stabilize build-dev paths and push behavior
---
docker/build-dev.sh | 94 +++++++++++++++++++++++----------------------
1 file changed, 49 insertions(+), 45 deletions(-)
diff --git a/docker/build-dev.sh b/docker/build-dev.sh
index 61640814..703e06c6 100755
--- a/docker/build-dev.sh
+++ b/docker/build-dev.sh
@@ -1,65 +1,69 @@
-#!/bin/bash
+#!/bin/bash
set -e
# Default values
-VERSION=$(python3 -c "import sys; sys.path.append('..'); import version; print(version.__version__)")
-REGISTRY="dispatcharr" # Registry or private repo to push to
-IMAGE="dispatcharr" # Image that we're building
-BRANCH="dev"
-ARCH="" # Architectures to build for, e.g. linux/amd64,linux/arm64
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+VERSION=$(python3 -c "import sys; sys.path.append('${ROOT_DIR}'); import version; print(version.__version__)")
+REGISTRY="dispatcharr" # Registry or private repo to push to
+IMAGE="dispatcharr" # Image that we're building
+BRANCH="dev"
+ARCH="" # Architectures to build for, e.g. linux/amd64,linux/arm64
PUSH=false
usage() {
- cat <<- EOF
- To test locally:
- ./build-dev.sh
+ cat <<-EOF
+ To test locally:
+ ./build-dev.sh
- To build and push to registry:
- ./build-dev.sh -p
+ To build and push to registry:
+ ./build-dev.sh -p
- To build and push to a private registry:
- ./build-dev.sh -p -r myregistry:5000
+ To build and push to a private registry:
+ ./build-dev.sh -p -r myregistry:5000
- To build for -both- x86_64 and arm_64:
- ./build-dev.sh -p -a linux/amd64,linux/arm64
+ To build for -both- x86_64 and arm_64:
+ ./build-dev.sh -p -a linux/amd64,linux/arm64
- Do it all:
- ./build-dev.sh -p -r myregistry:5000 -a linux/amd64,linux/arm64
-EOF
-exit 0
+ Do it all:
+ ./build-dev.sh -p -r myregistry:5000 -a linux/amd64,linux/arm64
+ EOF
+ exit 0
}
# Parse options
while getopts "pr:a:b:i:h" opt; do
- case $opt in
- r) REGISTRY="$OPTARG" ;;
- a) ARCH="--platform $OPTARG" ;;
- b) BRANCH="$OPTARG" ;;
- i) IMAGE="$OPTARG" ;;
- p) PUSH=true ;;
- h) usage ;;
- \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
- esac
+ case $opt in
+ r) REGISTRY="$OPTARG" ;;
+ a) ARCH="$OPTARG" ;;
+ b) BRANCH="$OPTARG" ;;
+ i) IMAGE="$OPTARG" ;;
+ p) PUSH=true ;;
+ h) usage ;;
+ \?)
+ echo "Invalid option: -$OPTARG" >&2
+ exit 1
+ ;;
+ esac
done
BUILD_ARGS="BRANCH=$BRANCH"
-
-echo docker build --build-arg $BUILD_ARGS $ARCH -t $IMAGE
-docker build -f Dockerfile --build-arg $BUILD_ARGS $ARCH -t $IMAGE ..
-docker tag $IMAGE $IMAGE:$BRANCH
-docker tag $IMAGE $IMAGE:$VERSION
-
-if [ -z "$PUSH" ]; then
- echo "Please run 'docker push -t $IMAGE:dev -t $IMAGE:${VERSION}' when ready"
-else
- for TAG in latest "$VERSION" "$BRANCH"; do
- docker tag "$IMAGE" "$REGISTRY/$IMAGE:$TAG"
- docker push -q "$REGISTRY/$IMAGE:$TAG"
- done
- echo "Images pushed successfully."
+ARCH_ARGS=()
+if [ -n "$ARCH" ]; then
+ ARCH_ARGS=(--platform "$ARCH")
fi
+echo docker build --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE"
+docker build -f "${SCRIPT_DIR}/Dockerfile" --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE" "$ROOT_DIR"
+docker tag "$IMAGE" "$IMAGE":"$BRANCH"
+docker tag "$IMAGE" "$IMAGE":"$VERSION"
-
-
-
+if [ "$PUSH" = "true" ]; then
+ for TAG in latest "$VERSION" "$BRANCH"; do
+ docker tag "$IMAGE" "$REGISTRY/$IMAGE:$TAG"
+ docker push -q "$REGISTRY/$IMAGE:$TAG"
+ done
+ echo "Images pushed successfully."
+else
+ echo "Please run 'docker push $IMAGE:$BRANCH' and 'docker push $IMAGE:${VERSION}' when ready"
+fi
From d33d047a9460dfcddaa1621950c814ab43234606 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 17 Jan 2026 15:00:28 -0600
Subject: [PATCH 010/125] Enhancement: - Mature content filtering support: -
Added `is_adult` boolean field to both Stream and Channel models with
database indexing for efficient filtering and sorting - Automatically
populated during M3U/XC refresh operations by extracting `is_adult` value
from provider data - Type-safe conversion supporting both integer (0/1) and
string ("0"/"1") formats from different providers - UI controls in channel
edit form (Switch with tooltip) and bulk edit form (Select dropdown) for easy
management - XtreamCodes API support with proper integer formatting (0/1)
in live stream responses - Automatic propagation from streams to channels
during both single and bulk channel creation operations - Included in
serializers for full API support - User-level content filtering: Non-admin
users can opt to hide mature content channels across all interfaces (web UI,
M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in
user settings (stored in custom_properties, admin users always see all
content)
---
CHANGELOG.md | 9 ++++
apps/channels/api_views.py | 5 +++
.../0032_channel_is_adult_stream_is_adult.py | 23 +++++++++++
apps/channels/models.py | 11 +++++
apps/channels/serializers.py | 2 +
apps/channels/tasks.py | 1 +
apps/m3u/tasks.py | 13 ++++--
apps/output/views.py | 41 +++++++++++++++----
frontend/src/components/forms/Channel.jsx | 17 ++++++--
.../src/components/forms/ChannelBatch.jsx | 28 +++++++++++++
frontend/src/components/forms/User.jsx | 27 ++++++++++++
11 files changed, 163 insertions(+), 14 deletions(-)
create mode 100644 apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c636e281..53da6118 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Mature content filtering support:
+ - Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting
+ - Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data
+ - Type-safe conversion supporting both integer (0/1) and string ("0"/"1") formats from different providers
+ - UI controls in channel edit form (Switch with tooltip) and bulk edit form (Select dropdown) for easy management
+ - XtreamCodes API support with proper integer formatting (0/1) in live stream responses
+ - Automatic propagation from streams to channels during both single and bulk channel creation operations
+ - Included in serializers for full API support
+ - User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content)
- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663)
- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 8ea5db8a..8cbd70d1 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -585,6 +585,10 @@ class ChannelViewSet(viewsets.ModelViewSet):
if self.request.user.user_level < 10:
filters["user_level__lte"] = self.request.user.user_level
+ # Hide adult content if user preference is set
+ custom_props = self.request.user.custom_properties or {}
+ if custom_props.get('hide_adult_content', False):
+ filters["is_adult"] = False
if filters:
qs = qs.filter(**filters)
@@ -947,6 +951,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
"tvg_id": stream.tvg_id,
"tvc_guide_stationid": tvc_guide_stationid,
"streams": [stream_id],
+ "is_adult": stream.is_adult,
}
# Only add channel_group_id if the stream has a channel group
diff --git a/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py
new file mode 100644
index 00000000..cbf6ba4f
--- /dev/null
+++ b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.9 on 2026-01-17 16:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dispatcharr_channels', '0031_channelgroupm3uaccount_is_stale_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='channel',
+ name='is_adult',
+ field=models.BooleanField(db_index=True, default=False, help_text='Whether this channel contains adult content'),
+ ),
+ migrations.AddField(
+ model_name='stream',
+ name='is_adult',
+ field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream contains adult content'),
+ ),
+ ]
diff --git a/apps/channels/models.py b/apps/channels/models.py
index 6d199520..703498a8 100644
--- a/apps/channels/models.py
+++ b/apps/channels/models.py
@@ -99,6 +99,11 @@ class Stream(models.Model):
db_index=True,
help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)"
)
+ is_adult = models.BooleanField(
+ default=False,
+ db_index=True,
+ help_text="Whether this stream contains adult content"
+ )
custom_properties = models.JSONField(default=dict, blank=True, null=True)
# Stream statistics fields
@@ -301,6 +306,12 @@ class Channel(models.Model):
user_level = models.IntegerField(default=0)
+ is_adult = models.BooleanField(
+ default=False,
+ db_index=True,
+ help_text="Whether this channel contains adult content"
+ )
+
auto_created = models.BooleanField(
default=False,
help_text="Whether this channel was automatically created via M3U auto channel sync"
diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py
index c1919e24..42853ca1 100644
--- a/apps/channels/serializers.py
+++ b/apps/channels/serializers.py
@@ -120,6 +120,7 @@ class StreamSerializer(serializers.ModelSerializer):
"updated_at",
"last_seen",
"is_stale",
+ "is_adult",
"stream_profile_id",
"is_custom",
"channel_group",
@@ -293,6 +294,7 @@ class ChannelSerializer(serializers.ModelSerializer):
"uuid",
"logo_id",
"user_level",
+ "is_adult",
"auto_created",
"auto_created_by",
"auto_created_by_name",
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index b3e11251..f37838fd 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -2585,6 +2585,7 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None
"name": name,
"tvc_guide_stationid": tvc_guide_stationid,
"tvg_id": stream.tvg_id,
+ "is_adult": stream.is_adult,
}
# Only add channel_group_id if the stream has a channel group
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index ed9eb465..82e4f213 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -834,6 +834,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
"channel_group_id": int(group_id),
"stream_hash": stream_hash,
"custom_properties": stream,
+ "is_adult": int(stream.get("is_adult", 0)) == 1,
"is_stale": False,
}
@@ -862,7 +863,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
obj.url != stream_props["url"] or
obj.logo_url != stream_props["logo_url"] or
obj.tvg_id != stream_props["tvg_id"] or
- obj.custom_properties != stream_props["custom_properties"]
+ obj.custom_properties != stream_props["custom_properties"] or
+ obj.is_adult != stream_props["is_adult"]
)
if changed:
@@ -898,7 +900,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
# Simplified bulk update for better performance
Stream.objects.bulk_update(
streams_to_update,
- ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'],
+ ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
batch_size=150 # Smaller batch size for XC processing
)
@@ -1011,6 +1013,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
"channel_group_id": int(groups.get(group_title)),
"stream_hash": stream_hash,
"custom_properties": stream_info["attributes"],
+ "is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1,
"is_stale": False,
}
@@ -1036,7 +1039,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.url != stream_props["url"] or
obj.logo_url != stream_props["logo_url"] or
obj.tvg_id != stream_props["tvg_id"] or
- obj.custom_properties != stream_props["custom_properties"]
+ obj.custom_properties != stream_props["custom_properties"] or
+ obj.is_adult != stream_props["is_adult"]
)
# Always update last_seen
@@ -1049,6 +1053,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.logo_url = stream_props["logo_url"]
obj.tvg_id = stream_props["tvg_id"]
obj.custom_properties = stream_props["custom_properties"]
+ obj.is_adult = stream_props["is_adult"]
obj.updated_at = timezone.now()
# Always mark as not stale since we saw it in this refresh
@@ -1071,7 +1076,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
# Update all streams in a single bulk operation
Stream.objects.bulk_update(
streams_to_update,
- ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'],
+ ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
batch_size=200
)
except Exception as e:
diff --git a/apps/output/views.py b/apps/output/views.py
index 2cdd4dac..a34ff45f 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -134,7 +134,11 @@ def generate_m3u(request, profile_name=None, user=None):
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
- channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number")
+ filters = {"user_level__lte": user.user_level}
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
+ channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
# User has specific limited profiles assigned
filters = {
@@ -142,6 +146,9 @@ def generate_m3u(request, profile_name=None, user=None):
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
@@ -1264,7 +1271,11 @@ def generate_epg(request, profile_name=None, user=None):
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
- channels = Channel.objects.filter(user_level__lte=user.user_level).order_by("channel_number")
+ filters = {"user_level__lte": user.user_level}
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
+ channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
# User has specific limited profiles assigned
filters = {
@@ -1272,6 +1283,9 @@ def generate_epg(request, profile_name=None, user=None):
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
@@ -2125,6 +2139,9 @@ def xc_get_live_streams(request, user, category_id=None):
filters = {"user_level__lte": user.user_level}
if category_id is not None:
filters["channel_group__id"] = category_id
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
# User has specific limited profiles assigned
@@ -2135,6 +2152,9 @@ def xc_get_live_streams(request, user, category_id=None):
}
if category_id is not None:
filters["channel_group__id"] = category_id
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
channels = Channel.objects.filter(**filters).distinct().order_by("channel_number")
else:
if not category_id:
@@ -2189,7 +2209,7 @@ def xc_get_live_streams(request, user, category_id=None):
),
"epg_channel_id": str(channel_num_int),
"added": int(channel.created_at.timestamp()),
- "is_adult": 0,
+ "is_adult": int(channel.is_adult),
"category_id": str(channel.channel_group.id),
"category_ids": [channel.channel_group.id],
"custom_sid": None,
@@ -2214,10 +2234,14 @@ def xc_get_epg(request, user, short=False):
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
- channel = Channel.objects.filter(
- id=channel_id,
- user_level__lte=user.user_level
- ).first()
+ filters = {
+ "id": channel_id,
+ "user_level__lte": user.user_level
+ }
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
+ channel = Channel.objects.filter(**filters).first()
else:
# User has specific limited profiles assigned
filters = {
@@ -2226,6 +2250,9 @@ def xc_get_epg(request, user, short=False):
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
+ # Hide adult content if user preference is set
+ if (user.custom_properties or {}).get('hide_adult_content', False):
+ filters["is_adult"] = False
channel = Channel.objects.filter(**filters).distinct().first()
if not channel:
diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx
index 4f12dd2c..379825a0 100644
--- a/frontend/src/components/forms/Channel.jsx
+++ b/frontend/src/components/forms/Channel.jsx
@@ -18,12 +18,10 @@ import {
Button,
Modal,
TextInput,
- NativeSelect,
Text,
Group,
ActionIcon,
Center,
- Grid,
Flex,
Select,
Divider,
@@ -33,8 +31,8 @@ import {
ScrollArea,
Tooltip,
NumberInput,
- Image,
UnstyledButton,
+ Switch,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { ListOrdered, SquarePlus, SquareX, X, Zap } from 'lucide-react';
@@ -318,6 +316,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
epg_data_id: channel?.epg_data_id ?? '',
logo_id: channel?.logo_id ? `${channel.logo_id}` : '',
user_level: `${channel?.user_level ?? '0'}`,
+ is_adult: channel?.is_adult ?? false,
}),
[channel, channelGroups]
);
@@ -811,6 +810,18 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
>
Upload or Create Logo
+
+
+
+ setValue('is_adult', event.currentTarget.checked)
+ }
+ size="md"
+ />
+
+
diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx
index 14dd22f1..bdbb0b2d 100644
--- a/frontend/src/components/forms/ChannelBatch.jsx
+++ b/frontend/src/components/forms/ChannelBatch.jsx
@@ -102,6 +102,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
logo: '(no change)',
stream_profile_id: '-1',
user_level: '-1',
+ is_adult: '-1',
},
});
@@ -153,6 +154,13 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
changes.push(`• User Level: ${userLevelLabel}`);
}
+ // Check mature content flag
+ if (values.is_adult && values.is_adult !== '-1') {
+ changes.push(
+ `• Mature Content: ${values.is_adult === 'true' ? 'Yes' : 'No'}`
+ );
+ }
+
// Check dummy EPG
if (selectedDummyEpgId) {
if (selectedDummyEpgId === 'clear') {
@@ -223,6 +231,14 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
delete values.user_level;
}
+ if (values.is_adult === '-1') {
+ delete values.is_adult;
+ } else if (values.is_adult === 'true') {
+ values.is_adult = true;
+ } else if (values.is_adult === 'false') {
+ values.is_adult = false;
+ }
+
// Remove the channel_group field from form values as we use channel_group_id
delete values.channel_group;
@@ -931,6 +947,18 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
})
)}
/>
+
+
diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx
index 619b156f..29c93f30 100644
--- a/frontend/src/components/forms/User.jsx
+++ b/frontend/src/components/forms/User.jsx
@@ -12,6 +12,9 @@ import {
Stack,
MultiSelect,
ActionIcon,
+ Switch,
+ Box,
+ Tooltip,
} from '@mantine/core';
import { RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
@@ -38,6 +41,7 @@ const User = ({ user = null, isOpen, onClose }) => {
password: '',
xc_password: '',
channel_profiles: [],
+ hide_adult_content: false,
},
validate: (values) => ({
@@ -80,6 +84,10 @@ const User = ({ user = null, isOpen, onClose }) => {
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
+ // Save hide_adult_content in custom_properties
+ customProps.hide_adult_content = values.hide_adult_content || false;
+ delete values.hide_adult_content;
+
values.custom_properties = customProps;
// If 'All' is included, clear this and we assume access to all channels
@@ -125,6 +133,7 @@ const User = ({ user = null, isOpen, onClose }) => {
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
+ hide_adult_content: customProps.hide_adult_content || false,
});
if (customProps.xc_password) {
@@ -242,6 +251,24 @@ const User = ({ user = null, isOpen, onClose }) => {
}))}
/>
)}
+
+ {showPermissions && (
+
+
+
+
+
+ )}
From 1afb15fb63d649f5696f251f2ca46fbc1e4bf72d Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 17 Jan 2026 16:15:11 -0600
Subject: [PATCH 011/125] Revert change to header sizing.
---
frontend/src/index.css | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1586f432..5e902b6c 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -118,14 +118,14 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
.custom-multiselect .mantine-MultiSelect-input {
min-height: 30px;
- max-height: fit-content;
- overflow: visible;
- flex-wrap: wrap;
+ max-height: 30px;
+ overflow: hidden;
+ flex-wrap: nowrap;
}
.custom-multiselect .mantine-MultiSelect-pillsList {
- flex-wrap: wrap;
- overflow: visible;
+ flex-wrap: nowrap;
+ overflow: hidden;
}
.custom-multiselect .mantine-MultiSelect-pill {
From f81014249389fd8be0f60cb693827291df47efbf Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 17 Jan 2026 17:55:12 -0600
Subject: [PATCH 012/125] Enhancement: Optimize channel filtering in
StreamViewSet using Count annotation for improved performance on large
datasets.
---
apps/channels/api_views.py | 4 +-
.../src/components/tables/StreamsTable.jsx | 125 +++++++++---------
2 files changed, 67 insertions(+), 62 deletions(-)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 8ea5db8a..a87bac90 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -8,6 +8,7 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
+from django.db.models import Count
from django.db.models import Q
import os, json, requests, logging
from urllib.parse import unquote
@@ -148,7 +149,8 @@ class StreamViewSet(viewsets.ModelViewSet):
unassigned = self.request.query_params.get("unassigned")
if unassigned == "1":
- qs = qs.filter(channels__isnull=True)
+ # Use annotation with Count for better performance on large datasets
+ qs = qs.annotate(channel_count=Count('channels')).filter(channel_count=0)
channel_group = self.request.query_params.get("channel_group")
if channel_group:
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 167d2532..61fe36b7 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -397,70 +397,73 @@ const StreamsTable = ({ onReady }) => {
}));
};
- const fetchData = useCallback(async ({ showLoader = true } = {}) => {
- if (showLoader) {
- setIsLoading(true);
- }
-
- const params = new URLSearchParams();
- params.append('page', pagination.pageIndex + 1);
- params.append('page_size', pagination.pageSize);
-
- // Apply sorting
- if (sorting.length > 0) {
- const columnId = sorting[0].id;
- // Map frontend column IDs to backend field names
- const fieldMapping = {
- name: 'name',
- group: 'channel_group__name',
- m3u: 'm3u_account__name',
- };
- const sortField = fieldMapping[columnId] || columnId;
- const sortDirection = sorting[0].desc ? '-' : '';
- params.append('ordering', `${sortDirection}${sortField}`);
- }
-
- // Apply debounced filters
- Object.entries(debouncedFilters).forEach(([key, value]) => {
- if (value) params.append(key, value);
- });
-
- try {
- const [result, ids, filterOptions] = await Promise.all([
- API.queryStreamsTable(params),
- API.getAllStreamIds(params),
- API.getStreamFilterOptions(params),
- ]);
-
- setAllRowIds(ids);
-
- // Set filtered options based on current filters
- setGroupOptions(filterOptions.groups);
- setM3uOptions(
- filterOptions.m3u_accounts.map((m3u) => ({
- label: m3u.name,
- value: `${m3u.id}`,
- }))
- );
-
- if (initialDataCount === null) {
- setInitialDataCount(result.count);
+ const fetchData = useCallback(
+ async ({ showLoader = true } = {}) => {
+ if (showLoader) {
+ setIsLoading(true);
}
- // Signal that initial data load is complete
- if (!hasSignaledReady.current && onReady) {
- hasSignaledReady.current = true;
- onReady();
- }
- } catch (error) {
- console.error('Error fetching data:', error);
- }
+ const params = new URLSearchParams();
+ params.append('page', pagination.pageIndex + 1);
+ params.append('page_size', pagination.pageSize);
- hasFetchedOnce.current = true;
- if (showLoader) {
- setIsLoading(false);
- }
- }, [pagination, sorting, debouncedFilters, onReady]);
+ // Apply sorting
+ if (sorting.length > 0) {
+ const columnId = sorting[0].id;
+ // Map frontend column IDs to backend field names
+ const fieldMapping = {
+ name: 'name',
+ group: 'channel_group__name',
+ m3u: 'm3u_account__name',
+ };
+ const sortField = fieldMapping[columnId] || columnId;
+ const sortDirection = sorting[0].desc ? '-' : '';
+ params.append('ordering', `${sortDirection}${sortField}`);
+ }
+
+ // Apply debounced filters
+ Object.entries(debouncedFilters).forEach(([key, value]) => {
+ if (value) params.append(key, value);
+ });
+
+ try {
+ const [result, ids, filterOptions] = await Promise.all([
+ API.queryStreamsTable(params),
+ API.getAllStreamIds(params),
+ API.getStreamFilterOptions(params),
+ ]);
+
+ setAllRowIds(ids);
+
+ // Set filtered options based on current filters
+ setGroupOptions(filterOptions.groups);
+ setM3uOptions(
+ filterOptions.m3u_accounts.map((m3u) => ({
+ label: m3u.name,
+ value: `${m3u.id}`,
+ }))
+ );
+
+ if (initialDataCount === null) {
+ setInitialDataCount(result.count);
+ }
+
+ // Signal that initial data load is complete
+ if (!hasSignaledReady.current && onReady) {
+ hasSignaledReady.current = true;
+ onReady();
+ }
+ } catch (error) {
+ console.error('Error fetching data:', error);
+ }
+
+ hasFetchedOnce.current = true;
+ if (showLoader) {
+ setIsLoading(false);
+ }
+ },
+ [pagination, sorting, debouncedFilters, onReady]
+ );
// Bulk creation: create channels from selected streams asynchronously
const createChannelsFromStreams = async () => {
From 0a340e51e2b89e1aaea2d9a6831cae51212e2cb7 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 17 Jan 2026 18:05:57 -0600
Subject: [PATCH 013/125] Enhancement: Add playlist and channel group fetching
logic to StreamsTable for improved data management
---
.../src/components/tables/StreamsTable.jsx | 31 ++++++++++++++++++-
1 file changed, 30 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 61fe36b7..dcb3284e 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -182,6 +182,8 @@ const StreamsTable = ({ onReady }) => {
const theme = useMantineTheme();
const hasSignaledReady = useRef(false);
const hasFetchedOnce = useRef(false);
+ const hasFetchedPlaylists = useRef(false);
+ const hasFetchedChannelGroups = useRef(false);
/**
* useState
@@ -249,6 +251,8 @@ const StreamsTable = ({ onReady }) => {
* Stores
*/
const playlists = usePlaylistsStore((s) => s.playlists);
+ const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
+ const playlistsLoading = usePlaylistsStore((s) => s.isLoading);
// Get direct access to channel groups without depending on other data
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
@@ -1048,11 +1052,15 @@ const StreamsTable = ({ onReady }) => {
}, [fetchData]);
useEffect(() => {
- if (Object.keys(channelGroups).length > 0) {
+ if (
+ Object.keys(channelGroups).length > 0 ||
+ hasFetchedChannelGroups.current
+ ) {
return;
}
const loadGroups = async () => {
+ hasFetchedChannelGroups.current = true;
try {
await fetchChannelGroups();
} catch (error) {
@@ -1063,6 +1071,27 @@ const StreamsTable = ({ onReady }) => {
loadGroups();
}, [channelGroups, fetchChannelGroups]);
+ useEffect(() => {
+ if (
+ playlists.length > 0 ||
+ hasFetchedPlaylists.current ||
+ playlistsLoading
+ ) {
+ return;
+ }
+
+ const loadPlaylists = async () => {
+ hasFetchedPlaylists.current = true;
+ try {
+ await fetchPlaylists();
+ } catch (error) {
+ console.error('Error fetching playlists:', error);
+ }
+ };
+
+ loadPlaylists();
+ }, [playlists, fetchPlaylists, playlistsLoading]);
+
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1;
const endItem = Math.min(
From a772f892bd86053d82001169b556540b0220f00d Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 17 Jan 2026 18:26:33 -0600
Subject: [PATCH 014/125] changelog: Update changelog for recent stream table
PR.
---
CHANGELOG.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53da6118..6ff0bf14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667)
- Mature content filtering support:
- Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting
- Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data
@@ -25,10 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence.
- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage.
-- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `table.headerPinned`) instead of calling hooks directly, improving maintainability and providing consistent API across all tables.
+- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `.g maintainability and providing consistent API across all tables.
+- Optimized unassociated streams filter performance: Replaced inefficient reverse foreign key NULL check (`channels__isnull=True`) with Count annotation approach, reducing query time from 4-5 seconds to under 500ms for large datasets (75k+ streams)
### Fixed
+- Fixed streams table column header overflow: Implemented fixed-height column headers (30px max-height) with pill-style filter display showing first selection plus count (e.g., "Sport +3"). Prevents header expansion when multiple filters are selected, maintaining compact table layout. (Fixes #613)
- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page.
- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database.
- Fixed channel profile filtering to properly restrict content based on assigned channel profiles for all non-admin users (user_level < 10) instead of only streamers (user_level == 0). This corrects the XtreamCodes API endpoints (`get_live_categories` and `get_live_streams`) along with M3U and EPG generation, ensuring standard users (level 1) are properly restricted by their assigned channel profiles. Previously, "Standard" users with channel profiles assigned would see all channels instead of only those in their assigned profiles.
From fe60c4f3bc111dd2cf6afd3422d1b182412204ae Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 17 Jan 2026 18:30:13 -0600
Subject: [PATCH 015/125] Enhancement: Update frontend tests workflow to ensure
proper triggering on push and pull request events only when frontend code
changes.
---
.github/workflows/frontend-tests.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml
index b279a82a..4e9e2505 100644
--- a/.github/workflows/frontend-tests.yml
+++ b/.github/workflows/frontend-tests.yml
@@ -3,8 +3,14 @@ name: Frontend Tests
on:
push:
branches: [main, dev]
+ paths:
+ - 'frontend/**'
+ - '.github/workflows/frontend-tests.yml'
pull_request:
branches: [main, dev]
+ paths:
+ - 'frontend/**'
+ - '.github/workflows/frontend-tests.yml'
jobs:
test:
From 4f9088c6fcfec0754e917a42a43078111edbb4f7 Mon Sep 17 00:00:00 2001
From: Jeffrey C <990135+JeffreyBytes@users.noreply.github.com>
Date: Sat, 17 Jan 2026 21:15:02 -0600
Subject: [PATCH 016/125] Fix: requery streams using table filters
---
frontend/src/api.js | 12 ++++++++----
frontend/src/store/streamsTable.jsx | 7 +++++++
2 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/frontend/src/api.js b/frontend/src/api.js
index f878c047..49a157e2 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -715,6 +715,7 @@ export default class API {
static async queryStreamsTable(params) {
try {
API.lastStreamQueryParams = params;
+ useStreamsTableStore.getState().setLastQueryParams(params);
const response = await request(
`${host}/api/channels/streams/?${params.toString()}`
@@ -729,21 +730,24 @@ export default class API {
}
static async requeryStreams() {
- if (!API.lastStreamQueryParams) {
+ const params =
+ useStreamsTableStore.getState().lastQueryParams ||
+ API.lastStreamQueryParams;
+ if (!params) {
return null;
}
try {
const [response, ids] = await Promise.all([
request(
- `${host}/api/channels/streams/?${API.lastStreamQueryParams.toString()}`
+ `${host}/api/channels/streams/?${params.toString()}`
),
- API.getAllStreamIds(API.lastStreamQueryParams),
+ API.getAllStreamIds(params),
]);
useStreamsTableStore
.getState()
- .queryStreams(response, API.lastStreamQueryParams);
+ .queryStreams(response, params);
useStreamsTableStore.getState().setAllQueryIds(ids);
return response;
diff --git a/frontend/src/store/streamsTable.jsx b/frontend/src/store/streamsTable.jsx
index f353acf7..2057a6e2 100644
--- a/frontend/src/store/streamsTable.jsx
+++ b/frontend/src/store/streamsTable.jsx
@@ -12,6 +12,7 @@ const useStreamsTableStore = create((set) => ({
},
selectedStreamIds: [],
allQueryIds: [],
+ lastQueryParams: null,
queryStreams: ({ results, count }, params) => {
set(() => ({
@@ -44,6 +45,12 @@ const useStreamsTableStore = create((set) => ({
sorting,
}));
},
+
+ setLastQueryParams: (lastQueryParams) => {
+ set(() => ({
+ lastQueryParams,
+ }));
+ },
}));
export default useStreamsTableStore;
From c970cfcf9a78706e795e074d3b09ce99c0731383 Mon Sep 17 00:00:00 2001
From: DawtCom
Date: Sun, 18 Jan 2026 00:49:17 -0600
Subject: [PATCH 017/125] Move caching to client to remove burden on dispatch
server
---
apps/channels/api_views.py | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index fcf50f49..c2ba7a06 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -9,7 +9,8 @@ from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Q
-import os, json, requests, logging
+import os, json, requests, logging, mimetypes
+from django.utils.http import http_date
from urllib.parse import unquote
from apps.accounts.permissions import (
Authenticated,
@@ -1653,11 +1654,10 @@ class LogoViewSet(viewsets.ModelViewSet):
"""Streams the logo file, whether it's local or remote."""
logo = self.get_object()
logo_url = logo.url
-
if logo_url.startswith("/data"): # Local file
if not os.path.exists(logo_url):
raise Http404("Image not found")
-
+ stat = os.stat(logo_url)
# Get proper mime type (first item of the tuple)
content_type, _ = mimetypes.guess_type(logo_url)
if not content_type:
@@ -1667,6 +1667,8 @@ class LogoViewSet(viewsets.ModelViewSet):
response = StreamingHttpResponse(
open(logo_url, "rb"), content_type=content_type
)
+ response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours
+ response["Last-Modified"] = http_date(stat.st_mtime)
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)
)
@@ -1706,6 +1708,10 @@ class LogoViewSet(viewsets.ModelViewSet):
remote_response.iter_content(chunk_size=8192),
content_type=content_type,
)
+ if(remote_response.headers.get("Cache-Control")):
+ response["Cache-Control"] = remote_response.headers.get("Cache-Control")
+ if(remote_response.headers.get("Last-Modified")):
+ response["Last-Modified"] = remote_response.headers.get("Last-Modified")
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)
)
From 620d65d1de0d84c0fcbc029bdd0b80766fd36eeb Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 18 Jan 2026 15:52:50 -0600
Subject: [PATCH 018/125] Bug Fix: Fixed XtreamCodes API crash when channels
have NULL channel_group: The `player_api.php` endpoint
(`xc_get_live_streams`) now gracefully handles channels without an assigned
channel_group by dynamically looking up and assigning them to "Default Group"
instead of crashing with AttributeError. Additionally, the Channel serializer
now auto-assigns new channels to "Default Group" when `channel_group_id` is
omitted during creation, preventing future NULL channel_group issues.
---
CHANGELOG.md | 1 +
apps/channels/serializers.py | 7 +++++++
apps/output/views.py | 4 ++--
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ff0bf14..26589be7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues.
- Fixed streams table column header overflow: Implemented fixed-height column headers (30px max-height) with pill-style filter display showing first selection plus count (e.g., "Sport +3"). Prevents header expansion when multiple filters are selected, maintaining compact table layout. (Fixes #613)
- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page.
- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database.
diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py
index 42853ca1..53c07292 100644
--- a/apps/channels/serializers.py
+++ b/apps/channels/serializers.py
@@ -332,6 +332,13 @@ class ChannelSerializer(serializers.ModelSerializer):
"channel_number", Channel.get_next_available_channel_number()
)
validated_data["channel_number"] = channel_number
+
+ # Auto-assign Default Group if no channel_group is specified
+ if "channel_group" not in validated_data or validated_data.get("channel_group") is None:
+ from apps.channels.models import ChannelGroup
+ default_group, _ = ChannelGroup.objects.get_or_create(name="Default Group")
+ validated_data["channel_group"] = default_group
+
channel = Channel.objects.create(**validated_data)
# Add streams in the specified order
diff --git a/apps/output/views.py b/apps/output/views.py
index a34ff45f..ba75246a 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -2210,8 +2210,8 @@ def xc_get_live_streams(request, user, category_id=None):
"epg_channel_id": str(channel_num_int),
"added": int(channel.created_at.timestamp()),
"is_adult": int(channel.is_adult),
- "category_id": str(channel.channel_group.id),
- "category_ids": [channel.channel_group.id],
+ "category_id": str(channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id),
+ "category_ids": [channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id],
"custom_sid": None,
"tv_archive": 0,
"direct_source": "",
From 39bf57bf407d68be5f8e9ac3da4302cfc1068458 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 18 Jan 2026 16:24:26 -0600
Subject: [PATCH 019/125] Bug Fix: Fixed TypeError on streams table load after
container restart: Added robust data validation and type coercion to handle
malformed filter options during container startup. The streams table
MultiSelect components now safely convert group names to strings and filter
out null/undefined values, preventing "right-hand side of 'in' should be an
object, got number" errors when the backend hasn't fully initialized. API
error handling returns safe defaults.
---
CHANGELOG.md | 1 +
frontend/src/api.js | 2 ++
.../src/components/tables/StreamsTable.jsx | 24 +++++++++++++------
3 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26589be7..68edab34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed TypeError on streams table load after container restart: Added robust data validation and type coercion to handle malformed filter options during container startup. The streams table MultiSelect components now safely convert group names to strings and filter out null/undefined values, preventing "right-hand side of 'in' should be an object, got number" errors when the backend hasn't fully initialized. API error handling returns safe defaults.
- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues.
- Fixed streams table column header overflow: Implemented fixed-height column headers (30px max-height) with pill-style filter display showing first selection plus count (e.g., "Sport +3"). Prevents header expansion when multiple filters are selected, maintaining compact table layout. (Fixes #613)
- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page.
diff --git a/frontend/src/api.js b/frontend/src/api.js
index f878c047..6b9f87c4 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -783,6 +783,8 @@ export default class API {
return response;
} catch (e) {
errorNotification('Failed to retrieve filter options', e);
+ // Return safe defaults to prevent crashes during container startup
+ return { groups: [], m3u_accounts: [] };
}
}
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index dcb3284e..ac189b32 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -440,13 +440,23 @@ const StreamsTable = ({ onReady }) => {
setAllRowIds(ids);
// Set filtered options based on current filters
- setGroupOptions(filterOptions.groups);
- setM3uOptions(
- filterOptions.m3u_accounts.map((m3u) => ({
- label: m3u.name,
- value: `${m3u.id}`,
- }))
- );
+ // Ensure groupOptions is always an array of valid strings
+ if (filterOptions && typeof filterOptions === 'object') {
+ setGroupOptions(
+ (filterOptions.groups || [])
+ .filter((group) => group != null && group !== '')
+ .map((group) => String(group))
+ );
+ // Ensure m3uOptions is always an array of valid objects
+ setM3uOptions(
+ (filterOptions.m3u_accounts || [])
+ .filter((m3u) => m3u && m3u.id != null && m3u.name)
+ .map((m3u) => ({
+ label: String(m3u.name),
+ value: String(m3u.id),
+ }))
+ );
+ }
if (initialDataCount === null) {
setInitialDataCount(result.count);
From 235467a1ac77374f8b7a721556d5f00370c6070d Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 18 Jan 2026 17:31:20 -0600
Subject: [PATCH 020/125] changelog: Update changelog for logo caching.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68edab34..77bd6602 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667)
+- Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom)
- Mature content filtering support:
- Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting
- Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data
From cf323f15a588889315169b51324aa2a75d064bd2 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 18 Jan 2026 17:52:27 -0600
Subject: [PATCH 021/125] changelog: Update changelog for dev build script.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77bd6602..8a868a2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed build-dev.sh script stability: Resolved Dockerfile and build context paths to be relative to script location for reliable execution from any working directory, added proper --platform argument handling with array-safe quoting, and corrected push behavior to honor -p flag with accurate messaging. Improved formatting and quoting throughout to prevent word-splitting issues - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes)
- Fixed TypeError on streams table load after container restart: Added robust data validation and type coercion to handle malformed filter options during container startup. The streams table MultiSelect components now safely convert group names to strings and filter out null/undefined values, preventing "right-hand side of 'in' should be an object, got number" errors when the backend hasn't fully initialized. API error handling returns safe defaults.
- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues.
- Fixed streams table column header overflow: Implemented fixed-height column headers (30px max-height) with pill-style filter display showing first selection plus count (e.g., "Sport +3"). Prevents header expansion when multiple filters are selected, maintaining compact table layout. (Fixes #613)
From 0b83137ac6d22a20c6074ae1d1954317dad2383b Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Mon, 19 Jan 2026 18:56:23 -0600
Subject: [PATCH 022/125] =?UTF-8?q?Enhancement:=20DVR=20recording=20remux?=
=?UTF-8?q?=20fallback=20strategy:=20Implemented=20two-stage=20TS=E2=86=92?=
=?UTF-8?q?MP4=E2=86=92MKV=20fallback=20when=20direct=20TS=E2=86=92MKV=20c?=
=?UTF-8?q?onversion=20fails=20due=20to=20timestamp=20issues.=20On=20remux?=
=?UTF-8?q?=20failure,=20system=20now=20attempts=20TS=E2=86=92MP4=20conver?=
=?UTF-8?q?sion=20(MP4=20container=20handles=20broken=20timestamps=20bette?=
=?UTF-8?q?r)=20followed=20by=20MP4=E2=86=92MKV=20conversion,=20automatica?=
=?UTF-8?q?lly=20recovering=20from=20provider=20timestamp=20corruption.=20?=
=?UTF-8?q?Failed=20conversions=20now=20properly=20clean=20up=20partial=20?=
=?UTF-8?q?files=20and=20preserve=20source=20TS=20for=20manual=20recovery.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 1 +
apps/channels/tasks.py | 91 +++++++++++++++++++++++++++++++++++++++---
2 files changed, 86 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a868a2b..6f53a70c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667)
- Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom)
+- DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery.
- Mature content filtering support:
- Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting
- Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index f37838fd..b9983b87 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -1874,18 +1874,97 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
remux_success = False
try:
if temp_ts_path and os.path.exists(temp_ts_path):
- subprocess.run([
- "ffmpeg", "-y", "-i", temp_ts_path, "-c", "copy", final_path
- ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- remux_success = os.path.exists(final_path)
- # Clean up temp file on success
+ # First attempt: Direct TS to MKV remux
+ result = subprocess.run([
+ "ffmpeg", "-y",
+ "-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS
+ "-err_detect", "ignore_err", # Ignore minor stream errors
+ "-i", temp_ts_path,
+ "-map", "0", # Map all streams
+ "-c", "copy",
+ final_path
+ ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+
+ # Check if FFmpeg succeeded (return code 0) and output file is valid
+ if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
+ remux_success = True
+ logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}")
+ else:
+ # Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps
+ logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV")
+
+ # Clean up partial/failed MKV
+ try:
+ if os.path.exists(final_path):
+ os.remove(final_path)
+ except Exception:
+ pass
+
+ # Step 1: TS → MP4 (MP4 container handles broken timestamps better)
+ temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4"
+ result_mp4 = subprocess.run([
+ "ffmpeg", "-y",
+ "-fflags", "+genpts+igndts+discardcorrupt",
+ "-err_detect", "ignore_err",
+ "-i", temp_ts_path,
+ "-map", "0",
+ "-c", "copy",
+ temp_mp4_path
+ ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+
+ if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0:
+ logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV")
+
+ # Step 2: MP4 → MKV (clean timestamps from MP4)
+ result_mkv = subprocess.run([
+ "ffmpeg", "-y",
+ "-i", temp_mp4_path,
+ "-map", "0",
+ "-c", "copy",
+ final_path
+ ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+
+ if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0:
+ remux_success = True
+ logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}")
+ else:
+ logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})")
+
+ # Clean up temp MP4
+ try:
+ if os.path.exists(temp_mp4_path):
+ os.remove(temp_mp4_path)
+ except Exception:
+ pass
+ else:
+ logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})")
+
+ # Clean up temp TS file only on successful remux
if remux_success:
try:
os.remove(temp_ts_path)
+ logger.debug(f"Cleaned up temp TS file: {temp_ts_path}")
+ except Exception as e:
+ logger.warning(f"Failed to remove temp TS file: {e}")
+ else:
+ # Keep TS file for debugging/manual recovery if remux failed
+ logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}")
+ # Clean up any partial MKV
+ try:
+ if os.path.exists(final_path):
+ os.remove(final_path)
+ logger.debug(f"Cleaned up partial MKV file: {final_path}")
except Exception:
pass
+
except Exception as e:
- logger.warning(f"MKV remux failed: {e}")
+ logger.warning(f"MKV remux failed with exception: {e}")
+ # Clean up any partial files on exception
+ try:
+ if os.path.exists(final_path):
+ os.remove(final_path)
+ except Exception:
+ pass
# Persist final metadata to Recording (status, ended_at, and stream stats if available)
try:
From cbcf2ac3c21cddae505ea0aa4e7aa506b7c97587 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Mon, 19 Jan 2026 20:07:31 -0600
Subject: [PATCH 023/125] Bug fix: - Fixed date/time formatting across all
tables to respect user's UI preferences (time format and date format) set in
Settings page: - Stream connection card "Connected" column - VOD
connection card "Connection Start Time" column - M3U table "Updated" column
- EPG table "Updated" column - Users table "Last Login" and "Date Joined"
columns - All components now use centralized `format()` helper from
dateTimeUtils for consistency - Removed unused imports from table components
for cleaner code
---
CHANGELOG.md | 8 +
.../src/components/cards/RecordingCard.jsx | 82 +++--
.../components/cards/StreamConnectionCard.jsx | 14 +-
.../components/cards/VodConnectionCard.jsx | 86 +++--
.../forms/RecordingDetailsModal.jsx | 337 ++++++++++--------
.../components/forms/RecurringRuleModal.jsx | 122 ++++---
frontend/src/components/tables/EPGsTable.jsx | 29 +-
frontend/src/components/tables/M3UsTable.jsx | 29 +-
frontend/src/components/tables/UsersTable.jsx | 29 +-
frontend/src/pages/Guide.jsx | 54 +--
frontend/src/pages/__tests__/Guide.test.jsx | 97 +++--
.../src/utils/__tests__/dateTimeUtils.test.js | 7 +-
.../utils/cards/StreamConnectionCardUtils.js | 6 +-
.../src/utils/cards/VodConnectionCardUtils.js | 6 +-
frontend/src/utils/dateTimeUtils.js | 16 +-
15 files changed, 530 insertions(+), 392 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f53a70c..3d63bbf4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page:
+ - Stream connection card "Connected" column
+ - VOD connection card "Connection Start Time" column
+ - M3U table "Updated" column
+ - EPG table "Updated" column
+ - Users table "Last Login" and "Date Joined" columns
+ - All components now use centralized `format()` helper from dateTimeUtils for consistency
+- Removed unused imports from table components for cleaner code
- Fixed build-dev.sh script stability: Resolved Dockerfile and build context paths to be relative to script location for reliable execution from any working directory, added proper --platform argument handling with array-safe quoting, and corrected push behavior to honor -p flag with accurate messaging. Improved formatting and quoting throughout to prevent word-splitting issues - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes)
- Fixed TypeError on streams table load after container restart: Added robust data validation and type coercion to handle malformed filter options during container startup. The streams table MultiSelect components now safely convert group names to strings and filter out null/undefined values, preventing "right-hand side of 'in' should be an object, got number" errors when the backend hasn't fully initialized. API error handling returns safe defaults.
- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues.
diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx
index 6f90e0f5..c3bffaee 100644
--- a/frontend/src/components/cards/RecordingCard.jsx
+++ b/frontend/src/components/cards/RecordingCard.jsx
@@ -1,7 +1,10 @@
import useChannelsStore from '../../store/channels.jsx';
import useSettingsStore from '../../store/settings.jsx';
import useVideoStore from '../../store/useVideoStore.jsx';
-import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
+import {
+ useDateTimeFormat,
+ useTimeHelpers,
+} from '../../utils/dateTimeUtils.js';
import { notifications } from '@mantine/notifications';
import React from 'react';
import {
@@ -39,7 +42,8 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
const showVideo = useVideoStore((s) => s.showVideo);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const { toUserTime, userNow } = useTimeHelpers();
- const [timeformat, dateformat] = useDateTimeFormat();
+ const { timeFormat: timeformat, dateFormat: dateformat } =
+ useDateTimeFormat();
const channel = channels?.[recording.channel];
@@ -52,7 +56,11 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
// Poster or channel logo
const posterUrl = getPosterUrl(
- customProps.poster_logo_id, customProps, channel?.logo?.cache_url, env_mode);
+ customProps.poster_logo_id,
+ customProps,
+ channel?.logo?.cache_url,
+ env_mode
+ );
const start = toUserTime(recording.start_time);
const end = toUserTime(recording.end_time);
@@ -161,44 +169,49 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
} else {
onOpenDetails?.(recording);
}
- }
+ };
const WatchLive = () => {
- return ;
- }
-
- const WatchRecording = () => {
- return
+ return (
+ );
+ };
+
+ const WatchRecording = () => {
+ return (
+
- Watch
-
- ;
- }
+
+
+ );
+ };
const MainCard = (
{
{isSeriesGroup ? 'Next recording' : 'Time'}
- {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)}
+ {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
+ {end.format(timeformat)}
@@ -419,4 +433,4 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
);
};
-export default RecordingCard;
\ No newline at end of file
+export default RecordingCard;
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
index a00f3664..0e441cbe 100644
--- a/frontend/src/components/cards/StreamConnectionCard.jsx
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -25,7 +25,10 @@ import {
Users,
Video,
} from 'lucide-react';
-import { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
+import {
+ toFriendlyDuration,
+ useDateTimeFormat,
+} from '../../utils/dateTimeUtils.js';
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
import { TableHelper } from '../../helpers/index.jsx';
import logo from '../../images/logo.png';
@@ -68,9 +71,8 @@ const StreamConnectionCard = ({
// Get settings for speed threshold
const settings = useSettingsStore((s) => s.settings);
- // Get Date-format from localStorage
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
+ // Get user's date/time format preferences
+ const { fullDateTimeFormat } = useDateTimeFormat();
// Create a map of M3U account IDs to names for quick lookup
const m3uAccountsMap = useMemo(() => {
@@ -258,7 +260,7 @@ const StreamConnectionCard = ({
{
id: 'connected',
header: 'Connected',
- accessorFn: connectedAccessor(dateFormat),
+ accessorFn: connectedAccessor(fullDateTimeFormat),
cell: ({ cell }) => (
{
bdrs={6}
bd={'1px solid rgba(255, 255, 255, 0.08)'}
>
- {connection.user_agent &&
- connection.user_agent !== 'Unknown' && (
-
-
- User Agent:
-
-
- {connection.user_agent.length > 100
- ? `${connection.user_agent.substring(0, 100)}...`
- : connection.user_agent}
-
-
- )}
+ {connection.user_agent && connection.user_agent !== 'Unknown' && (
+
+
+ User Agent:
+
+
+ {connection.user_agent.length > 100
+ ? `${connection.user_agent.substring(0, 100)}...`
+ : connection.user_agent}
+
+
+ )}
@@ -86,9 +107,7 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
{' '}
({Math.round(connection.last_seek_byte / (1024 * 1024))}
MB /{' '}
- {Math.round(
- connection.total_content_size / (1024 * 1024)
- )}
+ {Math.round(connection.total_content_size / (1024 * 1024))}
MB)
)}
@@ -120,12 +139,11 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
)}
);
-}
+};
// Create a VOD Card component similar to ChannelCard
const VodConnectionCard = ({ vodContent, stopVODClient }) => {
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
+ const { fullDateTimeFormat } = useDateTimeFormat();
const [isClientExpanded, setIsClientExpanded] = useState(false);
const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates
@@ -197,9 +215,9 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
// Get connection start time for tooltip
const getConnectionStartTime = useCallback(
(connection) => {
- return calculateConnectionStartTime(connection, dateFormat);
+ return calculateConnectionStartTime(connection, fullDateTimeFormat);
},
- [dateFormat]
+ [fullDateTimeFormat]
);
return (
@@ -211,14 +229,16 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
style={{
backgroundColor: '#27272A',
}}
- color='#FFF'
+ color="#FFF"
maw={700}
w={'100%'}
>
-
+
{/* Header with poster and basic info */}
- {
{connection &&
metadata.duration_secs &&
(() => {
- const { totalTime, currentTime, percentage} = getProgressInfo();
+ const { totalTime, currentTime, percentage } = getProgressInfo();
return totalTime > 0 ? (
@@ -346,8 +366,7 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
Progress
- {formatTime(currentTime)} /{' '}
- {formatTime(totalTime)}
+ {formatTime(currentTime)} / {formatTime(totalTime)}
+ )}
+
+
);
}
@@ -924,66 +921,64 @@ const StreamsTable = ({ onReady }) => {
const selectedM3Us = filters.m3u_account
? filters.m3u_account.split(',').filter(Boolean)
: [];
+ const firstLabel = selectedM3Us.length > 0
+ ? (m3uOptions.find((opt) => opt.value === selectedM3Us[0])?.label || selectedM3Us[0])
+ : null;
return (
- {
- const index = selectedM3Us.indexOf(value);
- if (index === 0) {
- const label =
- m3uOptions.find((opt) => opt.value === value)?.label ||
- value;
- return (
-
-
- {label}
-
- {selectedM3Us.length > 1 && (
-
- +{selectedM3Us.length - 1}
-
+
+ {selectedM3Us.length > 0 && (
+
+ {selectedM3Us.slice(0, 10).map((val, idx) => (
+
+ {m3uOptions.find((opt) => opt.value === val)?.label || val}
+
+ ))}
+ {selectedM3Us.length > 10 && (
+
+ +{selectedM3Us.length - 10} more
+
)}
-
- );
- }
- return null;
- }}
- style={{ flex: 1, minWidth: 0 }}
- rightSectionPointerEvents="auto"
- rightSection={React.createElement(sortingIcon, {
- onClick: (e) => {
- e.stopPropagation();
- onSortingChange('m3u');
- },
- size: 14,
- style: { cursor: 'pointer' },
- })}
- />
+
+ }
+ position="top"
+ withArrow
+ >
+
+ {firstLabel}
+ {selectedM3Us.length > 1 && (
+ +{selectedM3Us.length - 1}
+ )}
+
+
+ )}
+ {
+ e.stopPropagation();
+ onSortingChange('m3u');
+ },
+ size: 14,
+ style: { cursor: 'pointer' },
+ })}
+ />
+
);
}
From 091c1686e55ff143f45c8e38514d12d5eb9e2bd1 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 20 Jan 2026 15:31:34 -0600
Subject: [PATCH 030/125] Enhancement: Added tooltip on filter pills showing
all selected items in a vertical list (up to 10 items, with "+N more"
indicator) Also fixed +N not working as intended.
---
CHANGELOG.md | 1 +
.../src/components/tables/ChannelsTable.jsx | 14 ++
.../tables/CustomTable/CustomTableHeader.jsx | 58 ++++----
.../CustomTable/MultiSelectHeaderWrapper.jsx | 120 ++++++++++++++++
.../src/components/tables/StreamsTable.jsx | 135 +++++-------------
5 files changed, 206 insertions(+), 122 deletions(-)
create mode 100644 frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c371dd93..cb61bbf4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -70,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Added tooltip on filter pills showing all selected items in a vertical list (up to 10 items, with "+N more" indicator)
- Loading feedback for all confirmation dialogs: Extended visual loading indicators across all confirmation dialogs throughout the application. Delete, cleanup, and bulk operation dialogs now show an animated dots loader and disabled state during async operations, providing consistent user feedback for backups (restore/delete), channels, EPGs, logos, VOD logos, M3U accounts, streams, users, groups, filters, profiles, batch operations, and network access changes.
- Channel profile edit and duplicate functionality: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access.
- ProfileModal component extracted for improved code organization and maintainability of channel profile management operations.
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index b986e70e..78dd4f4c 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -1000,6 +1000,13 @@ const ChannelsTable = ({ onReady }) => {
clearable
onClick={stopPropagation}
onChange={handleEPGChange}
+ value={
+ Array.isArray(filters.epg)
+ ? filters.epg
+ : filters.epg
+ ? filters.epg.split(',').filter(Boolean)
+ : []
+ }
style={{ width: '100%' }}
/>
);
@@ -1058,6 +1065,13 @@ const ChannelsTable = ({ onReady }) => {
clearable
onClick={stopPropagation}
onChange={handleGroupChange}
+ value={
+ Array.isArray(filters.channel_group)
+ ? filters.channel_group
+ : filters.channel_group
+ ? filters.channel_group.split(',').filter(Boolean)
+ : []
+ }
style={{ width: '100%' }}
/>
);
diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
index 1bfc7773..0adc516e 100644
--- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
@@ -1,6 +1,7 @@
import { Box, Center, Checkbox, Flex } from '@mantine/core';
import { flexRender } from '@tanstack/react-table';
import { useCallback, useMemo } from 'react';
+import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper';
const CustomTableHeader = ({
getHeaderGroups,
@@ -12,33 +13,42 @@ const CustomTableHeader = ({
headerPinned = true,
}) => {
const renderHeaderCell = (header) => {
+ let content;
+
if (headerCellRenderFns[header.id]) {
- return headerCellRenderFns[header.id](header);
+ content = headerCellRenderFns[header.id](header);
+ } else {
+ switch (header.id) {
+ case 'select':
+ content = (
+
+ 0 &&
+ selectedTableIds.length !== allRowIds.length
+ }
+ onChange={onSelectAllChange}
+ />
+
+ );
+ break;
+
+ default:
+ content = flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ );
+ }
}
- switch (header.id) {
- case 'select':
- return (
-
- 0 &&
- selectedTableIds.length !== allRowIds.length
- }
- onChange={onSelectAllChange}
- />
-
- );
-
- default:
- return flexRender(header.column.columnDef.header, header.getContext());
- }
+ // Automatically wrap content to enhance MultiSelect components
+ return {content};
};
// Get header groups for dependency tracking
diff --git a/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx
new file mode 100644
index 00000000..947d03d9
--- /dev/null
+++ b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx
@@ -0,0 +1,120 @@
+import React, { cloneElement, isValidElement } from 'react';
+import { Box, Flex, Pill, Tooltip, MultiSelect } from '@mantine/core';
+
+/**
+ * Automatically wraps MultiSelect components with pill display and tooltips
+ * Recursively searches through React children to find and enhance MultiSelect
+ */
+const MultiSelectHeaderWrapper = ({ children }) => {
+ const enhanceMultiSelect = (element) => {
+ if (!isValidElement(element)) {
+ return element;
+ }
+
+ // Check if this element is a MultiSelect
+ if (element.type === MultiSelect) {
+ const { value = [], data = [], ...otherProps } = element.props;
+ const selectedValues = Array.isArray(value) ? value : [];
+
+ if (selectedValues.length === 0) {
+ // No selections - just render the MultiSelect with hidden pills
+ return cloneElement(element, {
+ ...otherProps,
+ value,
+ data,
+ styles: { pill: { display: 'none' } },
+ });
+ }
+
+ // Get first label
+ const firstLabel =
+ data.find((opt) => opt.value === selectedValues[0])?.label ||
+ selectedValues[0];
+
+ // Build tooltip content
+ const tooltipContent = (
+
+ {selectedValues.slice(0, 10).map((val, idx) => {
+ const label = data.find((opt) => opt.value === val)?.label || val;
+ return
{label}
;
+ })}
+ {selectedValues.length > 10 && (
+
+ +{selectedValues.length - 10} more
+
+ )}
+
+ );
+
+ return (
+
+
+
+ 1
+ ? 'calc(100% - 50px)'
+ : 'calc(100% - 30px)',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ display: 'block',
+ pointerEvents: 'auto',
+ }}
+ >
+ {firstLabel}
+
+ {selectedValues.length > 1 && (
+
+ +{selectedValues.length - 1}
+
+ )}
+
+
+ {cloneElement(element, {
+ ...otherProps,
+ value,
+ data,
+ styles: { pill: { display: 'none' } },
+ style: { width: '100%', ...otherProps.style },
+ })}
+
+ );
+ }
+
+ // Check if element has children - recursively enhance them
+ if (element.props && element.props.children) {
+ const enhancedChildren = React.Children.map(
+ element.props.children,
+ (child) => enhanceMultiSelect(child)
+ );
+
+ // Clone element with enhanced children
+ return cloneElement(element, {}, enhancedChildren);
+ }
+
+ return element;
+ };
+
+ return <>{enhanceMultiSelect(children)}>;
+};
+
+export default MultiSelectHeaderWrapper;
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index e165c62c..aa210b6e 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -872,48 +872,20 @@ const StreamsTable = ({ onReady }) => {
? filters.channel_group.split(',').filter(Boolean)
: [];
return (
-
- {selectedGroups.length > 0 && (
-
- {selectedGroups.slice(0, 10).map((group, idx) => (
- {group}
- ))}
- {selectedGroups.length > 10 && (
-
- +{selectedGroups.length - 10} more
-
- )}
-
- }
- position="top"
- withArrow
- >
-
- {selectedGroups[0]}
- {selectedGroups.length > 1 && (
- +{selectedGroups.length - 1}
- )}
-
-
- )}
-
-
+
);
}
@@ -921,64 +893,31 @@ const StreamsTable = ({ onReady }) => {
const selectedM3Us = filters.m3u_account
? filters.m3u_account.split(',').filter(Boolean)
: [];
- const firstLabel = selectedM3Us.length > 0
- ? (m3uOptions.find((opt) => opt.value === selectedM3Us[0])?.label || selectedM3Us[0])
- : null;
return (
-
- {selectedM3Us.length > 0 && (
-
- {selectedM3Us.slice(0, 10).map((val, idx) => (
-
- {m3uOptions.find((opt) => opt.value === val)?.label || val}
-
- ))}
- {selectedM3Us.length > 10 && (
-
- +{selectedM3Us.length - 10} more
-
- )}
-
- }
- position="top"
- withArrow
- >
-
- {firstLabel}
- {selectedM3Us.length > 1 && (
- +{selectedM3Us.length - 1}
- )}
-
-
- )}
- {
- e.stopPropagation();
- onSortingChange('m3u');
- },
- size: 14,
- style: { cursor: 'pointer' },
- })}
- />
-
+ {
+ e.stopPropagation();
+ onSortingChange('m3u');
+ },
+ size: 14,
+ style: { cursor: 'pointer' },
+ })}
+ />
);
}
From 6b9e6b2d8a3e6a232b5a4e478b90bb239bdafde9 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 20 Jan 2026 15:38:36 -0600
Subject: [PATCH 031/125] tests: Fix frontent tests for new stats features.
---
frontend/src/pages/__tests__/Stats.test.jsx | 57 +++++++++++++--------
1 file changed, 37 insertions(+), 20 deletions(-)
diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx
index bf5cdb42..c22ceac9 100644
--- a/frontend/src/pages/__tests__/Stats.test.jsx
+++ b/frontend/src/pages/__tests__/Stats.test.jsx
@@ -14,6 +14,7 @@ import useChannelsStore from '../../store/channels';
import useLogosStore from '../../store/logos';
import {
fetchActiveChannelStats,
+ getCurrentPrograms,
getClientStats,
getCombinedConnections,
getStatsByChannelId,
@@ -30,11 +31,11 @@ vi.mock('../../store/streamProfiles');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../components/SystemEvents', () => ({
- default: () => SystemEvents
+ default: () => SystemEvents
,
}));
vi.mock('../../components/ErrorBoundary.jsx', () => ({
- default: ({ children }) => {children}
+ default: ({ children }) => {children}
,
}));
vi.mock('../../components/cards/VodConnectionCard.jsx', () => ({
@@ -92,6 +93,7 @@ vi.mock('../../utils/pages/StatsUtils', () => {
return {
fetchActiveChannelStats: vi.fn(),
getVODStats: vi.fn(),
+ getCurrentPrograms: vi.fn(),
getClientStats: vi.fn(),
getCombinedConnections: vi.fn(),
getStatsByChannelId: vi.fn(),
@@ -112,9 +114,7 @@ describe('StatsPage', () => {
'channel-2': mockChannels[1],
};
- const mockStreamProfiles = [
- { id: 1, name: 'Profile 1' },
- ];
+ const mockStreamProfiles = [{ id: 1, name: 'Profile 1' }];
const mockLogos = {
'logo-1': 'logo-url-1',
@@ -131,9 +131,7 @@ describe('StatsPage', () => {
vod_connections: [
{
content_uuid: 'vod-1',
- connections: [
- { client_id: 'client-1', ip: '192.168.1.1' },
- ],
+ connections: [{ client_id: 'client-1', ip: '192.168.1.1' }],
},
],
};
@@ -152,7 +150,11 @@ describe('StatsPage', () => {
const mockCombinedConnections = [
{ id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } },
{ id: 2, type: 'stream', data: { id: 2, uuid: 'channel-2' } },
- { id: 3, type: 'vod', data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] } },
+ {
+ id: 3,
+ type: 'vod',
+ data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] },
+ },
];
let mockSetChannelStats;
@@ -194,6 +196,7 @@ describe('StatsPage', () => {
// Setup API mocks
fetchActiveChannelStats.mockResolvedValue(mockChannelStats);
getVODStats.mockResolvedValue(mockVODStats);
+ getCurrentPrograms.mockResolvedValue({});
getStatsByChannelId.mockReturnValue(mockProcessedChannelHistory);
getClientStats.mockReturnValue(mockClients);
getCombinedConnections.mockReturnValue(mockCombinedConnections);
@@ -206,7 +209,7 @@ describe('StatsPage', () => {
describe('Initial Rendering', () => {
it('renders the page title', async () => {
render();
- await screen.findByText('Active Connections')
+ await screen.findByText('Active Connections');
});
it('fetches initial stats on mount', async () => {
@@ -229,7 +232,7 @@ describe('StatsPage', () => {
it('renders SystemEvents component', async () => {
render();
- await screen.findByTestId('system-events')
+ await screen.findByTestId('system-events');
});
});
@@ -266,7 +269,7 @@ describe('StatsPage', () => {
useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
render();
- await screen.findByText('Refreshing disabled')
+ await screen.findByText('Refreshing disabled');
});
});
@@ -348,8 +351,12 @@ describe('StatsPage', () => {
render();
await waitFor(() => {
- expect(screen.getByTestId('stream-connection-card-channel-1')).toBeInTheDocument();
- expect(screen.getByTestId('stream-connection-card-channel-2')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('stream-connection-card-channel-1')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('stream-connection-card-channel-2')
+ ).toBeInTheDocument();
});
});
@@ -357,7 +364,9 @@ describe('StatsPage', () => {
render();
await waitFor(() => {
- expect(screen.getByTestId('vod-connection-card-vod-1')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('vod-connection-card-vod-1')
+ ).toBeInTheDocument();
});
});
@@ -376,7 +385,9 @@ describe('StatsPage', () => {
render();
await waitFor(() => {
- expect(screen.getByTestId('stop-vod-client-client-1')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('stop-vod-client-client-1')
+ ).toBeInTheDocument();
});
const stopButton = screen.getByTestId('stop-vod-client-client-1');
@@ -422,14 +433,18 @@ describe('StatsPage', () => {
render();
await waitFor(() => {
- expect(getClientStats).toHaveBeenCalledWith(mockProcessedChannelHistory);
+ expect(getClientStats).toHaveBeenCalledWith(
+ mockProcessedChannelHistory
+ );
});
});
});
describe('Error Handling', () => {
it('handles fetchActiveChannelStats error gracefully', async () => {
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
fetchActiveChannelStats.mockRejectedValue(new Error('API Error'));
render();
@@ -445,7 +460,9 @@ describe('StatsPage', () => {
});
it('handles getVODStats error gracefully', async () => {
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
getVODStats.mockRejectedValue(new Error('VOD API Error'));
render();
@@ -491,4 +508,4 @@ describe('StatsPage', () => {
});
});
});
-});
\ No newline at end of file
+});
From 0984ec98349db8ec8d0ddade1878c2900e02d6e7 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 20 Jan 2026 16:31:41 -0600
Subject: [PATCH 032/125] Enhancement: Add Progress bar showing elapsed and
remaining time for currently playing programs
---
CHANGELOG.md | 1 +
.../components/cards/StreamConnectionCard.jsx | 51 +++++++++++++++++++
2 files changed, 52 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb61bbf4..2946d261 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include:
- Currently playing program title displayed with live broadcast indicator (green Radio icon)
- Expandable program descriptions via chevron button
+ - Progress bar showing elapsed and remaining time for currently playing programs
- Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels
- Smart scheduling that fetches new program data 5 seconds after current program ends
- Only polls when active channel list changes, not on stats refresh
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
index 5488aef5..e8b8a33b 100644
--- a/frontend/src/components/cards/StreamConnectionCard.jsx
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -11,6 +11,7 @@ import {
Center,
Flex,
Group,
+ Progress,
Select,
Stack,
Text,
@@ -549,6 +550,56 @@ const StreamConnectionCard = ({
)}
+ {/* Program progress bar */}
+ {currentProgram &&
+ isProgramDescExpanded &&
+ currentProgram.start_time &&
+ currentProgram.end_time &&
+ (() => {
+ const now = new Date();
+ const startTime = new Date(currentProgram.start_time);
+ const endTime = new Date(currentProgram.end_time);
+ const totalDuration = (endTime - startTime) / 1000; // in seconds
+ const elapsed = (now - startTime) / 1000; // in seconds
+ const remaining = (endTime - now) / 1000; // in seconds
+ const percentage = Math.min(
+ 100,
+ Math.max(0, (elapsed / totalDuration) * 100)
+ );
+
+ const formatProgramTime = (seconds) => {
+ const absSeconds = Math.abs(seconds);
+ const hours = Math.floor(absSeconds / 3600);
+ const minutes = Math.floor((absSeconds % 3600) / 60);
+ const secs = Math.floor(absSeconds % 60);
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ return (
+
+
+
+ {formatProgramTime(elapsed)} elapsed
+
+
+ {formatProgramTime(remaining)} remaining
+
+
+
+
+ );
+ })()}
+
{/* Add stream selection dropdown and preview button */}
{availableStreams.length > 0 && (
From 8bc88112aa3fbdc17e6088e485482537b0fb56c4 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 20 Jan 2026 17:09:28 -0600
Subject: [PATCH 033/125] Enhancement: Improved pill functionality and sizing.
---
.../CustomTable/MultiSelectHeaderWrapper.jsx | 95 ++++++++++++++++---
1 file changed, 80 insertions(+), 15 deletions(-)
diff --git a/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx
index 947d03d9..8fca940f 100644
--- a/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx
+++ b/frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx
@@ -1,4 +1,4 @@
-import React, { cloneElement, isValidElement } from 'react';
+import React, { cloneElement, isValidElement, useRef } from 'react';
import { Box, Flex, Pill, Tooltip, MultiSelect } from '@mantine/core';
/**
@@ -6,6 +6,8 @@ import { Box, Flex, Pill, Tooltip, MultiSelect } from '@mantine/core';
* Recursively searches through React children to find and enhance MultiSelect
*/
const MultiSelectHeaderWrapper = ({ children }) => {
+ const inputRef = useRef(null);
+
const enhanceMultiSelect = (element) => {
if (!isValidElement(element)) {
return element;
@@ -13,7 +15,7 @@ const MultiSelectHeaderWrapper = ({ children }) => {
// Check if this element is a MultiSelect
if (element.type === MultiSelect) {
- const { value = [], data = [], ...otherProps } = element.props;
+ const { value = [], data = [], onChange, ...otherProps } = element.props;
const selectedValues = Array.isArray(value) ? value : [];
if (selectedValues.length === 0) {
@@ -22,6 +24,7 @@ const MultiSelectHeaderWrapper = ({ children }) => {
...otherProps,
value,
data,
+ onChange,
styles: { pill: { display: 'none' } },
});
}
@@ -46,8 +49,42 @@ const MultiSelectHeaderWrapper = ({ children }) => {
);
+ // Handle opening the dropdown when pill is clicked
+ const handlePillClick = (e) => {
+ // Check if the click is on the remove button (it has a data-attribute)
+ if (e.target.closest('[data-disabled]') || e.target.closest('button')) {
+ return; // Let the remove button handle it
+ }
+ e.stopPropagation();
+ // Focus and click the input to open the dropdown
+ if (inputRef.current) {
+ const input = inputRef.current.querySelector('input');
+ if (input) {
+ input.focus();
+ input.click();
+ }
+ }
+ };
+
+ // Handle removing a single filter value
+ const handleRemoveFirst = (e) => {
+ e?.stopPropagation?.();
+ if (onChange && selectedValues.length > 0) {
+ const newValues = selectedValues.slice(1);
+ onChange(newValues);
+ }
+ };
+
+ // Handle clearing all filters
+ const handleClearAll = (e) => {
+ e?.stopPropagation?.();
+ if (onChange) {
+ onChange([]);
+ }
+ };
+
return (
-
+
{
position: 'absolute',
top: 4,
left: 4,
- right: 30,
+ right: 20,
zIndex: 1,
pointerEvents: 'none',
overflow: 'hidden',
@@ -63,28 +100,55 @@ const MultiSelectHeaderWrapper = ({ children }) => {
>
1 ? '1 1 auto' : '0 1 auto',
minWidth: 0,
maxWidth:
- selectedValues.length > 1
- ? 'calc(100% - 50px)'
- : 'calc(100% - 30px)',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- display: 'block',
+ selectedValues.length > 1 ? 'calc(100% - 40px)' : '100%',
pointerEvents: 'auto',
}}
>
- {firstLabel}
+
+ {firstLabel}
+
{selectedValues.length > 1 && (
- +{selectedValues.length - 1}
+
+ +{selectedValues.length - 1}
+
)}
@@ -93,6 +157,7 @@ const MultiSelectHeaderWrapper = ({ children }) => {
...otherProps,
value,
data,
+ onChange,
styles: { pill: { display: 'none' } },
style: { width: '100%', ...otherProps.style },
})}
From c9b454431c678a08298a3b316720d20e286d666a Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 20 Jan 2026 18:01:17 -0600
Subject: [PATCH 034/125] Enhancement: Streams table UI: Added descriptive
tooltips to top-toolbar buttons (Add to Channel, Create Channels, Filters,
Create Stream, Delete) and to row action icons (Add to Channel, Create New
Channel). Tooltips now use a 500ms open delay for consistent behavior with
existing table header tooltips. Streams table button labels: Renamed "Remove"
to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and
consistency with other UI terminology.
---
CHANGELOG.md | 2 +
.../src/components/tables/StreamsTable.jsx | 157 ++++++++++--------
2 files changed, 90 insertions(+), 69 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2946d261..b1738429 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,10 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content)
- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663)
- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647)
+- Streams table UI: Added descriptive tooltips to top-toolbar buttons (Add to Channel, Create Channels, Filters, Create Stream, Delete) and to row action icons (Add to Channel, Create New Channel). Tooltips now use a 500ms open delay for consistent behavior with existing table header tooltips.
### Changed
- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence.
+- Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology.
- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage.
- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `.g maintainability and providing consistent API across all tables.
- Optimized unassociated streams filter performance: Replaced inefficient reverse foreign key NULL check (`channels__isnull=True`) with Count annotation approach, reducing query time from 4-5 seconds to under 500ms for large datasets (75k+ streams)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index aa210b6e..22e7cb67 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -117,7 +117,7 @@ const StreamRowActions = ({
return (
<>
-
+
-
+
{
gap={6}
>
- }
- variant={
- selectedStreamIds.length > 0 && selectedChannelIds.length === 1
- ? 'light'
- : 'default'
- }
- size="xs"
- onClick={addStreamsToChannel}
- p={5}
- color={
- selectedStreamIds.length > 0 && selectedChannelIds.length === 1
- ? theme.tailwind.green[5]
- : undefined
- }
- style={
- selectedStreamIds.length > 0 && selectedChannelIds.length === 1
- ? {
- borderWidth: '1px',
- borderColor: theme.tailwind.green[5],
- color: 'white',
- }
- : undefined
- }
- disabled={
- !(
+
+ }
+ variant={
selectedStreamIds.length > 0 &&
selectedChannelIds.length === 1
- )
- }
- >
- Add Streams to Channel
-
+ ? 'light'
+ : 'default'
+ }
+ size="xs"
+ onClick={addStreamsToChannel}
+ p={5}
+ color={
+ selectedStreamIds.length > 0 &&
+ selectedChannelIds.length === 1
+ ? theme.tailwind.green[5]
+ : undefined
+ }
+ style={
+ selectedStreamIds.length > 0 &&
+ selectedChannelIds.length === 1
+ ? {
+ borderWidth: '1px',
+ borderColor: theme.tailwind.green[5],
+ color: 'white',
+ }
+ : undefined
+ }
+ disabled={
+ !(
+ selectedStreamIds.length > 0 &&
+ selectedChannelIds.length === 1
+ )
+ }
+ >
+ Add to Channel
+
+
- }
- variant="default"
- size="xs"
- onClick={createChannelsFromStreams}
- p={5}
- disabled={selectedStreamIds.length == 0}
+
- {`Create Channels (${selectedStreamIds.length})`}
-
+ }
+ variant="default"
+ size="xs"
+ onClick={createChannelsFromStreams}
+ p={5}
+ disabled={selectedStreamIds.length == 0}
+ >
+ {`Create Channels (${selectedStreamIds.length})`}
+
+
- }
- variant="light"
- size="xs"
- onClick={() => editStream()}
- p={5}
- color={theme.tailwind.green[5]}
- style={{
- borderWidth: '1px',
- borderColor: theme.tailwind.green[5],
- color: 'white',
- }}
- >
- Create Stream
-
+
+ }
+ variant="light"
+ size="xs"
+ onClick={() => editStream()}
+ p={5}
+ color={theme.tailwind.green[5]}
+ style={{
+ borderWidth: '1px',
+ borderColor: theme.tailwind.green[5],
+ color: 'white',
+ }}
+ >
+ Create Stream
+
+
- }
- variant="default"
- size="xs"
- onClick={deleteStreams}
- disabled={selectedStreamIds.length == 0}
- >
- Remove
-
+
+ }
+ variant="default"
+ size="xs"
+ onClick={deleteStreams}
+ disabled={selectedStreamIds.length == 0}
+ >
+ Delete
+
+
From 2fc2486c344ccb46e332ba9136fa4e61008fdf06 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 20 Jan 2026 18:51:13 -0600
Subject: [PATCH 035/125] Enhancement: Added "Hide Stale" filter to quickly
hide streams marked as stale.
---
CHANGELOG.md | 1 +
apps/channels/api_views.py | 7 +++-
.../src/components/tables/StreamsTable.jsx | 34 ++++++++++++++++---
3 files changed, 36 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1738429..599fb986 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Only polls when active channel list changes, not on stats refresh
- Channel preview button: Added preview functionality to active stream cards on stats page
- Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667)
+- Streams table: Added "Hide Stale" filter to quickly hide streams marked as stale.
- Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom)
- DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery.
- Mature content filtering support:
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index f9daf0d0..11940177 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -149,7 +149,7 @@ class StreamViewSet(viewsets.ModelViewSet):
qs = qs.filter(channels__id=assigned)
unassigned = self.request.query_params.get("unassigned")
- if unassigned == "1":
+ if unassigned and str(unassigned).lower() in ("1", "true", "yes", "on"):
# Use annotation with Count for better performance on large datasets
qs = qs.annotate(channel_count=Count('channels')).filter(channel_count=0)
@@ -158,6 +158,11 @@ class StreamViewSet(viewsets.ModelViewSet):
group_names = channel_group.split(",")
qs = qs.filter(channel_group__name__in=group_names)
+ # Allow client to hide stale streams (streams marked as is_stale=True)
+ hide_stale = self.request.query_params.get("hide_stale")
+ if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"):
+ qs = qs.filter(is_stale=False)
+
return qs
def list(self, request, *args, **kwargs):
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 22e7cb67..5b9182b2 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -232,7 +232,8 @@ const StreamsTable = ({ onReady }) => {
name: '',
channel_group: '',
m3u_account: '',
- unassigned: '',
+ unassigned: false,
+ hide_stale: false,
});
const [columnSizing, setColumnSizing] = useLocalStorage(
'streams-table-column-sizing',
@@ -398,7 +399,14 @@ const StreamsTable = ({ onReady }) => {
const toggleUnassignedOnly = () => {
setFilters((prev) => ({
...prev,
- unassigned: prev.unassigned === '1' ? '' : '1',
+ unassigned: !prev.unassigned,
+ }));
+ };
+
+ const toggleHideStale = () => {
+ setFilters((prev) => ({
+ ...prev,
+ hide_stale: !prev.hide_stale,
}));
};
@@ -426,9 +434,13 @@ const StreamsTable = ({ onReady }) => {
params.append('ordering', `${sortDirection}${sortField}`);
}
- // Apply debounced filters
+ // Apply debounced filters; send boolean filters as 'true' when set
Object.entries(debouncedFilters).forEach(([key, value]) => {
- if (value) params.append(key, value);
+ if (typeof value === 'boolean') {
+ if (value) params.append(key, 'true');
+ } else if (value !== null && value !== undefined && value !== '') {
+ params.append(key, String(value));
+ }
});
try {
@@ -1188,7 +1200,7 @@ const StreamsTable = ({ onReady }) => {
) : (
@@ -1197,6 +1209,18 @@ const StreamsTable = ({ onReady }) => {
>
Only Unassociated
+
+ ) : (
+
+ )
+ }
+ >
+ Hide Stale
+
From e43aba67e71730796e5e1b1b4062c034449973cd Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 20 Jan 2026 19:35:19 -0600
Subject: [PATCH 036/125] Enhancement: Refactor StreamConnectionCard layout for
improved UI, separating channel name and current program display.
---
.../components/cards/StreamConnectionCard.jsx | 57 ++++++++++---------
1 file changed, 29 insertions(+), 28 deletions(-)
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
index e8b8a33b..6c5fc4fb 100644
--- a/frontend/src/components/cards/StreamConnectionCard.jsx
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -494,9 +494,8 @@ const StreamConnectionCard = ({
-
- {channelName}
-
+ {/* Stream Profile on right */}
+
@@ -505,32 +504,10 @@ const StreamConnectionCard = ({
- {/* Display M3U profile and current program */}
+ {/* Channel Name on left, M3U Profile on right */}
- {currentProgram ? (
-
-
-
- Now Playing:
-
-
- {currentProgram.title}
-
- setIsProgramDescExpanded(!isProgramDescExpanded)}
- >
- {isProgramDescExpanded ? (
-
- ) : (
-
- )}
-
-
- ) : (
-
- )}
+ {channelName}
+
@@ -539,6 +516,30 @@ const StreamConnectionCard = ({
+ {/* Display current program on its own line */}
+ {currentProgram && (
+
+
+
+ Now Playing:
+
+
+ {currentProgram.title}
+
+ setIsProgramDescExpanded(!isProgramDescExpanded)}
+ >
+ {isProgramDescExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
{/* Expandable program description */}
{currentProgram &&
isProgramDescExpanded &&
From cbb8a521633e2d619e1e83acefab8d5d5865f3b2 Mon Sep 17 00:00:00 2001
From: Mattias Svensson
Date: Wed, 21 Jan 2026 08:04:49 +0100
Subject: [PATCH 037/125] Fix: nginx startup failure due to group name mismatch
The script was creating a group named 'dispatch' (hardcoded) while
nginx.conf expected a group matching POSTGRES_USER (e.g., 'dispatcharr').
Changes:
- Use $POSTGRES_USER for group name instead of hardcoded 'dispatch'
- Make sed command more robust to match any existing user directive
This ensures the group name matches the user name, fixing the error:
"getgrnam("dispatcharr") failed in /etc/nginx/nginx.conf:1"
Fixes #877
Co-Authored-By: Claude Opus 4.5
---
docker/init/01-user-setup.sh | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh
index d7041265..1b0e0a27 100644
--- a/docker/init/01-user-setup.sh
+++ b/docker/init/01-user-setup.sh
@@ -6,17 +6,17 @@ export PGID=${PGID:-1000}
# Check if group with PGID exists
if getent group "$PGID" >/dev/null 2>&1; then
- # Group exists, check if it's named 'dispatch'
+ # Group exists, check if it's named correctly (should match POSTGRES_USER)
existing_group=$(getent group "$PGID" | cut -d: -f1)
- if [ "$existing_group" != "dispatch" ]; then
- # Rename the existing group to 'dispatch'
- groupmod -n "dispatch" "$existing_group"
- echo "Group $existing_group with GID $PGID renamed to dispatch"
+ if [ "$existing_group" != "$POSTGRES_USER" ]; then
+ # Rename the existing group to match POSTGRES_USER
+ groupmod -n "$POSTGRES_USER" "$existing_group"
+ echo "Group $existing_group with GID $PGID renamed to $POSTGRES_USER"
fi
else
- # Group doesn't exist, create it
- groupadd -g "$PGID" dispatch
- echo "Group dispatch with GID $PGID created"
+ # Group doesn't exist, create it with same name as POSTGRES_USER
+ groupadd -g "$PGID" "$POSTGRES_USER"
+ echo "Group $POSTGRES_USER with GID $PGID created"
fi
# Create user if it doesn't exist
@@ -86,5 +86,5 @@ if getent group video >/dev/null 2>&1; then
fi
fi
-# Run nginx as specified user
-sed -i "s/user www-data;/user $POSTGRES_USER;/g" /etc/nginx/nginx.conf
+# Run nginx as specified user (replace any existing user directive on line 1)
+sed -i "1s/^user .*/user $POSTGRES_USER;/" /etc/nginx/nginx.conf
From 3706e63beddeeccc1b5809fdb4677f9f2ba49ca6 Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Wed, 21 Jan 2026 11:14:28 -0600
Subject: [PATCH 038/125] feat: Add DISPATCHARR_ENV=modular support for
multi-container deployments
- docker/entrypoint.sh: Conditional PostgreSQL init/startup for modular mode
- docker/entrypoint.celery.sh: New dedicated entrypoint for celery container - prevents race condition in modular deployment
- docker/uwsgi.modular.ini: New uWSGI config without Redis/Celery daemons
- docker/Dockerfile: Add line ending fixes and chmod for entrypoints - adds cross-platform support for image builds
- docker/docker-compose.yml: Configure modular mode with DISPATCHARR_ENV - uses new celery entrypoint and adds depends_on to prevent race condition
---
docker/Dockerfile | 4 +++
docker/docker-compose.yml | 19 +++++++-----
docker/entrypoint.celery.sh | 22 ++++++++++++++
docker/entrypoint.sh | 52 ++++++++++++++++++++++----------
docker/uwsgi.modular.ini | 59 +++++++++++++++++++++++++++++++++++++
5 files changed, 133 insertions(+), 23 deletions(-)
create mode 100644 docker/entrypoint.celery.sh
create mode 100644 docker/uwsgi.modular.ini
diff --git a/docker/Dockerfile b/docker/Dockerfile
index bfb35c11..409f1096 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -30,6 +30,10 @@ WORKDIR /app
COPY . /app
# Copy nginx configuration
COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default
+# Verify entrypoint scripts exist, fix line endings, and make them executable
+RUN ls -la /app/docker/entrypoint*.sh && \
+ sed -i 's/\r$//' /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh && \
+ chmod +x /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh
# Clean out existing frontend folder
RUN rm -rf /app/frontend
# Copy built frontend assets
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index e4093e4b..df295f90 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -10,6 +10,7 @@ services:
- db
- redis
environment:
+ - DISPATCHARR_ENV=modular
- POSTGRES_HOST=db
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
@@ -25,7 +26,6 @@ services:
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
# Negative values require cap_add: SYS_NICE (uncomment below)
#- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority)
- #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority)
#
# Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0)
#cap_add:
@@ -52,22 +52,27 @@ services:
depends_on:
- db
- redis
+ - web
volumes:
- - ../:/app
+ - ./data:/data
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
+ - DISPATCHARR_ENV=modular
- POSTGRES_HOST=db
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- CELERY_BROKER_URL=redis://redis:6379/0
- command: >
- bash -c "
- cd /app &&
- nice -n 5 celery -A dispatcharr worker -l info
- "
+ - DISPATCHARR_LOG_LEVEL=info
+ #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19)
+ - DJANGO_SETTINGS_MODULE=dispatcharr.settings
+ - PYTHONUNBUFFERED=1
+ # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0)
+ #cap_add:
+ # - SYS_NICE
+ entrypoint: ["/app/docker/entrypoint.celery.sh"]
db:
image: postgres:14
diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh
new file mode 100644
index 00000000..fafe2c3f
--- /dev/null
+++ b/docker/entrypoint.celery.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -e
+
+cd /app
+source /dispatcharrpy/bin/activate
+
+# Wait for Django secret key
+echo 'Waiting for Django secret key...'
+while [ ! -f /data/jwt ]; do sleep 1; done
+export DJANGO_SECRET_KEY=$(cat /data/jwt)
+
+# Wait for migrations to complete
+echo 'Waiting for migrations to complete...'
+until python manage.py showmigrations 2>&1 | grep -q '\[X\]'; do
+ echo 'Migrations not ready yet, waiting...'
+ sleep 2
+done
+
+# Start Celery
+echo 'Migrations complete, starting Celery...'
+celery -A dispatcharr beat -l info &
+nice -n ${CELERY_NICE_LEVEL:-5} celery -A dispatcharr worker -l info --autoscale=6,1
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index a50f2f49..36948c1f 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -150,26 +150,43 @@ fi
# Run init scripts
echo "Starting user setup..."
. /app/docker/init/01-user-setup.sh
-echo "Setting up PostgreSQL..."
-. /app/docker/init/02-postgres.sh
+
+# Initialize PostgreSQL if NOT in modular mode (using external database)
+if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
+ echo "Setting up PostgreSQL..."
+ . /app/docker/init/02-postgres.sh
+fi
+
echo "Starting init process..."
. /app/docker/init/03-init-dispatcharr.sh
-# Start PostgreSQL
-echo "Starting Postgres..."
-su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
-# Wait for PostgreSQL to be ready
-until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
- echo_with_timestamp "Waiting for PostgreSQL to be ready..."
- sleep 1
-done
-postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
-echo "✅ Postgres started with PID $postgres_pid"
-pids+=("$postgres_pid")
+# Start PostgreSQL if NOT in modular mode (using external database)
+if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
+ echo "Starting Postgres..."
+ su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
+ # Wait for PostgreSQL to be ready
+ until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
+ echo_with_timestamp "Waiting for PostgreSQL to be ready..."
+ sleep 1
+ done
+ postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
+ echo "✅ Postgres started with PID $postgres_pid"
+ pids+=("$postgres_pid")
+else
+ echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}"
+ # Wait for external PostgreSQL to be ready
+ echo_with_timestamp "Waiting for external PostgreSQL to be ready..."
+ until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
+ echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..."
+ sleep 1
+ done
+ echo "✅ External PostgreSQL is ready"
+fi
-# Ensure database encoding is UTF8
-. /app/docker/init/02-postgres.sh
-ensure_utf8_encoding
+# Ensure database encoding is UTF8 (only for internal database)
+if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
+ ensure_utf8_encoding
+fi
if [[ "$DISPATCHARR_ENV" = "dev" ]]; then
. /app/docker/init/99-init-dev.sh
@@ -197,6 +214,9 @@ if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then
elif [ "$DISPATCHARR_DEBUG" = "true" ]; then
echo "🚀 Starting uwsgi in debug mode..."
uwsgi_file="/app/docker/uwsgi.debug.ini"
+elif [ "$DISPATCHARR_ENV" = "modular" ]; then
+ echo "🚀 Starting uwsgi in modular mode..."
+ uwsgi_file="/app/docker/uwsgi.modular.ini"
else
echo "🚀 Starting uwsgi in production mode..."
uwsgi_file="/app/docker/uwsgi.ini"
diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini
new file mode 100644
index 00000000..3220a8d8
--- /dev/null
+++ b/docker/uwsgi.modular.ini
@@ -0,0 +1,59 @@
+[uwsgi]
+; Modular deployment mode - external PostgreSQL, Redis, and Celery
+; Remove file creation commands since we're not logging to files anymore
+; exec-pre = mkdir -p /data/logs
+; exec-pre = touch /data/logs/uwsgi.log
+; exec-pre = chmod 666 /data/logs/uwsgi.log
+
+; First run Redis availability check script once
+exec-pre = python /app/scripts/wait_for_redis.py
+
+; Start Daphne for WebSocket support (required for real-time features)
+; Redis and Celery run in separate containers in modular mode
+attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
+
+# Core settings
+chdir = /app
+module = dispatcharr.wsgi:application
+virtualenv = /dispatcharrpy
+master = true
+env = DJANGO_SETTINGS_MODULE=dispatcharr.settings
+env = USE_NGINX_ACCEL=true
+socket = /app/uwsgi.sock
+chmod-socket = 777
+vacuum = true
+die-on-term = true
+static-map = /static=/app/static
+
+# Worker management
+workers = 4
+
+# Optimize for streaming
+http = 0.0.0.0:5656
+http-keepalive = 1
+buffer-size = 65536 # Increase buffer for large payloads
+post-buffering = 4096 # Reduce buffering for real-time streaming
+http-timeout = 600 # Prevent disconnects from long streams
+socket-timeout = 600 # Prevent write timeouts when client buffers
+lazy-apps = true # Improve memory efficiency
+
+# Async mode (use gevent for high concurrency)
+gevent = 400 # Each unused greenlet costs ~2-4KB of memory
+# Higher values have minimal performance impact when idle, but provide capacity for traffic spikes
+# If memory usage becomes an issue, reduce this value
+
+# Performance tuning
+thunder-lock = true
+log-4xx = true
+log-5xx = true
+disable-logging = false
+
+# Logging configuration
+# Enable console logging (stdout)
+log-master = true
+# Enable strftime formatting for timestamps
+logformat-strftime = true
+log-date = %%Y-%%m-%%d %%H:%%M:%%S,000
+# Use formatted time with environment variable for log level
+log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms
+log-buffering = 1024 # Add buffer size limit for logging
From 58d14664e3e6005539db2b43e554f6761f864219 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Wed, 21 Jan 2026 16:35:00 -0600
Subject: [PATCH 039/125] Enhancement: Adjust layout and spacing in
StreamConnectionCard for improved UI consistency and readability
---
.../components/cards/StreamConnectionCard.jsx | 40 ++++++++++---------
1 file changed, 22 insertions(+), 18 deletions(-)
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
index 6c5fc4fb..bd7c327c 100644
--- a/frontend/src/components/cards/StreamConnectionCard.jsx
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -450,14 +450,14 @@ const StreamConnectionCard = ({
w={'100%'}
>
-
+
-
+
@@ -494,42 +494,46 @@ const StreamConnectionCard = ({
- {/* Stream Profile on right */}
-
+ {/* Stream Profile on right - absolutely positioned */}
+
{streamProfileName}
-
-
- {/* Channel Name on left, M3U Profile on right */}
-
- {channelName}
+
+ {/* M3U Profile on right - absolutely positioned */}
+
{m3uProfileName}
-
+
+
+ {/* Channel Name on left */}
+
+ {channelName}
+
{/* Display current program on its own line */}
{currentProgram && (
-
-
-
+
+
+
Now Playing:
-
+
{currentProgram.title}
setIsProgramDescExpanded(!isProgramDescExpanded)}
+ style={{ flexShrink: 0 }}
>
{isProgramDescExpanded ? (
@@ -603,7 +607,7 @@ const StreamConnectionCard = ({
{/* Add stream selection dropdown and preview button */}
{availableStreams.length > 0 && (
-
+
@@ -640,7 +644,7 @@ const StreamConnectionCard = ({
)}
{/* Add stream information badges */}
-
+
{channel.resolution && (
From e79622f513474b68a178b3418a89b747f8a2cf05 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Wed, 21 Jan 2026 16:44:31 -0600
Subject: [PATCH 040/125] adjust arrow to be next to title always.
---
frontend/src/components/cards/StreamConnectionCard.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
index bd7c327c..1cd99814 100644
--- a/frontend/src/components/cards/StreamConnectionCard.jsx
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -526,7 +526,7 @@ const StreamConnectionCard = ({
Now Playing:
-
+
{currentProgram.title}
Date: Wed, 21 Jan 2026 16:55:25 -0600
Subject: [PATCH 041/125] Bug fix: Fixed long IP addresses overlapping adjacent
columns in stream connection card by adding truncation with tooltips
displaying the full address. (Fixes #712)
---
CHANGELOG.md | 1 +
frontend/src/components/cards/StreamConnectionCard.jsx | 8 ++++++++
2 files changed, 9 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 599fb986..14b41dcf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712)
- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869)
- Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page:
- Stream connection card "Connected" column
diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx
index 1cd99814..bf72b109 100644
--- a/frontend/src/components/cards/StreamConnectionCard.jsx
+++ b/frontend/src/components/cards/StreamConnectionCard.jsx
@@ -271,6 +271,14 @@ const StreamConnectionCard = ({
{
header: 'IP Address',
accessorKey: 'ip_address',
+ size: 150,
+ cell: ({ cell }) => (
+
+
+ {cell.getValue()}
+
+
+ ),
},
// Updated Connected column with tooltip
{
From 236b2307e1e7878ddb2a2bb67ea4ede7312479cd Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Wed, 21 Jan 2026 17:40:36 -0600
Subject: [PATCH 042/125] Add health check and fix dangerous checks
- docker-compose: added service health checks
- dockerfile: added if logic for sed command so missing files do not cause build fail
- entrypoint.celery: change migration detection to not continue until ALL migrations are applied
- entrypoint: trim django key and change external postgres check to python in case postgres binaries are missing
---
docker/Dockerfile | 10 ++++++----
docker/docker-compose.yml | 25 ++++++++++++++++++++-----
docker/entrypoint.celery.sh | 6 +++---
docker/entrypoint.sh | 17 ++++++++++++++---
4 files changed, 43 insertions(+), 15 deletions(-)
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 409f1096..3e30a825 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -30,10 +30,12 @@ WORKDIR /app
COPY . /app
# Copy nginx configuration
COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default
-# Verify entrypoint scripts exist, fix line endings, and make them executable
-RUN ls -la /app/docker/entrypoint*.sh && \
- sed -i 's/\r$//' /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh && \
- chmod +x /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh
+# Fix line endings and make entrypoint scripts executable
+RUN for f in /app/docker/entrypoint*.sh; do \
+ if [ -f "$f" ]; then \
+ sed -i 's/\r$//' "$f" && chmod +x "$f"; \
+ fi; \
+ done
# Clean out existing frontend folder
RUN rm -rf /app/frontend
# Copy built frontend assets
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index df295f90..e9254d30 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -7,8 +7,10 @@ services:
volumes:
- ./data:/data
depends_on:
- - db
- - redis
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
environment:
- DISPATCHARR_ENV=modular
- POSTGRES_HOST=db
@@ -50,9 +52,12 @@ services:
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_celery
depends_on:
- - db
- - redis
- - web
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ web:
+ condition: service_started
volumes:
- ./data:/data
extra_hosts:
@@ -85,10 +90,20 @@ services:
- POSTGRES_PASSWORD=secret
volumes:
- postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatcharr"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
redis:
image: redis:latest
container_name: dispatcharr_redis
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
volumes:
postgres_data:
diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh
index fafe2c3f..0516cc54 100644
--- a/docker/entrypoint.celery.sh
+++ b/docker/entrypoint.celery.sh
@@ -7,11 +7,11 @@ source /dispatcharrpy/bin/activate
# Wait for Django secret key
echo 'Waiting for Django secret key...'
while [ ! -f /data/jwt ]; do sleep 1; done
-export DJANGO_SECRET_KEY=$(cat /data/jwt)
+export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)"
-# Wait for migrations to complete
+# Wait for migrations to complete (check that NO unapplied migrations remain)
echo 'Waiting for migrations to complete...'
-until python manage.py showmigrations 2>&1 | grep -q '\[X\]'; do
+until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do
echo 'Migrations not ready yet, waiting...'
sleep 2
done
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 36948c1f..0c17b9d0 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -66,7 +66,7 @@ PY
mv -f "$tmpfile" "$SECRET_FILE" || { echo "move failed"; rm -f "$tmpfile"; exit 1; }
umask $old_umask
fi
-export DJANGO_SECRET_KEY="$(cat "$SECRET_FILE")"
+export DJANGO_SECRET_KEY="$(tr -d '\r\n' < "$SECRET_FILE")"
# Process priority configuration
# UWSGI_NICE_LEVEL: Absolute nice value for uWSGI/streaming (default: 0 = normal priority)
@@ -174,9 +174,20 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
pids+=("$postgres_pid")
else
echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}"
- # Wait for external PostgreSQL to be ready
+ # Wait for external PostgreSQL to be ready using Python (no pg_isready needed)
echo_with_timestamp "Waiting for external PostgreSQL to be ready..."
- until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
+ until python3 -c "
+import socket
+import sys
+try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(2)
+ s.connect(('${POSTGRES_HOST}', ${POSTGRES_PORT}))
+ s.close()
+ sys.exit(0)
+except Exception:
+ sys.exit(1)
+" 2>/dev/null; do
echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..."
sleep 1
done
From 7a46577d2ec89ac65f20ae7249cc4194659fde7e Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Wed, 21 Jan 2026 17:46:07 -0600
Subject: [PATCH 043/125] Remove health check conditions
Not supported on compose v3+
---
docker/docker-compose.yml | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index e9254d30..6673f896 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -7,10 +7,8 @@ services:
volumes:
- ./data:/data
depends_on:
- db:
- condition: service_healthy
- redis:
- condition: service_healthy
+ - db
+ - redis
environment:
- DISPATCHARR_ENV=modular
- POSTGRES_HOST=db
@@ -52,12 +50,9 @@ services:
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_celery
depends_on:
- db:
- condition: service_healthy
- redis:
- condition: service_healthy
- web:
- condition: service_started
+ - db
+ - redis
+ - web
volumes:
- ./data:/data
extra_hosts:
From 8c2ffbe76a82972de35493826b3d29591e2240cf Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Wed, 21 Jan 2026 17:59:23 -0600
Subject: [PATCH 044/125] CELERY_NICE_LEVEL warning
Changed the celery entrypoint to include a warning if CELERY_NICE_LEVEL is negative AND SYS_NICE is not configured (SYS_NICE is required for negative levels)
---
docker/entrypoint.celery.sh | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh
index 0516cc54..81cece86 100644
--- a/docker/entrypoint.celery.sh
+++ b/docker/entrypoint.celery.sh
@@ -19,4 +19,11 @@ done
# Start Celery
echo 'Migrations complete, starting Celery...'
celery -A dispatcharr beat -l info &
-nice -n ${CELERY_NICE_LEVEL:-5} celery -A dispatcharr worker -l info --autoscale=6,1
+
+# Default to nice level 5 (lower priority) - safe for unprivileged containers
+# Negative values require SYS_NICE capability
+NICE_LEVEL="${CELERY_NICE_LEVEL:-5}"
+if [ "$NICE_LEVEL" -lt 0 ] 2>/dev/null; then
+ echo "Warning: CELERY_NICE_LEVEL=$NICE_LEVEL is negative, requires SYS_NICE capability"
+fi
+nice -n "$NICE_LEVEL" celery -A dispatcharr worker -l info --autoscale=6,1
From 6e70753d1c7e949e7dfc816851c6f93f30b2bc3b Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 22 Jan 2026 14:25:33 -0600
Subject: [PATCH 045/125] changelog: Fixed nginx startup failure due to group
name mismatch in non-container deployments
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14b41dcf..c63d73e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712)
+- Fixed nginx startup failure due to group name mismatch in non-container deployments - Thanks [@s0len](https://github.com/s0len) (Fixes #877)
- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869)
- Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page:
- Stream connection card "Connected" column
From d4f412e3525dddc645941d81dddfec017b8e0971 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 23 Jan 2026 10:33:14 -0600
Subject: [PATCH 046/125] Bug Fix: Channels table pagination error handling:
Fixed "Invalid page" error notifications that appeared when filters reduced
the result set while on a page beyond the new total. The API layer now
automatically detects invalid page errors, resets pagination to page 1, and
retries the request transparently. (Fixes #864)
---
CHANGELOG.md | 1 +
frontend/src/api.js | 58 +++++++++++++++++++
.../src/components/tables/ChannelsTable.jsx | 39 ++++++++-----
3 files changed, 82 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c63d73e5..bd73931a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Channels table pagination error handling: Fixed "Invalid page" error notifications that appeared when filters reduced the result set while on a page beyond the new total. The API layer now automatically detects invalid page errors, resets pagination to page 1, and retries the request transparently. (Fixes #864)
- Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712)
- Fixed nginx startup failure due to group name mismatch in non-container deployments - Thanks [@s0len](https://github.com/s0len) (Fixes #877)
- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869)
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 4b728622..3df0b475 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -198,6 +198,31 @@ export default class API {
return response;
} catch (e) {
+ // Handle invalid page error by resetting to page 1 and retrying
+ if (e.body?.detail === 'Invalid page.') {
+ const currentPagination = useChannelsTableStore.getState().pagination;
+
+ // Only retry if we're not already on page 1
+ if (currentPagination.pageIndex > 0) {
+ // Reset to page 1
+ useChannelsTableStore.getState().setPagination({
+ ...currentPagination,
+ pageIndex: 0,
+ });
+
+ // Update params to page 1 and retry
+ const newParams = new URLSearchParams(params);
+ newParams.set('page', '1');
+
+ const response = await request(
+ `${host}/api/channels/channels/?${newParams.toString()}`
+ );
+
+ useChannelsTableStore.getState().queryChannels(response, newParams);
+ return response;
+ }
+ }
+
errorNotification('Failed to fetch channels', e);
}
}
@@ -218,6 +243,39 @@ export default class API {
return response;
} catch (e) {
+ // Handle invalid page error by resetting to page 1 and retrying
+ if (e.body?.detail === 'Invalid page.') {
+ const currentPagination = useChannelsTableStore.getState().pagination;
+
+ // Only retry if we're not already on page 1
+ if (currentPagination.pageIndex > 0) {
+ // Reset to page 1
+ useChannelsTableStore.getState().setPagination({
+ ...currentPagination,
+ pageIndex: 0,
+ });
+
+ // Update params to page 1 and retry
+ const newParams = new URLSearchParams(API.lastQueryParams);
+ newParams.set('page', '1');
+ API.lastQueryParams = newParams;
+
+ const [response, ids] = await Promise.all([
+ request(
+ `${host}/api/channels/channels/?${newParams.toString()}`
+ ),
+ API.getAllChannelIds(newParams),
+ ]);
+
+ useChannelsTableStore
+ .getState()
+ .queryChannels(response, newParams);
+ useChannelsTableStore.getState().setAllQueryIds(ids);
+
+ return response;
+ }
+ }
+
errorNotification('Failed to fetch channels', e);
}
}
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index 78dd4f4c..bcacfe98 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -421,25 +421,32 @@ const ChannelsTable = ({ onReady }) => {
}
});
- const [results, ids] = await Promise.all([
- await API.queryChannels(params),
- await API.getAllChannelIds(params),
- ]);
+ try {
+ const [results, ids] = await Promise.all([
+ await API.queryChannels(params),
+ await API.getAllChannelIds(params),
+ ]);
- setIsLoading(false);
- hasFetchedData.current = true;
+ setIsLoading(false);
+ hasFetchedData.current = true;
- setTablePrefs((prev) => ({
- ...prev,
- pageSize: pagination.pageSize,
- }));
- setAllRowIds(ids);
+ setTablePrefs((prev) => ({
+ ...prev,
+ pageSize: pagination.pageSize,
+ }));
+ setAllRowIds(ids);
- // Signal ready after first successful data fetch AND EPG data is loaded
- // This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
- if (!hasSignaledReady.current && onReady && tvgsLoaded) {
- hasSignaledReady.current = true;
- onReady();
+ // Signal ready after first successful data fetch AND EPG data is loaded
+ // This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
+ if (!hasSignaledReady.current && onReady && tvgsLoaded) {
+ hasSignaledReady.current = true;
+ onReady();
+ }
+ } catch (error) {
+ setIsLoading(false);
+ // API layer handles "Invalid page" errors by resetting and retrying
+ // Just re-throw to show notification for actual errors
+ throw error;
}
}, [
pagination,
From c698916a6091c6476516ebb11d24233486bbe63d Mon Sep 17 00:00:00 2001
From: Tobias Effner
Date: Fri, 23 Jan 2026 22:45:37 +0100
Subject: [PATCH 047/125] feat: migrate to UV
---
.github/workflows/base-image.yml | 4 +-
.python-version | 1 +
debian_install.sh | 25 +-
docker/DispatcharrBase | 23 +-
docker/init/99-init-dev.sh | 6 +-
pyproject.toml | 53 +
requirements.txt | 32 -
uv.lock | 2098 ++++++++++++++++++++++++++++++
8 files changed, 2190 insertions(+), 52 deletions(-)
create mode 100644 .python-version
create mode 100644 pyproject.toml
delete mode 100644 requirements.txt
create mode 100644 uv.lock
diff --git a/.github/workflows/base-image.yml b/.github/workflows/base-image.yml
index d290d49a..e3a8a4ee 100644
--- a/.github/workflows/base-image.yml
+++ b/.github/workflows/base-image.yml
@@ -6,13 +6,13 @@ on:
paths:
- 'docker/DispatcharrBase'
- '.github/workflows/base-image.yml'
- - 'requirements.txt'
+ - 'pyproject.toml'
pull_request:
branches: [main, dev]
paths:
- 'docker/DispatcharrBase'
- '.github/workflows/base-image.yml'
- - 'requirements.txt'
+ - 'pyproject.toml'
workflow_dispatch: # Allow manual triggering
permissions:
diff --git a/.python-version b/.python-version
new file mode 100644
index 00000000..24ee5b1b
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.13
diff --git a/debian_install.sh b/debian_install.sh
index bda506b1..3452e5c1 100755
--- a/debian_install.sh
+++ b/debian_install.sh
@@ -160,15 +160,28 @@ EOSU
# 6) Setup Python Environment
##############################################################################
+install_uv() {
+ echo ">>> Installing UV package manager..."
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+ export PATH="$HOME/.local/bin:$PATH"
+}
+
setup_python_env() {
- echo ">>> Setting up Python virtual environment..."
+ echo ">>> Setting up Python virtual environment with UV..."
+ # Install UV globally first
+ install_uv
+
su - "$DISPATCH_USER" < /dev/null; then
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+ export PATH="\$HOME/.local/bin:\$PATH"
+fi
+# Create venv and install dependencies using UV
+uv venv env --python $PYTHON_BIN
+uv sync --python env/bin/python --no-install-project --no-dev
EOSU
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
}
diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase
index 149bfffb..4793102d 100644
--- a/docker/DispatcharrBase
+++ b/docker/DispatcharrBase
@@ -3,6 +3,8 @@ FROM lscr.io/linuxserver/ffmpeg:latest
ENV DEBIAN_FRONTEND=noninteractive
ENV VIRTUAL_ENV=/dispatcharrpy
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
+ENV UV_COMPILE_BYTECODE=1
+ENV UV_LINK_MODE=copy
# --- Install Python 3.13 and build dependencies ---
# Note: Hardware acceleration (VA-API, VDPAU, NVENC) already included in base ffmpeg image
@@ -18,24 +20,27 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
vlc-bin vlc-plugin-base \
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build
-# --- Create Python virtual environment ---
-RUN python3.13 -m venv $VIRTUAL_ENV && $VIRTUAL_ENV/bin/pip install --upgrade pip
+# --- Install UV ---
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
-# --- Install Python dependencies ---
-COPY requirements.txt /tmp/requirements.txt
-RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && \
- rm /tmp/requirements.txt
+# --- Create Python virtual environment and install dependencies ---
+WORKDIR /tmp/build
+COPY pyproject.toml /tmp/build/
+RUN uv venv $VIRTUAL_ENV --python 3.13 && \
+ uv sync --python $VIRTUAL_ENV/bin/python --no-cache --no-install-project --no-dev && \
+ rm -rf /tmp/build
+WORKDIR /
# --- Build legacy NumPy wheel for old hardware (store for runtime switching) ---
-RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir build && \
+RUN uv pip install --python $VIRTUAL_ENV/bin/python --no-cache build && \
cd /tmp && \
- $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \
+ uv pip download --python $VIRTUAL_ENV/bin/python --no-binary numpy --no-deps numpy && \
tar -xzf numpy-*.tar.gz && \
cd numpy-*/ && \
$VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
mv dist/*.whl /opt/ && \
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \
- $VIRTUAL_ENV/bin/pip uninstall -y build
+ uv pip uninstall --python $VIRTUAL_ENV/bin/python build
# --- Clean up build dependencies to reduce image size ---
RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \
diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh
index 861c8307..c89f1e44 100644
--- a/docker/init/99-init-dev.sh
+++ b/docker/init/99-init-dev.sh
@@ -15,11 +15,11 @@ fi
# Install frontend dependencies
cd /app/frontend && npm install
-# Install pip dependencies
-cd /app && pip install -r requirements.txt
+# Install Python dependencies using UV
+cd /app && uv sync --python $VIRTUAL_ENV/bin/python --no-install-project --no-dev
# Install debugpy for remote debugging
if [ "$DISPATCHARR_DEBUG" = "true" ]; then
echo "=== setting up debugpy ==="
- pip install debugpy
+ uv pip install --python $VIRTUAL_ENV/bin/python debugpy
fi
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..0a8b5cff
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,53 @@
+[project]
+name = "dispatcharr"
+version = "0.17.0"
+description = "Dispatcharr - Stream dispatching and management"
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.13"
+dependencies = [
+ "Django==5.2.9",
+ "psycopg2-binary==2.9.11",
+ "celery[redis]==5.6.0",
+ "djangorestframework==3.16.1",
+ "requests==2.32.5",
+ "psutil==7.1.3",
+ "pillow",
+ "drf-yasg>=1.21.11",
+ "streamlink",
+ "python-vlc",
+ "yt-dlp",
+ "gevent==25.9.1",
+ "daphne",
+ "uwsgi",
+ "django-cors-headers",
+ "djangorestframework-simplejwt",
+ "m3u8",
+ "rapidfuzz==3.14.3",
+ "regex",
+ "tzlocal",
+ "torch==2.9.1+cpu",
+ "sentence-transformers==5.2.0",
+ "channels",
+ "channels-redis==4.3.0",
+ "django-filter",
+ "django-celery-beat",
+ "lxml==6.0.2",
+ "gunicorn",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+# PyTorch CPU-only wheels index
+[[tool.uv.index]]
+url = "https://download.pytorch.org/whl/cpu"
+name = "pytorch-cpu"
+explicit = true
+
+[tool.uv.sources]
+torch = { index = "pytorch-cpu" }
+
+[tool.hatch.build.targets.wheel]
+packages = ["dispatcharr", "apps", "core"]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 3416804d..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,32 +0,0 @@
-Django==5.2.9
-psycopg2-binary==2.9.11
-celery[redis]==5.6.0
-djangorestframework==3.16.1
-requests==2.32.5
-psutil==7.1.3
-pillow
-drf-yasg>=1.21.11
-streamlink
-python-vlc
-yt-dlp
-gevent==25.9.1
-daphne
-uwsgi
-django-cors-headers
-djangorestframework-simplejwt
-m3u8
-rapidfuzz==3.14.3
-regex # Required by transformers but also used for advanced regex features
-tzlocal
-
-# PyTorch dependencies (CPU only)
---extra-index-url https://download.pytorch.org/whl/cpu/
-torch==2.9.1+cpu
-
-# ML/NLP dependencies
-sentence-transformers==5.2.0
-channels
-channels-redis==4.3.0
-django-filter
-django-celery-beat
-lxml==6.0.2
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 00000000..8361f8fc
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,2098 @@
+version = 1
+revision = 3
+requires-python = ">=3.13"
+
+[[package]]
+name = "amqp"
+version = "5.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "vine" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
+]
+
+[[package]]
+name = "asgiref"
+version = "3.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+]
+
+[[package]]
+name = "autobahn"
+version = "25.12.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cbor2" },
+ { name = "cffi" },
+ { name = "cryptography" },
+ { name = "hyperlink" },
+ { name = "msgpack", marker = "platform_python_implementation == 'CPython'" },
+ { name = "py-ubjson" },
+ { name = "txaio" },
+ { name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" },
+ { name = "ujson" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/f7/430074a5ea3f6187335a4ddc26f16dd75d5125e346a84cf132ddbd41a3e8/autobahn-25.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9e2a962f2de0bc4c53b452916458417a15f5137c956245ac6d0a783a83fa1f7", size = 2151873, upload-time = "2025-12-15T11:13:08.89Z" },
+ { url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" },
+ { url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/58/e498821606db57305c8f3c26d9b28fd73e4e0583a1f48330df500721c418/autobahn-25.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:18b12e8af7fc115487715afa10b3f5b5a4b5989bebbe05b71722cf9fce7b1bfb", size = 2184111, upload-time = "2025-12-15T11:13:12.461Z" },
+]
+
+[[package]]
+name = "automat"
+version = "25.4.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" },
+]
+
+[[package]]
+name = "billiard"
+version = "4.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
+]
+
+[[package]]
+name = "cbor2"
+version = "5.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" },
+ { url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/d41f8c04c783a4d204e364be2d38043d4f732a3bed6f4c732e321cf34c7b/cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0", size = 69841, upload-time = "2025-12-30T18:44:01.159Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8c/0397a82f6e67665009951453c83058e4c77ba54b9a9017ede56d6870306c/cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea", size = 64982, upload-time = "2025-12-30T18:44:02.138Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" },
+ { url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" },
+ { url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" },
+ { url = "https://files.pythonhosted.org/packages/88/6d/e43452347630efe8133f5304127539100d937c138c0996d27ec63963ec2c/cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1", size = 71657, upload-time = "2025-12-30T18:44:10.162Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/66/9a780ef34ab10a0437666232e885378cdd5f60197b1b5e61a62499e5a10a/cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71", size = 67171, upload-time = "2025-12-30T18:44:11.619Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" },
+]
+
+[[package]]
+name = "celery"
+version = "5.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "billiard" },
+ { name = "click" },
+ { name = "click-didyoumean" },
+ { name = "click-plugins" },
+ { name = "click-repl" },
+ { name = "exceptiongroup" },
+ { name = "kombu" },
+ { name = "python-dateutil" },
+ { name = "tzlocal" },
+ { name = "vine" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/5f/b681ae3c89290d2ea6562ea96b40f5af6f6fc5f7743e2cd1a19e47721548/celery-5.6.0.tar.gz", hash = "sha256:641405206042d52ae460e4e9751a2e31b06cf80ab836fcf92e0b9311d7ea8113", size = 1712522, upload-time = "2025-11-30T17:39:46.282Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" },
+]
+
+[package.optional-dependencies]
+redis = [
+ { name = "kombu", extra = ["redis"] },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "channels"
+version = "4.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" },
+]
+
+[[package]]
+name = "channels-redis"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "channels" },
+ { name = "msgpack" },
+ { name = "redis" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5", size = 20641, upload-time = "2025-07-22T13:48:44.545Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "click-didyoumean"
+version = "0.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" },
+]
+
+[[package]]
+name = "click-plugins"
+version = "1.1.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" },
+]
+
+[[package]]
+name = "click-repl"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "prompt-toolkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "constantly"
+version = "23.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" },
+]
+
+[[package]]
+name = "cron-descriptor"
+version = "2.0.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
+ { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
+ { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
+ { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
+ { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
+ { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
+ { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
+ { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
+ { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
+ { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
+]
+
+[[package]]
+name = "daphne"
+version = "4.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "autobahn" },
+ { name = "twisted", extra = ["tls"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/9d/322b605fdc03b963cf2d33943321c8f4405e8d82e698bf49d1eed1ca40c4/daphne-4.2.1.tar.gz", hash = "sha256:5f898e700a1fda7addf1541d7c328606415e96a7bd768405f0463c312fcb31b3", size = 45600, upload-time = "2025-07-02T12:57:04.935Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" },
+]
+
+[[package]]
+name = "dispatcharr"
+version = "0.17.0"
+source = { editable = "." }
+dependencies = [
+ { name = "celery", extra = ["redis"] },
+ { name = "channels" },
+ { name = "channels-redis" },
+ { name = "daphne" },
+ { name = "django" },
+ { name = "django-celery-beat" },
+ { name = "django-cors-headers" },
+ { name = "django-filter" },
+ { name = "djangorestframework" },
+ { name = "djangorestframework-simplejwt" },
+ { name = "drf-yasg" },
+ { name = "gevent" },
+ { name = "gunicorn" },
+ { name = "lxml" },
+ { name = "m3u8" },
+ { name = "pillow" },
+ { name = "psutil" },
+ { name = "psycopg2-binary" },
+ { name = "python-vlc" },
+ { name = "rapidfuzz" },
+ { name = "regex" },
+ { name = "requests" },
+ { name = "sentence-transformers" },
+ { name = "streamlink" },
+ { name = "torch" },
+ { name = "tzlocal" },
+ { name = "uwsgi" },
+ { name = "yt-dlp" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "celery", extras = ["redis"], specifier = "==5.6.0" },
+ { name = "channels" },
+ { name = "channels-redis", specifier = "==4.3.0" },
+ { name = "daphne" },
+ { name = "django", specifier = "==5.2.9" },
+ { name = "django-celery-beat" },
+ { name = "django-cors-headers" },
+ { name = "django-filter" },
+ { name = "djangorestframework", specifier = "==3.16.1" },
+ { name = "djangorestframework-simplejwt" },
+ { name = "drf-yasg", specifier = ">=1.21.11" },
+ { name = "gevent", specifier = "==25.9.1" },
+ { name = "gunicorn" },
+ { name = "lxml", specifier = "==6.0.2" },
+ { name = "m3u8" },
+ { name = "pillow" },
+ { name = "psutil", specifier = "==7.1.3" },
+ { name = "psycopg2-binary", specifier = "==2.9.11" },
+ { name = "python-vlc" },
+ { name = "rapidfuzz", specifier = "==3.14.3" },
+ { name = "regex" },
+ { name = "requests", specifier = "==2.32.5" },
+ { name = "sentence-transformers", specifier = "==5.2.0" },
+ { name = "streamlink" },
+ { name = "torch", specifier = "==2.9.1+cpu", index = "https://download.pytorch.org/whl/cpu" },
+ { name = "tzlocal" },
+ { name = "uwsgi" },
+ { name = "yt-dlp" },
+]
+
+[[package]]
+name = "django"
+version = "5.2.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "sqlparse" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" },
+]
+
+[[package]]
+name = "django-celery-beat"
+version = "2.8.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "celery" },
+ { name = "cron-descriptor" },
+ { name = "django" },
+ { name = "django-timezone-field" },
+ { name = "python-crontab" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" },
+]
+
+[[package]]
+name = "django-cors-headers"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
+]
+
+[[package]]
+name = "django-filter"
+version = "25.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
+]
+
+[[package]]
+name = "django-timezone-field"
+version = "7.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" },
+]
+
+[[package]]
+name = "djangorestframework"
+version = "3.16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
+]
+
+[[package]]
+name = "djangorestframework-simplejwt"
+version = "5.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "djangorestframework" },
+ { name = "pyjwt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
+]
+
+[[package]]
+name = "drf-yasg"
+version = "1.21.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "djangorestframework" },
+ { name = "inflection" },
+ { name = "packaging" },
+ { name = "pytz" },
+ { name = "pyyaml" },
+ { name = "uritemplate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/1b/2305804c1efbc2da1890fb2a34155ef298565eb152e1c00bd35546be42fa/drf_yasg-1.21.14.tar.gz", hash = "sha256:68d62d5f7505611ba9577115c83b36211cc20d9748a50ce95a44abe411727d10", size = 5153307, upload-time = "2026-01-15T11:37:07.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/de/61880040534036044572225c485b0a4adc99b4c92e3eed3e5741b31674fd/drf_yasg-1.21.14-py3-none-any.whl", hash = "sha256:725ddda28aec7efc4cab985290fe790c8565e665017f7c80211d288a35821c70", size = 4856031, upload-time = "2026-01-15T11:37:04.753Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.20.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" },
+]
+
+[[package]]
+name = "gevent"
+version = "25.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
+ { name = "greenlet", marker = "platform_python_implementation == 'CPython'" },
+ { name = "zope-event" },
+ { name = "zope-interface" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" },
+ { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" },
+ { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" },
+ { url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" },
+ { url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" },
+ { url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
+ { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
+ { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" },
+ { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
+ { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
+ { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
+ { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
+ { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" },
+ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
+ { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
+ { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
+ { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
+]
+
+[[package]]
+name = "gunicorn"
+version = "24.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7b/8b/3cd14f3d78f050f8c703cb1a881d62f5fc695cfd9f258386982bdc1a018a/gunicorn-24.1.0.tar.gz", hash = "sha256:6df323c06bcb9499cbf8bc3b89a56619ac54d6a8c66130681e206272cc5b8e73", size = 286767, upload-time = "2026-01-23T20:50:38.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/ea/f91518dd05d81eb8fb4c10c86802aa943d65e4df4dbc820ec58e2597b501/gunicorn-24.1.0-py3-none-any.whl", hash = "sha256:d7c3e50dfdade301b1a94ed3e9a2c07ab7d154908203813c10d7b175d04bbf23", size = 114495, upload-time = "2026-01-23T20:50:36.855Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "hf-xet"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" },
+ { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" },
+ { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" },
+ { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" },
+ { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" },
+ { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" },
+]
+
+[[package]]
+name = "huggingface-hub"
+version = "0.36.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" },
+]
+
+[[package]]
+name = "hyperlink"
+version = "21.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "incremental"
+version = "24.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" },
+]
+
+[[package]]
+name = "inflection"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
+]
+
+[[package]]
+name = "isodate"
+version = "0.7.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "joblib"
+version = "1.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
+]
+
+[[package]]
+name = "kombu"
+version = "5.6.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "amqp" },
+ { name = "packaging" },
+ { name = "tzdata" },
+ { name = "vine" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" },
+]
+
+[package.optional-dependencies]
+redis = [
+ { name = "redis" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
+ { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
+ { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
+ { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
+ { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
+ { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
+ { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
+ { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
+ { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
+ { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
+ { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
+ { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
+ { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
+ { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
+ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
+]
+
+[[package]]
+name = "m3u8"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
+[[package]]
+name = "msgpack"
+version = "1.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
+ { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
+ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
+ { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
+ { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
+ { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
+ { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
+ { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
+ { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
+ { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
+ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" },
+ { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" },
+ { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" },
+ { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
+ { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
+ { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
+ { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
+ { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
+ { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
+ { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
+ { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" },
+ { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" },
+ { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" },
+ { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" },
+]
+
+[[package]]
+name = "outcome"
+version = "1.3.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "12.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
+ { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
+ { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
+ { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
+ { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
+ { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
+ { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
+ { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
+ { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
+ { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
+ { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
+ { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
+ { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.52"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" },
+ { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" },
+ { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" },
+ { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" },
+ { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" },
+ { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" },
+ { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" },
+]
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
+ { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
+ { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
+ { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
+ { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
+ { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
+ { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
+ { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
+ { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
+ { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
+ { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
+ { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
+]
+
+[[package]]
+name = "py-ubjson"
+version = "0.16.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" }
+
+[[package]]
+name = "pyasn1"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
+]
+
+[[package]]
+name = "pycountry"
+version = "24.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pycryptodome"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
+ { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
+ { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
+ { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
+ { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
+ { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
+ { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+]
+
+[[package]]
+name = "pyopenssl"
+version = "25.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
+]
+
+[[package]]
+name = "pysocks"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
+]
+
+[[package]]
+name = "python-crontab"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-vlc"
+version = "3.0.21203"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "rapidfuzz"
+version = "3.14.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" },
+ { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" },
+ { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" },
+ { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" },
+ { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" },
+ { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" },
+ { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" },
+ { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" },
+ { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" },
+ { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" },
+ { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" },
+ { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" },
+ { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" },
+ { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" },
+ { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" },
+]
+
+[[package]]
+name = "redis"
+version = "6.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
+]
+
+[[package]]
+name = "regex"
+version = "2026.1.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
+ { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
+ { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
+ { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
+ { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
+ { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
+ { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
+ { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
+ { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
+ { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
+ { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
+ { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
+ { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
+ { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
+ { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
+ { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
+ { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
+ { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
+ { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
+[[package]]
+name = "safetensors"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
+ { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
+ { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
+ { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
+]
+
+[[package]]
+name = "scikit-learn"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "joblib" },
+ { name = "numpy" },
+ { name = "scipy" },
+ { name = "threadpoolctl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
+ { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
+ { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
+ { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
+ { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
+ { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
+ { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
+ { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
+ { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
+ { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
+]
+
+[[package]]
+name = "scipy"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" },
+ { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" },
+ { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" },
+ { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" },
+ { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" },
+ { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" },
+ { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" },
+ { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" },
+ { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" },
+ { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" },
+ { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" },
+ { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" },
+ { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" },
+ { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" },
+ { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" },
+ { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" },
+ { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" },
+]
+
+[[package]]
+name = "sentence-transformers"
+version = "5.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "scikit-learn" },
+ { name = "scipy" },
+ { name = "torch" },
+ { name = "tqdm" },
+ { name = "transformers" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/a1/64e7b111e753307ffb7c5b6d039c52d4a91a47fa32a7f5bc377a49b22402/sentence_transformers-5.2.0.tar.gz", hash = "sha256:acaeb38717de689f3dab45d5e5a02ebe2f75960a4764ea35fea65f58a4d3019f", size = 381004, upload-time = "2025-12-11T14:12:31.038Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/d0/3b2897ef6a0c0c801e9fecca26bcc77081648e38e8c772885ebdd8d7d252/sentence_transformers-5.2.0-py3-none-any.whl", hash = "sha256:aa57180f053687d29b08206766ae7db549be5074f61849def7b17bf0b8025ca2", size = 493748, upload-time = "2025-12-11T14:12:29.516Z" },
+]
+
+[[package]]
+name = "service-identity"
+version = "24.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "cryptography" },
+ { name = "pyasn1" },
+ { name = "pyasn1-modules" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "80.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
+[[package]]
+name = "sqlparse"
+version = "0.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
+]
+
+[[package]]
+name = "streamlink"
+version = "8.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "isodate" },
+ { name = "lxml" },
+ { name = "pycountry" },
+ { name = "pycryptodome" },
+ { name = "pysocks" },
+ { name = "requests" },
+ { name = "trio" },
+ { name = "trio-websocket" },
+ { name = "urllib3" },
+ { name = "websocket-client" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7a/89/ea3baa9d0c2d564412be15ef5e799a56bf2a0068b27197e5e4c312ba13f5/streamlink-8.1.2.tar.gz", hash = "sha256:d08099fa1b169bad4a991d31b2ab89f04ba08b1319ed1f5bd0ead8547d4c5ad3", size = 823590, upload-time = "2026-01-18T12:21:53.28Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/8f/124e4db6bbf8fe3ab9bfe4dbd029f29211629c0f1733da2695576917c226/streamlink-8.1.2-py3-none-any.whl", hash = "sha256:2355cffcdbe60e8270144b5191a20b2ab195bbb9150b453334a23b829d1d75c7", size = 556372, upload-time = "2026-01-18T12:21:47.062Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/11/63a8c6b2b733d55189888504530865f68a3b69ebde7b78138d680e56bb45/streamlink-8.1.2-py3-none-win32.whl", hash = "sha256:c5aba06f24bb7a6a261ec409a301b5d8564ea865933f08001533067e5e40529c", size = 556386, upload-time = "2026-01-18T12:21:49.505Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/5a/81a8bbe0d8755154979a4cd56f2b03da67ddd2db7ab9e14145a35cf0eb72/streamlink-8.1.2-py3-none-win_amd64.whl", hash = "sha256:86043d339a1b9b9b49f36ed26cdd421d226a021f06c9434e2177bfc6caf420d5", size = 556389, upload-time = "2026-01-18T12:21:51.385Z" },
+]
+
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
+[[package]]
+name = "threadpoolctl"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
+]
+
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
+ { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
+ { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
+ { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
+ { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.9.1+cpu"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "jinja2" },
+ { name = "networkx" },
+ { name = "setuptools" },
+ { name = "sympy" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:404a7ab2fffaf2ca069e662f331eb46313692b2f1630df2720094284f390ccef" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:161decbff26a33f13cb5ba6d2c8f458bbf56193bcc32ecc70be6dd4c7a3ee79d" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:e586ab1363e3f86aa4cc133b7fdcf98deb1d2c13d43a7a6e5a6a18e9c5364893" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:65010ab4aacce6c9a1ddfc935f986c003ca8638ded04348fd326c3e74346237c" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:88adf5157db5da1d54b1c9fe4a6c1d20ceef00e75d854e206a87dbf69e3037dc" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:f60e2565f261542efac07e25208fb3fc55c6fe82314a5a9cbee971edb5f27713" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ac2b8df2c55430e836dcda31940d47f1f5f94b8731057b6f20300ebea394dd9" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b688445f928f13563b7418b17c57e97bf955ab559cf73cd8f2b961f8572dbb3" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:cf9c3e50b595721ca6b488bdcc326e0f1af73ed28b9b66eff504a96649bb5c96" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
+]
+
+[[package]]
+name = "transformers"
+version = "4.57.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "huggingface-hub" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "regex" },
+ { name = "requests" },
+ { name = "safetensors" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" },
+]
+
+[[package]]
+name = "trio"
+version = "0.32.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
+ { name = "idna" },
+ { name = "outcome" },
+ { name = "sniffio" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" },
+]
+
+[[package]]
+name = "trio-websocket"
+version = "0.12.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "outcome" },
+ { name = "trio" },
+ { name = "wsproto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" },
+]
+
+[[package]]
+name = "twisted"
+version = "25.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "automat" },
+ { name = "constantly" },
+ { name = "hyperlink" },
+ { name = "incremental" },
+ { name = "typing-extensions" },
+ { name = "zope-interface" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" },
+]
+
+[package.optional-dependencies]
+tls = [
+ { name = "idna" },
+ { name = "pyopenssl" },
+ { name = "service-identity" },
+]
+
+[[package]]
+name = "txaio"
+version = "25.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/67/ea9c9ddbaa3e0b4d53c91f8778a33e42045be352dc7200ed2b9aaa7dc229/txaio-25.12.2.tar.gz", hash = "sha256:9f232c21e12aa1ff52690e365b5a0ecfd42cc27a6ec86e1b92ece88f763f4b78", size = 117393, upload-time = "2025-12-09T15:03:26.527Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/05/bdb6318120cac9bf97779674f49035e0595d894b42d4c43b60637bafdb1f/txaio-25.12.2-py3-none-any.whl", hash = "sha256:5f6cd6c6b397fc3305790d15efd46a2d5b91cdbefa96543b4f8666aeb56ba026", size = 31208, upload-time = "2025-12-09T04:30:27.811Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
+]
+
+[[package]]
+name = "tzlocal"
+version = "5.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
+]
+
+[[package]]
+name = "u-msgpack-python"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/36/9d/a40411a475e7d4838994b7f6bcc6bfca9acc5b119ce3a7503608c4428b49/u-msgpack-python-2.8.0.tar.gz", hash = "sha256:b801a83d6ed75e6df41e44518b4f2a9c221dc2da4bcd5380e3a0feda520bc61a", size = 18167, upload-time = "2023-05-18T09:28:12.187Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" },
+]
+
+[[package]]
+name = "ujson"
+version = "5.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" },
+ { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" },
+ { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" },
+ { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" },
+ { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" },
+ { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" },
+]
+
+[[package]]
+name = "uritemplate"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "uwsgi"
+version = "2.0.31"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/49/2f57640e889ba509fd1fae10cccec1b58972a07c2724486efba94c5ea448/uwsgi-2.0.31.tar.gz", hash = "sha256:e8f8b350ccc106ff93a65247b9136f529c14bf96b936ac5b264c6ff9d0c76257", size = 822796, upload-time = "2025-10-11T19:17:28.794Z" }
+
+[[package]]
+name = "vine"
+version = "5.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/07/0b5bcc9812b1b2fd331cc88289ef4d47d428afdbbf0216bb7d53942d93d6/wcwidth-0.3.2.tar.gz", hash = "sha256:d469b3059dab6b1077def5923ed0a8bf5738bd4a1a87f686d5e2de455354c4ad", size = 233633, upload-time = "2026-01-23T21:08:52.451Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/c6/1452e716c5af065c018f75d42ca97517a04ac6aae4133722e0424649a07c/wcwidth-0.3.2-py3-none-any.whl", hash = "sha256:817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315", size = 86280, upload-time = "2026-01-23T21:08:51.362Z" },
+]
+
+[[package]]
+name = "websocket-client"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
+]
+
+[[package]]
+name = "wsproto"
+version = "1.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
+]
+
+[[package]]
+name = "yt-dlp"
+version = "2025.12.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947, upload-time = "2025-12-08T00:16:01.649Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464, upload-time = "2025-12-08T00:15:58.556Z" },
+]
+
+[[package]]
+name = "zope-event"
+version = "6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" },
+]
+
+[[package]]
+name = "zope-interface"
+version = "8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" },
+ { url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" },
+ { url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" },
+ { url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" },
+ { url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" },
+]
From 5348a4a5640b8ae937ee6dcdb2ad6a1e0b1733e7 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 23 Jan 2026 16:03:26 -0600
Subject: [PATCH 048/125] Feature: Editable Channel Table Mode: - Added a
robust inline editing mode for the channels table, allowing users to quickly
edit channel fields (name, number, group, EPG, logo) directly in the table
without opening a modal. - EPG and logo columns now support searchable
dropdowns with instant filtering and keyboard navigation for fast assignment.
- Drag-and-drop reordering of channels enabled when unlocked, with
persistent order updates. - Group column now uses a searchable dropdown for
quick group assignment, matching the UX of EPG and logo selectors. - All
editable cells provide clear focus, hover, and disabled states for improved
accessibility and usability. - Table unlock/edit mode toggle with clear
visual cues and safe-guarded save logic to prevent accidental edits. - All
changes are saved via API with optimistic UI updates and error handling.
---
CHANGELOG.md | 8 +
apps/channels/api_views.py | 75 +-
frontend/src/api.js | 18 +
.../src/components/tables/ChannelsTable.jsx | 217 ++---
.../ChannelsTable/ChannelTableHeader.jsx | 38 +
.../tables/ChannelsTable/DraggableRow.jsx | 59 ++
.../tables/ChannelsTable/EditableCell.jsx | 781 ++++++++++++++++++
.../tables/CustomTable/CustomTable.jsx | 6 +-
.../tables/CustomTable/CustomTableBody.jsx | 77 +-
.../tables/CustomTable/CustomTableHeader.jsx | 5 +
frontend/src/store/channelsTable.jsx | 13 +
11 files changed, 1188 insertions(+), 109 deletions(-)
create mode 100644 frontend/src/components/tables/ChannelsTable/DraggableRow.jsx
create mode 100644 frontend/src/components/tables/ChannelsTable/EditableCell.jsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd73931a..9d920d41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Editable Channel Table Mode:
+ - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal.
+ - EPG and logo columns now support searchable dropdowns with instant filtering and keyboard navigation for fast assignment.
+ - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates.
+ - Group column now uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors.
+ - All editable cells provide clear focus, hover, and disabled states for improved accessibility and usability.
+ - Table unlock/edit mode toggle with clear visual cues and safe-guarded save logic to prevent accidental edits.
+ - All changes are saved via API with optimistic UI updates and error handling.
- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include:
- Currently playing program title displayed with live broadcast indicator (green Radio icon)
- Expandable program descriptions via chevron button
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 11940177..2b2870fa 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -8,7 +8,7 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
-from django.db.models import Count
+from django.db.models import Count, F
from django.db.models import Q
import os, json, requests, logging, mimetypes
from django.utils.http import http_date
@@ -1251,6 +1251,79 @@ class ChannelViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=400)
+ @swagger_auto_schema(
+ method="post",
+ operation_description=(
+ "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). "
+ "The channel will receive the next whole number after the target channel, and all subsequent "
+ "channels will be renumbered accordingly."
+ ),
+ request_body=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ "insert_after_id": openapi.Schema(
+ type=openapi.TYPE_INTEGER,
+ description="ID of the channel to insert after. Use null to move to the beginning.",
+ nullable=True,
+ ),
+ },
+ ),
+ responses={
+ 200: "Channel reordered successfully",
+ 404: "Channel not found",
+ 400: "Invalid request",
+ },
+ )
+ @action(detail=True, methods=["post"], url_path="reorder")
+ def reorder(self, request, pk=None):
+ """
+ Reorder a channel by moving it near a target position.
+ Finds the first available channel number without unnecessarily shifting other channels.
+ """
+ channel = self.get_object()
+ insert_after_id = request.data.get("insert_after_id")
+ old_channel_number = channel.channel_number
+
+ with transaction.atomic():
+ if insert_after_id is None:
+ # Move to the beginning - find first available number starting from 1
+ new_channel_number = 1
+ # Check if 1 is taken, find first gap
+ occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
+ while new_channel_number in occupied:
+ new_channel_number += 1
+ else:
+ try:
+ target_channel = Channel.objects.get(id=insert_after_id)
+ target_number = target_channel.channel_number or 0
+ desired_position = int(target_number) + 1
+
+ # Get all occupied channel numbers (excluding the channel being moved)
+ occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
+
+ # Find the first available number at or after the desired position
+ new_channel_number = desired_position
+ while new_channel_number in occupied:
+ new_channel_number += 1
+
+ except Channel.DoesNotExist:
+ return Response(
+ {"error": "Target channel not found"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ # Update the dragged channel's number
+ channel.channel_number = new_channel_number
+ channel.save(update_fields=['channel_number'])
+
+ return Response(
+ {
+ "message": f"Channel {channel.name} moved to position {new_channel_number}",
+ "channel": self.get_serializer(channel).data,
+ },
+ status=status.HTTP_200_OK,
+ )
+
@swagger_auto_schema(
method="post",
operation_description="Associate multiple channels with EPG data without triggering a full refresh",
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 3df0b475..a831ad22 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -568,6 +568,24 @@ export default class API {
}
}
+ static async reorderChannel(channelId, insertAfterId) {
+ try {
+ const response = await request(
+ `${host}/api/channels/channels/${channelId}/reorder/`,
+ {
+ method: 'POST',
+ body: {
+ insert_after_id: insertAfterId,
+ },
+ }
+ );
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to reorder channel', e);
+ }
+ }
+
static async setChannelEPG(channelId, epgDataId) {
try {
const response = await request(
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index bcacfe98..9106fdcc 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -5,6 +5,17 @@ import React, {
useCallback,
useRef,
} from 'react';
+import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
import API from '../../api';
@@ -61,9 +72,18 @@ import ChannelTableStreams from './ChannelTableStreams';
import LazyLogo from '../LazyLogo';
import useLocalStorage from '../../hooks/useLocalStorage';
import useEPGsStore from '../../store/epgs';
+import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
import { CustomTable, useTable } from './CustomTable';
import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
+import {
+ EditableTextCell,
+ EditableNumberCell,
+ EditableGroupCell,
+ EditableEPGCell,
+ EditableLogoCell,
+} from './ChannelsTable/EditableCell';
+import { DraggableRow } from './ChannelsTable/DraggableRow';
import useWarningsStore from '../../store/warnings';
import ConfirmationDialog from '../ConfirmationDialog';
import useAuthStore from '../../store/auth';
@@ -231,6 +251,10 @@ const ChannelsTable = ({ onReady }) => {
const tvgsById = useEPGsStore((s) => s.tvgsById);
const epgs = useEPGsStore((s) => s.epgs);
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
+
+ // Get channel logos for logo selection
+ const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection();
+
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
@@ -258,6 +282,7 @@ const ChannelsTable = ({ onReady }) => {
const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams);
const allRowIds = useChannelsTableStore((s) => s.allQueryIds);
const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds);
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
// store/channels
const channels = useChannelsStore((s) => s.channels);
@@ -320,6 +345,15 @@ const ChannelsTable = ({ onReady }) => {
const hasFetchedData = useRef(false);
+ // Drag-and-drop sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8, // Require 8px movement before dragging starts
+ },
+ })
+ );
+
// Column sizing state for resizable columns
// Store in localStorage but with empty object as default
const [columnSizing, setColumnSizing] = useLocalStorage(
@@ -746,6 +780,47 @@ const ChannelsTable = ({ onReady }) => {
}
};
+ const handleDragEnd = async (event) => {
+ const { active, over } = event;
+
+ if (!over || active.id === over.id) {
+ return;
+ }
+
+ const activeIndex = rows.findIndex((row) => row.id === active.id);
+ const overIndex = rows.findIndex((row) => row.id === over.id);
+
+ if (activeIndex === -1 || overIndex === -1) {
+ return;
+ }
+
+ const activeChannel = rows[activeIndex].original;
+ const overChannel = rows[overIndex].original;
+
+ try {
+ // Optimistically update the local state
+ const reorderedData = [...data];
+ const [movedItem] = reorderedData.splice(activeIndex, 1);
+ reorderedData.splice(overIndex, 0, movedItem);
+ useChannelsTableStore.setState({ channels: reorderedData });
+
+ // Call backend to reorder
+ await API.reorderChannel(
+ activeChannel.id,
+ overIndex > activeIndex
+ ? overChannel.id
+ : rows[overIndex - 1]?.original.id || null
+ );
+
+ // Refetch to get updated channel numbers
+ await API.requeryChannels();
+ } catch (error) {
+ // Revert on error
+ console.error('Failed to reorder channel:', error);
+ await API.requeryChannels();
+ }
+ };
+
/**
* useEffect
*/
@@ -817,22 +892,7 @@ const ChannelsTable = ({ onReady }) => {
size: columnSizing.channel_number || 40,
minSize: 30,
maxSize: 100,
- cell: ({ getValue }) => {
- const value = getValue();
- // Format as integer if no decimal component
- const formattedValue =
- value !== null && value !== undefined
- ? value === Math.floor(value)
- ? Math.floor(value)
- : value
- : '';
-
- return (
-
- {formattedValue}
-
- );
- },
+ cell: (props) => ,
},
{
id: 'name',
@@ -840,73 +900,20 @@ const ChannelsTable = ({ onReady }) => {
size: columnSizing.name || 200,
minSize: 100,
grow: true,
- cell: ({ getValue }) => (
-
- {getValue()}
-
- ),
+ cell: (props) => ,
},
{
id: 'epg',
header: 'EPG',
accessorKey: 'epg_data_id',
- cell: ({ getValue }) => {
- const epgDataId = getValue();
- const epgObj = epgDataId ? tvgsById[epgDataId] : null;
- const tvgName = epgObj?.name;
- const tvgId = epgObj?.tvg_id;
- const epgName =
- epgObj && epgObj.epg_source
- ? epgs[epgObj.epg_source]?.name || epgObj.epg_source
- : null;
-
- const tooltip = epgObj
- ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
- : '';
-
- // If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading
- const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
-
- return (
-
- {epgObj && epgName ? (
- {tooltip}
- }
- withArrow
- position="top"
- >
-
- {epgObj.epg_source} - {tvgId}
-
-
- ) : epgObj ? (
- {epgObj.name}
- ) : isEpgDataPending ? (
-
- ) : (
- Not Assigned
- )}
-
- );
- },
+ cell: (props) => (
+
+ ),
size: columnSizing.epg || 200,
minSize: 80,
},
@@ -916,16 +923,8 @@ const ChannelsTable = ({ onReady }) => {
channelGroups[row.channel_group_id]
? channelGroups[row.channel_group_id].name
: '',
- cell: ({ getValue }) => (
-
- {getValue()}
-
+ cell: (props) => (
+
),
size: columnSizing.channel_group || 175,
minSize: 100,
@@ -941,19 +940,21 @@ const ChannelsTable = ({ onReady }) => {
maxSize: 120,
enableResizing: false,
header: '',
- cell: ({ getValue }) => {
- const logoId = getValue();
-
- return (
-
-
-
- );
- },
+ cell: (props) => (
+ {
+ // Ensure logos are loaded when user tries to edit
+ ensureLogosLoaded();
+ }}
+ style={{ width: '100%', height: '100%' }}
+ >
+
+
+ ),
},
{
id: 'actions',
@@ -1099,6 +1100,7 @@ const ChannelsTable = ({ onReady }) => {
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
+ enableDragDrop: true,
onRowSelectionChange: onRowSelectionChange,
state: {
pagination,
@@ -1463,7 +1465,18 @@ const ChannelsTable = ({ onReady }) => {
borderRadius: 'var(--mantine-radius-default)',
}}
>
-
+
+ row.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+
+
s.user);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const setIsUnlocked = useChannelsTableStore((s) => s.setIsUnlocked);
const headerPinned = table?.headerPinned ?? false;
const setHeaderPinned = table?.setHeaderPinned || (() => {});
@@ -239,6 +244,10 @@ const ChannelTableHeader = ({
setHeaderPinned(!headerPinned);
};
+ const toggleUnlock = () => {
+ setIsUnlocked(!isUnlocked);
+ };
+
return (
@@ -258,6 +267,23 @@ const ChannelTableHeader = ({
+
+ {isUnlocked && (
+
+
+ Editing Mode
+
+ )}
+ :
+ }
+ onClick={toggleUnlock}
+ disabled={authUser.user_level != USER_LEVELS.ADMIN}
+ >
+
+ {isUnlocked ? 'Lock Table' : 'Unlock for Editing'}
+
+
+
{
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: row.id,
+ disabled: !isUnlocked,
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ position: 'relative',
+ };
+
+ return (
+
+ {isUnlocked && (
+
+
+
+ )}
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
new file mode 100644
index 00000000..065e4631
--- /dev/null
+++ b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
@@ -0,0 +1,781 @@
+import React, {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ useMemo,
+} from 'react';
+import {
+ Box,
+ TextInput,
+ Select,
+ NumberInput,
+ Tooltip,
+ Center,
+ Skeleton,
+} from '@mantine/core';
+import API from '../../../api';
+import useChannelsTableStore from '../../../store/channelsTable';
+
+// Editable text cell
+export const EditableTextCell = ({ row, column, getValue }) => {
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const initialValue = getValue() || '';
+ const [value, setValue] = useState(initialValue);
+ const [isFocused, setIsFocused] = useState(false);
+ const previousValue = useRef(initialValue);
+ const isMounted = useRef(false);
+ const debounceTimer = useRef(null);
+
+ useEffect(() => {
+ const currentValue = getValue() || '';
+ if (!isFocused && currentValue !== previousValue.current) {
+ setValue(currentValue);
+ previousValue.current = currentValue;
+ }
+ }, [getValue, isFocused]);
+
+ const saveValue = useCallback(
+ async (newValue) => {
+ // Don't save if not mounted, not unlocked, or value hasn't changed
+ if (
+ !isMounted.current ||
+ !isUnlocked ||
+ newValue === previousValue.current
+ ) {
+ return;
+ }
+
+ try {
+ const response = await API.updateChannel({
+ id: row.original.id,
+ [column.id]: newValue || null,
+ });
+ previousValue.current = newValue;
+
+ // Update the table store to reflect the change
+ if (response) {
+ useChannelsTableStore.getState().updateChannel(response);
+ }
+ } catch (error) {
+ // Revert on error
+ setValue(previousValue.current || '');
+ }
+ },
+ [row.original.id, column.id, isUnlocked]
+ );
+
+ useEffect(() => {
+ isMounted.current = true;
+ const timer = debounceTimer.current;
+ return () => {
+ isMounted.current = false;
+ if (timer) {
+ clearTimeout(timer);
+ }
+ };
+ }, []);
+
+ const handleChange = (e) => {
+ if (!isUnlocked) return;
+ const newValue = e.currentTarget.value;
+ setValue(newValue);
+
+ // Clear existing timer
+ if (debounceTimer.current) {
+ clearTimeout(debounceTimer.current);
+ }
+
+ // Set new timer
+ debounceTimer.current = setTimeout(() => {
+ saveValue(newValue);
+ }, 500);
+ };
+
+ const handleBlur = () => {
+ setIsFocused(false);
+ if (isUnlocked) {
+ saveValue(value);
+ }
+ };
+
+ const handleClick = () => {
+ if (isUnlocked) {
+ setIsFocused(true);
+ }
+ };
+
+ if (!isUnlocked || !isFocused) {
+ return (
+
+ {value}
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+// Editable number cell
+export const EditableNumberCell = ({ row, column, getValue }) => {
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const initialValue = getValue();
+ const [value, setValue] = useState(initialValue);
+ const [isFocused, setIsFocused] = useState(false);
+ const previousValue = useRef(initialValue);
+ const isMounted = useRef(false);
+
+ useEffect(() => {
+ const currentValue = getValue();
+ if (!isFocused && currentValue !== previousValue.current) {
+ setValue(currentValue);
+ previousValue.current = currentValue;
+ }
+ }, [getValue, isFocused]);
+
+ const saveValue = useCallback(
+ async (newValue) => {
+ // Don't save if not mounted, not unlocked, or value hasn't changed
+ if (
+ !isMounted.current ||
+ !isUnlocked ||
+ newValue === previousValue.current
+ ) {
+ return;
+ }
+
+ // For channel_number, don't save null/undefined values
+ if (
+ column.id === 'channel_number' &&
+ (newValue === null || newValue === undefined || newValue === '')
+ ) {
+ // Revert to previous value
+ setValue(previousValue.current);
+ return;
+ }
+
+ try {
+ const response = await API.updateChannel({
+ id: row.original.id,
+ [column.id]: newValue,
+ });
+ previousValue.current = newValue;
+
+ // Update the table store to reflect the change
+ if (response) {
+ useChannelsTableStore.getState().updateChannel(response);
+
+ // If channel_number was changed, refetch to reorder the table
+ if (column.id === 'channel_number') {
+ await API.requeryChannels();
+ // Exit edit mode after resorting to avoid confusion
+ setIsFocused(false);
+ }
+ }
+ } catch (error) {
+ // Revert on error
+ setValue(previousValue.current);
+ }
+ },
+ [row.original.id, column.id, isUnlocked]
+ );
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+
+ const handleChange = (newValue) => {
+ if (!isUnlocked) return;
+ setValue(newValue);
+ };
+
+ const handleBlur = () => {
+ setIsFocused(false);
+ if (isUnlocked) {
+ saveValue(value);
+ }
+ };
+
+ const handleClick = () => {
+ if (isUnlocked) {
+ setIsFocused(true);
+ }
+ };
+
+ const formattedValue =
+ value !== null && value !== undefined
+ ? value === Math.floor(value)
+ ? Math.floor(value)
+ : value
+ : '';
+
+ if (!isUnlocked || !isFocused) {
+ return (
+
+ {formattedValue}
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+// Editable select cell for groups
+export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const groupId = row.original.channel_group_id;
+ const groupName = channelGroups[groupId]?.name || '';
+ const previousGroupId = useRef(groupId);
+ const [isFocused, setIsFocused] = useState(false);
+ const [searchValue, setSearchValue] = useState('');
+
+ const saveValue = useCallback(
+ async (newGroupId) => {
+ // Don't save if not unlocked or value hasn't changed
+ if (
+ !isUnlocked ||
+ String(newGroupId) === String(previousGroupId.current)
+ ) {
+ return;
+ }
+
+ try {
+ const response = await API.updateChannel({
+ id: row.original.id,
+ channel_group_id: parseInt(newGroupId, 10),
+ });
+ previousGroupId.current = newGroupId;
+
+ // Update the table store to reflect the change
+ if (response) {
+ useChannelsTableStore.getState().updateChannel(response);
+ }
+ } catch (error) {
+ console.error('Failed to update channel group:', error);
+ }
+ },
+ [row.original.id, isUnlocked]
+ );
+
+ const handleClick = () => {
+ if (isUnlocked) {
+ setIsFocused(true);
+ }
+ };
+
+ const handleChange = (newGroupId) => {
+ saveValue(newGroupId);
+ setIsFocused(false);
+ setSearchValue('');
+ };
+
+ const groupOptions = Object.values(channelGroups).map((group) => ({
+ value: String(group.id),
+ label: group.name,
+ }));
+
+ if (!isUnlocked || !isFocused) {
+ return (
+
+ {groupName}
+
+ );
+ }
+
+ return (
+
);
diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx
index 9e43c57a..d579a41a 100644
--- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx
@@ -3,6 +3,10 @@ import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useMemo } from 'react';
import table from '../../../helpers/table';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { GripVertical } from 'lucide-react';
+import useChannelsTableStore from '../../../store/channelsTable';
const CustomTableBody = ({
getRowModel,
@@ -10,9 +14,10 @@ const CustomTableBody = ({
expandedRowRenderer,
renderBodyCell,
getExpandedRowHeight,
- getRowStyles, // Add this prop to receive row styles
+ getRowStyles,
tableBodyProps,
tableCellProps,
+ enableDragDrop = false,
}) => {
const renderExpandedRow = (row) => {
if (expandedRowRenderer) {
@@ -101,7 +106,12 @@ const CustomTableBody = ({
delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style
return (
-
+
{expandedRowIds.includes(row.original.id) && renderExpandedRow(row)}
-
+
);
};
return renderTableBodyContents();
};
+const DraggableRowWrapper = ({
+ row,
+ children,
+ style = {},
+ enableDragDrop = false,
+}) => {
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const shouldEnableDrag = enableDragDrop && isUnlocked;
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: row.id,
+ disabled: !shouldEnableDrag,
+ });
+
+ const dragStyle = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ position: 'relative',
+ ...style,
+ };
+
+ return (
+
+ {shouldEnableDrag && (
+
+
+
+ )}
+
+ {children}
+
+
+ );
+};
+
export default CustomTableBody;
diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
index 0adc516e..f4b47171 100644
--- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
@@ -2,6 +2,7 @@ import { Box, Center, Checkbox, Flex } from '@mantine/core';
import { flexRender } from '@tanstack/react-table';
import { useCallback, useMemo } from 'react';
import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper';
+import useChannelsTableStore from '../../../store/channelsTable';
const CustomTableHeader = ({
getHeaderGroups,
@@ -11,7 +12,10 @@ const CustomTableHeader = ({
onSelectAllChange,
tableCellProps,
headerPinned = true,
+ enableDragDrop = false,
}) => {
+ const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const shouldEnableDrag = enableDragDrop && isUnlocked;
const renderHeaderCell = (header) => {
let content;
@@ -95,6 +99,7 @@ const CustomTableHeader = ({
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
+ paddingLeft: shouldEnableDrag ? 28 : 0,
}}
>
{headerGroup.headers.map((header) => {
diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx
index 75e14c63..0e922456 100644
--- a/frontend/src/store/channelsTable.jsx
+++ b/frontend/src/store/channelsTable.jsx
@@ -15,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({
},
selectedChannelIds: [],
allQueryIds: [],
+ isUnlocked: false,
queryChannels: ({ results, count }, params) => {
set((state) => {
@@ -54,6 +55,18 @@ const useChannelsTableStore = create((set, get) => ({
sorting,
}));
},
+
+ setIsUnlocked: (isUnlocked) => {
+ set({ isUnlocked });
+ },
+
+ updateChannel: (updatedChannel) => {
+ set((state) => ({
+ channels: state.channels.map((channel) =>
+ channel.id === updatedChannel.id ? updatedChannel : channel
+ ),
+ }));
+ },
}));
export default useChannelsTableStore;
From 86bc0e28433be64d6e465acc0eec9fd6507496f6 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 23 Jan 2026 16:48:54 -0600
Subject: [PATCH 049/125] changelog: Update verbiage on changelog for editable
channel table and link to FR 333
---
CHANGELOG.md | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d920d41..388f2156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,11 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Editable Channel Table Mode:
- Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal.
- - EPG and logo columns now support searchable dropdowns with instant filtering and keyboard navigation for fast assignment.
- - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates.
- - Group column now uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors.
- - All editable cells provide clear focus, hover, and disabled states for improved accessibility and usability.
- - Table unlock/edit mode toggle with clear visual cues and safe-guarded save logic to prevent accidental edits.
+ - EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment.
+ - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. (Closes #333)
+ - Group column uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors.
- All changes are saved via API with optimistic UI updates and error handling.
- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include:
- Currently playing program title displayed with live broadcast indicator (green Radio icon)
From e3122b1a1bbde868557a5057e749a841fb24b7ac Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 23 Jan 2026 17:31:46 -0600
Subject: [PATCH 050/125] Bug Fix: Fix reordering during drag and drop.
---
apps/channels/api_views.py | 54 +++++++++++++++++++++++---------------
1 file changed, 33 insertions(+), 21 deletions(-)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 2b2870fa..cc98a59b 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -1277,8 +1277,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"], url_path="reorder")
def reorder(self, request, pk=None):
"""
- Reorder a channel by moving it near a target position.
- Finds the first available channel number without unnecessarily shifting other channels.
+ Reorder a channel by moving it after another channel (or to the start if insert_after_id is null).
+ Shifts other channels as needed to maintain contiguous ordering.
"""
channel = self.get_object()
insert_after_id = request.data.get("insert_after_id")
@@ -1286,39 +1286,51 @@ class ChannelViewSet(viewsets.ModelViewSet):
with transaction.atomic():
if insert_after_id is None:
- # Move to the beginning - find first available number starting from 1
- new_channel_number = 1
- # Check if 1 is taken, find first gap
- occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
- while new_channel_number in occupied:
- new_channel_number += 1
+ # Move to the beginning (channel_number = 1)
+ target_number = 0
+ desired_number = 1
else:
try:
target_channel = Channel.objects.get(id=insert_after_id)
target_number = target_channel.channel_number or 0
- desired_position = int(target_number) + 1
-
- # Get all occupied channel numbers (excluding the channel being moved)
- occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
-
- # Find the first available number at or after the desired position
- new_channel_number = desired_position
- while new_channel_number in occupied:
- new_channel_number += 1
-
+ desired_number = int(target_number) + 1
except Channel.DoesNotExist:
return Response(
{"error": "Target channel not found"},
status=status.HTTP_404_NOT_FOUND,
)
- # Update the dragged channel's number
- channel.channel_number = new_channel_number
+ if desired_number == old_channel_number:
+ # No change needed
+ return Response(
+ {
+ "message": f"Channel {channel.name} already at position {desired_number}",
+ "channel": self.get_serializer(channel).data,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ # Find the first available channel number at or after desired_number
+ occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
+ probe = desired_number
+ while probe in occupied:
+ probe += 1
+ gap_number = probe
+
+ # Shift up all channels from desired_number to gap_number-1 (inclusive)
+ # This prevents duplicates and ensures contiguous numbering
+ if gap_number > desired_number:
+ # Shift up in ascending order to avoid collisions
+ for n in range(gap_number - 1, desired_number - 1, -1):
+ Channel.objects.filter(channel_number=n).update(channel_number=n + 1)
+
+ # Set the moved channel's number
+ channel.channel_number = desired_number
channel.save(update_fields=['channel_number'])
return Response(
{
- "message": f"Channel {channel.name} moved to position {new_channel_number}",
+ "message": f"Channel {channel.name} moved to position {desired_number}",
"channel": self.get_serializer(channel).data,
},
status=status.HTTP_200_OK,
From 22ecee4f8514a08845274c0ddfede4bbda5b3f18 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 23 Jan 2026 17:56:21 -0600
Subject: [PATCH 051/125] Bug Fix: Fix No EPG filter on channels table.
---
frontend/src/components/tables/ChannelsTable.jsx | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index 9106fdcc..f9780139 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -401,6 +401,12 @@ const ChannelsTable = ({ onReady }) => {
if (hasUnlinkedChannels) {
epgOptions.unshift('No EPG');
}
+ // Map for MultiSelect: value 'null' for 'No EPG', label for display
+ const epgSelectOptions = epgOptions.map((opt) =>
+ opt === 'No EPG'
+ ? { value: 'null', label: 'No EPG' }
+ : { value: opt, label: opt }
+ );
const debouncedFilters = useDebounce(filters, 500, () => {
setPagination({
...pagination,
@@ -515,9 +521,9 @@ const ChannelsTable = ({ onReady }) => {
};
const handleEPGChange = (value) => {
- // Convert "No EPG" to null for natural filtering
+ // Map 'null' (string) back to 'null' for backend, but keep UI label correct
const processedValue = value
- ? value.map((v) => (v === 'No EPG' ? null : v))
+ ? value.map((v) => (v === 'null' ? 'null' : v))
: '';
setFilters((prev) => ({
...prev,
@@ -1001,7 +1007,7 @@ const ChannelsTable = ({ onReady }) => {
Date: Fri, 23 Jan 2026 18:10:16 -0600
Subject: [PATCH 052/125] When moving a channel down (higher number) don't
increase channel numbers, decrease.
---
apps/channels/api_views.py | 38 +++++++++++++++++++++-----------------
1 file changed, 21 insertions(+), 17 deletions(-)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index cc98a59b..5d5dc4b6 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -1310,23 +1310,27 @@ class ChannelViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
- # Find the first available channel number at or after desired_number
- occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
- probe = desired_number
- while probe in occupied:
- probe += 1
- gap_number = probe
-
- # Shift up all channels from desired_number to gap_number-1 (inclusive)
- # This prevents duplicates and ensures contiguous numbering
- if gap_number > desired_number:
- # Shift up in ascending order to avoid collisions
- for n in range(gap_number - 1, desired_number - 1, -1):
- Channel.objects.filter(channel_number=n).update(channel_number=n + 1)
-
- # Set the moved channel's number
- channel.channel_number = desired_number
- channel.save(update_fields=['channel_number'])
+ if desired_number < old_channel_number:
+ # Moving up: increment all channels between desired_number and old_channel_number-1
+ Channel.objects.filter(
+ channel_number__gte=desired_number,
+ channel_number__lt=old_channel_number
+ ).update(channel_number=F('channel_number') + 1)
+ channel.channel_number = desired_number
+ channel.save(update_fields=['channel_number'])
+ elif desired_number > old_channel_number:
+ # Moving down: shift down channels between old+1 and desired-1, then set to desired-1
+ if desired_number > old_channel_number + 1:
+ Channel.objects.filter(
+ channel_number__gt=old_channel_number,
+ channel_number__lt=desired_number
+ ).update(channel_number=F('channel_number') - 1)
+ channel.channel_number = desired_number - 1
+ channel.save(update_fields=['channel_number'])
+ else:
+ # No move or same position
+ channel.channel_number = desired_number
+ channel.save(update_fields=['channel_number'])
return Response(
{
From 557e192d4c2df053fee966be0b8f70efe951c432 Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Fri, 23 Jan 2026 19:15:27 -0600
Subject: [PATCH 053/125] Add UTF8 encoding check for external databases
Extend ensure_utf8_encoding() to support both internal and external PostgreSQL.
- Modular mode: Uses TCP connection with PGPASSWORD authentication
- AIO mode: Uses Unix socket as postgres user
- Explicitly set database owner during recreation (fixes missing --owner flag)
- Conversion logic (dump/drop/recreate/restore) works for both AIO and modular modes
- Skip internal PostgreSQL setup entirely when DISPATCHARR_ENV=modular
---
docker/entrypoint.sh | 14 ++++------
docker/init/02-postgres.sh | 54 +++++++++++++++++++++++++++++---------
2 files changed, 46 insertions(+), 22 deletions(-)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 0c17b9d0..f1515ee0 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -151,11 +151,9 @@ fi
echo "Starting user setup..."
. /app/docker/init/01-user-setup.sh
-# Initialize PostgreSQL if NOT in modular mode (using external database)
-if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
- echo "Setting up PostgreSQL..."
- . /app/docker/init/02-postgres.sh
-fi
+# Initialize PostgreSQL (script handles modular vs internal mode internally)
+echo "Setting up PostgreSQL..."
+. /app/docker/init/02-postgres.sh
echo "Starting init process..."
. /app/docker/init/03-init-dispatcharr.sh
@@ -194,10 +192,8 @@ except Exception:
echo "✅ External PostgreSQL is ready"
fi
-# Ensure database encoding is UTF8 (only for internal database)
-if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
- ensure_utf8_encoding
-fi
+# Ensure database encoding is UTF8 (handles both internal and external databases)
+ensure_utf8_encoding
if [[ "$DISPATCHARR_ENV" = "dev" ]]; then
. /app/docker/init/99-init-dev.sh
diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh
index e36dd744..a9237df0 100644
--- a/docker/init/02-postgres.sh
+++ b/docker/init/02-postgres.sh
@@ -1,4 +1,8 @@
#!/bin/bash
+
+# Skip internal PostgreSQL setup in modular mode (using external database)
+if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
+
# Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove
# some time in the future.
if [ -e "/data/postgresql.conf" ]; then
@@ -139,27 +143,51 @@ EOF
done
fi
+fi # End of DISPATCHARR_ENV != modular check
+
ensure_utf8_encoding() {
# Check encoding of existing database
- CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | tr -d ' ')
+ # Supports both internal (Unix socket) and external (TCP) PostgreSQL
+ echo "Checking database encoding..."
+
+ if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
+ # External database: use TCP connection with password
+ CURRENT_ENCODING=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();" 2>/dev/null | tr -d ' ')
+ else
+ # Internal database: use Unix socket as postgres user
+ CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();\"" | tr -d ' ')
+ fi
+
if [ "$CURRENT_ENCODING" != "UTF8" ]; then
echo "Database $POSTGRES_DB encoding is $CURRENT_ENCODING, converting to UTF8..."
DUMP_FILE="/tmp/${POSTGRES_DB}_utf8_dump_$(date +%s).sql"
- # Dump database (include permissions and ownership)
- su - postgres -c "pg_dump -p ${POSTGRES_PORT} $POSTGRES_DB > $DUMP_FILE"
- # Drop and recreate database with UTF8 encoding using template0
- su - postgres -c "dropdb -p ${POSTGRES_PORT} $POSTGRES_DB"
- # Recreate database with UTF8 encoding
- su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 ${POSTGRES_DB}"
-
-
- # Restore data
- su - postgres -c "psql -p ${POSTGRES_PORT} -d $POSTGRES_DB < $DUMP_FILE"
- #configure_db
+ if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
+ # External database: use TCP connection with password
+ # Dump database (include permissions and ownership)
+ PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" > "$DUMP_FILE" || { echo "Dump failed"; return 1; }
+ # Drop and recreate database with UTF8 encoding using template0
+ PGPASSWORD="$POSTGRES_PASSWORD" dropdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" || { echo "Drop failed"; return 1; }
+ # Recreate database with UTF8 encoding
+ PGPASSWORD="$POSTGRES_PASSWORD" createdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" --encoding=UTF8 --template=template0 "$POSTGRES_DB" || { echo "Create failed"; return 1; }
+ # Restore data
+ PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$DUMP_FILE" || { echo "Restore failed"; return 1; }
+ else
+ # Internal database: use Unix socket as postgres user
+ # Dump database (include permissions and ownership)
+ su - postgres -c "pg_dump -p ${POSTGRES_PORT} ${POSTGRES_DB}" > "$DUMP_FILE" || { echo "Dump failed"; return 1; }
+ # Drop and recreate database with UTF8 encoding using template0
+ su - postgres -c "dropdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" || { echo "Drop failed"; return 1; }
+ # Recreate database with UTF8 encoding and correct owner
+ su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 --owner=${POSTGRES_USER} ${POSTGRES_DB}" || { echo "Create failed"; return 1; }
+ # Restore data
+ cat "$DUMP_FILE" | su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" || { echo "Restore failed"; return 1; }
+ fi
rm -f "$DUMP_FILE"
- echo "Database $POSTGRES_DB converted to UTF8 and permissions set."
+ echo "✅ Database $POSTGRES_DB converted to UTF8."
+ else
+ echo "✅ Database encoding is UTF8"
fi
}
From 47b591bacb7e38d3d617ca1ecbc8c08b5050672a Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Fri, 23 Jan 2026 21:28:53 -0600
Subject: [PATCH 054/125] Add configurable POSTGRES_PORT and REDIS_PORT for
external databases
Make database ports configurable for users with external PostgreSQL/Redis.
- Add POSTGRES_PORT and REDIS_PORT to docker-compose.yml (web and celery)
- Remove hardcoded CELERY_BROKER_URL (Django builds it from components)
- Add REDIS_PORT export to entrypoint.sh and profile variables
- Add Redis connection check for modular mode (matches PostgreSQL pattern)
Users can now specify custom ports for external databases:
[defaults]
POSTGRES_PORT=5432
REDIS_PORT=6379
---
docker/docker-compose.yml | 6 ++++--
docker/entrypoint.sh | 25 ++++++++++++++++++++++++-
2 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 6673f896..9d921c2d 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -12,11 +12,12 @@ services:
environment:
- DISPATCHARR_ENV=modular
- POSTGRES_HOST=db
+ - POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- - CELERY_BROKER_URL=redis://redis:6379/0
+ - REDIS_PORT=6379
- DISPATCHARR_LOG_LEVEL=info
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
@@ -60,11 +61,12 @@ services:
environment:
- DISPATCHARR_ENV=modular
- POSTGRES_HOST=db
+ - POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- - CELERY_BROKER_URL=redis://redis:6379/0
+ - REDIS_PORT=6379
- DISPATCHARR_LOG_LEVEL=info
#- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19)
- DJANGO_SETTINGS_MODULE=dispatcharr.settings
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index f1515ee0..e6fa8ef0 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -48,6 +48,7 @@ export POSTGRES_PORT=${POSTGRES_PORT:-5432}
export PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)
export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
export REDIS_HOST=${REDIS_HOST:-localhost}
+export REDIS_PORT=${REDIS_PORT:-6379}
export REDIS_DB=${REDIS_DB:-0}
export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191}
export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri'
@@ -115,7 +116,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
- REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT
+ REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
)
@@ -192,6 +193,28 @@ except Exception:
echo "✅ External PostgreSQL is ready"
fi
+# Wait for Redis to be ready (modular mode uses external Redis)
+if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
+ echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}"
+ echo_with_timestamp "Waiting for external Redis to be ready..."
+ until python3 -c "
+import socket
+import sys
+try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(2)
+ s.connect(('${REDIS_HOST}', ${REDIS_PORT}))
+ s.close()
+ sys.exit(0)
+except Exception:
+ sys.exit(1)
+" 2>/dev/null; do
+ echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
+ sleep 1
+ done
+ echo "✅ External Redis is ready"
+fi
+
# Ensure database encoding is UTF8 (handles both internal and external databases)
ensure_utf8_encoding
From e22e003be9064bb17ff00ff98020b08e882fc379 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 25 Jan 2026 19:46:34 -0600
Subject: [PATCH 055/125] Data loading and initialization refactor: Major
performance improvement reducing initial page load time by eliminating
duplicate API requests caused by race conditions between authentication flow
and route rendering: - Fixed authentication race condition where
`isAuthenticated` was set before data loading completed, causing routes to
render and tables to mount prematurely - Added `isInitialized` flag to
delay route rendering until after all initialization data is loaded via
`initData()` - Consolidated version and environment settings fetching into
centralized settings store with caching to prevent redundant calls -
Implemented stale fetch prevention in ChannelsTable and StreamsTable using
fetch version tracking to ignore outdated responses - Fixed filter handling
in tables to use `debouncedFilters` consistently, preventing unnecessary
refetches - Added initialization guards using refs to prevent
double-execution of auth and superuser checks during React StrictMode's
intentional double-rendering in development - Removed duplicate
version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by
using centralized store
---
CHANGELOG.md | 8 +++
frontend/src/App.jsx | 36 +++++++++----
frontend/src/components/Sidebar.jsx | 42 ++-------------
frontend/src/components/forms/LoginForm.jsx | 21 ++++----
.../src/components/forms/SuperuserForm.jsx | 16 +++---
.../src/components/tables/ChannelsTable.jsx | 20 +++++--
.../src/components/tables/StreamsTable.jsx | 18 +++++++
frontend/src/store/auth.jsx | 41 +++++++++++----
frontend/src/store/settings.jsx | 52 +++++++++++++++++--
9 files changed, 167 insertions(+), 87 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 388f2156..1fd90457 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering:
+ - Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely
+ - Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()`
+ - Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls
+ - Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses
+ - Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches
+ - Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development
+ - Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store
- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence.
- Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology.
- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f22d408f..3869740e 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,4 @@
-// frontend/src/App.js
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import {
BrowserRouter as Router,
Route,
@@ -40,18 +39,25 @@ const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const isInitialized = useAuthStore((s) => s.isInitialized);
const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated);
const logout = useAuthStore((s) => s.logout);
const initData = useAuthStore((s) => s.initData);
const initializeAuth = useAuthStore((s) => s.initializeAuth);
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
+ const authCheckStarted = useRef(false);
+ const superuserCheckStarted = useRef(false);
+
const toggleDrawer = () => {
setOpen(!open);
};
// Check if a superuser exists on first load.
useEffect(() => {
+ if (superuserCheckStarted.current) return;
+ superuserCheckStarted.current = true;
+
async function checkSuperuser() {
try {
const response = await API.fetchSuperUser();
@@ -69,10 +75,13 @@ const App = () => {
}
}
checkSuperuser();
- }, []);
+ }, [setSuperuserExists]);
// Authentication check
useEffect(() => {
+ if (authCheckStarted.current) return;
+ authCheckStarted.current = true;
+
const checkAuth = async () => {
try {
const loggedIn = await initializeAuth();
@@ -105,14 +114,15 @@ const App = () => {
height: 0,
}}
navbar={{
- width: isAuthenticated
- ? open
- ? drawerWidth
- : miniDrawerWidth
- : 0,
+ width:
+ isAuthenticated && isInitialized
+ ? open
+ ? drawerWidth
+ : miniDrawerWidth
+ : 0,
}}
>
- {isAuthenticated && (
+ {isAuthenticated && isInitialized && (
{
>
- {isAuthenticated ? (
+ {isAuthenticated && isInitialized ? (
<>
} />
} />
@@ -154,7 +164,11 @@ const App = () => {
path="*"
element={
}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index d8c3fae8..a25aa301 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -1,4 +1,4 @@
-import React, { useRef, useEffect, useState } from 'react';
+import React, { useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
@@ -33,8 +33,7 @@ import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
import useSettingsStore from '../store/settings';
-import useAuthStore from '../store/auth'; // Add this import
-import API from '../api';
+import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
@@ -75,16 +74,13 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const channels = useChannelsStore((s) => s.channels);
const environment = useSettingsStore((s) => s.environment);
+ const appVersion = useSettingsStore((s) => s.version);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authUser = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const publicIPRef = useRef(null);
- const [appVersion, setAppVersion] = useState({
- version: '',
- timestamp: null,
- });
const [userFormOpen, setUserFormOpen] = useState(false);
const closeUserForm = () => setUserFormOpen(false);
@@ -144,36 +140,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
},
];
- // Fetch environment settings including version on component mount
- useEffect(() => {
- if (!isAuthenticated) {
- return;
- }
-
- const fetchEnvironment = async () => {
- API.getEnvironmentSettings();
- };
-
- fetchEnvironment();
- }, [isAuthenticated]);
-
- // Fetch version information on component mount (regardless of authentication)
- useEffect(() => {
- const fetchVersion = async () => {
- try {
- const versionData = await API.getVersion();
- setAppVersion({
- version: versionData.version || '',
- timestamp: versionData.timestamp || null,
- });
- } catch (error) {
- console.error('Failed to fetch version information:', error);
- // Keep using default values from useState initialization
- }
- };
-
- fetchVersion();
- }, []);
+ // Environment settings and version are loaded by the settings store during initData()
+ // No need to fetch them again here - just use the store values
const copyPublicIP = async () => {
const success = await copyToClipboard(environment.public_ip);
diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx
index 353cd50e..4e973891 100644
--- a/frontend/src/components/forms/LoginForm.jsx
+++ b/frontend/src/components/forms/LoginForm.jsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
-import API from '../../api';
+import useSettingsStore from '../../store/settings';
import {
Paper,
Title,
@@ -25,13 +25,14 @@ const LoginForm = () => {
const logout = useAuthStore((s) => s.logout);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const initData = useAuthStore((s) => s.initData);
+ const fetchVersion = useSettingsStore((s) => s.fetchVersion);
+ const storedVersion = useSettingsStore((s) => s.version);
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);
// Simple base64 encoding/decoding for localStorage
@@ -55,11 +56,9 @@ const LoginForm = () => {
};
useEffect(() => {
- // Fetch version info
- API.getVersion().then((data) => {
- setVersion(data?.version);
- });
- }, []);
+ // Fetch version info using the settings store (will skip if already loaded)
+ fetchVersion();
+ }, [fetchVersion]);
useEffect(() => {
// Load saved username if it exists
@@ -234,8 +233,8 @@ const LoginForm = () => {
lineHeight: '1.2',
}}
>
- ⚠ Password will be stored locally without encryption. Only
- use on trusted devices.
+ ⚠ Password will be stored locally without encryption. Only use
+ on trusted devices.
)}
@@ -252,7 +251,7 @@ const LoginForm = () => {
- {version && (
+ {storedVersion.version && (
{
right: 30,
}}
>
- v{version}
+ v{storedVersion.version}
)}
diff --git a/frontend/src/components/forms/SuperuserForm.jsx b/frontend/src/components/forms/SuperuserForm.jsx
index ca8c81fc..b5094993 100644
--- a/frontend/src/components/forms/SuperuserForm.jsx
+++ b/frontend/src/components/forms/SuperuserForm.jsx
@@ -13,6 +13,7 @@ import {
} from '@mantine/core';
import API from '../../api';
import useAuthStore from '../../store/auth';
+import useSettingsStore from '../../store/settings';
import logo from '../../assets/logo.png';
function SuperuserForm() {
@@ -22,15 +23,14 @@ function SuperuserForm() {
email: '',
});
const [error, setError] = useState('');
- const [version, setVersion] = useState(null);
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
+ const fetchVersion = useSettingsStore((s) => s.fetchVersion);
+ const storedVersion = useSettingsStore((s) => s.version);
useEffect(() => {
- // Fetch version info
- API.getVersion().then((data) => {
- setVersion(data?.version);
- });
- }, []);
+ // Fetch version info using the settings store (will skip if already loaded)
+ fetchVersion();
+ }, [fetchVersion]);
const handleChange = (e) => {
setFormData((prev) => ({
@@ -120,7 +120,7 @@ function SuperuserForm() {
- {version && (
+ {storedVersion.version && (
- v{version}
+ v{storedVersion.version}
)}
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index f9780139..962fcd7d 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -344,6 +344,7 @@ const ChannelsTable = ({ onReady }) => {
const [deleting, setDeleting] = useState(false);
const hasFetchedData = useRef(false);
+ const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
// Drag-and-drop sensors
const sensors = useSensors(
@@ -423,6 +424,9 @@ const ChannelsTable = ({ onReady }) => {
* Functions
*/
const fetchData = useCallback(async () => {
+ // Increment fetch version to track this specific fetch request
+ const currentFetchVersion = ++fetchVersionRef.current;
+
setIsLoading(true);
const params = new URLSearchParams();
@@ -447,7 +451,7 @@ const ChannelsTable = ({ onReady }) => {
}
// Apply debounced filters
- Object.entries(filters).forEach(([key, value]) => {
+ Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) {
if (Array.isArray(value)) {
// Convert null values to "null" string for URL parameter
@@ -467,6 +471,11 @@ const ChannelsTable = ({ onReady }) => {
await API.getAllChannelIds(params),
]);
+ // Skip state updates if a newer fetch has been initiated
+ if (currentFetchVersion !== fetchVersionRef.current) {
+ return;
+ }
+
setIsLoading(false);
hasFetchedData.current = true;
@@ -483,6 +492,10 @@ const ChannelsTable = ({ onReady }) => {
onReady();
}
} catch (error) {
+ // Skip state updates if a newer fetch has been initiated
+ if (currentFetchVersion !== fetchVersionRef.current) {
+ return;
+ }
setIsLoading(false);
// API layer handles "Invalid page" errors by resetting and retrying
// Just re-throw to show notification for actual errors
@@ -492,11 +505,9 @@ const ChannelsTable = ({ onReady }) => {
pagination,
sorting,
debouncedFilters,
- onReady,
showDisabled,
selectedProfileId,
showOnlyStreamlessChannels,
- tvgsLoaded,
]);
const stopPropagation = useCallback((e) => {
@@ -987,8 +998,9 @@ const ChannelsTable = ({ onReady }) => {
// the actual sizes through its own state after initialization.
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
// from the store, so we don't need to recreate columns when logos load.
+ // Note: tvgsLoaded is intentionally excluded - EditableEPGCell handles loading state internally
// eslint-disable-next-line react-hooks/exhaustive-deps
- [selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded]
+ [selectedProfileId, channelGroups, theme, tvgsById, epgs]
);
const renderHeaderCell = (header) => {
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 5b9182b2..ec8fad30 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -197,6 +197,7 @@ const StreamsTable = ({ onReady }) => {
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
+ const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
// Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
@@ -412,6 +413,9 @@ const StreamsTable = ({ onReady }) => {
const fetchData = useCallback(
async ({ showLoader = true } = {}) => {
+ // Increment fetch version to track this specific fetch request
+ const currentFetchVersion = ++fetchVersionRef.current;
+
if (showLoader) {
setIsLoading(true);
}
@@ -450,6 +454,11 @@ const StreamsTable = ({ onReady }) => {
API.getStreamFilterOptions(params),
]);
+ // Skip state updates if a newer fetch has been initiated
+ if (currentFetchVersion !== fetchVersionRef.current) {
+ return;
+ }
+
setAllRowIds(ids);
// Set filtered options based on current filters
@@ -481,9 +490,18 @@ const StreamsTable = ({ onReady }) => {
onReady();
}
} catch (error) {
+ // Skip logging if a newer fetch has been initiated
+ if (currentFetchVersion !== fetchVersionRef.current) {
+ return;
+ }
console.error('Error fetching data:', error);
}
+ // Skip state updates if a newer fetch has been initiated
+ if (currentFetchVersion !== fetchVersionRef.current) {
+ return;
+ }
+
hasFetchedOnce.current = true;
if (showLoader) {
setIsLoading(false);
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index 8fe943b7..efc531ab 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -25,6 +25,7 @@ const isTokenExpired = (expirationTime) => {
const useAuthStore = create((set, get) => ({
isAuthenticated: false,
isInitialized: false,
+ isInitializing: false,
needsSuperuser: false,
user: {
username: '',
@@ -37,17 +38,24 @@ const useAuthStore = create((set, get) => ({
setUser: (user) => set({ user }),
initData: async () => {
- const user = await API.me();
- if (user.user_level <= USER_LEVELS.STREAMER) {
- throw new Error('Unauthorized');
+ // Prevent multiple simultaneous initData calls
+ if (get().isInitializing || get().isInitialized) {
+ return;
}
- set({ user, isAuthenticated: true });
-
- // Ensure settings are loaded first
- await useSettingsStore.getState().fetchSettings();
+ set({ isInitializing: true });
try {
+ const user = await API.me();
+ if (user.user_level <= USER_LEVELS.STREAMER) {
+ throw new Error('Unauthorized');
+ }
+
+ set({ user });
+
+ // Ensure settings are loaded first
+ await useSettingsStore.getState().fetchSettings();
+
// Only after settings are loaded, fetch the essential data
await Promise.all([
useChannelsStore.getState().fetchChannels(),
@@ -64,10 +72,20 @@ const useAuthStore = create((set, get) => ({
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
+ // Only set isAuthenticated and isInitialized AFTER all data is loaded
+ // This prevents routes from rendering before data is ready
+ set({
+ isAuthenticated: true,
+ isInitialized: true,
+ isInitializing: false,
+ });
+
// Note: Logos are loaded after the Channels page tables finish loading
// This is handled by the tables themselves signaling completion
} catch (error) {
console.error('Error initializing data:', error);
+ set({ isInitializing: false });
+ throw error;
}
},
@@ -118,7 +136,7 @@ const useAuthStore = create((set, get) => ({
// Action to refresh the token
getRefreshToken: async () => {
const refreshToken = localStorage.getItem('refreshToken');
- if (!refreshToken) return false; // Add explicit return here
+ if (!refreshToken) return false;
try {
const data = await api.refreshToken(refreshToken);
@@ -126,18 +144,17 @@ const useAuthStore = create((set, get) => ({
set({
accessToken: data.access,
tokenExpiration: decodeToken(data.access),
- isAuthenticated: true,
});
localStorage.setItem('accessToken', data.access);
localStorage.setItem('tokenExpiration', decodeToken(data.access));
return data.access;
}
- return false; // Add explicit return for when data.access is not available
+ return false;
} catch (error) {
console.error('Token refresh failed:', error);
await get().logout();
- return false; // Add explicit return after error
+ return false;
}
},
@@ -156,6 +173,8 @@ const useAuthStore = create((set, get) => ({
refreshToken: null,
tokenExpiration: null,
isAuthenticated: false,
+ isInitialized: false,
+ isInitializing: false,
user: null,
});
localStorage.removeItem('accessToken');
diff --git a/frontend/src/store/settings.jsx b/frontend/src/store/settings.jsx
index 99390320..d7d46a3f 100644
--- a/frontend/src/store/settings.jsx
+++ b/frontend/src/store/settings.jsx
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import api from '../api';
-const useSettingsStore = create((set) => ({
+const useSettingsStore = create((set, get) => ({
settings: {},
environment: {
// Add default values for environment settings
@@ -10,15 +10,27 @@ const useSettingsStore = create((set) => ({
country_name: '',
env_mode: 'prod',
},
+ version: {
+ version: '',
+ timestamp: null,
+ },
isLoading: false,
error: null,
fetchSettings: async () => {
set({ isLoading: true, error: null });
try {
- const settings = await api.getSettings();
- const env = await api.getEnvironmentSettings();
- set({
+ // Only fetch version if not already loaded (may have been fetched by Login/Superuser form)
+ const currentVersion = get().version;
+ const needsVersion = !currentVersion.version;
+
+ const [settings, env, versionData] = await Promise.all([
+ api.getSettings(),
+ api.getEnvironmentSettings(),
+ needsVersion ? api.getVersion() : Promise.resolve(null),
+ ]);
+
+ const newState = {
settings: settings.reduce((acc, setting) => {
acc[setting.key] = setting;
return acc;
@@ -30,12 +42,42 @@ const useSettingsStore = create((set) => ({
country_name: '',
env_mode: 'prod',
},
- });
+ };
+
+ // Only update version if we fetched it
+ if (versionData) {
+ newState.version = {
+ version: versionData?.version || '',
+ timestamp: versionData?.timestamp || null,
+ };
+ }
+
+ set(newState);
} catch (error) {
set({ error: 'Failed to load settings.', isLoading: false });
}
},
+ // Fetch version independently (for unauthenticated pages like Login)
+ fetchVersion: async () => {
+ // Skip if already loaded
+ if (get().version.version) {
+ return get().version;
+ }
+ try {
+ const versionData = await api.getVersion();
+ const version = {
+ version: versionData?.version || '',
+ timestamp: versionData?.timestamp || null,
+ };
+ set({ version });
+ return version;
+ } catch (error) {
+ console.error('Failed to fetch version:', error);
+ return get().version;
+ }
+ },
+
updateSetting: (setting) =>
set((state) => ({
settings: { ...state.settings, [setting.key]: setting },
From 9440bbe2ab7e8d4a5b7c3859c7db85542900c58b Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 25 Jan 2026 20:56:07 -0600
Subject: [PATCH 056/125] Performance: Removed unnecessary double wait that was
causing fetching of channel data to not run in parallel.
---
frontend/src/components/tables/ChannelsTable.jsx | 4 ++--
frontend/src/utils.js | 16 ++++++++++++++--
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index f9780139..45d46f17 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -463,8 +463,8 @@ const ChannelsTable = ({ onReady }) => {
try {
const [results, ids] = await Promise.all([
- await API.queryChannels(params),
- await API.getAllChannelIds(params),
+ API.queryChannels(params),
+ API.getAllChannelIds(params),
]);
setIsLoading(false);
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index 81836f0a..bb1b6060 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
export default {
Limiter: (n, list) => {
@@ -40,13 +40,25 @@ export default {
// Custom debounce hook
export function useDebounce(value, delay = 500, callback = null) {
const [debouncedValue, setDebouncedValue] = useState(value);
+ const isFirstRender = useRef(true);
+ const previousValueRef = useRef(JSON.stringify(value));
useEffect(() => {
+ const currentValueStr = JSON.stringify(value);
+
+ // Skip if value hasn't actually changed (prevents unnecessary state updates)
+ if (previousValueRef.current === currentValueStr) {
+ return;
+ }
+
const handler = setTimeout(() => {
setDebouncedValue(value);
- if (callback) {
+ // Only fire callback if not the first render
+ if (callback && !isFirstRender.current) {
callback();
}
+ isFirstRender.current = false;
+ previousValueRef.current = currentValueStr;
}, delay);
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change
From 716dc25c52e6a3c977808011b08e697a925dd8a8 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 25 Jan 2026 21:14:44 -0600
Subject: [PATCH 057/125] Enhancement: Prevent duplicate fetch requests in
ChannelsTable and StreamsTable by tracking last fetch parameters and
in-progress status
---
.../src/components/tables/ChannelsTable.jsx | 29 ++++++++++++++---
.../src/components/tables/StreamsTable.jsx | 32 +++++++++++++++----
2 files changed, 49 insertions(+), 12 deletions(-)
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index 86711124..3d27ca61 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -345,6 +345,8 @@ const ChannelsTable = ({ onReady }) => {
const hasFetchedData = useRef(false);
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
+ const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
+ const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
// Drag-and-drop sensors
const sensors = useSensors(
@@ -424,11 +426,7 @@ const ChannelsTable = ({ onReady }) => {
* Functions
*/
const fetchData = useCallback(async () => {
- // Increment fetch version to track this specific fetch request
- const currentFetchVersion = ++fetchVersionRef.current;
-
- setIsLoading(true);
-
+ // Build params first to check for duplicates
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
@@ -465,12 +463,31 @@ const ChannelsTable = ({ onReady }) => {
}
});
+ const paramsString = params.toString();
+
+ // Skip if same fetch is already in progress (prevents StrictMode double-fetch)
+ if (
+ fetchInProgressRef.current &&
+ lastFetchParamsRef.current === paramsString
+ ) {
+ return;
+ }
+
+ // Increment fetch version to track this specific fetch request
+ const currentFetchVersion = ++fetchVersionRef.current;
+ lastFetchParamsRef.current = paramsString;
+ fetchInProgressRef.current = true;
+
+ setIsLoading(true);
+
try {
const [results, ids] = await Promise.all([
API.queryChannels(params),
API.getAllChannelIds(params),
]);
+ fetchInProgressRef.current = false;
+
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
@@ -492,6 +509,8 @@ const ChannelsTable = ({ onReady }) => {
onReady();
}
} catch (error) {
+ fetchInProgressRef.current = false;
+
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index ec8fad30..00741d46 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -198,6 +198,8 @@ const StreamsTable = ({ onReady }) => {
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates
+ const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests
+ const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress
// Channel creation modal state (bulk)
const [channelNumberingModalOpen, setChannelNumberingModalOpen] =
@@ -413,13 +415,6 @@ const StreamsTable = ({ onReady }) => {
const fetchData = useCallback(
async ({ showLoader = true } = {}) => {
- // Increment fetch version to track this specific fetch request
- const currentFetchVersion = ++fetchVersionRef.current;
-
- if (showLoader) {
- setIsLoading(true);
- }
-
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
@@ -447,6 +442,25 @@ const StreamsTable = ({ onReady }) => {
}
});
+ const paramsString = params.toString();
+
+ // Skip if same fetch is already in progress (prevents StrictMode double-fetch)
+ if (
+ fetchInProgressRef.current &&
+ lastFetchParamsRef.current === paramsString
+ ) {
+ return;
+ }
+
+ // Increment fetch version to track this specific fetch request
+ const currentFetchVersion = ++fetchVersionRef.current;
+ lastFetchParamsRef.current = paramsString;
+ fetchInProgressRef.current = true;
+
+ if (showLoader) {
+ setIsLoading(true);
+ }
+
try {
const [result, ids, filterOptions] = await Promise.all([
API.queryStreamsTable(params),
@@ -454,6 +468,8 @@ const StreamsTable = ({ onReady }) => {
API.getStreamFilterOptions(params),
]);
+ fetchInProgressRef.current = false;
+
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
@@ -490,6 +506,8 @@ const StreamsTable = ({ onReady }) => {
onReady();
}
} catch (error) {
+ fetchInProgressRef.current = false;
+
// Skip logging if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
From 8d69fd93743f2524ccffeba27712f2434c3b21d4 Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Sun, 25 Jan 2026 22:05:28 -0600
Subject: [PATCH 058/125] Add configurable EPG matching normalization settings
(Feature #771)
Implements user-configurable string removal for EPG matching to improve channel-to-EPG
association accuracy. Settings are non-destructive and only affect EPG matching
normalization, never modifying actual channel display names.
---
apps/channels/tasks.py | 66 +++++++++++-
core/models.py | 23 ++++
.../forms/settings/EPGSettingsForm.jsx | 102 ++++++++++++++++++
frontend/src/pages/Settings.jsx | 14 +++
.../forms/settings/EPGSettingsFormUtils.js | 11 ++
frontend/src/utils/pages/SettingsUtils.js | 29 ++++-
6 files changed, 242 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/components/forms/settings/EPGSettingsForm.jsx
create mode 100644 frontend/src/utils/forms/settings/EPGSettingsFormUtils.js
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index b9983b87..cd597ca3 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -139,6 +139,7 @@ COMMON_EXTRANEOUS_WORDS = [
def normalize_name(name: str) -> str:
"""
A more aggressive normalization that:
+ - Removes user-configured prefixes/suffixes/custom strings (if configured)
- Lowercases
- Removes bracketed/parenthesized text
- Removes punctuation
@@ -148,7 +149,70 @@ def normalize_name(name: str) -> str:
if not name:
return ""
- norm = name.lower()
+ # Load user-configured EPG matching rules (fail gracefully)
+ prefixes = []
+ suffixes = []
+ custom_strings = []
+
+ try:
+ from core.models import CoreSettings
+ settings = CoreSettings.get_epg_settings()
+ prefixes = settings.get("epg_match_ignore_prefixes", [])
+ suffixes = settings.get("epg_match_ignore_suffixes", [])
+ custom_strings = settings.get("epg_match_ignore_custom", [])
+
+ # Ensure we have lists (defensive)
+ if not isinstance(prefixes, list):
+ prefixes = []
+ if not isinstance(suffixes, list):
+ suffixes = []
+ if not isinstance(custom_strings, list):
+ custom_strings = []
+
+ except Exception as e:
+ # Settings unavailable or error - continue with empty lists (graceful degradation)
+ logger.debug(f"Could not load EPG matching settings: {e}")
+ prefixes = []
+ suffixes = []
+ custom_strings = []
+
+ result = name
+
+ # Step 1: Remove prefixes (from START only - exact string match)
+ for prefix in prefixes:
+ # Skip empty or non-string entries
+ if not prefix or not isinstance(prefix, str):
+ continue
+ # Exact match at start
+ if result.startswith(prefix):
+ result = result[len(prefix):]
+ break # Only remove first matching prefix
+
+ # Step 2: Remove suffixes (from END only - exact string match)
+ for suffix in suffixes:
+ # Skip empty or non-string entries
+ if not suffix or not isinstance(suffix, str):
+ continue
+ # Exact match at end
+ if result.endswith(suffix):
+ result = result[:-len(suffix)]
+ break # Only remove first matching suffix
+
+ # Step 3: Remove custom strings (from ANYWHERE - exact string match)
+ for custom in custom_strings:
+ # Skip empty or non-string entries
+ if not custom or not isinstance(custom, str):
+ continue
+ try:
+ # Exact string removal (replace with empty string)
+ result = result.replace(custom, "")
+ except Exception as e:
+ # If removal fails for any reason, skip this entry
+ logger.debug(f"Failed to remove custom string '{custom}': {e}")
+ continue
+
+ # Step 4: Existing normalization logic (unchanged)
+ norm = result.lower()
norm = re.sub(r"\[.*?\]", "", norm)
# Extract and preserve important call signs from parentheses before removing them
diff --git a/core/models.py b/core/models.py
index 683acb0d..1037a6e3 100644
--- a/core/models.py
+++ b/core/models.py
@@ -155,6 +155,7 @@ BACKUP_SETTINGS_KEY = "backup_settings"
PROXY_SETTINGS_KEY = "proxy_settings"
NETWORK_ACCESS_KEY = "network_access"
SYSTEM_SETTINGS_KEY = "system_settings"
+EPG_SETTINGS_KEY = "epg_settings"
class CoreSettings(models.Model):
@@ -227,6 +228,28 @@ class CoreSettings(models.Model):
def get_auto_import_mapped_files(cls):
return cls.get_stream_settings().get("auto_import_mapped_files")
+ # EPG Settings
+ @classmethod
+ def get_epg_settings(cls):
+ """Get all EPG-related settings."""
+ return cls._get_group(EPG_SETTINGS_KEY, {
+ "epg_match_ignore_prefixes": [],
+ "epg_match_ignore_suffixes": [],
+ "epg_match_ignore_custom": [],
+ })
+
+ @classmethod
+ def get_epg_match_ignore_prefixes(cls):
+ return cls.get_epg_settings().get("epg_match_ignore_prefixes", [])
+
+ @classmethod
+ def get_epg_match_ignore_suffixes(cls):
+ return cls.get_epg_settings().get("epg_match_ignore_suffixes", [])
+
+ @classmethod
+ def get_epg_match_ignore_custom(cls):
+ return cls.get_epg_settings().get("epg_match_ignore_custom", [])
+
# DVR Settings
@classmethod
def get_dvr_settings(cls):
diff --git a/frontend/src/components/forms/settings/EPGSettingsForm.jsx b/frontend/src/components/forms/settings/EPGSettingsForm.jsx
new file mode 100644
index 00000000..7cfcc513
--- /dev/null
+++ b/frontend/src/components/forms/settings/EPGSettingsForm.jsx
@@ -0,0 +1,102 @@
+import useSettingsStore from '../../../store/settings.jsx';
+import React, { useEffect, useState } from 'react';
+import {
+ getChangedSettings,
+ parseSettings,
+ saveChangedSettings,
+} from '../../../utils/pages/SettingsUtils.js';
+import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { getEPGSettingsFormInitialValues } from '../../../utils/forms/settings/EPGSettingsFormUtils.js';
+
+const EPGSettingsForm = React.memo(({ active }) => {
+ const settings = useSettingsStore((s) => s.settings);
+
+ const [saved, setSaved] = useState(false);
+
+ const form = useForm({
+ mode: 'controlled',
+ initialValues: getEPGSettingsFormInitialValues(),
+ });
+
+ useEffect(() => {
+ if (!active) setSaved(false);
+ }, [active]);
+
+ useEffect(() => {
+ if (settings) {
+ const formValues = parseSettings(settings);
+
+ form.setValues(formValues);
+ }
+ }, [settings]);
+
+ const onSubmit = async () => {
+ setSaved(false);
+
+ const changedSettings = getChangedSettings(form.getValues(), settings);
+
+ // Update each changed setting in the backend (create if missing)
+ try {
+ await saveChangedSettings(settings, changedSettings);
+
+ setSaved(true);
+ } catch (error) {
+ // Error notifications are already shown by API functions
+ // Just don't show the success message
+ console.error('Error saving settings:', error);
+ }
+ };
+
+ return (
+
+ {saved && (
+
+ )}
+
+ Configure how channel names are normalized during EPG matching.
+ These settings help channels with different names match the same EPG data.
+ Channel display names are never modified.
+
+
+
+
+
+
+
+
+
+
+ Save
+
+
+
+ );
+});
+
+export default EPGSettingsForm;
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 4ce519a3..bbe4631f 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -25,6 +25,8 @@ const ProxySettingsForm = React.lazy(() =>
import('../components/forms/settings/ProxySettingsForm.jsx'));
const StreamSettingsForm = React.lazy(() =>
import('../components/forms/settings/StreamSettingsForm.jsx'));
+const EPGSettingsForm = React.lazy(() =>
+ import('../components/forms/settings/EPGSettingsForm.jsx'));
const DvrSettingsForm = React.lazy(() =>
import('../components/forms/settings/DvrSettingsForm.jsx'));
const SystemSettingsForm = React.lazy(() =>
@@ -78,6 +80,18 @@ const SettingsPage = () => {
+
+ EPG Settings
+
+
+ }>
+
+
+
+
+
+
System Settings
diff --git a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js
new file mode 100644
index 00000000..7babbbf9
--- /dev/null
+++ b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js
@@ -0,0 +1,11 @@
+export const getEPGSettingsFormInitialValues = () => {
+ return {
+ epg_match_ignore_prefixes: [],
+ epg_match_ignore_suffixes: [],
+ epg_match_ignore_custom: [],
+ };
+};
+
+export const getEPGSettingsFormValidation = () => {
+ return {};
+};
diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js
index 6ee12f60..9cd4b2c8 100644
--- a/frontend/src/utils/pages/SettingsUtils.js
+++ b/frontend/src/utils/pages/SettingsUtils.js
@@ -20,6 +20,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
// Group changes by their setting group based on field name prefixes
const groupedChanges = {
stream_settings: {},
+ epg_settings: {},
dvr_settings: {},
backup_settings: {},
system_settings: {},
@@ -27,6 +28,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
// Map of field prefixes to their groups
const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
+ const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
@@ -58,6 +60,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
}
// Type conversions for proper storage
+ // EPG fields should remain as arrays, don't convert them
if (formKey === 'm3u_hash_key' && Array.isArray(value)) {
value = value.join(',');
}
@@ -79,6 +82,8 @@ export const saveChangedSettings = async (settings, changedSettings) => {
// Route to appropriate group
if (streamFields.includes(formKey)) {
groupedChanges.stream_settings[formKey] = value;
+ } else if (epgFields.includes(formKey)) {
+ groupedChanges.epg_settings[formKey] = value;
} else if (dvrFields.includes(formKey)) {
groupedChanges.dvr_settings[formKey] = value;
} else if (backupFields.includes(formKey)) {
@@ -114,6 +119,9 @@ export const saveChangedSettings = async (settings, changedSettings) => {
export const getChangedSettings = (values, settings) => {
const changedSettings = {};
+ // EPG fields that should be kept as arrays
+ const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
+
for (const settingKey in values) {
// Skip grouped settings that are handled by their own dedicated forms
if (settingKey === 'proxy_settings' || settingKey === 'network_access') {
@@ -123,10 +131,20 @@ export const getChangedSettings = (values, settings) => {
// Only compare against existing value if the setting exists
const existing = settings[settingKey];
- // Convert array values (like m3u_hash_key) to comma-separated strings for comparison
- let compareValue;
let actualValue = values[settingKey];
+ let compareValue;
+ // Handle EPG fields specially - keep as arrays, don't skip empty arrays
+ if (epgFields.includes(settingKey)) {
+ if (!Array.isArray(actualValue)) {
+ actualValue = [];
+ }
+ // Always include EPG fields in changes (even if empty)
+ changedSettings[settingKey] = actualValue;
+ continue;
+ }
+
+ // Convert array values (like m3u_hash_key) to comma-separated strings for comparison
if (Array.isArray(actualValue)) {
actualValue = actualValue.join(',');
compareValue = actualValue;
@@ -173,6 +191,13 @@ export const parseSettings = (settings) => {
}
}
+ // EPG settings - direct mapping with underscore keys
+ const epgSettings = settings['epg_settings']?.value;
+ // Always set EPG fields (even if settings don't exist yet)
+ parsed.epg_match_ignore_prefixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)) ? epgSettings.epg_match_ignore_prefixes : [];
+ parsed.epg_match_ignore_suffixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)) ? epgSettings.epg_match_ignore_suffixes : [];
+ parsed.epg_match_ignore_custom = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)) ? epgSettings.epg_match_ignore_custom : [];
+
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;
if (dvrSettings && typeof dvrSettings === 'object') {
From a1fa737ccdbc260e06e51cf98499eae29d533c77 Mon Sep 17 00:00:00 2001
From: Matt Grutza
Date: Sun, 25 Jan 2026 22:08:32 -0600
Subject: [PATCH 059/125] Update verbiage
---
apps/channels/tasks.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index cd597ca3..8a0dffe1 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -161,7 +161,7 @@ def normalize_name(name: str) -> str:
suffixes = settings.get("epg_match_ignore_suffixes", [])
custom_strings = settings.get("epg_match_ignore_custom", [])
- # Ensure we have lists (defensive)
+ # Ensure we have lists
if not isinstance(prefixes, list):
prefixes = []
if not isinstance(suffixes, list):
From e2a915f10ba34a66c6ddb36bdffa71a41c33733b Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Mon, 26 Jan 2026 16:19:07 -0600
Subject: [PATCH 060/125] Perf: lazy load editable cells on focus, not on
unlock. Also don't wait for all channels to load before logging in.
---
.../src/components/tables/ChannelsTable.jsx | 20 +-
.../tables/ChannelsTable/EditableCell.jsx | 561 ++++++++++--------
frontend/src/store/auth.jsx | 10 +-
3 files changed, 320 insertions(+), 271 deletions(-)
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index 3d27ca61..f41ca72d 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -253,7 +253,7 @@ const ChannelsTable = ({ onReady }) => {
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
// Get channel logos for logo selection
- const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection();
+ const { ensureLogosLoaded } = useChannelLogoSelection();
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
@@ -977,19 +977,11 @@ const ChannelsTable = ({ onReady }) => {
enableResizing: false,
header: '',
cell: (props) => (
- {
- // Ensure logos are loaded when user tries to edit
- ensureLogosLoaded();
- }}
- style={{ width: '100%', height: '100%' }}
- >
-
-
+
),
},
{
diff --git a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
index 065e4631..42d99e73 100644
--- a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
+++ b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
@@ -4,6 +4,7 @@ import React, {
useEffect,
useRef,
useMemo,
+ memo,
} from 'react';
import {
Box,
@@ -16,33 +17,88 @@ import {
} from '@mantine/core';
import API from '../../../api';
import useChannelsTableStore from '../../../store/channelsTable';
+import useLogosStore from '../../../store/logos';
+
+// Lightweight wrapper that only renders full editable cell when unlocked
+// This prevents 250+ heavy component instances when table is locked
+const EditableCellWrapper = memo(
+ ({ children, getValue, isUnlocked, renderLocked }) => {
+ if (!isUnlocked) {
+ // Render lightweight locked view
+ return renderLocked ? (
+ renderLocked(getValue())
+ ) : (
+
+ {getValue() ?? ''}
+
+ );
+ }
+ // Only render heavy component when unlocked
+ return children;
+ }
+);
// Editable text cell
export const EditableTextCell = ({ row, column, getValue }) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const [isFocused, setIsFocused] = useState(false);
+
+ // When locked or not focused, show simple display
+ if (!isUnlocked || !isFocused) {
+ return (
+ isUnlocked && setIsFocused(true)}
+ style={{
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ cursor: isUnlocked ? 'text' : 'default',
+ padding: '0 4px',
+ }}
+ >
+ {getValue() ?? ''}
+
+ );
+ }
+
+ // Only mount heavy component when actually editing
+ return (
+ setIsFocused(false)}
+ />
+ );
+};
+
+// Inner component with all the editing logic - only rendered when focused
+const EditableTextCellInner = ({ row, column, getValue, onBlur }) => {
const initialValue = getValue() || '';
const [value, setValue] = useState(initialValue);
- const [isFocused, setIsFocused] = useState(false);
const previousValue = useRef(initialValue);
const isMounted = useRef(false);
const debounceTimer = useRef(null);
useEffect(() => {
const currentValue = getValue() || '';
- if (!isFocused && currentValue !== previousValue.current) {
+ if (currentValue !== previousValue.current) {
setValue(currentValue);
previousValue.current = currentValue;
}
- }, [getValue, isFocused]);
+ }, [getValue]);
const saveValue = useCallback(
async (newValue) => {
- // Don't save if not mounted, not unlocked, or value hasn't changed
- if (
- !isMounted.current ||
- !isUnlocked ||
- newValue === previousValue.current
- ) {
+ // Don't save if not mounted or value hasn't changed
+ if (!isMounted.current || newValue === previousValue.current) {
return;
}
@@ -62,7 +118,7 @@ export const EditableTextCell = ({ row, column, getValue }) => {
setValue(previousValue.current || '');
}
},
- [row.original.id, column.id, isUnlocked]
+ [row.original.id, column.id]
);
useEffect(() => {
@@ -77,7 +133,6 @@ export const EditableTextCell = ({ row, column, getValue }) => {
}, []);
const handleChange = (e) => {
- if (!isUnlocked) return;
const newValue = e.currentTarget.value;
setValue(newValue);
@@ -93,40 +148,10 @@ export const EditableTextCell = ({ row, column, getValue }) => {
};
const handleBlur = () => {
- setIsFocused(false);
- if (isUnlocked) {
- saveValue(value);
- }
+ saveValue(value);
+ onBlur();
};
- const handleClick = () => {
- if (isUnlocked) {
- setIsFocused(true);
- }
- };
-
- if (!isUnlocked || !isFocused) {
- return (
-
- {value}
-
- );
- }
-
return (
{
// Editable number cell
export const EditableNumberCell = ({ row, column, getValue }) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const [isFocused, setIsFocused] = useState(false);
+
+ const value = getValue();
+ const formattedValue =
+ value !== null && value !== undefined
+ ? value === Math.floor(value)
+ ? Math.floor(value)
+ : value
+ : '';
+
+ // When locked or not focused, show simple display
+ if (!isUnlocked || !isFocused) {
+ return (
+ isUnlocked && setIsFocused(true)}
+ style={{
+ textAlign: 'right',
+ width: '100%',
+ cursor: isUnlocked ? 'text' : 'default',
+ padding: '0 4px',
+ }}
+ >
+ {formattedValue}
+
+ );
+ }
+
+ return (
+ setIsFocused(false)}
+ />
+ );
+};
+
+// Inner component with all the editing logic - only rendered when focused
+const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => {
const initialValue = getValue();
const [value, setValue] = useState(initialValue);
- const [isFocused, setIsFocused] = useState(false);
const previousValue = useRef(initialValue);
const isMounted = useRef(false);
useEffect(() => {
const currentValue = getValue();
- if (!isFocused && currentValue !== previousValue.current) {
+ if (currentValue !== previousValue.current) {
setValue(currentValue);
previousValue.current = currentValue;
}
- }, [getValue, isFocused]);
+ }, [getValue]);
const saveValue = useCallback(
async (newValue) => {
- // Don't save if not mounted, not unlocked, or value hasn't changed
- if (
- !isMounted.current ||
- !isUnlocked ||
- newValue === previousValue.current
- ) {
+ // Don't save if not mounted or value hasn't changed
+ if (!isMounted.current || newValue === previousValue.current) {
return;
}
@@ -203,8 +262,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => {
// If channel_number was changed, refetch to reorder the table
if (column.id === 'channel_number') {
await API.requeryChannels();
- // Exit edit mode after resorting to avoid confusion
- setIsFocused(false);
+ onBlur();
}
}
} catch (error) {
@@ -212,7 +270,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => {
setValue(previousValue.current);
}
},
- [row.original.id, column.id, isUnlocked]
+ [row.original.id, column.id, onBlur]
);
useEffect(() => {
@@ -223,51 +281,14 @@ export const EditableNumberCell = ({ row, column, getValue }) => {
}, []);
const handleChange = (newValue) => {
- if (!isUnlocked) return;
setValue(newValue);
};
const handleBlur = () => {
- setIsFocused(false);
- if (isUnlocked) {
- saveValue(value);
- }
+ saveValue(value);
+ onBlur();
};
- const handleClick = () => {
- if (isUnlocked) {
- setIsFocused(true);
- }
- };
-
- const formattedValue =
- value !== null && value !== undefined
- ? value === Math.floor(value)
- ? Math.floor(value)
- : value
- : '';
-
- if (!isUnlocked || !isFocused) {
- return (
-
- {formattedValue}
-
- );
- }
-
return (
{
};
// Editable select cell for groups
-export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
+export const EditableGroupCell = ({ row, channelGroups }) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
+ const [isFocused, setIsFocused] = useState(false);
const groupId = row.original.channel_group_id;
const groupName = channelGroups[groupId]?.name || '';
+
+ // Show simple display when locked OR when unlocked but not focused
+ if (!isUnlocked || !isFocused) {
+ return (
+ isUnlocked && setIsFocused(true)}
+ style={{
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ padding: '0 4px',
+ cursor: isUnlocked ? 'pointer' : 'default',
+ }}
+ >
+ {groupName}
+
+ );
+ }
+
+ return (
+ setIsFocused(false)}
+ />
+ );
+};
+
+// Inner component with all the editing logic - only rendered when focused
+const EditableGroupCellInner = ({
+ row,
+ channelGroups,
+ groupName,
+ groupId,
+ onBlur,
+}) => {
const previousGroupId = useRef(groupId);
- const [isFocused, setIsFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const saveValue = useCallback(
async (newGroupId) => {
- // Don't save if not unlocked or value hasn't changed
- if (
- !isUnlocked ||
- String(newGroupId) === String(previousGroupId.current)
- ) {
+ // Don't save if value hasn't changed
+ if (String(newGroupId) === String(previousGroupId.current)) {
return;
}
@@ -324,18 +380,12 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
console.error('Failed to update channel group:', error);
}
},
- [row.original.id, isUnlocked]
+ [row.original.id]
);
- const handleClick = () => {
- if (isUnlocked) {
- setIsFocused(true);
- }
- };
-
const handleChange = (newGroupId) => {
saveValue(newGroupId);
- setIsFocused(false);
+ onBlur();
setSearchValue('');
};
@@ -344,33 +394,11 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
label: group.name,
}));
- if (!isUnlocked || !isFocused) {
- return (
-
- {groupName}
-
- );
- }
-
return (
setIsFocused(false)}
+ onBlur={onBlur}
data={groupOptions}
size="xs"
variant="unstyled"
@@ -401,12 +429,10 @@ export const EditableEPGCell = ({
tvgsLoaded,
}) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
- const epgDataId = getValue();
- const previousEpgDataId = useRef(epgDataId);
const [isFocused, setIsFocused] = useState(false);
- const [searchValue, setSearchValue] = useState('');
+ const epgDataId = getValue();
- // Format display text
+ // Format display text - needed for both locked and unlocked states
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
const tvgId = epgObj?.tvg_id;
const epgName =
@@ -423,13 +449,90 @@ export const EditableEPGCell = ({
// Show skeleton while EPG data is loading (only if channel has an EPG assignment)
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
+ // Build tooltip content
+ const tooltip = epgObj
+ ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
+ : '';
+
+ // Show simple display when locked OR when unlocked but not focused
+ if (!isUnlocked || !isFocused) {
+ // If loading EPG data, show skeleton
+ if (isEpgDataPending) {
+ return (
+ isUnlocked && setIsFocused(true)}
+ style={{
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ padding: '0 4px',
+ cursor: isUnlocked ? 'pointer' : 'default',
+ }}
+ >
+
+
+ );
+ }
+ return (
+ {tooltip}}
+ withArrow
+ position="top"
+ disabled={!epgObj}
+ openDelay={500}
+ >
+ isUnlocked && setIsFocused(true)}
+ style={{
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ padding: '0 4px',
+ cursor: isUnlocked ? 'pointer' : 'default',
+ }}
+ >
+ {displayText}
+
+
+ );
+ }
+
+ return (
+ setIsFocused(false)}
+ />
+ );
+};
+
+// Inner component with all the editing logic - only rendered when focused
+const EditableEPGCellInner = ({
+ row,
+ tvgsById,
+ epgs,
+ epgDataId,
+ displayText,
+ onBlur,
+}) => {
+ const previousEpgDataId = useRef(epgDataId);
+ const [searchValue, setSearchValue] = useState('');
+
const saveValue = useCallback(
async (newEpgDataId) => {
- // Don't save if not unlocked or value hasn't changed
- if (
- !isUnlocked ||
- String(newEpgDataId) === String(previousEpgDataId.current)
- ) {
+ // Don't save if value hasn't changed
+ if (String(newEpgDataId) === String(previousEpgDataId.current)) {
return;
}
@@ -449,20 +552,13 @@ export const EditableEPGCell = ({
console.error('Failed to update EPG:', error);
}
},
- [row.original.id, isUnlocked]
+ [row.original.id]
);
- const handleClick = () => {
- if (isUnlocked) {
- setSearchValue(''); // Start with empty search
- setIsFocused(true);
- }
- };
-
const handleChange = (newEpgDataId) => {
saveValue(newEpgDataId);
setSearchValue('');
- setIsFocused(false);
+ onBlur();
};
// Build EPG options
@@ -514,69 +610,11 @@ export const EditableEPGCell = ({
return options;
}, [tvgsById, epgs]);
- // Build tooltip content
- const tooltip = epgObj
- ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
- : '';
-
- if (!isUnlocked || !isFocused) {
- // If loading EPG data, show skeleton
- if (isEpgDataPending) {
- return (
-
-
-
- );
- }
- // Otherwise, show the normal EPG assignment cell
- return (
- {tooltip}}
- withArrow
- position="top"
- disabled={!epgObj}
- openDelay={500}
- >
-
- {displayText}
-
-
- );
- }
-
return (
setIsFocused(false)}
+ onBlur={onBlur}
data={epgOptions}
size="xs"
variant="unstyled"
@@ -599,17 +637,69 @@ export const EditableEPGCell = ({
};
// Editable cell for Logo selection
-export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
+export const EditableLogoCell = ({
+ row,
+ getValue,
+ LazyLogo,
+ ensureLogosLoaded,
+}) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
- const logoId = getValue();
- const previousLogoId = useRef(logoId);
const [isFocused, setIsFocused] = useState(false);
+ const logoId = getValue();
+
+ const handleClick = () => {
+ if (isUnlocked) {
+ // Ensure logos are loaded when user tries to edit
+ ensureLogosLoaded?.();
+ setIsFocused(true);
+ }
+ };
+
+ // Show simple display when locked OR when unlocked but not focused
+ if (!isUnlocked || !isFocused) {
+ return (
+
+ {LazyLogo && (
+
+ )}
+
+ );
+ }
+
+ return (
+ setIsFocused(false)}
+ />
+ );
+};
+
+// Inner component with all the editing logic - only rendered when focused
+const EditableLogoCellInner = ({ row, logoId, onBlur }) => {
+ // Subscribe directly to the logos store so we get updates when logos load
+ const channelLogos = useLogosStore((s) => s.channelLogos);
+ const previousLogoId = useRef(logoId);
const [searchValue, setSearchValue] = useState('');
const saveValue = useCallback(
async (newLogoId) => {
- // Don't save if not unlocked or value hasn't changed
- if (!isUnlocked || String(newLogoId) === String(previousLogoId.current)) {
+ // Don't save if value hasn't changed
+ if (String(newLogoId) === String(previousLogoId.current)) {
return;
}
@@ -628,20 +718,13 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
console.error('Failed to update logo:', error);
}
},
- [row.original.id, isUnlocked]
+ [row.original.id]
);
- const handleClick = () => {
- if (isUnlocked) {
- setSearchValue('');
- setIsFocused(true);
- }
- };
-
const handleChange = (newLogoId) => {
saveValue(newLogoId);
setSearchValue('');
- setIsFocused(false);
+ onBlur();
};
// Build logo options with logo data
@@ -706,36 +789,6 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
);
};
- if (!isUnlocked || !isFocused) {
- // When not editing, show the logo image
- return (
-
- {LazyLogo && (
-
- )}
-
- );
- }
-
return (
{
setIsFocused(false)}
+ onBlur={onBlur}
data={logoOptions}
size="xs"
variant="unstyled"
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index efc531ab..2a002d52 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -56,9 +56,10 @@ const useAuthStore = create((set, get) => ({
// Ensure settings are loaded first
await useSettingsStore.getState().fetchSettings();
- // Only after settings are loaded, fetch the essential data
+ // Fetch essential data needed for initial render
+ // Note: fetchChannels() is intentionally NOT awaited here - it's slow (~3s)
+ // and only needed for delete modal details. It loads in background after UI renders.
await Promise.all([
- useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useChannelsStore.getState().fetchChannelProfiles(),
usePlaylistsStore.getState().fetchPlaylists(),
@@ -72,7 +73,7 @@ const useAuthStore = create((set, get) => ({
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
- // Only set isAuthenticated and isInitialized AFTER all data is loaded
+ // Only set isAuthenticated and isInitialized AFTER essential data is loaded
// This prevents routes from rendering before data is ready
set({
isAuthenticated: true,
@@ -80,6 +81,9 @@ const useAuthStore = create((set, get) => ({
isInitializing: false,
});
+ // Load channels data in background (not blocking) - needed for delete modal details
+ useChannelsStore.getState().fetchChannels();
+
// Note: Logos are loaded after the Channels page tables finish loading
// This is handled by the tables themselves signaling completion
} catch (error) {
From 76c895f6138734f3a4bb84683314b628eca4444c Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Mon, 26 Jan 2026 17:37:50 -0600
Subject: [PATCH 061/125] Security: Fixed moderate severity Prototype Pollution
vulnerability in Lodash (`_.unset` and `_.omit` functions) See
[GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for
details.
---
CHANGELOG.md | 1 +
frontend/package-lock.json | 6 +++---
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fd90457..629ce88c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx))
- **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7))
- Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router)
+- Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details.
### Added
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 476b764b..0a349a3d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -3720,9 +3720,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.clamp": {
From 497bb819b97368ec46dd139531a9d02767903b4e Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 09:50:27 -0600
Subject: [PATCH 062/125] Docs: Added comprehensive Swagger/OpenAPI
documentation for all series-rules endpoints
---
CHANGELOG.md | 1 +
apps/channels/api_views.py | 118 +++++++++++++++++++++++++++++++++++++
2 files changed, 119 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 629ce88c..cd1786ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Series Rules API Swagger Documentation: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints (`GET /series-rules/`, `POST /series-rules/`, `DELETE /series-rules/{tvg_id}/`, `POST /series-rules/evaluate/`, `POST /series-rules/bulk-remove/`), including detailed descriptions, request/response schemas, and error handling information for improved API discoverability
- Editable Channel Table Mode:
- Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal.
- EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment.
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 5d5dc4b6..40659828 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -2406,9 +2406,61 @@ class SeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
+ @swagger_auto_schema(
+ operation_summary="List all series rules",
+ operation_description="Retrieve all configured DVR series recording rules.",
+ responses={
+ 200: openapi.Response(
+ description="List of series rules",
+ schema=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'rules': openapi.Schema(
+ type=openapi.TYPE_ARRAY,
+ items=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
+ 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'),
+ 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
+ },
+ ),
+ description='List of series recording rules'
+ ),
+ },
+ ),
+ ),
+ },
+ )
def get(self, request):
return Response({"rules": CoreSettings.get_dvr_series_rules()})
+ @swagger_auto_schema(
+ operation_summary="Create or update a series rule",
+ operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
+ request_body=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ required=['tvg_id'],
+ properties={
+ 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
+ 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'),
+ 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
+ },
+ ),
+ responses={
+ 200: openapi.Response(
+ description="Series rule created/updated successfully",
+ schema=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
+ 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'),
+ },
+ ),
+ ),
+ 400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"),
+ },
+ )
def post(self, request):
data = request.data or {}
tvg_id = str(data.get("tvg_id") or "").strip()
@@ -2441,6 +2493,25 @@ class DeleteSeriesRuleAPIView(APIView):
except KeyError:
return [Authenticated()]
+ @swagger_auto_schema(
+ operation_summary="Delete a series rule",
+ operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
+ manual_parameters=[
+ openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'),
+ ],
+ responses={
+ 200: openapi.Response(
+ description="Series rule deleted successfully",
+ schema=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
+ 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'),
+ },
+ ),
+ ),
+ },
+ )
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
rules = [r for r in CoreSettings.get_dvr_series_rules() if str(r.get("tvg_id")) != tvg_id]
@@ -2455,6 +2526,27 @@ class EvaluateSeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
+ @swagger_auto_schema(
+ operation_summary="Evaluate series rules",
+ operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
+ request_body=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'),
+ },
+ ),
+ responses={
+ 200: openapi.Response(
+ description="Evaluation completed successfully",
+ schema=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
+ },
+ ),
+ ),
+ },
+ )
def post(self, request):
tvg_id = request.data.get("tvg_id")
# Run synchronously so UI sees results immediately
@@ -2476,6 +2568,32 @@ class BulkRemoveSeriesRecordingsAPIView(APIView):
except KeyError:
return [Authenticated()]
+ @swagger_auto_schema(
+ operation_summary="Bulk remove scheduled recordings for a series",
+ operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
+ request_body=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ required=['tvg_id'],
+ properties={
+ 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'),
+ 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'),
+ 'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'),
+ },
+ ),
+ responses={
+ 200: openapi.Response(
+ description="Recordings removed successfully",
+ schema=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
+ 'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'),
+ },
+ ),
+ ),
+ 400: openapi.Response(description="Bad request (missing tvg_id)"),
+ },
+ )
def post(self, request):
from django.utils import timezone
tvg_id = str(request.data.get("tvg_id") or "").strip()
From 9026ece87a5b2d1ac6e936b31fefdb6bf520885c Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Tue, 27 Jan 2026 16:38:36 +0000
Subject: [PATCH 063/125] Release v0.18.0
---
CHANGELOG.md | 2 ++
version.py | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 629ce88c..94a0f3d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.18.0] - 2026-01-27
+
### Security
- Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities:
diff --git a/version.py b/version.py
index 1aae4039..371a0c5d 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process
From ed4fabd7735235f8ee83474cec1734e254722e8e Mon Sep 17 00:00:00 2001
From: SergeantPanda <61642231+SergeantPanda@users.noreply.github.com>
Date: Tue, 27 Jan 2026 10:41:28 -0600
Subject: [PATCH 064/125] Downgrade version from 0.18.0 to 0.17.0
---
version.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/version.py b/version.py
index 371a0c5d..1aae4039 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process
From b5fedc1a6c39c6abe65b532c68581281acc9f522 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Tue, 27 Jan 2026 16:44:10 +0000
Subject: [PATCH 065/125] Release v0.18.0
---
CHANGELOG.md | 2 ++
version.py | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c655ea0e..e455048f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.18.0] - 2026-01-27
+## [0.18.0] - 2026-01-27
+
### Security
- Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities:
diff --git a/version.py b/version.py
index 1aae4039..371a0c5d 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.17.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process
From 2abe07fe1c4bcd001b933e1ee20ddf9082283d70 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 10:56:04 -0600
Subject: [PATCH 066/125] Update Series Rules API Swagger Documentation to
include required items parameter for TYPE_ARRAY schemas
---
CHANGELOG.md | 4 +++-
apps/channels/api_views.py | 26 ++++++++++++++++++++++++--
2 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e455048f..975d3120 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-## [0.18.0] - 2026-01-27
+### Fixed
+
+- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure
## [0.18.0] - 2026-01-27
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 40659828..11e7525f 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -2454,7 +2454,18 @@ class SeriesRulesAPIView(APIView):
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'),
+ 'rules': openapi.Schema(
+ type=openapi.TYPE_ARRAY,
+ items=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
+ 'mode': openapi.Schema(type=openapi.TYPE_STRING),
+ 'title': openapi.Schema(type=openapi.TYPE_STRING),
+ },
+ ),
+ description='Updated list of all rules'
+ ),
},
),
),
@@ -2506,7 +2517,18 @@ class DeleteSeriesRuleAPIView(APIView):
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'rules': openapi.Schema(type=openapi.TYPE_ARRAY, description='Updated list of all rules'),
+ 'rules': openapi.Schema(
+ type=openapi.TYPE_ARRAY,
+ items=openapi.Schema(
+ type=openapi.TYPE_OBJECT,
+ properties={
+ 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
+ 'mode': openapi.Schema(type=openapi.TYPE_STRING),
+ 'title': openapi.Schema(type=openapi.TYPE_STRING),
+ },
+ ),
+ description='Updated list of all rules'
+ ),
},
),
),
From 39ed48e0e55412933ea125be9953c8572dcd34b2 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Tue, 27 Jan 2026 16:57:57 +0000
Subject: [PATCH 067/125] Release v0.18.1
---
CHANGELOG.md | 2 ++
version.py | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e455048f..aa464cff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.18.1] - 2026-01-27
+
## [0.18.0] - 2026-01-27
## [0.18.0] - 2026-01-27
diff --git a/version.py b/version.py
index 371a0c5d..f821e438 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process
From b55ed77516e86c054989c1bd1df64add629664c0 Mon Sep 17 00:00:00 2001
From: SergeantPanda <61642231+SergeantPanda@users.noreply.github.com>
Date: Tue, 27 Jan 2026 11:05:52 -0600
Subject: [PATCH 068/125] Downgrade version from 0.18.1 to 0.18.0
---
version.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/version.py b/version.py
index f821e438..371a0c5d 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process
From ddbda17ae4e6d5c118489d6dc6bfc818f9df6f86 Mon Sep 17 00:00:00 2001
From: GitHub Actions
Date: Tue, 27 Jan 2026 17:06:41 +0000
Subject: [PATCH 069/125] Release v0.18.1
---
CHANGELOG.md | 2 ++
version.py | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 975d3120..97952632 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.18.1] - 2026-01-27
+
### Fixed
- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure
diff --git a/version.py b/version.py
index 371a0c5d..f821e438 100644
--- a/version.py
+++ b/version.py
@@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
-__version__ = '0.18.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
+__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process
From 8616933ad7852082c6d779e65fd63ad289215e1b Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 13:17:59 -0600
Subject: [PATCH 070/125] Replace drf-yasg pip with drf-spectactular
---
requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 3416804d..1f3c63aa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,7 @@ djangorestframework==3.16.1
requests==2.32.5
psutil==7.1.3
pillow
-drf-yasg>=1.21.11
+drf-spectacular>=0.28.0
streamlink
python-vlc
yt-dlp
From 53641237452b5cdcfed0ce8a9963356488d9f414 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 13:33:33 -0600
Subject: [PATCH 071/125] - Swagger/OpenAPI Migration: Migrated from `drf-yasg`
(OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This
provides: - Native Bearer token authentication support in Swagger UI -
users can now enter just the JWT token and the "Bearer " prefix is
automatically added - Modern OpenAPI 3.0 specification compliance -
Better auto-generation of request/response schemas - Improved documentation
accuracy with serializer introspection
---
CHANGELOG.md | 8 +
apps/accounts/api_views.py | 63 +++--
apps/api/urls.py | 26 +-
apps/channels/api_views.py | 487 +++++++++++++------------------------
apps/epg/api_views.py | 39 ++-
apps/hdhr/api_views.py | 24 +-
apps/hdhr/views.py | 24 +-
apps/m3u/api_views.py | 19 +-
core/api_views.py | 27 +-
dispatcharr/settings.py | 24 +-
dispatcharr/urls.py | 28 +--
11 files changed, 290 insertions(+), 479 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97952632..26c3abd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
+ - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
+ - Modern OpenAPI 3.0 specification compliance
+ - Better auto-generation of request/response schemas
+ - Improved documentation accuracy with serializer introspection
+
## [0.18.1] - 2026-01-27
### Fixed
diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py
index 41e2f077..607ce199 100644
--- a/apps/accounts/api_views.py
+++ b/apps/accounts/api_views.py
@@ -4,9 +4,9 @@ from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.response import Response
-from rest_framework import viewsets, status
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
+from rest_framework import viewsets, status, serializers
+from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
+from drf_spectacular.types import OpenApiTypes
import json
from .permissions import IsAdmin, Authenticated
from dispatcharr.utils import network_access_allowed
@@ -147,19 +147,15 @@ class AuthViewSet(viewsets.ViewSet):
return [IsAuthenticated()]
return []
- @swagger_auto_schema(
- operation_description="Authenticate and log in a user",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["username", "password"],
- properties={
- "username": openapi.Schema(type=openapi.TYPE_STRING),
- "password": openapi.Schema(
- type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD
- ),
+ @extend_schema(
+ description="Authenticate and log in a user",
+ request=inline_serializer(
+ name="LoginRequest",
+ fields={
+ "username": serializers.CharField(),
+ "password": serializers.CharField(),
},
),
- responses={200: "Login successful", 400: "Invalid credentials"},
)
def login(self, request):
"""Logs in a user and returns user details"""
@@ -209,9 +205,8 @@ class AuthViewSet(viewsets.ViewSet):
)
return Response({"error": "Invalid credentials"}, status=400)
- @swagger_auto_schema(
- operation_description="Log out the current user",
- responses={200: "Logout successful"},
+ @extend_schema(
+ description="Log out the current user",
)
def logout(self, request):
"""Logs out the authenticated user"""
@@ -245,32 +240,31 @@ class UserViewSet(viewsets.ModelViewSet):
return [IsAdmin()]
- @swagger_auto_schema(
- operation_description="Retrieve a list of users",
+ @extend_schema(
+ description="Retrieve a list of users",
responses={200: UserSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Retrieve a specific user by ID")
+ @extend_schema(description="Retrieve a specific user by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Create a new user")
+ @extend_schema(description="Create a new user")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Update a user")
+ @extend_schema(description="Update a user")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Delete a user")
+ @extend_schema(description="Delete a user")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
- @swagger_auto_schema(
- method="get",
- operation_description="Get active user information",
+ @extend_schema(
+ description="Get active user information",
)
@action(detail=False, methods=["get"], url_path="me")
def me(self, request):
@@ -287,34 +281,33 @@ class GroupViewSet(viewsets.ModelViewSet):
serializer_class = GroupSerializer
permission_classes = [Authenticated]
- @swagger_auto_schema(
- operation_description="Retrieve a list of groups",
+ @extend_schema(
+ description="Retrieve a list of groups",
responses={200: GroupSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Retrieve a specific group by ID")
+ @extend_schema(description="Retrieve a specific group by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Create a new group")
+ @extend_schema(description="Create a new group")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Update a group")
+ @extend_schema(description="Update a group")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
- @swagger_auto_schema(operation_description="Delete a group")
+ @extend_schema(description="Delete a group")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# 🔹 4) Permissions List API
-@swagger_auto_schema(
- method="get",
- operation_description="Retrieve a list of all permissions",
+@extend_schema(
+ description="Retrieve a list of all permissions",
responses={200: PermissionSerializer(many=True)},
)
@api_view(["GET"])
diff --git a/apps/api/urls.py b/apps/api/urls.py
index 4c92c70a..5a688778 100644
--- a/apps/api/urls.py
+++ b/apps/api/urls.py
@@ -1,23 +1,8 @@
from django.urls import path, include, re_path
-from drf_yasg.views import get_schema_view
-from drf_yasg import openapi
-from rest_framework.permissions import AllowAny
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
app_name = 'api'
-schema_view = get_schema_view(
- openapi.Info(
- title="Dispatcharr API",
- default_version='v1',
- description="API documentation for Dispatcharr",
- terms_of_service="https://www.google.com/policies/terms/",
- contact=openapi.Contact(email="support@dispatcharr.local"),
- license=openapi.License(name="Unlicense"),
- ),
- public=True,
- permission_classes=(AllowAny,),
-)
-
urlpatterns = [
path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')),
path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')),
@@ -35,8 +20,9 @@ urlpatterns = [
- # Swagger Documentation api_urls
- re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
- path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
- path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
+ # OpenAPI Schema and Documentation (drf-spectacular)
+ path('schema/', SpectacularAPIView.as_view(), name='schema'),
+ re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
+ path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
+ path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'),
]
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 11e7525f..27e07dac 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -4,8 +4,9 @@ from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
+from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
+from drf_spectacular.types import OpenApiTypes
+from rest_framework import serializers
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Count, F
@@ -269,17 +270,15 @@ class StreamViewSet(viewsets.ModelViewSet):
]
})
- @swagger_auto_schema(
- method="post",
- operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["ids"],
- properties={
- "ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="List of stream IDs to retrieve"
+ @extend_schema(
+ methods=["POST"],
+ description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
+ request=inline_serializer(
+ name="StreamByIdsRequest",
+ fields={
+ "ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="List of stream IDs to retrieve"
),
},
),
@@ -342,10 +341,9 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
return super().partial_update(request, *args, **kwargs)
- @swagger_auto_schema(
- method="post",
- operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)",
- responses={200: "Cleanup completed"},
+ @extend_schema(
+ methods=["POST"],
+ description="Delete all channel groups that have no associations (no channels or M3U accounts)",
)
@action(detail=False, methods=["post"], url_path="cleanup")
def cleanup_unused_groups(self, request):
@@ -831,25 +829,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Return the response with the list of IDs
return Response(list(channel_ids))
- @swagger_auto_schema(
- method="post",
- operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["channel_ids"],
- properties={
- "starting_number": openapi.Schema(
- type=openapi.TYPE_NUMBER,
- description="Starting channel number to assign (can be decimal)",
+ @extend_schema(
+ methods=["POST"],
+ description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
+ request=inline_serializer(
+ name="AssignChannelsRequest",
+ fields={
+ "starting_number": serializers.FloatField(
+ help_text="Starting channel number to assign (can be decimal)",
+ required=False,
),
- "channel_ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="Channel IDs to assign",
+ "channel_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="Channel IDs to assign",
),
},
),
- responses={200: "Channels have been auto-assigned!"},
)
@action(detail=False, methods=["post"], url_path="assign")
def assign(self, request):
@@ -869,33 +864,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
{"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK
)
- @swagger_auto_schema(
- method="post",
- operation_description=(
+ @extend_schema(
+ methods=["POST"],
+ description=(
"Create a new channel from an existing stream. "
"If 'channel_number' is provided, it will be used (if available); "
"otherwise, the next available channel number is assigned. "
"If 'channel_profile_ids' is provided, the channel will only be added to those profiles. "
"Accepts either a single ID or an array of IDs."
),
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["stream_id"],
- properties={
- "stream_id": openapi.Schema(
- type=openapi.TYPE_INTEGER, description="ID of the stream to link"
+ request=inline_serializer(
+ name="FromStreamRequest",
+ fields={
+ "stream_id": serializers.IntegerField(help_text="ID of the stream to link"),
+ "channel_number": serializers.FloatField(
+ help_text="(Optional) Desired channel number. Must not be in use.",
+ required=False,
),
- "channel_number": openapi.Schema(
- type=openapi.TYPE_NUMBER,
- description="(Optional) Desired channel number. Must not be in use.",
- ),
- "name": openapi.Schema(
- type=openapi.TYPE_STRING, description="Desired channel name"
- ),
- "channel_profile_ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
+ "name": serializers.CharField(help_text="Desired channel name", required=False),
+ "channel_profile_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
+ required=False,
),
},
),
@@ -1055,34 +1045,31 @@ class ChannelViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
- @swagger_auto_schema(
- method="post",
- operation_description=(
+ @extend_schema(
+ methods=["POST"],
+ description=(
"Asynchronously bulk create channels from stream IDs. "
"Returns a task ID to track progress via WebSocket. "
"This is the recommended approach for large bulk operations."
),
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["stream_ids"],
- properties={
- "stream_ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="List of stream IDs to create channels from"
+ request=inline_serializer(
+ name="FromStreamBulkRequest",
+ fields={
+ "stream_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="List of stream IDs to create channels from"
),
- "channel_profile_ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
+ "channel_profile_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
+ required=False,
),
- "starting_channel_number": openapi.Schema(
- type=openapi.TYPE_INTEGER,
- description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number"
+ "starting_channel_number": serializers.IntegerField(
+ help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number",
+ required=False,
),
},
),
- responses={202: "Task started successfully"},
)
@action(detail=False, methods=["post"], url_path="from-stream/bulk")
def from_stream_bulk(self, request):
@@ -1122,20 +1109,19 @@ class ChannelViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
# 6) EPG Fuzzy Matching
# ─────────────────────────────────────────────────────────
- @swagger_auto_schema(
- method="post",
- operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'channel_ids': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(type=openapi.TYPE_INTEGER),
- description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.'
+ @extend_schema(
+ methods=["POST"],
+ description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
+ request=inline_serializer(
+ name="MatchEpgRequest",
+ fields={
+ 'channel_ids': serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.',
+ required=False,
)
}
),
- responses={202: "EPG matching task initiated"},
)
@action(detail=False, methods=["post"], url_path="match-epg")
def match_epg(self, request):
@@ -1156,10 +1142,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
{"message": message}, status=status.HTTP_202_ACCEPTED
)
- @swagger_auto_schema(
- method="post",
- operation_description="Try to auto-match this specific channel with EPG data.",
- responses={200: "EPG matching completed", 202: "EPG matching task initiated"},
+ @extend_schema(
+ methods=["POST"],
+ description="Try to auto-match this specific channel with EPG data.",
)
@action(detail=True, methods=["post"], url_path="match-epg")
def match_channel_epg(self, request, pk=None):
@@ -1186,16 +1171,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
# 7) Set EPG and Refresh
# ─────────────────────────────────────────────────────────
- @swagger_auto_schema(
- method="post",
- operation_description="Set EPG data for a channel and refresh program data",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["epg_data_id"],
- properties={
- "epg_data_id": openapi.Schema(
- type=openapi.TYPE_INTEGER, description="EPG data ID to link"
- )
+ @extend_schema(
+ methods=["POST"],
+ description="Set EPG data for a channel and refresh program data",
+ request=inline_serializer(
+ name="SetEpgRequest",
+ fields={
+ "epg_data_id": serializers.IntegerField(help_text="EPG data ID to link")
},
),
responses={200: "EPG data linked and refresh triggered"},
@@ -1251,28 +1233,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=400)
- @swagger_auto_schema(
- method="post",
- operation_description=(
+ @extend_schema(
+ description=(
"Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). "
"The channel will receive the next whole number after the target channel, and all subsequent "
"channels will be renumbered accordingly."
),
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- "insert_after_id": openapi.Schema(
- type=openapi.TYPE_INTEGER,
- description="ID of the channel to insert after. Use null to move to the beginning.",
- nullable=True,
+ request=inline_serializer(
+ name="ReorderChannelRequest",
+ fields={
+ "insert_after_id": serializers.IntegerField(
+ help_text="ID of the channel to insert after. Use null to move to the beginning.",
+ required=False,
+ allow_null=True,
),
},
),
- responses={
- 200: "Channel reordered successfully",
- 404: "Channel not found",
- 400: "Invalid request",
- },
)
@action(detail=True, methods=["post"], url_path="reorder")
def reorder(self, request, pk=None):
@@ -1340,25 +1316,23 @@ class ChannelViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
- @swagger_auto_schema(
- method="post",
- operation_description="Associate multiple channels with EPG data without triggering a full refresh",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- "associations": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- "channel_id": openapi.Schema(type=openapi.TYPE_INTEGER),
- "epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER),
+ @extend_schema(
+ methods=["POST"],
+ description="Associate multiple channels with EPG data without triggering a full refresh",
+ request=inline_serializer(
+ name="BatchSetEpgRequest",
+ fields={
+ "associations": serializers.ListField(
+ child=inline_serializer(
+ name="EpgAssociation",
+ fields={
+ "channel_id": serializers.IntegerField(),
+ "epg_data_id": serializers.IntegerField(),
},
),
)
},
),
- responses={200: "EPG data linked for multiple channels"},
)
@action(detail=False, methods=["post"], url_path="batch-set-epg")
def batch_set_epg(self, request):
@@ -1456,20 +1430,17 @@ class BulkDeleteStreamsAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Bulk delete streams by ID",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["stream_ids"],
- properties={
- "stream_ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="Stream IDs to delete",
+ @extend_schema(
+ description="Bulk delete streams by ID",
+ request=inline_serializer(
+ name="BulkDeleteStreamsRequest",
+ fields={
+ "stream_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="Stream IDs to delete",
)
},
),
- responses={204: "Streams deleted"},
)
def delete(self, request, *args, **kwargs):
stream_ids = request.data.get("stream_ids", [])
@@ -1492,20 +1463,17 @@ class BulkDeleteChannelsAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Bulk delete channels by ID",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["channel_ids"],
- properties={
- "channel_ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="Channel IDs to delete",
+ @extend_schema(
+ description="Bulk delete channels by ID",
+ request=inline_serializer(
+ name="BulkDeleteChannelsRequest",
+ fields={
+ "channel_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="Channel IDs to delete",
)
},
),
- responses={204: "Channels deleted"},
)
def delete(self, request):
channel_ids = request.data.get("channel_ids", [])
@@ -1527,20 +1495,17 @@ class BulkDeleteLogosAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Bulk delete logos by ID",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["logo_ids"],
- properties={
- "logo_ids": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Items(type=openapi.TYPE_INTEGER),
- description="Logo IDs to delete",
+ @extend_schema(
+ description="Bulk delete logos by ID",
+ request=inline_serializer(
+ name="BulkDeleteLogosRequest",
+ fields={
+ "logo_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ help_text="Logo IDs to delete",
)
},
),
- responses={204: "Logos deleted"},
)
def delete(self, request):
logo_ids = request.data.get("logo_ids", [])
@@ -1597,19 +1562,18 @@ class CleanupUnusedLogosAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Delete all channel logos that are not used by any channels",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- "delete_files": openapi.Schema(
- type=openapi.TYPE_BOOLEAN,
- description="Whether to delete local logo files from disk",
- default=False
+ @extend_schema(
+ description="Delete all channel logos that are not used by any channels",
+ request=inline_serializer(
+ name="CleanupUnusedLogosRequest",
+ fields={
+ "delete_files": serializers.BooleanField(
+ help_text="Whether to delete local logo files from disk",
+ default=False,
+ required=False,
)
},
),
- responses={200: "Cleanup completed"},
)
def post(self, request):
"""Delete all channel logos with no channel associations"""
@@ -2019,29 +1983,9 @@ class BulkUpdateChannelMembershipAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
- request_body=BulkChannelProfileMembershipSerializer,
- responses={
- 200: openapi.Response(
- description="Channels updated successfully",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- "status": openapi.Schema(type=openapi.TYPE_STRING, example="success"),
- "updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"),
- "created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"),
- "invalid_channels": openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(type=openapi.TYPE_INTEGER),
- description="List of channel IDs that don't exist"
- ),
- },
- ),
- ),
- 400: "Invalid request data",
- 404: "Profile not found",
- },
+ @extend_schema(
+ description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
+ request=BulkChannelProfileMembershipSerializer,
)
def patch(self, request, profile_id):
"""Bulk enable or disable channels for a specific profile"""
@@ -2406,71 +2350,24 @@ class SeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_summary="List all series rules",
- operation_description="Retrieve all configured DVR series recording rules.",
- responses={
- 200: openapi.Response(
- description="List of series rules",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'rules': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
- 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'),
- 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
- },
- ),
- description='List of series recording rules'
- ),
- },
- ),
- ),
- },
+ @extend_schema(
+ summary="List all series rules",
+ description="Retrieve all configured DVR series recording rules.",
)
def get(self, request):
return Response({"rules": CoreSettings.get_dvr_series_rules()})
- @swagger_auto_schema(
- operation_summary="Create or update a series rule",
- operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=['tvg_id'],
- properties={
- 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
- 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'),
- 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
+ @extend_schema(
+ summary="Create or update a series rule",
+ description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
+ request=inline_serializer(
+ name="SeriesRuleRequest",
+ fields={
+ 'tvg_id': serializers.CharField(help_text='Channel TVG ID'),
+ 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'),
+ 'title': serializers.CharField(help_text='Series title', required=False),
},
),
- responses={
- 200: openapi.Response(
- description="Series rule created/updated successfully",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'rules': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
- 'mode': openapi.Schema(type=openapi.TYPE_STRING),
- 'title': openapi.Schema(type=openapi.TYPE_STRING),
- },
- ),
- description='Updated list of all rules'
- ),
- },
- ),
- ),
- 400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"),
- },
)
def post(self, request):
data = request.data or {}
@@ -2504,35 +2401,12 @@ class DeleteSeriesRuleAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_summary="Delete a series rule",
- operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
- manual_parameters=[
- openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'),
+ @extend_schema(
+ summary="Delete a series rule",
+ description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
+ parameters=[
+ OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
],
- responses={
- 200: openapi.Response(
- description="Series rule deleted successfully",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'rules': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
- 'mode': openapi.Schema(type=openapi.TYPE_STRING),
- 'title': openapi.Schema(type=openapi.TYPE_STRING),
- },
- ),
- description='Updated list of all rules'
- ),
- },
- ),
- ),
- },
)
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
@@ -2548,26 +2422,15 @@ class EvaluateSeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_summary="Evaluate series rules",
- operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'),
+ @extend_schema(
+ summary="Evaluate series rules",
+ description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
+ request=inline_serializer(
+ name="EvaluateSeriesRulesRequest",
+ fields={
+ "tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."),
},
),
- responses={
- 200: openapi.Response(
- description="Evaluation completed successfully",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- },
- ),
- ),
- },
)
def post(self, request):
tvg_id = request.data.get("tvg_id")
@@ -2590,31 +2453,17 @@ class BulkRemoveSeriesRecordingsAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_summary="Bulk remove scheduled recordings for a series",
- operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=['tvg_id'],
- properties={
- 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'),
- 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'),
- 'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'),
+ @extend_schema(
+ summary="Bulk remove scheduled recordings for a series",
+ description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
+ request=inline_serializer(
+ name="BulkRemoveSeriesRecordingsRequest",
+ fields={
+ "tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"),
+ "title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"),
+ "scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"),
},
),
- responses={
- 200: openapi.Response(
- description="Recordings removed successfully",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'),
- },
- ),
- ),
- 400: openapi.Response(description="Bad request (missing tvg_id)"),
- },
)
def post(self, request):
from django.utils import timezone
diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py
index 319368d0..00f7403f 100644
--- a/apps/epg/api_views.py
+++ b/apps/epg/api_views.py
@@ -1,10 +1,10 @@
import logging, os
-from rest_framework import viewsets, status
+from rest_framework import viewsets, status, serializers
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import action
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
+from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
+from drf_spectacular.types import OpenApiTypes
from django.utils import timezone
from datetime import timedelta
from .models import EPGSource, ProgramData, EPGData # Added ProgramData
@@ -122,8 +122,8 @@ class EPGGridAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
+ @extend_schema(
+ description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
responses={200: ProgramDataSerializer(many=True)},
)
def get(self, request, format=None):
@@ -371,9 +371,8 @@ class EPGImportAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Triggers an EPG data import",
- responses={202: "EPG data import initiated"},
+ @extend_schema(
+ description="Triggers an EPG data import",
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
@@ -435,20 +434,20 @@ class CurrentProgramsAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Get currently playing programs for specified channels or all channels",
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'channel_ids': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(type=openapi.TYPE_INTEGER),
- description="Array of channel IDs. If null or omitted, returns all channels with current programs.",
- nullable=True,
- )
+ @extend_schema(
+ description="Get currently playing programs for specified channels or all channels",
+ request=inline_serializer(
+ name="CurrentProgramsRequest",
+ fields={
+ "channel_ids": serializers.ListField(
+ child=serializers.IntegerField(),
+ required=False,
+ allow_null=True,
+ help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.",
+ ),
},
),
- responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))},
+ responses={200: ProgramDataSerializer(many=True)},
)
def post(self, request, format=None):
# Get channel IDs from request body
diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py
index 8f1609d4..2227170e 100644
--- a/apps/hdhr/api_views.py
+++ b/apps/hdhr/api_views.py
@@ -4,8 +4,8 @@ from rest_framework.views import APIView
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
import logging
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
+from drf_spectacular.utils import extend_schema, OpenApiParameter
+from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from django.db import models
from apps.channels.models import Channel, ChannelProfile, Stream
@@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
- @swagger_auto_schema(
- operation_description="Retrieve HDHomeRun device discovery information",
- responses={200: openapi.Response("HDHR Discovery JSON")},
+ @extend_schema(
+ description="Retrieve HDHomeRun device discovery information",
)
def get(self, request, profile=None):
uri_parts = ["hdhr"]
@@ -100,9 +99,8 @@ class DiscoverAPIView(APIView):
class LineupAPIView(APIView):
"""Returns available channel lineup"""
- @swagger_auto_schema(
- operation_description="Retrieve the available channel lineup",
- responses={200: openapi.Response("Channel Lineup JSON")},
+ @extend_schema(
+ description="Retrieve the available channel lineup",
)
def get(self, request, profile=None):
if profile is not None:
@@ -141,9 +139,8 @@ class LineupAPIView(APIView):
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
- @swagger_auto_schema(
- operation_description="Retrieve the HDHomeRun lineup status",
- responses={200: openapi.Response("Lineup Status JSON")},
+ @extend_schema(
+ description="Retrieve the HDHomeRun lineup status",
)
def get(self, request, profile=None):
data = {
@@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView):
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
- @swagger_auto_schema(
- operation_description="Retrieve the HDHomeRun device XML configuration",
- responses={200: openapi.Response("HDHR Device XML")},
+ @extend_schema(
+ description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py
index 40823259..f9dd42d2 100644
--- a/apps/hdhr/views.py
+++ b/apps/hdhr/views.py
@@ -3,8 +3,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
+from drf_spectacular.utils import extend_schema, OpenApiParameter
+from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel
from .models import HDHRDevice
@@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
- @swagger_auto_schema(
- operation_description="Retrieve HDHomeRun device discovery information",
- responses={200: openapi.Response("HDHR Discovery JSON")},
+ @extend_schema(
+ description="Retrieve HDHomeRun device discovery information",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
@@ -81,9 +80,8 @@ class DiscoverAPIView(APIView):
class LineupAPIView(APIView):
"""Returns available channel lineup"""
- @swagger_auto_schema(
- operation_description="Retrieve the available channel lineup",
- responses={200: openapi.Response("Channel Lineup JSON")},
+ @extend_schema(
+ description="Retrieve the available channel lineup",
)
def get(self, request):
channels = Channel.objects.all().order_by("channel_number")
@@ -102,9 +100,8 @@ class LineupAPIView(APIView):
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
- @swagger_auto_schema(
- operation_description="Retrieve the HDHomeRun lineup status",
- responses={200: openapi.Response("Lineup Status JSON")},
+ @extend_schema(
+ description="Retrieve the HDHomeRun lineup status",
)
def get(self, request):
data = {
@@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView):
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
- @swagger_auto_schema(
- operation_description="Retrieve the HDHomeRun device XML configuration",
- responses={200: openapi.Response("HDHR Device XML")},
+ @extend_schema(
+ description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py
index 1f16f20f..73331f7a 100644
--- a/apps/m3u/api_views.py
+++ b/apps/m3u/api_views.py
@@ -6,8 +6,8 @@ from apps.accounts.permissions import (
permission_classes_by_action,
permission_classes_by_method,
)
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
+from drf_spectacular.utils import extend_schema, OpenApiParameter
+from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
@@ -357,9 +357,8 @@ class RefreshM3UAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Triggers a refresh of all active M3U accounts",
- responses={202: "M3U refresh initiated"},
+ @extend_schema(
+ description="Triggers a refresh of all active M3U accounts",
)
def post(self, request, format=None):
refresh_m3u_accounts.delay()
@@ -380,9 +379,8 @@ class RefreshSingleM3UAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Triggers a refresh of a single M3U account",
- responses={202: "M3U account refresh initiated"},
+ @extend_schema(
+ description="Triggers a refresh of a single M3U account",
)
def post(self, request, account_id, format=None):
refresh_single_m3u_account.delay(account_id)
@@ -406,9 +404,8 @@ class RefreshAccountInfoAPIView(APIView):
except KeyError:
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Triggers a refresh of account information for a specific M3U profile",
- responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"},
+ @extend_schema(
+ description="Triggers a refresh of account information for a specific M3U profile",
)
def post(self, request, profile_id, format=None):
try:
diff --git a/core/api_views.py b/core/api_views.py
index 30829174..44001f95 100644
--- a/core/api_views.py
+++ b/core/api_views.py
@@ -9,8 +9,8 @@ from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes, action
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
+from drf_spectacular.utils import extend_schema, OpenApiParameter
+from drf_spectacular.types import OpenApiTypes
from .models import (
UserAgent,
StreamProfile,
@@ -241,10 +241,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
-@swagger_auto_schema(
- method="get",
- operation_description="Endpoint for environment details",
- responses={200: "Environment variables"},
+@extend_schema(
+ description="Endpoint for environment details",
)
@api_view(["GET"])
@permission_classes([Authenticated])
@@ -314,10 +312,8 @@ def environment(request):
)
-@swagger_auto_schema(
- method="get",
- operation_description="Get application version information",
- responses={200: "Version information"},
+@extend_schema(
+ description="Get application version information",
)
@api_view(["GET"])
@@ -333,10 +329,8 @@ def version(request):
)
-@swagger_auto_schema(
- method="post",
- operation_description="Trigger rehashing of all streams",
- responses={200: "Rehash task started"},
+@extend_schema(
+ description="Trigger rehashing of all streams",
)
@api_view(["POST"])
@permission_classes([Authenticated])
@@ -383,9 +377,8 @@ class TimezoneListView(APIView):
def get_permissions(self):
return [Authenticated()]
- @swagger_auto_schema(
- operation_description="Get list of all supported timezones",
- responses={200: openapi.Response('List of timezones with grouping by region')}
+ @extend_schema(
+ description="Get list of all supported timezones",
)
def get(self, request):
import pytz
diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index 1a9a1a44..b65ee2d0 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -32,7 +32,7 @@ INSTALLED_APPS = [
"apps.vod.apps.VODConfig",
"core",
"daphne",
- "drf_yasg",
+ "drf_spectacular",
"channels",
"django.contrib.admin",
"django.contrib.auth",
@@ -151,7 +151,7 @@ AUTH_PASSWORD_VALIDATORS = [
]
REST_FRAMEWORK = {
- "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
+ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
@@ -162,10 +162,22 @@ REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}
-SWAGGER_SETTINGS = {
- "SECURITY_DEFINITIONS": {
- "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}
- }
+SPECTACULAR_SETTINGS = {
+ "TITLE": "Dispatcharr API",
+ "DESCRIPTION": "API documentation for Dispatcharr",
+ "VERSION": "1.0.0",
+ "SERVE_INCLUDE_SCHEMA": False,
+ "SECURITY": [{"BearerAuth": []}],
+ "COMPONENTS": {
+ "securitySchemes": {
+ "BearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ "description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.",
+ }
+ }
+ },
}
LANGUAGE_CODE = "en-us"
diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py
index 890d0c2d..1ab4ebfb 100644
--- a/dispatcharr/urls.py
+++ b/dispatcharr/urls.py
@@ -3,32 +3,20 @@ from django.urls import path, include, re_path
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import TemplateView, RedirectView
-from rest_framework import permissions
-from drf_yasg.views import get_schema_view
-from drf_yasg import openapi
from .routing import websocket_urlpatterns
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
from apps.proxy.ts_proxy.views import stream_xc
from apps.output.views import xc_movie_stream, xc_series_stream
-# Define schema_view for Swagger
-schema_view = get_schema_view(
- openapi.Info(
- title="Dispatcharr API",
- default_version="v1",
- description="API documentation for Dispatcharr",
- terms_of_service="https://www.google.com/policies/terms/",
- contact=openapi.Contact(email="contact@dispatcharr.local"),
- license=openapi.License(name="Creative Commons by-nc-sa"),
- ),
- public=True,
- permission_classes=(permissions.AllowAny,),
-)
-
urlpatterns = [
# API Routes
path("api/", include(("apps.api.urls", "api"), namespace="api")),
path("api", RedirectView.as_view(url="/api/", permanent=True)),
+ # Swagger redirects (Swagger UI is served at /api/swagger/)
+ path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)),
+ path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)),
+ path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)),
+ path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)),
# Admin
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
path("admin/", admin.site.urls),
@@ -68,12 +56,6 @@ urlpatterns = [
name="xc_series_stream",
),
- re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
- # ReDoc UI
- path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
- # Optionally, serve the raw Swagger JSON
- path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"),
-
# VOD proxy is now handled by the main proxy URLs above
# Catch-all routes should always be last
path("", TemplateView.as_view(template_name="index.html")), # React entry point
From ad51f7341185ba29f5f42da43d9378631b9743fc Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 13:17:59 -0600
Subject: [PATCH 072/125] Replace drf-yasg pip with drf-spectactular
---
requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 3416804d..1f3c63aa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,7 @@ djangorestframework==3.16.1
requests==2.32.5
psutil==7.1.3
pillow
-drf-yasg>=1.21.11
+drf-spectacular>=0.28.0
streamlink
python-vlc
yt-dlp
From bd1e0e8f079c4e1a7a004b448e1c80fc1db20a56 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 14:36:57 -0600
Subject: [PATCH 073/125] Bug fix: Admin URL Conflict with XC Streams: Updated
nginx configuration to only redirect exact `/admin` and `/admin/` paths to
login in production, preventing interference with stream URLs that use
"admin" as a username (e.g., `/admin/password/stream_id` now properly routes
to stream handling instead of being redirected).
---
CHANGELOG.md | 4 ++++
dispatcharr/urls.py | 6 +++---
docker/nginx.conf | 2 +-
3 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26c3abd4..960302d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Better auto-generation of request/response schemas
- Improved documentation accuracy with serializer introspection
+### Fixed
+
+- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
+
## [0.18.1] - 2026-01-27
### Fixed
diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py
index 1ab4ebfb..092fb0cb 100644
--- a/dispatcharr/urls.py
+++ b/dispatcharr/urls.py
@@ -17,9 +17,6 @@ urlpatterns = [
path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)),
path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)),
path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)),
- # Admin
- path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
- path("admin/", admin.site.urls),
# Outputs
path("output", RedirectView.as_view(url="/output/", permanent=True)),
path("output/", include(("apps.output.urls", "output"), namespace="output")),
@@ -55,6 +52,9 @@ urlpatterns = [
xc_series_stream,
name="xc_series_stream",
),
+ # Admin
+ path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
+ path("admin/", admin.site.urls),
# VOD proxy is now handled by the main proxy URLs above
# Catch-all routes should always be last
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 406d587c..e08d08f2 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -59,7 +59,7 @@ server {
}
# admin disabled when not in dev mode
- location /admin {
+ location ~ ^/admin/?$ {
return 301 /login;
}
From 5cb0b7dd3b3e07fbe9e82dcd3a57e087889a7429 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 14:52:12 -0600
Subject: [PATCH 074/125] Add pytz to requirements. Was included as a
dependency of drf-yasg before.
---
requirements.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 1f3c63aa..6858baec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,7 @@ djangorestframework==3.16.1
requests==2.32.5
psutil==7.1.3
pillow
-drf-spectacular>=0.28.0
+drf-spectacular>=0.29.0
streamlink
python-vlc
yt-dlp
@@ -18,6 +18,7 @@ m3u8
rapidfuzz==3.14.3
regex # Required by transformers but also used for advanced regex features
tzlocal
+pytz
# PyTorch dependencies (CPU only)
--extra-index-url https://download.pytorch.org/whl/cpu/
From dfc57c23cc5e3f12acff4f9c725e615e4d05ea0a Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 27 Jan 2026 14:52:12 -0600
Subject: [PATCH 075/125] Add pytz to requirements. Was included as a
dependency of drf-yasg before.
---
requirements.txt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 1f3c63aa..6858baec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,7 @@ djangorestframework==3.16.1
requests==2.32.5
psutil==7.1.3
pillow
-drf-spectacular>=0.28.0
+drf-spectacular>=0.29.0
streamlink
python-vlc
yt-dlp
@@ -18,6 +18,7 @@ m3u8
rapidfuzz==3.14.3
regex # Required by transformers but also used for advanced regex features
tzlocal
+pytz
# PyTorch dependencies (CPU only)
--extra-index-url https://download.pytorch.org/whl/cpu/
From bd4c9e1d9505bf817daa66a50edeb5ec6b93552b Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 27 Jan 2026 13:39:04 -0800
Subject: [PATCH 076/125] Added unit tests
---
.../hooks/__tests__/useLocalStorage.test.jsx | 110 +++
.../hooks/__tests__/useSmartLogos.test.jsx | 297 ++++++
frontend/src/store/__tests__/auth.test.jsx | 479 +++++++++
.../src/store/__tests__/channels.test.jsx | 442 +++++++++
.../store/__tests__/channelsTable.test.jsx | 295 ++++++
frontend/src/store/__tests__/epgs.test.jsx | 687 +++++++++++++
frontend/src/store/__tests__/logos.test.jsx | 916 ++++++++++++++++++
.../src/store/__tests__/playlists.test.jsx | 266 +++++
frontend/src/store/__tests__/plugins.test.jsx | 234 +++++
.../src/store/__tests__/settings.test.jsx | 204 ++++
.../store/__tests__/streamProfiles.test.jsx | 265 +++++
frontend/src/store/__tests__/streams.test.jsx | 289 ++++++
.../src/store/__tests__/useVODStore.test.jsx | 755 +++++++++++++++
.../store/__tests__/useVideoStore.test.jsx | 182 ++++
.../src/store/__tests__/userAgents.test.jsx | 277 ++++++
frontend/src/store/__tests__/users.test.jsx | 258 +++++
.../src/store/__tests__/vodLogos.test.jsx | 480 +++++++++
.../src/store/__tests__/warnings.test.jsx | 195 ++++
18 files changed, 6631 insertions(+)
create mode 100644 frontend/src/hooks/__tests__/useLocalStorage.test.jsx
create mode 100644 frontend/src/hooks/__tests__/useSmartLogos.test.jsx
create mode 100644 frontend/src/store/__tests__/auth.test.jsx
create mode 100644 frontend/src/store/__tests__/channels.test.jsx
create mode 100644 frontend/src/store/__tests__/channelsTable.test.jsx
create mode 100644 frontend/src/store/__tests__/epgs.test.jsx
create mode 100644 frontend/src/store/__tests__/logos.test.jsx
create mode 100644 frontend/src/store/__tests__/playlists.test.jsx
create mode 100644 frontend/src/store/__tests__/plugins.test.jsx
create mode 100644 frontend/src/store/__tests__/settings.test.jsx
create mode 100644 frontend/src/store/__tests__/streamProfiles.test.jsx
create mode 100644 frontend/src/store/__tests__/streams.test.jsx
create mode 100644 frontend/src/store/__tests__/useVODStore.test.jsx
create mode 100644 frontend/src/store/__tests__/useVideoStore.test.jsx
create mode 100644 frontend/src/store/__tests__/userAgents.test.jsx
create mode 100644 frontend/src/store/__tests__/users.test.jsx
create mode 100644 frontend/src/store/__tests__/vodLogos.test.jsx
create mode 100644 frontend/src/store/__tests__/warnings.test.jsx
diff --git a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx
new file mode 100644
index 00000000..8be21e28
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx
@@ -0,0 +1,110 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useLocalStorage from '../useLocalStorage';
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store = {};
+
+ return {
+ getItem: vi.fn((key) => store[key] || null),
+ setItem: vi.fn((key, value) => {
+ store[key] = value.toString();
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ removeItem: vi.fn((key) => {
+ delete store[key];
+ })
+ };
+})();
+
+global.localStorage = localStorageMock;
+
+// Mock console.error to avoid cluttering test output
+global.console.error = vi.fn();
+
+describe('useLocalStorage', () => {
+ beforeEach(() => {
+ localStorageMock.clear();
+ vi.clearAllMocks();
+ });
+
+ it('should initialize with default value when localStorage is empty', () => {
+ const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+
+ expect(result.current[0]).toBe('defaultValue');
+ });
+
+ it('should initialize with value from localStorage if available', () => {
+ localStorageMock.setItem('testKey', JSON.stringify('storedValue'));
+
+ const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+
+ expect(result.current[0]).toBe('storedValue');
+ });
+
+ it('should update localStorage when value changes', () => {
+ const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
+
+ act(() => {
+ result.current[1]('updated');
+ });
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated'));
+ expect(result.current[0]).toBe('updated');
+ });
+
+ it('should handle complex objects', () => {
+ const complexObject = { name: 'test', count: 42, nested: { value: true } };
+
+ const { result } = renderHook(() => useLocalStorage('testKey', complexObject));
+
+ act(() => {
+ result.current[1]({ name: 'updated', count: 100 });
+ });
+
+ expect(result.current[0]).toEqual({ name: 'updated', count: 100 });
+ });
+
+ it('should handle errors when reading from localStorage', () => {
+ localStorageMock.getItem.mockImplementationOnce(() => {
+ throw new Error('Read error');
+ });
+
+ const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+
+ expect(result.current[0]).toBe('defaultValue');
+ expect(console.error).toHaveBeenCalledWith(
+ 'Error reading key "testKey":',
+ expect.any(Error)
+ );
+ });
+
+ it('should handle errors when writing to localStorage', () => {
+ localStorageMock.setItem.mockImplementationOnce(() => {
+ throw new Error('Write error');
+ });
+
+ const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
+
+ act(() => {
+ result.current[1]('updated');
+ });
+
+ expect(console.error).toHaveBeenCalledWith(
+ 'Error saving setting: testKey:',
+ expect.any(Error)
+ );
+ });
+
+ it('should handle invalid JSON in localStorage', () => {
+ localStorageMock.getItem.mockReturnValueOnce('invalid json{');
+
+ const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+
+ expect(result.current[0]).toBe('defaultValue');
+ expect(console.error).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx
new file mode 100644
index 00000000..d8ef2711
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx
@@ -0,0 +1,297 @@
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos';
+import useLogosStore from '../../store/logos';
+
+// Mock the logos store
+vi.mock('../../store/logos');
+
+describe('useSmartLogos', () => {
+ describe('useLogoSelection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should initialize with empty state', () => {
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogos: vi.fn(),
+ }));
+
+ const { result } = renderHook(() => useLogoSelection());
+
+ expect(result.current.logos).toEqual({});
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasLogos).toBe(false);
+ });
+
+ it('should load logos when ensureLogosLoaded is called', async () => {
+ const mockFetchLogos = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogos: mockFetchLogos,
+ }));
+
+ const { result } = renderHook(() => useLogoSelection());
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ expect(mockFetchLogos).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not reload logos if already loaded', async () => {
+ const mockFetchLogos = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: { logo1: { id: 'logo1' } },
+ fetchLogos: mockFetchLogos,
+ }));
+
+ const { result } = renderHook(() => useLogoSelection());
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ expect(mockFetchLogos).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle errors when fetching logos', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogos: mockFetchLogos,
+ }));
+
+ const { result } = renderHook(() => useLogoSelection());
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to load logos for selection:',
+ expect.any(Error)
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should indicate hasLogos when logos are present', () => {
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
+ fetchLogos: vi.fn(),
+ }));
+
+ const { result } = renderHook(() => useLogoSelection());
+
+ expect(result.current.hasLogos).toBe(true);
+ });
+ });
+
+ describe('useChannelLogoSelection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should initialize with channel logos state', () => {
+ useLogosStore.mockImplementation((selector) => selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: vi.fn(),
+ }));
+
+ const { result } = renderHook(() => useChannelLogoSelection());
+
+ expect(result.current.logos).toEqual({});
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasLogos).toBe(false);
+ });
+
+ it('should load channel logos when ensureLogosLoaded is called', async () => {
+ const mockFetchChannelLogos = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ }));
+
+ const { result } = renderHook(() => useChannelLogoSelection());
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ expect(mockFetchChannelLogos).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not reload if already loaded', async () => {
+ const mockFetchChannelLogos = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ channelLogos: { logo1: { id: 'logo1' } },
+ hasLoadedChannelLogos: true,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ }));
+
+ const { result } = renderHook(() => useChannelLogoSelection());
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ expect(mockFetchChannelLogos).not.toHaveBeenCalled();
+ });
+
+ it('should not load if backgroundLoading is true', async () => {
+ const mockFetchChannelLogos = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: true,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ }));
+
+ const { result } = renderHook(() => useChannelLogoSelection());
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ expect(mockFetchChannelLogos).not.toHaveBeenCalled();
+ });
+
+ it('should handle errors when fetching channel logos', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
+ useLogosStore.mockImplementation((selector) => selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ }));
+
+ const { result } = renderHook(() => useChannelLogoSelection());
+
+ await act(async () => {
+ await result.current.ensureLogosLoaded();
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to load channel logos:',
+ expect.any(Error)
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+ });
+
+ describe('useLogosById', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should initialize with empty logos', () => {
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogosByIds: vi.fn(),
+ }));
+
+ const { result } = renderHook(() => useLogosById([]));
+
+ expect(result.current.logos).toEqual({});
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.missingLogos).toBe(0);
+ });
+
+ it('should fetch missing logos by IDs', async () => {
+ const mockFetchLogosByIds = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ }));
+
+ renderHook(() => useLogosById(['logo1', 'logo2']));
+
+ await waitFor(() => {
+ expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
+ });
+ });
+
+ it('should not fetch logos that are already loaded', async () => {
+ const mockFetchLogosByIds = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: { logo1: { id: 'logo1' } },
+ fetchLogosByIds: mockFetchLogosByIds,
+ }));
+
+ renderHook(() => useLogosById(['logo1', 'logo2']));
+
+ await waitFor(() => {
+ expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo2']);
+ });
+ });
+
+ it('should handle errors when fetching logos by IDs', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed'));
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ }));
+
+ renderHook(() => useLogosById(['logo1']));
+
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to load logos by IDs:',
+ expect.any(Error)
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should filter out null/undefined IDs', async () => {
+ const mockFetchLogosByIds = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ }));
+
+ renderHook(() => useLogosById(['logo1', null, undefined, 'logo2']));
+
+ await waitFor(() => {
+ expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
+ });
+ });
+
+ it('should not refetch the same IDs multiple times', async () => {
+ const mockFetchLogosByIds = vi.fn().mockResolvedValue();
+ useLogosStore.mockImplementation((selector) => selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ }));
+
+ const { rerender } = renderHook(() => useLogosById(['logo1']));
+
+ await waitFor(() => {
+ expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx
new file mode 100644
index 00000000..4ae99e4b
--- /dev/null
+++ b/frontend/src/store/__tests__/auth.test.jsx
@@ -0,0 +1,479 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import useAuthStore from '../auth';
+import useSettingsStore from '../settings';
+import useChannelsStore from '../channels';
+import usePlaylistsStore from '../playlists';
+import useEPGsStore from '../epgs';
+import useStreamProfilesStore from '../streamProfiles';
+import useUserAgentsStore from '../userAgents';
+import useUsersStore from '../users';
+import API from '../../api';
+import { USER_LEVELS } from '../../constants';
+
+// Mock all store dependencies
+vi.mock('../settings');
+vi.mock('../channels');
+vi.mock('../playlists');
+vi.mock('../epgs');
+vi.mock('../streamProfiles');
+vi.mock('../userAgents');
+vi.mock('../users');
+vi.mock('../../api');
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store = {};
+ return {
+ getItem: vi.fn((key) => store[key] || null),
+ setItem: vi.fn((key, value) => {
+ store[key] = value.toString();
+ }),
+ removeItem: vi.fn((key) => {
+ delete store[key];
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ };
+})();
+
+global.localStorage = localStorageMock;
+
+// Mock console methods
+global.console.error = vi.fn();
+
+// Helper to create a mock JWT token
+const createMockToken = (expiresInSeconds = 3600) => {
+ const now = Math.floor(Date.now() / 1000);
+ const exp = now + expiresInSeconds;
+ const payload = btoa(JSON.stringify({ exp }));
+ return `header.${payload}.signature`;
+};
+
+describe('useAuthStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorageMock.clear();
+
+ // Setup default store mocks
+ useSettingsStore.mockImplementation((selector) => selector({
+ fetchSettings: vi.fn().mockResolvedValue(),
+ }));
+
+ useChannelsStore.mockImplementation((selector) => selector({
+ fetchChannels: vi.fn().mockResolvedValue(),
+ fetchChannelGroups: vi.fn().mockResolvedValue(),
+ fetchChannelProfiles: vi.fn().mockResolvedValue(),
+ }));
+
+ usePlaylistsStore.mockImplementation((selector) => selector({
+ fetchPlaylists: vi.fn().mockResolvedValue(),
+ }));
+
+ useEPGsStore.mockImplementation((selector) => selector({
+ fetchEPGs: vi.fn().mockResolvedValue(),
+ fetchEPGData: vi.fn().mockResolvedValue(),
+ }));
+
+ useStreamProfilesStore.mockImplementation((selector) => selector({
+ fetchProfiles: vi.fn().mockResolvedValue(),
+ }));
+
+ useUserAgentsStore.mockImplementation((selector) => selector({
+ fetchUserAgents: vi.fn().mockResolvedValue(),
+ }));
+
+ useUsersStore.mockImplementation((selector) => selector({
+ fetchUsers: vi.fn().mockResolvedValue(),
+ }));
+ });
+
+ afterEach(() => {
+ // Reset the store state
+ const { setState } = useAuthStore;
+ if (setState) {
+ setState({
+ isAuthenticated: false,
+ isInitialized: false,
+ needsSuperuser: false,
+ user: {
+ username: '',
+ email: '',
+ user_level: '',
+ },
+ isLoading: false,
+ error: null,
+ accessToken: null,
+ refreshToken: null,
+ tokenExpiration: null,
+ superuserExists: true,
+ });
+ }
+ });
+
+ describe('Initial State', () => {
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useAuthStore());
+
+ expect(result.current.isAuthenticated).toBe(false);
+ expect(result.current.isInitialized).toBe(false);
+ expect(result.current.needsSuperuser).toBe(false);
+ expect(result.current.user).toEqual({
+ username: '',
+ email: '',
+ user_level: '',
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(result.current.superuserExists).toBe(true);
+ });
+ });
+
+ describe('login', () => {
+ it('should successfully login and store tokens', async () => {
+ const mockAccessToken = createMockToken();
+ const mockRefreshToken = createMockToken(86400);
+
+ API.login.mockResolvedValue({
+ access: mockAccessToken,
+ refresh: mockRefreshToken,
+ });
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.login({ username: 'testuser', password: 'password' });
+ });
+
+ expect(API.login).toHaveBeenCalledWith('testuser', 'password');
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken);
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken);
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number));
+ });
+
+ it('should handle login failure', async () => {
+ API.login.mockRejectedValue(new Error('Invalid credentials'));
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.login({ username: 'testuser', password: 'wrong' });
+ });
+
+ expect(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error));
+ });
+ });
+
+ describe('getRefreshToken', () => {
+ it('should refresh token successfully', async () => {
+ const mockNewAccessToken = createMockToken();
+ localStorageMock.getItem.mockReturnValue('old-refresh-token');
+
+ API.refreshToken.mockResolvedValue({
+ access: mockNewAccessToken,
+ });
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let newToken;
+ await act(async () => {
+ newToken = await result.current.getRefreshToken();
+ });
+
+ expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token');
+ expect(newToken).toBe(mockNewAccessToken);
+ expect(result.current.isAuthenticated).toBe(true);
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken);
+ });
+
+ it('should return false if no refresh token exists', async () => {
+ localStorageMock.getItem.mockReturnValue(null);
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let response;
+ await act(async () => {
+ response = await result.current.getRefreshToken();
+ });
+
+ expect(response).toBe(false);
+ expect(API.refreshToken).not.toHaveBeenCalled();
+ });
+
+ it('should logout on refresh token failure', async () => {
+ localStorageMock.getItem.mockReturnValue('invalid-refresh-token');
+ API.refreshToken.mockRejectedValue(new Error('Invalid token'));
+ API.logout.mockResolvedValue();
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.getRefreshToken();
+ });
+
+ expect(console.error).toHaveBeenCalledWith('Token refresh failed:', expect.any(Error));
+ expect(result.current.isAuthenticated).toBe(false);
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
+ });
+ });
+
+ describe('getToken', () => {
+ it('should return valid access token if not expired', async () => {
+ const mockToken = createMockToken(3600);
+ const now = Math.floor(Date.now() / 1000);
+
+ localStorageMock.getItem.mockImplementation((key) => {
+ if (key === 'tokenExpiration') return (now + 1800).toString();
+ if (key === 'accessToken') return mockToken;
+ return null;
+ });
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let token;
+ await act(async () => {
+ token = await result.current.getToken();
+ });
+
+ expect(token).toBe(mockToken);
+ });
+
+ it('should refresh token if expired', async () => {
+ const now = Math.floor(Date.now() / 1000);
+ const mockNewToken = createMockToken();
+
+ localStorageMock.getItem.mockImplementation((key) => {
+ if (key === 'tokenExpiration') return (now - 100).toString();
+ if (key === 'refreshToken') return 'refresh-token';
+ return null;
+ });
+
+ API.refreshToken.mockResolvedValue({
+ access: mockNewToken,
+ });
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.getToken();
+ });
+
+ expect(API.refreshToken).toHaveBeenCalled();
+ });
+ });
+
+ describe('logout', () => {
+ it('should clear tokens and call logout API', async () => {
+ API.logout.mockResolvedValue();
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.logout();
+ });
+
+ expect(API.logout).toHaveBeenCalled();
+ expect(result.current.isAuthenticated).toBe(false);
+ expect(result.current.user).toBeNull();
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
+ });
+
+ it('should continue logout even if API call fails', async () => {
+ API.logout.mockRejectedValue(new Error('API error'));
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.logout();
+ });
+
+ expect(console.error).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error));
+ expect(result.current.isAuthenticated).toBe(false);
+ expect(localStorageMock.removeItem).toHaveBeenCalled();
+ });
+ });
+
+ describe('initializeAuth', () => {
+ it('should initialize auth with valid refresh token', async () => {
+ const mockToken = createMockToken();
+ localStorageMock.getItem.mockReturnValue('valid-refresh-token');
+ API.refreshToken.mockResolvedValue({ access: mockToken });
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let initialized;
+ await act(async () => {
+ initialized = await result.current.initializeAuth();
+ });
+
+ expect(initialized).toBe(true);
+ });
+
+ it('should return false if no refresh token exists', async () => {
+ localStorageMock.getItem.mockReturnValue(null);
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let initialized;
+ await act(async () => {
+ initialized = await result.current.initializeAuth();
+ });
+
+ expect(initialized).toBe(false);
+ });
+ });
+
+ describe('initData', () => {
+ const fetchSettings = vi.fn().mockResolvedValue();
+ const fetchChannels = vi.fn().mockResolvedValue();
+ const fetchChannelGroups = vi.fn().mockResolvedValue();
+ const fetchChannelProfiles = vi.fn().mockResolvedValue();
+ const fetchPlaylists = vi.fn().mockResolvedValue();
+ const fetchEPGs = vi.fn().mockResolvedValue();
+ const fetchEPGData = vi.fn().mockResolvedValue();
+ const fetchProfiles = vi.fn().mockResolvedValue();
+ const fetchUserAgents = vi.fn().mockResolvedValue();
+ const fetchUsers = vi.fn().mockResolvedValue();
+
+ // Mock getState for each store
+ useSettingsStore.getState = () => ({ fetchSettings });
+ useChannelsStore.getState = () => ({
+ fetchChannels,
+ fetchChannelGroups,
+ fetchChannelProfiles,
+ });
+ usePlaylistsStore.getState = () => ({ fetchPlaylists });
+ useEPGsStore.getState = () => ({ fetchEPGs, fetchEPGData });
+ useStreamProfilesStore.getState = () => ({ fetchProfiles });
+ useUserAgentsStore.getState = () => ({ fetchUserAgents });
+ useUsersStore.getState = () => ({ fetchUsers });
+
+ it('should initialize data for admin user', async () => {
+ const mockUser = {
+ username: 'admin',
+ email: 'admin@test.com',
+ user_level: USER_LEVELS.ADMIN,
+ };
+
+ API.me.mockResolvedValue(mockUser);
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.initData();
+ });
+
+ expect(API.me).toHaveBeenCalled();
+ expect(result.current.user).toEqual(mockUser);
+ expect(result.current.isAuthenticated).toBe(true);
+ expect(fetchSettings).toHaveBeenCalled();
+ expect(fetchChannels).toHaveBeenCalled();
+ expect(fetchUsers).toHaveBeenCalled();
+ });
+
+
+ it('should not fetch users for non-admin user', async () => {
+ const mockUser = {
+ username: 'reseller',
+ email: 'reseller@test.com',
+ user_level: USER_LEVELS.RESELLER,
+ };
+
+ API.me.mockResolvedValue(mockUser);
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.initData();
+ });
+
+ expect(fetchUsers).not.toHaveBeenCalled();
+ });
+
+ it('should throw error for unauthorized user level', async () => {
+ const mockUser = {
+ username: 'streamer',
+ email: 'streamer@test.com',
+ user_level: USER_LEVELS.STREAMER,
+ };
+
+ API.me.mockResolvedValue(mockUser);
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await expect(
+ act(async () => {
+ await result.current.initData();
+ })
+ ).rejects.toThrow('Unauthorized');
+ });
+
+ it('should handle errors during data initialization', async () => {
+ const mockUser = {
+ username: 'admin',
+ email: 'admin@test.com',
+ user_level: USER_LEVELS.ADMIN,
+ };
+
+ API.me.mockResolvedValue(mockUser);
+
+ const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed'));
+
+ useChannelsStore.getState = vi.fn(() => ({
+ fetchChannels,
+ fetchChannelGroups: vi.fn().mockResolvedValue(),
+ fetchChannelProfiles: vi.fn().mockResolvedValue(),
+ }));
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.initData();
+ });
+
+ expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error));
+ });
+ });
+
+ describe('setUser', () => {
+ it('should update user state', () => {
+ const { result } = renderHook(() => useAuthStore());
+ const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN };
+
+ act(() => {
+ result.current.setUser(newUser);
+ });
+
+ expect(result.current.user).toEqual(newUser);
+ });
+ });
+
+ describe('setIsAuthenticated', () => {
+ it('should update authentication state', () => {
+ const { result } = renderHook(() => useAuthStore());
+
+ act(() => {
+ result.current.setIsAuthenticated(true);
+ });
+
+ expect(result.current.isAuthenticated).toBe(true);
+ });
+ });
+
+ describe('setSuperuserExists', () => {
+ it('should update superuser exists state', () => {
+ const { result } = renderHook(() => useAuthStore());
+
+ act(() => {
+ result.current.setSuperuserExists(false);
+ });
+
+ expect(result.current.superuserExists).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/store/__tests__/channels.test.jsx b/frontend/src/store/__tests__/channels.test.jsx
new file mode 100644
index 00000000..6b452549
--- /dev/null
+++ b/frontend/src/store/__tests__/channels.test.jsx
@@ -0,0 +1,442 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useChannelsStore from '../channels';
+import api from '../../api';
+import { showNotification } from '../../utils/notificationUtils';
+
+// Mock dependencies
+vi.mock('../../api');
+vi.mock('../../utils/notificationUtils');
+
+describe('useChannelsStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset store state between tests
+ useChannelsStore.setState({
+ channels: {},
+ channelsByUUID: {},
+ channelGroups: {},
+ profiles: { 0: { id: '0', name: 'All', channels: new Set() } },
+ selectedProfileId: '0',
+ channelsPageSelection: [],
+ stats: {},
+ activeChannels: {},
+ activeClients: {},
+ recordings: [],
+ recurringRules: [],
+ isLoading: false,
+ error: null,
+ forceUpdate: 0,
+ });
+ });
+
+ describe('fetchChannels', () => {
+ it('should fetch and store channels successfully', async () => {
+ const mockChannels = [
+ { id: 1, uuid: 'uuid-1', name: 'Channel 1' },
+ { id: 2, uuid: 'uuid-2', name: 'Channel 2' },
+ ];
+ api.getChannels.mockResolvedValue(mockChannels);
+
+ const { result } = renderHook(() => useChannelsStore());
+
+ await act(async () => {
+ await result.current.fetchChannels();
+ });
+
+ expect(api.getChannels).toHaveBeenCalledOnce();
+ expect(result.current.channels).toEqual({
+ 1: mockChannels[0],
+ 2: mockChannels[1],
+ });
+ expect(result.current.channelsByUUID).toEqual({
+ 'uuid-1': 1,
+ 'uuid-2': 2,
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('should handle fetch error', async () => {
+ const errorMessage = 'Network error';
+ api.getChannels.mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() => useChannelsStore());
+
+ await act(async () => {
+ await result.current.fetchChannels();
+ });
+
+ expect(result.current.error).toBe(errorMessage);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ describe('fetchChannelGroups', () => {
+ it('should fetch and process channel groups', async () => {
+ const mockGroups = [
+ { id: 1, name: 'Group 1', channel_count: 5, m3u_account_count: 0 },
+ { id: 2, name: 'Group 2', channel_count: 0, m3u_account_count: 2 },
+ ];
+ api.getChannelGroups.mockResolvedValue(mockGroups);
+
+ const { result } = renderHook(() => useChannelsStore());
+
+ await act(async () => {
+ await result.current.fetchChannelGroups();
+ });
+
+ expect(api.getChannelGroups).toHaveBeenCalledOnce();
+ expect(result.current.channelGroups[1]).toMatchObject({
+ hasChannels: true,
+ hasM3UAccounts: false,
+ canEdit: true,
+ canDelete: false,
+ });
+ expect(result.current.channelGroups[2]).toMatchObject({
+ hasChannels: false,
+ hasM3UAccounts: true,
+ canEdit: false,
+ canDelete: false,
+ });
+ });
+ });
+
+ describe('fetchChannelProfiles', () => {
+ it('should fetch and process channel profiles', async () => {
+ const mockProfiles = [
+ { id: '1', name: 'Profile 1', channels: [1, 2, 3] },
+ ];
+ api.getChannelProfiles.mockResolvedValue(mockProfiles);
+
+ const { result } = renderHook(() => useChannelsStore());
+
+ await act(async () => {
+ await result.current.fetchChannelProfiles();
+ });
+
+ expect(api.getChannelProfiles).toHaveBeenCalledOnce();
+ expect(result.current.profiles['1'].channels).toBeInstanceOf(Set);
+ expect(result.current.profiles['1'].channels.has(1)).toBe(true);
+ });
+ });
+
+ describe('addChannel', () => {
+ it('should add a new channel', async () => {
+ const newChannel = { id: 3, uuid: 'uuid-3', name: 'Channel 3' };
+ api.getChannelProfiles.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useChannelsStore());
+
+ await act(async () => {
+ result.current.addChannel(newChannel);
+ });
+
+ expect(result.current.channels[3]).toEqual(newChannel);
+ expect(result.current.channelsByUUID['uuid-3']).toBe(3);
+ });
+ });
+
+ describe('updateChannel', () => {
+ it('should update an existing channel', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ channels: { 1: { id: 1, uuid: 'uuid-1', name: 'Old Name' } },
+ channelsByUUID: { 'uuid-1': 1 },
+ });
+ });
+
+ const updatedChannel = { id: 1, uuid: 'uuid-1', name: 'New Name' };
+
+ act(() => {
+ result.current.updateChannel(updatedChannel);
+ });
+
+ expect(result.current.channels[1].name).toBe('New Name');
+ });
+ });
+
+ describe('removeChannels', () => {
+ it('should remove channels by IDs', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ channels: {
+ 1: { id: 1, uuid: 'uuid-1' },
+ 2: { id: 2, uuid: 'uuid-2' },
+ },
+ channelsByUUID: { 'uuid-1': 1, 'uuid-2': 2 },
+ });
+ });
+
+ act(() => {
+ result.current.removeChannels([1]);
+ });
+
+ expect(result.current.channels[1]).toBeUndefined();
+ expect(result.current.channelsByUUID['uuid-1']).toBeUndefined();
+ expect(result.current.channels[2]).toBeDefined();
+ });
+ });
+
+ describe('channel groups operations', () => {
+ it('should add a channel group', () => {
+ const { result } = renderHook(() => useChannelsStore());
+ const newGroup = { id: 1, name: 'New Group' };
+
+ act(() => {
+ result.current.addChannelGroup(newGroup);
+ });
+
+ expect(result.current.channelGroups[1]).toEqual(newGroup);
+ });
+
+ it('should update a channel group', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ channelGroups: { 1: { id: 1, name: 'Old Name' } },
+ });
+ });
+
+ act(() => {
+ result.current.updateChannelGroup({ id: 1, name: 'Updated Name' });
+ });
+
+ expect(result.current.channelGroups[1].name).toBe('Updated Name');
+ });
+
+ it('should remove a channel group', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ channelGroups: { 1: { id: 1, name: 'Group' } },
+ });
+ });
+
+ act(() => {
+ result.current.removeChannelGroup(1);
+ });
+
+ expect(result.current.channelGroups[1]).toBeUndefined();
+ });
+ });
+
+ describe('profile operations', () => {
+ it('should add a profile', () => {
+ const { result } = renderHook(() => useChannelsStore());
+ const newProfile = { id: '1', name: 'Profile', channels: [1, 2] };
+
+ act(() => {
+ result.current.addProfile(newProfile);
+ });
+
+ expect(result.current.profiles['1'].channels).toBeInstanceOf(Set);
+ });
+
+ it('should update a profile', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] });
+ });
+
+ expect(result.current.profiles['1'].name).toBe('Updated');
+ });
+
+ it('should remove profiles and reset selected if needed', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ profiles: { '1': { id: '1' }, '2': { id: '2' } },
+ selectedProfileId: '1',
+ });
+ });
+
+ act(() => {
+ result.current.removeProfiles(['1']);
+ });
+
+ expect(result.current.profiles['1']).toBeUndefined();
+ expect(result.current.selectedProfileId).toBe('0');
+ });
+ });
+
+ describe('updateProfileChannels', () => {
+ it('should add channels to profile', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ profiles: { '1': { id: '1', channels: new Set([1]) } },
+ });
+ });
+
+ act(() => {
+ result.current.updateProfileChannels([2, 3], '1', true);
+ });
+
+ expect(result.current.profiles['1'].channels.has(2)).toBe(true);
+ expect(result.current.profiles['1'].channels.has(3)).toBe(true);
+ });
+
+ it('should remove channels from profile', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } },
+ });
+ });
+
+ act(() => {
+ result.current.updateProfileChannels([2], '1', false);
+ });
+
+ expect(result.current.profiles['1'].channels.has(2)).toBe(false);
+ });
+ });
+
+ describe('setChannelStats', () => {
+ it('should update stats and show notifications for new channels', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ channelsByUUID: { 'uuid-1': 1 },
+ stats: { channels: [] },
+ });
+ });
+
+ const newStats = {
+ channels: [
+ { channel_id: 'uuid-1', clients: [] },
+ ],
+ };
+
+ act(() => {
+ result.current.setChannelStats(newStats);
+ });
+
+ expect(result.current.stats).toEqual(newStats);
+ expect(showNotification).toHaveBeenCalled();
+ });
+ });
+
+ describe('recordings operations', () => {
+ it('should fetch recordings', async () => {
+ const mockRecordings = [{ id: 1, title: 'Recording 1' }];
+ api.getRecordings.mockResolvedValue(mockRecordings);
+
+ const { result } = renderHook(() => useChannelsStore());
+
+ await act(async () => {
+ await result.current.fetchRecordings();
+ });
+
+ expect(result.current.recordings).toEqual(mockRecordings);
+ });
+
+ it('should remove a recording', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ recordings: [{ id: 1 }, { id: 2 }],
+ });
+ });
+
+ act(() => {
+ result.current.removeRecording(1);
+ });
+
+ expect(result.current.recordings).toHaveLength(1);
+ expect(result.current.recordings[0].id).toBe(2);
+ });
+ });
+
+ describe('recurring rules operations', () => {
+ it('should fetch recurring rules', async () => {
+ const mockRules = [{ id: 1, name: 'Rule 1' }];
+ api.listRecurringRules.mockResolvedValue(mockRules);
+
+ const { result } = renderHook(() => useChannelsStore());
+
+ await act(async () => {
+ await result.current.fetchRecurringRules();
+ });
+
+ expect(result.current.recurringRules).toEqual(mockRules);
+ });
+
+ it('should remove a recurring rule', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ recurringRules: [{ id: 1 }, { id: 2 }],
+ });
+ });
+
+ act(() => {
+ result.current.removeRecurringRule(1);
+ });
+
+ expect(result.current.recurringRules).toHaveLength(1);
+ });
+ });
+
+ describe('helper methods', () => {
+ it('should validate if channel group can be edited', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ channelGroups: {
+ 1: { id: 1, canEdit: true },
+ 2: { id: 2, canEdit: false },
+ },
+ });
+ });
+
+ expect(result.current.canEditChannelGroup(1)).toBe(true);
+ expect(result.current.canEditChannelGroup(2)).toBe(false);
+ });
+
+ it('should validate if channel group can be deleted', () => {
+ const { result } = renderHook(() => useChannelsStore());
+
+ act(() => {
+ useChannelsStore.setState({
+ channelGroups: {
+ 1: { id: 1, canDelete: true },
+ 2: { id: 2, canDelete: false },
+ },
+ });
+ });
+
+ expect(result.current.canDeleteChannelGroup(1)).toBe(true);
+ expect(result.current.canDeleteChannelGroup(2)).toBe(false);
+ });
+ });
+
+ describe('triggerUpdate', () => {
+ it('should update forceUpdate timestamp', () => {
+ const { result } = renderHook(() => useChannelsStore());
+ const initialUpdate = result.current.forceUpdate;
+
+ act(() => {
+ result.current.triggerUpdate();
+ });
+
+ expect(result.current.forceUpdate).not.toBe(initialUpdate);
+ });
+ });
+});
diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx
new file mode 100644
index 00000000..171f0672
--- /dev/null
+++ b/frontend/src/store/__tests__/channelsTable.test.jsx
@@ -0,0 +1,295 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import useChannelsTableStore from '../channelsTable';
+
+describe('useChannelsTableStore', () => {
+ beforeEach(() => {
+ // Mock localStorage
+ const mockLocalStorage = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ clear: vi.fn(),
+ };
+ global.localStorage = mockLocalStorage;
+
+ vi.clearAllMocks();
+
+ // Reset store state between tests
+ useChannelsTableStore.setState({
+ channels: [],
+ pageCount: 0,
+ totalCount: 0,
+ sorting: [{ id: 'channel_number', desc: false }],
+ pagination: {
+ pageIndex: 0,
+ pageSize: 50,
+ },
+ selectedChannelIds: [],
+ allQueryIds: [],
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('initial state', () => {
+ it('should initialize with default values', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ expect(result.current.channels).toEqual([]);
+ expect(result.current.pageCount).toBe(0);
+ expect(result.current.totalCount).toBe(0);
+ expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]);
+ expect(result.current.pagination.pageIndex).toBe(0);
+ expect(result.current.pagination.pageSize).toBe(50);
+ expect(result.current.selectedChannelIds).toEqual([]);
+ expect(result.current.allQueryIds).toEqual([]);
+ });
+ });
+
+ describe('queryChannels', () => {
+ it('should update channels, totalCount, and pageCount', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockResults = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+ const mockData = {
+ results: mockResults,
+ count: 150,
+ };
+ const mockParams = new URLSearchParams({ page_size: '50' });
+
+ act(() => {
+ result.current.queryChannels(mockData, mockParams);
+ });
+
+ expect(result.current.channels).toEqual(mockResults);
+ expect(result.current.totalCount).toBe(150);
+ expect(result.current.pageCount).toBe(3); // Math.ceil(150 / 50)
+ });
+
+ it('should calculate pageCount correctly with different page sizes', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockData = {
+ results: [],
+ count: 75,
+ };
+ const mockParams = new URLSearchParams({ page_size: '25' });
+
+ act(() => {
+ result.current.queryChannels(mockData, mockParams);
+ });
+
+ expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
+ });
+
+ it('should handle zero results', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockData = {
+ results: [],
+ count: 0,
+ };
+ const mockParams = new URLSearchParams({ page_size: '50' });
+
+ act(() => {
+ result.current.queryChannels(mockData, mockParams);
+ });
+
+ expect(result.current.channels).toEqual([]);
+ expect(result.current.totalCount).toBe(0);
+ expect(result.current.pageCount).toBe(0);
+ });
+ });
+
+ describe('setAllQueryIds', () => {
+ it('should update allQueryIds', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockIds = [1, 2, 3, 4, 5];
+
+ act(() => {
+ result.current.setAllQueryIds(mockIds);
+ });
+
+ expect(result.current.allQueryIds).toEqual(mockIds);
+ });
+
+ it('should replace existing allQueryIds', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ act(() => {
+ result.current.setAllQueryIds([1, 2, 3]);
+ });
+
+ expect(result.current.allQueryIds).toEqual([1, 2, 3]);
+
+ act(() => {
+ result.current.setAllQueryIds([4, 5, 6]);
+ });
+
+ expect(result.current.allQueryIds).toEqual([4, 5, 6]);
+ });
+ });
+
+ describe('setSelectedChannelIds', () => {
+ it('should update selectedChannelIds', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockIds = [1, 3, 5];
+
+ act(() => {
+ result.current.setSelectedChannelIds(mockIds);
+ });
+
+ expect(result.current.selectedChannelIds).toEqual(mockIds);
+ });
+
+ it('should clear selectedChannelIds when empty array is passed', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ act(() => {
+ result.current.setSelectedChannelIds([1, 2, 3]);
+ });
+
+ expect(result.current.selectedChannelIds).toEqual([1, 2, 3]);
+
+ act(() => {
+ result.current.setSelectedChannelIds([]);
+ });
+
+ expect(result.current.selectedChannelIds).toEqual([]);
+ });
+ });
+
+ describe('getChannelStreams', () => {
+ it('should return streams for existing channel', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockChannels = [
+ { id: 1, name: 'Channel 1', streams: ['stream1', 'stream2'] },
+ { id: 2, name: 'Channel 2', streams: ['stream3'] },
+ ];
+
+ act(() => {
+ useChannelsTableStore.setState({ channels: mockChannels });
+ });
+
+ const streams = result.current.getChannelStreams(1);
+
+ expect(streams).toEqual(['stream1', 'stream2']);
+ });
+
+ it('should return empty array for channel without streams', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockChannels = [
+ { id: 1, name: 'Channel 1' },
+ ];
+
+ act(() => {
+ useChannelsTableStore.setState({ channels: mockChannels });
+ });
+
+ const streams = result.current.getChannelStreams(1);
+
+ expect(streams).toEqual([]);
+ });
+
+ it('should return empty array for non-existent channel', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockChannels = [
+ { id: 1, name: 'Channel 1', streams: ['stream1'] },
+ ];
+
+ act(() => {
+ useChannelsTableStore.setState({ channels: mockChannels });
+ });
+
+ const streams = result.current.getChannelStreams(999);
+
+ expect(streams).toEqual([]);
+ });
+ });
+
+ describe('setPagination', () => {
+ it('should update pagination', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const newPagination = {
+ pageIndex: 2,
+ pageSize: 100,
+ };
+
+ act(() => {
+ result.current.setPagination(newPagination);
+ });
+
+ expect(result.current.pagination).toEqual(newPagination);
+ });
+
+ it('should update only changed pagination properties', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ act(() => {
+ result.current.setPagination({
+ pageIndex: 5,
+ pageSize: 50,
+ });
+ });
+
+ expect(result.current.pagination.pageIndex).toBe(5);
+ expect(result.current.pagination.pageSize).toBe(50);
+ });
+ });
+
+ describe('setSorting', () => {
+ it('should update sorting', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const newSorting = [{ id: 'name', desc: true }];
+
+ act(() => {
+ result.current.setSorting(newSorting);
+ });
+
+ expect(result.current.sorting).toEqual(newSorting);
+ });
+
+ it('should handle multiple sorting columns', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const newSorting = [
+ { id: 'name', desc: false },
+ { id: 'channel_number', desc: true },
+ ];
+
+ act(() => {
+ result.current.setSorting(newSorting);
+ });
+
+ expect(result.current.sorting).toEqual(newSorting);
+ });
+
+ it('should clear sorting when empty array is passed', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ act(() => {
+ result.current.setSorting([{ id: 'name', desc: true }]);
+ });
+
+ expect(result.current.sorting).toHaveLength(1);
+
+ act(() => {
+ result.current.setSorting([]);
+ });
+
+ expect(result.current.sorting).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/store/__tests__/epgs.test.jsx b/frontend/src/store/__tests__/epgs.test.jsx
new file mode 100644
index 00000000..05f136d3
--- /dev/null
+++ b/frontend/src/store/__tests__/epgs.test.jsx
@@ -0,0 +1,687 @@
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import useEPGsStore from '../epgs';
+import api from '../../api';
+
+// Mock the api module
+vi.mock('../../api');
+
+describe('useEPGsStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Reset store state between tests
+ useEPGsStore.setState({
+ epgs: {},
+ tvgs: [],
+ tvgsById: {},
+ tvgsLoaded: false,
+ isLoading: false,
+ error: null,
+ refreshProgress: {},
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('initial state', () => {
+ it('should initialize with default values', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ expect(result.current.epgs).toEqual({});
+ expect(result.current.tvgs).toEqual([]);
+ expect(result.current.tvgsById).toEqual({});
+ expect(result.current.tvgsLoaded).toBe(false);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(result.current.refreshProgress).toEqual({});
+ });
+ });
+
+ describe('fetchEPGs', () => {
+ it('should fetch and store EPGs successfully', async () => {
+ const mockEPGs = [
+ { id: 'epg1', name: 'EPG 1', status: 'idle' },
+ { id: 'epg2', name: 'EPG 2', status: 'success' },
+ ];
+
+ api.getEPGs.mockResolvedValue(mockEPGs);
+
+ const { result } = renderHook(() => useEPGsStore());
+
+ await act(async () => {
+ await result.current.fetchEPGs();
+ });
+
+ expect(result.current.epgs).toEqual({
+ epg1: { id: 'epg1', name: 'EPG 1', status: 'idle' },
+ epg2: { id: 'epg2', name: 'EPG 2', status: 'success' },
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(api.getEPGs).toHaveBeenCalledTimes(1);
+ });
+
+ it('should set loading state while fetching', async () => {
+ api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
+
+ const { result } = renderHook(() => useEPGsStore());
+
+ const fetchPromise = act(async () => {
+ await result.current.fetchEPGs();
+ });
+
+ // Check loading state immediately after calling
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ await fetchPromise;
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should handle fetch error', async () => {
+ const mockError = new Error('Network error');
+ api.getEPGs.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useEPGsStore());
+
+ await act(async () => {
+ await result.current.fetchEPGs();
+ });
+
+ expect(result.current.error).toBe('Failed to load epgs.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError);
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle empty EPGs array', async () => {
+ api.getEPGs.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useEPGsStore());
+
+ await act(async () => {
+ await result.current.fetchEPGs();
+ });
+
+ expect(result.current.epgs).toEqual({});
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ describe('fetchEPGData', () => {
+ it('should fetch and store TVG data successfully', async () => {
+ const mockTVGs = [
+ { id: 'tvg1', name: 'TVG 1' },
+ { id: 'tvg2', name: 'TVG 2' },
+ ];
+
+ api.getEPGData.mockResolvedValue(mockTVGs);
+
+ const { result } = renderHook(() => useEPGsStore());
+
+ await act(async () => {
+ await result.current.fetchEPGData();
+ });
+
+ expect(result.current.tvgs).toEqual(mockTVGs);
+ expect(result.current.tvgsById).toEqual({
+ tvg1: { id: 'tvg1', name: 'TVG 1' },
+ tvg2: { id: 'tvg2', name: 'TVG 2' },
+ });
+ expect(result.current.tvgsLoaded).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(api.getEPGData).toHaveBeenCalledTimes(1);
+ });
+
+ it('should set loading state while fetching', async () => {
+ api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
+
+ const { result } = renderHook(() => useEPGsStore());
+
+ const fetchPromise = act(async () => {
+ await result.current.fetchEPGData();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ await fetchPromise;
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should handle fetch error', async () => {
+ const mockError = new Error('API error');
+ api.getEPGData.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useEPGsStore());
+
+ await act(async () => {
+ await result.current.fetchEPGData();
+ });
+
+ expect(result.current.error).toBe('Failed to load tvgs.');
+ expect(result.current.tvgsLoaded).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError);
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle empty TVG array', async () => {
+ api.getEPGData.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useEPGsStore());
+
+ await act(async () => {
+ await result.current.fetchEPGData();
+ });
+
+ expect(result.current.tvgs).toEqual([]);
+ expect(result.current.tvgsById).toEqual({});
+ expect(result.current.tvgsLoaded).toBe(true);
+ });
+ });
+
+ describe('addEPG', () => {
+ it('should add new EPG to store', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const newEPG = { id: 'epg1', name: 'New EPG', status: 'idle' };
+
+ act(() => {
+ result.current.addEPG(newEPG);
+ });
+
+ expect(result.current.epgs).toEqual({
+ epg1: newEPG,
+ });
+ });
+
+ it('should add multiple EPGs', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const epg1 = { id: 'epg1', name: 'EPG 1', status: 'idle' };
+ const epg2 = { id: 'epg2', name: 'EPG 2', status: 'success' };
+
+ act(() => {
+ result.current.addEPG(epg1);
+ result.current.addEPG(epg2);
+ });
+
+ expect(result.current.epgs).toEqual({
+ epg1,
+ epg2,
+ });
+ });
+
+ it('should not overwrite existing EPGs', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' };
+ const newEPG = { id: 'epg2', name: 'New', status: 'success' };
+
+ act(() => {
+ result.current.addEPG(originalEPG);
+ result.current.addEPG(newEPG);
+ });
+
+ expect(result.current.epgs).toEqual({
+ epg1: originalEPG,
+ epg2: newEPG,
+ });
+ });
+ });
+
+ describe('updateEPG', () => {
+ it('should update existing EPG', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' };
+ const updatedEPG = { id: 'epg1', name: 'Updated', status: 'success' };
+
+ act(() => {
+ result.current.addEPG(originalEPG);
+ });
+
+ act(() => {
+ result.current.updateEPG(updatedEPG);
+ });
+
+ expect(result.current.epgs.epg1).toEqual(updatedEPG);
+ });
+
+ it('should add EPG if it does not exist', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const newEPG = { id: 'epg1', name: 'New', status: 'idle' };
+
+ act(() => {
+ result.current.updateEPG(newEPG);
+ });
+
+ expect(result.current.epgs.epg1).toEqual(newEPG);
+ });
+
+ it('should not update state when called with invalid epg (null)', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
+ act(() => {
+ useEPGsStore.setState({ epgs: initialEPGs });
+ });
+
+ act(() => {
+ result.current.updateEPG(null);
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null);
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should not update state when called with invalid epg (missing id)', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
+ act(() => {
+ useEPGsStore.setState({ epgs: initialEPGs });
+ });
+
+ const invalidEPG = { name: 'No ID' };
+
+ act(() => {
+ result.current.updateEPG(invalidEPG);
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG);
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should not update state when called with invalid epg (non-object)', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
+ act(() => {
+ useEPGsStore.setState({ epgs: initialEPGs });
+ });
+
+ act(() => {
+ result.current.updateEPG('invalid');
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid');
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('removeEPGs', () => {
+ it('should remove single EPG', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ useEPGsStore.setState({
+ epgs: {
+ epg1: { id: 'epg1', name: 'EPG 1' },
+ epg2: { id: 'epg2', name: 'EPG 2' },
+ },
+ });
+ });
+
+ act(() => {
+ result.current.removeEPGs(['epg1']);
+ });
+
+ expect(result.current.epgs).toEqual({
+ epg2: { id: 'epg2', name: 'EPG 2' },
+ });
+ });
+
+ it('should remove multiple EPGs', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ useEPGsStore.setState({
+ epgs: {
+ epg1: { id: 'epg1', name: 'EPG 1' },
+ epg2: { id: 'epg2', name: 'EPG 2' },
+ epg3: { id: 'epg3', name: 'EPG 3' },
+ },
+ });
+ });
+
+ act(() => {
+ result.current.removeEPGs(['epg1', 'epg3']);
+ });
+
+ expect(result.current.epgs).toEqual({
+ epg2: { id: 'epg2', name: 'EPG 2' },
+ });
+ });
+
+ it('should handle removing non-existent EPG', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = {
+ epg1: { id: 'epg1', name: 'EPG 1' },
+ };
+
+ act(() => {
+ useEPGsStore.setState({ epgs: initialEPGs });
+ });
+
+ act(() => {
+ result.current.removeEPGs(['nonexistent']);
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ });
+
+ it('should handle empty array', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = {
+ epg1: { id: 'epg1', name: 'EPG 1' },
+ };
+
+ act(() => {
+ useEPGsStore.setState({ epgs: initialEPGs });
+ });
+
+ act(() => {
+ result.current.removeEPGs([]);
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ });
+ });
+
+ describe('updateEPGProgress', () => {
+ beforeEach(() => {
+ act(() => {
+ useEPGsStore.setState({
+ epgs: {
+ source1: { id: 'source1', status: 'idle', last_message: '' },
+ },
+ });
+ });
+ });
+
+ it('should update progress for downloading action', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ action: 'downloading',
+ progress: 50,
+ speed: '1.5 MB/s',
+ elapsed_time: '00:00:30',
+ time_remaining: '00:00:30',
+ });
+ });
+
+ expect(result.current.refreshProgress.source1).toEqual({
+ action: 'downloading',
+ progress: 50,
+ speed: '1.5 MB/s',
+ elapsed_time: '00:00:30',
+ time_remaining: '00:00:30',
+ status: 'in_progress',
+ });
+ expect(result.current.epgs.source1.status).toBe('fetching');
+ });
+
+ it('should update progress for parsing_channels action', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ action: 'parsing_channels',
+ progress: 75,
+ });
+ });
+
+ expect(result.current.epgs.source1.status).toBe('parsing');
+ });
+
+ it('should update progress for parsing_programs action', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ action: 'parsing_programs',
+ progress: 90,
+ });
+ });
+
+ expect(result.current.epgs.source1.status).toBe('parsing');
+ });
+
+ it('should set status to success when progress is 100', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ action: 'success',
+ progress: 100,
+ });
+ });
+
+ expect(result.current.epgs.source1.status).toBe('success');
+ });
+
+ it('should use explicit status from data', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ status: 'error',
+ progress: 50,
+ });
+ });
+
+ expect(result.current.epgs.source1.status).toBe('error');
+ expect(result.current.refreshProgress.source1.status).toBe('error');
+ });
+
+ it('should set last_message on error status', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ status: 'error',
+ error: 'Connection failed',
+ });
+ });
+
+ expect(result.current.epgs.source1.last_message).toBe('Connection failed');
+ });
+
+ it('should use default error message if error is not provided', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ status: 'error',
+ });
+ });
+
+ expect(result.current.epgs.source1.last_message).toBe('Unknown error');
+ });
+
+ it('should not update state when called with invalid data (null)', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = { ...result.current.epgs };
+ const initialProgress = { ...result.current.refreshProgress };
+
+ act(() => {
+ result.current.updateEPGProgress(null);
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ expect(result.current.refreshProgress).toEqual(initialProgress);
+ expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null);
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should not update state when called with invalid data (missing source)', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = { ...result.current.epgs };
+ const initialProgress = { ...result.current.refreshProgress };
+
+ act(() => {
+ result.current.updateEPGProgress({ progress: 50 });
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ expect(result.current.refreshProgress).toEqual(initialProgress);
+ expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 });
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should not update state when source does not exist and no status', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ const initialEPGs = { ...result.current.epgs };
+ const initialProgress = { ...result.current.refreshProgress };
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'nonexistent',
+ progress: 50,
+ });
+ });
+
+ expect(result.current.epgs).toEqual(initialEPGs);
+ expect(result.current.refreshProgress).toEqual(initialProgress);
+ });
+
+ it('should update refreshProgress even when source does not exist but status is provided', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'newSource',
+ status: 'success',
+ progress: 100,
+ });
+ });
+
+ expect(result.current.refreshProgress.newSource).toEqual({
+ action: undefined,
+ progress: 100,
+ speed: undefined,
+ elapsed_time: undefined,
+ time_remaining: undefined,
+ status: 'success',
+ });
+ });
+
+ it('should not update EPG if status and last_message have not changed', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ useEPGsStore.setState({
+ epgs: {
+ source1: { id: 'source1', status: 'fetching', last_message: '' },
+ },
+ });
+ });
+
+ const epgsBeforeUpdate = result.current.epgs;
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ action: 'downloading',
+ progress: 25,
+ });
+ });
+
+ // EPGs object should be the same reference (not updated)
+ expect(result.current.epgs).toBe(epgsBeforeUpdate);
+ // But refreshProgress should be updated
+ expect(result.current.refreshProgress.source1.progress).toBe(25);
+ });
+
+ it('should update EPG if status changed', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ useEPGsStore.setState({
+ epgs: {
+ source1: { id: 'source1', status: 'idle', last_message: '' },
+ },
+ });
+ });
+
+ const epgsBeforeUpdate = result.current.epgs;
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ action: 'downloading',
+ progress: 25,
+ });
+ });
+
+ // EPGs object should be different (updated) because status changed from 'idle' to 'fetching'
+ expect(result.current.epgs).not.toBe(epgsBeforeUpdate);
+ expect(result.current.epgs.source1.status).toBe('fetching');
+ });
+
+ it('should preserve current EPG status when no status change is detected', () => {
+ const { result } = renderHook(() => useEPGsStore());
+
+ act(() => {
+ useEPGsStore.setState({
+ epgs: {
+ source1: { id: 'source1', status: 'parsing', last_message: 'Processing' },
+ },
+ });
+ });
+
+ act(() => {
+ result.current.updateEPGProgress({
+ source: 'source1',
+ action: 'parsing_programs',
+ progress: 85,
+ });
+ });
+
+ expect(result.current.epgs.source1.status).toBe('parsing');
+ expect(result.current.epgs.source1.last_message).toBe('Processing');
+ });
+ });
+});
diff --git a/frontend/src/store/__tests__/logos.test.jsx b/frontend/src/store/__tests__/logos.test.jsx
new file mode 100644
index 00000000..59ac4283
--- /dev/null
+++ b/frontend/src/store/__tests__/logos.test.jsx
@@ -0,0 +1,916 @@
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import useLogosStore from '../logos';
+import api from '../../api';
+
+// Mock the api module
+vi.mock('../../api');
+
+describe('useLogosStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Reset store state between tests
+ useLogosStore.setState({
+ logos: {},
+ channelLogos: {},
+ isLoading: false,
+ backgroundLoading: false,
+ hasLoadedAll: false,
+ hasLoadedChannelLogos: false,
+ error: null,
+ allowLogoRendering: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('initial state', () => {
+ it('should initialize with default values', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ expect(result.current.logos).toEqual({});
+ expect(result.current.channelLogos).toEqual({});
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.backgroundLoading).toBe(false);
+ expect(result.current.hasLoadedAll).toBe(false);
+ expect(result.current.hasLoadedChannelLogos).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(result.current.allowLogoRendering).toBe(false);
+ });
+ });
+
+ describe('enableLogoRendering', () => {
+ it('should enable logo rendering', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ result.current.enableLogoRendering();
+ });
+
+ expect(result.current.allowLogoRendering).toBe(true);
+ });
+ });
+
+ describe('addLogo', () => {
+ it('should add logo to main logos store', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
+
+ act(() => {
+ result.current.addLogo(newLogo);
+ });
+
+ expect(result.current.logos).toEqual({
+ logo1: newLogo,
+ });
+ });
+
+ it('should add logo to channelLogos if hasLoadedChannelLogos is true', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ hasLoadedChannelLogos: true });
+ });
+
+ const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
+
+ act(() => {
+ result.current.addLogo(newLogo);
+ });
+
+ expect(result.current.logos).toEqual({ logo1: newLogo });
+ expect(result.current.channelLogos).toEqual({ logo1: newLogo });
+ });
+
+ it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
+
+ act(() => {
+ result.current.addLogo(newLogo);
+ });
+
+ expect(result.current.logos).toEqual({ logo1: newLogo });
+ expect(result.current.channelLogos).toEqual({});
+ });
+ });
+
+ describe('updateLogo', () => {
+ it('should update logo in main logos store', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
+ const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1: originalLogo } });
+ });
+
+ act(() => {
+ result.current.updateLogo(updatedLogo);
+ });
+
+ expect(result.current.logos.logo1).toEqual(updatedLogo);
+ });
+
+ it('should update logo in channelLogos if it exists there', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
+ const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
+
+ act(() => {
+ useLogosStore.setState({
+ logos: { logo1: originalLogo },
+ channelLogos: { logo1: originalLogo },
+ });
+ });
+
+ act(() => {
+ result.current.updateLogo(updatedLogo);
+ });
+
+ expect(result.current.logos.logo1).toEqual(updatedLogo);
+ expect(result.current.channelLogos.logo1).toEqual(updatedLogo);
+ });
+
+ it('should not update channelLogos if logo does not exist there', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
+ const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1: originalLogo } });
+ });
+
+ act(() => {
+ result.current.updateLogo(updatedLogo);
+ });
+
+ expect(result.current.channelLogos).toEqual({});
+ });
+ });
+
+ describe('removeLogo', () => {
+ it('should remove logo from both stores', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ const logo1 = { id: 'logo1', name: 'Logo 1' };
+ const logo2 = { id: 'logo2', name: 'Logo 2' };
+
+ act(() => {
+ useLogosStore.setState({
+ logos: { logo1, logo2 },
+ channelLogos: { logo1, logo2 },
+ });
+ });
+
+ act(() => {
+ result.current.removeLogo('logo1');
+ });
+
+ expect(result.current.logos).toEqual({ logo2 });
+ expect(result.current.channelLogos).toEqual({ logo2 });
+ });
+
+ it('should handle removing non-existent logo', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ const logo1 = { id: 'logo1', name: 'Logo 1' };
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1 } });
+ });
+
+ act(() => {
+ result.current.removeLogo('nonexistent');
+ });
+
+ expect(result.current.logos).toEqual({ logo1 });
+ });
+ });
+
+ describe('fetchLogos', () => {
+ it('should fetch logos successfully with array response', async () => {
+ const mockLogos = [
+ { id: 'logo1', name: 'Logo 1' },
+ { id: 'logo2', name: 'Logo 2' },
+ ];
+
+ api.getLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ let response;
+ await act(async () => {
+ response = await result.current.fetchLogos(100);
+ });
+
+ expect(result.current.logos).toEqual({
+ logo1: { id: 'logo1', name: 'Logo 1' },
+ logo2: { id: 'logo2', name: 'Logo 2' },
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(api.getLogos).toHaveBeenCalledWith({ page_size: 100 });
+ expect(response).toEqual(mockLogos);
+ });
+
+ it('should fetch logos successfully with paginated response', async () => {
+ const mockResponse = {
+ results: [
+ { id: 'logo1', name: 'Logo 1' },
+ { id: 'logo2', name: 'Logo 2' },
+ ],
+ count: 2,
+ };
+
+ api.getLogos.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ await act(async () => {
+ await result.current.fetchLogos(50);
+ });
+
+ expect(result.current.logos).toEqual({
+ logo1: { id: 'logo1', name: 'Logo 1' },
+ logo2: { id: 'logo2', name: 'Logo 2' },
+ });
+ expect(api.getLogos).toHaveBeenCalledWith({ page_size: 50 });
+ });
+
+ it('should handle fetch error', async () => {
+ const mockError = new Error('Network error');
+ api.getLogos.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ await expect(
+ act(async () => {
+ await result.current.fetchLogos();
+ })
+ ).rejects.toThrow('Network error');
+
+ await waitFor(() => {
+ expect(result.current.error).toBe('Failed to load logos.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError);
+ });
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('fetchAllLogos', () => {
+ it('should fetch all logos successfully', async () => {
+ const mockLogos = [
+ { id: 'logo1', name: 'Logo 1' },
+ { id: 'logo2', name: 'Logo 2' },
+ ];
+
+ api.getLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ let response;
+ await act(async () => {
+ response = await result.current.fetchAllLogos();
+ });
+
+ expect(result.current.logos).toEqual({
+ logo1: { id: 'logo1', name: 'Logo 1' },
+ logo2: { id: 'logo2', name: 'Logo 2' },
+ });
+ expect(result.current.hasLoadedAll).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
+ expect(response).toEqual(mockLogos);
+ });
+
+ it('should not refetch if already loaded and not forced', async () => {
+ const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
+
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({
+ logos: { logo1: mockLogos[0] },
+ hasLoadedAll: true,
+ });
+ });
+
+ const response = await act(async () => {
+ return await result.current.fetchAllLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ expect(response).toEqual([mockLogos[0]]);
+ });
+
+ it('should refetch if forced', async () => {
+ const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
+
+ api.getLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({
+ logos: { logo1: mockLogos[0] },
+ hasLoadedAll: true,
+ });
+ });
+
+ await act(async () => {
+ await result.current.fetchAllLogos(true);
+ });
+
+ expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
+ });
+
+ it('should not refetch if already loading', async () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ isLoading: true });
+ });
+
+ const response = await act(async () => {
+ return await result.current.fetchAllLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ expect(response).toEqual([]);
+ });
+
+ it('should handle fetch error', async () => {
+ const mockError = new Error('API error');
+ api.getLogos.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ await expect(
+ act(async () => {
+ await result.current.fetchAllLogos();
+ })
+ ).rejects.toThrow('API error');
+
+ await waitFor(() => {
+ expect(result.current.error).toBe('Failed to load all logos.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError);
+ });
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('fetchUsedLogos', () => {
+ it('should fetch used logos successfully', async () => {
+ const mockResponse = {
+ results: [
+ { id: 'logo1', name: 'Used Logo 1' },
+ { id: 'logo2', name: 'Used Logo 2' },
+ ],
+ };
+
+ api.getLogos.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ let response;
+ await act(async () => {
+ response = await result.current.fetchUsedLogos(100);
+ });
+
+ expect(result.current.logos).toEqual({
+ logo1: { id: 'logo1', name: 'Used Logo 1' },
+ logo2: { id: 'logo2', name: 'Used Logo 2' },
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 });
+ expect(response).toEqual(mockResponse);
+ });
+
+ it('should merge with existing logos', async () => {
+ const existingLogo = { id: 'logo1', name: 'Existing Logo' };
+ const newLogo = { id: 'logo2', name: 'New Logo' };
+
+ api.getLogos.mockResolvedValue({ results: [newLogo] });
+
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1: existingLogo } });
+ });
+
+ await act(async () => {
+ await result.current.fetchUsedLogos();
+ });
+
+ expect(result.current.logos).toEqual({
+ logo1: existingLogo,
+ logo2: newLogo,
+ });
+ });
+
+ it('should handle fetch error', async () => {
+ const mockError = new Error('Fetch error');
+ api.getLogos.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ await expect(
+ act(async () => {
+ await result.current.fetchUsedLogos();
+ })
+ ).rejects.toThrow('Fetch error');
+
+ await waitFor(() => {
+ expect(result.current.error).toBe('Failed to load used logos.');
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError);
+ });
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('fetchChannelAssignableLogos', () => {
+ it('should return cached logos if already loaded', async () => {
+ const cachedLogos = {
+ logo1: { id: 'logo1', name: 'Cached Logo' },
+ };
+
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({
+ channelLogos: cachedLogos,
+ hasLoadedChannelLogos: true,
+ });
+ });
+
+ const response = await act(async () => {
+ return await result.current.fetchChannelAssignableLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ expect(response).toEqual([cachedLogos.logo1]);
+ });
+
+ it('should fetch and cache logos if not loaded', async () => {
+ const mockLogos = [
+ { id: 'logo1', name: 'Logo 1' },
+ { id: 'logo2', name: 'Logo 2' },
+ ];
+
+ api.getLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ await act(async () => {
+ await result.current.fetchChannelAssignableLogos();
+ });
+
+ expect(result.current.channelLogos).toEqual({
+ logo1: { id: 'logo1', name: 'Logo 1' },
+ logo2: { id: 'logo2', name: 'Logo 2' },
+ });
+ expect(result.current.hasLoadedChannelLogos).toBe(true);
+ expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
+ });
+ });
+
+ describe('fetchLogosByIds', () => {
+ it('should fetch missing logos by IDs', async () => {
+ const existingLogo = { id: 'logo1', name: 'Existing' };
+ const newLogo = { id: 'logo2', name: 'New' };
+
+ api.getLogosByIds.mockResolvedValue([newLogo]);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1: existingLogo } });
+ });
+
+ let response;
+ await act(async () => {
+ response = await result.current.fetchLogosByIds(['logo1', 'logo2']);
+ });
+
+ expect(api.getLogosByIds).toHaveBeenCalledWith(['logo2']);
+ expect(result.current.logos).toEqual({
+ logo1: existingLogo,
+ logo2: newLogo,
+ });
+ expect(response).toEqual([newLogo]);
+ });
+
+ it('should return empty array if all logos exist', async () => {
+ const logo1 = { id: 'logo1', name: 'Logo 1' };
+
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1 } });
+ });
+
+ const response = await act(async () => {
+ return await result.current.fetchLogosByIds(['logo1']);
+ });
+
+ expect(api.getLogosByIds).not.toHaveBeenCalled();
+ expect(response).toEqual([]);
+ });
+
+ it('should handle fetch error', async () => {
+ const mockError = new Error('Fetch error');
+ api.getLogosByIds.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ await expect(
+ act(async () => {
+ await result.current.fetchLogosByIds(['logo1']);
+ })
+ ).rejects.toThrow('Fetch error');
+
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError);
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('fetchLogosInBackground', () => {
+ it('should fetch logos in background with pagination', async () => {
+ // vi.useRealTimers();
+
+ const page1 = {
+ results: [{ id: 'logo1', name: 'Logo 1' }],
+ next: 'http://example.com/page2',
+ };
+ const page2 = {
+ results: [{ id: 'logo2', name: 'Logo 2' }],
+ next: null,
+ };
+
+ api.getLogos
+ .mockResolvedValueOnce(page1)
+ .mockResolvedValueOnce(page2);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ await act(async () => {
+ await result.current.fetchLogosInBackground();
+ });
+
+ expect(result.current.logos).toEqual({
+ logo1: { id: 'logo1', name: 'Logo 1' },
+ logo2: { id: 'logo2', name: 'Logo 2' },
+ });
+ expect(result.current.backgroundLoading).toBe(false);
+ expect(api.getLogos).toHaveBeenCalledTimes(2);
+ expect(api.getLogos).toHaveBeenCalledWith({ page: 1, page_size: 200 });
+ expect(api.getLogos).toHaveBeenCalledWith({ page: 2, page_size: 200 });
+ });
+
+ it('should handle errors gracefully without throwing', async () => {
+ const mockError = new Error('Network error');
+ api.getLogos.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ await act(async () => {
+ await result.current.fetchLogosInBackground();
+ });
+
+ expect(result.current.backgroundLoading).toBe(false);
+ expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError);
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('backgroundLoadAllLogos', () => {
+ it('should not start if already loading', async () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ backgroundLoading: true });
+ });
+
+ await act(async () => {
+ await result.current.backgroundLoadAllLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ });
+
+ it('should not start if already loaded', async () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ hasLoadedAll: true });
+ });
+
+ await act(async () => {
+ await result.current.backgroundLoadAllLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ });
+
+ it('should load logos in background asynchronously', async () => {
+ vi.useFakeTimers();
+
+ const mockLogos = Array.from({ length: 2500 }, (_, i) => ({
+ id: `logo${i}`,
+ name: `Logo ${i}`,
+ }));
+
+ api.getLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ // Start background loading
+ result.current.backgroundLoadAllLogos();
+
+ // Advance timers to execute setTimeout
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(result.current.hasLoadedAll).toBe(true);
+ expect(result.current.backgroundLoading).toBe(false);
+ expect(Object.keys(result.current.logos).length).toBe(2500);
+
+ vi.useRealTimers();
+ });
+
+ it('should handle errors gracefully', async () => {
+ vi.useFakeTimers();
+
+ const mockError = new Error('Fetch error');
+ api.getLogos.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ result.current.backgroundLoadAllLogos();
+
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(result.current.backgroundLoading).toBe(false);
+ expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError);
+
+ consoleSpy.mockRestore();
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe('backgroundLoadChannelLogos', () => {
+ it('should not start if already loading', async () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ backgroundLoading: true });
+ });
+
+ await act(async () => {
+ await result.current.backgroundLoadChannelLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ });
+
+ it('should not start if already loaded', async () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ hasLoadedChannelLogos: true });
+ });
+
+ await act(async () => {
+ await result.current.backgroundLoadChannelLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ });
+
+ it('should not start if channelLogos already has many items', async () => {
+ const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]);
+ const channelLogosObj = Object.fromEntries(channelLogos);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ channelLogos: channelLogosObj });
+ });
+
+ await act(async () => {
+ await result.current.backgroundLoadChannelLogos();
+ });
+
+ expect(api.getLogos).not.toHaveBeenCalled();
+ });
+
+ it('should load channel logos in background', async () => {
+ const mockLogos = [
+ { id: 'logo1', name: 'Logo 1' },
+ { id: 'logo2', name: 'Logo 2' },
+ ];
+
+ api.getLogos.mockResolvedValue(mockLogos);
+
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ await act(async () => {
+ await result.current.backgroundLoadChannelLogos();
+ });
+
+ expect(result.current.hasLoadedChannelLogos).toBe(true);
+ expect(result.current.backgroundLoading).toBe(false);
+ expect(Object.keys(result.current.channelLogos).length).toBe(2);
+ expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...');
+ expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos');
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle errors gracefully', async () => {
+ const mockError = new Error('Fetch error');
+ api.getLogos.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ await act(async () => {
+ await result.current.backgroundLoadChannelLogos();
+ });
+
+ expect(result.current.backgroundLoading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ consoleLogSpy.mockRestore();
+ });
+ });
+
+ describe('startBackgroundLoading', () => {
+ it('should start background loading after delay', async () => {
+ vi.useFakeTimers();
+
+ const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
+ api.getLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useLogosStore());
+
+ result.current.startBackgroundLoading();
+
+ // Advance timer by 3 seconds
+ await act(async () => {
+ vi.advanceTimersByTime(3000);
+ await vi.runAllTimersAsync();
+ });
+
+ expect(result.current.hasLoadedAll).toBe(true);
+
+ vi.useRealTimers();
+ });
+
+ it('should handle errors in background loading', async () => {
+ vi.useFakeTimers();
+
+ const mockError = new Error('Background error');
+ api.getLogos.mockRejectedValue(mockError);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useLogosStore());
+
+ result.current.startBackgroundLoading();
+
+ await act(async () => {
+ vi.advanceTimersByTime(3000);
+ await vi.runAllTimersAsync();
+ });
+
+ expect(consoleSpy).toHaveBeenCalled();
+
+ consoleSpy.mockRestore();
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe('helper methods', () => {
+ describe('getLogoById', () => {
+ it('should return logo if it exists', () => {
+ const logo = { id: 'logo1', name: 'Logo 1' };
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1: logo } });
+ });
+
+ expect(result.current.getLogoById('logo1')).toEqual(logo);
+ });
+
+ it('should return null if logo does not exist', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ expect(result.current.getLogoById('nonexistent')).toBeNull();
+ });
+ });
+
+ describe('hasLogo', () => {
+ it('should return true if logo exists', () => {
+ const logo = { id: 'logo1', name: 'Logo 1' };
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ logos: { logo1: logo } });
+ });
+
+ expect(result.current.hasLogo('logo1')).toBe(true);
+ });
+
+ it('should return false if logo does not exist', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ expect(result.current.hasLogo('nonexistent')).toBe(false);
+ });
+ });
+
+ describe('getLogosCount', () => {
+ it('should return correct count of logos', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({
+ logos: {
+ logo1: { id: 'logo1' },
+ logo2: { id: 'logo2' },
+ logo3: { id: 'logo3' },
+ },
+ });
+ });
+
+ expect(result.current.getLogosCount()).toBe(3);
+ });
+
+ it('should return 0 for empty logos', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ expect(result.current.getLogosCount()).toBe(0);
+ });
+ });
+
+ describe('needsAllLogos', () => {
+ it('should return true if hasLoadedAll is false', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ expect(result.current.needsAllLogos()).toBe(true);
+ });
+
+ it('should return true if logos is empty', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({ hasLoadedAll: true, logos: {} });
+ });
+
+ expect(result.current.needsAllLogos()).toBe(true);
+ });
+
+ it('should return false if hasLoadedAll is true and logos exist', () => {
+ const { result } = renderHook(() => useLogosStore());
+
+ act(() => {
+ useLogosStore.setState({
+ hasLoadedAll: true,
+ logos: { logo1: { id: 'logo1' } },
+ });
+ });
+
+ expect(result.current.needsAllLogos()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/frontend/src/store/__tests__/playlists.test.jsx b/frontend/src/store/__tests__/playlists.test.jsx
new file mode 100644
index 00000000..2d49791e
--- /dev/null
+++ b/frontend/src/store/__tests__/playlists.test.jsx
@@ -0,0 +1,266 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import usePlaylistsStore from '../playlists';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('usePlaylistsStore', () => {
+ beforeEach(() => {
+ const { result } = renderHook(() => usePlaylistsStore());
+ act(() => {
+ result.current.playlists = [];
+ result.current.profiles = {};
+ result.current.refreshProgress = {};
+ result.current.isLoading = false;
+ result.current.error = null;
+ result.current.profileSearchPreview = '';
+ result.current.profileResult = '';
+ result.current.editPlaylistId = null;
+ });
+ vi.clearAllMocks();
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ expect(result.current.playlists).toEqual([]);
+ expect(result.current.profiles).toEqual({});
+ expect(result.current.refreshProgress).toEqual({});
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ expect(result.current.profileSearchPreview).toBe('');
+ expect(result.current.profileResult).toBe('');
+ expect(result.current.editPlaylistId).toBe(null);
+ });
+
+ it('should set edit playlist id', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ act(() => {
+ result.current.setEditPlaylistId('playlist1');
+ });
+
+ expect(result.current.editPlaylistId).toBe('playlist1');
+ });
+
+ it('should fetch playlist successfully', async () => {
+ const mockPlaylist = {
+ id: 'playlist1',
+ name: 'Test Playlist',
+ profiles: ['profile1', 'profile2'],
+ };
+
+ api.getPlaylist.mockResolvedValue(mockPlaylist);
+
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ act(() => {
+ result.current.playlists = [{ id: 'playlist1', name: 'Old Name' }];
+ });
+
+ await act(async () => {
+ await result.current.fetchPlaylist('playlist1');
+ });
+
+ expect(api.getPlaylist).toHaveBeenCalledWith('playlist1');
+ expect(result.current.playlists).toEqual([mockPlaylist]);
+ expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle fetch playlist error', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ api.getPlaylist.mockRejectedValue(new Error('Network error'));
+
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ await act(async () => {
+ await result.current.fetchPlaylist('playlist1');
+ });
+
+ expect(result.current.error).toBe('Failed to load playlists.');
+ expect(result.current.isLoading).toBe(false);
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should fetch playlists successfully', async () => {
+ const mockPlaylists = [
+ { id: 'playlist1', name: 'Playlist 1', profiles: ['profile1'] },
+ { id: 'playlist2', name: 'Playlist 2', profiles: ['profile2'] },
+ ];
+
+ api.getPlaylists.mockResolvedValue(mockPlaylists);
+
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ await act(async () => {
+ await result.current.fetchPlaylists();
+ });
+
+ expect(api.getPlaylists).toHaveBeenCalled();
+ expect(result.current.playlists).toEqual(mockPlaylists);
+ expect(result.current.profiles).toEqual({
+ playlist1: ['profile1'],
+ playlist2: ['profile2'],
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle fetch playlists error', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ api.getPlaylists.mockRejectedValue(new Error('Network error'));
+
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ await act(async () => {
+ await result.current.fetchPlaylists();
+ });
+
+ expect(result.current.error).toBe('Failed to load playlists.');
+ expect(result.current.isLoading).toBe(false);
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should add playlist', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+ const newPlaylist = {
+ id: 'playlist1',
+ name: 'New Playlist',
+ profiles: ['profile1'],
+ };
+
+ act(() => {
+ result.current.addPlaylist(newPlaylist);
+ });
+
+ expect(result.current.playlists).toEqual([newPlaylist]);
+ expect(result.current.profiles).toEqual({ playlist1: ['profile1'] });
+ });
+
+ it('should update playlist', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+ const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] };
+ const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] };
+
+ act(() => {
+ result.current.playlists = [existingPlaylist];
+ result.current.profiles = { playlist1: ['profile1'] };
+ });
+
+ act(() => {
+ result.current.updatePlaylist(updatedPlaylist);
+ });
+
+ expect(result.current.playlists).toEqual([updatedPlaylist]);
+ expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
+ });
+
+ it('should update profiles', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ act(() => {
+ result.current.profiles = { playlist1: ['profile1'] };
+ });
+
+ act(() => {
+ result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']);
+ });
+
+ expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] });
+ });
+
+ it('should remove playlists', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ act(() => {
+ result.current.playlists = [
+ { id: 'playlist1', name: 'Playlist 1' },
+ { id: 'playlist2', name: 'Playlist 2' },
+ { id: 'playlist3', name: 'Playlist 3' },
+ ];
+ });
+
+ act(() => {
+ result.current.removePlaylists(['playlist1', 'playlist3']);
+ });
+
+ expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]);
+ });
+
+ it('should set refresh progress with two parameters', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+ const progressData = { action: 'refreshing', progress: 50 };
+
+ act(() => {
+ result.current.setRefreshProgress('account1', progressData);
+ });
+
+ expect(result.current.refreshProgress).toEqual({ account1: progressData });
+ });
+
+ it('should set refresh progress with WebSocket data', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+ const wsData = { account: 'account1', action: 'refreshing', progress: 50 };
+
+ act(() => {
+ result.current.setRefreshProgress(wsData);
+ });
+
+ expect(result.current.refreshProgress).toEqual({ account1: wsData });
+ });
+
+ it('should preserve initializing status until real progress', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ act(() => {
+ result.current.refreshProgress = {
+ account1: { action: 'initializing', progress: 0 },
+ };
+ });
+
+ act(() => {
+ result.current.setRefreshProgress({ account: 'account1', progress: 0 });
+ });
+
+ expect(result.current.refreshProgress.account1.action).toBe('initializing');
+
+ act(() => {
+ result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 });
+ });
+
+ expect(result.current.refreshProgress.account1.action).toBe('refreshing');
+ });
+
+ it('should remove refresh progress', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ act(() => {
+ result.current.refreshProgress = {
+ account1: { action: 'refreshing', progress: 50 },
+ account2: { action: 'refreshing', progress: 75 },
+ };
+ });
+
+ act(() => {
+ result.current.removeRefreshProgress('account1');
+ });
+
+ expect(result.current.refreshProgress).toEqual({
+ account2: { action: 'refreshing', progress: 75 },
+ });
+ });
+
+ it('should set profile preview', () => {
+ const { result } = renderHook(() => usePlaylistsStore());
+
+ act(() => {
+ result.current.setProfilePreview('search text', 'result data');
+ });
+
+ expect(result.current.profileSearchPreview).toBe('search text');
+ expect(result.current.profileResult).toBe('result data');
+ });
+});
diff --git a/frontend/src/store/__tests__/plugins.test.jsx b/frontend/src/store/__tests__/plugins.test.jsx
new file mode 100644
index 00000000..2db48095
--- /dev/null
+++ b/frontend/src/store/__tests__/plugins.test.jsx
@@ -0,0 +1,234 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { usePluginStore } from '../plugins';
+import API from '../../api';
+
+vi.mock('../../api');
+
+describe('usePluginStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ usePluginStore.setState({
+ plugins: [],
+ loading: false,
+ error: null,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => usePluginStore());
+
+ expect(result.current.plugins).toEqual([]);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should fetch plugins successfully', async () => {
+ const mockPlugins = [
+ { key: 'plugin1', name: 'Plugin 1', enabled: true },
+ { key: 'plugin2', name: 'Plugin 2', enabled: false },
+ ];
+
+ API.getPlugins.mockResolvedValue(mockPlugins);
+
+ const { result } = renderHook(() => usePluginStore());
+
+ await act(async () => {
+ await result.current.fetchPlugins();
+ });
+
+ expect(API.getPlugins).toHaveBeenCalled();
+ expect(result.current.plugins).toEqual(mockPlugins);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle fetch plugins with empty response', async () => {
+ API.getPlugins.mockResolvedValue(null);
+
+ const { result } = renderHook(() => usePluginStore());
+
+ await act(async () => {
+ await result.current.fetchPlugins();
+ });
+
+ expect(result.current.plugins).toEqual([]);
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should handle fetch plugins error', async () => {
+ const mockError = new Error('Network error');
+ API.getPlugins.mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => usePluginStore());
+
+ await act(async () => {
+ await result.current.fetchPlugins();
+ });
+
+ expect(result.current.error).toEqual(mockError);
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ API.getPlugins.mockReturnValue(promise);
+
+ const { result } = renderHook(() => usePluginStore());
+
+ // Start the fetch without awaiting
+ act(() => {
+ result.current.fetchPlugins();
+ });
+
+ // Check loading is true synchronously
+ expect(result.current.loading).toBe(true);
+
+ // Resolve the promise and wait for state update
+ await act(async () => {
+ resolvePromise([]);
+ await promise;
+ });
+
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should update plugin', () => {
+ const { result } = renderHook(() => usePluginStore());
+
+ act(() => {
+ usePluginStore.setState({
+ plugins: [
+ { key: 'plugin1', name: 'Plugin 1', enabled: false },
+ { key: 'plugin2', name: 'Plugin 2', enabled: false },
+ ],
+ });
+ });
+
+ act(() => {
+ result.current.updatePlugin('plugin1', { enabled: true });
+ });
+
+ expect(result.current.plugins).toEqual([
+ { key: 'plugin1', name: 'Plugin 1', enabled: true },
+ { key: 'plugin2', name: 'Plugin 2', enabled: false },
+ ]);
+ });
+
+ it('should not modify other plugins when updating', () => {
+ const { result } = renderHook(() => usePluginStore());
+
+ act(() => {
+ usePluginStore.setState({
+ plugins: [
+ { key: 'plugin1', name: 'Plugin 1', enabled: false },
+ { key: 'plugin2', name: 'Plugin 2', enabled: false },
+ ],
+ });
+ });
+
+ act(() => {
+ result.current.updatePlugin('plugin1', { name: 'Updated Plugin' });
+ });
+
+ expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false });
+ });
+
+ it('should add plugin', () => {
+ const { result } = renderHook(() => usePluginStore());
+ const newPlugin = { key: 'plugin1', name: 'New Plugin', enabled: true };
+
+ act(() => {
+ result.current.addPlugin(newPlugin);
+ });
+
+ expect(result.current.plugins).toEqual([newPlugin]);
+ });
+
+ it('should add plugin to existing plugins', () => {
+ const { result } = renderHook(() => usePluginStore());
+ const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true };
+ const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false };
+
+ act(() => {
+ usePluginStore.setState({ plugins: [existingPlugin] });
+ });
+
+ act(() => {
+ result.current.addPlugin(newPlugin);
+ });
+
+ expect(result.current.plugins).toEqual([existingPlugin, newPlugin]);
+ });
+
+ it('should remove plugin', () => {
+ const { result } = renderHook(() => usePluginStore());
+
+ act(() => {
+ usePluginStore.setState({
+ plugins: [
+ { key: 'plugin1', name: 'Plugin 1', enabled: true },
+ { key: 'plugin2', name: 'Plugin 2', enabled: false },
+ ],
+ });
+ });
+
+ act(() => {
+ result.current.removePlugin('plugin1');
+ });
+
+ expect(result.current.plugins).toEqual([
+ { key: 'plugin2', name: 'Plugin 2', enabled: false },
+ ]);
+ });
+
+ it('should handle removing non-existent plugin', () => {
+ const { result } = renderHook(() => usePluginStore());
+
+ act(() => {
+ usePluginStore.setState({
+ plugins: [
+ { key: 'plugin1', name: 'Plugin 1', enabled: true },
+ ],
+ });
+ });
+
+ act(() => {
+ result.current.removePlugin('nonexistent');
+ });
+
+ expect(result.current.plugins).toEqual([
+ { key: 'plugin1', name: 'Plugin 1', enabled: true },
+ ]);
+ });
+
+ it('should invalidate plugins and refetch', async () => {
+ const mockPlugins = [
+ { key: 'plugin1', name: 'Plugin 1', enabled: true },
+ ];
+
+ API.getPlugins.mockResolvedValue(mockPlugins);
+
+ const { result } = renderHook(() => usePluginStore());
+
+ act(() => {
+ usePluginStore.setState({
+ plugins: [
+ { key: 'old-plugin', name: 'Old Plugin', enabled: false },
+ ],
+ });
+ });
+
+ await act(async () => {
+ await result.current.invalidatePlugins();
+ });
+
+ expect(result.current.plugins).toEqual(mockPlugins);
+ expect(API.getPlugins).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx
new file mode 100644
index 00000000..59da4437
--- /dev/null
+++ b/frontend/src/store/__tests__/settings.test.jsx
@@ -0,0 +1,204 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useSettingsStore from '../settings';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('useSettingsStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useSettingsStore.setState({
+ settings: {},
+ environment: {
+ public_ip: '',
+ country_code: '',
+ country_name: '',
+ env_mode: 'prod',
+ },
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useSettingsStore());
+
+ expect(result.current.settings).toEqual({});
+ expect(result.current.environment).toEqual({
+ public_ip: '',
+ country_code: '',
+ country_name: '',
+ env_mode: 'prod',
+ });
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should fetch settings successfully', async () => {
+ const mockSettings = [
+ { key: 'setting1', value: 'value1' },
+ { key: 'setting2', value: 'value2' },
+ ];
+ const mockEnv = {
+ public_ip: '192.168.1.1',
+ country_code: 'US',
+ country_name: 'United States',
+ env_mode: 'dev',
+ };
+
+ api.getSettings.mockResolvedValue(mockSettings);
+ api.getEnvironmentSettings.mockResolvedValue(mockEnv);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ await act(async () => {
+ await result.current.fetchSettings();
+ });
+
+ expect(api.getSettings).toHaveBeenCalled();
+ expect(api.getEnvironmentSettings).toHaveBeenCalled();
+ expect(result.current.settings).toEqual({
+ setting1: { key: 'setting1', value: 'value1' },
+ setting2: { key: 'setting2', value: 'value2' },
+ });
+ expect(result.current.environment).toEqual(mockEnv);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle null environment response', async () => {
+ const mockSettings = [{ key: 'setting1', value: 'value1' }];
+
+ api.getSettings.mockResolvedValue(mockSettings);
+ api.getEnvironmentSettings.mockResolvedValue(null);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ await act(async () => {
+ await result.current.fetchSettings();
+ });
+
+ expect(result.current.environment).toEqual({
+ public_ip: '',
+ country_code: '',
+ country_name: '',
+ env_mode: 'prod',
+ });
+ });
+
+ it('should handle fetch settings error', async () => {
+ const mockError = new Error('Network error');
+ api.getSettings.mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ await act(async () => {
+ await result.current.fetchSettings();
+ });
+
+ expect(result.current.error).toBe('Failed to load settings.');
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolveSettingsPromise;
+ let resolveEnvPromise;
+ const settingsPromise = new Promise((resolve) => {
+ resolveSettingsPromise = resolve;
+ });
+ const envPromise = new Promise((resolve) => {
+ resolveEnvPromise = resolve;
+ });
+
+ api.getSettings.mockReturnValue(settingsPromise);
+ api.getEnvironmentSettings.mockReturnValue(envPromise);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ act(() => {
+ result.current.fetchSettings();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBe(null);
+
+ await act(async () => {
+ resolveSettingsPromise([]);
+ resolveEnvPromise({});
+ await settingsPromise;
+ await envPromise;
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should update setting', () => {
+ useSettingsStore.setState({
+ settings: {
+ setting1: { key: 'setting1', value: 'old_value' },
+ setting2: { key: 'setting2', value: 'value2' },
+ },
+ });
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ act(() => {
+ result.current.updateSetting({ key: 'setting1', value: 'new_value' });
+ });
+
+ expect(result.current.settings).toEqual({
+ setting1: { key: 'setting1', value: 'new_value' },
+ setting2: { key: 'setting2', value: 'value2' },
+ });
+ });
+
+ it('should add new setting when updating non-existent key', () => {
+ useSettingsStore.setState({
+ settings: {
+ setting1: { key: 'setting1', value: 'value1' },
+ },
+ });
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ act(() => {
+ result.current.updateSetting({ key: 'setting2', value: 'new_value' });
+ });
+
+ expect(result.current.settings).toEqual({
+ setting1: { key: 'setting1', value: 'value1' },
+ setting2: { key: 'setting2', value: 'new_value' },
+ });
+ });
+
+ it('should not modify other settings when updating', () => {
+ useSettingsStore.setState({
+ settings: {
+ setting1: { key: 'setting1', value: 'value1' },
+ setting2: { key: 'setting2', value: 'value2' },
+ },
+ });
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ act(() => {
+ result.current.updateSetting({ key: 'setting1', value: 'updated' });
+ });
+
+ expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' });
+ });
+
+ it('should handle empty settings array', async () => {
+ api.getSettings.mockResolvedValue([]);
+ api.getEnvironmentSettings.mockResolvedValue({});
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ await act(async () => {
+ await result.current.fetchSettings();
+ });
+
+ expect(result.current.settings).toEqual({});
+ });
+});
diff --git a/frontend/src/store/__tests__/streamProfiles.test.jsx b/frontend/src/store/__tests__/streamProfiles.test.jsx
new file mode 100644
index 00000000..30380664
--- /dev/null
+++ b/frontend/src/store/__tests__/streamProfiles.test.jsx
@@ -0,0 +1,265 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useStreamProfilesStore from '../streamProfiles';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('useStreamProfilesStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useStreamProfilesStore.setState({
+ profiles: [],
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ expect(result.current.profiles).toEqual([]);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should fetch profiles successfully', async () => {
+ const mockProfiles = [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ];
+
+ api.getStreamProfiles.mockResolvedValue(mockProfiles);
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ await act(async () => {
+ await result.current.fetchProfiles();
+ });
+
+ expect(api.getStreamProfiles).toHaveBeenCalled();
+ expect(result.current.profiles).toEqual(mockProfiles);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle fetch profiles error', async () => {
+ const mockError = new Error('Network error');
+ api.getStreamProfiles.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ await act(async () => {
+ await result.current.fetchProfiles();
+ });
+
+ expect(result.current.error).toBe('Failed to load profiles.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ api.getStreamProfiles.mockReturnValue(promise);
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ act(() => {
+ result.current.fetchProfiles();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBe(null);
+
+ await act(async () => {
+ resolvePromise([]);
+ await promise;
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should add stream profile', () => {
+ useStreamProfilesStore.setState({
+ profiles: [{ id: 1, name: 'Profile 1', bitrate: 5000 }],
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+ const newProfile = { id: 2, name: 'Profile 2', bitrate: 8000 };
+
+ act(() => {
+ result.current.addStreamProfile(newProfile);
+ });
+
+ expect(result.current.profiles).toEqual([
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ]);
+ });
+
+ it('should add stream profile to empty profiles', () => {
+ const { result } = renderHook(() => useStreamProfilesStore());
+ const newProfile = { id: 1, name: 'Profile 1', bitrate: 5000 };
+
+ act(() => {
+ result.current.addStreamProfile(newProfile);
+ });
+
+ expect(result.current.profiles).toEqual([newProfile]);
+ });
+
+ it('should update stream profile', () => {
+ useStreamProfilesStore.setState({
+ profiles: [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+ const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 };
+
+ act(() => {
+ result.current.updateStreamProfile(updatedProfile);
+ });
+
+ expect(result.current.profiles).toEqual([
+ { id: 1, name: 'Updated Profile', bitrate: 10000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ]);
+ });
+
+ it('should not modify other profiles when updating', () => {
+ useStreamProfilesStore.setState({
+ profiles: [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+ const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 };
+
+ act(() => {
+ result.current.updateStreamProfile(updatedProfile);
+ });
+
+ expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 });
+ });
+
+ it('should not modify profiles when updating non-existent profile', () => {
+ const initialProfiles = [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ];
+
+ useStreamProfilesStore.setState({
+ profiles: initialProfiles,
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+ const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 };
+
+ act(() => {
+ result.current.updateStreamProfile(nonExistentProfile);
+ });
+
+ expect(result.current.profiles).toEqual(initialProfiles);
+ });
+
+ it('should remove single stream profile', () => {
+ useStreamProfilesStore.setState({
+ profiles: [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ { id: 3, name: 'Profile 3', bitrate: 10000 },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ act(() => {
+ result.current.removeStreamProfiles([2]);
+ });
+
+ expect(result.current.profiles).toEqual([
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 3, name: 'Profile 3', bitrate: 10000 },
+ ]);
+ });
+
+ it('should remove multiple stream profiles', () => {
+ useStreamProfilesStore.setState({
+ profiles: [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ { id: 3, name: 'Profile 3', bitrate: 10000 },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ act(() => {
+ result.current.removeStreamProfiles([1, 3]);
+ });
+
+ expect(result.current.profiles).toEqual([
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ]);
+ });
+
+ it('should handle removing non-existent profiles', () => {
+ const initialProfiles = [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ { id: 2, name: 'Profile 2', bitrate: 8000 },
+ ];
+
+ useStreamProfilesStore.setState({
+ profiles: initialProfiles,
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ act(() => {
+ result.current.removeStreamProfiles([999]);
+ });
+
+ expect(result.current.profiles).toEqual(initialProfiles);
+ });
+
+ it('should handle removing from empty profiles', () => {
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ act(() => {
+ result.current.removeStreamProfiles([1, 2]);
+ });
+
+ expect(result.current.profiles).toEqual([]);
+ });
+
+ it('should handle empty array when removing profiles', () => {
+ const initialProfiles = [
+ { id: 1, name: 'Profile 1', bitrate: 5000 },
+ ];
+
+ useStreamProfilesStore.setState({
+ profiles: initialProfiles,
+ });
+
+ const { result } = renderHook(() => useStreamProfilesStore());
+
+ act(() => {
+ result.current.removeStreamProfiles([]);
+ });
+
+ expect(result.current.profiles).toEqual(initialProfiles);
+ });
+});
diff --git a/frontend/src/store/__tests__/streams.test.jsx b/frontend/src/store/__tests__/streams.test.jsx
new file mode 100644
index 00000000..e6ed3952
--- /dev/null
+++ b/frontend/src/store/__tests__/streams.test.jsx
@@ -0,0 +1,289 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useStreamsStore from '../streams';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('useStreamsStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useStreamsStore.setState({
+ streams: [],
+ count: 0,
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useStreamsStore());
+
+ expect(result.current.streams).toEqual([]);
+ expect(result.current.count).toBe(0);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should fetch streams successfully', async () => {
+ const mockResponse = {
+ results: [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ],
+ count: 2,
+ };
+
+ api.getStreams.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ await act(async () => {
+ await result.current.fetchStreams();
+ });
+
+ expect(api.getStreams).toHaveBeenCalled();
+ expect(result.current.streams).toEqual(mockResponse.results);
+ expect(result.current.count).toBe(2);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle fetch streams error', async () => {
+ const mockError = new Error('Network error');
+ api.getStreams.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ await act(async () => {
+ await result.current.fetchStreams();
+ });
+
+ expect(result.current.error).toBe('Failed to load streams.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ api.getStreams.mockReturnValue(promise);
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ act(() => {
+ result.current.fetchStreams();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBe(null);
+
+ await act(async () => {
+ resolvePromise({ results: [], count: 0 });
+ await promise;
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should add stream', () => {
+ useStreamsStore.setState({
+ streams: [{ id: 1, name: 'Stream 1', url: 'http://example.com/1' }],
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+ const newStream = { id: 2, name: 'Stream 2', url: 'http://example.com/2' };
+
+ act(() => {
+ result.current.addStream(newStream);
+ });
+
+ expect(result.current.streams).toEqual([
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ]);
+ });
+
+ it('should add stream to empty streams', () => {
+ const { result } = renderHook(() => useStreamsStore());
+ const newStream = { id: 1, name: 'Stream 1', url: 'http://example.com/1' };
+
+ act(() => {
+ result.current.addStream(newStream);
+ });
+
+ expect(result.current.streams).toEqual([newStream]);
+ });
+
+ it('should update stream', () => {
+ useStreamsStore.setState({
+ streams: [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+ const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
+
+ act(() => {
+ result.current.updateStream(updatedStream);
+ });
+
+ expect(result.current.streams).toEqual([
+ { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ]);
+ });
+
+ it('should not modify other streams when updating', () => {
+ useStreamsStore.setState({
+ streams: [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+ const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
+
+ act(() => {
+ result.current.updateStream(updatedStream);
+ });
+
+ expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' });
+ });
+
+ it('should not modify streams when updating non-existent stream', () => {
+ const initialStreams = [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ];
+
+ useStreamsStore.setState({
+ streams: initialStreams,
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+ const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' };
+
+ act(() => {
+ result.current.updateStream(nonExistentStream);
+ });
+
+ expect(result.current.streams).toEqual(initialStreams);
+ });
+
+ it('should remove single stream', () => {
+ useStreamsStore.setState({
+ streams: [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ { id: 3, name: 'Stream 3', url: 'http://example.com/3' },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ act(() => {
+ result.current.removeStreams([2]);
+ });
+
+ expect(result.current.streams).toEqual([
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 3, name: 'Stream 3', url: 'http://example.com/3' },
+ ]);
+ });
+
+ it('should remove multiple streams', () => {
+ useStreamsStore.setState({
+ streams: [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ { id: 3, name: 'Stream 3', url: 'http://example.com/3' },
+ ],
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ act(() => {
+ result.current.removeStreams([1, 3]);
+ });
+
+ expect(result.current.streams).toEqual([
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ]);
+ });
+
+ it('should handle removing non-existent streams', () => {
+ const initialStreams = [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ { id: 2, name: 'Stream 2', url: 'http://example.com/2' },
+ ];
+
+ useStreamsStore.setState({
+ streams: initialStreams,
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ act(() => {
+ result.current.removeStreams([999]);
+ });
+
+ expect(result.current.streams).toEqual(initialStreams);
+ });
+
+ it('should handle removing from empty streams', () => {
+ const { result } = renderHook(() => useStreamsStore());
+
+ act(() => {
+ result.current.removeStreams([1, 2]);
+ });
+
+ expect(result.current.streams).toEqual([]);
+ });
+
+ it('should handle empty array when removing streams', () => {
+ const initialStreams = [
+ { id: 1, name: 'Stream 1', url: 'http://example.com/1' },
+ ];
+
+ useStreamsStore.setState({
+ streams: initialStreams,
+ });
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ act(() => {
+ result.current.removeStreams([]);
+ });
+
+ expect(result.current.streams).toEqual(initialStreams);
+ });
+
+ it('should handle fetch with empty results', async () => {
+ const mockResponse = {
+ results: [],
+ count: 0,
+ };
+
+ api.getStreams.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useStreamsStore());
+
+ await act(async () => {
+ await result.current.fetchStreams();
+ });
+
+ expect(result.current.streams).toEqual([]);
+ expect(result.current.count).toBe(0);
+ });
+});
diff --git a/frontend/src/store/__tests__/useVODStore.test.jsx b/frontend/src/store/__tests__/useVODStore.test.jsx
new file mode 100644
index 00000000..5734e5a2
--- /dev/null
+++ b/frontend/src/store/__tests__/useVODStore.test.jsx
@@ -0,0 +1,755 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useVODStore from '../useVODStore';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('useVODStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useVODStore.setState({
+ content: {},
+ currentPageContent: [],
+ episodes: {},
+ categories: {},
+ loading: false,
+ error: null,
+ filters: {
+ type: 'all',
+ search: '',
+ category: '',
+ },
+ currentPage: 1,
+ totalCount: 0,
+ pageSize: 24,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useVODStore());
+
+ expect(result.current.content).toEqual({});
+ expect(result.current.currentPageContent).toEqual([]);
+ expect(result.current.episodes).toEqual({});
+ expect(result.current.categories).toEqual({});
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe(null);
+ expect(result.current.filters).toEqual({
+ type: 'all',
+ search: '',
+ category: '',
+ });
+ expect(result.current.currentPage).toBe(1);
+ expect(result.current.totalCount).toBe(0);
+ expect(result.current.pageSize).toBe(24);
+ });
+
+ it('should set filters and reset to first page', () => {
+ useVODStore.setState({ currentPage: 5 });
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.setFilters({ search: 'test', category: 'action' });
+ });
+
+ expect(result.current.filters).toEqual({
+ type: 'all',
+ search: 'test',
+ category: 'action',
+ });
+ expect(result.current.currentPage).toBe(1);
+ });
+
+ it('should set page', () => {
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.setPage(3);
+ });
+
+ expect(result.current.currentPage).toBe(3);
+ });
+
+ it('should set page size and reset to first page', () => {
+ useVODStore.setState({ currentPage: 3 });
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.setPageSize(50);
+ });
+
+ expect(result.current.pageSize).toBe(50);
+ expect(result.current.currentPage).toBe(1);
+ });
+
+ it('should fetch all content successfully', async () => {
+ const mockResponse = {
+ results: [
+ { id: 1, name: 'Movie 1', content_type: 'movie' },
+ { id: 2, name: 'Series 1', content_type: 'series' },
+ ],
+ count: 2,
+ };
+
+ api.getAllContent.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ await result.current.fetchContent();
+ });
+
+ expect(api.getAllContent).toHaveBeenCalled();
+ expect(result.current.currentPageContent).toEqual([
+ { id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' },
+ { id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' },
+ ]);
+ expect(result.current.totalCount).toBe(2);
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should fetch only movies when filter type is movies', async () => {
+ const mockResponse = {
+ results: [
+ { id: 1, name: 'Movie 1' },
+ { id: 2, name: 'Movie 2' },
+ ],
+ count: 2,
+ };
+
+ api.getMovies.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.setFilters({ type: 'movies' });
+ });
+
+ await act(async () => {
+ await result.current.fetchContent();
+ });
+
+ expect(api.getMovies).toHaveBeenCalled();
+ expect(result.current.currentPageContent).toEqual([
+ { id: 1, name: 'Movie 1', contentType: 'movie' },
+ { id: 2, name: 'Movie 2', contentType: 'movie' },
+ ]);
+ expect(result.current.totalCount).toBe(2);
+ });
+
+ it('should fetch only series when filter type is series', async () => {
+ const mockResponse = {
+ results: [
+ { id: 1, name: 'Series 1' },
+ { id: 2, name: 'Series 2' },
+ ],
+ count: 2,
+ };
+
+ api.getSeries.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.setFilters({ type: 'series' });
+ });
+
+ await act(async () => {
+ await result.current.fetchContent();
+ });
+
+ expect(api.getSeries).toHaveBeenCalled();
+ expect(result.current.currentPageContent).toEqual([
+ { id: 1, name: 'Series 1', contentType: 'series' },
+ { id: 2, name: 'Series 2', contentType: 'series' },
+ ]);
+ expect(result.current.totalCount).toBe(2);
+ });
+
+ it('should handle fetch content error', async () => {
+ const mockError = new Error('Network error');
+ api.getAllContent.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ await result.current.fetchContent();
+ });
+
+ expect(result.current.error).toBe('Failed to load content.');
+ expect(result.current.loading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should handle invalid response format', async () => {
+ api.getAllContent.mockResolvedValue({ results: 'not-an-array' });
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ await result.current.fetchContent();
+ });
+
+ expect(result.current.error).toBe('Failed to load content.');
+ expect(consoleErrorSpy).toHaveBeenCalled();
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should fetch movie details successfully', async () => {
+ const mockResponse = {
+ id: 1,
+ name: 'Test Movie',
+ description: 'A test movie',
+ year: 2023,
+ url: 'http://example.com/movie.mp4',
+ };
+
+ api.getMovieDetails.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ let movieDetails;
+ await act(async () => {
+ movieDetails = await result.current.fetchMovieDetails(1);
+ });
+
+ expect(api.getMovieDetails).toHaveBeenCalledWith(1);
+ expect(movieDetails.id).toBe(1);
+ expect(movieDetails.name).toBe('Test Movie');
+ expect(movieDetails.stream_url).toBe('http://example.com/movie.mp4');
+ expect(result.current.content['movie_1']).toBeDefined();
+ expect(result.current.content['movie_1'].contentType).toBe('movie');
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should handle fetch movie details error', async () => {
+ const mockError = new Error('Not found');
+ api.getMovieDetails.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ try {
+ await result.current.fetchMovieDetails(999);
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(result.current.error).toBe('Failed to load movie details.');
+ expect(result.current.loading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should fetch movie details from provider without merging to store', async () => {
+ const mockResponse = {
+ id: 1,
+ name: 'Provider Movie',
+ plot: 'From provider',
+ stream_url: 'http://provider.com/movie.mp4',
+ backdrop_path: ['path1', 'path2'],
+ };
+
+ api.getMovieProviderInfo.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ let movieDetails;
+ await act(async () => {
+ movieDetails = await result.current.fetchMovieDetailsFromProvider(1);
+ });
+
+ expect(api.getMovieProviderInfo).toHaveBeenCalledWith(1);
+ expect(movieDetails.name).toBe('Provider Movie');
+ expect(movieDetails.description).toBe('From provider');
+ expect(movieDetails.backdrop_path).toEqual(['path1', 'path2']);
+ expect(result.current.content['movie_1']).toBeUndefined();
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should handle fetch movie provider error', async () => {
+ const mockError = new Error('Provider error');
+ api.getMovieProviderInfo.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ try {
+ await result.current.fetchMovieDetailsFromProvider(1);
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(result.current.error).toBe('Failed to load movie details from provider.');
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should fetch movie providers successfully', async () => {
+ const mockProviders = [
+ { id: 1, name: 'Provider 1' },
+ { id: 2, name: 'Provider 2' },
+ ];
+
+ api.getMovieProviders.mockResolvedValue(mockProviders);
+
+ const { result } = renderHook(() => useVODStore());
+
+ let providers;
+ await act(async () => {
+ providers = await result.current.fetchMovieProviders(1);
+ });
+
+ expect(api.getMovieProviders).toHaveBeenCalledWith(1);
+ expect(providers).toEqual(mockProviders);
+ });
+
+ it('should handle fetch movie providers error', async () => {
+ const mockError = new Error('Providers error');
+ api.getMovieProviders.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ try {
+ await result.current.fetchMovieProviders(1);
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should fetch series providers successfully', async () => {
+ const mockProviders = [{ id: 1, name: 'Series Provider 1' }];
+
+ api.getSeriesProviders.mockResolvedValue(mockProviders);
+
+ const { result } = renderHook(() => useVODStore());
+
+ let providers;
+ await act(async () => {
+ providers = await result.current.fetchSeriesProviders(1);
+ });
+
+ expect(api.getSeriesProviders).toHaveBeenCalledWith(1);
+ expect(providers).toEqual(mockProviders);
+ });
+
+ it('should handle fetch series providers error', async () => {
+ const mockError = new Error('Series providers error');
+ api.getSeriesProviders.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ try {
+ await result.current.fetchSeriesProviders(1);
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should fetch series info successfully', async () => {
+ const mockResponse = {
+ id: 1,
+ name: 'Test Series',
+ description: 'A test series',
+ year: 2023,
+ cover: 'http://example.com/cover.jpg',
+ episodes: {
+ 1: [
+ {
+ id: 101,
+ title: 'Episode 1',
+ episode_number: 1,
+ plot: 'First episode',
+ },
+ {
+ id: 102,
+ title: 'Episode 2',
+ episode_number: 2,
+ plot: 'Second episode',
+ },
+ ],
+ },
+ };
+
+ api.getSeriesInfo.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ let seriesInfo;
+ await act(async () => {
+ seriesInfo = await result.current.fetchSeriesInfo(1);
+ });
+
+ expect(api.getSeriesInfo).toHaveBeenCalledWith(1);
+ expect(seriesInfo.id).toBe(1);
+ expect(seriesInfo.name).toBe('Test Series');
+ expect(seriesInfo.episodesList).toHaveLength(2);
+ expect(result.current.content['series_1']).toBeDefined();
+ expect(result.current.content['series_1'].contentType).toBe('series');
+ expect(result.current.episodes[101]).toBeDefined();
+ expect(result.current.episodes[102]).toBeDefined();
+ expect(result.current.episodes[101].name).toBe('Episode 1');
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should handle fetch series info error', async () => {
+ const mockError = new Error('Series not found');
+ api.getSeriesInfo.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ try {
+ await result.current.fetchSeriesInfo(999);
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(result.current.error).toBe('Failed to load series details.');
+ expect(result.current.loading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should fetch categories successfully with array response', async () => {
+ const mockCategories = [
+ { id: 1, name: 'Action' },
+ { id: 2, name: 'Comedy' },
+ ];
+
+ api.getVODCategories.mockResolvedValue(mockCategories);
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ await result.current.fetchCategories();
+ });
+
+ expect(api.getVODCategories).toHaveBeenCalled();
+ expect(result.current.categories).toEqual({
+ 1: { id: 1, name: 'Action' },
+ 2: { id: 2, name: 'Comedy' },
+ });
+ });
+
+ it('should fetch categories successfully with paginated response', async () => {
+ const mockResponse = {
+ results: [
+ { id: 1, name: 'Drama' },
+ { id: 2, name: 'Thriller' },
+ ],
+ };
+
+ api.getVODCategories.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ await result.current.fetchCategories();
+ });
+
+ expect(result.current.categories).toEqual({
+ 1: { id: 1, name: 'Drama' },
+ 2: { id: 2, name: 'Thriller' },
+ });
+ });
+
+ it('should handle fetch categories error', async () => {
+ const mockError = new Error('Categories error');
+ api.getVODCategories.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODStore());
+
+ await act(async () => {
+ await result.current.fetchCategories();
+ });
+
+ expect(result.current.error).toBe('Failed to load categories.');
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should add movie to content', () => {
+ const { result } = renderHook(() => useVODStore());
+ const movie = { id: 1, name: 'New Movie' };
+
+ act(() => {
+ result.current.addMovie(movie);
+ });
+
+ expect(result.current.content['movie_1']).toEqual({
+ id: 1,
+ name: 'New Movie',
+ contentType: 'movie',
+ });
+ });
+
+ it('should update movie in content', () => {
+ useVODStore.setState({
+ content: {
+ movie_1: { id: 1, name: 'Old Movie', contentType: 'movie' },
+ },
+ });
+
+ const { result } = renderHook(() => useVODStore());
+ const updatedMovie = { id: 1, name: 'Updated Movie' };
+
+ act(() => {
+ result.current.updateMovie(updatedMovie);
+ });
+
+ expect(result.current.content['movie_1']).toEqual({
+ id: 1,
+ name: 'Updated Movie',
+ contentType: 'movie',
+ });
+ });
+
+ it('should remove movie from content', () => {
+ useVODStore.setState({
+ content: {
+ movie_1: { id: 1, name: 'Movie to Remove', contentType: 'movie' },
+ movie_2: { id: 2, name: 'Movie to Keep', contentType: 'movie' },
+ },
+ });
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.removeMovie(1);
+ });
+
+ expect(result.current.content['movie_1']).toBeUndefined();
+ expect(result.current.content['movie_2']).toBeDefined();
+ });
+
+ it('should add series to content', () => {
+ const { result } = renderHook(() => useVODStore());
+ const series = { id: 1, name: 'New Series' };
+
+ act(() => {
+ result.current.addSeries(series);
+ });
+
+ expect(result.current.content['series_1']).toEqual({
+ id: 1,
+ name: 'New Series',
+ contentType: 'series',
+ });
+ });
+
+ it('should update series in content', () => {
+ useVODStore.setState({
+ content: {
+ series_1: { id: 1, name: 'Old Series', contentType: 'series' },
+ },
+ });
+
+ const { result } = renderHook(() => useVODStore());
+ const updatedSeries = { id: 1, name: 'Updated Series' };
+
+ act(() => {
+ result.current.updateSeries(updatedSeries);
+ });
+
+ expect(result.current.content['series_1']).toEqual({
+ id: 1,
+ name: 'Updated Series',
+ contentType: 'series',
+ });
+ });
+
+ it('should remove series from content', () => {
+ useVODStore.setState({
+ content: {
+ series_1: { id: 1, name: 'Series to Remove', contentType: 'series' },
+ series_2: { id: 2, name: 'Series to Keep', contentType: 'series' },
+ },
+ });
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.removeSeries(1);
+ });
+
+ expect(result.current.content['series_1']).toBeUndefined();
+ expect(result.current.content['series_2']).toBeDefined();
+ });
+
+ it('should get filtered content from current page', () => {
+ const mockContent = [
+ { id: 1, name: 'Movie 1', contentType: 'movie' },
+ { id: 2, name: 'Series 1', contentType: 'series' },
+ ];
+
+ useVODStore.setState({
+ currentPageContent: mockContent,
+ });
+
+ const { result } = renderHook(() => useVODStore());
+ const filtered = result.current.getFilteredContent();
+
+ expect(filtered).toEqual(mockContent);
+ });
+
+ it('should get only movies from content', () => {
+ useVODStore.setState({
+ content: {
+ movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
+ series_1: { id: 2, name: 'Series 1', contentType: 'series' },
+ movie_2: { id: 3, name: 'Movie 2', contentType: 'movie' },
+ },
+ });
+
+ const { result } = renderHook(() => useVODStore());
+ const movies = result.current.getMovies();
+
+ expect(movies).toHaveLength(2);
+ expect(movies.every((item) => item.contentType === 'movie')).toBe(true);
+ });
+
+ it('should get only series from content', () => {
+ useVODStore.setState({
+ content: {
+ movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
+ series_1: { id: 2, name: 'Series 1', contentType: 'series' },
+ series_2: { id: 3, name: 'Series 2', contentType: 'series' },
+ },
+ });
+
+ const { result } = renderHook(() => useVODStore());
+ const series = result.current.getSeries();
+
+ expect(series).toHaveLength(2);
+ expect(series.every((item) => item.contentType === 'series')).toBe(true);
+ });
+
+ it('should clear all content', () => {
+ useVODStore.setState({
+ content: {
+ movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
+ series_1: { id: 2, name: 'Series 1', contentType: 'series' },
+ },
+ totalCount: 2,
+ });
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.clearContent();
+ });
+
+ expect(result.current.content).toEqual({});
+ expect(result.current.totalCount).toBe(0);
+ });
+
+ it('should handle fetch content with search filter', async () => {
+ const mockResponse = {
+ results: [{ id: 1, name: 'Searched Movie', content_type: 'movie' }],
+ count: 1,
+ };
+
+ api.getAllContent.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.setFilters({ search: 'Searched' });
+ });
+
+ await act(async () => {
+ await result.current.fetchContent();
+ });
+
+ expect(api.getAllContent).toHaveBeenCalled();
+ const callArgs = api.getAllContent.mock.calls[0][0];
+ expect(callArgs.get('search')).toBe('Searched');
+ });
+
+ it('should handle fetch content with category filter', async () => {
+ const mockResponse = {
+ results: [{ id: 1, name: 'Action Movie', content_type: 'movie' }],
+ count: 1,
+ };
+
+ api.getAllContent.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.setFilters({ category: 'action' });
+ });
+
+ await act(async () => {
+ await result.current.fetchContent();
+ });
+
+ const callArgs = api.getAllContent.mock.calls[0][0];
+ expect(callArgs.get('category')).toBe('action');
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ api.getAllContent.mockReturnValue(promise);
+
+ const { result } = renderHook(() => useVODStore());
+
+ act(() => {
+ result.current.fetchContent();
+ });
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.error).toBe(null);
+
+ await act(async () => {
+ resolvePromise({ results: [], count: 0 });
+ await promise;
+ });
+
+ expect(result.current.loading).toBe(false);
+ });
+});
diff --git a/frontend/src/store/__tests__/useVideoStore.test.jsx b/frontend/src/store/__tests__/useVideoStore.test.jsx
new file mode 100644
index 00000000..b1f87d6d
--- /dev/null
+++ b/frontend/src/store/__tests__/useVideoStore.test.jsx
@@ -0,0 +1,182 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach } from 'vitest';
+import useVideoStore from '../useVideoStore';
+
+describe('useVideoStore', () => {
+ beforeEach(() => {
+ useVideoStore.setState({
+ isVisible: false,
+ streamUrl: null,
+ contentType: 'live',
+ metadata: null,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useVideoStore());
+
+ expect(result.current.isVisible).toBe(false);
+ expect(result.current.streamUrl).toBe(null);
+ expect(result.current.contentType).toBe('live');
+ expect(result.current.metadata).toBe(null);
+ });
+
+ it('should show video with live stream', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const streamUrl = 'http://example.com/stream.ts';
+
+ act(() => {
+ result.current.showVideo(streamUrl);
+ });
+
+ expect(result.current.isVisible).toBe(true);
+ expect(result.current.streamUrl).toBe(streamUrl);
+ expect(result.current.contentType).toBe('live');
+ expect(result.current.metadata).toBe(null);
+ });
+
+ it('should show video with VOD content', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const streamUrl = 'http://example.com/video.mp4';
+ const metadata = { title: 'Test Video', duration: 120 };
+
+ act(() => {
+ result.current.showVideo(streamUrl, 'vod', metadata);
+ });
+
+ expect(result.current.isVisible).toBe(true);
+ expect(result.current.streamUrl).toBe(streamUrl);
+ expect(result.current.contentType).toBe('vod');
+ expect(result.current.metadata).toEqual(metadata);
+ });
+
+ it('should show video with custom content type', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const streamUrl = 'http://example.com/video.mkv';
+
+ act(() => {
+ result.current.showVideo(streamUrl, 'vod');
+ });
+
+ expect(result.current.isVisible).toBe(true);
+ expect(result.current.streamUrl).toBe(streamUrl);
+ expect(result.current.contentType).toBe('vod');
+ expect(result.current.metadata).toBe(null);
+ });
+
+ it('should hide video and reset state', () => {
+ const { result } = renderHook(() => useVideoStore());
+
+ act(() => {
+ result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' });
+ });
+
+ expect(result.current.isVisible).toBe(true);
+
+ act(() => {
+ result.current.hideVideo();
+ });
+
+ expect(result.current.isVisible).toBe(false);
+ expect(result.current.streamUrl).toBe(null);
+ expect(result.current.contentType).toBe('live');
+ expect(result.current.metadata).toBe(null);
+ });
+
+ it('should update stream when showing different video', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const firstUrl = 'http://example.com/stream1.ts';
+ const secondUrl = 'http://example.com/stream2.ts';
+
+ act(() => {
+ result.current.showVideo(firstUrl);
+ });
+
+ expect(result.current.streamUrl).toBe(firstUrl);
+
+ act(() => {
+ result.current.showVideo(secondUrl);
+ });
+
+ expect(result.current.streamUrl).toBe(secondUrl);
+ expect(result.current.isVisible).toBe(true);
+ });
+
+ it('should handle showing video with null metadata explicitly', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const streamUrl = 'http://example.com/stream.ts';
+
+ act(() => {
+ result.current.showVideo(streamUrl, 'live', null);
+ });
+
+ expect(result.current.isVisible).toBe(true);
+ expect(result.current.streamUrl).toBe(streamUrl);
+ expect(result.current.contentType).toBe('live');
+ expect(result.current.metadata).toBe(null);
+ });
+
+ it('should preserve metadata when showing VOD content', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const metadata = {
+ title: 'Test Video',
+ duration: 120,
+ thumbnailUrl: 'http://example.com/thumb.jpg'
+ };
+
+ act(() => {
+ result.current.showVideo('http://example.com/video.mp4', 'vod', metadata);
+ });
+
+ expect(result.current.metadata).toEqual(metadata);
+ expect(result.current.metadata.title).toBe('Test Video');
+ expect(result.current.metadata.duration).toBe(120);
+ });
+
+ it('should override previous metadata when showing new video', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const firstMetadata = { title: 'First Video' };
+ const secondMetadata = { title: 'Second Video' };
+
+ act(() => {
+ result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata);
+ });
+
+ expect(result.current.metadata).toEqual(firstMetadata);
+
+ act(() => {
+ result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata);
+ });
+
+ expect(result.current.metadata).toEqual(secondMetadata);
+ });
+
+ it('should handle hiding video when already hidden', () => {
+ const { result } = renderHook(() => useVideoStore());
+
+ expect(result.current.isVisible).toBe(false);
+
+ act(() => {
+ result.current.hideVideo();
+ });
+
+ expect(result.current.isVisible).toBe(false);
+ expect(result.current.streamUrl).toBe(null);
+ });
+
+ it('should handle showing video multiple times consecutively', () => {
+ const { result } = renderHook(() => useVideoStore());
+ const url1 = 'http://example.com/stream1.ts';
+ const url2 = 'http://example.com/stream2.ts';
+ const url3 = 'http://example.com/stream3.ts';
+
+ act(() => {
+ result.current.showVideo(url1);
+ result.current.showVideo(url2);
+ result.current.showVideo(url3);
+ });
+
+ expect(result.current.isVisible).toBe(true);
+ expect(result.current.streamUrl).toBe(url3);
+ });
+});
diff --git a/frontend/src/store/__tests__/userAgents.test.jsx b/frontend/src/store/__tests__/userAgents.test.jsx
new file mode 100644
index 00000000..8e804bec
--- /dev/null
+++ b/frontend/src/store/__tests__/userAgents.test.jsx
@@ -0,0 +1,277 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useUserAgentsStore from '../userAgents';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('useUserAgentsStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useUserAgentsStore.setState({
+ userAgents: [],
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ expect(result.current.userAgents).toEqual([]);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should fetch user agents successfully', async () => {
+ const mockUserAgents = [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ];
+
+ api.getUserAgents.mockResolvedValue(mockUserAgents);
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ await act(async () => {
+ await result.current.fetchUserAgents();
+ });
+
+ expect(api.getUserAgents).toHaveBeenCalled();
+ expect(result.current.userAgents).toEqual(mockUserAgents);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle fetch user agents error', async () => {
+ const mockError = new Error('Network error');
+ api.getUserAgents.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ await act(async () => {
+ await result.current.fetchUserAgents();
+ });
+
+ expect(result.current.error).toBe('Failed to load userAgents.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ api.getUserAgents.mockReturnValue(promise);
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ act(() => {
+ result.current.fetchUserAgents();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBe(null);
+
+ await act(async () => {
+ resolvePromise([]);
+ await promise;
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should add user agent', () => {
+ useUserAgentsStore.setState({
+ userAgents: [{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }],
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+ const newUserAgent = { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' };
+
+ act(() => {
+ result.current.addUserAgent(newUserAgent);
+ });
+
+ expect(result.current.userAgents).toEqual([
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ]);
+ });
+
+ it('should add user agent to empty user agents', () => {
+ const { result } = renderHook(() => useUserAgentsStore());
+ const newUserAgent = { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' };
+
+ act(() => {
+ result.current.addUserAgent(newUserAgent);
+ });
+
+ expect(result.current.userAgents).toEqual([newUserAgent]);
+ });
+
+ it('should update user agent', () => {
+ useUserAgentsStore.setState({
+ userAgents: [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+ const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
+
+ act(() => {
+ result.current.updateUserAgent(updatedUserAgent);
+ });
+
+ expect(result.current.userAgents).toEqual([
+ { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ]);
+ });
+
+ it('should not modify other user agents when updating', () => {
+ useUserAgentsStore.setState({
+ userAgents: [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+ const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
+
+ act(() => {
+ result.current.updateUserAgent(updatedUserAgent);
+ });
+
+ expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' });
+ });
+
+ it('should not modify user agents when updating non-existent user agent', () => {
+ const initialUserAgents = [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ];
+
+ useUserAgentsStore.setState({
+ userAgents: initialUserAgents,
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+ const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' };
+
+ act(() => {
+ result.current.updateUserAgent(nonExistentUserAgent);
+ });
+
+ expect(result.current.userAgents).toEqual(initialUserAgents);
+ });
+
+ it('should remove single user agent', () => {
+ useUserAgentsStore.setState({
+ userAgents: [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ { id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ act(() => {
+ result.current.removeUserAgents([2]);
+ });
+
+ expect(result.current.userAgents).toEqual([
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
+ ]);
+ });
+
+ it('should remove multiple user agents', () => {
+ useUserAgentsStore.setState({
+ userAgents: [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ { id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ act(() => {
+ result.current.removeUserAgents([1, 3]);
+ });
+
+ expect(result.current.userAgents).toEqual([
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ]);
+ });
+
+ it('should handle removing non-existent user agents', () => {
+ const initialUserAgents = [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
+ ];
+
+ useUserAgentsStore.setState({
+ userAgents: initialUserAgents,
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ act(() => {
+ result.current.removeUserAgents([999]);
+ });
+
+ expect(result.current.userAgents).toEqual(initialUserAgents);
+ });
+
+ it('should handle removing from empty user agents', () => {
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ act(() => {
+ result.current.removeUserAgents([1, 2]);
+ });
+
+ expect(result.current.userAgents).toEqual([]);
+ });
+
+ it('should handle empty array when removing user agents', () => {
+ const initialUserAgents = [
+ { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
+ ];
+
+ useUserAgentsStore.setState({
+ userAgents: initialUserAgents,
+ });
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ act(() => {
+ result.current.removeUserAgents([]);
+ });
+
+ expect(result.current.userAgents).toEqual(initialUserAgents);
+ });
+
+ it('should handle fetch with empty results', async () => {
+ api.getUserAgents.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useUserAgentsStore());
+
+ await act(async () => {
+ await result.current.fetchUserAgents();
+ });
+
+ expect(result.current.userAgents).toEqual([]);
+ });
+});
diff --git a/frontend/src/store/__tests__/users.test.jsx b/frontend/src/store/__tests__/users.test.jsx
new file mode 100644
index 00000000..58b343ba
--- /dev/null
+++ b/frontend/src/store/__tests__/users.test.jsx
@@ -0,0 +1,258 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useUsersStore from '../users';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('useUsersStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useUsersStore.setState({
+ users: [],
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useUsersStore());
+
+ expect(result.current.users).toEqual([]);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should fetch users successfully', async () => {
+ const mockUsers = [
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ ];
+
+ api.getUsers.mockResolvedValue(mockUsers);
+
+ const { result } = renderHook(() => useUsersStore());
+
+ await act(async () => {
+ await result.current.fetchUsers();
+ });
+
+ expect(api.getUsers).toHaveBeenCalled();
+ expect(result.current.users).toEqual(mockUsers);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle fetch users error', async () => {
+ const mockError = new Error('Network error');
+ api.getUsers.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useUsersStore());
+
+ await act(async () => {
+ await result.current.fetchUsers();
+ });
+
+ expect(result.current.error).toBe('Failed to load users.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ api.getUsers.mockReturnValue(promise);
+
+ const { result } = renderHook(() => useUsersStore());
+
+ act(() => {
+ result.current.fetchUsers();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBe(null);
+
+ await act(async () => {
+ resolvePromise([]);
+ await promise;
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should add user', () => {
+ useUsersStore.setState({
+ users: [{ id: 1, name: 'User 1', email: 'user1@example.com' }],
+ });
+
+ const { result } = renderHook(() => useUsersStore());
+ const newUser = { id: 2, name: 'User 2', email: 'user2@example.com' };
+
+ act(() => {
+ result.current.addUser(newUser);
+ });
+
+ expect(result.current.users).toEqual([
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ ]);
+ });
+
+ it('should add user to empty users', () => {
+ const { result } = renderHook(() => useUsersStore());
+ const newUser = { id: 1, name: 'User 1', email: 'user1@example.com' };
+
+ act(() => {
+ result.current.addUser(newUser);
+ });
+
+ expect(result.current.users).toEqual([newUser]);
+ });
+
+ it('should update user', () => {
+ useUsersStore.setState({
+ users: [
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUsersStore());
+ const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
+
+ act(() => {
+ result.current.updateUser(updatedUser);
+ });
+
+ expect(result.current.users).toEqual([
+ { id: 1, name: 'Updated User', email: 'updated@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ ]);
+ });
+
+ it('should not modify other users when updating', () => {
+ useUsersStore.setState({
+ users: [
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUsersStore());
+ const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
+
+ act(() => {
+ result.current.updateUser(updatedUser);
+ });
+
+ expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' });
+ });
+
+ it('should not modify users when updating non-existent user', () => {
+ const initialUsers = [
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ ];
+
+ useUsersStore.setState({
+ users: initialUsers,
+ });
+
+ const { result } = renderHook(() => useUsersStore());
+ const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' };
+
+ act(() => {
+ result.current.updateUser(nonExistentUser);
+ });
+
+ expect(result.current.users).toEqual(initialUsers);
+ });
+
+ it('should remove user', () => {
+ useUsersStore.setState({
+ users: [
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ { id: 3, name: 'User 3', email: 'user3@example.com' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUsersStore());
+
+ act(() => {
+ result.current.removeUser(2);
+ });
+
+ expect(result.current.users).toEqual([
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 3, name: 'User 3', email: 'user3@example.com' },
+ ]);
+ });
+
+ it('should handle removing non-existent user', () => {
+ const initialUsers = [
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ ];
+
+ useUsersStore.setState({
+ users: initialUsers,
+ });
+
+ const { result } = renderHook(() => useUsersStore());
+
+ act(() => {
+ result.current.removeUser(999);
+ });
+
+ expect(result.current.users).toEqual(initialUsers);
+ });
+
+ it('should handle removing from empty users', () => {
+ const { result } = renderHook(() => useUsersStore());
+
+ act(() => {
+ result.current.removeUser(1);
+ });
+
+ expect(result.current.users).toEqual([]);
+ });
+
+ it('should handle fetch with empty results', async () => {
+ api.getUsers.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useUsersStore());
+
+ await act(async () => {
+ await result.current.fetchUsers();
+ });
+
+ expect(result.current.users).toEqual([]);
+ });
+
+ it('should not modify other users when removing', () => {
+ useUsersStore.setState({
+ users: [
+ { id: 1, name: 'User 1', email: 'user1@example.com' },
+ { id: 2, name: 'User 2', email: 'user2@example.com' },
+ { id: 3, name: 'User 3', email: 'user3@example.com' },
+ ],
+ });
+
+ const { result } = renderHook(() => useUsersStore());
+
+ act(() => {
+ result.current.removeUser(2);
+ });
+
+ expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' });
+ expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' });
+ });
+});
diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx
new file mode 100644
index 00000000..6ac3cc44
--- /dev/null
+++ b/frontend/src/store/__tests__/vodLogos.test.jsx
@@ -0,0 +1,480 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import useVODLogosStore from '../vodLogos';
+import api from '../../api';
+
+vi.mock('../../api');
+
+describe('useVODLogosStore', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useVODLogosStore.setState({
+ vodLogos: {},
+ logos: [],
+ isLoading: false,
+ hasLoaded: false,
+ error: null,
+ totalCount: 0,
+ currentPage: 1,
+ pageSize: 25,
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useVODLogosStore());
+
+ expect(result.current.vodLogos).toEqual({});
+ expect(result.current.logos).toEqual([]);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasLoaded).toBe(false);
+ expect(result.current.error).toBe(null);
+ expect(result.current.totalCount).toBe(0);
+ expect(result.current.currentPage).toBe(1);
+ expect(result.current.pageSize).toBe(25);
+ });
+
+ it('should set VOD logos with normalized structure', () => {
+ const { result } = renderHook(() => useVODLogosStore());
+ const mockLogos = [
+ { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
+ { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
+ ];
+
+ act(() => {
+ result.current.setVODLogos(mockLogos, 2);
+ });
+
+ expect(result.current.vodLogos).toEqual({
+ 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
+ 2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
+ });
+ expect(result.current.totalCount).toBe(2);
+ expect(result.current.hasLoaded).toBe(true);
+ });
+
+ it('should fetch VOD logos successfully with array response', async () => {
+ const mockLogos = [
+ { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
+ { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
+ ];
+
+ api.getVODLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ await result.current.fetchVODLogos();
+ });
+
+ expect(api.getVODLogos).toHaveBeenCalled();
+ expect(result.current.vodLogos).toEqual({
+ 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
+ 2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
+ });
+ expect(result.current.logos).toEqual(mockLogos);
+ expect(result.current.totalCount).toBe(2);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasLoaded).toBe(true);
+ });
+
+ it('should fetch VOD logos successfully with paginated response', async () => {
+ const mockLogos = [
+ { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
+ ];
+ const mockResponse = {
+ results: mockLogos,
+ count: 10,
+ };
+
+ api.getVODLogos.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ await result.current.fetchVODLogos({ page: 1, page_size: 25 });
+ });
+
+ expect(result.current.vodLogos).toEqual({
+ 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
+ });
+ expect(result.current.logos).toEqual(mockLogos);
+ expect(result.current.totalCount).toBe(10);
+ });
+
+ it('should handle fetch VOD logos error', async () => {
+ const mockError = new Error('Network error');
+ api.getVODLogos.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ try {
+ await result.current.fetchVODLogos();
+ } catch (error) {
+ // Expected error
+ }
+ });
+
+ expect(result.current.error).toBe('Failed to load VOD logos.');
+ expect(result.current.isLoading).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ api.getVODLogos.mockReturnValue(promise);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ result.current.fetchVODLogos();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBe(null);
+
+ await act(async () => {
+ resolvePromise([]);
+ await promise;
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should remove single VOD logo from state', () => {
+ useVODLogosStore.setState({
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ 2: { id: 2, name: 'Logo 2' },
+ 3: { id: 3, name: 'Logo 3' },
+ },
+ logos: [
+ { id: 1, name: 'Logo 1' },
+ { id: 2, name: 'Logo 2' },
+ { id: 3, name: 'Logo 3' },
+ ],
+ totalCount: 3,
+ });
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ result.current.removeVODLogo(2);
+ });
+
+ expect(result.current.vodLogos).toEqual({
+ 1: { id: 1, name: 'Logo 1' },
+ 3: { id: 3, name: 'Logo 3' },
+ });
+ expect(result.current.logos).toEqual([
+ { id: 1, name: 'Logo 1' },
+ { id: 3, name: 'Logo 3' },
+ ]);
+ expect(result.current.totalCount).toBe(2);
+ });
+
+ it('should remove multiple VOD logos from state using _removeLogosFromState', () => {
+ useVODLogosStore.setState({
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ 2: { id: 2, name: 'Logo 2' },
+ 3: { id: 3, name: 'Logo 3' },
+ },
+ logos: [
+ { id: 1, name: 'Logo 1' },
+ { id: 2, name: 'Logo 2' },
+ { id: 3, name: 'Logo 3' },
+ ],
+ totalCount: 3,
+ });
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ result.current._removeLogosFromState([1, 3]);
+ });
+
+ expect(result.current.vodLogos).toEqual({
+ 2: { id: 2, name: 'Logo 2' },
+ });
+ expect(result.current.logos).toEqual([
+ { id: 2, name: 'Logo 2' },
+ ]);
+ expect(result.current.totalCount).toBe(1);
+ });
+
+ it('should delete single VOD logo successfully', async () => {
+ useVODLogosStore.setState({
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ 2: { id: 2, name: 'Logo 2' },
+ },
+ logos: [
+ { id: 1, name: 'Logo 1' },
+ { id: 2, name: 'Logo 2' },
+ ],
+ totalCount: 2,
+ });
+
+ api.deleteVODLogo.mockResolvedValue({});
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ await result.current.deleteVODLogo(1);
+ });
+
+ expect(api.deleteVODLogo).toHaveBeenCalledWith(1);
+ expect(result.current.vodLogos).toEqual({
+ 2: { id: 2, name: 'Logo 2' },
+ });
+ expect(result.current.logos).toEqual([
+ { id: 2, name: 'Logo 2' },
+ ]);
+ expect(result.current.totalCount).toBe(1);
+ });
+
+ it('should handle delete VOD logo error', async () => {
+ const mockError = new Error('Delete failed');
+ api.deleteVODLogo.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ try {
+ await result.current.deleteVODLogo(1);
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should delete multiple VOD logos successfully', async () => {
+ useVODLogosStore.setState({
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ 2: { id: 2, name: 'Logo 2' },
+ 3: { id: 3, name: 'Logo 3' },
+ },
+ logos: [
+ { id: 1, name: 'Logo 1' },
+ { id: 2, name: 'Logo 2' },
+ { id: 3, name: 'Logo 3' },
+ ],
+ totalCount: 3,
+ });
+
+ api.deleteVODLogos.mockResolvedValue({});
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ await result.current.deleteVODLogos([1, 2]);
+ });
+
+ expect(api.deleteVODLogos).toHaveBeenCalledWith([1, 2]);
+ expect(result.current.vodLogos).toEqual({
+ 3: { id: 3, name: 'Logo 3' },
+ });
+ expect(result.current.logos).toEqual([
+ { id: 3, name: 'Logo 3' },
+ ]);
+ expect(result.current.totalCount).toBe(1);
+ });
+
+ it('should handle delete multiple VOD logos error', async () => {
+ const mockError = new Error('Bulk delete failed');
+ api.deleteVODLogos.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ try {
+ await result.current.deleteVODLogos([1, 2]);
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should cleanup unused VOD logos and refresh', async () => {
+ useVODLogosStore.setState({
+ currentPage: 2,
+ pageSize: 10,
+ });
+
+ const mockCleanupResult = { deleted: 5 };
+ const mockRefreshedLogos = [{ id: 1, name: 'Logo 1' }];
+
+ api.cleanupUnusedVODLogos.mockResolvedValue(mockCleanupResult);
+ api.getVODLogos.mockResolvedValue(mockRefreshedLogos);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ const cleanupResult = await result.current.cleanupUnusedVODLogos();
+ expect(cleanupResult).toEqual(mockCleanupResult);
+ });
+
+ expect(api.cleanupUnusedVODLogos).toHaveBeenCalled();
+ expect(api.getVODLogos).toHaveBeenCalledWith({
+ page: 2,
+ page_size: 10,
+ });
+ expect(result.current.logos).toEqual(mockRefreshedLogos);
+ });
+
+ it('should handle cleanup unused VOD logos error', async () => {
+ const mockError = new Error('Cleanup failed');
+ api.cleanupUnusedVODLogos.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ try {
+ await result.current.cleanupUnusedVODLogos();
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should clear VOD logos', () => {
+ useVODLogosStore.setState({
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ },
+ logos: [{ id: 1, name: 'Logo 1' }],
+ hasLoaded: true,
+ totalCount: 1,
+ error: 'Some error',
+ });
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ result.current.clearVODLogos();
+ });
+
+ expect(result.current.vodLogos).toEqual({});
+ expect(result.current.logos).toEqual([]);
+ expect(result.current.hasLoaded).toBe(false);
+ expect(result.current.totalCount).toBe(0);
+ expect(result.current.error).toBe(null);
+ });
+
+ it('should handle removing non-existent logo', () => {
+ useVODLogosStore.setState({
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ },
+ logos: [{ id: 1, name: 'Logo 1' }],
+ totalCount: 1,
+ });
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ result.current.removeVODLogo(999);
+ });
+
+ expect(result.current.vodLogos).toEqual({
+ 1: { id: 1, name: 'Logo 1' },
+ });
+ expect(result.current.logos).toEqual([{ id: 1, name: 'Logo 1' }]);
+ expect(result.current.totalCount).toBe(1);
+ });
+
+ it('should handle fetch with empty response', async () => {
+ api.getVODLogos.mockResolvedValue([]);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ await result.current.fetchVODLogos();
+ });
+
+ expect(result.current.vodLogos).toEqual({});
+ expect(result.current.logos).toEqual([]);
+ expect(result.current.totalCount).toBe(0);
+ });
+
+ it('should not allow totalCount to go below zero', () => {
+ useVODLogosStore.setState({
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ },
+ logos: [{ id: 1, name: 'Logo 1' }],
+ totalCount: 1,
+ });
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ result.current._removeLogosFromState([1, 2, 3]); // Remove more than exist
+ });
+
+ expect(result.current.totalCount).toBe(0);
+ });
+
+ it('should handle _removeLogosFromState with empty array', () => {
+ const initialState = {
+ vodLogos: {
+ 1: { id: 1, name: 'Logo 1' },
+ },
+ logos: [{ id: 1, name: 'Logo 1' }],
+ totalCount: 1,
+ };
+
+ useVODLogosStore.setState(initialState);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ result.current._removeLogosFromState([]);
+ });
+
+ expect(result.current.vodLogos).toEqual(initialState.vodLogos);
+ expect(result.current.logos).toEqual(initialState.logos);
+ expect(result.current.totalCount).toBe(1);
+ });
+
+ it('should fetch with custom params', async () => {
+ const mockLogos = [{ id: 1, name: 'Logo 1' }];
+ api.getVODLogos.mockResolvedValue(mockLogos);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ await result.current.fetchVODLogos({ search: 'test', page: 2 });
+ });
+
+ expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 });
+ });
+});
diff --git a/frontend/src/store/__tests__/warnings.test.jsx b/frontend/src/store/__tests__/warnings.test.jsx
new file mode 100644
index 00000000..500fbd8c
--- /dev/null
+++ b/frontend/src/store/__tests__/warnings.test.jsx
@@ -0,0 +1,195 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach } from 'vitest';
+import useWarningsStore from '../warnings';
+
+describe('useWarningsStore', () => {
+ beforeEach(() => {
+ useWarningsStore.setState({
+ suppressedWarnings: {},
+ });
+ });
+
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ expect(result.current.suppressedWarnings).toEqual({});
+ });
+
+ it('should suppress a warning', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({
+ deleteStream: true,
+ });
+ });
+
+ it('should suppress multiple warnings', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ result.current.suppressWarning('deleteProfile');
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({
+ deleteStream: true,
+ deleteProfile: true,
+ });
+ });
+
+ it('should suppress warning with explicit true value', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream', true);
+ });
+
+ expect(result.current.suppressedWarnings.deleteStream).toBe(true);
+ });
+
+ it('should unsuppress a warning with explicit false value', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ });
+
+ expect(result.current.suppressedWarnings.deleteStream).toBe(true);
+
+ act(() => {
+ result.current.suppressWarning('deleteStream', false);
+ });
+
+ expect(result.current.suppressedWarnings.deleteStream).toBe(false);
+ });
+
+ it('should check if warning is suppressed', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ expect(result.current.isWarningSuppressed('deleteStream')).toBe(false);
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ });
+
+ expect(result.current.isWarningSuppressed('deleteStream')).toBe(true);
+ });
+
+ it('should return false for non-existent warning', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ expect(result.current.isWarningSuppressed('nonExistentAction')).toBe(false);
+ });
+
+ it('should reset all suppressions', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ result.current.suppressWarning('deleteProfile');
+ result.current.suppressWarning('deleteUser');
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({
+ deleteStream: true,
+ deleteProfile: true,
+ deleteUser: true,
+ });
+
+ act(() => {
+ result.current.resetSuppressions();
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({});
+ });
+
+ it('should maintain other suppressions when adding new one', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ result.current.suppressWarning('deleteProfile');
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({
+ deleteStream: true,
+ deleteProfile: true,
+ });
+ });
+
+ it('should handle resetting when already empty', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ expect(result.current.suppressedWarnings).toEqual({});
+
+ act(() => {
+ result.current.resetSuppressions();
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({});
+ });
+
+ it('should handle checking suppression after unsuppressing', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream', true);
+ });
+
+ expect(result.current.isWarningSuppressed('deleteStream')).toBe(true);
+
+ act(() => {
+ result.current.suppressWarning('deleteStream', false);
+ });
+
+ expect(result.current.isWarningSuppressed('deleteStream')).toBe(false);
+ });
+
+ it('should preserve other warnings when unsuppressing one', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ result.current.suppressWarning('deleteProfile');
+ });
+
+ act(() => {
+ result.current.suppressWarning('deleteStream', false);
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({
+ deleteStream: false,
+ deleteProfile: true,
+ });
+ });
+
+ it('should handle suppressing same warning multiple times', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('deleteStream');
+ result.current.suppressWarning('deleteStream');
+ });
+
+ expect(result.current.suppressedWarnings).toEqual({
+ deleteStream: true,
+ });
+ });
+
+ it('should handle different action keys independently', () => {
+ const { result } = renderHook(() => useWarningsStore());
+
+ act(() => {
+ result.current.suppressWarning('action1');
+ result.current.suppressWarning('action2', false);
+ });
+
+ expect(result.current.isWarningSuppressed('action1')).toBe(true);
+ expect(result.current.isWarningSuppressed('action2')).toBe(false);
+ expect(result.current.isWarningSuppressed('action3')).toBe(false);
+ });
+});
From cf1625fab91568beb3cadcdd95f852c530c18a4f Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 27 Jan 2026 13:41:25 -0800
Subject: [PATCH 077/125] Slight refactoring
---
frontend/src/hooks/useLocalStorage.jsx | 2 +-
frontend/src/store/auth.jsx | 17 +-
frontend/src/store/channels.jsx | 196 ++++++++++----------
frontend/src/store/channelsTable.jsx | 3 -
frontend/src/store/epgs.jsx | 55 +++---
frontend/src/store/logos.jsx | 33 ++--
frontend/src/store/useVODStore.jsx | 240 +++++++++++++------------
frontend/src/store/vodLogos.jsx | 61 +++----
8 files changed, 312 insertions(+), 295 deletions(-)
diff --git a/frontend/src/hooks/useLocalStorage.jsx b/frontend/src/hooks/useLocalStorage.jsx
index 450c55df..d559d960 100644
--- a/frontend/src/hooks/useLocalStorage.jsx
+++ b/frontend/src/hooks/useLocalStorage.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
const useLocalStorage = (key, defaultValue) => {
const localKey = key;
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index 8fe943b7..e97e7e53 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -1,5 +1,4 @@
import { create } from 'zustand';
-import api from '../api';
import useSettingsStore from './settings';
import useChannelsStore from './channels';
import usePlaylistsStore from './playlists';
@@ -82,20 +81,16 @@ const useAuthStore = create((set, get) => ({
getToken: async () => {
const tokenExpiration = localStorage.getItem('tokenExpiration');
- let accessToken = null;
- if (isTokenExpired(tokenExpiration)) {
- accessToken = await get().getRefreshToken();
- } else {
- accessToken = localStorage.getItem('accessToken');
- }
- return accessToken;
+ return isTokenExpired(tokenExpiration)
+ ? await get().getRefreshToken()
+ : localStorage.getItem('accessToken');
},
// Action to login
login: async ({ username, password }) => {
try {
- const response = await api.login(username, password);
+ const response = await API.login(username, password);
if (response.access) {
const expiration = decodeToken(response.access);
set({
@@ -121,8 +116,8 @@ const useAuthStore = create((set, get) => ({
if (!refreshToken) return false; // Add explicit return here
try {
- const data = await api.refreshToken(refreshToken);
- if (data && data.access) {
+ const data = await API.refreshToken(refreshToken);
+ if (data?.access) {
set({
accessToken: data.access,
tokenExpiration: decodeToken(data.access),
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index 9fb958b2..a95959b2 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -1,9 +1,91 @@
import { create } from 'zustand';
import api from '../api';
-import { notifications } from '@mantine/notifications';
+import { showNotification } from '../utils/notificationUtils.js';
const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } };
+const reduceChannels = (channels) => {
+ const channelsByUUID = {};
+ const channelsByID = channels.reduce((acc, channel) => {
+ acc[channel.id] = channel;
+ channelsByUUID[channel.uuid] = channel.id;
+ return acc;
+ }, {});
+ return { channelsByUUID, channelsByID };
+}
+
+const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => {
+ if (currentStats.channels) {
+ if (oldChannels[ch.channel_id] === undefined) {
+ // Add null checks to prevent accessing properties on undefined
+ const channelId = channelsByUUID[ch.channel_id];
+ const channel = channelId ? channels[channelId] : null;
+
+ if (channel) {
+ showNotification({
+ title: 'New channel streaming',
+ message: channel.name,
+ color: 'blue.5',
+ });
+ }
+ }
+ }
+}
+
+const showNotificationIfNewClient = (currentStats, oldClients, client) => {
+ // This check prevents the notifications if streams are active on page load
+ if (currentStats.channels) {
+ if (oldClients[client.client_id] === undefined) {
+ showNotification({
+ title: 'New client started streaming',
+ message: `Client streaming from ${client.ip_address}`,
+ color: 'blue.5',
+ });
+ }
+ }
+}
+
+const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => {
+ // This check prevents the notifications if streams are active on page load
+ if (currentStats.channels) {
+ for (const uuid in oldChannels) {
+ if (newChannels[uuid] === undefined) {
+ // Add null check for channel name
+ const channelId = channelsByUUID[uuid];
+ const channel = channelId && channels[channelId];
+
+ if (channel) {
+ showNotification({
+ title: 'Channel streaming stopped',
+ message: channel.name,
+ color: 'blue.5',
+ });
+ } else {
+ showNotification({
+ title: 'Channel streaming stopped',
+ message: `Channel (${uuid})`,
+ color: 'blue.5',
+ });
+ }
+ }
+ }
+ }
+}
+
+const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => {
+ if (currentStats.channels) {
+ for (const clientId in oldClients) {
+ if (newClients[clientId] === undefined) {
+ showNotification({
+ title: 'Client stopped streaming',
+ message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
+ color: 'blue.5',
+ });
+ }
+ }
+ }
+}
+
const useChannelsStore = create((set, get) => ({
channels: [],
channelsByUUID: {},
@@ -28,12 +110,7 @@ const useChannelsStore = create((set, get) => ({
set({ isLoading: true, error: null });
try {
const channels = await api.getChannels();
- const channelsByUUID = {};
- const channelsByID = channels.reduce((acc, channel) => {
- acc[channel.id] = channel;
- channelsByUUID[channel.uuid] = channel.id;
- return acc;
- }, {});
+ const { channelsByUUID, channelsByID } = reduceChannels(channels);
set({
channels: channelsByID,
channelsByUUID,
@@ -113,16 +190,7 @@ const useChannelsStore = create((set, get) => ({
addChannels: (newChannels) =>
set((state) => {
- const channelsByUUID = {};
- const profileChannels = new Set();
-
- const channelsByID = newChannels.reduce((acc, channel) => {
- acc[channel.id] = channel;
- channelsByUUID[channel.uuid] = channel.id;
- profileChannels.add(channel.id);
-
- return acc;
- }, {});
+ const { channelsByUUID, channelsByID } = reduceChannels(newChannels);
// Don't automatically add to all profiles anymore - let the backend handle profile assignments
// Just maintain the existing profile structure
@@ -160,12 +228,8 @@ const useChannelsStore = create((set, get) => ({
);
return;
}
- const channelsByUUID = {};
- const updatedChannels = channels.reduce((acc, chan) => {
- channelsByUUID[chan.uuid] = chan.id;
- acc[chan.id] = chan;
- return acc;
- }, {});
+
+ const { channelsByUUID, updatedChannels } = reduceChannels(channels);
set((state) => ({
channels: {
@@ -249,12 +313,9 @@ const useChannelsStore = create((set, get) => ({
delete updatedProfiles[id];
}
- let additionalUpdates = {};
- if (profileIds.includes(state.selectedProfileId)) {
- additionalUpdates = {
- selectedProfileId: '0',
- };
- }
+ const additionalUpdates = profileIds.includes(state.selectedProfileId)
+ ? { selectedProfileId: '0' }
+ : {};
return {
profiles: updatedProfiles,
@@ -296,14 +357,12 @@ const useChannelsStore = create((set, get) => ({
channels: currentChannelsSet,
};
- const updates = {
+ return {
profiles: {
...state.profiles,
[profileId]: updatedProfile,
},
};
-
- return updates;
}),
setChannelsPageSelection: (channelsPageSelection) =>
@@ -324,71 +383,24 @@ const useChannelsStore = create((set, get) => ({
channelsByUUID,
} = state;
const newClients = {};
+
const newChannels = stats.channels.reduce((acc, ch) => {
acc[ch.channel_id] = ch;
- if (currentStats.channels) {
- if (oldChannels[ch.channel_id] === undefined) {
- // Add null checks to prevent accessing properties on undefined
- const channelId = channelsByUUID[ch.channel_id];
- const channel = channelId ? channels[channelId] : null;
-
- if (channel) {
- notifications.show({
- title: 'New channel streaming',
- message: channel.name,
- color: 'blue.5',
- });
- }
- }
- }
- ch.clients.map((client) => {
- newClients[client.client_id] = client;
- // This check prevents the notifications if streams are active on page load
- if (currentStats.channels) {
- if (oldClients[client.client_id] === undefined) {
- notifications.show({
- title: 'New client started streaming',
- message: `Client streaming from ${client.ip_address}`,
- color: 'blue.5',
- });
- }
- }
- });
return acc;
}, {});
- // This check prevents the notifications if streams are active on page load
- if (currentStats.channels) {
- for (const uuid in oldChannels) {
- if (newChannels[uuid] === undefined) {
- // Add null check for channel name
- const channelId = channelsByUUID[uuid];
- const channel = channelId && channels[channelId];
- if (channel) {
- notifications.show({
- title: 'Channel streaming stopped',
- message: channel.name,
- color: 'blue.5',
- });
- } else {
- notifications.show({
- title: 'Channel streaming stopped',
- message: `Channel (${uuid})`,
- color: 'blue.5',
- });
- }
- }
- }
- for (const clientId in oldClients) {
- if (newClients[clientId] === undefined) {
- notifications.show({
- title: 'Client stopped streaming',
- message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
- color: 'blue.5',
- });
- }
- }
- }
+ stats.channels.forEach(ch => {
+ showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels);
+
+ ch.clients.forEach(client => {
+ newClients[client.client_id] = client;
+ showNotificationIfNewClient(currentStats, oldClients, client);
+ });
+ });
+
+ showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels);
+ showNotificationIfClientStopped(currentStats, oldClients, newClients);
+
return {
stats,
activeChannels: newChannels,
diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx
index 75e14c63..c205a9be 100644
--- a/frontend/src/store/channelsTable.jsx
+++ b/frontend/src/store/channelsTable.jsx
@@ -1,7 +1,4 @@
import { create } from 'zustand';
-import api from '../api';
-import { notifications } from '@mantine/notifications';
-import API from '../api';
const useChannelsTableStore = create((set, get) => ({
channels: [],
diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx
index 1760bc45..e8f5e2ad 100644
--- a/frontend/src/store/epgs.jsx
+++ b/frontend/src/store/epgs.jsx
@@ -1,6 +1,14 @@
import { create } from 'zustand';
import api from '../api';
+const determineEPGStatus = (data, currentEpg) => {
+ if (data.status) return data.status;
+ if (data.action === 'downloading') return 'fetching';
+ if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing';
+ if (data.progress === 100) return 'success';
+ return currentEpg?.status || 'idle';
+};
+
const useEPGsStore = create((set) => ({
epgs: {},
tvgs: [],
@@ -92,7 +100,7 @@ const useEPGsStore = create((set) => ({
}
// Create a new refreshProgress object that includes the current update
- const newRefreshProgress = {
+ const refreshProgress = {
...state.refreshProgress,
[data.source]: {
action: data.action,
@@ -106,45 +114,30 @@ const useEPGsStore = create((set) => ({
// Set the EPG source status based on the update
// First prioritize explicit status values from the backend
- const sourceStatus = data.status
- ? data.status // Use explicit status if provided
- : data.action === 'downloading'
- ? 'fetching'
- : data.action === 'parsing_channels' ||
- data.action === 'parsing_programs'
- ? 'parsing'
- : data.progress === 100
- ? 'success' // Mark as success when progress is 100%
- : state.epgs[data.source]?.status || 'idle';
+ const status = determineEPGStatus(data, state.epgs[data.source]);
// Only update epgs object if status or last_message actually changed
// This prevents unnecessary re-renders on every progress update
- const currentEpg = state.epgs[data.source];
- const newLastMessage =
- data.status === 'error'
- ? data.error || 'Unknown error'
- : currentEpg?.last_message;
+ const lastMessage = data.status === 'error'
+ ? (data.error || 'Unknown error')
+ : state.epgs[data.source]?.last_message;
- let newEpgs = state.epgs;
- if (
- currentEpg &&
- (currentEpg.status !== sourceStatus ||
- currentEpg.last_message !== newLastMessage)
- ) {
- newEpgs = {
+ const currentEpg = state.epgs[data.source];
+ const shouldUpdateEpg = currentEpg &&
+ (currentEpg.status !== status || currentEpg.last_message !== lastMessage);
+
+ const epgs = shouldUpdateEpg
+ ? {
...state.epgs,
[data.source]: {
...currentEpg,
- status: sourceStatus,
- last_message: newLastMessage,
+ status,
+ last_message: lastMessage,
},
- };
- }
+ }
+ : state.epgs;
- return {
- refreshProgress: newRefreshProgress,
- epgs: newEpgs,
- };
+ return { refreshProgress, epgs };
}),
}));
diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx
index 5843b113..5e09cf02 100644
--- a/frontend/src/store/logos.jsx
+++ b/frontend/src/store/logos.jsx
@@ -1,6 +1,12 @@
import { create } from 'zustand';
import api from '../api';
+const getLogosArray = (response) => {
+ return Array.isArray(response)
+ ? response
+ : response.results || [];
+}
+
const useLogosStore = create((set, get) => ({
logos: {},
channelLogos: {}, // Separate cache for channel forms to avoid reloading
@@ -24,13 +30,12 @@ const useLogosStore = create((set, get) => ({
// Add to channelLogos if the user has loaded channel logos
// This means they're using channel forms and the new logo should be available there
- let newChannelLogos = state.channelLogos;
- if (state.hasLoadedChannelLogos) {
- newChannelLogos = {
- ...state.channelLogos,
- [newLogo.id]: { ...newLogo },
- };
- }
+ const newChannelLogos = state.hasLoadedChannelLogos
+ ? {
+ ...state.channelLogos,
+ [newLogo.id]: { ...newLogo },
+ }
+ : state.channelLogos;
return {
logos: newLogos,
@@ -75,7 +80,7 @@ const useLogosStore = create((set, get) => ({
const response = await api.getLogos({ page_size: pageSize });
// Handle both paginated and non-paginated responses
- const logos = Array.isArray(response) ? response : response.results || [];
+ const logos = getLogosArray(response);
set({
logos: logos.reduce((acc, logo) => {
@@ -109,9 +114,7 @@ const useLogosStore = create((set, get) => ({
const response = await api.getLogos({ no_pagination: 'true' });
// Handle both paginated and non-paginated responses
- const logosArray = Array.isArray(response)
- ? response
- : response.results || [];
+ const logosArray = getLogosArray(response);
set({
logos: logosArray.reduce((acc, logo) => {
@@ -139,7 +142,7 @@ const useLogosStore = create((set, get) => ({
});
// Handle both paginated and non-paginated responses
- const logos = Array.isArray(response) ? response : response.results || [];
+ const logos = getLogosArray(response);
set((state) => ({
logos: {
@@ -190,7 +193,7 @@ const useLogosStore = create((set, get) => ({
const response = await api.getLogosByIds(missingIds);
// Handle both paginated and non-paginated responses
- const logos = Array.isArray(response) ? response : response.results || [];
+ const logos = getLogosArray(response);
set((state) => ({
logos: {
@@ -262,9 +265,7 @@ const useLogosStore = create((set, get) => ({
try {
// Use the API directly to avoid interfering with the main isLoading state
const response = await api.getLogos({ no_pagination: 'true' });
- const logosArray = Array.isArray(response)
- ? response
- : response.results || [];
+ const logosArray = getLogosArray(response);
// Process logos in smaller chunks to avoid blocking the main thread
const chunkSize = 1000;
diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx
index 43edb1c9..defbfe17 100644
--- a/frontend/src/store/useVODStore.jsx
+++ b/frontend/src/store/useVODStore.jsx
@@ -1,6 +1,124 @@
import { create } from 'zustand';
import api from '../api';
+const getFetchContentParams = (state) => {
+ const params = new URLSearchParams();
+ params.append('page', state.currentPage);
+ params.append('page_size', state.pageSize);
+
+ if (state.filters.search) {
+ params.append('search', state.filters.search);
+ }
+
+ if (state.filters.category) {
+ params.append('category', state.filters.category);
+ }
+ return params;
+}
+
+const getMovieDetails = (response, movieId) => {
+ return {
+ id: response.id || movieId,
+ name: response.name || '',
+ description: response.description || '',
+ year: response.year || null,
+ genre: response.genre || '',
+ rating: response.rating || '',
+ duration_secs: response.duration_secs || null,
+ stream_url: response.url || '',
+ logo: response.logo_url || null,
+ type: 'movie',
+ director: response.director || '',
+ actors: response.actors || '',
+ country: response.country || '',
+ tmdb_id: response.tmdb_id || '',
+ imdb_id: response.imdb_id || '',
+ m3u_account: response.m3u_account || '',
+ };
+}
+
+const getMovieDetailsWithProvider = (response, movieId) => {
+ return {
+ id: response.id || movieId,
+ name: response.name || '',
+ description: response.description || response.plot || '',
+ year: response.year || null,
+ genre: response.genre || '',
+ rating: response.rating || '',
+ duration_secs: response.duration_secs || null,
+ stream_url: response.stream_url || '',
+ logo: response.logo || response.cover || null,
+ type: 'movie',
+ director: response.director || '',
+ actors: response.actors || response.cast || '',
+ country: response.country || '',
+ tmdb_id: response.tmdb_id || '',
+ youtube_trailer: response.youtube_trailer || '',
+ // Additional provider fields
+ backdrop_path: response.backdrop_path || [],
+ release_date: response.release_date || response.releasedate || '',
+ movie_image: response.movie_image || null,
+ o_name: response.o_name || '',
+ age: response.age || '',
+ episode_run_time: response.episode_run_time || null,
+ bitrate: response.bitrate || 0,
+ video: response.video || {},
+ audio: response.audio || {},
+ };
+}
+
+const getSeriesDetails = (response, seriesId) => {
+ return {
+ id: response.id || seriesId,
+ name: response.name || '',
+ description: response.description || response.custom_properties?.plot || '',
+ year: response.year || null,
+ genre: response.genre || '',
+ rating: response.rating || '',
+ logo: response.cover || null,
+ type: 'series',
+ director: response.custom_properties?.director || '',
+ cast: response.custom_properties?.cast || '',
+ country: response.country || '',
+ tmdb_id: response.tmdb_id || '',
+ imdb_id: response.imdb_id || '',
+ episode_count: response.episode_count || 0,
+ // Additional provider fields
+ backdrop_path: response.custom_properties?.backdrop_path || [],
+ release_date: response.release_date || '',
+ series_image: response.series_image || null,
+ o_name: response.o_name || '',
+ age: response.age || '',
+ m3u_account: response.m3u_account || '',
+ youtube_trailer: response.custom_properties?.youtube_trailer || '',
+ };
+}
+
+const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
+ return {
+ id: episode.id,
+ stream_id: episode.id,
+ name: episode.title || '',
+ description: episode.plot || '',
+ season_number: parseInt(seasonNumber) || 0,
+ episode_number: episode.episode_number || 0,
+ duration_secs: episode.duration_secs || null,
+ rating: episode.rating || '',
+ container_extension: episode.container_extension || '',
+ series: {
+ id: seriesInfo.id,
+ name: seriesInfo.name,
+ },
+ type: 'episode',
+ uuid: episode.uuid,
+ logo: episode.movie_image ? { url: episode.movie_image } : null,
+ air_date: episode.air_date || null,
+ movie_image: episode.movie_image || null,
+ tmdb_id: episode.tmdb_id || '',
+ imdb_id: episode.imdb_id || '',
+ };
+}
+
const useVODStore = create((set, get) => ({
content: {}, // Store for individual content details (when fetching movie/series details)
currentPageContent: [], // Store the current page's results
@@ -39,17 +157,7 @@ const useVODStore = create((set, get) => ({
set({ loading: true, error: null });
const state = get();
- const params = new URLSearchParams();
- params.append('page', state.currentPage);
- params.append('page_size', state.pageSize);
-
- if (state.filters.search) {
- params.append('search', state.filters.search);
- }
-
- if (state.filters.category) {
- params.append('category', state.filters.category);
- }
+ const params = getFetchContentParams(state);
let allResults = [];
let totalCount = 0;
@@ -58,12 +166,14 @@ const useVODStore = create((set, get) => ({
// Fetch only movies
const response = await api.getMovies(params);
const results = response.results || response;
+
allResults = results.map((item) => ({ ...item, contentType: 'movie' }));
totalCount = response.count || results.length;
} else if (state.filters.type === 'series') {
// Fetch only series
const response = await api.getSeries(params);
const results = response.results || response;
+
allResults = results.map((item) => ({
...item,
contentType: 'series',
@@ -73,16 +183,9 @@ const useVODStore = create((set, get) => ({
// Use the new unified backend endpoint for 'all' view
const response = await api.getAllContent(params);
console.log('getAllContent response:', response);
- console.log('response type:', typeof response);
- console.log(
- 'response keys:',
- response ? Object.keys(response) : 'no response'
- );
const results = response.results || response;
console.log('results:', results);
- console.log('results type:', typeof results);
- console.log('results is array:', Array.isArray(results));
// Check if results is actually an array before calling map
if (!Array.isArray(results)) {
@@ -140,24 +243,7 @@ const useVODStore = create((set, get) => ({
const response = await api.getMovieDetails(movieId);
// Transform the response data to match our expected format
- const movieDetails = {
- id: response.id || movieId,
- name: response.name || '',
- description: response.description || '',
- year: response.year || null,
- genre: response.genre || '',
- rating: response.rating || '',
- duration_secs: response.duration_secs || null,
- stream_url: response.url || '',
- logo: response.logo_url || null,
- type: 'movie',
- director: response.director || '',
- actors: response.actors || '',
- country: response.country || '',
- tmdb_id: response.tmdb_id || '',
- imdb_id: response.imdb_id || '',
- m3u_account: response.m3u_account || '',
- };
+ const movieDetails = getMovieDetails(response, movieId);
console.log('Fetched Movie Details:', movieDetails);
set((state) => ({
content: {
@@ -184,33 +270,7 @@ const useVODStore = create((set, get) => ({
const response = await api.getMovieProviderInfo(movieId);
// Transform the response data to match our expected format
- const movieDetails = {
- id: response.id || movieId,
- name: response.name || '',
- description: response.description || response.plot || '',
- year: response.year || null,
- genre: response.genre || '',
- rating: response.rating || '',
- duration_secs: response.duration_secs || null,
- stream_url: response.stream_url || '',
- logo: response.logo || response.cover || null,
- type: 'movie',
- director: response.director || '',
- actors: response.actors || response.cast || '',
- country: response.country || '',
- tmdb_id: response.tmdb_id || '',
- youtube_trailer: response.youtube_trailer || '',
- // Additional provider fields
- backdrop_path: response.backdrop_path || [],
- release_date: response.release_date || response.releasedate || '',
- movie_image: response.movie_image || null,
- o_name: response.o_name || '',
- age: response.age || '',
- episode_run_time: response.episode_run_time || null,
- bitrate: response.bitrate || 0,
- video: response.video || {},
- audio: response.audio || {},
- };
+ const movieDetails = getMovieDetailsWithProvider(response, movieId);
set({ loading: false }); // Only update loading state
@@ -316,31 +376,7 @@ const useVODStore = create((set, get) => ({
const response = await api.getSeriesInfo(seriesId);
// Transform the response data to match our expected format
- const seriesInfo = {
- id: response.id || seriesId,
- name: response.name || '',
- description:
- response.description || response.custom_properties?.plot || '',
- year: response.year || null,
- genre: response.genre || '',
- rating: response.rating || '',
- logo: response.cover || null,
- type: 'series',
- director: response.custom_properties?.director || '',
- cast: response.custom_properties?.cast || '',
- country: response.country || '',
- tmdb_id: response.tmdb_id || '',
- imdb_id: response.imdb_id || '',
- episode_count: response.episode_count || 0,
- // Additional provider fields
- backdrop_path: response.custom_properties?.backdrop_path || [],
- release_date: response.release_date || '',
- series_image: response.series_image || null,
- o_name: response.o_name || '',
- age: response.age || '',
- m3u_account: response.m3u_account || '',
- youtube_trailer: response.custom_properties?.youtube_trailer || '',
- };
+ const seriesInfo = getSeriesDetails(response, seriesId);
let episodesData = {};
@@ -349,29 +385,11 @@ const useVODStore = create((set, get) => ({
Object.entries(response.episodes).forEach(
([seasonNumber, seasonEpisodes]) => {
seasonEpisodes.forEach((episode) => {
- const episodeData = {
- id: episode.id,
- stream_id: episode.id,
- name: episode.title || '',
- description: episode.plot || '',
- season_number: parseInt(seasonNumber) || 0,
- episode_number: episode.episode_number || 0,
- duration_secs: episode.duration_secs || null,
- rating: episode.rating || '',
- container_extension: episode.container_extension || '',
- series: {
- id: seriesInfo.id,
- name: seriesInfo.name,
- },
- type: 'episode',
- uuid: episode.uuid,
- logo: episode.movie_image ? { url: episode.movie_image } : null,
- air_date: episode.air_date || null,
- movie_image: episode.movie_image || null,
- tmdb_id: episode.tmdb_id || '',
- imdb_id: episode.imdb_id || '',
- };
- episodesData[episode.id] = episodeData;
+ episodesData[episode.id] = getEpisodeDetails(
+ episode,
+ seasonNumber,
+ seriesInfo
+ );
});
}
);
diff --git a/frontend/src/store/vodLogos.jsx b/frontend/src/store/vodLogos.jsx
index 4df2dd17..34a30f2a 100644
--- a/frontend/src/store/vodLogos.jsx
+++ b/frontend/src/store/vodLogos.jsx
@@ -11,6 +11,29 @@ const useVODLogosStore = create((set) => ({
currentPage: 1,
pageSize: 25,
+ _removeLogosFromState: (logoIds) => {
+ set((state) => {
+ const newVODLogos = { ...state.vodLogos };
+ const logoIdSet = new Set(Array.isArray(logoIds) ? logoIds : [logoIds]);
+
+ let removedCount = 0;
+ logoIdSet.forEach((id) => {
+ if (newVODLogos[id]) {
+ delete newVODLogos[id];
+ removedCount++;
+ }
+ });
+
+ const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id));
+
+ return {
+ vodLogos: newVODLogos,
+ logos: newLogos,
+ totalCount: Math.max(0, state.totalCount - removedCount),
+ };
+ });
+ },
+
setVODLogos: (logos, totalCount = 0) => {
set({
vodLogos: logos.reduce((acc, logo) => {
@@ -22,15 +45,10 @@ const useVODLogosStore = create((set) => ({
});
},
- removeVODLogo: (logoId) =>
- set((state) => {
- const newVODLogos = { ...state.vodLogos };
- delete newVODLogos[logoId];
- return {
- vodLogos: newVODLogos,
- totalCount: Math.max(0, state.totalCount - 1),
- };
- }),
+ removeVODLogo: (logoId) => {
+ const state = useVODLogosStore.getState();
+ state._removeLogosFromState(logoId);
+ },
fetchVODLogos: async (params = {}) => {
set({ isLoading: true, error: null });
@@ -62,16 +80,8 @@ const useVODLogosStore = create((set) => ({
deleteVODLogo: async (logoId) => {
try {
await api.deleteVODLogo(logoId);
- set((state) => {
- const newVODLogos = { ...state.vodLogos };
- delete newVODLogos[logoId];
- const newLogos = state.logos.filter((logo) => logo.id !== logoId);
- return {
- vodLogos: newVODLogos,
- logos: newLogos,
- totalCount: Math.max(0, state.totalCount - 1),
- };
- });
+ const state = useVODLogosStore.getState();
+ state._removeLogosFromState(logoId);
} catch (error) {
console.error('Failed to delete VOD logo:', error);
throw error;
@@ -81,17 +91,8 @@ const useVODLogosStore = create((set) => ({
deleteVODLogos: async (logoIds) => {
try {
await api.deleteVODLogos(logoIds);
- set((state) => {
- const newVODLogos = { ...state.vodLogos };
- logoIds.forEach((id) => delete newVODLogos[id]);
- const logoIdSet = new Set(logoIds);
- const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id));
- return {
- vodLogos: newVODLogos,
- logos: newLogos,
- totalCount: Math.max(0, state.totalCount - logoIds.length),
- };
- });
+ const state = useVODLogosStore.getState();
+ state._removeLogosFromState(logoIds);
} catch (error) {
console.error('Failed to delete VOD logos:', error);
throw error;
From 525ae60157ff4ea9f4de4db63e36b8313e2ad8af Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 27 Jan 2026 13:41:50 -0800
Subject: [PATCH 078/125] Removed functionality not implemented
---
.../src/components/tables/StreamsTable.jsx | 2 --
frontend/src/store/logos.jsx | 3 ---
frontend/src/store/useVODStore.jsx | 24 -------------------
3 files changed, 29 deletions(-)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 21b13baf..c3b2f677 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -702,8 +702,6 @@ const StreamsTable = ({ onReady }) => {
channel_profile_ids: channelProfileIds,
});
await API.requeryChannels();
- const fetchLogos = useChannelsStore.getState().fetchLogos;
- fetchLogos();
};
// Handle confirming the single channel numbering modal
diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx
index 5e09cf02..f821c424 100644
--- a/frontend/src/store/logos.jsx
+++ b/frontend/src/store/logos.jsx
@@ -72,9 +72,6 @@ const useLogosStore = create((set, get) => ({
// Smart loading methods
fetchLogos: async (pageSize = 100) => {
- // Don't fetch if logo fetching is not allowed yet
- if (!get().allowLogoFetching) return [];
-
set({ isLoading: true, error: null });
try {
const response = await api.getLogos({ page_size: pageSize });
diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx
index defbfe17..0d8fe446 100644
--- a/frontend/src/store/useVODStore.jsx
+++ b/frontend/src/store/useVODStore.jsx
@@ -213,30 +213,6 @@ const useVODStore = create((set, get) => ({
}
},
- fetchSeriesEpisodes: async (seriesId) => {
- set({ loading: true, error: null });
- try {
- const response = await api.getSeriesEpisodes(seriesId);
-
- set((state) => ({
- episodes: {
- ...state.episodes,
- ...response.reduce((acc, episode) => {
- acc[episode.id] = episode;
- return acc;
- }, {}),
- },
- loading: false,
- }));
-
- return response;
- } catch (error) {
- console.error('Failed to fetch series episodes:', error);
- set({ error: 'Failed to load episodes.', loading: false });
- throw error; // Re-throw to allow calling component to handle
- }
- },
-
fetchMovieDetails: async (movieId) => {
set({ loading: true, error: null });
try {
From 04e2886ba53ea0655bf3587f2cd327c785323d6f Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 27 Jan 2026 16:09:01 -0800
Subject: [PATCH 079/125] Updated tests after sync
---
frontend/src/store/__tests__/auth.test.jsx | 170 +++++++++++++++++-
.../store/__tests__/channelsTable.test.jsx | 107 +++++++++++
.../src/store/__tests__/settings.test.jsx | 149 +++++++++++++++
.../src/store/__tests__/vodLogos.test.jsx | 70 ++++++++
4 files changed, 487 insertions(+), 9 deletions(-)
diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx
index 4ae99e4b..7dc8277b 100644
--- a/frontend/src/store/__tests__/auth.test.jsx
+++ b/frontend/src/store/__tests__/auth.test.jsx
@@ -40,9 +40,6 @@ const localStorageMock = (() => {
global.localStorage = localStorageMock;
-// Mock console methods
-global.console.error = vi.fn();
-
// Helper to create a mock JWT token
const createMockToken = (expiresInSeconds = 3600) => {
const now = Math.floor(Date.now() / 1000);
@@ -161,7 +158,9 @@ describe('useAuthStore', () => {
await result.current.login({ username: 'testuser', password: 'wrong' });
});
- expect(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error));
+ expect(API.login).toHaveBeenCalledWith('testuser', 'wrong');
+ expect(result.current.isAuthenticated).toBe(false);
+ expect(localStorageMock.setItem).not.toHaveBeenCalled();
});
});
@@ -183,7 +182,6 @@ describe('useAuthStore', () => {
expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token');
expect(newToken).toBe(mockNewAccessToken);
- expect(result.current.isAuthenticated).toBe(true);
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken);
});
@@ -212,7 +210,6 @@ describe('useAuthStore', () => {
await result.current.getRefreshToken();
});
- expect(console.error).toHaveBeenCalledWith('Token refresh failed:', expect.any(Error));
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
@@ -292,7 +289,6 @@ describe('useAuthStore', () => {
await result.current.logout();
});
- expect(console.error).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error));
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.removeItem).toHaveBeenCalled();
});
@@ -435,8 +431,6 @@ describe('useAuthStore', () => {
await act(async () => {
await result.current.initData();
});
-
- expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error));
});
});
@@ -476,4 +470,162 @@ describe('useAuthStore', () => {
expect(result.current.superuserExists).toBe(false);
});
});
+
+ describe('getRefreshToken edge cases', () => {
+ it('should return false if API response has no access token', async () => {
+ localStorageMock.getItem.mockReturnValue('refresh-token');
+ API.refreshToken.mockResolvedValue({});
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let response;
+ await act(async () => {
+ response = await result.current.getRefreshToken();
+ });
+
+ expect(response).toBe(false);
+ });
+ });
+
+ describe('login edge cases', () => {
+ it('should not update state if response has no access token', async () => {
+ API.login.mockResolvedValue({});
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.login({ username: 'testuser', password: 'password' });
+ });
+
+ expect(result.current.accessToken).toBeNull();
+ expect(localStorageMock.setItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('logout edge cases', () => {
+ it('should reset isInitializing flag on logout', async () => {
+ useAuthStore.setState({ isInitializing: true });
+ API.logout.mockResolvedValue();
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.logout();
+ });
+
+ expect(result.current.isInitializing).toBe(false);
+ });
+ });
+
+ describe('initializeAuth edge cases', () => {
+ it('should return false if refresh token API call fails', async () => {
+ localStorageMock.getItem.mockReturnValue('refresh-token');
+ API.refreshToken.mockRejectedValue(new Error('Token expired'));
+ API.logout.mockResolvedValue();
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let initialized;
+ await act(async () => {
+ initialized = await result.current.initializeAuth();
+ });
+
+ expect(initialized).toBe(false);
+ });
+
+ it('should return false if refresh returns no access token', async () => {
+ localStorageMock.getItem.mockReturnValue('refresh-token');
+ API.refreshToken.mockResolvedValue({});
+
+ const { result } = renderHook(() => useAuthStore());
+
+ let initialized;
+ await act(async () => {
+ initialized = await result.current.initializeAuth();
+ });
+
+ expect(initialized).toBe(false);
+ });
+ });
+
+ describe('initData edge cases', () => {
+ it('should skip initialization if already initialized', async () => {
+ useAuthStore.setState({ isInitialized: true });
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.initData();
+ });
+
+ expect(API.me).not.toHaveBeenCalled();
+ });
+
+ it('should skip initialization if already initializing', async () => {
+ useAuthStore.setState({ isInitializing: true });
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.initData();
+ });
+
+ expect(API.me).not.toHaveBeenCalled();
+ });
+
+ it('should set isInitializing to false on error', async () => {
+ // Reset state before the test
+ useAuthStore.setState({
+ isInitializing: false,
+ isInitialized: false
+ });
+
+ API.me.mockRejectedValue(new Error('API error'));
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ try {
+ await result.current.initData();
+ } catch {
+ // Expected error
+ }
+ });
+
+ expect(result.current.isInitializing).toBe(false);
+ expect(result.current.isInitialized).toBe(false);
+ });
+
+ it('should call fetchChannels in background after initialization', async () => {
+ const mockUser = {
+ username: 'admin',
+ email: 'admin@test.com',
+ user_level: USER_LEVELS.ADMIN,
+ };
+
+ const fetchChannels = vi.fn().mockResolvedValue();
+ useChannelsStore.getState = () => ({
+ fetchChannels,
+ fetchChannelGroups: vi.fn().mockResolvedValue(),
+ fetchChannelProfiles: vi.fn().mockResolvedValue(),
+ });
+
+ API.me.mockResolvedValue(mockUser);
+
+ const { result } = renderHook(() => useAuthStore());
+
+ await act(async () => {
+ await result.current.initData();
+ });
+
+ // Wait for the background call to complete
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ });
+
+ // The background fetchChannels is called synchronously without await
+ // so we just need to verify it was called
+ expect(fetchChannels).toHaveBeenCalled();
+ });
+ });
});
diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx
index 171f0672..0bf3e31a 100644
--- a/frontend/src/store/__tests__/channelsTable.test.jsx
+++ b/frontend/src/store/__tests__/channelsTable.test.jsx
@@ -292,4 +292,111 @@ describe('useChannelsTableStore', () => {
expect(result.current.sorting).toEqual([]);
});
});
+
+ describe('isUnlocked', () => {
+ it('should initialize with default false value', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ expect(result.current.isUnlocked).toBe(false);
+ });
+ });
+
+ describe('setIsUnlocked', () => {
+ it('should update isUnlocked to true', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ act(() => {
+ result.current.setIsUnlocked(true);
+ });
+
+ expect(result.current.isUnlocked).toBe(true);
+ });
+
+ it('should update isUnlocked to false', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ act(() => {
+ result.current.setIsUnlocked(true);
+ });
+
+ expect(result.current.isUnlocked).toBe(true);
+
+ act(() => {
+ result.current.setIsUnlocked(false);
+ });
+
+ expect(result.current.isUnlocked).toBe(false);
+ });
+ });
+
+ describe('updateChannel', () => {
+ it('should update an existing channel', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockChannels = [
+ { id: 1, name: 'Channel 1', channel_number: 1 },
+ { id: 2, name: 'Channel 2', channel_number: 2 },
+ { id: 3, name: 'Channel 3', channel_number: 3 },
+ ];
+
+ act(() => {
+ useChannelsTableStore.setState({ channels: mockChannels });
+ });
+
+ const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 };
+
+ act(() => {
+ result.current.updateChannel(updatedChannel);
+ });
+
+ expect(result.current.channels).toEqual([
+ { id: 1, name: 'Channel 1', channel_number: 1 },
+ { id: 2, name: 'Updated Channel 2', channel_number: 22 },
+ { id: 3, name: 'Channel 3', channel_number: 3 },
+ ]);
+ });
+
+ it('should not modify channels when updating non-existent channel', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockChannels = [
+ { id: 1, name: 'Channel 1', channel_number: 1 },
+ { id: 2, name: 'Channel 2', channel_number: 2 },
+ ];
+
+ act(() => {
+ useChannelsTableStore.setState({ channels: mockChannels });
+ });
+
+ const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 };
+
+ act(() => {
+ result.current.updateChannel(updatedChannel);
+ });
+
+ expect(result.current.channels).toEqual(mockChannels);
+ });
+
+ it('should preserve other channels when updating one channel', () => {
+ const { result } = renderHook(() => useChannelsTableStore());
+
+ const mockChannels = [
+ { id: 1, name: 'Channel 1', channel_number: 1, streams: ['stream1'] },
+ { id: 2, name: 'Channel 2', channel_number: 2, streams: ['stream2'] },
+ ];
+
+ act(() => {
+ useChannelsTableStore.setState({ channels: mockChannels });
+ });
+
+ const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 };
+
+ act(() => {
+ result.current.updateChannel(updatedChannel);
+ });
+
+ expect(result.current.channels[0]).toEqual(updatedChannel);
+ expect(result.current.channels[1]).toEqual(mockChannels[1]);
+ });
+ });
});
diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx
index 59da4437..58f64724 100644
--- a/frontend/src/store/__tests__/settings.test.jsx
+++ b/frontend/src/store/__tests__/settings.test.jsx
@@ -16,6 +16,10 @@ describe('useSettingsStore', () => {
country_name: '',
env_mode: 'prod',
},
+ version: {
+ version: '',
+ timestamp: null,
+ },
isLoading: false,
error: null,
});
@@ -201,4 +205,149 @@ describe('useSettingsStore', () => {
expect(result.current.settings).toEqual({});
});
+
+ it('should initialize version with default state', () => {
+ const { result } = renderHook(() => useSettingsStore());
+
+ expect(result.current.version).toEqual({
+ version: '',
+ timestamp: null,
+ });
+ });
+
+ it('should fetch version successfully', async () => {
+ const mockVersion = {
+ version: '1.2.3',
+ timestamp: '2024-01-01T00:00:00Z',
+ };
+
+ api.getVersion.mockResolvedValue(mockVersion);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ let versionResult;
+ await act(async () => {
+ versionResult = await result.current.fetchVersion();
+ });
+
+ expect(api.getVersion).toHaveBeenCalled();
+ expect(result.current.version).toEqual({
+ version: '1.2.3',
+ timestamp: '2024-01-01T00:00:00Z',
+ });
+ expect(versionResult).toEqual({
+ version: '1.2.3',
+ timestamp: '2024-01-01T00:00:00Z',
+ });
+ });
+
+ it('should skip fetching version if already loaded', async () => {
+ useSettingsStore.setState({
+ version: {
+ version: '1.0.0',
+ timestamp: '2023-01-01T00:00:00Z',
+ },
+ });
+
+ api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ let versionResult;
+ await act(async () => {
+ versionResult = await result.current.fetchVersion();
+ });
+
+ expect(api.getVersion).not.toHaveBeenCalled();
+ expect(result.current.version).toEqual({
+ version: '1.0.0',
+ timestamp: '2023-01-01T00:00:00Z',
+ });
+ expect(versionResult).toEqual({
+ version: '1.0.0',
+ timestamp: '2023-01-01T00:00:00Z',
+ });
+ });
+
+ it('should handle null version response', async () => {
+ api.getVersion.mockResolvedValue(null);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ await act(async () => {
+ await result.current.fetchVersion();
+ });
+
+ expect(result.current.version).toEqual({
+ version: '',
+ timestamp: null,
+ });
+ });
+
+ it('should handle fetch version error', async () => {
+ const mockError = new Error('Version fetch failed');
+ api.getVersion.mockRejectedValue(mockError);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ let versionResult;
+ await act(async () => {
+ versionResult = await result.current.fetchVersion();
+ });
+
+ expect(versionResult).toEqual({
+ version: '',
+ timestamp: null,
+ });
+ });
+
+ it('should fetch version with settings when version not loaded', async () => {
+ const mockSettings = [{ key: 'setting1', value: 'value1' }];
+ const mockEnv = { public_ip: '192.168.1.1' };
+ const mockVersion = { version: '1.0.0', timestamp: '2024-01-01T00:00:00Z' };
+
+ api.getSettings.mockResolvedValue(mockSettings);
+ api.getEnvironmentSettings.mockResolvedValue(mockEnv);
+ api.getVersion.mockResolvedValue(mockVersion);
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ await act(async () => {
+ await result.current.fetchSettings();
+ });
+
+ expect(api.getVersion).toHaveBeenCalled();
+ expect(result.current.version).toEqual({
+ version: '1.0.0',
+ timestamp: '2024-01-01T00:00:00Z',
+ });
+ });
+
+ it('should skip fetching version with settings when already loaded', async () => {
+ useSettingsStore.setState({
+ version: {
+ version: '1.0.0',
+ timestamp: '2023-01-01T00:00:00Z',
+ },
+ });
+
+ const mockSettings = [{ key: 'setting1', value: 'value1' }];
+ const mockEnv = { public_ip: '192.168.1.1' };
+
+ api.getSettings.mockResolvedValue(mockSettings);
+ api.getEnvironmentSettings.mockResolvedValue(mockEnv);
+ api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
+
+ const { result } = renderHook(() => useSettingsStore());
+
+ await act(async () => {
+ await result.current.fetchSettings();
+ });
+
+ expect(api.getVersion).not.toHaveBeenCalled();
+ expect(result.current.version).toEqual({
+ version: '1.0.0',
+ timestamp: '2023-01-01T00:00:00Z',
+ });
+ });
});
diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx
index 6ac3cc44..116cbf9d 100644
--- a/frontend/src/store/__tests__/vodLogos.test.jsx
+++ b/frontend/src/store/__tests__/vodLogos.test.jsx
@@ -477,4 +477,74 @@ describe('useVODLogosStore', () => {
expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 });
});
+
+ it('should get unused logos count successfully', async () => {
+ const mockResponse = {
+ results: [{ id: 1, name: 'Unused Logo' }],
+ count: 42,
+ };
+
+ api.getVODLogos.mockResolvedValue(mockResponse);
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ let unusedCount;
+ await act(async () => {
+ unusedCount = await result.current.getUnusedLogosCount();
+ });
+
+ expect(api.getVODLogos).toHaveBeenCalledWith({
+ used: 'false',
+ page_size: 1,
+ });
+ expect(unusedCount).toBe(42);
+ });
+
+ it('should return 0 when unused logos count response has no count', async () => {
+ api.getVODLogos.mockResolvedValue({ results: [] });
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ let unusedCount;
+ await act(async () => {
+ unusedCount = await result.current.getUnusedLogosCount();
+ });
+
+ expect(unusedCount).toBe(0);
+ });
+
+ it('should handle get unused logos count error', async () => {
+ const mockError = new Error('Failed to fetch count');
+ api.getVODLogos.mockRejectedValue(mockError);
+
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { result } = renderHook(() => useVODLogosStore());
+
+ await act(async () => {
+ try {
+ await result.current.getUnusedLogosCount();
+ } catch (error) {
+ expect(error).toBe(mockError);
+ }
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError);
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should update currentPage and pageSize state', () => {
+ const { result } = renderHook(() => useVODLogosStore());
+
+ act(() => {
+ useVODLogosStore.setState({
+ currentPage: 3,
+ pageSize: 50,
+ });
+ });
+
+ expect(result.current.currentPage).toBe(3);
+ expect(result.current.pageSize).toBe(50);
+ });
});
From c8430760c419bd5efa64e7d9478d3c77f9a457a7 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Wed, 28 Jan 2026 13:15:26 -0800
Subject: [PATCH 080/125] Added tests after sync
---
.../__tests__/useTablePreferences.test.jsx | 390 ++++++++++++++++++
.../src/store/__tests__/streamsTable.test.jsx | 349 ++++++++++++++++
2 files changed, 739 insertions(+)
create mode 100644 frontend/src/hooks/__tests__/useTablePreferences.test.jsx
create mode 100644 frontend/src/store/__tests__/streamsTable.test.jsx
diff --git a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx
new file mode 100644
index 00000000..2158df54
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx
@@ -0,0 +1,390 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import useTablePreferences from '../useTablePreferences';
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store = {};
+
+ return {
+ getItem: vi.fn((key) => store[key] || null),
+ setItem: vi.fn((key, value) => {
+ store[key] = value.toString();
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ removeItem: vi.fn((key) => {
+ delete store[key];
+ })
+ };
+})();
+
+global.localStorage = localStorageMock;
+
+describe('useTablePreferences', () => {
+ let consoleErrorSpy;
+
+ beforeEach(() => {
+ // Spy on console.error
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ localStorageMock.clear();
+
+ // Mock window.addEventListener and removeEventListener
+ vi.spyOn(window, 'addEventListener');
+ vi.spyOn(window, 'removeEventListener');
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ consoleErrorSpy.mockRestore();
+ });
+
+ describe('Initial State', () => {
+ it('should initialize with default values when localStorage is empty', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ expect(result.current.headerPinned).toBe(false);
+ expect(result.current.tableSize).toBe('default');
+ });
+
+ it('should initialize headerPinned from localStorage', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ expect(result.current.headerPinned).toBe(true);
+ });
+
+ it('should initialize tableSize from localStorage', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ expect(result.current.tableSize).toBe('compact');
+ });
+
+ it('should initialize both preferences from localStorage', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' }));
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ expect(result.current.headerPinned).toBe(true);
+ expect(result.current.tableSize).toBe('comfortable');
+ });
+
+ it('should migrate tableSize from old localStorage location', () => {
+ localStorageMock.setItem('table-size', JSON.stringify('compact'));
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ expect(result.current.tableSize).toBe('compact');
+ });
+
+ it('should prefer new localStorage location over old location', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' }));
+ localStorageMock.setItem('table-size', JSON.stringify('compact'));
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ expect(result.current.tableSize).toBe('comfortable');
+ });
+
+ it('should handle malformed JSON in localStorage gracefully', () => {
+ localStorageMock.setItem('table-preferences', 'invalid json');
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ expect(result.current.headerPinned).toBe(false);
+ expect(result.current.tableSize).toBe('default');
+ expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('setHeaderPinned', () => {
+ it('should update headerPinned state', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setHeaderPinned(true);
+ });
+
+ expect(result.current.headerPinned).toBe(true);
+ });
+
+ it('should persist headerPinned to localStorage', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setHeaderPinned(true);
+ });
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true })
+ );
+ });
+
+ it('should preserve existing preferences when updating headerPinned', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setHeaderPinned(true);
+ });
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'table-preferences',
+ JSON.stringify({ tableSize: 'compact', headerPinned: true })
+ );
+ });
+
+ it('should dispatch custom event when updating headerPinned', () => {
+ const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setHeaderPinned(true);
+ });
+
+ expect(dispatchEventSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'table-preferences-changed',
+ detail: { headerPinned: true },
+ })
+ );
+
+ dispatchEventSpy.mockRestore();
+ });
+
+ it('should handle localStorage errors gracefully', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
+ localStorageMock.setItem.mockImplementationOnce(() => {
+ throw new Error('Storage error');
+ });
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setHeaderPinned(true);
+ });
+
+ expect(result.current.headerPinned).toBe(true);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error saving headerPinned to localStorage:',
+ expect.any(Error)
+ );
+ });
+ });
+
+ describe('setTableSize', () => {
+ it('should update tableSize state', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setTableSize('compact');
+ });
+
+ expect(result.current.tableSize).toBe('compact');
+ });
+
+ it('should persist tableSize to localStorage', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setTableSize('comfortable');
+ });
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'table-preferences',
+ JSON.stringify({ tableSize: 'comfortable' })
+ );
+ });
+
+ it('should preserve existing preferences when updating tableSize', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setTableSize('compact');
+ });
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true, tableSize: 'compact' })
+ );
+ });
+
+ it('should dispatch custom event when updating tableSize', () => {
+ const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setTableSize('compact');
+ });
+
+ expect(dispatchEventSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'table-preferences-changed',
+ detail: { tableSize: 'compact' },
+ })
+ );
+
+ dispatchEventSpy.mockRestore();
+ });
+
+ it('should handle localStorage errors gracefully', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
+ localStorageMock.setItem.mockImplementationOnce(() => {
+ throw new Error('Storage error');
+ });
+
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ result.current.setTableSize('compact');
+ });
+
+ expect(result.current.tableSize).toBe('compact');
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error saving tableSize to localStorage:',
+ expect.any(Error)
+ );
+ });
+ });
+
+ describe('Event Listeners', () => {
+ it('should register event listener on mount', () => {
+ renderHook(() => useTablePreferences());
+
+ expect(window.addEventListener).toHaveBeenCalledWith(
+ 'table-preferences-changed',
+ expect.any(Function)
+ );
+ });
+
+ it('should remove event listener on unmount', () => {
+ const { unmount } = renderHook(() => useTablePreferences());
+
+ unmount();
+
+ expect(window.removeEventListener).toHaveBeenCalledWith(
+ 'table-preferences-changed',
+ expect.any(Function)
+ );
+ });
+
+ it('should update headerPinned from custom event', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ const event = new CustomEvent('table-preferences-changed', {
+ detail: { headerPinned: true },
+ });
+ window.dispatchEvent(event);
+ });
+
+ expect(result.current.headerPinned).toBe(true);
+ });
+
+ it('should update tableSize from custom event', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ const event = new CustomEvent('table-preferences-changed', {
+ detail: { tableSize: 'compact' },
+ });
+ window.dispatchEvent(event);
+ });
+
+ expect(result.current.tableSize).toBe('compact');
+ });
+
+ it('should update both preferences from custom event', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ act(() => {
+ const event = new CustomEvent('table-preferences-changed', {
+ detail: { headerPinned: true, tableSize: 'comfortable' },
+ });
+ window.dispatchEvent(event);
+ });
+
+ expect(result.current.headerPinned).toBe(true);
+ expect(result.current.tableSize).toBe('comfortable');
+ });
+
+ it('should not update if value is the same', () => {
+ localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
+
+ const { result } = renderHook(() => useTablePreferences());
+ const initialHeaderPinned = result.current.headerPinned;
+
+ act(() => {
+ const event = new CustomEvent('table-preferences-changed', {
+ detail: { headerPinned: true },
+ });
+ window.dispatchEvent(event);
+ });
+
+ expect(result.current.headerPinned).toBe(initialHeaderPinned);
+ });
+ });
+
+ describe('Integration Tests', () => {
+ it('should handle complete workflow', () => {
+ const { result } = renderHook(() => useTablePreferences());
+
+ // Update headerPinned
+ act(() => {
+ result.current.setHeaderPinned(true);
+ });
+
+ expect(result.current.headerPinned).toBe(true);
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true })
+ );
+
+ // Update tableSize
+ act(() => {
+ result.current.setTableSize('compact');
+ });
+
+ expect(result.current.tableSize).toBe('compact');
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true, tableSize: 'compact' })
+ );
+
+ // Verify both preferences are maintained
+ expect(result.current.headerPinned).toBe(true);
+ expect(result.current.tableSize).toBe('compact');
+ });
+
+ it('should sync changes across multiple hook instances via events', () => {
+ const { result: result1 } = renderHook(() => useTablePreferences());
+ const { result: result2 } = renderHook(() => useTablePreferences());
+
+ // Update from first instance
+ act(() => {
+ result1.current.setHeaderPinned(true);
+ });
+
+ // Second instance should receive the event
+ act(() => {
+ const event = new CustomEvent('table-preferences-changed', {
+ detail: { headerPinned: true },
+ });
+ window.dispatchEvent(event);
+ });
+
+ expect(result2.current.headerPinned).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/store/__tests__/streamsTable.test.jsx b/frontend/src/store/__tests__/streamsTable.test.jsx
new file mode 100644
index 00000000..62818107
--- /dev/null
+++ b/frontend/src/store/__tests__/streamsTable.test.jsx
@@ -0,0 +1,349 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import useStreamsTableStore from '../streamsTable';
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store = {};
+
+ return {
+ getItem: vi.fn((key) => store[key] || null),
+ setItem: vi.fn((key, value) => {
+ store[key] = value.toString();
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ removeItem: vi.fn((key) => {
+ delete store[key];
+ })
+ };
+})();
+
+globalThis.localStorage = localStorageMock;
+
+describe('useStreamsTableStore', () => {
+ beforeEach(() => {
+ localStorageMock.clear();
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Initial State', () => {
+ it('should initialize with default state', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ expect(result.current.streams).toEqual([]);
+ expect(result.current.pageCount).toBe(0);
+ expect(result.current.totalCount).toBe(0);
+ expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]);
+ expect(result.current.pagination).toEqual({
+ pageIndex: 0,
+ pageSize: 50,
+ });
+ expect(result.current.selectedStreamIds).toEqual([]);
+ expect(result.current.allQueryIds).toEqual([]);
+ expect(result.current.lastQueryParams).toBeNull();
+ });
+
+ it('should initialize pagination with localStorage value if available', () => {
+ // Set localStorage BEFORE getting the store state
+ localStorageMock.setItem('streams-page-size', JSON.stringify(100));
+
+ // Now update the store to re-read from localStorage
+ useStreamsTableStore.setState({
+ pagination: {
+ pageIndex: 0,
+ pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
+ }
+ });
+
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ expect(result.current.pagination.pageSize).toBe(100);
+ });
+
+ it('should use default page size if localStorage is empty', () => {
+ // Ensure localStorage is empty
+ localStorageMock.clear();
+
+ useStreamsTableStore.setState({
+ pagination: {
+ pageIndex: 0,
+ pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
+ }
+ });
+
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ expect(result.current.pagination.pageSize).toBe(50);
+ });
+ });
+
+ describe('queryStreams', () => {
+ it('should update streams, totalCount, and pageCount', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const mockResults = [
+ { id: 1, name: 'Stream 1' },
+ { id: 2, name: 'Stream 2' },
+ ];
+ const mockCount = 100;
+ const mockParams = new URLSearchParams({ page_size: '50' });
+
+ act(() => {
+ result.current.queryStreams(
+ { results: mockResults, count: mockCount },
+ mockParams
+ );
+ });
+
+ expect(result.current.streams).toEqual(mockResults);
+ expect(result.current.totalCount).toBe(100);
+ expect(result.current.pageCount).toBe(2); // Math.ceil(100 / 50)
+ });
+
+ it('should calculate pageCount correctly with different page sizes', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const mockParams = new URLSearchParams({ page_size: '25' });
+
+ act(() => {
+ result.current.queryStreams(
+ { results: [], count: 75 },
+ mockParams
+ );
+ });
+
+ expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
+ });
+
+ it('should handle empty results', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const mockParams = new URLSearchParams({ page_size: '50' });
+
+ act(() => {
+ result.current.queryStreams(
+ { results: [], count: 0 },
+ mockParams
+ );
+ });
+
+ expect(result.current.streams).toEqual([]);
+ expect(result.current.totalCount).toBe(0);
+ expect(result.current.pageCount).toBe(0);
+ });
+ });
+
+ describe('setAllQueryIds', () => {
+ it('should update allQueryIds', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const mockIds = [1, 2, 3, 4, 5];
+
+ act(() => {
+ result.current.setAllQueryIds(mockIds);
+ });
+
+ expect(result.current.allQueryIds).toEqual(mockIds);
+ });
+
+ it('should replace previous allQueryIds', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ act(() => {
+ result.current.setAllQueryIds([1, 2, 3]);
+ });
+
+ act(() => {
+ result.current.setAllQueryIds([4, 5, 6]);
+ });
+
+ expect(result.current.allQueryIds).toEqual([4, 5, 6]);
+ });
+ });
+
+ describe('setSelectedStreamIds', () => {
+ it('should update selectedStreamIds', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const mockIds = [1, 3, 5];
+
+ act(() => {
+ result.current.setSelectedStreamIds(mockIds);
+ });
+
+ expect(result.current.selectedStreamIds).toEqual(mockIds);
+ });
+
+ it('should handle empty selection', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ act(() => {
+ result.current.setSelectedStreamIds([1, 2, 3]);
+ });
+
+ act(() => {
+ result.current.setSelectedStreamIds([]);
+ });
+
+ expect(result.current.selectedStreamIds).toEqual([]);
+ });
+ });
+
+ describe('setPagination', () => {
+ it('should update pagination state', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const newPagination = {
+ pageIndex: 2,
+ pageSize: 100,
+ };
+
+ act(() => {
+ result.current.setPagination(newPagination);
+ });
+
+ expect(result.current.pagination).toEqual(newPagination);
+ });
+
+ it('should replace entire pagination object', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ act(() => {
+ result.current.setPagination({ pageIndex: 1, pageSize: 25 });
+ });
+
+ act(() => {
+ result.current.setPagination({ pageIndex: 3, pageSize: 75 });
+ });
+
+ expect(result.current.pagination).toEqual({ pageIndex: 3, pageSize: 75 });
+ });
+ });
+
+ describe('setSorting', () => {
+ it('should update sorting state', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const newSorting = [{ id: 'created_at', desc: true }];
+
+ act(() => {
+ result.current.setSorting(newSorting);
+ });
+
+ expect(result.current.sorting).toEqual(newSorting);
+ });
+
+ it('should handle multiple sorting columns', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const newSorting = [
+ { id: 'name', desc: false },
+ { id: 'created_at', desc: true },
+ ];
+
+ act(() => {
+ result.current.setSorting(newSorting);
+ });
+
+ expect(result.current.sorting).toEqual(newSorting);
+ });
+
+ it('should handle empty sorting array', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ act(() => {
+ result.current.setSorting([]);
+ });
+
+ expect(result.current.sorting).toEqual([]);
+ });
+ });
+
+ describe('setLastQueryParams', () => {
+ it('should update lastQueryParams', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ const mockParams = new URLSearchParams({ page: '1', search: 'test' });
+
+ act(() => {
+ result.current.setLastQueryParams(mockParams);
+ });
+
+ expect(result.current.lastQueryParams).toBe(mockParams);
+ });
+
+ it('should handle null value', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ act(() => {
+ result.current.setLastQueryParams(new URLSearchParams());
+ });
+
+ act(() => {
+ result.current.setLastQueryParams(null);
+ });
+
+ expect(result.current.lastQueryParams).toBeNull();
+ });
+ });
+
+ describe('Integration Tests', () => {
+ it('should handle a typical query flow', () => {
+ const { result } = renderHook(() => useStreamsTableStore());
+
+ // Set pagination
+ act(() => {
+ result.current.setPagination({ pageIndex: 0, pageSize: 25 });
+ });
+
+ // Set sorting
+ act(() => {
+ result.current.setSorting([{ id: 'created_at', desc: true }]);
+ });
+
+ // Query streams
+ const mockResults = [
+ { id: 1, name: 'Stream 1' },
+ { id: 2, name: 'Stream 2' },
+ ];
+ const mockParams = new URLSearchParams({ page_size: '25' });
+
+ act(() => {
+ result.current.queryStreams(
+ { results: mockResults, count: 50 },
+ mockParams
+ );
+ });
+
+ // Set query IDs
+ act(() => {
+ result.current.setAllQueryIds([1, 2, 3, 4, 5]);
+ });
+
+ // Select streams
+ act(() => {
+ result.current.setSelectedStreamIds([1, 2]);
+ });
+
+ // Set last query params
+ act(() => {
+ result.current.setLastQueryParams(mockParams);
+ });
+
+ expect(result.current.streams).toEqual(mockResults);
+ expect(result.current.totalCount).toBe(50);
+ expect(result.current.pageCount).toBe(2);
+ expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
+ expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]);
+ expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]);
+ expect(result.current.selectedStreamIds).toEqual([1, 2]);
+ expect(result.current.lastQueryParams).toBe(mockParams);
+ });
+ });
+});
From 37a2a7b52b047d61908373cd2d0c388ca01e6048 Mon Sep 17 00:00:00 2001
From: None
Date: Wed, 28 Jan 2026 20:15:03 -0600
Subject: [PATCH 081/125] fix: Escape XML special characters in EPG channel IDs
Fixed XML parsing errors in EPG output when channel IDs contain special
characters.
Changes:
- Added html.escape() to apps/output/views.py where
channel_id is used in XML attributes
- Added unit tests in apps/output/tests.py to validate XML
escaping for &, <, >, and " characters
---
apps/output/tests.py | 106 +++++++++++++++++++++++++++++++++++++++++++
apps/output/views.py | 10 ++--
2 files changed, 111 insertions(+), 5 deletions(-)
diff --git a/apps/output/tests.py b/apps/output/tests.py
index f87c8340..b1c7d589 100644
--- a/apps/output/tests.py
+++ b/apps/output/tests.py
@@ -1,5 +1,8 @@
from django.test import TestCase, Client
from django.urls import reverse
+from apps.channels.models import Channel, ChannelGroup
+from apps.epg.models import EPGData, EPGSource
+import xml.etree.ElementTree as ET
class OutputM3UTest(TestCase):
def setUp(self):
@@ -37,3 +40,106 @@ class OutputM3UTest(TestCase):
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())
+
+
+class OutputEPGXMLEscapingTest(TestCase):
+ """Test XML escaping of channel_id attributes in EPG generation"""
+
+ def setUp(self):
+ self.client = Client()
+ self.group = ChannelGroup.objects.create(name="Test Group")
+
+ def test_channel_id_with_ampersand(self):
+ """Test channel ID with ampersand is properly escaped"""
+ channel = Channel.objects.create(
+ channel_number=1.0,
+ name="Test Channel",
+ tvg_id="News & Sports",
+ channel_group=self.group
+ )
+
+ url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ content = response.content.decode()
+
+ # Should contain escaped ampersand
+ self.assertIn('id="News & Sports"', content)
+ self.assertNotIn('id="News & Sports"', content)
+
+ # Verify XML is parseable
+ try:
+ ET.fromstring(content)
+ except ET.ParseError as e:
+ self.fail(f"Generated EPG is not valid XML: {e}")
+
+ def test_channel_id_with_angle_brackets(self):
+ """Test channel ID with < and > characters"""
+ channel = Channel.objects.create(
+ channel_number=2.0,
+ name="HD Channel",
+ tvg_id="Channel ",
+ channel_group=self.group
+ )
+
+ url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
+ response = self.client.get(url)
+
+ content = response.content.decode()
+ self.assertIn('id="Channel <HD>"', content)
+
+ try:
+ ET.fromstring(content)
+ except ET.ParseError as e:
+ self.fail(f"Generated EPG with < > is not valid XML: {e}")
+
+ def test_channel_id_with_all_special_chars(self):
+ """Test channel ID with all XML special characters"""
+ channel = Channel.objects.create(
+ channel_number=3.0,
+ name="Complex Channel",
+ tvg_id='Test & "Special" ',
+ channel_group=self.group
+ )
+
+ url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
+ response = self.client.get(url)
+
+ content = response.content.decode()
+ self.assertIn('id="Test & "Special" <Chars>"', content)
+
+ try:
+ tree = ET.fromstring(content)
+ # Verify we can find the channel with correct ID in parsed tree
+ channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" "]')
+ self.assertIsNotNone(channel_elem)
+ except ET.ParseError as e:
+ self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
+
+ def test_program_channel_attribute_escaping(self):
+ """Test that programme elements also have escaped channel attributes"""
+ epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy")
+ epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source)
+ channel = Channel.objects.create(
+ channel_number=4.0,
+ name="Program Test",
+ tvg_id="News & Sports",
+ epg_data=epg_data,
+ channel_group=self.group
+ )
+
+ url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
+ response = self.client.get(url)
+
+ content = response.content.decode()
+
+ # Check programme elements have escaped channel attributes
+ self.assertIn('channel="News & Sports"', content)
+
+ try:
+ tree = ET.fromstring(content)
+ programmes = tree.findall('.//programme[@channel="News & Sports"]')
+ self.assertGreater(len(programmes), 0)
+ except ET.ParseError as e:
+ self.fail(f"Generated EPG with programme elements is not valid XML: {e}")
diff --git a/apps/output/views.py b/apps/output/views.py
index ba75246a..58707ae2 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -1203,7 +1203,7 @@ def generate_dummy_epg(
# Create program entry with escaped channel name
xml_lines.append(
- f' '
+ f' '
)
xml_lines.append(f" {html.escape(program['title'])}")
xml_lines.append(f" {html.escape(program['description'])}")
@@ -1448,7 +1448,7 @@ def generate_epg(request, profile_name=None, user=None):
else:
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id]))
display_name = channel.name
- xml_lines.append(f' ')
+ xml_lines.append(f' ')
xml_lines.append(f' {html.escape(display_name)}')
xml_lines.append(f' ')
xml_lines.append(" ")
@@ -1523,7 +1523,7 @@ def generate_epg(request, profile_name=None, user=None):
stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
# Create program entry with escaped channel name
- yield f' \n'
+ yield f' \n'
yield f" {html.escape(program['title'])}\n"
yield f" {html.escape(program['description'])}\n"
@@ -1572,7 +1572,7 @@ def generate_epg(request, profile_name=None, user=None):
start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z")
stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
- yield f' \n'
+ yield f' \n'
yield f" {html.escape(program['title'])}\n"
yield f" {html.escape(program['description'])}\n"
@@ -1634,7 +1634,7 @@ def generate_epg(request, profile_name=None, user=None):
start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
- program_xml = [f' ']
+ program_xml = [f' ']
program_xml.append(f' {html.escape(prog.title)}')
# Add subtitle if available
From 8de22fc44dc64b2558f77f3402db20a6e05111ff Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 29 Jan 2026 10:22:07 -0600
Subject: [PATCH 082/125] Indent modular if statement for readability.
---
docker/init/02-postgres.sh | 226 ++++++++++++++++++-------------------
1 file changed, 113 insertions(+), 113 deletions(-)
diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh
index a9237df0..972dea06 100644
--- a/docker/init/02-postgres.sh
+++ b/docker/init/02-postgres.sh
@@ -3,125 +3,125 @@
# Skip internal PostgreSQL setup in modular mode (using external database)
if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
-# Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove
-# some time in the future.
-if [ -e "/data/postgresql.conf" ]; then
- echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..."
+ # Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove
+ # some time in the future.
+ if [ -e "/data/postgresql.conf" ]; then
+ echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..."
- # Create a temporary directory outside of /data
- mkdir -p /tmp/postgres_migration
+ # Create a temporary directory outside of /data
+ mkdir -p /tmp/postgres_migration
- # Move the PostgreSQL files to the temporary directory
- mv /data/* /tmp/postgres_migration/
+ # Move the PostgreSQL files to the temporary directory
+ mv /data/* /tmp/postgres_migration/
- # Create the target directory
- mkdir -p $POSTGRES_DIR
+ # Create the target directory
+ mkdir -p $POSTGRES_DIR
- # Move the files from temporary directory to the final location
- mv /tmp/postgres_migration/* $POSTGRES_DIR/
+ # Move the files from temporary directory to the final location
+ mv /tmp/postgres_migration/* $POSTGRES_DIR/
- # Clean up the temporary directory
- rmdir /tmp/postgres_migration
+ # Clean up the temporary directory
+ rmdir /tmp/postgres_migration
- # Set proper ownership and permissions for PostgreSQL data directory
- chown -R postgres:postgres $POSTGRES_DIR
- chmod 700 $POSTGRES_DIR
+ # Set proper ownership and permissions for PostgreSQL data directory
+ chown -R postgres:postgres $POSTGRES_DIR
+ chmod 700 $POSTGRES_DIR
- echo "Migration completed successfully."
-fi
+ echo "Migration completed successfully."
+ fi
-PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION"
+ PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION"
-# Detect current version from data directory, if present
-if [ -f "$PG_VERSION_FILE" ]; then
- CURRENT_VERSION=$(cat "$PG_VERSION_FILE")
-else
- CURRENT_VERSION=""
-fi
+ # Detect current version from data directory, if present
+ if [ -f "$PG_VERSION_FILE" ]; then
+ CURRENT_VERSION=$(cat "$PG_VERSION_FILE")
+ else
+ CURRENT_VERSION=""
+ fi
-# Only run upgrade if current version is set and not the target
-if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then
- echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..."
- # Set binary paths for upgrade if needed
- OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin"
- NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
- PG_INSTALLED_BY_SCRIPT=0
- if [ ! -d "$OLD_BINDIR" ]; then
- echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..."
- apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
- if [ $? -ne 0 ]; then
- echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting."
- exit 1
+ # Only run upgrade if current version is set and not the target
+ if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then
+ echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..."
+ # Set binary paths for upgrade if needed
+ OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin"
+ NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
+ PG_INSTALLED_BY_SCRIPT=0
+ if [ ! -d "$OLD_BINDIR" ]; then
+ echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..."
+ apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
+ if [ $? -ne 0 ]; then
+ echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting."
+ exit 1
+ fi
+ PG_INSTALLED_BY_SCRIPT=1
+ fi
+
+ # Prepare new data directory
+ NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION"
+
+ # Remove new data directory if it already exists (from a failed/partial upgrade)
+ if [ -d "$NEW_POSTGRES_DIR" ]; then
+ echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues."
+ rm -rf "$NEW_POSTGRES_DIR"
+ fi
+
+ mkdir -p "$NEW_POSTGRES_DIR"
+ chown -R postgres:postgres "$NEW_POSTGRES_DIR"
+ chmod 700 "$NEW_POSTGRES_DIR"
+
+ # Initialize new data directory
+ echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..."
+ su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR"
+ echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..."
+ # Run pg_upgrade
+ su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR"
+
+ # Move old data directory for backup, move new into place
+ mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)"
+ mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR"
+
+ echo "Upgrade complete. Old data directory backed up."
+
+ # Uninstall PostgreSQL if we installed it just for upgrade
+ if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then
+ echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..."
+ apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
+ apt autoremove -y
fi
- PG_INSTALLED_BY_SCRIPT=1
fi
- # Prepare new data directory
- NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION"
+ # Initialize PostgreSQL database
+ if [ -z "$(ls -A $POSTGRES_DIR)" ]; then
+ echo "Initializing PostgreSQL database..."
+ mkdir -p $POSTGRES_DIR
+ chown -R postgres:postgres $POSTGRES_DIR
+ chmod 700 $POSTGRES_DIR
- # Remove new data directory if it already exists (from a failed/partial upgrade)
- if [ -d "$NEW_POSTGRES_DIR" ]; then
- echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues."
- rm -rf "$NEW_POSTGRES_DIR"
- fi
+ # Initialize PostgreSQL
+ su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}"
+ # Configure PostgreSQL
+ echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf"
+ echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf"
- mkdir -p "$NEW_POSTGRES_DIR"
- chown -R postgres:postgres "$NEW_POSTGRES_DIR"
- chmod 700 "$NEW_POSTGRES_DIR"
+ # Start PostgreSQL
+ echo "Starting Postgres..."
+ su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
+ # Wait for PostgreSQL to be ready
+ until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
+ echo "Waiting for PostgreSQL to be ready..."
+ sleep 1
+ done
- # Initialize new data directory
- echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..."
- su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR"
- echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..."
- # Run pg_upgrade
- su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR"
+ postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
- # Move old data directory for backup, move new into place
- mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)"
- mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR"
-
- echo "Upgrade complete. Old data directory backed up."
-
- # Uninstall PostgreSQL if we installed it just for upgrade
- if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then
- echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..."
- apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
- apt autoremove -y
- fi
-fi
-
-# Initialize PostgreSQL database
-if [ -z "$(ls -A $POSTGRES_DIR)" ]; then
- echo "Initializing PostgreSQL database..."
- mkdir -p $POSTGRES_DIR
- chown -R postgres:postgres $POSTGRES_DIR
- chmod 700 $POSTGRES_DIR
-
- # Initialize PostgreSQL
- su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}"
- # Configure PostgreSQL
- echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf"
- echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf"
-
- # Start PostgreSQL
- echo "Starting Postgres..."
- su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
- # Wait for PostgreSQL to be ready
- until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
- echo "Waiting for PostgreSQL to be ready..."
- sleep 1
- done
-
- postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
-
- # Setup database if needed
- if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then
- # Create PostgreSQL database
- echo "Creating PostgreSQL database..."
- su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}"
- # Create user, set ownership, and grant privileges
- echo "Creating PostgreSQL user..."
- su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" <
Date: Thu, 29 Jan 2026 10:42:34 -0600
Subject: [PATCH 083/125] Bump modular postgres to 17 from 14 to match AIO.
---
docker/docker-compose.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 9d921c2d..c8e2d53e 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -77,7 +77,7 @@ services:
entrypoint: ["/app/docker/entrypoint.celery.sh"]
db:
- image: postgres:14
+ image: postgres:17
container_name: dispatcharr_db
ports:
- "5436:5432"
From 5f4e645a4896b128329821e1a2eec34eff1fe002 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 29 Jan 2026 13:12:08 -0600
Subject: [PATCH 084/125] changelog: Update changelog for frontend
tests/refactoring PR.
---
CHANGELOG.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 960302d2..a81a83bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including:
+ - `useLocalStorage` hook tests with localStorage mocking and error handling
+ - `useSmartLogos` hook tests for logo loading and management
+ - `useTablePreferences` hook tests for table settings persistence
+ - `useAuthStore` tests for authentication flow and token management
+ - `useChannelsStore` tests for channel data management
+ - `useUserAgentsStore` tests for user agent CRUD operations
+ - `useUsersStore` tests for user management functionality
+ - `useVODLogosStore` tests for VOD logo operations
+ - `useVideoStore` tests for video player state management
+ - `useWarningsStore` tests for warning suppression functionality
+ - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
+
### Changed
- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
From 573b7d12abf163299121a2e0994500d48c2349c2 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 29 Jan 2026 13:26:16 -0600
Subject: [PATCH 085/125] changelog: Update changelog for EPG Channel ID xml
escaping.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a81a83bf..e28ecb11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
+- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.18.1] - 2026-01-27
From 3a959c2743e9ec211e12db982da3574a00ac4a86 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 29 Jan 2026 16:15:04 -0600
Subject: [PATCH 086/125] Fixed pip download (uv doesn't have an equivalent)
---
docker/DispatcharrBase | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase
index 4793102d..d89f334f 100644
--- a/docker/DispatcharrBase
+++ b/docker/DispatcharrBase
@@ -26,7 +26,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# --- Create Python virtual environment and install dependencies ---
WORKDIR /tmp/build
COPY pyproject.toml /tmp/build/
-RUN uv venv $VIRTUAL_ENV --python 3.13 && \
+RUN uv venv $VIRTUAL_ENV --python 3.13 --seed && \
uv sync --python $VIRTUAL_ENV/bin/python --no-cache --no-install-project --no-dev && \
rm -rf /tmp/build
WORKDIR /
@@ -34,7 +34,7 @@ WORKDIR /
# --- Build legacy NumPy wheel for old hardware (store for runtime switching) ---
RUN uv pip install --python $VIRTUAL_ENV/bin/python --no-cache build && \
cd /tmp && \
- uv pip download --python $VIRTUAL_ENV/bin/python --no-binary numpy --no-deps numpy && \
+ $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \
tar -xzf numpy-*.tar.gz && \
cd numpy-*/ && \
$VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
From 29c0a6e524d51c4b356c5ad05e2b5767ef27d72a Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 29 Jan 2026 17:02:02 -0600
Subject: [PATCH 087/125] fix: update dependencies in pyproject.toml for
compatibility
---
pyproject.toml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 0a8b5cff..bdd056cc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ dependencies = [
"requests==2.32.5",
"psutil==7.1.3",
"pillow",
- "drf-yasg>=1.21.11",
+ "drf-spectacular>=0.29.0",
"streamlink",
"python-vlc",
"yt-dlp",
@@ -26,6 +26,7 @@ dependencies = [
"rapidfuzz==3.14.3",
"regex",
"tzlocal",
+ "pytz",
"torch==2.9.1+cpu",
"sentence-transformers==5.2.0",
"channels",
@@ -33,7 +34,6 @@ dependencies = [
"django-filter",
"django-celery-beat",
"lxml==6.0.2",
- "gunicorn",
]
[build-system]
From 71e117a85021bf9b8ab0cfb089997ac3ace3caf0 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 29 Jan 2026 17:24:41 -0600
Subject: [PATCH 088/125] Remove uv.lock and add to gitignore. This gets
generated automatically during build anyway and would add confusion to users
not using our container.
---
.gitignore | 3 +-
uv.lock | 2098 ----------------------------------------------------
2 files changed, 2 insertions(+), 2099 deletions(-)
delete mode 100644 uv.lock
diff --git a/.gitignore b/.gitignore
index 20968f46..6d1becaa 100755
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,5 @@ debugpy*
uwsgi.sock
package-lock.json
models
-.idea
\ No newline at end of file
+.idea
+uv.lock
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
deleted file mode 100644
index 8361f8fc..00000000
--- a/uv.lock
+++ /dev/null
@@ -1,2098 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.13"
-
-[[package]]
-name = "amqp"
-version = "5.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "vine" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
-]
-
-[[package]]
-name = "asgiref"
-version = "3.11.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
-]
-
-[[package]]
-name = "attrs"
-version = "25.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
-]
-
-[[package]]
-name = "autobahn"
-version = "25.12.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cbor2" },
- { name = "cffi" },
- { name = "cryptography" },
- { name = "hyperlink" },
- { name = "msgpack", marker = "platform_python_implementation == 'CPython'" },
- { name = "py-ubjson" },
- { name = "txaio" },
- { name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" },
- { name = "ujson" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" },
- { url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" },
- { url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" },
- { url = "https://files.pythonhosted.org/packages/ed/f7/430074a5ea3f6187335a4ddc26f16dd75d5125e346a84cf132ddbd41a3e8/autobahn-25.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9e2a962f2de0bc4c53b452916458417a15f5137c956245ac6d0a783a83fa1f7", size = 2151873, upload-time = "2025-12-15T11:13:08.89Z" },
- { url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" },
- { url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" },
- { url = "https://files.pythonhosted.org/packages/f6/58/e498821606db57305c8f3c26d9b28fd73e4e0583a1f48330df500721c418/autobahn-25.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:18b12e8af7fc115487715afa10b3f5b5a4b5989bebbe05b71722cf9fce7b1bfb", size = 2184111, upload-time = "2025-12-15T11:13:12.461Z" },
-]
-
-[[package]]
-name = "automat"
-version = "25.4.16"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" },
-]
-
-[[package]]
-name = "billiard"
-version = "4.2.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
-]
-
-[[package]]
-name = "cbor2"
-version = "5.8.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" },
- { url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" },
- { url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" },
- { url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" },
- { url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" },
- { url = "https://files.pythonhosted.org/packages/eb/d2/d41f8c04c783a4d204e364be2d38043d4f732a3bed6f4c732e321cf34c7b/cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0", size = 69841, upload-time = "2025-12-30T18:44:01.159Z" },
- { url = "https://files.pythonhosted.org/packages/1b/8c/0397a82f6e67665009951453c83058e4c77ba54b9a9017ede56d6870306c/cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea", size = 64982, upload-time = "2025-12-30T18:44:02.138Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" },
- { url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" },
- { url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" },
- { url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" },
- { url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" },
- { url = "https://files.pythonhosted.org/packages/88/6d/e43452347630efe8133f5304127539100d937c138c0996d27ec63963ec2c/cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1", size = 71657, upload-time = "2025-12-30T18:44:10.162Z" },
- { url = "https://files.pythonhosted.org/packages/8b/66/9a780ef34ab10a0437666232e885378cdd5f60197b1b5e61a62499e5a10a/cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71", size = 67171, upload-time = "2025-12-30T18:44:11.619Z" },
- { url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" },
-]
-
-[[package]]
-name = "celery"
-version = "5.6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "billiard" },
- { name = "click" },
- { name = "click-didyoumean" },
- { name = "click-plugins" },
- { name = "click-repl" },
- { name = "exceptiongroup" },
- { name = "kombu" },
- { name = "python-dateutil" },
- { name = "tzlocal" },
- { name = "vine" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ad/5f/b681ae3c89290d2ea6562ea96b40f5af6f6fc5f7743e2cd1a19e47721548/celery-5.6.0.tar.gz", hash = "sha256:641405206042d52ae460e4e9751a2e31b06cf80ab836fcf92e0b9311d7ea8113", size = 1712522, upload-time = "2025-11-30T17:39:46.282Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" },
-]
-
-[package.optional-dependencies]
-redis = [
- { name = "kombu", extra = ["redis"] },
-]
-
-[[package]]
-name = "certifi"
-version = "2026.1.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
-]
-
-[[package]]
-name = "cffi"
-version = "2.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
- { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
- { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
- { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
- { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
- { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
- { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
- { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
- { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
- { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
- { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
- { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
- { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
- { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
- { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
- { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
- { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
- { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
- { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
- { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
- { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
- { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
- { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
- { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
- { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
- { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
- { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
- { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
- { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
- { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
- { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
-]
-
-[[package]]
-name = "channels"
-version = "4.3.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asgiref" },
- { name = "django" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" },
-]
-
-[[package]]
-name = "channels-redis"
-version = "4.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asgiref" },
- { name = "channels" },
- { name = "msgpack" },
- { name = "redis" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5", size = 20641, upload-time = "2025-07-22T13:48:44.545Z" },
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
- { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
- { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
- { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
- { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
- { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
- { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
- { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
- { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
- { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
- { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
- { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
- { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
- { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
- { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
- { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
- { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
- { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
- { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
- { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
- { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
- { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
- { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
- { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
- { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
-]
-
-[[package]]
-name = "click"
-version = "8.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
-]
-
-[[package]]
-name = "click-didyoumean"
-version = "0.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" },
-]
-
-[[package]]
-name = "click-plugins"
-version = "1.1.1.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" },
-]
-
-[[package]]
-name = "click-repl"
-version = "0.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "prompt-toolkit" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "constantly"
-version = "23.10.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" },
-]
-
-[[package]]
-name = "cron-descriptor"
-version = "2.0.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" },
-]
-
-[[package]]
-name = "cryptography"
-version = "46.0.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
- { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
- { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
- { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
- { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
- { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
- { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
- { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
- { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
- { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
- { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
- { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
- { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
- { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
- { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
- { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
- { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
- { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
- { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
- { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
- { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
- { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
- { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
- { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
- { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
- { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
- { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
- { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
- { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
- { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
- { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
- { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
- { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
- { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
- { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
- { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
- { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
- { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
- { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
- { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
- { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
- { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
- { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
- { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
- { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
-]
-
-[[package]]
-name = "daphne"
-version = "4.2.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asgiref" },
- { name = "autobahn" },
- { name = "twisted", extra = ["tls"] },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/cd/9d/322b605fdc03b963cf2d33943321c8f4405e8d82e698bf49d1eed1ca40c4/daphne-4.2.1.tar.gz", hash = "sha256:5f898e700a1fda7addf1541d7c328606415e96a7bd768405f0463c312fcb31b3", size = 45600, upload-time = "2025-07-02T12:57:04.935Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" },
-]
-
-[[package]]
-name = "dispatcharr"
-version = "0.17.0"
-source = { editable = "." }
-dependencies = [
- { name = "celery", extra = ["redis"] },
- { name = "channels" },
- { name = "channels-redis" },
- { name = "daphne" },
- { name = "django" },
- { name = "django-celery-beat" },
- { name = "django-cors-headers" },
- { name = "django-filter" },
- { name = "djangorestframework" },
- { name = "djangorestframework-simplejwt" },
- { name = "drf-yasg" },
- { name = "gevent" },
- { name = "gunicorn" },
- { name = "lxml" },
- { name = "m3u8" },
- { name = "pillow" },
- { name = "psutil" },
- { name = "psycopg2-binary" },
- { name = "python-vlc" },
- { name = "rapidfuzz" },
- { name = "regex" },
- { name = "requests" },
- { name = "sentence-transformers" },
- { name = "streamlink" },
- { name = "torch" },
- { name = "tzlocal" },
- { name = "uwsgi" },
- { name = "yt-dlp" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "celery", extras = ["redis"], specifier = "==5.6.0" },
- { name = "channels" },
- { name = "channels-redis", specifier = "==4.3.0" },
- { name = "daphne" },
- { name = "django", specifier = "==5.2.9" },
- { name = "django-celery-beat" },
- { name = "django-cors-headers" },
- { name = "django-filter" },
- { name = "djangorestframework", specifier = "==3.16.1" },
- { name = "djangorestframework-simplejwt" },
- { name = "drf-yasg", specifier = ">=1.21.11" },
- { name = "gevent", specifier = "==25.9.1" },
- { name = "gunicorn" },
- { name = "lxml", specifier = "==6.0.2" },
- { name = "m3u8" },
- { name = "pillow" },
- { name = "psutil", specifier = "==7.1.3" },
- { name = "psycopg2-binary", specifier = "==2.9.11" },
- { name = "python-vlc" },
- { name = "rapidfuzz", specifier = "==3.14.3" },
- { name = "regex" },
- { name = "requests", specifier = "==2.32.5" },
- { name = "sentence-transformers", specifier = "==5.2.0" },
- { name = "streamlink" },
- { name = "torch", specifier = "==2.9.1+cpu", index = "https://download.pytorch.org/whl/cpu" },
- { name = "tzlocal" },
- { name = "uwsgi" },
- { name = "yt-dlp" },
-]
-
-[[package]]
-name = "django"
-version = "5.2.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asgiref" },
- { name = "sqlparse" },
- { name = "tzdata", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" },
-]
-
-[[package]]
-name = "django-celery-beat"
-version = "2.8.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "celery" },
- { name = "cron-descriptor" },
- { name = "django" },
- { name = "django-timezone-field" },
- { name = "python-crontab" },
- { name = "tzdata" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" },
-]
-
-[[package]]
-name = "django-cors-headers"
-version = "4.9.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asgiref" },
- { name = "django" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
-]
-
-[[package]]
-name = "django-filter"
-version = "25.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "django" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
-]
-
-[[package]]
-name = "django-timezone-field"
-version = "7.2.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "django" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" },
-]
-
-[[package]]
-name = "djangorestframework"
-version = "3.16.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "django" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
-]
-
-[[package]]
-name = "djangorestframework-simplejwt"
-version = "5.5.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "django" },
- { name = "djangorestframework" },
- { name = "pyjwt" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
-]
-
-[[package]]
-name = "drf-yasg"
-version = "1.21.14"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "django" },
- { name = "djangorestframework" },
- { name = "inflection" },
- { name = "packaging" },
- { name = "pytz" },
- { name = "pyyaml" },
- { name = "uritemplate" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/78/1b/2305804c1efbc2da1890fb2a34155ef298565eb152e1c00bd35546be42fa/drf_yasg-1.21.14.tar.gz", hash = "sha256:68d62d5f7505611ba9577115c83b36211cc20d9748a50ce95a44abe411727d10", size = 5153307, upload-time = "2026-01-15T11:37:07.086Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bb/de/61880040534036044572225c485b0a4adc99b4c92e3eed3e5741b31674fd/drf_yasg-1.21.14-py3-none-any.whl", hash = "sha256:725ddda28aec7efc4cab985290fe790c8565e665017f7c80211d288a35821c70", size = 4856031, upload-time = "2026-01-15T11:37:04.753Z" },
-]
-
-[[package]]
-name = "exceptiongroup"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
-]
-
-[[package]]
-name = "filelock"
-version = "3.20.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
-]
-
-[[package]]
-name = "fsspec"
-version = "2026.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" },
-]
-
-[[package]]
-name = "gevent"
-version = "25.9.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
- { name = "greenlet", marker = "platform_python_implementation == 'CPython'" },
- { name = "zope-event" },
- { name = "zope-interface" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" },
- { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" },
- { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" },
- { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" },
- { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" },
- { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" },
- { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" },
- { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" },
- { url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" },
- { url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" },
- { url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" },
- { url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" },
- { url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" },
- { url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" },
- { url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" },
- { url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" },
-]
-
-[[package]]
-name = "greenlet"
-version = "3.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
- { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
- { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
- { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
- { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
- { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
- { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
- { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" },
- { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" },
- { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
- { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
- { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
- { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
- { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
- { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
- { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" },
- { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" },
- { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
- { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
- { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
- { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
- { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
- { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
- { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
- { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
-]
-
-[[package]]
-name = "gunicorn"
-version = "24.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "packaging" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7b/8b/3cd14f3d78f050f8c703cb1a881d62f5fc695cfd9f258386982bdc1a018a/gunicorn-24.1.0.tar.gz", hash = "sha256:6df323c06bcb9499cbf8bc3b89a56619ac54d6a8c66130681e206272cc5b8e73", size = 286767, upload-time = "2026-01-23T20:50:38.98Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/ea/f91518dd05d81eb8fb4c10c86802aa943d65e4df4dbc820ec58e2597b501/gunicorn-24.1.0-py3-none-any.whl", hash = "sha256:d7c3e50dfdade301b1a94ed3e9a2c07ab7d154908203813c10d7b175d04bbf23", size = 114495, upload-time = "2026-01-23T20:50:36.855Z" },
-]
-
-[[package]]
-name = "h11"
-version = "0.16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
-]
-
-[[package]]
-name = "hf-xet"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" },
- { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" },
- { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" },
- { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" },
- { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" },
- { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" },
- { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" },
- { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" },
- { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" },
- { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" },
- { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" },
- { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" },
- { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" },
- { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" },
- { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" },
- { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" },
- { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" },
- { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" },
- { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" },
- { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" },
-]
-
-[[package]]
-name = "huggingface-hub"
-version = "0.36.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "filelock" },
- { name = "fsspec" },
- { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
- { name = "packaging" },
- { name = "pyyaml" },
- { name = "requests" },
- { name = "tqdm" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" },
-]
-
-[[package]]
-name = "hyperlink"
-version = "21.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" },
-]
-
-[[package]]
-name = "idna"
-version = "3.11"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
-]
-
-[[package]]
-name = "incremental"
-version = "24.11.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "packaging" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" },
-]
-
-[[package]]
-name = "inflection"
-version = "0.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
-]
-
-[[package]]
-name = "isodate"
-version = "0.7.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
-]
-
-[[package]]
-name = "joblib"
-version = "1.5.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
-]
-
-[[package]]
-name = "kombu"
-version = "5.6.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "amqp" },
- { name = "packaging" },
- { name = "tzdata" },
- { name = "vine" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" },
-]
-
-[package.optional-dependencies]
-redis = [
- { name = "redis" },
-]
-
-[[package]]
-name = "lxml"
-version = "6.0.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
- { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
- { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
- { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
- { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
- { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
- { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
- { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
- { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
- { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
- { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
- { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
- { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
- { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
- { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
- { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
- { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
- { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
- { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
- { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
- { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
- { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
- { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
- { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
- { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
- { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
- { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
- { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
- { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
- { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
- { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
- { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
- { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
- { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
- { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
- { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
- { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
- { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
- { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
- { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
- { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
- { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
- { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
- { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
- { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
- { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
- { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
- { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
- { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
- { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
- { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
- { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
- { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
- { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
-]
-
-[[package]]
-name = "m3u8"
-version = "6.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" },
-]
-
-[[package]]
-name = "markupsafe"
-version = "3.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
- { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
- { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
- { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
- { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
- { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
- { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
- { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
- { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
- { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
- { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
- { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
- { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
- { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
- { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
- { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
- { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
- { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
- { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
- { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
- { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
- { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
- { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
- { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
- { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
- { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
- { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
- { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
- { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
- { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
- { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
- { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
- { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
- { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
-]
-
-[[package]]
-name = "mpmath"
-version = "1.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
-]
-
-[[package]]
-name = "msgpack"
-version = "1.1.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
- { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
- { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
- { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
- { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
- { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
- { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
- { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
- { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
- { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
- { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
- { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
- { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
- { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
- { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
- { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
- { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
- { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
- { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
- { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
- { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
- { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
- { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
- { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
- { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
- { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
- { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
-]
-
-[[package]]
-name = "networkx"
-version = "3.6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
-]
-
-[[package]]
-name = "numpy"
-version = "2.4.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" },
- { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" },
- { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" },
- { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" },
- { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" },
- { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
- { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
- { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
- { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
- { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
- { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
- { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
- { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
- { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
- { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" },
- { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
- { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
- { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
- { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
- { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
- { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
- { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" },
- { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" },
- { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" },
- { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" },
- { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" },
- { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" },
- { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" },
- { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" },
- { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" },
- { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" },
- { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" },
- { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" },
- { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" },
- { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" },
- { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" },
- { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" },
- { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" },
- { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" },
- { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" },
- { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" },
- { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" },
-]
-
-[[package]]
-name = "outcome"
-version = "1.3.0.post0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" },
-]
-
-[[package]]
-name = "packaging"
-version = "26.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
-]
-
-[[package]]
-name = "pillow"
-version = "12.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
- { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
- { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
- { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
- { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
- { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
- { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
- { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
- { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
- { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
- { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
- { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
- { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
- { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
- { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
- { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
- { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
- { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
- { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
- { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
- { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
- { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
- { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
- { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
- { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
- { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
- { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
- { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
- { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
- { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
- { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
- { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
- { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
- { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
- { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
- { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
- { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
- { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
- { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
- { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
- { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
- { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
- { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
- { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
- { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
- { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
- { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
- { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
- { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
-]
-
-[[package]]
-name = "prompt-toolkit"
-version = "3.0.52"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "wcwidth" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
-]
-
-[[package]]
-name = "psutil"
-version = "7.1.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" },
- { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" },
- { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" },
- { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" },
- { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" },
- { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" },
- { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" },
- { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" },
- { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" },
- { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" },
- { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" },
- { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" },
- { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" },
- { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" },
- { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" },
- { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" },
- { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" },
- { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" },
-]
-
-[[package]]
-name = "psycopg2-binary"
-version = "2.9.11"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
- { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
- { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
- { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
- { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
- { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
- { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
- { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
- { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
- { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
- { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
- { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
- { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
- { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
- { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
- { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
- { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
- { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
- { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
- { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
- { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
-]
-
-[[package]]
-name = "py-ubjson"
-version = "0.16.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" }
-
-[[package]]
-name = "pyasn1"
-version = "0.6.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
-]
-
-[[package]]
-name = "pyasn1-modules"
-version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pyasn1" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
-]
-
-[[package]]
-name = "pycountry"
-version = "24.6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" },
-]
-
-[[package]]
-name = "pycparser"
-version = "3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
-]
-
-[[package]]
-name = "pycryptodome"
-version = "3.23.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
- { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
- { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
- { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
- { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
- { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
- { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
- { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
- { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
- { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
- { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
- { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
- { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
- { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
- { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
- { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
- { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
- { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
- { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
- { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
- { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
- { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
-]
-
-[[package]]
-name = "pyjwt"
-version = "2.10.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
-]
-
-[[package]]
-name = "pyopenssl"
-version = "25.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cryptography" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
-]
-
-[[package]]
-name = "pysocks"
-version = "1.7.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
-]
-
-[[package]]
-name = "python-crontab"
-version = "3.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
-]
-
-[[package]]
-name = "python-dateutil"
-version = "2.9.0.post0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
-]
-
-[[package]]
-name = "python-vlc"
-version = "3.0.21203"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" },
-]
-
-[[package]]
-name = "pytz"
-version = "2025.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
-]
-
-[[package]]
-name = "pyyaml"
-version = "6.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
- { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
- { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
- { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
- { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
- { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
- { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
- { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
- { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
- { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
- { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
- { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
- { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
- { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
- { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
- { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
- { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
- { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
- { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
- { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
- { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
- { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
- { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
- { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
- { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
- { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
- { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
-]
-
-[[package]]
-name = "rapidfuzz"
-version = "3.14.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" },
- { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" },
- { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" },
- { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" },
- { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" },
- { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" },
- { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" },
- { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" },
- { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" },
- { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" },
- { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" },
- { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" },
- { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" },
- { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" },
- { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" },
- { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" },
- { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" },
- { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" },
- { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" },
- { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" },
- { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" },
- { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" },
- { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" },
- { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" },
- { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" },
- { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" },
- { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" },
- { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" },
- { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" },
- { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" },
- { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" },
- { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" },
- { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" },
- { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" },
- { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" },
- { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" },
- { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" },
- { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" },
- { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" },
- { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" },
- { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" },
- { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" },
- { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" },
- { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" },
-]
-
-[[package]]
-name = "redis"
-version = "6.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
-]
-
-[[package]]
-name = "regex"
-version = "2026.1.15"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
- { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
- { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
- { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
- { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
- { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
- { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
- { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
- { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
- { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
- { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
- { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
- { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
- { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
- { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
- { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
- { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
- { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
- { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
- { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
- { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
- { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
- { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
- { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
- { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
- { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
- { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
- { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
- { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
- { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
- { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
- { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
- { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
- { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
- { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
- { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
- { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
- { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
- { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
- { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
- { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
- { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
- { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
- { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
- { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
- { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
- { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
- { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
- { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
- { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
- { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
- { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
- { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
- { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
- { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
- { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
- { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
- { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
- { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
- { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
- { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
- { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
- { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
- { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
-]
-
-[[package]]
-name = "requests"
-version = "2.32.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
-]
-
-[[package]]
-name = "safetensors"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
- { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
- { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
- { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
- { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
- { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
- { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
- { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
- { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
- { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
- { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
- { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
-]
-
-[[package]]
-name = "scikit-learn"
-version = "1.8.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "joblib" },
- { name = "numpy" },
- { name = "scipy" },
- { name = "threadpoolctl" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
- { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
- { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
- { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
- { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
- { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
- { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
- { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
- { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
- { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
- { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
- { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
- { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
- { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
- { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
- { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
- { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
- { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
- { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
- { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
- { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
- { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
- { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
- { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
-]
-
-[[package]]
-name = "scipy"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" },
- { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" },
- { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" },
- { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" },
- { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" },
- { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" },
- { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" },
- { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" },
- { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" },
- { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" },
- { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" },
- { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" },
- { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" },
- { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" },
- { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" },
- { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" },
- { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" },
- { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" },
- { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" },
- { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" },
- { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" },
- { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" },
- { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" },
- { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" },
- { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" },
- { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" },
- { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" },
- { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" },
- { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" },
- { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" },
- { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" },
- { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" },
- { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" },
- { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" },
- { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" },
- { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" },
- { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" },
- { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" },
- { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" },
-]
-
-[[package]]
-name = "sentence-transformers"
-version = "5.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "huggingface-hub" },
- { name = "scikit-learn" },
- { name = "scipy" },
- { name = "torch" },
- { name = "tqdm" },
- { name = "transformers" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a2/a1/64e7b111e753307ffb7c5b6d039c52d4a91a47fa32a7f5bc377a49b22402/sentence_transformers-5.2.0.tar.gz", hash = "sha256:acaeb38717de689f3dab45d5e5a02ebe2f75960a4764ea35fea65f58a4d3019f", size = 381004, upload-time = "2025-12-11T14:12:31.038Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/40/d0/3b2897ef6a0c0c801e9fecca26bcc77081648e38e8c772885ebdd8d7d252/sentence_transformers-5.2.0-py3-none-any.whl", hash = "sha256:aa57180f053687d29b08206766ae7db549be5074f61849def7b17bf0b8025ca2", size = 493748, upload-time = "2025-12-11T14:12:29.516Z" },
-]
-
-[[package]]
-name = "service-identity"
-version = "24.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "cryptography" },
- { name = "pyasn1" },
- { name = "pyasn1-modules" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" },
-]
-
-[[package]]
-name = "setuptools"
-version = "80.10.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" },
-]
-
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
-]
-
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
-]
-
-[[package]]
-name = "sortedcontainers"
-version = "2.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
-]
-
-[[package]]
-name = "sqlparse"
-version = "0.5.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
-]
-
-[[package]]
-name = "streamlink"
-version = "8.1.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "isodate" },
- { name = "lxml" },
- { name = "pycountry" },
- { name = "pycryptodome" },
- { name = "pysocks" },
- { name = "requests" },
- { name = "trio" },
- { name = "trio-websocket" },
- { name = "urllib3" },
- { name = "websocket-client" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7a/89/ea3baa9d0c2d564412be15ef5e799a56bf2a0068b27197e5e4c312ba13f5/streamlink-8.1.2.tar.gz", hash = "sha256:d08099fa1b169bad4a991d31b2ab89f04ba08b1319ed1f5bd0ead8547d4c5ad3", size = 823590, upload-time = "2026-01-18T12:21:53.28Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/8f/124e4db6bbf8fe3ab9bfe4dbd029f29211629c0f1733da2695576917c226/streamlink-8.1.2-py3-none-any.whl", hash = "sha256:2355cffcdbe60e8270144b5191a20b2ab195bbb9150b453334a23b829d1d75c7", size = 556372, upload-time = "2026-01-18T12:21:47.062Z" },
- { url = "https://files.pythonhosted.org/packages/c9/11/63a8c6b2b733d55189888504530865f68a3b69ebde7b78138d680e56bb45/streamlink-8.1.2-py3-none-win32.whl", hash = "sha256:c5aba06f24bb7a6a261ec409a301b5d8564ea865933f08001533067e5e40529c", size = 556386, upload-time = "2026-01-18T12:21:49.505Z" },
- { url = "https://files.pythonhosted.org/packages/ed/5a/81a8bbe0d8755154979a4cd56f2b03da67ddd2db7ab9e14145a35cf0eb72/streamlink-8.1.2-py3-none-win_amd64.whl", hash = "sha256:86043d339a1b9b9b49f36ed26cdd421d226a021f06c9434e2177bfc6caf420d5", size = 556389, upload-time = "2026-01-18T12:21:51.385Z" },
-]
-
-[[package]]
-name = "sympy"
-version = "1.14.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "mpmath" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
-]
-
-[[package]]
-name = "threadpoolctl"
-version = "3.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
-]
-
-[[package]]
-name = "tokenizers"
-version = "0.22.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "huggingface-hub" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
- { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
- { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
- { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
- { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
- { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
- { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
- { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
- { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
- { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
- { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
- { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
- { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
- { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
- { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
-]
-
-[[package]]
-name = "torch"
-version = "2.9.1+cpu"
-source = { registry = "https://download.pytorch.org/whl/cpu" }
-dependencies = [
- { name = "filelock" },
- { name = "fsspec" },
- { name = "jinja2" },
- { name = "networkx" },
- { name = "setuptools" },
- { name = "sympy" },
- { name = "typing-extensions" },
-]
-wheels = [
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:404a7ab2fffaf2ca069e662f331eb46313692b2f1630df2720094284f390ccef" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:161decbff26a33f13cb5ba6d2c8f458bbf56193bcc32ecc70be6dd4c7a3ee79d" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:e586ab1363e3f86aa4cc133b7fdcf98deb1d2c13d43a7a6e5a6a18e9c5364893" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:65010ab4aacce6c9a1ddfc935f986c003ca8638ded04348fd326c3e74346237c" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:88adf5157db5da1d54b1c9fe4a6c1d20ceef00e75d854e206a87dbf69e3037dc" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:f60e2565f261542efac07e25208fb3fc55c6fe82314a5a9cbee971edb5f27713" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ac2b8df2c55430e836dcda31940d47f1f5f94b8731057b6f20300ebea394dd9" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b688445f928f13563b7418b17c57e97bf955ab559cf73cd8f2b961f8572dbb3" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:cf9c3e50b595721ca6b488bdcc326e0f1af73ed28b9b66eff504a96649bb5c96" },
-]
-
-[[package]]
-name = "tqdm"
-version = "4.67.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
-]
-
-[[package]]
-name = "transformers"
-version = "4.57.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "filelock" },
- { name = "huggingface-hub" },
- { name = "numpy" },
- { name = "packaging" },
- { name = "pyyaml" },
- { name = "regex" },
- { name = "requests" },
- { name = "safetensors" },
- { name = "tokenizers" },
- { name = "tqdm" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" },
-]
-
-[[package]]
-name = "trio"
-version = "0.32.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
- { name = "idna" },
- { name = "outcome" },
- { name = "sniffio" },
- { name = "sortedcontainers" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" },
-]
-
-[[package]]
-name = "trio-websocket"
-version = "0.12.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "outcome" },
- { name = "trio" },
- { name = "wsproto" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" },
-]
-
-[[package]]
-name = "twisted"
-version = "25.5.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "automat" },
- { name = "constantly" },
- { name = "hyperlink" },
- { name = "incremental" },
- { name = "typing-extensions" },
- { name = "zope-interface" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" },
-]
-
-[package.optional-dependencies]
-tls = [
- { name = "idna" },
- { name = "pyopenssl" },
- { name = "service-identity" },
-]
-
-[[package]]
-name = "txaio"
-version = "25.12.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7f/67/ea9c9ddbaa3e0b4d53c91f8778a33e42045be352dc7200ed2b9aaa7dc229/txaio-25.12.2.tar.gz", hash = "sha256:9f232c21e12aa1ff52690e365b5a0ecfd42cc27a6ec86e1b92ece88f763f4b78", size = 117393, upload-time = "2025-12-09T15:03:26.527Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/50/05/bdb6318120cac9bf97779674f49035e0595d894b42d4c43b60637bafdb1f/txaio-25.12.2-py3-none-any.whl", hash = "sha256:5f6cd6c6b397fc3305790d15efd46a2d5b91cdbefa96543b4f8666aeb56ba026", size = 31208, upload-time = "2025-12-09T04:30:27.811Z" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.15.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
-]
-
-[[package]]
-name = "tzdata"
-version = "2025.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
-]
-
-[[package]]
-name = "tzlocal"
-version = "5.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "tzdata", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
-]
-
-[[package]]
-name = "u-msgpack-python"
-version = "2.8.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/36/9d/a40411a475e7d4838994b7f6bcc6bfca9acc5b119ce3a7503608c4428b49/u-msgpack-python-2.8.0.tar.gz", hash = "sha256:b801a83d6ed75e6df41e44518b4f2a9c221dc2da4bcd5380e3a0feda520bc61a", size = 18167, upload-time = "2023-05-18T09:28:12.187Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" },
-]
-
-[[package]]
-name = "ujson"
-version = "5.11.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" },
- { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" },
- { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" },
- { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" },
- { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" },
- { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" },
- { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" },
- { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" },
- { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" },
- { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" },
- { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" },
- { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" },
- { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" },
- { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" },
- { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" },
- { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" },
- { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" },
- { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" },
- { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" },
- { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" },
- { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" },
- { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" },
- { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" },
- { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" },
- { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" },
- { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" },
- { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" },
- { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" },
- { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" },
- { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" },
- { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" },
- { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" },
- { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" },
-]
-
-[[package]]
-name = "uritemplate"
-version = "4.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
-]
-
-[[package]]
-name = "urllib3"
-version = "2.6.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
-]
-
-[[package]]
-name = "uwsgi"
-version = "2.0.31"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9f/49/2f57640e889ba509fd1fae10cccec1b58972a07c2724486efba94c5ea448/uwsgi-2.0.31.tar.gz", hash = "sha256:e8f8b350ccc106ff93a65247b9136f529c14bf96b936ac5b264c6ff9d0c76257", size = 822796, upload-time = "2025-10-11T19:17:28.794Z" }
-
-[[package]]
-name = "vine"
-version = "5.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
-]
-
-[[package]]
-name = "wcwidth"
-version = "0.3.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/05/07/0b5bcc9812b1b2fd331cc88289ef4d47d428afdbbf0216bb7d53942d93d6/wcwidth-0.3.2.tar.gz", hash = "sha256:d469b3059dab6b1077def5923ed0a8bf5738bd4a1a87f686d5e2de455354c4ad", size = 233633, upload-time = "2026-01-23T21:08:52.451Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/72/c6/1452e716c5af065c018f75d42ca97517a04ac6aae4133722e0424649a07c/wcwidth-0.3.2-py3-none-any.whl", hash = "sha256:817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315", size = 86280, upload-time = "2026-01-23T21:08:51.362Z" },
-]
-
-[[package]]
-name = "websocket-client"
-version = "1.9.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
-]
-
-[[package]]
-name = "wsproto"
-version = "1.3.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
-]
-
-[[package]]
-name = "yt-dlp"
-version = "2025.12.8"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947, upload-time = "2025-12-08T00:16:01.649Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464, upload-time = "2025-12-08T00:15:58.556Z" },
-]
-
-[[package]]
-name = "zope-event"
-version = "6.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" },
-]
-
-[[package]]
-name = "zope-interface"
-version = "8.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" },
- { url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" },
- { url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" },
- { url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" },
- { url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" },
- { url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" },
- { url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" },
- { url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" },
- { url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" },
- { url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" },
- { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" },
- { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" },
-]
From ac48580e637d8b54ad89786056dcf4cdee0d24b7 Mon Sep 17 00:00:00 2001
From: None
Date: Thu, 29 Jan 2026 20:35:20 -0600
Subject: [PATCH 089/125] Move EPG match settings from Settings page to
auto-match modal
Relocate EPG matching configuration (ignore prefixes, suffixes, and custom
strings) from the Settings page to a dedicated modal that opens when triggering auto-match.
Changes:
- frontend/src/components/modals/EPGMatchModal.jsx: Added new modal that combines EPG match settings configuration with the auto-match trigger.
- frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx: Added EPGMatchModal import and state. Changed menu item to open modal instead of directly calling API. Removed inline matchEpg function (logic moved to modal)
- frontend/src/pages/Settings.jsx: Removed EPG Settings accordion (now accessible via modal)
- frontend/src/components/forms/settings/EPGSettingsForm.jsx: Deleted - functionality replaced by EPGMatchModal
- frontend/src/utils/forms/settings/EPGSettingsFormUtils.js: Deleted - was only used by EPGSettingsForm (now defunct)
---
.../forms/settings/EPGSettingsForm.jsx | 102 ----------
.../src/components/modals/EPGMatchModal.jsx | 178 ++++++++++++++++++
.../ChannelsTable/ChannelTableHeader.jsx | 31 +--
frontend/src/pages/Settings.jsx | 14 --
.../forms/settings/EPGSettingsFormUtils.js | 11 --
5 files changed, 188 insertions(+), 148 deletions(-)
delete mode 100644 frontend/src/components/forms/settings/EPGSettingsForm.jsx
create mode 100644 frontend/src/components/modals/EPGMatchModal.jsx
delete mode 100644 frontend/src/utils/forms/settings/EPGSettingsFormUtils.js
diff --git a/frontend/src/components/forms/settings/EPGSettingsForm.jsx b/frontend/src/components/forms/settings/EPGSettingsForm.jsx
deleted file mode 100644
index 7cfcc513..00000000
--- a/frontend/src/components/forms/settings/EPGSettingsForm.jsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import useSettingsStore from '../../../store/settings.jsx';
-import React, { useEffect, useState } from 'react';
-import {
- getChangedSettings,
- parseSettings,
- saveChangedSettings,
-} from '../../../utils/pages/SettingsUtils.js';
-import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core';
-import { useForm } from '@mantine/form';
-import { getEPGSettingsFormInitialValues } from '../../../utils/forms/settings/EPGSettingsFormUtils.js';
-
-const EPGSettingsForm = React.memo(({ active }) => {
- const settings = useSettingsStore((s) => s.settings);
-
- const [saved, setSaved] = useState(false);
-
- const form = useForm({
- mode: 'controlled',
- initialValues: getEPGSettingsFormInitialValues(),
- });
-
- useEffect(() => {
- if (!active) setSaved(false);
- }, [active]);
-
- useEffect(() => {
- if (settings) {
- const formValues = parseSettings(settings);
-
- form.setValues(formValues);
- }
- }, [settings]);
-
- const onSubmit = async () => {
- setSaved(false);
-
- const changedSettings = getChangedSettings(form.getValues(), settings);
-
- // Update each changed setting in the backend (create if missing)
- try {
- await saveChangedSettings(settings, changedSettings);
-
- setSaved(true);
- } catch (error) {
- // Error notifications are already shown by API functions
- // Just don't show the success message
- console.error('Error saving settings:', error);
- }
- };
-
- return (
-
- {saved && (
-
- )}
-
- Configure how channel names are normalized during EPG matching.
- These settings help channels with different names match the same EPG data.
- Channel display names are never modified.
-
-
-
-
-
-
-
-
-
-
- Save
-
-
-
- );
-});
-
-export default EPGSettingsForm;
diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx
new file mode 100644
index 00000000..fc773d94
--- /dev/null
+++ b/frontend/src/components/modals/EPGMatchModal.jsx
@@ -0,0 +1,178 @@
+import { useMemo, useState, useEffect } from 'react';
+import {
+ Modal,
+ Stack,
+ Text,
+ TagsInput,
+ Group,
+ Button,
+ Loader,
+} from '@mantine/core';
+import { notifications } from '@mantine/notifications';
+import useSettingsStore from '../../store/settings';
+import API from '../../api';
+import {
+ getChangedSettings,
+ saveChangedSettings,
+} from '../../utils/pages/SettingsUtils';
+
+// Extract EPG settings directly without parsing all settings
+const getEpgSettingsFromStore = (settings) => {
+ const epgSettings = settings?.['epg_settings']?.value;
+ return {
+ epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes)
+ ? epgSettings.epg_match_ignore_prefixes
+ : [],
+ epg_match_ignore_suffixes: Array.isArray(epgSettings?.epg_match_ignore_suffixes)
+ ? epgSettings.epg_match_ignore_suffixes
+ : [],
+ epg_match_ignore_custom: Array.isArray(epgSettings?.epg_match_ignore_custom)
+ ? epgSettings.epg_match_ignore_custom
+ : [],
+ };
+};
+
+const EPGMatchModal = ({
+ opened,
+ onClose,
+ selectedChannelIds = [],
+}) => {
+ const settings = useSettingsStore((s) => s.settings);
+
+ const [loading, setLoading] = useState(false);
+
+ // Compute form values directly from settings - memoized for performance
+ const storedValues = useMemo(
+ () => getEpgSettingsFromStore(settings),
+ [settings]
+ );
+
+ // Local form state
+ const [formValues, setFormValues] = useState(storedValues);
+
+ // Reset to stored values when modal opens
+ useEffect(() => {
+ if (opened) {
+ setFormValues(storedValues);
+ }
+ }, [opened, storedValues]);
+
+ const handleConfirm = async () => {
+ setLoading(true);
+ try {
+ // Save settings first
+ const changedSettings = getChangedSettings(formValues, settings);
+ if (Object.keys(changedSettings).length > 0) {
+ await saveChangedSettings(settings, changedSettings);
+ }
+
+ // Then trigger auto-match
+ if (selectedChannelIds.length > 0) {
+ await API.matchEpg(selectedChannelIds);
+ notifications.show({
+ title: `EPG matching started for ${selectedChannelIds.length} selected channel(s)`,
+ color: 'green',
+ });
+ } else {
+ await API.matchEpg();
+ notifications.show({
+ title: 'EPG matching started for all channels without EPG',
+ color: 'green',
+ });
+ }
+
+ onClose();
+ } catch (error) {
+ console.error('Error during auto-match:', error);
+ notifications.show({
+ title: 'Error',
+ message: error.message || 'Failed to start EPG matching',
+ color: 'red',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const scopeText = selectedChannelIds.length > 0
+ ? `${selectedChannelIds.length} selected channel(s)`
+ : 'all channels without EPG';
+
+ return (
+
+
+
+ Configure how channel names are normalized during matching, then start
+ the auto-match process for {scopeText}.
+
+
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_prefixes: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
+
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_suffixes: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
+
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_custom: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
+
+
+ Channel display names are never modified. These settings only affect
+ the matching algorithm.
+
+
+
+
+ Cancel
+
+
+ {loading ? : 'Start Auto-Match'}
+
+
+
+
+ );
+};
+
+export default EPGMatchModal;
diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
index 3410879a..4e549699 100644
--- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
+++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
@@ -44,6 +44,7 @@ import GroupManager from '../../forms/GroupManager';
import ConfirmationDialog from '../../ConfirmationDialog';
import useWarningsStore from '../../../store/warnings';
import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal';
+import EPGMatchModal from '../../modals/EPGMatchModal';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
@@ -121,6 +122,7 @@ const ChannelTableHeader = ({
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
+ const [epgMatchModalOpen, setEpgMatchModalOpen] = useState(false);
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] =
useState(false);
const [profileToDelete, setProfileToDelete] = useState(null);
@@ -178,26 +180,7 @@ const ChannelTableHeader = ({
}
};
- const matchEpg = async () => {
- try {
- // Hit our new endpoint that triggers the fuzzy matching Celery task
- // If channels are selected, only match those; otherwise match all
- if (selectedTableIds.length > 0) {
- await API.matchEpg(selectedTableIds);
- notifications.show({
- title: `EPG matching task started for ${selectedTableIds.length} selected channel(s)!`,
- });
- } else {
- await API.matchEpg();
- notifications.show({
- title: 'EPG matching task started for all channels without EPG!',
- });
- }
- } catch (err) {
- notifications.show(`Error: ${err.message}`);
- }
- };
-
+
const assignChannels = async () => {
try {
// Call our custom API endpoint
@@ -421,7 +404,7 @@ const ChannelTableHeader = ({
}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
- onClick={matchEpg}
+ onClick={() => setEpgMatchModalOpen(true)}
>
{selectedTableIds.length > 0
@@ -465,6 +448,12 @@ const ChannelTableHeader = ({
onClose={() => setGroupManagerOpen(false)}
/>
+ setEpgMatchModalOpen(false)}
+ selectedChannelIds={selectedTableIds}
+ />
+
setConfirmDeleteProfileOpen(false)}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index bbe4631f..4ce519a3 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -25,8 +25,6 @@ const ProxySettingsForm = React.lazy(() =>
import('../components/forms/settings/ProxySettingsForm.jsx'));
const StreamSettingsForm = React.lazy(() =>
import('../components/forms/settings/StreamSettingsForm.jsx'));
-const EPGSettingsForm = React.lazy(() =>
- import('../components/forms/settings/EPGSettingsForm.jsx'));
const DvrSettingsForm = React.lazy(() =>
import('../components/forms/settings/DvrSettingsForm.jsx'));
const SystemSettingsForm = React.lazy(() =>
@@ -80,18 +78,6 @@ const SettingsPage = () => {
-
- EPG Settings
-
-
- }>
-
-
-
-
-
-
System Settings
diff --git a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js
deleted file mode 100644
index 7babbbf9..00000000
--- a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export const getEPGSettingsFormInitialValues = () => {
- return {
- epg_match_ignore_prefixes: [],
- epg_match_ignore_suffixes: [],
- epg_match_ignore_custom: [],
- };
-};
-
-export const getEPGSettingsFormValidation = () => {
- return {};
-};
From a3ed1d96283b2bb95edb8928967ddb7c2ea5df43 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 30 Jan 2026 12:12:42 -0600
Subject: [PATCH 090/125] Refactor for uv transition.
---
.dockerignore | 1 -
docker/DispatcharrBase | 14 ++++++++------
docker/entrypoint.sh | 6 +++---
docker/init/99-init-dev.sh | 4 ++--
pyproject.toml | 7 +++++--
5 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/.dockerignore b/.dockerignore
index 296537de..002f1c63 100755
--- a/.dockerignore
+++ b/.dockerignore
@@ -29,6 +29,5 @@
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
-README.md
data/
docker/data/
diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase
index d89f334f..d2e8ceaa 100644
--- a/docker/DispatcharrBase
+++ b/docker/DispatcharrBase
@@ -1,6 +1,7 @@
FROM lscr.io/linuxserver/ffmpeg:latest
ENV DEBIAN_FRONTEND=noninteractive
+ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy
ENV VIRTUAL_ENV=/dispatcharrpy
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV UV_COMPILE_BYTECODE=1
@@ -26,21 +27,22 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# --- Create Python virtual environment and install dependencies ---
WORKDIR /tmp/build
COPY pyproject.toml /tmp/build/
-RUN uv venv $VIRTUAL_ENV --python 3.13 --seed && \
- uv sync --python $VIRTUAL_ENV/bin/python --no-cache --no-install-project --no-dev && \
+COPY version.py /tmp/build/
+COPY README.md /tmp/build/
+RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \
rm -rf /tmp/build
WORKDIR /
# --- Build legacy NumPy wheel for old hardware (store for runtime switching) ---
-RUN uv pip install --python $VIRTUAL_ENV/bin/python --no-cache build && \
+RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \
cd /tmp && \
- $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \
+ $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \
tar -xzf numpy-*.tar.gz && \
cd numpy-*/ && \
- $VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
+ $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
mv dist/*.whl /opt/ && \
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \
- uv pip uninstall --python $VIRTUAL_ENV/bin/python build
+ uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip
# --- Clean up build dependencies to reduce image size ---
RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index a50f2f49..ff8a193f 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -30,9 +30,9 @@ echo_with_timestamp() {
# --- NumPy version switching for legacy hardware ---
if [ "$USE_LEGACY_NUMPY" = "true" ]; then
# Check if NumPy was compiled with baseline support
- if /dispatcharrpy/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then
+ if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then
echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
- /dispatcharrpy/bin/pip install --no-cache-dir --force-reinstall --no-deps /opt/numpy-*.whl
+ uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl
echo_with_timestamp "✅ Legacy NumPy installed"
else
echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation"
@@ -214,7 +214,7 @@ fi
# Users can override via UWSGI_NICE_LEVEL environment variable in docker-compose
# Start with nice as root, then use setpriv to drop privileges to dispatch user
# This preserves both the nice value and environment variables
-nice -n $UWSGI_NICE_LEVEL su - "$POSTGRES_USER" -c "cd /app && exec /dispatcharrpy/bin/uwsgi $uwsgi_args" & uwsgi_pid=$!
+nice -n $UWSGI_NICE_LEVEL su - "$POSTGRES_USER" -c "cd /app && exec $VIRTUAL_ENV/bin/uwsgi $uwsgi_args" & uwsgi_pid=$!
echo "✅ uwsgi started with PID $uwsgi_pid (nice $UWSGI_NICE_LEVEL)"
pids+=("$uwsgi_pid")
diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh
index c89f1e44..5afbc96d 100644
--- a/docker/init/99-init-dev.sh
+++ b/docker/init/99-init-dev.sh
@@ -16,10 +16,10 @@ fi
# Install frontend dependencies
cd /app/frontend && npm install
# Install Python dependencies using UV
-cd /app && uv sync --python $VIRTUAL_ENV/bin/python --no-install-project --no-dev
+cd /app && uv sync --python $UV_PROJECT_ENVIRONMENT/bin/python --no-install-project --no-dev
# Install debugpy for remote debugging
if [ "$DISPATCHARR_DEBUG" = "true" ]; then
echo "=== setting up debugpy ==="
- uv pip install --python $VIRTUAL_ENV/bin/python debugpy
+ uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python debugpy
fi
diff --git a/pyproject.toml b/pyproject.toml
index bdd056cc..9400a55d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,10 +1,10 @@
[project]
name = "dispatcharr"
-version = "0.17.0"
description = "Dispatcharr - Stream dispatching and management"
readme = "README.md"
-license = "MIT"
+license = "CC-BY-NC-SA-4.0"
requires-python = ">=3.13"
+dynamic = ["version"]
dependencies = [
"Django==5.2.9",
"psycopg2-binary==2.9.11",
@@ -51,3 +51,6 @@ torch = { index = "pytorch-cpu" }
[tool.hatch.build.targets.wheel]
packages = ["dispatcharr", "apps", "core"]
+
+[tool.hatch.version]
+path = "version.py"
From 256d19a2cc76b02c59d2dedc6e8516592ab65cf1 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 30 Jan 2026 12:45:11 -0600
Subject: [PATCH 091/125] Move legacy numpy install to after init-dev. UV
replaces.
---
docker/entrypoint.sh | 25 +++++++++++++------------
1 file changed, 13 insertions(+), 12 deletions(-)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index ff8a193f..0f8f5f88 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -27,18 +27,6 @@ echo_with_timestamp() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}
-# --- NumPy version switching for legacy hardware ---
-if [ "$USE_LEGACY_NUMPY" = "true" ]; then
- # Check if NumPy was compiled with baseline support
- if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then
- echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
- uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl
- echo_with_timestamp "✅ Legacy NumPy installed"
- else
- echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation"
- fi
-fi
-
# Set PostgreSQL environment variables
export POSTGRES_DB=${POSTGRES_DB:-dispatcharr}
export POSTGRES_USER=${POSTGRES_USER:-dispatch}
@@ -186,6 +174,19 @@ else
pids+=("$nginx_pid")
fi
+
+# --- NumPy version switching for legacy hardware ---
+if [ "$USE_LEGACY_NUMPY" = "true" ]; then
+ # Check if NumPy was compiled with baseline support
+ if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then
+ echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
+ uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl
+ echo_with_timestamp "✅ Legacy NumPy installed"
+ else
+ echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation"
+ fi
+fi
+
# Run Django commands as non-root user to prevent permission issues
su - $POSTGRES_USER -c "cd /app && python manage.py migrate --noinput"
su - $POSTGRES_USER -c "cd /app && python manage.py collectstatic --noinput"
From fca50e15704c07493006e1086bd496ebc5677cb8 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 30 Jan 2026 15:40:33 -0600
Subject: [PATCH 092/125] changelog: Update changelog for UV PR.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e28ecb11..efb7d6ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Modern OpenAPI 3.0 specification compliance
- Better auto-generation of request/response schemas
- Improved documentation accuracy with serializer introspection
+- Switched to uv for package management: Migrated from pip to uv (Astral's fast Python package installer) for improved dependency resolution speed and reliability. This includes updates to Docker build processes, installation scripts (debian_install.sh), and project configuration (pyproject.toml) to leverage uv's features like virtual environment management and lockfile generation. - Thanks [@tobimichael96](https://github.com/tobimichael96) for getting it started!
### Fixed
From bdcc865b9db8e7f06f2489bf5aeaaee535d7a711 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 30 Jan 2026 16:35:56 -0600
Subject: [PATCH 093/125] Bug Fix: Fixed NumPy baseline detection in Docker
entrypoint. Now properly detects when NumPy crashes on import due to CPU
baseline incompatibility and installs legacy NumPy version. Previously, if
NumPy failed to import, the script would skip legacy installation assuming it
was already compatible.
---
CHANGELOG.md | 1 +
docker/entrypoint.sh | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index efb7d6ed..d05e10c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
+- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible.
## [0.18.1] - 2026-01-27
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 0f8f5f88..1594385f 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -178,7 +178,7 @@ fi
# --- NumPy version switching for legacy hardware ---
if [ "$USE_LEGACY_NUMPY" = "true" ]; then
# Check if NumPy was compiled with baseline support
- if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then
+ if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then
echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl
echo_with_timestamp "✅ Legacy NumPy installed"
From f8ec970561081c418aa1448cea586908ecf94be4 Mon Sep 17 00:00:00 2001
From: None
Date: Tue, 27 Jan 2026 22:16:33 -0600
Subject: [PATCH 094/125] Add TVG-ID column with filter and sort to Streams
table
Changes:
- frontend/src/components/tables/StreamsTable.jsx
Added TVG-ID column to streams table
Added search filter input in column header
Added sort button for ascending/descending order
Column follows same pattern as Name, Group, and M3U columns
- apps/channels/api_views.py
Added TVG-ID to filterable fields
Added TVG-ID to sortable fields
Enables backend support for search and sorting
Users can now view, search, and sort streams by their TVG-ID directly in the Streams table. Fulfills #866
---
apps/channels/api_views.py | 4 +-
.../src/components/tables/StreamsTable.jsx | 48 +++++++++++++++++++
2 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 27e07dac..fd79807e 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -106,6 +106,7 @@ class StreamFilter(django_filters.FilterSet):
m3u_account_is_active = django_filters.BooleanFilter(
field_name="m3u_account__is_active"
)
+ tvg_id = django_filters.CharFilter(lookup_expr="icontains")
class Meta:
model = Stream
@@ -115,6 +116,7 @@ class StreamFilter(django_filters.FilterSet):
"m3u_account",
"m3u_account_name",
"m3u_account_is_active",
+ "tvg_id",
]
@@ -129,7 +131,7 @@ class StreamViewSet(viewsets.ModelViewSet):
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = StreamFilter
search_fields = ["name", "channel_group__name"]
- ordering_fields = ["name", "channel_group__name", "m3u_account__name"]
+ ordering_fields = ["name", "channel_group__name", "m3u_account__name", "tvg_id"]
ordering = ["-name"]
def get_permissions(self):
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 64c087ef..3ac06ac1 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -370,6 +370,25 @@ const StreamsTable = ({ onReady }) => {
),
},
+ {
+ header: 'TVG-ID',
+ id: 'tvg_id',
+ accessorKey: 'tvg_id',
+ size: columnSizing.tvg_id || 120,
+ cell: ({ getValue }) => (
+
+
+ {getValue()}
+
+
+ ),
+ },
],
[channelGroups, playlists, columnSizing]
);
@@ -427,6 +446,7 @@ const StreamsTable = ({ onReady }) => {
name: 'name',
group: 'channel_group__name',
m3u: 'm3u_account__name',
+ tvg_id: 'tvg_id',
};
const sortField = fieldMapping[columnId] || columnId;
const sortDirection = sorting[0].desc ? '-' : '';
@@ -967,6 +987,33 @@ const StreamsTable = ({ onReady }) => {
);
}
+
+ case 'tvg_id':
+ return (
+
+ e.stopPropagation()}
+ onChange={handleFilterChange}
+ size="xs"
+ variant="unstyled"
+ className="table-input-header"
+ leftSection={}
+ style={{ flex: 1, minWidth: 0 }}
+ rightSectionPointerEvents="auto"
+ rightSection={React.createElement(sortingIcon, {
+ onClick: (e) => {
+ e.stopPropagation();
+ onSortingChange('tvg_id');
+ },
+ size: 14,
+ style: { cursor: 'pointer' },
+ })}
+ />
+
+ );
}
};
@@ -1019,6 +1066,7 @@ const StreamsTable = ({ onReady }) => {
name: renderHeaderCell,
group: renderHeaderCell,
m3u: renderHeaderCell,
+ tvg_id: renderHeaderCell,
},
bodyCellRenderFns: {
actions: renderBodyCell,
From 4a4df1495067d29fe5e6bd91504dfd0bbe94a04a Mon Sep 17 00:00:00 2001
From: None
Date: Fri, 30 Jan 2026 23:30:36 -0600
Subject: [PATCH 095/125] Add column visibility toggle and Source Info column
to StreamsTable
- Add three-dot menu for toggling column visibility (Name, Group, M3U, TVG-ID, Source Info)
- Add Source Info column showing stream resolution and codec with detailed tooltip
- Column visibility persists to localStorage with proper migration handling for existing users
- Default hidden columns: TVG-ID, Source Info
- Auto-refresh table data when video preview closes to show updated stream stats
- Fix state spreading bug in CustomTable/index.jsx (was spreading options.state instead of state)
I think the people are gonna like this one!
---
.../components/tables/CustomTable/index.jsx | 4 +-
.../src/components/tables/StreamsTable.jsx | 210 ++++++++++++++++++
2 files changed, 213 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx
index d523d757..883e01d7 100644
--- a/frontend/src/components/tables/CustomTable/index.jsx
+++ b/frontend/src/components/tables/CustomTable/index.jsx
@@ -21,6 +21,7 @@ const useTable = ({
state = [],
columnSizing,
setColumnSizing,
+ onColumnVisibilityChange,
...options
}) => {
const [selectedTableIds, setSelectedTableIds] = useState([]);
@@ -98,12 +99,13 @@ const useTable = ({
},
...options,
state: {
- ...options.state,
+ ...state,
selectedTableIds,
...(columnSizing && { columnSizing }),
},
onStateChange: options.onStateChange,
...(setColumnSizing && { onColumnSizingChange: setColumnSizing }),
+ ...(onColumnVisibilityChange && { onColumnVisibilityChange }),
getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(),
enableColumnResizing: true,
columnResizeMode: 'onChange',
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 3ac06ac1..e8d99ad7 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -23,6 +23,9 @@ import {
Filter,
Square,
SquareCheck,
+ Eye,
+ EyeOff,
+ RotateCcw,
} from 'lucide-react';
import {
TextInput,
@@ -242,6 +245,64 @@ const StreamsTable = ({ onReady }) => {
'streams-table-column-sizing',
{}
);
+
+ // Column visibility - persisted to localStorage
+ // Default visible: name, group, m3u
+ // Default hidden: tvg_id, source_info
+ const DEFAULT_COLUMN_VISIBILITY = {
+ actions: true,
+ select: true,
+ name: true,
+ group: true,
+ m3u: true,
+ tvg_id: false,
+ source_info: false,
+ };
+
+ const [storedColumnVisibility, setStoredColumnVisibility] = useLocalStorage(
+ 'streams-table-column-visibility',
+ null // Use null as default to detect fresh install
+ );
+
+ // Merge defaults with stored values, ensuring all columns have values
+ // - Fresh install (null): use defaults
+ // - Existing users: merge settings with defaults for any new columns
+ const columnVisibility = useMemo(() => {
+ if (!storedColumnVisibility || typeof storedColumnVisibility !== 'object') {
+ return DEFAULT_COLUMN_VISIBILITY;
+ }
+ // Merge: start with defaults, overlay stored values only for keys that exist in defaults
+ const merged = { ...DEFAULT_COLUMN_VISIBILITY };
+ for (const key of Object.keys(DEFAULT_COLUMN_VISIBILITY)) {
+ if (key in storedColumnVisibility && typeof storedColumnVisibility[key] === 'boolean') {
+ merged[key] = storedColumnVisibility[key];
+ }
+ }
+ return merged;
+ }, [storedColumnVisibility]);
+
+ const setColumnVisibility = (newValue) => {
+ if (typeof newValue === 'function') {
+ setStoredColumnVisibility((prev) => {
+ const prevMerged = prev && typeof prev === 'object' ? { ...DEFAULT_COLUMN_VISIBILITY, ...prev } : DEFAULT_COLUMN_VISIBILITY;
+ return newValue(prevMerged);
+ });
+ } else {
+ setStoredColumnVisibility(newValue);
+ }
+ };
+
+ const toggleColumnVisibility = (columnId) => {
+ setColumnVisibility((prev) => ({
+ ...prev,
+ [columnId]: !prev[columnId],
+ }));
+ };
+
+ const resetColumnVisibility = () => {
+ setStoredColumnVisibility(DEFAULT_COLUMN_VISIBILITY);
+ };
+
const debouncedFilters = useDebounce(filters, 500, () => {
// Reset to first page whenever filters change to avoid "Invalid page" errors
setPagination({
@@ -272,6 +333,7 @@ const StreamsTable = ({ onReady }) => {
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
+ const videoIsVisible = useVideoStore((s) => s.isVisible);
const data = useStreamsTableStore((s) => s.streams);
const pageCount = useStreamsTableStore((s) => s.pageCount);
@@ -389,6 +451,61 @@ const StreamsTable = ({ onReady }) => {
),
},
+ {
+ header: 'Source Info',
+ id: 'source_info',
+ accessorKey: 'stream_stats',
+ size: columnSizing.source_info || 120,
+ cell: ({ getValue }) => {
+ const stats = getValue();
+ if (!stats) return -;
+
+ // Build compact display (resolution + video codec)
+ const parts = [];
+ if (stats.resolution) {
+ // Convert "1920x1080" to "1080p" format
+ const height = stats.resolution.split('x')[1];
+ if (height) parts.push(`${height}p`);
+ }
+ if (stats.video_codec) {
+ parts.push(stats.video_codec.toUpperCase());
+ }
+ const compactDisplay = parts.length > 0 ? parts.join(' ') : '-';
+
+ // Build tooltip content with friendly labels
+ const tooltipLines = [];
+ if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`);
+ if (stats.video_codec) tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`);
+ if (stats.video_bitrate) tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`);
+ if (stats.source_fps) tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`);
+ if (stats.audio_codec) tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`);
+ if (stats.audio_channels) tooltipLines.push(`Audio Channels: ${stats.audio_channels}`);
+ if (stats.audio_bitrate) tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`);
+
+ const tooltipContent = tooltipLines.length > 0
+ ? tooltipLines.join('\n')
+ : 'No source info available';
+
+ return (
+ {tooltipContent}}
+ openDelay={500}
+ multiline
+ w={220}
+ >
+
+ {compactDisplay}
+
+
+ );
+ },
+ },
],
[channelGroups, playlists, columnSizing]
);
@@ -1053,6 +1170,7 @@ const StreamsTable = ({ onReady }) => {
sorting,
columnSizing,
setColumnSizing,
+ onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: onRowSelectionChange,
manualPagination: true,
manualSorting: true,
@@ -1061,6 +1179,7 @@ const StreamsTable = ({ onReady }) => {
state: {
pagination,
sorting,
+ columnVisibility,
},
headerCellRenderFns: {
name: renderHeaderCell,
@@ -1089,6 +1208,16 @@ const StreamsTable = ({ onReady }) => {
fetchData();
}, [fetchData]);
+ // Refetch data when video player closes to update stream stats
+ const prevVideoVisible = useRef(false);
+ useEffect(() => {
+ if (prevVideoVisible.current && !videoIsVisible) {
+ // Video was closed, refetch to get updated stream stats
+ fetchData({ showLoader: false });
+ }
+ prevVideoVisible.current = videoIsVisible;
+ }, [videoIsVisible, fetchData]);
+
useEffect(() => {
if (
Object.keys(channelGroups).length > 0 ||
@@ -1306,6 +1435,87 @@ const StreamsTable = ({ onReady }) => {
+
+
}
From 460a005c82ce0073fa500326866a0cea7c5f581a Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 12:40:15 -0600
Subject: [PATCH 096/125] Adjusted minimum sizes, renamed source_info to stats
for consistency. Moved menu to far right.
---
.../src/components/tables/StreamsTable.jsx | 165 ++++++++++++------
frontend/src/pages/Channels.jsx | 6 +-
2 files changed, 115 insertions(+), 56 deletions(-)
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index e8d99ad7..d7c1b059 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -248,7 +248,7 @@ const StreamsTable = ({ onReady }) => {
// Column visibility - persisted to localStorage
// Default visible: name, group, m3u
- // Default hidden: tvg_id, source_info
+ // Default hidden: tvg_id, stats
const DEFAULT_COLUMN_VISIBILITY = {
actions: true,
select: true,
@@ -256,7 +256,7 @@ const StreamsTable = ({ onReady }) => {
group: true,
m3u: true,
tvg_id: false,
- source_info: false,
+ stats: false,
};
const [storedColumnVisibility, setStoredColumnVisibility] = useLocalStorage(
@@ -274,7 +274,10 @@ const StreamsTable = ({ onReady }) => {
// Merge: start with defaults, overlay stored values only for keys that exist in defaults
const merged = { ...DEFAULT_COLUMN_VISIBILITY };
for (const key of Object.keys(DEFAULT_COLUMN_VISIBILITY)) {
- if (key in storedColumnVisibility && typeof storedColumnVisibility[key] === 'boolean') {
+ if (
+ key in storedColumnVisibility &&
+ typeof storedColumnVisibility[key] === 'boolean'
+ ) {
merged[key] = storedColumnVisibility[key];
}
}
@@ -284,7 +287,10 @@ const StreamsTable = ({ onReady }) => {
const setColumnVisibility = (newValue) => {
if (typeof newValue === 'function') {
setStoredColumnVisibility((prev) => {
- const prevMerged = prev && typeof prev === 'object' ? { ...DEFAULT_COLUMN_VISIBILITY, ...prev } : DEFAULT_COLUMN_VISIBILITY;
+ const prevMerged =
+ prev && typeof prev === 'object'
+ ? { ...DEFAULT_COLUMN_VISIBILITY, ...prev }
+ : DEFAULT_COLUMN_VISIBILITY;
return newValue(prevMerged);
});
} else {
@@ -366,16 +372,19 @@ const StreamsTable = ({ onReady }) => {
{
id: 'actions',
size: columnSizing.actions || 75,
+ minSize: 65,
},
{
id: 'select',
size: columnSizing.select || 30,
+ minSize: 30,
},
{
header: 'Name',
accessorKey: 'name',
grow: true,
size: columnSizing.name || 200,
+ minSize: 100,
cell: ({ getValue }) => (
{
? channelGroups[row.channel_group].name
: '',
size: columnSizing.group || 150,
+ minSize: 75,
cell: ({ getValue }) => (
{
header: 'M3U',
id: 'm3u',
size: columnSizing.m3u || 150,
+ minSize: 75,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
cell: ({ getValue }) => (
@@ -437,6 +448,7 @@ const StreamsTable = ({ onReady }) => {
id: 'tvg_id',
accessorKey: 'tvg_id',
size: columnSizing.tvg_id || 120,
+ minSize: 75,
cell: ({ getValue }) => (
{
),
},
{
- header: 'Source Info',
- id: 'source_info',
+ header: 'Stats',
+ id: 'stats',
accessorKey: 'stream_stats',
- size: columnSizing.source_info || 120,
+ size: columnSizing.stats || 120,
+ minSize: 75,
cell: ({ getValue }) => {
const stats = getValue();
- if (!stats) return -;
+ if (!stats)
+ return (
+
+ -
+
+ );
// Build compact display (resolution + video codec)
const parts = [];
@@ -474,21 +492,37 @@ const StreamsTable = ({ onReady }) => {
// Build tooltip content with friendly labels
const tooltipLines = [];
- if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`);
- if (stats.video_codec) tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`);
- if (stats.video_bitrate) tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`);
- if (stats.source_fps) tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`);
- if (stats.audio_codec) tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`);
- if (stats.audio_channels) tooltipLines.push(`Audio Channels: ${stats.audio_channels}`);
- if (stats.audio_bitrate) tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`);
+ if (stats.resolution)
+ tooltipLines.push(`Resolution: ${stats.resolution}`);
+ if (stats.video_codec)
+ tooltipLines.push(
+ `Video Codec: ${stats.video_codec.toUpperCase()}`
+ );
+ if (stats.video_bitrate)
+ tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`);
+ if (stats.source_fps)
+ tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`);
+ if (stats.audio_codec)
+ tooltipLines.push(
+ `Audio Codec: ${stats.audio_codec.toUpperCase()}`
+ );
+ if (stats.audio_channels)
+ tooltipLines.push(`Audio Channels: ${stats.audio_channels}`);
+ if (stats.audio_bitrate)
+ tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`);
- const tooltipContent = tooltipLines.length > 0
- ? tooltipLines.join('\n')
- : 'No source info available';
+ const tooltipContent =
+ tooltipLines.length > 0
+ ? tooltipLines.join('\n')
+ : 'No source info available';
return (
{tooltipContent}}
+ label={
+
+ {tooltipContent}
+
+ }
openDelay={500}
multiline
w={220}
@@ -1131,6 +1165,30 @@ const StreamsTable = ({ onReady }) => {
/>
);
+
+ case 'stats':
+ return (
+
+
+ Stats
+
+
+ );
}
};
@@ -1186,6 +1244,7 @@ const StreamsTable = ({ onReady }) => {
group: renderHeaderCell,
m3u: renderHeaderCell,
tvg_id: renderHeaderCell,
+ stats: renderHeaderCell,
},
bodyCellRenderFns: {
actions: renderBodyCell,
@@ -1435,9 +1494,39 @@ const StreamsTable = ({ onReady }) => {
+
+ }
+ variant="light"
+ size="xs"
+ onClick={() => editStream()}
+ p={5}
+ color={theme.tailwind.green[5]}
+ style={{
+ borderWidth: '1px',
+ borderColor: theme.tailwind.green[5],
+ color: 'white',
+ }}
+ >
+ Create Stream
+
+
+
+
+ }
+ variant="default"
+ size="xs"
+ onClick={deleteStreams}
+ disabled={selectedStreamIds.length == 0}
+ >
+ Delete
+
+
+
-
-
- }
- variant="light"
- size="xs"
- onClick={() => editStream()}
- p={5}
- color={theme.tailwind.green[5]}
- style={{
- borderWidth: '1px',
- borderColor: theme.tailwind.green[5],
- color: 'white',
- }}
- >
- Create Stream
-
-
-
-
- }
- variant="default"
- size="xs"
- onClick={deleteStreams}
- disabled={selectedStreamIds.length == 0}
- >
- Delete
-
-
diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx
index b7b87b17..ca8438bb 100644
--- a/frontend/src/pages/Channels.jsx
+++ b/frontend/src/pages/Channels.jsx
@@ -79,19 +79,19 @@ const PageContent = () => {
defaultSizes={allotmentSizes}
h={'100%'}
w={'100%'}
- miw={'600px'}
+ miw={'625px'}
className="custom-allotment"
minSize={100}
onChange={handleSplitChange}
onResize={handleResize}
>
-
+
-
+
From 5ed42432fc3c6e85a1d9342a54b2514ca76457a0 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 14:15:39 -0600
Subject: [PATCH 097/125] Bug Fix: Stream rehash/merge logic now guarantees
unique stream_hash and always preserves the stream with the best channel
ordering and relationships. This prevents duplicate key errors and ensures
the correct stream is retained when merging. (Fixes #892)
---
CHANGELOG.md | 1 +
core/tasks.py | 247 +++++++++++++++++++++++++++++++++-----------------
2 files changed, 166 insertions(+), 82 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d05e10c0..e998420a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892)
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible.
diff --git a/core/tasks.py b/core/tasks.py
index 207e7c5e..fd8a19b5 100644
--- a/core/tasks.py
+++ b/core/tasks.py
@@ -480,15 +480,18 @@ def rehash_streams(keys):
try:
batch_size = 1000
- queryset = Stream.objects.all()
# Track statistics
total_processed = 0
duplicates_merged = 0
+ # hash_keys maps new_hash -> stream_id for streams we've already processed
hash_keys = {}
+ # Track IDs of streams that have been deleted to avoid stale references
+ deleted_stream_ids = set()
- total_records = queryset.count()
- logger.info(f"Starting rehash of {total_records} streams with keys: {keys}")
+ # Get initial count for progress reporting
+ initial_total_records = Stream.objects.count()
+ logger.info(f"Starting rehash of {initial_total_records} streams with keys: {keys}")
# Send initial WebSocket update
send_websocket_update(
@@ -499,103 +502,108 @@ def rehash_streams(keys):
"type": "stream_rehash",
"action": "starting",
"progress": 0,
- "total_records": total_records,
- "message": f"Starting rehash of {total_records} streams"
+ "total_records": initial_total_records,
+ "message": f"Starting rehash of {initial_total_records} streams"
}
)
- for start in range(0, total_records, batch_size):
+ # Use ID-based pagination to handle deletions correctly
+ # This ensures we don't skip records when items are deleted
+ last_processed_id = 0
+ batch_number = 0
+
+ while True:
+ batch_number += 1
batch_processed = 0
batch_duplicates = 0
with transaction.atomic():
- batch = queryset[start:start + batch_size]
+ # Fetch batch by ID ordering, using select_for_update to lock records
+ # This prevents race conditions and ensures we process each record exactly once
+ batch = list(
+ Stream.objects.filter(id__gt=last_processed_id)
+ .select_for_update(skip_locked=True, of=('self',))
+ .select_related('channel_group', 'm3u_account')
+ .order_by('id')[:batch_size]
+ )
+
+ if not batch:
+ # No more records to process
+ break
for obj in batch:
+ # Update the last processed ID for next batch
+ last_processed_id = obj.id
+
# Generate new hash
group_name = obj.channel_group.name if obj.channel_group else None
new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys, m3u_id=obj.m3u_account_id, group=group_name)
- # Check if this hash already exists in our tracking dict or in database
+ # Check if this hash already exists in our tracking dict
if new_hash in hash_keys:
- # Found duplicate in current batch - merge the streams
existing_stream_id = hash_keys[new_hash]
- existing_stream = Stream.objects.get(id=existing_stream_id)
- # Move any channel relationships from duplicate to existing stream
- # Handle potential unique constraint violations
- for channel_stream in ChannelStream.objects.filter(stream_id=obj.id):
- # Check if this channel already has a relationship with the target stream
- existing_relationship = ChannelStream.objects.filter(
- channel_id=channel_stream.channel_id,
- stream_id=existing_stream_id
- ).first()
+ # Verify the target stream still exists and hasn't been deleted
+ if existing_stream_id in deleted_stream_ids:
+ # The target was deleted, so this stream becomes the new canonical one
+ obj.stream_hash = new_hash
+ obj.save(update_fields=['stream_hash'])
+ hash_keys[new_hash] = obj.id
+ batch_processed += 1
+ continue
- if existing_relationship:
- # Relationship already exists, just delete the duplicate
- channel_stream.delete()
- else:
- # Safe to update the relationship
- channel_stream.stream_id = existing_stream_id
- channel_stream.save()
+ try:
+ existing_stream = Stream.objects.get(id=existing_stream_id)
+ except Stream.DoesNotExist:
+ # Target stream was deleted externally, make this the canonical one
+ deleted_stream_ids.add(existing_stream_id)
+ obj.stream_hash = new_hash
+ obj.save(update_fields=['stream_hash'])
+ hash_keys[new_hash] = obj.id
+ batch_processed += 1
+ continue
- # Update the existing stream with the most recent data
- if obj.updated_at > existing_stream.updated_at:
- existing_stream.name = obj.name
- existing_stream.url = obj.url
- existing_stream.logo_url = obj.logo_url
- existing_stream.tvg_id = obj.tvg_id
- existing_stream.m3u_account = obj.m3u_account
- existing_stream.channel_group = obj.channel_group
- existing_stream.custom_properties = obj.custom_properties
- existing_stream.last_seen = obj.last_seen
- existing_stream.updated_at = obj.updated_at
- existing_stream.save()
+ # Determine which stream to keep based on channel ordering
+ stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj)
- # Delete the duplicate
- obj.delete()
+ # Move channel relationships from the stream being deleted to the one being kept
+ _merge_stream_relationships(stream_to_delete, stream_to_keep)
+
+ # Delete the duplicate FIRST to free up the unique hash constraint
+ deleted_stream_ids.add(stream_to_delete.id)
+ stream_to_delete.delete()
batch_duplicates += 1
+
+ # Now safely set the hash on the kept stream (after deletion freed it up)
+ if stream_to_keep.stream_hash != new_hash:
+ stream_to_keep.stream_hash = new_hash
+ stream_to_keep.save(update_fields=['stream_hash'])
+
+ # Update hash_keys to point to the kept stream
+ hash_keys[new_hash] = stream_to_keep.id
else:
- # Check if hash already exists in database (from previous batches or existing data)
+ # Check if hash already exists in database (from streams not yet processed)
existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first()
if existing_stream:
- # Found duplicate in database - merge the streams
- # Move any channel relationships from duplicate to existing stream
- # Handle potential unique constraint violations
- for channel_stream in ChannelStream.objects.filter(stream_id=obj.id):
- # Check if this channel already has a relationship with the target stream
- existing_relationship = ChannelStream.objects.filter(
- channel_id=channel_stream.channel_id,
- stream_id=existing_stream.id
- ).first()
+ # Found duplicate in database - determine which to keep based on channel ordering
+ stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj)
- if existing_relationship:
- # Relationship already exists, just delete the duplicate
- channel_stream.delete()
- else:
- # Safe to update the relationship
- channel_stream.stream_id = existing_stream.id
- channel_stream.save()
+ # Move channel relationships from the stream being deleted to the one being kept
+ _merge_stream_relationships(stream_to_delete, stream_to_keep)
- # Update the existing stream with the most recent data
- if obj.updated_at > existing_stream.updated_at:
- existing_stream.name = obj.name
- existing_stream.url = obj.url
- existing_stream.logo_url = obj.logo_url
- existing_stream.tvg_id = obj.tvg_id
- existing_stream.m3u_account = obj.m3u_account
- existing_stream.channel_group = obj.channel_group
- existing_stream.custom_properties = obj.custom_properties
- existing_stream.last_seen = obj.last_seen
- existing_stream.updated_at = obj.updated_at
- existing_stream.save()
-
- # Delete the duplicate
- obj.delete()
+ # Delete the duplicate FIRST to free up the unique hash constraint
+ deleted_stream_ids.add(stream_to_delete.id)
+ stream_to_delete.delete()
batch_duplicates += 1
- hash_keys[new_hash] = existing_stream.id
+
+ # Now safely set the hash on the kept stream (after deletion freed it up)
+ if stream_to_keep.stream_hash != new_hash:
+ stream_to_keep.stream_hash = new_hash
+ stream_to_keep.save(update_fields=['stream_hash'])
+
+ hash_keys[new_hash] = stream_to_keep.id
else:
- # Update hash for this stream
+ # No duplicate - update hash for this stream
obj.stream_hash = new_hash
obj.save(update_fields=['stream_hash'])
hash_keys[new_hash] = obj.id
@@ -605,10 +613,9 @@ def rehash_streams(keys):
total_processed += batch_processed
duplicates_merged += batch_duplicates
- # Calculate progress percentage
- progress_percent = int((total_processed / total_records) * 100)
- current_batch = start // batch_size + 1
- total_batches = (total_records // batch_size) + 1
+ # Calculate progress percentage based on initial count
+ # Cap at 99% until we're actually done to avoid showing 100% prematurely
+ progress_percent = min(99, int((total_processed / max(initial_total_records, 1)) * 100))
# Send progress update via WebSocket
send_websocket_update(
@@ -619,15 +626,14 @@ def rehash_streams(keys):
"type": "stream_rehash",
"action": "processing",
"progress": progress_percent,
- "batch": current_batch,
- "total_batches": total_batches,
+ "batch": batch_number,
"processed": total_processed,
"duplicates_merged": duplicates_merged,
- "message": f"Processed batch {current_batch}/{total_batches}: {batch_processed} streams, {batch_duplicates} duplicates merged"
+ "message": f"Processed batch {batch_number}: {batch_processed} streams, {batch_duplicates} duplicates merged"
}
)
- logger.info(f"Rehashed batch {current_batch}/{total_batches}: "
+ logger.info(f"Rehashed batch {batch_number}: "
f"{batch_processed} processed, {batch_duplicates} duplicates merged")
logger.info(f"Rehashing complete: {total_processed} streams processed, "
@@ -654,7 +660,7 @@ def rehash_streams(keys):
return f"Successfully rehashed {total_processed} streams"
except Exception as e:
- logger.error(f"Error during stream rehash: {e}")
+ logger.error(f"Error during stream rehash: {e}", exc_info=True)
raise
finally:
# Always release all acquired M3U locks
@@ -663,6 +669,83 @@ def rehash_streams(keys):
logger.info(f"Released M3U task locks for {len(acquired_locks)} accounts")
+def _merge_stream_relationships(source_stream, target_stream):
+ """
+ Move channel relationships from source_stream to target_stream.
+ Handles unique constraint violations by preserving existing relationships.
+ Preserves the best ordering when merging relationships.
+ """
+ for channel_stream in ChannelStream.objects.filter(stream_id=source_stream.id):
+ # Check if this channel already has a relationship with the target stream
+ existing_relationship = ChannelStream.objects.filter(
+ channel_id=channel_stream.channel_id,
+ stream_id=target_stream.id
+ ).first()
+
+ if existing_relationship:
+ # Relationship already exists - keep the one with better ordering (lower order value)
+ if channel_stream.order < existing_relationship.order:
+ existing_relationship.order = channel_stream.order
+ existing_relationship.save(update_fields=['order'])
+ # Delete the duplicate relationship
+ channel_stream.delete()
+ else:
+ # Safe to update the relationship
+ channel_stream.stream_id = target_stream.id
+ channel_stream.save()
+
+
+def _get_best_channel_order(stream):
+ """
+ Get the best (lowest) channel order for a stream.
+ Returns None if stream has no channel relationships.
+ Lower order value = better/higher position in the channel list.
+ """
+ best_order = ChannelStream.objects.filter(stream_id=stream.id).order_by('order').values_list('order', flat=True).first()
+ return best_order
+
+
+def _determine_stream_to_keep(stream_a, stream_b):
+ """
+ Determine which stream should be kept when merging duplicates.
+
+ Priority:
+ 1. Stream with better (lower) channel order wins
+ 2. If both have same order or neither has channel relationships,
+ keep the one with more recent updated_at
+ 3. If still tied, keep the one with the lower ID (more stable)
+
+ Returns: (stream_to_keep, stream_to_delete)
+ """
+ order_a = _get_best_channel_order(stream_a)
+ order_b = _get_best_channel_order(stream_b)
+
+ # If one has channel relationships and the other doesn't, keep the one with relationships
+ if order_a is not None and order_b is None:
+ return (stream_a, stream_b)
+ if order_b is not None and order_a is None:
+ return (stream_b, stream_a)
+
+ # If both have channel relationships, keep the one with better (lower) order
+ if order_a is not None and order_b is not None:
+ if order_a < order_b:
+ return (stream_a, stream_b)
+ elif order_b < order_a:
+ return (stream_b, stream_a)
+ # Same order, fall through to other criteria
+
+ # Neither has relationships, or same order - use updated_at
+ if stream_a.updated_at > stream_b.updated_at:
+ return (stream_a, stream_b)
+ elif stream_b.updated_at > stream_a.updated_at:
+ return (stream_b, stream_a)
+
+ # Same updated_at - keep lower ID for stability
+ if stream_a.id < stream_b.id:
+ return (stream_a, stream_b)
+ return (stream_b, stream_a)
+
+
@shared_task
def cleanup_vod_persistent_connections():
"""Clean up stale VOD persistent connections"""
From e4b312d136635321976e5ea9b4e0c3192eae723e Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 14:44:52 -0600
Subject: [PATCH 098/125] Enhancement: Requery channels and streams after
rehashing completes
---
CHANGELOG.md | 1 +
frontend/src/WebSocket.jsx | 11 +++++++++++
2 files changed, 12 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e998420a..b3dc21a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations.
- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including:
- `useLocalStorage` hook tests with localStorage mocking and error handling
- `useSmartLogos` hook tests for logo loading and management
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 72bbb39a..034418f6 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -700,6 +700,17 @@ export const WebsocketProvider = ({ children }) => {
withCloseButton: true, // Allow manual close
loading: false, // Remove loading indicator
});
+ // Requery streams and channels after rehash completes
+ try {
+ await API.requeryChannels();
+ await API.requeryStreams();
+ await useChannelsStore.getState().fetchChannels();
+ } catch (error) {
+ console.error(
+ 'Error refreshing channels/streams after rehash:',
+ error
+ );
+ }
} else if (parsedEvent.data.action === 'blocked') {
// Handle blocked rehash attempt
notifications.show({
From 9ffd595c96e290e21152d513c6b3af1662c915d5 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 17:47:46 -0600
Subject: [PATCH 099/125] Enhancement: Added `stream_id` (provider stream
identifier) and `stream_chno` (provider channel number) fields to Stream
model. For XC accounts, the stream hash now uses the stable `stream_id`
instead of the URL when hashing, ensuring XC streams maintain their identity
and channel associations even when account credentials or server URLs change.
Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes.
---
CHANGELOG.md | 1 +
apps/channels/api_views.py | 18 +-
.../migrations/0033_stream_id_stream_chno.py | 205 ++++++++++++++++++
apps/channels/models.py | 30 ++-
apps/channels/serializers.py | 4 +-
apps/channels/tasks.py | 10 +-
apps/m3u/tasks.py | 79 ++++++-
core/tasks.py | 11 +-
8 files changed, 325 insertions(+), 33 deletions(-)
create mode 100644 apps/channels/migrations/0033_stream_id_stream_chno.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3dc21a6..f48d9456 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes.
- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
- Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
- Modern OpenAPI 3.0 specification compliance
diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 27e07dac..0ab48861 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -907,18 +907,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
if name is None:
name = stream.name
- # Check if client provided a channel_number; if not, auto-assign one.
- stream_custom_props = stream.custom_properties or {}
+ # Check if client provided a channel_number; if not, use stream_chno or auto-assign
channel_number = request.data.get("channel_number")
if channel_number is None:
- # Channel number not provided by client, check stream properties or auto-assign
- if "tvg-chno" in stream_custom_props:
- channel_number = float(stream_custom_props["tvg-chno"])
- elif "channel-number" in stream_custom_props:
- channel_number = float(stream_custom_props["channel-number"])
- elif "num" in stream_custom_props:
- channel_number = float(stream_custom_props["num"])
+ # Channel number not provided by client, check stream's channel number or auto-assign
+ if stream.stream_chno is not None:
+ channel_number = stream.stream_chno
elif channel_number == 0:
# Special case: 0 means ignore provider numbers and auto-assign
channel_number = None
@@ -939,9 +934,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
if Channel.objects.filter(channel_number=channel_number).exists():
channel_number = Channel.get_next_available_channel_number(channel_number)
# Get the tvc_guide_stationid from custom properties if it exists
- tvc_guide_stationid = None
- if "tvc-guide-stationid" in stream_custom_props:
- tvc_guide_stationid = stream_custom_props["tvc-guide-stationid"]
+ stream_custom_props = stream.custom_properties or {}
+ tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid")
channel_data = {
"channel_number": channel_number,
diff --git a/apps/channels/migrations/0033_stream_id_stream_chno.py b/apps/channels/migrations/0033_stream_id_stream_chno.py
new file mode 100644
index 00000000..31690a93
--- /dev/null
+++ b/apps/channels/migrations/0033_stream_id_stream_chno.py
@@ -0,0 +1,205 @@
+# Generated by Django - Add stream_id and channel_number fields with data migration
+
+from django.db import migrations, models
+import hashlib
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def populate_fields_and_rehash(apps, schema_editor):
+ """
+ Populate stream_id and stream_chno from custom_properties for XC account streams,
+ populate stream_chno from tvg-chno for standard M3U accounts,
+ then rehash XC streams using stable hash keys.
+ """
+ Stream = apps.get_model('dispatcharr_channels', 'Stream')
+ M3UAccount = apps.get_model('m3u', 'M3UAccount')
+ CoreSettings = apps.get_model('core', 'CoreSettings')
+
+ # Get hash keys from settings
+ try:
+ stream_settings = CoreSettings.objects.get(key='stream_settings')
+ hash_key_str = stream_settings.value.get('m3u_hash_key', '') if stream_settings.value else ''
+ keys = [k.strip() for k in hash_key_str.split(',') if k.strip()] if hash_key_str else []
+ except CoreSettings.DoesNotExist:
+ keys = []
+
+ logger.info(f"Using hash keys: {keys}")
+
+ # Get XC account IDs
+ xc_account_ids = set(
+ M3UAccount.objects.filter(account_type='XC').values_list('id', flat=True)
+ )
+
+ logger.info(f"Found {len(xc_account_ids)} XC accounts")
+
+ # Track hash collisions for XC streams
+ hash_map = {} # new_hash -> stream_id
+ duplicates_to_delete = []
+
+ # Process all streams in batches
+ batch_size = 1000
+ processed = 0
+ updated = 0
+
+ total_count = Stream.objects.count()
+ logger.info(f"Processing {total_count} total streams")
+
+ streams_to_update = []
+
+ for stream in Stream.objects.select_related('channel_group', 'm3u_account').iterator(chunk_size=batch_size):
+ processed += 1
+ needs_update = False
+
+ custom_props = stream.custom_properties or {}
+ is_xc = stream.m3u_account_id in xc_account_ids if stream.m3u_account_id else False
+
+ # Extract stream_id (XC accounts only)
+ if is_xc and isinstance(custom_props, dict):
+ provider_stream_id = custom_props.get('stream_id')
+ if provider_stream_id:
+ try:
+ stream.stream_id = int(provider_stream_id)
+ needs_update = True
+ except (ValueError, TypeError):
+ pass
+
+ # Extract stream_chno
+ channel_num = None
+ if isinstance(custom_props, dict):
+ if is_xc:
+ # XC accounts use 'num'
+ channel_num = custom_props.get('num')
+ else:
+ # Standard M3U accounts use 'tvg-chno' or 'channel-number' (case insensitive check)
+ for key in ['tvg-chno', 'TVG-CHNO', 'tvg-Chno', 'Tvg-Chno', 'channel-number', 'Channel-Number', 'CHANNEL-NUMBER']:
+ if key in custom_props:
+ channel_num = custom_props.get(key)
+ break
+
+ if channel_num is not None:
+ try:
+ stream.stream_chno = float(channel_num)
+ needs_update = True
+ except (ValueError, TypeError):
+ pass
+
+ # Rehash XC streams only when 'url' is in hash keys (otherwise hash wouldn't change)
+ if is_xc and stream.stream_id and keys and 'url' in keys:
+ # For XC accounts, use stream_id instead of url when 'url' is in the hash keys
+ # This ensures credential/URL changes don't break stream identity
+ effective_url = stream.stream_id
+
+ # Get group name
+ group_name = stream.channel_group.name if stream.channel_group else None
+
+ # Build hash parts
+ stream_parts = {
+ "name": stream.name,
+ "url": effective_url,
+ "tvg_id": stream.tvg_id,
+ "m3u_id": stream.m3u_account_id,
+ "group": group_name
+ }
+ hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts}
+
+ # When using stream_id instead of URL, we MUST include m3u_id to prevent
+ # collisions across different XC accounts (stream_id is only unique per account)
+ if 'm3u_id' not in hash_parts:
+ hash_parts['m3u_id'] = stream.m3u_account_id
+
+ # Generate hash
+ serialized_obj = json.dumps(hash_parts, sort_keys=True)
+ new_hash = hashlib.sha256(serialized_obj.encode()).hexdigest()
+
+ # Check for collisions
+ if new_hash in hash_map:
+ # Duplicate - mark for deletion (keep the first one)
+ duplicates_to_delete.append(stream.id)
+ continue
+
+ hash_map[new_hash] = stream.id
+ stream.stream_hash = new_hash
+ needs_update = True
+
+ if needs_update:
+ streams_to_update.append(stream)
+ updated += 1
+
+ # Bulk update in batches
+ if len(streams_to_update) >= batch_size:
+ Stream.objects.bulk_update(
+ streams_to_update,
+ ['stream_id', 'stream_chno', 'stream_hash'],
+ batch_size=500
+ )
+ logger.info(f"Updated batch: {processed}/{total_count} streams processed")
+ streams_to_update = []
+
+ # Final batch
+ if streams_to_update:
+ Stream.objects.bulk_update(
+ streams_to_update,
+ ['stream_id', 'stream_chno', 'stream_hash'],
+ batch_size=500
+ )
+
+ # Delete duplicates if any
+ if duplicates_to_delete:
+ logger.warning(f"Deleting {len(duplicates_to_delete)} duplicate streams due to hash collisions")
+ Stream.objects.filter(id__in=duplicates_to_delete).delete()
+
+ logger.info(f"Migration complete: {updated} streams updated, {len(duplicates_to_delete)} duplicates removed")
+
+
+def reverse_migration(apps, schema_editor):
+ """
+ Reverse migration - clear fields but don't attempt to reverse hash changes.
+ """
+ Stream = apps.get_model('dispatcharr_channels', 'Stream')
+ Stream.objects.all().update(stream_id=None, stream_chno=None)
+ logger.info("Cleared stream_id and stream_chno fields. Note: stream hashes were not reverted.")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dispatcharr_channels', '0032_channel_is_adult_stream_is_adult'),
+ ('m3u', '0018_add_profile_custom_properties'),
+ ('core', '0020_change_coresettings_value_to_jsonfield'),
+ ]
+
+ operations = [
+ # Schema changes - add fields WITHOUT indexes first
+ migrations.AddField(
+ model_name='stream',
+ name='stream_id',
+ field=models.IntegerField(
+ blank=True,
+ help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes',
+ null=True,
+ ),
+ ),
+ migrations.AddField(
+ model_name='stream',
+ name='stream_chno',
+ field=models.FloatField(
+ blank=True,
+ help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1',
+ null=True,
+ ),
+ ),
+ # Data migration (may delete duplicates, which would conflict with pending index creation)
+ migrations.RunPython(populate_fields_and_rehash, reverse_migration),
+ # Add indexes AFTER data migration completes
+ migrations.AddIndex(
+ model_name='stream',
+ index=models.Index(fields=['stream_id'], name='dispatcharr_stream_id_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='stream',
+ index=models.Index(fields=['stream_chno'], name='dispatcharr_stream_chno_idx'),
+ ),
+ ]
diff --git a/apps/channels/models.py b/apps/channels/models.py
index 703498a8..ec5e135b 100644
--- a/apps/channels/models.py
+++ b/apps/channels/models.py
@@ -106,6 +106,19 @@ class Stream(models.Model):
)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
+ stream_id = models.IntegerField(
+ null=True,
+ blank=True,
+ db_index=True,
+ help_text="Provider stream ID (e.g., XC stream_id) for stable identity across credential changes"
+ )
+ stream_chno = models.FloatField(
+ null=True,
+ blank=True,
+ db_index=True,
+ help_text="Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1"
+ )
+
# Stream statistics fields
stream_stats = models.JSONField(
null=True,
@@ -129,14 +142,27 @@ class Stream(models.Model):
return self.name or self.url or f"Stream ID {self.id}"
@classmethod
- def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None):
+ def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None,
+ account_type=None, stream_id=None):
if keys is None:
keys = CoreSettings.get_m3u_hash_key().split(",")
- stream_parts = {"name": name, "url": url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group}
+ # For XC accounts, use stream_id instead of url when 'url' is in the hash keys
+ # This ensures credential/URL changes don't break stream identity
+ effective_url = url
+ use_stream_id = account_type == 'XC' and stream_id and 'url' in keys
+ if use_stream_id:
+ effective_url = stream_id
+
+ stream_parts = {"name": name, "url": effective_url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group}
hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts}
+ # When using stream_id instead of URL, we MUST include m3u_id to prevent
+ # collisions across different XC accounts (stream_id is only unique per account)
+ if use_stream_id and 'm3u_id' not in hash_parts:
+ hash_parts['m3u_id'] = m3u_id
+
# Serialize and hash the dictionary
serialized_obj = json.dumps(
hash_parts, sort_keys=True
diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py
index 53c07292..abf26e91 100644
--- a/apps/channels/serializers.py
+++ b/apps/channels/serializers.py
@@ -104,7 +104,7 @@ class StreamSerializer(serializers.ModelSerializer):
allow_null=True,
required=False,
)
- read_only_fields = ["is_custom", "m3u_account", "stream_hash"]
+ read_only_fields = ["is_custom", "m3u_account", "stream_hash", "stream_id", "stream_chno"]
class Meta:
model = Stream
@@ -127,6 +127,8 @@ class StreamSerializer(serializers.ModelSerializer):
"stream_hash",
"stream_stats",
"stream_stats_updated_at",
+ "stream_id",
+ "stream_chno",
]
def get_fields(self):
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index b9983b87..fe0c2884 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -2629,13 +2629,9 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None
channel_number = None
if starting_channel_number is None:
- # Mode 1: Use provider numbers when available
- if "tvg-chno" in stream_custom_props:
- channel_number = float(stream_custom_props["tvg-chno"])
- elif "channel-number" in stream_custom_props:
- channel_number = float(stream_custom_props["channel-number"])
- elif "num" in stream_custom_props:
- channel_number = float(stream_custom_props["num"])
+ # Mode 1: Use provider numbers when available (from stream_chno field)
+ if stream.stream_chno is not None:
+ channel_number = stream.stream_chno
# For modes 2 and 3 (starting_channel_number == 0 or specific number),
# ignore provider numbers and use sequential assignment
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index 82e4f213..d0d6978e 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -741,6 +741,7 @@ def collect_xc_streams(account_id, enabled_groups):
"group-title": group_info["name"],
# Preserve all XC stream properties as custom attributes
"stream_id": str(stream.get("stream_id", "")),
+ "num": stream.get("num"),
"category_id": category_id,
"stream_type": stream.get("stream_type", ""),
"added": stream.get("added", ""),
@@ -749,7 +750,7 @@ def collect_xc_streams(account_id, enabled_groups):
# Include any other properties that might be present
**{k: str(v) for k, v in stream.items() if k not in [
"name", "stream_id", "epg_channel_id", "stream_icon",
- "category_id", "stream_type", "added", "is_adult", "custom_sid"
+ "category_id", "stream_type", "added", "is_adult", "custom_sid", "num"
] and v is not None}
}
}
@@ -817,13 +818,28 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
for stream in streams:
name = stream["name"]
+ raw_stream_id = stream.get("stream_id", "")
+ provider_stream_id = None
+ if raw_stream_id:
+ try:
+ provider_stream_id = int(raw_stream_id)
+ except (ValueError, TypeError):
+ pass
url = xc_client.get_stream_url(stream["stream_id"])
tvg_id = stream.get("epg_channel_id", "")
tvg_logo = stream.get("stream_icon", "")
group_title = group_name
+ stream_chno = stream.get("num")
+ # Convert stream_chno to float if valid, otherwise None
+ if stream_chno is not None:
+ try:
+ stream_chno = float(stream_chno)
+ except (ValueError, TypeError):
+ stream_chno = None
stream_hash = Stream.generate_hash_key(
- name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title
+ name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title,
+ account_type='XC', stream_id=provider_stream_id
)
stream_props = {
"name": name,
@@ -836,6 +852,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
"custom_properties": stream,
"is_adult": int(stream.get("is_adult", 0)) == 1,
"is_stale": False,
+ "stream_id": provider_stream_id,
+ "stream_chno": stream_chno,
}
if stream_hash not in stream_hashes:
@@ -850,7 +868,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
existing_streams = {
s.stream_hash: s
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
- 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account'
+ 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
)
}
@@ -864,7 +882,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
obj.logo_url != stream_props["logo_url"] or
obj.tvg_id != stream_props["tvg_id"] or
obj.custom_properties != stream_props["custom_properties"] or
- obj.is_adult != stream_props["is_adult"]
+ obj.is_adult != stream_props["is_adult"] or
+ obj.stream_id != stream_props["stream_id"] or
+ obj.stream_chno != stream_props["stream_chno"]
)
if changed:
@@ -900,7 +920,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
# Simplified bulk update for better performance
Stream.objects.bulk_update(
streams_to_update,
- ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
+ ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
batch_size=150 # Smaller batch size for XC processing
)
@@ -1003,7 +1023,42 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
)
continue
- stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title)
+ # Determine provider-specific fields first
+ provider_stream_id = None
+ channel_num = None
+ account_type_for_hash = None
+
+ if account.account_type == M3UAccount.Types.XC:
+ account_type_for_hash = 'XC'
+ raw_stream_id = stream_info["attributes"].get("stream_id", "")
+ if raw_stream_id:
+ try:
+ provider_stream_id = int(raw_stream_id)
+ except (ValueError, TypeError):
+ pass
+ raw_num = stream_info["attributes"].get("num")
+ if raw_num is not None:
+ try:
+ channel_num = float(raw_num)
+ except (ValueError, TypeError):
+ pass
+ else:
+ # For standard M3U accounts, check for tvg-chno or channel-number
+ tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "tvg-chno", None)
+ if tvg_chno is None:
+ tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "channel-number", None)
+ if tvg_chno is not None:
+ try:
+ channel_num = float(tvg_chno)
+ except (ValueError, TypeError):
+ pass
+
+ # Generate hash once with all parameters
+ stream_hash = Stream.generate_hash_key(
+ name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title,
+ account_type=account_type_for_hash, stream_id=provider_stream_id
+ )
+
stream_props = {
"name": name,
"url": url,
@@ -1015,6 +1070,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
"custom_properties": stream_info["attributes"],
"is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1,
"is_stale": False,
+ "stream_id": provider_stream_id,
+ "stream_chno": channel_num,
}
if stream_hash not in stream_hashes:
@@ -1026,7 +1083,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
existing_streams = {
s.stream_hash: s
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
- 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account'
+ 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
)
}
@@ -1040,7 +1097,9 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.logo_url != stream_props["logo_url"] or
obj.tvg_id != stream_props["tvg_id"] or
obj.custom_properties != stream_props["custom_properties"] or
- obj.is_adult != stream_props["is_adult"]
+ obj.is_adult != stream_props["is_adult"] or
+ obj.stream_id != stream_props["stream_id"] or
+ obj.stream_chno != stream_props["stream_chno"]
)
# Always update last_seen
@@ -1054,6 +1113,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
obj.tvg_id = stream_props["tvg_id"]
obj.custom_properties = stream_props["custom_properties"]
obj.is_adult = stream_props["is_adult"]
+ obj.stream_id = stream_props["stream_id"]
+ obj.stream_chno = stream_props["stream_chno"]
obj.updated_at = timezone.now()
# Always mark as not stale since we saw it in this refresh
@@ -1076,7 +1137,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
# Update all streams in a single bulk operation
Stream.objects.bulk_update(
streams_to_update,
- ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'],
+ ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
batch_size=200
)
except Exception as e:
diff --git a/core/tasks.py b/core/tasks.py
index fd8a19b5..78a3e0ec 100644
--- a/core/tasks.py
+++ b/core/tasks.py
@@ -535,9 +535,16 @@ def rehash_streams(keys):
# Update the last processed ID for next batch
last_processed_id = obj.id
- # Generate new hash
+ # Generate new hash - handle XC accounts differently
group_name = obj.channel_group.name if obj.channel_group else None
- new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys, m3u_id=obj.m3u_account_id, group=group_name)
+ account_type = obj.m3u_account.account_type if obj.m3u_account else None
+ stream_id_val = obj.stream_id if hasattr(obj, 'stream_id') else None
+
+ new_hash = Stream.generate_hash_key(
+ obj.name, obj.url, obj.tvg_id, keys,
+ m3u_id=obj.m3u_account_id, group=group_name,
+ account_type=account_type, stream_id=stream_id_val
+ )
# Check if this hash already exists in our tracking dict
if new_hash in hash_keys:
From aa1f62740298d4345fc41f3536a4d51f4595c898 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 19:01:34 -0600
Subject: [PATCH 100/125] Enhancement: Refactored `copyToClipboard` utility
function to include notification handling internally, eliminating duplicate
notification code across the frontend. The function now accepts optional
parameters for customizing success/failure messages while providing
consistent behavior across all copy operations.
---
CHANGELOG.md | 1 +
frontend/src/components/SeriesModal.jsx | 10 ++--
frontend/src/components/Sidebar.jsx | 14 ++---
frontend/src/components/VODModal.jsx | 10 ++--
.../components/tables/ChannelTableStreams.jsx | 10 ++--
.../src/components/tables/ChannelsTable.jsx | 38 ++++----------
frontend/src/utils.js | 52 ++++++++++++++-----
7 files changed, 61 insertions(+), 74 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f48d9456..152dd5b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Better auto-generation of request/response schemas
- Improved documentation accuracy with serializer introspection
- Switched to uv for package management: Migrated from pip to uv (Astral's fast Python package installer) for improved dependency resolution speed and reliability. This includes updates to Docker build processes, installation scripts (debian_install.sh), and project configuration (pyproject.toml) to leverage uv's features like virtual environment management and lockfile generation. - Thanks [@tobimichael96](https://github.com/tobimichael96) for getting it started!
+- Copy to Clipboard: Refactored `copyToClipboard` utility function to include notification handling internally, eliminating duplicate notification code across the frontend. The function now accepts optional parameters for customizing success/failure messages while providing consistent behavior across all copy operations.
### Fixed
diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx
index 05023712..c8e551af 100644
--- a/frontend/src/components/SeriesModal.jsx
+++ b/frontend/src/components/SeriesModal.jsx
@@ -287,13 +287,9 @@ const SeriesModal = ({ series, opened, onClose }) => {
const handleCopyEpisodeLink = async (episode) => {
const streamUrl = getEpisodeStreamUrl(episode);
- const success = await copyToClipboard(streamUrl);
- notifications.show({
- title: success ? 'Link Copied!' : 'Copy Failed',
- message: success
- ? 'Episode link copied to clipboard'
- : 'Failed to copy link to clipboard',
- color: success ? 'green' : 'red',
+ await copyToClipboard(streamUrl, {
+ successTitle: 'Link Copied!',
+ successMessage: 'Episode link copied to clipboard',
});
};
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index a25aa301..435509e0 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -144,16 +144,10 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
// No need to fetch them again here - just use the store values
const copyPublicIP = async () => {
- const success = await copyToClipboard(environment.public_ip);
- if (success) {
- notifications.show({
- title: 'Success',
- message: 'Public IP copied to clipboard',
- color: 'green',
- });
- } else {
- console.error('Failed to copy public IP to clipboard');
- }
+ await copyToClipboard(environment.public_ip, {
+ successTitle: 'Success',
+ successMessage: 'Public IP copied to clipboard',
+ });
};
const onLogout = async () => {
diff --git a/frontend/src/components/VODModal.jsx b/frontend/src/components/VODModal.jsx
index 7df90ec0..f0ff7fec 100644
--- a/frontend/src/components/VODModal.jsx
+++ b/frontend/src/components/VODModal.jsx
@@ -268,13 +268,9 @@ const VODModal = ({ vod, opened, onClose }) => {
const handleCopyLink = async () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
- const success = await copyToClipboard(streamUrl);
- notifications.show({
- title: success ? 'Link Copied!' : 'Copy Failed',
- message: success
- ? 'Stream link copied to clipboard'
- : 'Failed to copy link to clipboard',
- color: success ? 'green' : 'red',
+ await copyToClipboard(streamUrl, {
+ successTitle: 'Link Copied!',
+ successMessage: 'Stream link copied to clipboard',
});
};
diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx
index 0443b3ca..30dabf3d 100644
--- a/frontend/src/components/tables/ChannelTableStreams.jsx
+++ b/frontend/src/components/tables/ChannelTableStreams.jsx
@@ -379,13 +379,9 @@ const ChannelStreams = ({ channel, isExpanded }) => {
style={{ cursor: 'pointer' }}
onClick={async (e) => {
e.stopPropagation();
- const success = await copyToClipboard(stream.url);
- notifications.show({
- title: success ? 'URL Copied' : 'Copy Failed',
- message: success
- ? 'Stream URL copied to clipboard'
- : 'Failed to copy URL to clipboard',
- color: success ? 'green' : 'red',
+ await copyToClipboard(stream.url, {
+ successTitle: 'URL Copied',
+ successMessage: 'Stream URL copied to clipboard',
});
}}
>
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index f41ca72d..371cb77f 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -727,14 +727,6 @@ const ChannelsTable = ({ onReady }) => {
setRecordingModalOpen(false);
};
- const handleCopy = async (textToCopy, ref) => {
- const success = await copyToClipboard(textToCopy);
- notifications.show({
- title: success ? 'Copied!' : 'Copy Failed',
- message: success ? undefined : 'Failed to copy to clipboard',
- color: success ? 'green' : 'red',
- });
- };
// Build URLs with parameters
const buildM3UUrl = () => {
const params = new URLSearchParams();
@@ -759,35 +751,23 @@ const ChannelsTable = ({ onReady }) => {
};
// Example copy URLs
const copyM3UUrl = async () => {
- const success = await copyToClipboard(buildM3UUrl());
- notifications.show({
- title: success ? 'M3U URL Copied!' : 'Copy Failed',
- message: success
- ? 'The M3U URL has been copied to your clipboard.'
- : 'Failed to copy M3U URL to clipboard',
- color: success ? 'green' : 'red',
+ await copyToClipboard(buildM3UUrl(), {
+ successTitle: 'M3U URL Copied!',
+ successMessage: 'The M3U URL has been copied to your clipboard.',
});
};
const copyEPGUrl = async () => {
- const success = await copyToClipboard(buildEPGUrl());
- notifications.show({
- title: success ? 'EPG URL Copied!' : 'Copy Failed',
- message: success
- ? 'The EPG URL has been copied to your clipboard.'
- : 'Failed to copy EPG URL to clipboard',
- color: success ? 'green' : 'red',
+ await copyToClipboard(buildEPGUrl(), {
+ successTitle: 'EPG URL Copied!',
+ successMessage: 'The EPG URL has been copied to your clipboard.',
});
};
const copyHDHRUrl = async () => {
- const success = await copyToClipboard(hdhrUrl);
- notifications.show({
- title: success ? 'HDHR URL Copied!' : 'Copy Failed',
- message: success
- ? 'The HDHR URL has been copied to your clipboard.'
- : 'Failed to copy HDHR URL to clipboard',
- color: success ? 'green' : 'red',
+ await copyToClipboard(hdhrUrl, {
+ successTitle: 'HDHR URL Copied!',
+ successMessage: 'The HDHR URL has been copied to your clipboard.',
});
};
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index bb1b6060..27abf86c 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
+import { notifications } from '@mantine/notifications';
export default {
Limiter: (n, list) => {
@@ -76,30 +77,53 @@ export function sleep(ms) {
export const getDescendantProp = (obj, path) =>
path.split('.').reduce((acc, part) => acc && acc[part], obj);
-export const copyToClipboard = async (value) => {
+export const copyToClipboard = async (value, options = {}) => {
+ const {
+ successTitle = 'Copied!',
+ successMessage = 'Copied to clipboard',
+ failureTitle = 'Copy Failed',
+ failureMessage = 'Failed to copy to clipboard',
+ showNotification = true,
+ } = options;
+
+ let success = false;
+
if (navigator.clipboard) {
// Modern method, using navigator.clipboard
try {
await navigator.clipboard.writeText(value);
- return true;
+ success = true;
} catch (err) {
console.error('Failed to copy: ', err);
}
}
- // Fallback method for environments without clipboard support
- try {
- const textarea = document.createElement('textarea');
- textarea.value = value;
- document.body.appendChild(textarea);
- textarea.select();
- const successful = document.execCommand('copy');
- document.body.removeChild(textarea);
- return successful;
- } catch (err) {
- console.error('Failed to copy with fallback method: ', err);
- return false;
+ if (!success) {
+ // Fallback method for environments without clipboard support
+ try {
+ const textarea = document.createElement('textarea');
+ textarea.value = value;
+ document.body.appendChild(textarea);
+ textarea.select();
+ const successful = document.execCommand('copy');
+ document.body.removeChild(textarea);
+ success = successful;
+ } catch (err) {
+ console.error('Failed to copy with fallback method: ', err);
+ success = false;
+ }
}
+
+ // Show notification if enabled
+ if (showNotification) {
+ notifications.show({
+ title: success ? successTitle : failureTitle,
+ message: success ? successMessage : failureMessage,
+ color: success ? 'green' : 'red',
+ });
+ }
+
+ return success;
};
export const setCustomProperty = (input, key, value, serialize = false) => {
From bbe4679a72edfbe7e589206bfa9a6cd426500dff Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 19:46:36 -0600
Subject: [PATCH 101/125] changelog: Update changelog for stream table PR.
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 152dd5b8..3d520c02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view.
+- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations.
- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including:
- `useLocalStorage` hook tests with localStorage mocking and error handling
From 3c556494c168269b4f3ba9c18518dee380f30e74 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 20:26:45 -0600
Subject: [PATCH 102/125] Enhancement: Updated default network access settings
for M3U and EPG endpoints to only allow local/private networks by default
(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7,
fe80::/10). This improves security by preventing public internet access to
these endpoints unless explicitly configured. Other endpoints (Streams, XC
API, UI) remain open by default.
---
CHANGELOG.md | 1 +
dispatcharr/utils.py | 10 +++++++++-
.../components/forms/settings/NetworkAccessForm.jsx | 7 ++++++-
.../src/utils/forms/settings/NetworkAccessFormUtils.js | 6 +++++-
4 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d520c02..8af6c6a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default.
- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes.
- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
- Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py
index e588bcaa..c8fe58ad 100644
--- a/dispatcharr/utils.py
+++ b/dispatcharr/utils.py
@@ -43,11 +43,19 @@ def network_access_allowed(request, settings_key):
network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value
except CoreSettings.DoesNotExist:
network_access = {}
+ local_cidrs = ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"]
+ # Set defaults based on endpoint type
+ if settings_key == "M3U_EPG":
+ # M3U/EPG endpoints: local IPv4 and IPv6 only by default
+ default_cidrs = local_cidrs
+ else:
+ # Other endpoints: allow all by default
+ default_cidrs = ["0.0.0.0/0", "::/0"]
cidrs = (
network_access[settings_key].split(",")
if settings_key in network_access
- else ["0.0.0.0/0", "::/0"]
+ else default_cidrs
)
network_allowed = False
diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
index cc701f14..dd01e75a 100644
--- a/frontend/src/components/forms/settings/NetworkAccessForm.jsx
+++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
@@ -37,9 +37,14 @@ const NetworkAccessForm = React.memo(({ active }) => {
useEffect(() => {
const networkAccessSettings = settings['network_access']?.value || {};
+ // M3U/EPG endpoints default to local networks only
+ const m3uEpgDefaults =
+ '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
networkAccessForm.setValues(
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0';
+ const defaultValue =
+ key === 'M3U_EPG' ? m3uEpgDefaults : '0.0.0.0/0,::/0';
+ acc[key] = networkAccessSettings[key] || defaultValue;
return acc;
}, {})
);
diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
index fe1eea8a..e5b27d92 100644
--- a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
+++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
@@ -1,9 +1,13 @@
import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
+// Default CIDR ranges for M3U/EPG endpoints (local networks only)
+const M3U_EPG_DEFAULTS = '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
+
export const getNetworkAccessFormInitialValues = () => {
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
- acc[key] = '0.0.0.0/0,::/0';
+ // M3U/EPG endpoints default to local networks only
+ acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : '0.0.0.0/0,::/0';
return acc;
}, {});
};
From dc51ab4dd10c4a7eae57ca60f9f18a0aaf4ced83 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 20:35:48 -0600
Subject: [PATCH 103/125] Enhancement: Added a "Reset to Defaults" button to
the Network Access settings form, matching the functionality in Proxy
Settings. Users can now quickly restore recommended network access settings
with one click.
---
CHANGELOG.md | 1 +
.../forms/settings/NetworkAccessForm.jsx | 14 +++++++++++++-
.../utils/forms/settings/NetworkAccessFormUtils.js | 9 +++++++++
3 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8af6c6a1..3966174a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click.
- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view.
- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations.
diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
index dd01e75a..04ab60b0 100644
--- a/frontend/src/components/forms/settings/NetworkAccessForm.jsx
+++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
@@ -11,6 +11,7 @@ import ConfirmationDialog from '../../ConfirmationDialog.jsx';
import {
getNetworkAccessFormInitialValues,
getNetworkAccessFormValidation,
+ getNetworkAccessDefaults,
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
const NetworkAccessForm = React.memo(({ active }) => {
@@ -50,6 +51,10 @@ const NetworkAccessForm = React.memo(({ active }) => {
);
}, [settings]);
+ const resetNetworkAccessToDefaults = () => {
+ networkAccessForm.setValues(getNetworkAccessDefaults());
+ };
+
const onNetworkAccessSubmit = async () => {
setSaved(false);
setNetworkAccessError(null);
@@ -125,7 +130,14 @@ const NetworkAccessForm = React.memo(({ active }) => {
/>
))}
-
+
+
+ Reset to Defaults
+
{
};
return acc;
}, {});
+};
+
+export const getNetworkAccessDefaults = () => {
+ return {
+ M3U_EPG: M3U_EPG_DEFAULTS,
+ STREAMS: '0.0.0.0/0,::/0',
+ XC_API: '0.0.0.0/0,::/0',
+ UI: '0.0.0.0/0,::/0',
+ };
};
\ No newline at end of file
From 8eef0889504018a17f36f96f0444519d7f79eff4 Mon Sep 17 00:00:00 2001
From: None
Date: Sat, 31 Jan 2026 20:45:39 -0600
Subject: [PATCH 104/125] Add PostgreSQL version check for modular deployments
Implements automatic version validation for external databases in modular mode to ensure compatibility.
Changes:
- Added check_external_postgres_version() in 02-postgres.sh
- Integrated version check in entrypoint.sh after database connection
- Enforces minimum PostgreSQL version matching DispatcharrBase
- Allows newer versions with forward compatibility notice
- Rejects older versions with clear upgrade instructions
The version requirement automatically scales when DispatcharrBase is updated, requiring no manual maintenance. Only applies to modular deployments using external databases; AIO deployments are unaffected.
Tested and verified correct behavior with PostgreSQL v17, v18, and v16
---
docker/entrypoint.sh | 3 +++
docker/init/02-postgres.sh | 54 ++++++++++++++++++++++++++++++++++++++
2 files changed, 57 insertions(+)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index e6fa8ef0..f53fadf8 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -191,6 +191,9 @@ except Exception:
sleep 1
done
echo "✅ External PostgreSQL is ready"
+
+ # Check PostgreSQL version compatibility
+ check_external_postgres_version || exit 1
fi
# Wait for Redis to be ready (modular mode uses external Redis)
diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh
index 972dea06..012b5c09 100644
--- a/docker/init/02-postgres.sh
+++ b/docker/init/02-postgres.sh
@@ -191,4 +191,58 @@ ensure_utf8_encoding() {
fi
}
+check_external_postgres_version() {
+ # Only check for modular deployments using external databases
+ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
+ return 0
+ fi
+
+ echo "🔍 Checking external PostgreSQL version compatibility..."
+
+ # Get minimum required version from base image (set in entrypoint.sh)
+ # PG_VERSION is the version installed in DispatcharrBase
+ MIN_REQUIRED_VERSION=$PG_VERSION
+
+ # Query external PostgreSQL version
+ EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "postgres" -tAc "SHOW server_version;" 2>/dev/null | grep -oE '^[0-9]+')
+
+ if [ -z "$EXTERNAL_VERSION" ]; then
+ echo "❌ ERROR: Unable to determine external PostgreSQL version"
+ echo " Could not connect to database at ${POSTGRES_HOST}:${POSTGRES_PORT}"
+ echo " Please verify your database connection settings."
+ return 1
+ fi
+
+ # Compare versions
+ if [[ "$EXTERNAL_VERSION" -lt "$MIN_REQUIRED_VERSION" ]]; then
+ # FAIL: Version too old
+ echo ""
+ echo "❌ ERROR: PostgreSQL version mismatch"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " External Database: PostgreSQL $EXTERNAL_VERSION"
+ echo " Required Version: PostgreSQL $MIN_REQUIRED_VERSION or higher"
+ echo ""
+ echo " Your external PostgreSQL database is too old for Dispatcharr."
+ echo " Please upgrade to PostgreSQL $MIN_REQUIRED_VERSION or later."
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ return 1
+
+ elif [[ "$EXTERNAL_VERSION" -eq "$MIN_REQUIRED_VERSION" ]]; then
+ # MATCH: Exact version match
+ echo "✅ PostgreSQL version check passed"
+ echo " External Database: PostgreSQL $EXTERNAL_VERSION (matches target version)"
+
+ else
+ # HIGHER: Newer version
+ echo "✅ PostgreSQL version check passed"
+ echo " External Database: PostgreSQL $EXTERNAL_VERSION"
+ echo " Target Version: PostgreSQL $MIN_REQUIRED_VERSION"
+ echo " ℹ️ Your database is newer than the target version."
+ echo " PostgreSQL version should be compatible with Dispatcharr."
+ fi
+
+ return 0
+}
+
From 762949e983a18b7dd6bd0344314bf72247ce09c2 Mon Sep 17 00:00:00 2001
From: None
Date: Sat, 31 Jan 2026 20:48:54 -0600
Subject: [PATCH 105/125] Update verbiage for consistency
---
docker/init/02-postgres.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh
index 012b5c09..87aa94ef 100644
--- a/docker/init/02-postgres.sh
+++ b/docker/init/02-postgres.sh
@@ -192,7 +192,7 @@ ensure_utf8_encoding() {
}
check_external_postgres_version() {
- # Only check for modular deployments using external databases
+ # Only check for modular deployments
if [[ "$DISPATCHARR_ENV" != "modular" ]]; then
return 0
fi
@@ -200,7 +200,7 @@ check_external_postgres_version() {
echo "🔍 Checking external PostgreSQL version compatibility..."
# Get minimum required version from base image (set in entrypoint.sh)
- # PG_VERSION is the version installed in DispatcharrBase
+ # PG_VERSION is from DispatcharrBase
MIN_REQUIRED_VERSION=$PG_VERSION
# Query external PostgreSQL version
@@ -223,7 +223,7 @@ check_external_postgres_version() {
echo " Required Version: PostgreSQL $MIN_REQUIRED_VERSION or higher"
echo ""
echo " Your external PostgreSQL database is too old for Dispatcharr."
- echo " Please upgrade to PostgreSQL $MIN_REQUIRED_VERSION or later."
+ echo " Please upgrade to PostgreSQL $MIN_REQUIRED_VERSION or higher."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
return 1
From da598d3b33d9ae0e4661fb0bc7c946fe02956604 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 31 Jan 2026 21:26:34 -0600
Subject: [PATCH 106/125] Missed migration for previous feature.
---
...ream_dispatcharr_stream_id_idx_and_more.py | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
create mode 100644 apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py
diff --git a/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py b/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py
new file mode 100644
index 00000000..77b88768
--- /dev/null
+++ b/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py
@@ -0,0 +1,31 @@
+# Generated by Django 5.2.9 on 2026-02-01 03:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dispatcharr_channels', '0033_stream_id_stream_chno'),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name='stream',
+ name='dispatcharr_stream_id_idx',
+ ),
+ migrations.RemoveIndex(
+ model_name='stream',
+ name='dispatcharr_stream_chno_idx',
+ ),
+ migrations.AlterField(
+ model_name='stream',
+ name='stream_chno',
+ field=models.FloatField(blank=True, db_index=True, help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', null=True),
+ ),
+ migrations.AlterField(
+ model_name='stream',
+ name='stream_id',
+ field=models.IntegerField(blank=True, db_index=True, help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', null=True),
+ ),
+ ]
From d7b98fef8d3da676c0dd0f0cd015afc784bc5dce Mon Sep 17 00:00:00 2001
From: None
Date: Sat, 31 Jan 2026 23:11:20 -0600
Subject: [PATCH 107/125] Add default/advanced mode toggle and test coverage
for EPG matching
Enhanced EPG matching UX by adding radio button toggle to more clearly define optional advanced options. Also added test coverage for stability.
Frontend Changes:
- Added radio button UI to EPGMatchModal for default/advanced mode selection
- Default mode: Uses built-in normalization
- Advanced mode: Shows TagsInput fields for custom prefixes/suffixes/strings
- Mode persists across sessions and settings are preserved when switching modes
Test Coverage:
- Created EPGMatchModal.test.jsx with test cases covering:
- Component rendering and mode switching
- Form submission and settings persistence
- Error handling and UI text variations
- Added test cases to SettingsUtils.test.js for EPG mode handling:
- epg_match_mode routing to epg_settings group
- Settings creation and preservation
Since advanced settings are saved persistently but ignored when default settings are used, this adds an easy way in the future to add additional advanced settings to EPG matching like region influence, match aggression, and match preview, since users can easily switch between modes.
---
apps/channels/tasks.py | 28 ++-
core/models.py | 1 +
.../src/components/modals/EPGMatchModal.jsx | 141 ++++++++-----
.../modals/__tests__/EPGMatchModal.test.jsx | 190 ++++++++++++++++++
frontend/src/utils/pages/SettingsUtils.js | 8 +-
.../pages/__tests__/SettingsUtils.test.js | 126 ++++++++++++
6 files changed, 429 insertions(+), 65 deletions(-)
create mode 100644 frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index 25c0bfa0..8d49287b 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -139,7 +139,7 @@ COMMON_EXTRANEOUS_WORDS = [
def normalize_name(name: str) -> str:
"""
A more aggressive normalization that:
- - Removes user-configured prefixes/suffixes/custom strings (if configured)
+ - Removes user-configured prefixes/suffixes/custom strings (only if mode is 'advanced')
- Lowercases
- Removes bracketed/parenthesized text
- Removes punctuation
@@ -157,17 +157,23 @@ def normalize_name(name: str) -> str:
try:
from core.models import CoreSettings
settings = CoreSettings.get_epg_settings()
- prefixes = settings.get("epg_match_ignore_prefixes", [])
- suffixes = settings.get("epg_match_ignore_suffixes", [])
- custom_strings = settings.get("epg_match_ignore_custom", [])
- # Ensure we have lists
- if not isinstance(prefixes, list):
- prefixes = []
- if not isinstance(suffixes, list):
- suffixes = []
- if not isinstance(custom_strings, list):
- custom_strings = []
+ # Check if user has enabled advanced mode
+ mode = settings.get("epg_match_mode", "default")
+
+ # Only use custom settings if mode is 'advanced'
+ if mode == "advanced":
+ prefixes = settings.get("epg_match_ignore_prefixes", [])
+ suffixes = settings.get("epg_match_ignore_suffixes", [])
+ custom_strings = settings.get("epg_match_ignore_custom", [])
+
+ # Ensure we have lists
+ if not isinstance(prefixes, list):
+ prefixes = []
+ if not isinstance(suffixes, list):
+ suffixes = []
+ if not isinstance(custom_strings, list):
+ custom_strings = []
except Exception as e:
# Settings unavailable or error - continue with empty lists (graceful degradation)
diff --git a/core/models.py b/core/models.py
index 1037a6e3..c72192db 100644
--- a/core/models.py
+++ b/core/models.py
@@ -233,6 +233,7 @@ class CoreSettings(models.Model):
def get_epg_settings(cls):
"""Get all EPG-related settings."""
return cls._get_group(EPG_SETTINGS_KEY, {
+ "epg_match_mode": "default",
"epg_match_ignore_prefixes": [],
"epg_match_ignore_suffixes": [],
"epg_match_ignore_custom": [],
diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx
index fc773d94..64873643 100644
--- a/frontend/src/components/modals/EPGMatchModal.jsx
+++ b/frontend/src/components/modals/EPGMatchModal.jsx
@@ -1,4 +1,4 @@
-import { useMemo, useState, useEffect } from 'react';
+import { useMemo, useState, useEffect, useRef } from 'react';
import {
Modal,
Stack,
@@ -7,6 +7,7 @@ import {
Group,
Button,
Loader,
+ Radio,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import useSettingsStore from '../../store/settings';
@@ -20,6 +21,7 @@ import {
const getEpgSettingsFromStore = (settings) => {
const epgSettings = settings?.['epg_settings']?.value;
return {
+ epg_match_mode: epgSettings?.epg_match_mode || 'default',
epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes)
? epgSettings.epg_match_ignore_prefixes
: [],
@@ -40,6 +42,7 @@ const EPGMatchModal = ({
const settings = useSettingsStore((s) => s.settings);
const [loading, setLoading] = useState(false);
+ const [settingsMode, setSettingsMode] = useState('default');
// Compute form values directly from settings - memoized for performance
const storedValues = useMemo(
@@ -50,18 +53,28 @@ const EPGMatchModal = ({
// Local form state
const [formValues, setFormValues] = useState(storedValues);
- // Reset to stored values when modal opens
+ // Track previous opened state to detect transitions
+ const prevOpened = useRef(false);
+
+ // Reset to stored values and mode only when modal opens (not on storedValues changes)
useEffect(() => {
- if (opened) {
+ // Only reset when transitioning from closed to open
+ if (opened && !prevOpened.current) {
setFormValues(storedValues);
+ setSettingsMode(storedValues.epg_match_mode);
}
+ prevOpened.current = opened;
}, [opened, storedValues]);
const handleConfirm = async () => {
setLoading(true);
try {
- // Save settings first
- const changedSettings = getChangedSettings(formValues, settings);
+ // Save mode and settings (backend will ignore custom settings if mode is 'default')
+ const settingsToSave = {
+ ...formValues,
+ epg_match_mode: settingsMode,
+ };
+ const changedSettings = getChangedSettings(settingsToSave, settings);
if (Object.keys(changedSettings).length > 0) {
await saveChangedSettings(settings, changedSettings);
}
@@ -108,59 +121,81 @@ const EPGMatchModal = ({
>
- Configure how channel names are normalized during matching, then start
- the auto-match process for {scopeText}.
+ Match channels to EPG data for {scopeText}.
-
- setFormValues((prev) => ({
- ...prev,
- epg_match_ignore_prefixes: value,
- }))
- }
- splitChars={[]}
- clearable
- />
+
+
+
+
+
+
-
- setFormValues((prev) => ({
- ...prev,
- epg_match_ignore_suffixes: value,
- }))
- }
- splitChars={[]}
- clearable
- />
+ {settingsMode === 'advanced' && (
+ <>
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_prefixes: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
-
- setFormValues((prev) => ({
- ...prev,
- epg_match_ignore_custom: value,
- }))
- }
- splitChars={[]}
- clearable
- />
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_suffixes: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
-
- Channel display names are never modified. These settings only affect
- the matching algorithm.
-
+
+ setFormValues((prev) => ({
+ ...prev,
+ epg_match_ignore_custom: value,
+ }))
+ }
+ splitChars={[]}
+ clearable
+ />
+
+
+ Channel display names are never modified. These settings only affect
+ the matching algorithm.
+
+ >
+ )}
diff --git a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
new file mode 100644
index 00000000..0d5ecbb8
--- /dev/null
+++ b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
@@ -0,0 +1,190 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import EPGMatchModal from '../EPGMatchModal';
+import * as SettingsUtils from '../../../utils/pages/SettingsUtils';
+import API from '../../../api';
+
+// Mock dependencies
+vi.mock('../../../api', () => ({
+ default: {
+ matchEpg: vi.fn(),
+ },
+}));
+
+vi.mock('../../../utils/pages/SettingsUtils', () => ({
+ getChangedSettings: vi.fn(),
+ saveChangedSettings: vi.fn(),
+}));
+
+vi.mock('@mantine/notifications', () => ({
+ notifications: {
+ show: vi.fn(),
+ },
+}));
+
+vi.mock('../../../store/settings', () => ({
+ default: vi.fn((selector) => {
+ const mockState = {
+ settings: {
+ epg_settings: {
+ value: {
+ epg_match_mode: 'default',
+ epg_match_ignore_prefixes: [],
+ epg_match_ignore_suffixes: [],
+ epg_match_ignore_custom: [],
+ },
+ },
+ },
+ };
+ return selector(mockState);
+ }),
+}));
+
+describe('EPGMatchModal', () => {
+ const defaultProps = {
+ opened: true,
+ onClose: vi.fn(),
+ selectedChannelIds: [],
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render the modal when opened', () => {
+ render();
+ expect(screen.getByText('EPG Match Settings')).toBeInTheDocument();
+ });
+
+ it('should show default mode selected by default', () => {
+ render();
+ const defaultRadio = screen.getByLabelText('Use default settings');
+ expect(defaultRadio).toBeChecked();
+ });
+
+ it('should not show advanced fields in default mode', () => {
+ render();
+ expect(screen.queryByLabelText('Ignore Prefixes')).not.toBeInTheDocument();
+ });
+
+ it('should show advanced fields when advanced mode is selected', async () => {
+ render();
+ const advancedRadio = screen.getByLabelText('Configure advanced options');
+
+ fireEvent.click(advancedRadio);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Ignore Prefixes')).toBeInTheDocument();
+ expect(screen.getByLabelText('Ignore Suffixes')).toBeInTheDocument();
+ expect(screen.getByLabelText('Ignore Custom Strings')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Mode Switching', () => {
+ it('should allow switching between default and advanced modes', async () => {
+ render();
+
+ const defaultRadio = screen.getByLabelText('Use default settings');
+ const advancedRadio = screen.getByLabelText('Configure advanced options');
+
+ expect(defaultRadio).toBeChecked();
+
+ fireEvent.click(advancedRadio);
+ await waitFor(() => {
+ expect(advancedRadio).toBeChecked();
+ expect(defaultRadio).not.toBeChecked();
+ });
+
+ fireEvent.click(defaultRadio);
+ await waitFor(() => {
+ expect(defaultRadio).toBeChecked();
+ expect(advancedRadio).not.toBeChecked();
+ });
+ });
+ });
+
+ describe('Form Submission', () => {
+ it('should save mode and trigger auto-match', async () => {
+ SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
+ SettingsUtils.saveChangedSettings.mockResolvedValue();
+ API.matchEpg.mockResolvedValue();
+
+ render();
+
+ const submitButton = screen.getByText('Start Auto-Match');
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
+ expect(API.matchEpg).toHaveBeenCalled();
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('should pass selectedChannelIds to matchEpg when provided', async () => {
+ const selectedIds = [1, 2, 3];
+ SettingsUtils.getChangedSettings.mockReturnValue({});
+ API.matchEpg.mockResolvedValue();
+
+ render();
+
+ const submitButton = screen.getByText('Start Auto-Match');
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(API.matchEpg).toHaveBeenCalledWith(selectedIds);
+ });
+ });
+
+ it('should handle save errors gracefully', async () => {
+ const error = new Error('Save failed');
+ SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
+ SettingsUtils.saveChangedSettings.mockRejectedValue(error);
+
+ render();
+
+ const submitButton = screen.getByText('Start Auto-Match');
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(defaultProps.onClose).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Settings Persistence', () => {
+ it('should include epg_match_mode in settings to save', async () => {
+ SettingsUtils.getChangedSettings.mockImplementation((values) => values);
+ SettingsUtils.saveChangedSettings.mockResolvedValue();
+ API.matchEpg.mockResolvedValue();
+
+ render();
+
+ const submitButton = screen.getByText('Start Auto-Match');
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
+ expect.objectContaining({
+ epg_match_mode: 'default',
+ }),
+ expect.anything()
+ );
+ });
+ });
+ });
+
+ describe('UI Text', () => {
+ it('should show correct text for selected channels', () => {
+ render();
+ expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument();
+ });
+
+ it('should show correct text for all channels', () => {
+ render();
+ expect(screen.getByText(/Match channels to EPG data for all channels without EPG/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js
index 9cd4b2c8..c3bb9caf 100644
--- a/frontend/src/utils/pages/SettingsUtils.js
+++ b/frontend/src/utils/pages/SettingsUtils.js
@@ -28,7 +28,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
// Map of field prefixes to their groups
const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
- const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
+ const epgFields = ['epg_match_mode', 'epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
@@ -134,6 +134,12 @@ export const getChangedSettings = (values, settings) => {
let actualValue = values[settingKey];
let compareValue;
+ // Handle EPG mode field - always include (defaults to 'default' if not set)
+ if (settingKey === 'epg_match_mode') {
+ changedSettings[settingKey] = actualValue || 'default';
+ continue;
+ }
+
// Handle EPG fields specially - keep as arrays, don't skip empty arrays
if (epgFields.includes(settingKey)) {
if (!Array.isArray(actualValue)) {
diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
index 1611c7d3..17783f24 100644
--- a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
@@ -407,5 +407,131 @@ describe('SettingsUtils', () => {
expect(changes.network_access).toBeUndefined();
expect(changes.time_zone).toBe('America/New_York');
});
+
+ it('should always include epg_match_mode', () => {
+ const values = {
+ epg_match_mode: 'advanced',
+ epg_match_ignore_prefixes: ['HD:'],
+ };
+ const settings = {};
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(changes.epg_match_mode).toBe('advanced');
+ });
+
+ it('should default epg_match_mode to "default" if not provided', () => {
+ const values = {
+ epg_match_ignore_prefixes: ['HD:'],
+ };
+ const settings = {};
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ // epg_match_mode should not be included if not in values
+ expect(changes.epg_match_mode).toBeUndefined();
+ });
+
+ it('should always include EPG array fields even if empty', () => {
+ const values = {
+ epg_match_ignore_prefixes: [],
+ epg_match_ignore_suffixes: [],
+ epg_match_ignore_custom: [],
+ };
+ const settings = {};
+
+ const changes = SettingsUtils.getChangedSettings(values, settings);
+ expect(changes.epg_match_ignore_prefixes).toEqual([]);
+ expect(changes.epg_match_ignore_suffixes).toEqual([]);
+ expect(changes.epg_match_ignore_custom).toEqual([]);
+ });
+ });
+
+ describe('saveChangedSettings - EPG Mode', () => {
+ it('should save epg_match_mode to epg_settings group', async () => {
+ const settings = {
+ epg_settings: {
+ id: 3,
+ key: 'epg_settings',
+ value: {
+ epg_match_mode: 'default',
+ epg_match_ignore_prefixes: [],
+ epg_match_ignore_suffixes: [],
+ epg_match_ignore_custom: [],
+ }
+ }
+ };
+ const changedSettings = {
+ epg_match_mode: 'advanced',
+ epg_match_ignore_prefixes: ['HD:'],
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 3,
+ key: 'epg_settings',
+ value: {
+ epg_match_mode: 'advanced',
+ epg_match_ignore_prefixes: ['HD:'],
+ epg_match_ignore_suffixes: [],
+ epg_match_ignore_custom: [],
+ }
+ });
+ });
+
+ it('should create epg_settings if it does not exist', async () => {
+ const settings = {};
+ const changedSettings = {
+ epg_match_mode: 'advanced',
+ epg_match_ignore_prefixes: ['Sling:'],
+ };
+
+ API.createSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.createSetting).toHaveBeenCalledWith({
+ key: 'epg_settings',
+ name: 'Epg Settings',
+ value: {
+ epg_match_mode: 'advanced',
+ epg_match_ignore_prefixes: ['Sling:'],
+ }
+ });
+ });
+
+ it('should preserve existing EPG settings when updating mode', async () => {
+ const settings = {
+ epg_settings: {
+ id: 3,
+ key: 'epg_settings',
+ value: {
+ epg_match_mode: 'advanced',
+ epg_match_ignore_prefixes: ['HD:'],
+ epg_match_ignore_suffixes: [' 4K'],
+ epg_match_ignore_custom: ['Plus'],
+ }
+ }
+ };
+ const changedSettings = {
+ epg_match_mode: 'default',
+ };
+
+ API.updateSetting.mockResolvedValue({});
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 3,
+ key: 'epg_settings',
+ value: {
+ epg_match_mode: 'default',
+ epg_match_ignore_prefixes: ['HD:'],
+ epg_match_ignore_suffixes: [' 4K'],
+ epg_match_ignore_custom: ['Plus'],
+ }
+ });
+ });
});
});
From 6617ee4e663a1224177af01bc9d1602b99ea31e7 Mon Sep 17 00:00:00 2001
From: None
Date: Sun, 1 Feb 2026 00:23:20 -0600
Subject: [PATCH 108/125] Add Mantine component mocks to EPGMatchModal tests
Fixed failing EPGMatchModal tests by adding mocking for @mantine/core components
Follows existing test patterns from Settings.test.jsx
---
.../modals/__tests__/EPGMatchModal.test.jsx | 92 +++++++++++++++++++
1 file changed, 92 insertions(+)
diff --git a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
index 0d5ecbb8..3f149347 100644
--- a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
+++ b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
@@ -40,6 +40,98 @@ vi.mock('../../../store/settings', () => ({
}),
}));
+vi.mock('@mantine/core', () => {
+ const React = require('react');
+
+ const RadioComponent = ({ label, value, checked, description, groupValue, groupOnChange }) => {
+ const isChecked = checked !== undefined ? checked : groupValue === value;
+ const handleChange = groupOnChange || (() => {});
+
+ return (
+
+ );
+ };
+
+ RadioComponent.Group = ({ children, value, onChange, label }) => {
+ // Clone children and inject group props
+ const enhancedChildren = React.Children.map(children, (child) => {
+ if (React.isValidElement(child)) {
+ // If it's a Stack or other container, recursively enhance its children
+ if (child.type?.name === 'Stack' || child.props['data-testid'] === 'stack') {
+ return React.cloneElement(child, {
+ children: React.Children.map(child.props.children, (nestedChild) => {
+ if (React.isValidElement(nestedChild) && nestedChild.type === RadioComponent) {
+ return React.cloneElement(nestedChild, {
+ groupValue: value,
+ groupOnChange: onChange,
+ });
+ }
+ return nestedChild;
+ }),
+ });
+ }
+ // If it's a Radio component, inject props directly
+ if (child.type === RadioComponent) {
+ return React.cloneElement(child, {
+ groupValue: value,
+ groupOnChange: onChange,
+ });
+ }
+ }
+ return child;
+ });
+
+ return (
+
+ {label && {label}}
+ {enhancedChildren}
+
+ );
+ };
+
+ return {
+ Modal: ({ children, opened, title }) =>
+ opened ? (
+
+ ) : null,
+ Stack: ({ children }) => {children}
,
+ Radio: RadioComponent,
+ TagsInput: ({ label, value, onChange, ...props }) => (
+
+
+ onChange(e.target.value.split(',').filter(Boolean))}
+ {...props}
+ />
+
+ ),
+ Button: ({ children, onClick, loading, ...props }) => (
+
+ {loading ? 'Loading...' : children}
+
+ ),
+ Group: ({ children }) => {children}
,
+ Loader: () => Loading...
,
+ Text: ({ children }) => {children},
+ };
+});
+
describe('EPGMatchModal', () => {
const defaultProps = {
opened: true,
From 5c4a4580d6b0246b317cdfb041ef7f855756ad4b Mon Sep 17 00:00:00 2001
From: Jeff Casimir
Date: Sun, 1 Feb 2026 19:34:01 -0700
Subject: [PATCH 109/125] Fix backup scheduler test to match actual defaults
The test expected enabled=False and retention_count=0, but commit
5371519d intentionally changed these defaults to enabled=True and
retention_count=3. Update test to match the intended behavior.
Co-Authored-By: Claude Opus 4.5
---
apps/backups/tests.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/apps/backups/tests.py b/apps/backups/tests.py
index dc8a5136..85076a85 100644
--- a/apps/backups/tests.py
+++ b/apps/backups/tests.py
@@ -765,11 +765,12 @@ class BackupSchedulerTestCase(TestCase):
settings = scheduler.get_schedule_settings()
- self.assertEqual(settings['enabled'], False)
+ # These should match the DEFAULTS in scheduler.py
+ self.assertEqual(settings['enabled'], True)
self.assertEqual(settings['frequency'], 'daily')
self.assertEqual(settings['time'], '03:00')
self.assertEqual(settings['day_of_week'], 0)
- self.assertEqual(settings['retention_count'], 0)
+ self.assertEqual(settings['retention_count'], 3)
self.assertEqual(settings['cron_expression'], '')
def test_update_schedule_settings_stores_values(self):
From b01eb9585ccf4120228fe449aa32aa1a4b86b0d6 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 3 Feb 2026 09:24:02 -0600
Subject: [PATCH 110/125] feat: add system notifications and update checks
Real-time notifications for system events and alerts Per-user notification
management and dismissal Update check on startup and every 24 hours to notify
users of available versions Notification center UI component Automatic
cleanup of expired notifications
---
CHANGELOG.md | 6 +
core/api_urls.py | 2 +
core/api_views.py | 157 +++++++
core/apps.py | 49 ++
core/developer_notifications.py | 412 +++++++++++++++++
core/fixtures/developer_notifications.json | 43 ++
...ystemnotification_notificationdismissal.py | 52 +++
core/models.py | 150 ++++++
core/serializers.py | 49 +-
core/signals.py | 34 +-
core/tasks.py | 224 +++++++++
core/utils.py | 73 +++
dispatcharr/settings.py | 5 +
frontend/src/WebSocket.jsx | 53 +++
frontend/src/api.js | 103 +++++
.../src/components/NotificationCenter.jsx | 429 ++++++++++++++++++
frontend/src/components/Sidebar.jsx | 55 ++-
frontend/src/pages/Settings.jsx | 82 ++--
frontend/src/store/notifications.jsx | 111 +++++
pyproject.toml | 1 +
20 files changed, 2041 insertions(+), 49 deletions(-)
create mode 100644 core/developer_notifications.py
create mode 100644 core/fixtures/developer_notifications.json
create mode 100644 core/migrations/0021_systemnotification_notificationdismissal.py
create mode 100644 frontend/src/components/NotificationCenter.jsx
create mode 100644 frontend/src/store/notifications.jsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3966174a..8b3f4f3c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Add system notifications and update checks
+ -Real-time notifications for system events and alerts
+ -Per-user notification management and dismissal
+ -Update check on startup and every 24 hours to notify users of available versions
+ -Notification center UI component
+ -Automatic cleanup of expired notifications
- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click.
- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view.
- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen)
diff --git a/core/api_urls.py b/core/api_urls.py
index 75257db1..1ac58455 100644
--- a/core/api_urls.py
+++ b/core/api_urls.py
@@ -6,6 +6,7 @@ from .api_views import (
UserAgentViewSet,
StreamProfileViewSet,
CoreSettingsViewSet,
+ SystemNotificationViewSet,
environment,
version,
rehash_streams_endpoint,
@@ -17,6 +18,7 @@ router = DefaultRouter()
router.register(r'useragents', UserAgentViewSet, basename='useragent')
router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile')
router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
+router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification')
urlpatterns = [
path('settings/env/', environment, name='token_refresh'),
path('version/', version, name='version'),
diff --git a/core/api_views.py b/core/api_views.py
index 44001f95..20ef6249 100644
--- a/core/api_views.py
+++ b/core/api_views.py
@@ -3,6 +3,7 @@
import json
import ipaddress
import logging
+from django.db import models
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -466,3 +467,159 @@ def get_system_events(request):
return Response({
'error': 'Failed to fetch system events'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+# ─────────────────────────────
+# System Notifications API
+# ─────────────────────────────
+from .models import SystemNotification, NotificationDismissal
+from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer
+from django.utils import timezone as dj_timezone
+
+
+class SystemNotificationViewSet(viewsets.ModelViewSet):
+ """
+ API endpoint for system notifications.
+ Users can view active notifications and dismiss them.
+ Admins can create and manage notifications.
+ """
+ serializer_class = SystemNotificationSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ """
+ Return notifications based on user permissions.
+ Filter out expired and dismissed notifications for regular users.
+ Evaluate conditions for developer notifications.
+ """
+ from core.developer_notifications import evaluate_conditions
+ from django.core.cache import cache
+
+ user = self.request.user
+ now = dj_timezone.now()
+
+ queryset = SystemNotification.objects.filter(is_active=True)
+
+ # Filter out expired notifications
+ queryset = queryset.filter(
+ models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
+ )
+
+ # Filter admin-only notifications for non-admins
+ if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10:
+ queryset = queryset.filter(admin_only=False)
+
+ # For developer notifications, evaluate conditions
+ # Cache the evaluation per notification to avoid repeated condition checks
+ notifications_to_exclude = []
+ developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER)
+
+ for notification in developer_notifications:
+ action_data = notification.action_data or {}
+ conditions = action_data.get('condition', [])
+
+ if not conditions:
+ continue
+
+ # Cache key based on notification ID and current settings
+ # Cache for 5 minutes to balance freshness with performance
+ cache_key = f'dev_notif_condition_{notification.id}_{user.id}'
+ should_show = cache.get(cache_key)
+
+ if should_show is None:
+ should_show = evaluate_conditions(conditions, user)
+ cache.set(cache_key, should_show, timeout=300) # 5 minutes
+
+ if not should_show:
+ notifications_to_exclude.append(notification.id)
+
+ if notifications_to_exclude:
+ queryset = queryset.exclude(id__in=notifications_to_exclude)
+
+ return queryset
+
+ def get_serializer_context(self):
+ context = super().get_serializer_context()
+ context['request'] = self.request
+ return context
+
+ def list(self, request):
+ """
+ List all active notifications for the current user.
+ Optionally filter by dismissed status.
+ """
+ queryset = self.get_queryset()
+
+ # Optional: filter out already dismissed notifications
+ include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true'
+ if not include_dismissed:
+ dismissed_ids = NotificationDismissal.objects.filter(
+ user=request.user
+ ).values_list('notification_id', flat=True)
+ queryset = queryset.exclude(id__in=dismissed_ids)
+
+ serializer = self.get_serializer(queryset, many=True)
+ return Response({
+ 'notifications': serializer.data,
+ 'count': len(serializer.data),
+ 'unread_count': queryset.count()
+ })
+
+ @action(detail=True, methods=['post'], url_path='dismiss')
+ def dismiss(self, request, pk=None):
+ """Dismiss a notification for the current user."""
+ notification = self.get_object()
+ action_taken = request.data.get('action_taken', None)
+
+ dismissal, created = NotificationDismissal.objects.get_or_create(
+ user=request.user,
+ notification=notification,
+ defaults={'action_taken': action_taken}
+ )
+
+ if not created and action_taken:
+ dismissal.action_taken = action_taken
+ dismissal.save()
+
+ return Response({
+ 'success': True,
+ 'message': 'Notification dismissed',
+ 'notification_key': notification.notification_key
+ })
+
+ @action(detail=False, methods=['post'], url_path='dismiss-all')
+ def dismiss_all(self, request):
+ """Dismiss all notifications for the current user."""
+ notifications = self.get_queryset()
+
+ # Get notifications not yet dismissed
+ dismissed_ids = NotificationDismissal.objects.filter(
+ user=request.user
+ ).values_list('notification_id', flat=True)
+ to_dismiss = notifications.exclude(id__in=dismissed_ids)
+
+ # Create dismissals for all
+ dismissals = [
+ NotificationDismissal(user=request.user, notification=n)
+ for n in to_dismiss
+ ]
+ NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True)
+
+ return Response({
+ 'success': True,
+ 'dismissed_count': len(dismissals)
+ })
+
+ @action(detail=False, methods=['get'], url_path='count')
+ def unread_count(self, request):
+ """Get count of unread notifications."""
+ queryset = self.get_queryset()
+ dismissed_ids = NotificationDismissal.objects.filter(
+ user=request.user
+ ).values_list('notification_id', flat=True)
+ unread_count = queryset.exclude(id__in=dismissed_ids).count()
+
+ return Response({
+ 'unread_count': unread_count
+ })
+
diff --git a/core/apps.py b/core/apps.py
index 96ccfb3b..12f6d054 100644
--- a/core/apps.py
+++ b/core/apps.py
@@ -22,3 +22,52 @@ class CoreConfig(AppConfig):
def ready(self):
# Import signals to ensure they get registered
import core.signals
+
+ # Sync developer notifications and check for version updates on startup
+ # Only run in the main process (not in management commands or migrations)
+ import sys
+ if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False:
+ self._sync_developer_notifications()
+ self._check_version_update()
+
+ def _sync_developer_notifications(self):
+ """Sync developer notifications from JSON file to database."""
+ from django.db import connection
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ # Check if tables exist (avoid running during migrations)
+ try:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "SELECT 1 FROM information_schema.tables WHERE table_name = 'core_systemnotification'"
+ )
+ if not cursor.fetchone():
+ # For SQLite
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='core_systemnotification'"
+ )
+ if not cursor.fetchone():
+ return
+ except Exception:
+ # If we can't check, the table might not exist yet
+ pass
+
+ try:
+ from core.developer_notifications import sync_developer_notifications
+ sync_developer_notifications()
+ except Exception as e:
+ logger.warning(f"Failed to sync developer notifications on startup: {e}")
+
+ def _check_version_update(self):
+ """Check for version updates on startup."""
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ try:
+ from core.tasks import check_for_version_update
+ check_for_version_update.delay()
+ except Exception as e:
+ logger.warning(f"Failed to check for version updates on startup: {e}")
diff --git a/core/developer_notifications.py b/core/developer_notifications.py
new file mode 100644
index 00000000..cf16e7a2
--- /dev/null
+++ b/core/developer_notifications.py
@@ -0,0 +1,412 @@
+"""
+Developer Notification Sync Service
+
+Handles syncing developer-defined notifications from the JSON file to the database.
+This ensures users receive important notifications from the development team
+about recommended settings, security updates, and other announcements.
+
+JSON Schema (see fixtures/developer_notifications.json):
+ {
+ "id": str, # REQUIRED - Unique identifier (notification_key)
+ "title": str, # REQUIRED - Notification heading
+ "message": str, # REQUIRED - Notification body text
+
+ "notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info')
+ "priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal')
+ "min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null)
+ "max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null)
+ "created_at": str, # OPTIONAL - ISO timestamp for tracking
+ "expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null)
+ "condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: [])
+ "user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all')
+ "action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access")
+ "action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set)
+ }
+
+Condition Checks:
+ Conditions are function names from CONDITION_CHECKS registry that evaluate
+ whether a notification should be shown. All conditions must pass (AND logic).
+
+ Available conditions:
+ - 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere
+
+ To add new conditions:
+ 1. Define a function: check_your_condition(user) -> bool
+ 2. Add to CONDITION_CHECKS registry
+ 3. Reference in JSON: "condition": ["your_condition"]
+
+Sync Behavior:
+ - Runs on startup (see apps.py)
+ - Runs when relevant settings change (see signals.py)
+ - Adds new notifications if in version range and not expired
+ - Updates existing notifications with latest data
+ - Removes notifications that are:
+ * No longer in JSON file
+ * Out of current version range
+ * Past expiration date
+ - Sends websocket event to refresh frontend
+ - Cache invalidated when triggering settings change
+"""
+import json
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Callable
+
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+from packaging import version
+
+from version import __version__
+
+logger = logging.getLogger(__name__)
+
+# Path to developer notifications JSON file
+NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json'
+
+
+# ─────────────────────────────
+# Condition Checks
+# ─────────────────────────────
+# Each condition function receives (user) and returns True if the notification should show
+
+def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool:
+ """
+ Check if network access settings for a specific endpoint are insecure (allow all).
+
+ Args:
+ user: The user object (unused but required for condition check signature)
+ endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API')
+
+ Returns:
+ True if the notification should show (insecure settings detected)
+ """
+ from core.models import CoreSettings, NETWORK_ACCESS_KEY
+
+ try:
+ network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {})
+
+ # Empty settings are secure (defaults to local network only)
+ if not network_settings:
+ return False
+
+ # Get the specific endpoint's allowed networks (stored as comma-separated string)
+ allowed_networks_str = network_settings.get(endpoint, '')
+ if not allowed_networks_str:
+ return False
+
+ # Parse comma-separated network addresses
+ allowed_networks = [net.strip() for net in allowed_networks_str.split(',')]
+
+ # Check if settings allow access from anywhere (insecure)
+ if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks:
+ return True
+
+ return False
+ except Exception as e:
+ logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}")
+ return False
+
+
+# Registry of all available condition checks
+CONDITION_CHECKS: dict[str, Callable] = {
+ 'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'),
+ # Add more conditions here as needed
+ # 'transcode_not_configured': check_transcode_not_configured,
+ # 'no_backup_configured': check_no_backup_configured,
+}
+
+
+# ─────────────────────────────
+# Version Utilities
+# ─────────────────────────────
+
+def parse_version(version_str: str | None) -> version.Version | None:
+ """Parse a version string, returning None if invalid or empty."""
+ if not version_str:
+ return None
+ try:
+ return version.parse(version_str)
+ except Exception:
+ return None
+
+
+def is_version_in_range(
+ current_version: str,
+ min_version: str | None,
+ max_version: str | None
+) -> bool:
+ """Check if current version is within the specified range."""
+ current = parse_version(current_version)
+ if not current:
+ return True # If we can't parse version, show notification
+
+ min_ver = parse_version(min_version)
+ max_ver = parse_version(max_version)
+
+ if min_ver and current < min_ver:
+ return False
+ if max_ver and current > max_ver:
+ return False
+
+ return True
+
+
+# ─────────────────────────────
+# Notification Evaluation
+# ─────────────────────────────
+
+def evaluate_conditions(conditions: list[str] | str | None, user) -> bool:
+ """
+ Evaluate notification conditions for a user.
+ All conditions must pass (AND logic).
+ """
+ if not conditions:
+ return True
+
+ # Normalize to list
+ if isinstance(conditions, str):
+ conditions = [conditions]
+
+ for condition in conditions:
+ if condition not in CONDITION_CHECKS:
+ logger.warning(f"Unknown condition: {condition}")
+ continue
+
+ try:
+ if not CONDITION_CHECKS[condition](user):
+ return False
+ except Exception as e:
+ logger.error(f"Error evaluating condition {condition}: {e}")
+ # On error, skip this condition (fail open)
+ continue
+
+ return True
+
+
+def should_show_notification(notification_data: dict, user) -> bool:
+ """
+ Determine if a notification should be shown to a specific user.
+ Checks version range, user level, and conditions.
+ """
+ # Check version range
+ if not is_version_in_range(
+ __version__,
+ notification_data.get('min_version'),
+ notification_data.get('max_version')
+ ):
+ return False
+
+ # Check user level
+ user_level = notification_data.get('user_level', 'all')
+ if user_level == 'admin' and not getattr(user, 'is_superuser', False):
+ return False
+
+ # Check conditions
+ conditions = notification_data.get('condition', [])
+ if not evaluate_conditions(conditions, user):
+ return False
+
+ return True
+
+
+# ─────────────────────────────
+# Sync Service
+# ─────────────────────────────
+
+def load_developer_notifications() -> list[dict]:
+ """Load notifications from the JSON file."""
+ if not NOTIFICATIONS_FILE.exists():
+ logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}")
+ return []
+
+ try:
+ with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ return data.get('notifications', [])
+ except json.JSONDecodeError as e:
+ logger.error(f"Error parsing developer notifications JSON: {e}")
+ return []
+ except Exception as e:
+ logger.error(f"Error loading developer notifications: {e}")
+ return []
+
+
+def sync_developer_notifications() -> dict[str, int]:
+ """
+ Sync developer notifications from JSON file to database.
+
+ - Adds new notifications that don't exist in the DB
+ - Removes DB notifications that are no longer in the JSON file
+ - Updates existing notifications if they've changed
+
+ Returns a dict with counts of added, updated, and removed notifications.
+ """
+ from core.models import SystemNotification
+
+ results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0}
+
+ notifications = load_developer_notifications()
+ json_notification_keys = set()
+ notifications_to_remove = set() # Track notifications to remove (out of range or expired)
+
+ for notif_data in notifications:
+ notification_id = notif_data.get('id')
+ if not notification_id:
+ logger.warning("Notification missing 'id' field, skipping")
+ results['skipped'] += 1
+ continue
+
+ json_notification_keys.add(notification_id)
+
+ # Check version constraints (only add if current version is in range)
+ if not is_version_in_range(
+ __version__,
+ notif_data.get('min_version'),
+ notif_data.get('max_version')
+ ):
+ logger.debug(f"Notification {notification_id} not in version range, marking for removal")
+ results['skipped'] += 1
+ notifications_to_remove.add(notification_id)
+ continue
+
+ # Parse expires_at if provided
+ expires_at = None
+ if notif_data.get('expires_at'):
+ try:
+ expires_at = datetime.fromisoformat(
+ notif_data['expires_at'].replace('Z', '+00:00')
+ )
+ # Skip if already expired and mark for removal
+ if expires_at < timezone.now():
+ logger.debug(f"Notification {notification_id} has expired, marking for removal")
+ results['skipped'] += 1
+ notifications_to_remove.add(notification_id)
+ continue
+ except (ValueError, TypeError) as e:
+ logger.warning(f"Invalid expires_at for {notification_id}: {e}")
+
+ # Map notification_type from JSON to model choices
+ type_mapping = {
+ 'version_update': SystemNotification.NotificationType.VERSION_UPDATE,
+ 'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION,
+ 'announcement': SystemNotification.NotificationType.ANNOUNCEMENT,
+ 'warning': SystemNotification.NotificationType.WARNING,
+ 'info': SystemNotification.NotificationType.INFO,
+ }
+ notification_type = type_mapping.get(
+ notif_data.get('notification_type', 'info'),
+ SystemNotification.NotificationType.INFO
+ )
+
+ # Map priority
+ priority_mapping = {
+ 'low': SystemNotification.Priority.LOW,
+ 'normal': SystemNotification.Priority.NORMAL,
+ 'high': SystemNotification.Priority.HIGH,
+ 'critical': SystemNotification.Priority.CRITICAL,
+ }
+ priority = priority_mapping.get(
+ notif_data.get('priority', 'normal'),
+ SystemNotification.Priority.NORMAL
+ )
+
+ # Prepare action_data
+ action_data = {
+ 'action_url': notif_data.get('action_url'),
+ 'action_text': notif_data.get('action_text'),
+ 'condition': notif_data.get('condition', []),
+ 'min_version': notif_data.get('min_version'),
+ 'max_version': notif_data.get('max_version'),
+ 'user_level': notif_data.get('user_level', 'all'),
+ }
+
+ # Determine if admin-only based on user_level
+ admin_only = notif_data.get('user_level', 'all') == 'admin'
+
+ # Create or update the notification
+ notification, created = SystemNotification.objects.update_or_create(
+ notification_key=notification_id,
+ defaults={
+ 'notification_type': notification_type,
+ 'priority': priority,
+ 'source': SystemNotification.Source.DEVELOPER,
+ 'title': notif_data.get('title', 'Notification'),
+ 'message': notif_data.get('message', ''),
+ 'action_data': action_data,
+ 'is_active': True,
+ 'admin_only': admin_only,
+ 'expires_at': expires_at,
+ }
+ )
+
+ if created:
+ logger.info(f"Added developer notification: {notification_id}")
+ results['added'] += 1
+ else:
+ logger.debug(f"Updated developer notification: {notification_id}")
+ results['updated'] += 1
+
+ # Remove developer notifications that are:
+ # - No longer in the JSON file, OR
+ # - Out of version range for the current version, OR
+ # - Expired
+ removed_count, _ = SystemNotification.objects.filter(
+ source=SystemNotification.Source.DEVELOPER
+ ).filter(
+ models.Q(notification_key__in=notifications_to_remove) |
+ ~models.Q(notification_key__in=json_notification_keys)
+ ).delete()
+
+ if removed_count:
+ logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)")
+ results['removed'] = removed_count
+
+ logger.info(
+ f"Developer notification sync complete: "
+ f"{results['added']} added, {results['updated']} updated, "
+ f"{results['removed']} removed, {results['skipped']} skipped"
+ )
+
+ # Send websocket notification to frontend to refresh notifications
+ try:
+ from core.utils import send_websocket_update
+ send_websocket_update('updates', 'update', {
+ 'type': 'notifications_cleared',
+ })
+ logger.debug("Sent websocket notification for notifications refresh")
+ except Exception as e:
+ logger.warning(f"Failed to send websocket update: {e}")
+
+ return results
+
+
+def get_user_developer_notifications(user) -> list:
+ """
+ Get all developer notifications that should be shown to a specific user.
+ Evaluates conditions and user_level for each notification.
+ """
+ from core.models import SystemNotification
+
+ # Get all active developer notifications
+ notifications = SystemNotification.objects.filter(
+ source=SystemNotification.Source.DEVELOPER,
+ is_active=True
+ )
+
+ # Filter by admin_only based on user
+ if not getattr(user, 'is_superuser', False):
+ notifications = notifications.filter(admin_only=False)
+
+ # Filter by conditions
+ result = []
+ for notification in notifications:
+ action_data = notification.action_data or {}
+
+ # Evaluate conditions
+ conditions = action_data.get('condition', [])
+ if evaluate_conditions(conditions, user):
+ result.append(notification)
+
+ return result
diff --git a/core/fixtures/developer_notifications.json b/core/fixtures/developer_notifications.json
new file mode 100644
index 00000000..0040682e
--- /dev/null
+++ b/core/fixtures/developer_notifications.json
@@ -0,0 +1,43 @@
+{
+ "_schema_documentation": {
+ "description": "Developer notification definitions. Each notification is evaluated at sync time.",
+ "fields": {
+ "id": "[REQUIRED] Unique identifier (notification_key in database).",
+ "notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'",
+ "priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'",
+ "title": "[REQUIRED] Notification title/heading.",
+ "message": "[REQUIRED] Detailed notification message body.",
+ "min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'",
+ "max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'",
+ "created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.",
+ "expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'",
+ "condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']",
+ "user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'",
+ "action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'",
+ "action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'"
+ },
+ "notes": [
+ "Notifications are synced from this file to database on startup and when relevant settings change",
+ "Out-of-range versions are automatically removed from database",
+ "Expired notifications are automatically removed from database",
+ "Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)"
+ ]
+ },
+ "notifications": [
+ {
+ "id": "network_security_m3u_epg",
+ "notification_type": "warning",
+ "priority": "high",
+ "title": "Network Access Security Warning",
+ "message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.",
+ "min_version": null,
+ "max_version": null,
+ "created_at": "2026-02-02T00:00:00Z",
+ "expires_at": null,
+ "condition": ["m3u_epg_network_insecure"],
+ "user_level": "admin",
+ "action_url": "/settings#network-access",
+ "action_text": "Review Settings"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/core/migrations/0021_systemnotification_notificationdismissal.py b/core/migrations/0021_systemnotification_notificationdismissal.py
new file mode 100644
index 00000000..66eb9b18
--- /dev/null
+++ b/core/migrations/0021_systemnotification_notificationdismissal.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.2.9 on 2026-02-02 20:38
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0020_change_coresettings_value_to_jsonfield'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SystemNotification',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('notification_key', models.CharField(db_index=True, max_length=255, unique=True)),
+ ('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)),
+ ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)),
+ ('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)),
+ ('title', models.CharField(max_length=255)),
+ ('message', models.TextField()),
+ ('action_data', models.JSONField(blank=True, default=dict)),
+ ('is_active', models.BooleanField(db_index=True, default=True)),
+ ('admin_only', models.BooleanField(default=False)),
+ ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'ordering': ['-priority', '-created_at'],
+ 'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')],
+ },
+ ),
+ migrations.CreateModel(
+ name='NotificationDismissal',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('dismissed_at', models.DateTimeField(auto_now_add=True)),
+ ('action_taken', models.CharField(blank=True, max_length=50, null=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)),
+ ('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')),
+ ],
+ options={
+ 'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')],
+ 'unique_together': {('user', 'notification')},
+ },
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 683acb0d..a694a76b 100644
--- a/core/models.py
+++ b/core/models.py
@@ -370,3 +370,153 @@ class SystemEvent(models.Model):
def __str__(self):
return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}"
+
+
+class SystemNotification(models.Model):
+ """
+ Stores system notifications that users can view and dismiss.
+ Used for version updates, recommended settings, announcements, etc.
+ """
+ class NotificationType(models.TextChoices):
+ VERSION_UPDATE = 'version_update', 'Version Update Available'
+ SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change'
+ ANNOUNCEMENT = 'announcement', 'System Announcement'
+ WARNING = 'warning', 'Warning'
+ INFO = 'info', 'Information'
+
+ class Priority(models.TextChoices):
+ LOW = 'low', 'Low'
+ NORMAL = 'normal', 'Normal'
+ HIGH = 'high', 'High'
+ CRITICAL = 'critical', 'Critical'
+
+ class Source(models.TextChoices):
+ SYSTEM = 'system', 'System Generated'
+ DEVELOPER = 'developer', 'Developer Notification'
+
+ # Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer')
+ # This allows deduplication and targeted dismissals
+ notification_key = models.CharField(max_length=255, unique=True, db_index=True)
+
+ notification_type = models.CharField(
+ max_length=50,
+ choices=NotificationType.choices,
+ default=NotificationType.INFO,
+ db_index=True
+ )
+ priority = models.CharField(
+ max_length=20,
+ choices=Priority.choices,
+ default=Priority.NORMAL
+ )
+
+ # Source of the notification (system-generated vs developer-defined)
+ source = models.CharField(
+ max_length=20,
+ choices=Source.choices,
+ default=Source.SYSTEM,
+ db_index=True
+ )
+
+ title = models.CharField(max_length=255)
+ message = models.TextField()
+
+ # Optional action data (e.g., setting key/value for recommendations, release URL for versions)
+ action_data = models.JSONField(default=dict, blank=True)
+
+ # Whether this notification is currently active
+ is_active = models.BooleanField(default=True, db_index=True)
+
+ # Admin-only notifications require admin privileges to view
+ admin_only = models.BooleanField(default=False)
+
+ # Auto-expire after this date (null = never expires)
+ expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['-priority', '-created_at']
+ indexes = [
+ models.Index(fields=['is_active', '-created_at']),
+ models.Index(fields=['notification_type', 'is_active']),
+ models.Index(fields=['source', 'is_active']),
+ ]
+
+ def __str__(self):
+ return f"[{self.notification_type}] {self.title}"
+
+ @classmethod
+ def create_version_notification(cls, version, release_url=None, release_notes=None):
+ """Create or update a version update notification. Returns (notification, created) tuple."""
+ key = f"version-{version}"
+ notification, created = cls.objects.update_or_create(
+ notification_key=key,
+ defaults={
+ 'notification_type': cls.NotificationType.VERSION_UPDATE,
+ 'priority': cls.Priority.HIGH,
+ 'title': f'Version {version} Available',
+ 'message': f'A new version of Dispatcharr ({version}) is available.',
+ 'action_data': {
+ 'version': version,
+ 'release_url': release_url,
+ 'release_notes': release_notes,
+ },
+ 'is_active': True,
+ 'admin_only': True,
+ }
+ )
+ return notification, created
+
+ @classmethod
+ def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None):
+ """Create a setting recommendation notification. Returns (notification, created) tuple."""
+ key = f"setting-{setting_key}"
+ notification, created = cls.objects.update_or_create(
+ notification_key=key,
+ defaults={
+ 'notification_type': cls.NotificationType.SETTING_RECOMMENDATION,
+ 'priority': cls.Priority.NORMAL,
+ 'title': f'Recommended Setting: {setting_key}',
+ 'message': reason,
+ 'action_data': {
+ 'setting_key': setting_key,
+ 'recommended_value': recommended_value,
+ 'current_value': current_value,
+ },
+ 'is_active': True,
+ 'admin_only': True,
+ }
+ )
+ return notification, created
+
+
+class NotificationDismissal(models.Model):
+ """
+ Tracks which users have dismissed which notifications.
+ Allows users to dismiss notifications once without seeing them again.
+ """
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name='dismissed_notifications'
+ )
+ notification = models.ForeignKey(
+ SystemNotification,
+ on_delete=models.CASCADE,
+ related_name='dismissals'
+ )
+ dismissed_at = models.DateTimeField(auto_now_add=True)
+
+ # Optional: track if user accepted/applied the recommendation
+ action_taken = models.CharField(max_length=50, blank=True, null=True)
+
+ class Meta:
+ unique_together = ['user', 'notification']
+ indexes = [
+ models.Index(fields=['user', 'notification']),
+ ]
+
+ def __str__(self):
+ return f"{self.user.username} dismissed {self.notification.notification_key}"
diff --git a/core/serializers.py b/core/serializers.py
index b2bd8ecc..03ba33e8 100644
--- a/core/serializers.py
+++ b/core/serializers.py
@@ -64,7 +64,12 @@ class CoreSettingsSerializer(serializers.ModelSerializer):
}
)
- return super().update(instance, validated_data)
+ result = super().update(instance, validated_data)
+
+ # Note: Cache invalidation and notification sync is handled by post_save signal
+ # in core/signals.py to ensure it happens even if settings are updated elsewhere
+
+ return result
class ProxySettingsSerializer(serializers.Serializer):
"""Serializer for proxy settings stored as JSON in CoreSettings"""
@@ -98,3 +103,45 @@ class ProxySettingsSerializer(serializers.Serializer):
if value < 0 or value > 60:
raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds")
return value
+
+
+class SystemNotificationSerializer(serializers.ModelSerializer):
+ """Serializer for system notifications."""
+ is_dismissed = serializers.SerializerMethodField()
+
+ class Meta:
+ from .models import SystemNotification
+ model = SystemNotification
+ fields = [
+ 'id',
+ 'notification_key',
+ 'notification_type',
+ 'priority',
+ 'title',
+ 'message',
+ 'action_data',
+ 'is_active',
+ 'admin_only',
+ 'expires_at',
+ 'created_at',
+ 'is_dismissed',
+ 'source',
+ ]
+ read_only_fields = ['created_at']
+
+ def get_is_dismissed(self, obj):
+ """Check if the current user has dismissed this notification."""
+ request = self.context.get('request')
+ if request and request.user.is_authenticated:
+ return obj.dismissals.filter(user=request.user).exists()
+ return False
+
+
+class NotificationDismissalSerializer(serializers.ModelSerializer):
+ """Serializer for notification dismissals."""
+
+ class Meta:
+ from .models import NotificationDismissal
+ model = NotificationDismissal
+ fields = ['id', 'notification', 'dismissed_at', 'action_taken']
+ read_only_fields = ['dismissed_at']
diff --git a/core/signals.py b/core/signals.py
index 6844a890..bbb73dd9 100644
--- a/core/signals.py
+++ b/core/signals.py
@@ -1,9 +1,39 @@
-from django.db.models.signals import pre_delete
+from django.db.models.signals import pre_delete, post_save
from django.dispatch import receiver
from django.core.exceptions import ValidationError
-from .models import StreamProfile
+from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY
@receiver(pre_delete, sender=StreamProfile)
def prevent_deletion_if_locked(sender, instance, **kwargs):
if instance.locked:
raise ValidationError("This profile is locked and cannot be deleted.")
+
+@receiver(post_save, sender=CoreSettings)
+def handle_network_access_update(sender, instance, **kwargs):
+ """Invalidate cache and sync notifications when network access settings change."""
+ if instance.key == NETWORK_ACCESS_KEY:
+ from django.core.cache import cache
+ from core.developer_notifications import sync_developer_notifications
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ # Invalidate all notification condition caches
+ try:
+ cache.delete_pattern('dev_notif_condition_*')
+ logger.info("Invalidated notification condition cache due to network access settings update")
+ except Exception as e:
+ logger.warning(f"Failed to delete cache pattern: {e}")
+ # Fallback: try to clear entire cache (if delete_pattern not supported)
+ try:
+ cache.clear()
+ except Exception:
+ pass
+
+ # Re-sync developer notifications to re-evaluate conditions
+ # (websocket notification is sent by sync_developer_notifications)
+ try:
+ sync_developer_notifications()
+ logger.info("Re-synced developer notifications after network access settings update")
+ except Exception as e:
+ logger.error(f"Failed to sync developer notifications: {e}")
diff --git a/core/tasks.py b/core/tasks.py
index 78a3e0ec..44ddc68a 100644
--- a/core/tasks.py
+++ b/core/tasks.py
@@ -765,3 +765,227 @@ def cleanup_vod_persistent_connections():
except Exception as e:
logger.error(f"Error during VOD persistent connection cleanup: {e}")
+
+
+@shared_task
+def check_for_version_update():
+ """
+ Check for new Dispatcharr versions on GitHub and create a notification if available.
+ This task should be run periodically (e.g., daily) via Celery Beat.
+
+ For dev builds (identified by __timestamp__), checks for stable releases only.
+ For production builds, checks for stable releases.
+
+ Note: Dev builds are container images from the dev branch and don't have GitHub releases.
+ This checks if a stable release is available so dev users know when to upgrade.
+ """
+ import requests
+ from datetime import datetime, timezone
+ from packaging import version as pkg_version
+ from version import __version__, __timestamp__
+ from core.models import SystemNotification
+ from core.utils import send_websocket_notification
+
+ try:
+ is_dev_build = __timestamp__ is not None
+ DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'}
+ if is_dev_build:
+ # Check Docker Hub for newer dev builds
+ docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev"
+
+ response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10)
+
+ if response.status_code != 200:
+ logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}")
+ return
+
+ dev_tag_data = response.json()
+ docker_last_updated = dev_tag_data.get("last_updated")
+
+ if not docker_last_updated:
+ logger.warning("No last_updated timestamp found in Docker Hub response")
+ return
+
+ # Parse timestamps for comparison
+ local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
+ docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00'))
+
+ # Calculate difference in minutes
+ diff_minutes = (docker_dt - local_dt).total_seconds() / 60
+
+ # Threshold to account for build/push time differences
+ THRESHOLD_MINUTES = 10
+
+ if diff_minutes > THRESHOLD_MINUTES:
+ logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)")
+
+ # Delete any old version update notifications (both dev and stable, in case user switched)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).delete()[0]
+ if deleted_count > 0:
+ logger.debug(f"Deleted {deleted_count} old dev build notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+
+ # Create notification for new dev build
+ notification, created = SystemNotification.objects.get_or_create(
+ notification_key=f'version-dev-{docker_last_updated}',
+ defaults={
+ 'notification_type': 'version_update',
+ 'title': 'New Dev Build Available',
+ 'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)',
+ 'priority': 'medium',
+ 'action_data': {
+ 'current_version': __version__,
+ 'current_timestamp': __timestamp__,
+ 'docker_updated': docker_last_updated,
+ 'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags'
+ },
+ 'is_active': True,
+ 'admin_only': True,
+ }
+ )
+
+ if created:
+ # Only send WebSocket for newly created notifications
+ send_websocket_notification(notification)
+ logger.info(f"New dev build notification created and sent via WebSocket")
+ else:
+ logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})")
+
+ # Delete all version update notifications when up to date (both dev and stable)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).delete()[0]
+
+ if deleted_count > 0:
+ logger.info(f"Deleted {deleted_count} outdated dev build notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+ else:
+ # Production build - check GitHub for stable releases
+ github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest"
+ headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS}
+ response = requests.get(
+ github_api_url,
+ headers=headers,
+ timeout=10
+ )
+
+ if response.status_code != 200:
+ logger.warning(f"Failed to check for updates: HTTP {response.status_code}")
+ return
+
+ release_data = response.json()
+ latest_version = release_data.get("tag_name", "").lstrip("v")
+ release_url = release_data.get("html_url", "")
+
+ if not latest_version:
+ logger.warning("No version tag found in GitHub release")
+ return
+
+ # Compare versions
+ current = pkg_version.parse(__version__)
+ latest = pkg_version.parse(latest_version)
+ if latest > current:
+ logger.info(f"New stable version available: {latest_version} (current: {__version__})")
+
+ # Delete any old version update notifications (superseded by this one)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).exclude(
+ notification_key=f"version-{latest_version}"
+ ).delete()[0]
+ if deleted_count > 0:
+ logger.debug(f"Deleted {deleted_count} old version notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+
+ # Create or update the notification for the new version
+ notification, created = SystemNotification.create_version_notification(
+ version=latest_version,
+ release_url=release_url,
+ )
+
+ if created:
+ # Only send WebSocket for newly created notifications
+ send_websocket_notification(notification)
+ logger.info(f"New version notification created and sent via WebSocket")
+ else:
+ logger.debug(f"Dispatcharr is up to date (v{__version__})")
+
+ # Delete ALL version update notifications when up to date (no longer needed)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).delete()[0]
+
+ if deleted_count > 0:
+ logger.info(f"Deleted {deleted_count} outdated version notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+
+ except requests.RequestException as e:
+ logger.warning(f"Network error checking for updates: {e}")
+ except Exception as e:
+ logger.error(f"Error checking for version updates: {e}")
+
+
+def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None):
+ """
+ Create a setting recommendation notification.
+ This is a helper function that can be called from anywhere in the codebase.
+
+ Args:
+ setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout')
+ recommended_value: The recommended value for the setting
+ reason: Why this setting is recommended
+ current_value: The current value (optional)
+
+ Returns:
+ The created SystemNotification instance
+ """
+ from core.models import SystemNotification
+ from core.utils import send_websocket_notification
+
+ notification, created = SystemNotification.create_setting_recommendation(
+ setting_key=setting_key,
+ recommended_value=recommended_value,
+ reason=reason,
+ current_value=current_value
+ )
+
+ # Only send via WebSocket for newly created notifications
+ if created:
+ send_websocket_notification(notification)
+
+ return notification
+
diff --git a/core/utils.py b/core/utils.py
index e3d6c389..5c5a0045 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -437,3 +437,76 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
except Exception as e:
# Don't let event logging break the main application
logger.error(f"Failed to log system event {event_type}: {e}")
+
+
+def send_websocket_notification(notification):
+ """
+ Send a system notification to all connected WebSocket clients.
+
+ Args:
+ notification: A SystemNotification model instance or dict with notification data
+
+ Example:
+ from core.models import SystemNotification
+ notification = SystemNotification.create_version_notification('0.19.0', 'https://...')
+ send_websocket_notification(notification)
+ """
+ try:
+ channel_layer = get_channel_layer()
+
+ # Convert model instance to dict if needed
+ if hasattr(notification, 'id'):
+ notification_data = {
+ 'id': notification.id,
+ 'notification_key': notification.notification_key,
+ 'notification_type': notification.notification_type,
+ 'priority': notification.priority,
+ 'title': notification.title,
+ 'message': notification.message,
+ 'action_data': notification.action_data,
+ 'is_active': notification.is_active,
+ 'admin_only': notification.admin_only,
+ 'created_at': notification.created_at.isoformat() if notification.created_at else None,
+ }
+ else:
+ notification_data = notification
+
+ async_to_sync(channel_layer.group_send)(
+ 'updates',
+ {
+ 'type': 'update',
+ 'data': {
+ 'type': 'system_notification',
+ 'notification': notification_data,
+ }
+ }
+ )
+ logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}")
+ except Exception as e:
+ logger.error(f"Failed to send WebSocket notification: {e}")
+
+
+def send_notification_dismissed(notification_key):
+ """
+ Notify all connected clients that a notification was dismissed.
+ Useful for syncing dismissal state across multiple browser tabs/sessions.
+
+ Args:
+ notification_key: The unique key of the dismissed notification
+ """
+ try:
+ channel_layer = get_channel_layer()
+
+ async_to_sync(channel_layer.group_send)(
+ 'updates',
+ {
+ 'type': 'update',
+ 'data': {
+ 'type': 'notification_dismissed',
+ 'notification_key': notification_key,
+ }
+ }
+ )
+ except Exception as e:
+ logger.error(f"Failed to send notification dismissed event: {e}")
+
diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index b65ee2d0..7af6e939 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -236,6 +236,11 @@ CELERY_BEAT_SCHEDULE = {
"task": "apps.channels.tasks.maintain_recurring_recordings",
"schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead
},
+ # Check for version updates daily
+ "check-version-updates": {
+ "task": "core.tasks.check_for_version_update",
+ "schedule": 86400.0, # Once every 24 hours
+ },
}
MEDIA_ROOT = BASE_DIR / "media"
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 034418f6..2d6cc0d9 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -845,6 +845,59 @@ export const WebsocketProvider = ({ children }) => {
break;
}
+ case 'system_notification': {
+ // Handle real-time system notifications (version updates, setting recommendations, etc.)
+ const notificationData = parsedEvent.data.notification;
+ if (notificationData) {
+ // Import and update the notifications store
+ const { default: useNotificationsStore } =
+ await import('./store/notifications');
+ useNotificationsStore
+ .getState()
+ .addNotification(notificationData);
+
+ // Show a toast notification for high priority items
+ if (
+ notificationData.priority === 'high' ||
+ notificationData.priority === 'critical'
+ ) {
+ const color =
+ notificationData.notification_type === 'version_update'
+ ? 'green'
+ : notificationData.notification_type === 'warning'
+ ? 'orange'
+ : 'blue';
+
+ notifications.show({
+ title: notificationData.title,
+ message: notificationData.message,
+ color,
+ autoClose: 10000,
+ });
+ }
+ }
+ break;
+ }
+
+ case 'notification_dismissed': {
+ // Handle notification dismissed from another session
+ const { notification_key } = parsedEvent.data;
+ if (notification_key) {
+ const { default: useNotificationsStore } =
+ await import('./store/notifications');
+ useNotificationsStore
+ .getState()
+ .dismissNotification(notification_key);
+ }
+ break;
+ }
+
+ case 'notifications_cleared': {
+ // Handle bulk notification clearing (e.g., when version is updated)
+ API.getNotifications();
+ break;
+ }
+
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`
diff --git a/frontend/src/api.js b/frontend/src/api.js
index a831ad22..676e97d8 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -2890,4 +2890,107 @@ export default class API {
errorNotification('Failed to retrieve system events', e);
}
}
+
+ // ─────────────────────────────
+ // System Notifications
+ // ─────────────────────────────
+
+ /**
+ * Get all active notifications for the current user
+ * @param {boolean} includeDismissed - Whether to include already dismissed notifications
+ */
+ static async getNotifications(includeDismissed = false) {
+ try {
+ const params = new URLSearchParams();
+ if (includeDismissed) {
+ params.append('include_dismissed', 'true');
+ }
+ const response = await request(
+ `${host}/api/core/notifications/?${params.toString()}`
+ );
+
+ // Update the store with fetched notifications
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().setNotifications(response.notifications);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve notifications', e);
+ }
+ }
+
+ // Get unread notification count
+ static async getNotificationCount() {
+ try {
+ const response = await request(`${host}/api/core/notifications/count/`);
+
+ // Update the store with the count
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().setUnreadCount(response.unread_count);
+
+ return response;
+ } catch (e) {
+ // Silent fail for count - not critical
+ console.error('Failed to get notification count:', e);
+ return { unread_count: 0 };
+ }
+ }
+
+ /**
+ * Dismiss a specific notification
+ * @param {number} notificationId - The notification ID to dismiss
+ * @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored')
+ */
+ static async dismissNotification(notificationId, actionTaken = null) {
+ try {
+ const body = {};
+ if (actionTaken) {
+ body.action_taken = actionTaken;
+ }
+
+ const response = await request(
+ `${host}/api/core/notifications/${notificationId}/dismiss/`,
+ {
+ method: 'POST',
+ body,
+ }
+ );
+
+ // Update the store
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().dismissNotification(response.notification_key);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to dismiss notification', e);
+ }
+ }
+
+ // Dismiss all notifications
+ static async dismissAllNotifications() {
+ try {
+ const response = await request(
+ `${host}/api/core/notifications/dismiss-all/`,
+ {
+ method: 'POST',
+ }
+ );
+
+ // Update the store
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().dismissAllNotifications();
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to dismiss all notifications', e);
+ }
+ }
}
diff --git a/frontend/src/components/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx
new file mode 100644
index 00000000..f7da56ad
--- /dev/null
+++ b/frontend/src/components/NotificationCenter.jsx
@@ -0,0 +1,429 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Divider,
+ Group,
+ Indicator,
+ Popover,
+ ScrollArea,
+ Stack,
+ Text,
+ ThemeIcon,
+ Tooltip,
+ useMantineTheme,
+} from '@mantine/core';
+import {
+ Bell,
+ Check,
+ CheckCheck,
+ Download,
+ ExternalLink,
+ Info,
+ Settings,
+ AlertTriangle,
+ Megaphone,
+ X,
+ Eye,
+ EyeOff,
+ ArrowRight,
+} from 'lucide-react';
+import useNotificationsStore from '../store/notifications';
+import API from '../api';
+
+// Get icon for notification type
+const getNotificationIcon = (type) => {
+ switch (type) {
+ case 'version_update':
+ return ;
+ case 'setting_recommendation':
+ return ;
+ case 'announcement':
+ return ;
+ case 'warning':
+ return ;
+ case 'info':
+ default:
+ return ;
+ }
+};
+
+// Get color for notification priority
+const getPriorityColor = (priority) => {
+ switch (priority) {
+ case 'critical':
+ return 'red';
+ case 'high':
+ return 'orange';
+ case 'normal':
+ return 'blue';
+ case 'low':
+ default:
+ return 'gray';
+ }
+};
+
+// Get color for notification type
+const getTypeColor = (type) => {
+ switch (type) {
+ case 'version_update':
+ return 'green';
+ case 'setting_recommendation':
+ return 'blue';
+ case 'announcement':
+ return 'violet';
+ case 'warning':
+ return 'orange';
+ case 'info':
+ default:
+ return 'gray';
+ }
+};
+
+// Individual notification item component
+const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
+ const theme = useMantineTheme();
+ const navigate = useNavigate();
+ const typeColor = getTypeColor(notification.notification_type);
+ const priorityColor = getPriorityColor(notification.priority);
+ const isDismissed = notification.is_dismissed;
+
+ const handleDismiss = (e) => {
+ e.stopPropagation();
+ onDismiss(notification.id, 'dismissed');
+ };
+
+ const handleAction = () => {
+ // Handle action_url from action_data
+ const actionUrl = notification.action_data?.action_url;
+ const releaseUrl = notification.action_data?.release_url;
+
+ if (actionUrl) {
+ // Internal navigation
+ onClose(); // Close the popover
+ navigate(actionUrl);
+ } else if (releaseUrl) {
+ // External link
+ window.open(releaseUrl, '_blank');
+ }
+
+ if (onAction) {
+ onAction(notification);
+ }
+ };
+
+ return (
+
+ {/* Dismiss button for non-setting notifications (only if not already dismissed) */}
+ {notification.notification_type !== 'setting_recommendation' &&
+ !isDismissed && (
+
+
+
+ )}
+
+
+
+ {getNotificationIcon(notification.notification_type)}
+
+
+
+
+ {notification.title}
+
+ {isDismissed && (
+
+ Dismissed
+
+ )}
+ {notification.priority === 'high' ||
+ notification.priority === 'critical' ? (
+
+ {notification.priority}
+
+ ) : null}
+
+
+ {notification.message}
+
+
+ {/* Action buttons for specific notification types */}
+ {notification.notification_type === 'version_update' &&
+ notification.action_data?.release_url && (
+ }
+ onClick={handleAction}
+ >
+ View Release
+
+ )}
+
+ {/* Generic action button for notifications with action_url/action_text */}
+ {notification.action_data?.action_url &&
+ notification.action_data?.action_text && (
+ }
+ onClick={handleAction}
+ >
+ {notification.action_data.action_text}
+
+ )}
+
+ {notification.notification_type === 'setting_recommendation' &&
+ !notification.action_data?.action_url && (
+
+ {
+ onDismiss(notification.id, 'applied');
+ // Navigate to settings or apply the setting
+ if (onAction) onAction(notification);
+ }}
+ >
+ Apply
+
+
+ Ignore
+
+
+ )}
+
+
+
+
+ {new Date(notification.created_at).toLocaleDateString()}
+
+
+ );
+};
+
+// Main notification center component with bell icon and popover
+const NotificationCenter = ({ onSettingAction }) => {
+ const [opened, setOpened] = useState(false);
+ const [showDismissed, setShowDismissed] = useState(false);
+
+ const notifications = useNotificationsStore((s) => s.notifications);
+ const unreadCount = useNotificationsStore((s) => s.unreadCount);
+ const getUnreadNotifications = useNotificationsStore(
+ (s) => s.getUnreadNotifications
+ );
+
+ // Fetch notifications on mount and periodically
+ const fetchNotifications = useCallback(async () => {
+ try {
+ await API.getNotifications(showDismissed);
+ } catch (error) {
+ console.error('Failed to fetch notifications:', error);
+ }
+ }, [showDismissed]);
+
+ useEffect(() => {
+ fetchNotifications();
+
+ // Refresh notifications every 5 minutes
+ const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
+ return () => clearInterval(interval);
+ }, [fetchNotifications]);
+
+ const handleDismiss = async (notificationId, actionTaken = null) => {
+ try {
+ await API.dismissNotification(notificationId, actionTaken);
+ } catch (error) {
+ console.error('Failed to dismiss notification:', error);
+ }
+ };
+
+ const handleDismissAll = async () => {
+ try {
+ await API.dismissAllNotifications();
+ } catch (error) {
+ console.error('Failed to dismiss all notifications:', error);
+ }
+ };
+
+ const handleAction = (notification) => {
+ if (
+ notification.notification_type === 'setting_recommendation' &&
+ onSettingAction
+ ) {
+ onSettingAction(notification);
+ }
+ };
+
+ const unreadNotifications = getUnreadNotifications();
+ const displayedNotifications = showDismissed
+ ? notifications
+ : unreadNotifications;
+
+ return (
+
+
+ 9 ? '9+' : unreadCount}
+ disabled={unreadCount === 0}
+ offset={4}
+ processing={unreadCount > 0}
+ >
+ setOpened((o) => !o)}
+ aria-label="Notifications"
+ >
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+ Notifications
+
+ {unreadCount > 0 && (
+
+ {unreadCount} new
+
+ )}
+
+
+
+ setShowDismissed((prev) => !prev)}
+ >
+ {showDismissed ? : }
+
+
+ {unreadCount > 0 && (
+
+
+
+
+
+ )}
+
+
+
+
+
+ {/* Notification list */}
+
+ {displayedNotifications.length === 0 ? (
+
+
+
+
+
+ {showDismissed
+ ? 'No dismissed notifications'
+ : 'All caught up!'}
+
+
+ {showDismissed
+ ? 'Dismissed notifications appear here'
+ : 'No new notifications'}
+
+
+ ) : (
+
+ {displayedNotifications.map((notification) => (
+ setOpened(false)}
+ />
+ ))}
+
+ )}
+
+
+ {/* Footer with info text */}
+ {!showDismissed &&
+ notifications.length > unreadNotifications.length && (
+ <>
+
+
+
+ {notifications.length - unreadNotifications.length} dismissed
+ notification
+ {notifications.length - unreadNotifications.length !== 1
+ ? 's'
+ : ''}
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default NotificationCenter;
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 435509e0..6c997cfe 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -36,6 +36,7 @@ import useSettingsStore from '../store/settings';
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
+import NotificationCenter from './NotificationCenter';
const NavLink = ({ item, isActive, collapsed }) => {
return (
@@ -232,7 +233,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}}
>
{isAuthenticated && (
-
+
{!collapsed && (
{
/>
)}
-
{!collapsed && authUser && (
- setUserFormOpen(true)}>
- {authUser.first_name || authUser.username}
-
-
+
+
+ setUserFormOpen(true)}>
+ {authUser.first_name || authUser.username}
+
+
)}
-
+ {collapsed && (
+
+
+
+ )}
+
)}
- {/* Version is always shown when sidebar is expanded, regardless of auth status */}
+ {/* Version and Notification */}
{!collapsed && (
-
- v{appVersion?.version || '0.0.0'}
- {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
-
+
+
+ v{appVersion?.version || '0.0.0'}
+ {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
+
+ {isAuthenticated && }
+
+ )}
+ {collapsed && isAuthenticated && (
+
+
+
)}
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 4ce519a3..a2ee0545 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,4 +1,5 @@
-import React, { Suspense, useState } from 'react';
+import React, { Suspense, useState, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
import {
Accordion,
AccordionControl,
@@ -7,48 +8,64 @@ import {
Box,
Center,
Text,
- Loader
+ Loader,
} from '@mantine/core';
-const UserAgentsTable = React.lazy(() =>
- import('../components/tables/UserAgentsTable.jsx'));
-const StreamProfilesTable = React.lazy(() =>
- import('../components/tables/StreamProfilesTable.jsx'));
-const BackupManager = React.lazy(() =>
- import('../components/backups/BackupManager.jsx'));
+const UserAgentsTable = React.lazy(
+ () => import('../components/tables/UserAgentsTable.jsx')
+);
+const StreamProfilesTable = React.lazy(
+ () => import('../components/tables/StreamProfilesTable.jsx')
+);
+const BackupManager = React.lazy(
+ () => import('../components/backups/BackupManager.jsx')
+);
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
-const NetworkAccessForm = React.lazy(() =>
- import('../components/forms/settings/NetworkAccessForm.jsx'));
-const ProxySettingsForm = React.lazy(() =>
- import('../components/forms/settings/ProxySettingsForm.jsx'));
-const StreamSettingsForm = React.lazy(() =>
- import('../components/forms/settings/StreamSettingsForm.jsx'));
-const DvrSettingsForm = React.lazy(() =>
- import('../components/forms/settings/DvrSettingsForm.jsx'));
-const SystemSettingsForm = React.lazy(() =>
- import('../components/forms/settings/SystemSettingsForm.jsx'));
+const NetworkAccessForm = React.lazy(
+ () => import('../components/forms/settings/NetworkAccessForm.jsx')
+);
+const ProxySettingsForm = React.lazy(
+ () => import('../components/forms/settings/ProxySettingsForm.jsx')
+);
+const StreamSettingsForm = React.lazy(
+ () => import('../components/forms/settings/StreamSettingsForm.jsx')
+);
+const DvrSettingsForm = React.lazy(
+ () => import('../components/forms/settings/DvrSettingsForm.jsx')
+);
+const SystemSettingsForm = React.lazy(
+ () => import('../components/forms/settings/SystemSettingsForm.jsx')
+);
const SettingsPage = () => {
const authUser = useAuthStore((s) => s.user);
+ const location = useLocation();
- const [accordianValue, setAccordianValue] = useState(null);
+ const [accordianValue, setAccordianValue] = useState('ui-settings');
+
+ // Handle hash navigation to open specific accordion
+ useEffect(() => {
+ const hash = location.hash.replace('#', '');
+ if (hash) {
+ setAccordianValue(hash);
+ }
+ }, [location.hash]);
return (
UI Settings
-
+
@@ -60,7 +77,8 @@ const SettingsPage = () => {
}>
+ active={accordianValue === 'dvr-settings'}
+ />
@@ -72,7 +90,8 @@ const SettingsPage = () => {
}>
+ active={accordianValue === 'stream-settings'}
+ />
@@ -84,7 +103,8 @@ const SettingsPage = () => {
}>
+ active={accordianValue === 'system-settings'}
+ />
@@ -96,7 +116,8 @@ const SettingsPage = () => {
}>
+ active={accordianValue === 'user-agents'}
+ />
@@ -108,7 +129,8 @@ const SettingsPage = () => {
}>
+ active={accordianValue === 'stream-profiles'}
+ />
@@ -127,7 +149,8 @@ const SettingsPage = () => {
}>
+ active={accordianValue === 'network-access'}
+ />
@@ -141,7 +164,8 @@ const SettingsPage = () => {
}>
+ active={accordianValue === 'proxy-settings'}
+ />
diff --git a/frontend/src/store/notifications.jsx b/frontend/src/store/notifications.jsx
new file mode 100644
index 00000000..438b0cbc
--- /dev/null
+++ b/frontend/src/store/notifications.jsx
@@ -0,0 +1,111 @@
+import { create } from 'zustand';
+
+// Store for managing system notifications (version updates, recommended settings, announcements)
+const useNotificationsStore = create((set, get) => ({
+ notifications: [],
+ unreadCount: 0,
+ isLoading: false,
+ error: null,
+ lastFetched: null,
+
+ // Set notifications directly (used by API layer)
+ setNotifications: (notifications) => {
+ const unreadCount = notifications.filter((n) => !n.is_dismissed).length;
+ set({
+ notifications,
+ unreadCount,
+ lastFetched: new Date().toISOString(),
+ });
+ },
+
+ // Add a new notification (e.g., from WebSocket)
+ addNotification: (notification) => {
+ set((state) => {
+ const exists = state.notifications.some(
+ (n) => n.notification_key === notification.notification_key
+ );
+ if (exists) {
+ // Update existing notification
+ return {
+ notifications: state.notifications.map((n) =>
+ n.notification_key === notification.notification_key
+ ? { ...n, ...notification }
+ : n
+ ),
+ };
+ }
+ // Add new notification
+ const newNotifications = [notification, ...state.notifications];
+ return {
+ notifications: newNotifications,
+ unreadCount: newNotifications.filter((n) => !n.is_dismissed).length,
+ };
+ });
+ },
+
+ // Mark a notification as dismissed locally
+ dismissNotification: (notificationKey) => {
+ set((state) => {
+ const notifications = state.notifications.map((n) =>
+ n.notification_key === notificationKey
+ ? { ...n, is_dismissed: true }
+ : n
+ );
+ return {
+ notifications,
+ unreadCount: notifications.filter((n) => !n.is_dismissed).length,
+ };
+ });
+ },
+
+ // Mark all notifications as dismissed locally
+ dismissAllNotifications: () => {
+ set((state) => ({
+ notifications: state.notifications.map((n) => ({
+ ...n,
+ is_dismissed: true,
+ })),
+ unreadCount: 0,
+ }));
+ },
+
+ // Remove a notification from the store
+ removeNotification: (notificationKey) => {
+ set((state) => {
+ const notifications = state.notifications.filter(
+ (n) => n.notification_key !== notificationKey
+ );
+ return {
+ notifications,
+ unreadCount: notifications.filter((n) => !n.is_dismissed).length,
+ };
+ });
+ },
+
+ // Update unread count
+ setUnreadCount: (count) => {
+ set({ unreadCount: count });
+ },
+
+ // Set loading state
+ setLoading: (isLoading) => {
+ set({ isLoading });
+ },
+
+ // Set error state
+ setError: (error) => {
+ set({ error });
+ },
+
+ // Get notifications by type
+ getNotificationsByType: (type) => {
+ return get().notifications.filter((n) => n.notification_type === type);
+ },
+
+ // Get unread notifications only
+ getUnreadNotifications: () => {
+ return get().notifications.filter((n) => !n.is_dismissed);
+ },
+}));
+
+export default useNotificationsStore;
diff --git a/pyproject.toml b/pyproject.toml
index 9400a55d..82961ccb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ dependencies = [
"django-filter",
"django-celery-beat",
"lxml==6.0.2",
+ "packaging",
]
[build-system]
From 49a5feb6713c0fc4c3f0c0ba7ffce20985493397 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 3 Feb 2026 09:41:40 -0600
Subject: [PATCH 111/125] test: refactor SettingsPage tests to use MemoryRouter
for routing context
---
.../src/pages/__tests__/Settings.test.jsx | 136 +++++++++++++-----
1 file changed, 98 insertions(+), 38 deletions(-)
diff --git a/frontend/src/pages/__tests__/Settings.test.jsx b/frontend/src/pages/__tests__/Settings.test.jsx
index 6a254326..3e32afca 100644
--- a/frontend/src/pages/__tests__/Settings.test.jsx
+++ b/frontend/src/pages/__tests__/Settings.test.jsx
@@ -1,9 +1,6 @@
-import {
- render,
- screen,
- waitFor,
-} from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { MemoryRouter } from 'react-router-dom';
import SettingsPage from '../Settings';
import useAuthStore from '../../store/auth';
import { USER_LEVELS } from '../../constants';
@@ -12,38 +9,76 @@ import userEvent from '@testing-library/user-event';
// Mock all dependencies
vi.mock('../../store/auth');
vi.mock('../../components/tables/UserAgentsTable', () => ({
- default: ({ active }) => UserAgentsTable {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ UserAgentsTable {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/tables/StreamProfilesTable', () => ({
- default: ({ active }) => StreamProfilesTable {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ StreamProfilesTable {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/backups/BackupManager', () => ({
- default: ({ active }) => BackupManager {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ BackupManager {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/forms/settings/UiSettingsForm', () => ({
- default: ({ active }) => UiSettingsForm {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ UiSettingsForm {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({
- default: ({ active }) => NetworkAccessForm {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ NetworkAccessForm {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({
- default: ({ active }) => ProxySettingsForm {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ ProxySettingsForm {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({
- default: ({ active }) => StreamSettingsForm {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ StreamSettingsForm {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({
- default: ({ active }) => DvrSettingsForm {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ DvrSettingsForm {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
- default: ({ active }) => SystemSettingsForm {active ? 'active' : 'inactive'}
,
+ default: ({ active }) => (
+
+ SystemSettingsForm {active ? 'active' : 'inactive'}
+
+ ),
}));
vi.mock('../../components/ErrorBoundary', () => ({
default: ({ children }) => {children}
,
}));
vi.mock('@mantine/core', async () => {
- const accordionComponent = ({ children, onChange, defaultValue }) => {children}
;
+ const accordionComponent = ({ children, onChange, defaultValue }) => (
+ {children}
+ );
accordionComponent.Item = ({ children, value }) => (
{children}
);
@@ -66,6 +101,15 @@ vi.mock('@mantine/core', async () => {
};
});
+// Helper function to render with router context
+const renderWithRouter = (
+ component,
+ { initialEntries = ['/settings'] } = {}
+) => {
+ return render(
+ {component}
+ );
+};
describe('SettingsPage', () => {
beforeEach(() => {
@@ -81,26 +125,28 @@ describe('SettingsPage', () => {
});
it('renders the settings page', () => {
- render();
+ renderWithRouter();
expect(screen.getByTestId('accordion')).toBeInTheDocument();
});
it('renders UI Settings accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-ui-settings')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-ui-settings')
+ ).toBeInTheDocument();
expect(screen.getByText('UI Settings')).toBeInTheDocument();
});
it('opens UI Settings panel by default', () => {
- render();
+ renderWithRouter();
expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
});
it('does not render admin-only sections for regular users', () => {
- render();
+ renderWithRouter();
expect(screen.queryByText('DVR')).not.toBeInTheDocument();
expect(screen.queryByText('Stream Settings')).not.toBeInTheDocument();
@@ -122,7 +168,7 @@ describe('SettingsPage', () => {
});
it('renders all accordion items for admin', async () => {
- render();
+ renderWithRouter();
expect(screen.getByText('UI Settings')).toBeInTheDocument();
@@ -139,49 +185,63 @@ describe('SettingsPage', () => {
});
it('renders DVR settings accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-dvr-settings')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-dvr-settings')
+ ).toBeInTheDocument();
});
it('renders Stream Settings accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-stream-settings')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-stream-settings')
+ ).toBeInTheDocument();
});
it('renders System Settings accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-system-settings')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-system-settings')
+ ).toBeInTheDocument();
});
it('renders User-Agents accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-user-agents')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-user-agents')
+ ).toBeInTheDocument();
});
it('renders Stream Profiles accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-stream-profiles')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-stream-profiles')
+ ).toBeInTheDocument();
});
it('renders Network Access accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-network-access')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-network-access')
+ ).toBeInTheDocument();
});
it('renders Proxy Settings accordion item', () => {
- render();
+ renderWithRouter();
- expect(screen.getByTestId('accordion-item-proxy-settings')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('accordion-item-proxy-settings')
+ ).toBeInTheDocument();
});
it('renders Backup & Restore accordion item', () => {
- render();
+ renderWithRouter();
expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument();
});
@@ -197,7 +257,7 @@ describe('SettingsPage', () => {
it('opens DVR settings when clicked', async () => {
const user = userEvent.setup();
- render();
+ renderWithRouter();
const streamSettingsButton = screen.getByText('DVR');
await user.click(streamSettingsButton);
@@ -205,4 +265,4 @@ describe('SettingsPage', () => {
await screen.findByTestId('dvr-settings-form');
});
});
-});
\ No newline at end of file
+});
From 92f59b1a9e87e040a78680ee42459de4c6241331 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 3 Feb 2026 10:03:37 -0600
Subject: [PATCH 112/125] changelog: Update changelog for modular PR
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b3f4f3c..da54fb68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default.
+- Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues.
- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes.
- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
- Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
@@ -46,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892)
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
From 7b83a90f01594d68285894bed4ced11168baf033 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 3 Feb 2026 10:35:50 -0600
Subject: [PATCH 113/125] changelog: Update changelog for auto-match PR
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da54fb68..24b0dfb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `useVideoStore` tests for video player state management
- `useWarningsStore` tests for warning suppression functionality
- Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
+- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen)
### Changed
From 027d114299dfe154b05a5501d32c3152bcca3ed3 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Tue, 3 Feb 2026 12:16:31 -0600
Subject: [PATCH 114/125] Enhancement: XtreamCodes Authentication Optimization:
Reduced API calls during XC refresh by 50% by eliminating redundant
authentication step. This should help reduce rate-limiting errors.
---
CHANGELOG.md | 1 +
apps/m3u/tasks.py | 48 +++++++++++++++++++----------------------------
2 files changed, 20 insertions(+), 29 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24b0dfb0..cb77e53d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors.
- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default.
- Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues.
- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes.
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index d0d6978e..f929a208 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -1326,36 +1326,14 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
) as xc_client:
logger.info(f"XCClient instance created successfully")
- # Authenticate with detailed error handling
+ # Queue async profile refresh task to run in background
+ # This prevents any delay in the main refresh process
try:
- logger.debug(f"Authenticating with XC server {server_url}")
- auth_result = xc_client.authenticate()
- logger.debug(f"Authentication response: {auth_result}")
-
- # Queue async profile refresh task to run in background
- # This prevents any delay in the main refresh process
- try:
- logger.info(f"Queueing background profile refresh for account {account.name}")
- refresh_account_profiles.delay(account.id)
- except Exception as e:
- logger.warning(f"Failed to queue profile refresh task: {str(e)}")
- # Don't fail the main refresh if profile refresh can't be queued
-
+ logger.info(f"Queueing background profile refresh for account {account.name}")
+ refresh_account_profiles.delay(account.id)
except Exception as e:
- error_msg = f"Failed to authenticate with XC server: {str(e)}"
- logger.error(error_msg)
- account.status = M3UAccount.Status.ERROR
- account.last_message = error_msg
- account.save(update_fields=["status", "last_message"])
- send_m3u_update(
- account_id,
- "processing_groups",
- 100,
- status="error",
- error=error_msg,
- )
- release_task_lock("refresh_m3u_account_groups", account_id)
- return error_msg, None
+ logger.warning(f"Failed to queue profile refresh task: {str(e)}")
+ # Don't fail the main refresh if profile refresh can't be queued
# Get categories with detailed error handling
try:
@@ -1395,7 +1373,19 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta
"xc_id": cat_id,
}
except Exception as e:
- error_msg = f"Failed to get categories from XC server: {str(e)}"
+ # Determine if this is an authentication error or category retrieval error
+ error_str = str(e).lower()
+ # Check for authentication-related keywords or HTTP status codes commonly used for auth failures
+ is_auth_error = any(keyword in error_str for keyword in [
+ 'auth', 'credential', 'login', 'unauthorized', 'forbidden',
+ '401', '403', '512', '513' # HTTP status codes: 401 Unauthorized, 403 Forbidden, 512-513 (non-standard auth failure)
+ ])
+
+ if is_auth_error:
+ error_msg = f"Failed to authenticate with XC server: {str(e)}"
+ else:
+ error_msg = f"Failed to get categories from XC server: {str(e)}"
+
logger.error(error_msg)
account.status = M3UAccount.Status.ERROR
account.last_message = error_msg
From e217960500be87238c82053a66b3eb0a25b46318 Mon Sep 17 00:00:00 2001
From: None
Date: Tue, 3 Feb 2026 21:42:06 -0600
Subject: [PATCH 115/125] feat: Add Redis authentication support for modular
deployment
Add support for authentication when connecting to external Redis instances in modular deployment mode. This enables secure Redis deployments using either password-only authentication (Redis <6) or
username + password authentication (Redis 6+ ACL).
Changes:
- Add REDIS_PASSWORD and REDIS_USER environment variables
- Implement URL encoding for special characters in passwords
- Update all Redis connection points to support auth
- Add comprehensive documentation and examples to docker-compose.yml
- Maintains full backward compatibility (empty defaults = no auth)
All authentication mechanisms have been fully tested including pasword-only authentication, Redis 6+ ACL authentication with username + password, volume-mounted configuration files, and special character handling.
Existing deployments are not effected, authentication support is entirely opt-in using docker-compose.
Also, acknowledging the fact that the docker-compose file for modular deployments has been getting out of hand with auth support, the docker-compose.yml file was re-formatted for better visibility in configuration. This seemed like a better way to go than mandating a .env file.
---
apps/proxy/ts_proxy/server.py | 4 +
apps/proxy/vod_proxy/connection_manager.py | 11 ++-
apps/proxy/vod_proxy/views.py | 11 ++-
core/utils.py | 8 ++
core/views.py | 10 +-
dispatcharr/persistent_lock.py | 10 +-
dispatcharr/settings.py | 27 +++++-
docker/docker-compose.yml | 108 ++++++++++++++++++---
docker/entrypoint.sh | 4 +-
scripts/wait_for_redis.py | 8 +-
10 files changed, 176 insertions(+), 25 deletions(-)
diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py
index db5b3d57..df51744d 100644
--- a/apps/proxy/ts_proxy/server.py
+++ b/apps/proxy/ts_proxy/server.py
@@ -149,11 +149,15 @@ class ProxyServer:
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
+ redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
+ redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
pubsub_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
+ password=redis_password if redis_password else None,
+ username=redis_user if redis_user else None,
socket_timeout=60,
socket_connect_timeout=10,
socket_keepalive=True,
diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py
index ec0bffa5..aafc75cd 100644
--- a/apps/proxy/vod_proxy/connection_manager.py
+++ b/apps/proxy/vod_proxy/connection_manager.py
@@ -101,7 +101,16 @@ class PersistentVODConnection:
redis_host = getattr(settings, 'REDIS_HOST', 'localhost')
redis_port = int(getattr(settings, 'REDIS_PORT', 6379))
redis_db = int(getattr(settings, 'REDIS_DB', 0))
- r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True)
+ redis_password = getattr(settings, 'REDIS_PASSWORD', '')
+ redis_user = getattr(settings, 'REDIS_USER', '')
+ r = redis.StrictRedis(
+ host=redis_host,
+ port=redis_port,
+ db=redis_db,
+ password=redis_password if redis_password else None,
+ username=redis_user if redis_user else None,
+ decode_responses=True
+ )
content_length_key = f"vod_content_length:{self.session_id}"
stored_length = r.get(content_length_key)
if stored_length:
diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py
index 2ec95cc3..948604d6 100644
--- a/apps/proxy/vod_proxy/views.py
+++ b/apps/proxy/vod_proxy/views.py
@@ -333,7 +333,16 @@ class VODStreamView(View):
redis_host = getattr(settings, 'REDIS_HOST', 'localhost')
redis_port = int(getattr(settings, 'REDIS_PORT', 6379))
redis_db = int(getattr(settings, 'REDIS_DB', 0))
- r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True)
+ redis_password = getattr(settings, 'REDIS_PASSWORD', '')
+ redis_user = getattr(settings, 'REDIS_USER', '')
+ r = redis.StrictRedis(
+ host=redis_host,
+ port=redis_port,
+ db=redis_db,
+ password=redis_password if redis_password else None,
+ username=redis_user if redis_user else None,
+ decode_responses=True
+ )
content_length_key = f"vod_content_length:{session_id}"
r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes
logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}")
diff --git a/core/utils.py b/core/utils.py
index 5c5a0045..c0e87a42 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -55,6 +55,8 @@ class RedisClient:
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
+ redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
+ redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
# Use standardized settings
socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5)
@@ -68,6 +70,8 @@ class RedisClient:
host=redis_host,
port=redis_port,
db=redis_db,
+ password=redis_password if redis_password else None,
+ username=redis_user if redis_user else None,
socket_timeout=socket_timeout,
socket_connect_timeout=socket_connect_timeout,
socket_keepalive=socket_keepalive,
@@ -148,6 +152,8 @@ class RedisClient:
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
+ redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
+ redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
# Use standardized settings but without socket timeouts for PubSub
# Important: socket_timeout is None for PubSub operations
@@ -161,6 +167,8 @@ class RedisClient:
host=redis_host,
port=redis_port,
db=redis_db,
+ password=redis_password if redis_password else None,
+ username=redis_user if redis_user else None,
socket_timeout=None, # Critical: No timeout for PubSub operations
socket_connect_timeout=socket_connect_timeout,
socket_keepalive=socket_keepalive,
diff --git a/core/views.py b/core/views.py
index 5806d63c..082cccc3 100644
--- a/core/views.py
+++ b/core/views.py
@@ -40,7 +40,15 @@ def stream_view(request, channel_uuid):
redis_host = getattr(settings, "REDIS_HOST", "localhost")
redis_port = int(getattr(settings, "REDIS_PORT", 6379))
redis_db = int(getattr(settings, "REDIS_DB", "0"))
- redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db)
+ redis_password = getattr(settings, "REDIS_PASSWORD", "")
+ redis_user = getattr(settings, "REDIS_USER", "")
+ redis_client = redis.Redis(
+ host=redis_host,
+ port=redis_port,
+ db=redis_db,
+ password=redis_password if redis_password else None,
+ username=redis_user if redis_user else None
+ )
# Retrieve the channel by the provided stream_id.
channel = Channel.objects.get(uuid=channel_uuid)
diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py
index 27d480be..2ed72bf4 100644
--- a/dispatcharr/persistent_lock.py
+++ b/dispatcharr/persistent_lock.py
@@ -78,7 +78,15 @@ if __name__ == "__main__":
redis_host = os.environ.get("REDIS_HOST", "localhost")
redis_port = int(os.environ.get("REDIS_PORT", 6379))
redis_db = int(os.environ.get("REDIS_DB", 0))
- client = redis.Redis(host=redis_host, port=redis_port, db=redis_db)
+ redis_password = os.environ.get("REDIS_PASSWORD", "")
+ redis_user = os.environ.get("REDIS_USER", "")
+ client = redis.Redis(
+ host=redis_host,
+ port=redis_port,
+ db=redis_db,
+ password=redis_password if redis_password else None,
+ username=redis_user if redis_user else None
+ )
lock = PersistentLock(client, "lock:example_account", lock_timeout=120)
if lock.acquire():
diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index 7af6e939..f0ef75a0 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -1,6 +1,7 @@
import os
from pathlib import Path
from datetime import timedelta
+from urllib.parse import quote_plus
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -8,6 +9,8 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))
REDIS_DB = os.environ.get("REDIS_DB", "0")
+REDIS_USER = os.environ.get("REDIS_USER", "")
+REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
# Set DEBUG to True for development, False for production
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
@@ -119,7 +122,12 @@ CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
- "hosts": [(REDIS_HOST, REDIS_PORT, REDIS_DB)], # Ensure Redis is running
+ "hosts": ["redis://{redis_auth}{host}:{port}/{db}".format(
+ redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "",
+ host=REDIS_HOST,
+ port=REDIS_PORT,
+ db=REDIS_DB
+ )], # URL format supports authentication
},
},
}
@@ -197,8 +205,19 @@ STATICFILES_DIRS = [
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "accounts.User"
-# Build default Redis URL from components for Celery
-_default_redis_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
+# Build default Redis URL from components for Celery with optional authentication
+# Build auth string conditionally with URL encoding for special characters
+if REDIS_PASSWORD:
+ encoded_password = quote_plus(REDIS_PASSWORD)
+ if REDIS_USER:
+ encoded_user = quote_plus(REDIS_USER)
+ redis_auth = f"{encoded_user}:{encoded_password}@"
+ else:
+ redis_auth = f":{encoded_password}@"
+else:
+ redis_auth = ""
+
+_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url)
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
@@ -269,7 +288,7 @@ SIMPLE_JWT = {
}
# Redis connection settings
-REDIS_URL = os.environ.get("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}")
+REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url)
REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds
REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds
REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index c8e2d53e..89585fc7 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,4 +1,11 @@
+# Dispatcharr - Modular Deployment Configuration
+# This compose file runs Dispatcharr in modular mode with separate containers
+# for web, celery workers, PostgreSQL database, and Redis cache.
+
services:
+ # ============================================================================
+ # Web Service
+ # ============================================================================
web:
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_web
@@ -9,44 +16,66 @@ services:
depends_on:
- db
- redis
+
+ # --- Environment Configuration ---
environment:
+ # Deployment Mode
- DISPATCHARR_ENV=modular
+
+ # PostgreSQL Connection
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
+
+ # Redis Connection
- REDIS_HOST=redis
- REDIS_PORT=6379
+
+ # Redis Authentication (Optional)
+ # Uncomment and set if your Redis requires authentication:
+ #- REDIS_PASSWORD=your_strong_redis_password
+ #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below
+
+ # Logging
- DISPATCHARR_LOG_LEVEL=info
+
# Legacy CPU Support (Optional)
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
- # that lack support for newer baseline CPU features
+ # that lack support for newer baseline CPU features:
#- USE_LEGACY_NUMPY=true
+
# Process Priority Configuration (Optional)
# Lower values = higher priority. Range: -20 (highest) to 19 (lowest)
- # Negative values require cap_add: SYS_NICE (uncomment below)
+ # Negative values require cap_add: SYS_NICE (see below)
#- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority)
- #
+
+ # --- Advanced Configuration ---
# Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0)
#cap_add:
# - SYS_NICE
- # Optional for hardware acceleration
+
+ # --- Hardware Acceleration (Optional) ---
+ # Uncomment for GPU access (transcoding acceleration)
#group_add:
# - video
- # #- render # Uncomment if your GPU requires it
+ # #- render # Uncomment if your GPU requires it
#devices:
# - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API)
- # Uncomment the following lines for NVIDIA GPU support
- # NVidia GPU support (requires NVIDIA Container Toolkit)
+
+ # NVIDIA GPU Support (requires NVIDIA Container Toolkit)
#deploy:
# resources:
- # reservations:
- # devices:
- # - driver: nvidia
- # count: all
- # capabilities: [gpu]
+ # reservations:
+ # devices:
+ # - driver: nvidia
+ # count: all
+ # capabilities: [gpu]
+ # ============================================================================
+ # Celery Service - Background task worker
+ # ============================================================================
celery:
image: ghcr.io/dispatcharr/dispatcharr:latest
container_name: dispatcharr_celery
@@ -58,24 +87,47 @@ services:
- ./data:/data
extra_hosts:
- "host.docker.internal:host-gateway"
+ entrypoint: ["/app/docker/entrypoint.celery.sh"]
+
+ # --- Environment Configuration ---
environment:
+ # Deployment Mode
- DISPATCHARR_ENV=modular
+
+ # PostgreSQL Connection
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
+
+ # Redis Connection
- REDIS_HOST=redis
- REDIS_PORT=6379
+
+ # Redis Authentication (Optional)
+ # Uncomment and set if your Redis requires authentication:
+ #- REDIS_PASSWORD=your_strong_redis_password
+ #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below
+
+ # Logging
- DISPATCHARR_LOG_LEVEL=info
- #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19)
+
+ # Process Priority Configuration (Optional)
+ #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19)
+
+ # Django Configuration
- DJANGO_SETTINGS_MODULE=dispatcharr.settings
- PYTHONUNBUFFERED=1
+
+ # --- Advanced Configuration ---
# Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0)
#cap_add:
# - SYS_NICE
- entrypoint: ["/app/docker/entrypoint.celery.sh"]
+ # ============================================================================
+ # PostgreSQL
+ # ============================================================================
db:
image: postgres:17
container_name: dispatcharr_db
@@ -93,14 +145,42 @@ services:
timeout: 5s
retries: 5
+ # ============================================================================
+ # Redis
+ # ============================================================================
redis:
image: redis:latest
container_name: dispatcharr_redis
+
+ # --- Authentication Configuration (Optional) ---
+ # By default, Redis runs without authentication.
+ # Choose ONE of the following options if authentication is required:
+
+ # Option 1: Password-only authentication (Redis <6 or default user)
+ #command: ["redis-server", "--requirepass", "your_strong_redis_password"]
+
+ # Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration)
+ #command: ["redis-server", "/etc/redis/redis.conf"]
+ #volumes:
+ # - ./redis.conf:/etc/redis/redis.conf:ro
+
+ # --- Health Check Configuration ---
healthcheck:
+ # Default: No authentication
test: ["CMD", "redis-cli", "ping"]
+
+ # If using Option 1 (password-only), uncomment this instead:
+ #test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"]
+
+ # If using Option 2 (Redis 6+ ACL), uncomment this instead:
+ #test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"]
+
interval: 5s
timeout: 5s
retries: 5
+# ==============================================================================
+# Volumes
+# ==============================================================================
volumes:
postgres_data:
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index ead6aadd..8ea47318 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -38,6 +38,8 @@ export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
export REDIS_HOST=${REDIS_HOST:-localhost}
export REDIS_PORT=${REDIS_PORT:-6379}
export REDIS_DB=${REDIS_DB:-0}
+export REDIS_PASSWORD=${REDIS_PASSWORD:-}
+export REDIS_USER=${REDIS_USER:-}
export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191}
export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri'
export LD_LIBRARY_PATH='/usr/local/lib'
@@ -104,7 +106,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
- REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT
+ REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
)
diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py
index 38737728..0d278150 100644
--- a/scripts/wait_for_redis.py
+++ b/scripts/wait_for_redis.py
@@ -12,7 +12,7 @@ import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
-def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_interval=2):
+def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2):
"""Wait for Redis to become available"""
redis_client = None
retry_count = 0
@@ -25,6 +25,8 @@ def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_inte
host=host,
port=port,
db=db,
+ password=password if password else None,
+ username=username if username else None,
socket_timeout=2,
socket_connect_timeout=2
)
@@ -50,12 +52,14 @@ if __name__ == "__main__":
host = os.environ.get('REDIS_HOST', 'localhost')
port = int(os.environ.get('REDIS_PORT', 6379))
db = int(os.environ.get('REDIS_DB', 0))
+ password = os.environ.get('REDIS_PASSWORD', '')
+ username = os.environ.get('REDIS_USER', '')
max_retries = int(os.environ.get('REDIS_WAIT_RETRIES', 30))
retry_interval = int(os.environ.get('REDIS_WAIT_INTERVAL', 2))
logger.info(f"Starting Redis availability check at {host}:{port}/{db}")
- if wait_for_redis(host, port, db, max_retries, retry_interval):
+ if wait_for_redis(host, port, db, password, username, max_retries, retry_interval):
sys.exit(0)
else:
sys.exit(1)
From e26c1908c5ac8c49d324c7084dae7b6f9efe9a36 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Wed, 4 Feb 2026 16:56:11 -0600
Subject: [PATCH 116/125] Bug Fix: Automatic backups not enabled by default on
new installations: Added backups app to `INSTALLED_APPS` and implemented
automatic scheduler initialization in `BackupsConfig.ready()`. The backup
scheduler now properly syncs the periodic task on startup, ensuring automatic
daily backups are enabled and scheduled immediately on fresh database
creation without requiring manual user intervention.
---
CHANGELOG.md | 1 +
apps/backups/apps.py | 30 ++++++++++++++++++++++++++++++
dispatcharr/settings.py | 1 +
3 files changed, 32 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb77e53d..f711d67c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention.
- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892)
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
diff --git a/apps/backups/apps.py b/apps/backups/apps.py
index ee644149..d4bbb973 100644
--- a/apps/backups/apps.py
+++ b/apps/backups/apps.py
@@ -1,7 +1,37 @@
+import logging
+import sys
+
from django.apps import AppConfig
+logger = logging.getLogger(__name__)
+
class BackupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.backups"
verbose_name = "Backups"
+
+ def ready(self):
+ """Initialize backup scheduler on app startup."""
+ # Only run in the main process (not in workers, beat, or management commands)
+ if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False:
+ self._sync_backup_scheduler()
+
+ def _sync_backup_scheduler(self):
+ """Sync backup scheduler task to database."""
+ # Import here to avoid circular imports
+ from core.models import CoreSettings
+ from .scheduler import _sync_periodic_task, DEFAULTS
+ try:
+ # Ensure settings exist with defaults if this is a new install
+ CoreSettings.objects.get_or_create(
+ key="backup_settings",
+ defaults={"name": "Backup Settings", "value": DEFAULTS.copy()}
+ )
+
+ # Always sync the periodic task (handles new installs, updates, or missing tasks)
+ logger.debug("Syncing backup scheduler")
+ _sync_periodic_task()
+ except Exception as e:
+ # Log but don't fail startup if there's an issue
+ logger.warning(f"Failed to initialize backup scheduler: {e}")
diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index 7af6e939..cfc499da 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -21,6 +21,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS = [
"apps.api",
"apps.accounts",
+ "apps.backups.apps.BackupsConfig",
"apps.channels.apps.ChannelsConfig",
"apps.dashboard",
"apps.epg",
From da5fbb1f5c470d2e24201246a71fe8cd19c9e88c Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Wed, 4 Feb 2026 17:33:18 -0600
Subject: [PATCH 117/125] Change backup schedule initialization to support
gunicorn as well.
---
apps/backups/apps.py | 31 ++++++++++++++++++++++++++++---
1 file changed, 28 insertions(+), 3 deletions(-)
diff --git a/apps/backups/apps.py b/apps/backups/apps.py
index d4bbb973..6b34d933 100644
--- a/apps/backups/apps.py
+++ b/apps/backups/apps.py
@@ -1,11 +1,24 @@
import logging
import sys
+import os
from django.apps import AppConfig
+import psutil
logger = logging.getLogger(__name__)
+def _is_worker_process():
+ """Check if this process is a worker spawned by uwsgi/gunicorn."""
+ try:
+ parent = psutil.Process(os.getppid())
+ parent_name = parent.name()
+ return parent_name in ['uwsgi', 'gunicorn']
+ except Exception:
+ # If we can't determine, assume it's not a worker (safe default)
+ return False
+
+
class BackupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.backups"
@@ -13,9 +26,21 @@ class BackupsConfig(AppConfig):
def ready(self):
"""Initialize backup scheduler on app startup."""
- # Only run in the main process (not in workers, beat, or management commands)
- if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False:
- self._sync_backup_scheduler()
+ # Skip management commands and celery
+ skip_commands = ['celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell', 'collectstatic', 'loaddata']
+ if any(cmd in sys.argv for cmd in skip_commands):
+ return
+
+ # Skip daphne dev server
+ if 'daphne' in sys.argv[0] if sys.argv else False:
+ return
+
+ # Skip if this is a worker process spawned by uwsgi/gunicorn
+ if _is_worker_process():
+ return
+
+ # Proceed with syncing the backup scheduler
+ self._sync_backup_scheduler()
def _sync_backup_scheduler(self):
"""Sync backup scheduler task to database."""
From de81dc6c80148160ec19f87949508bd399eea81a Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 5 Feb 2026 13:48:01 -0600
Subject: [PATCH 118/125] Enhancement: Refactored app initialization to prevent
redundant execution across multiple worker processes. Created
`dispatcharr.app_initialization` utility module with
`should_skip_initialization()` function that prevents custom initialization
tasks (backup scheduler sync, developer notifications sync) from running
during management commands, in worker processes, or in development servers.
This significantly reduces startup overhead in multi-worker deployments
(e.g., uWSGI with 10 workers now syncs the scheduler once instead of 10
times). Applied to both `CoreConfig` and `BackupsConfig` apps.
---
CHANGELOG.md | 1 +
apps/backups/apps.py | 32 +++----------------
core/apps.py | 31 ++++++------------
dispatcharr/app_initialization.py | 52 +++++++++++++++++++++++++++++++
4 files changed, 67 insertions(+), 49 deletions(-)
create mode 100644 dispatcharr/app_initialization.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f711d67c..babec5d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors.
+- App initialization efficiency: Refactored app initialization to prevent redundant execution across multiple worker processes. Created `dispatcharr.app_initialization` utility module with `should_skip_initialization()` function that prevents custom initialization tasks (backup scheduler sync, developer notifications sync) from running during management commands, in worker processes, or in development servers. This significantly reduces startup overhead in multi-worker deployments (e.g., uWSGI with 10 workers now syncs the scheduler once instead of 10 times). Applied to both `CoreConfig` and `BackupsConfig` apps.
- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default.
- Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues.
- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes.
diff --git a/apps/backups/apps.py b/apps/backups/apps.py
index 6b34d933..d22ffb29 100644
--- a/apps/backups/apps.py
+++ b/apps/backups/apps.py
@@ -1,24 +1,10 @@
import logging
-import sys
-import os
from django.apps import AppConfig
-import psutil
logger = logging.getLogger(__name__)
-def _is_worker_process():
- """Check if this process is a worker spawned by uwsgi/gunicorn."""
- try:
- parent = psutil.Process(os.getppid())
- parent_name = parent.name()
- return parent_name in ['uwsgi', 'gunicorn']
- except Exception:
- # If we can't determine, assume it's not a worker (safe default)
- return False
-
-
class BackupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.backups"
@@ -26,25 +12,17 @@ class BackupsConfig(AppConfig):
def ready(self):
"""Initialize backup scheduler on app startup."""
- # Skip management commands and celery
- skip_commands = ['celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell', 'collectstatic', 'loaddata']
- if any(cmd in sys.argv for cmd in skip_commands):
+ from dispatcharr.app_initialization import should_skip_initialization
+
+ # Skip if this is a management command, worker process, or dev server
+ if should_skip_initialization():
return
- # Skip daphne dev server
- if 'daphne' in sys.argv[0] if sys.argv else False:
- return
-
- # Skip if this is a worker process spawned by uwsgi/gunicorn
- if _is_worker_process():
- return
-
- # Proceed with syncing the backup scheduler
+ logger.debug("Syncing backup scheduler on app startup")
self._sync_backup_scheduler()
def _sync_backup_scheduler(self):
"""Sync backup scheduler task to database."""
- # Import here to avoid circular imports
from core.models import CoreSettings
from .scheduler import _sync_periodic_task, DEFAULTS
try:
diff --git a/core/apps.py b/core/apps.py
index 12f6d054..f2780bd1 100644
--- a/core/apps.py
+++ b/core/apps.py
@@ -1,6 +1,6 @@
from django.apps import AppConfig
from django.conf import settings
-import os, logging
+import logging
# Define TRACE level (5 is below DEBUG which is 10)
TRACE = 5
@@ -15,6 +15,7 @@ def trace(self, message, *args, **kwargs):
# Add the trace method to the Logger class
logging.Logger.trace = trace
+
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'
@@ -22,13 +23,15 @@ class CoreConfig(AppConfig):
def ready(self):
# Import signals to ensure they get registered
import core.signals
+ from dispatcharr.app_initialization import should_skip_initialization
# Sync developer notifications and check for version updates on startup
- # Only run in the main process (not in management commands or migrations)
- import sys
- if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False:
- self._sync_developer_notifications()
- self._check_version_update()
+ # Only run in the main process (not in management commands, migrations, or workers)
+ if should_skip_initialization():
+ return
+
+ self._sync_developer_notifications()
+ self._check_version_update()
def _sync_developer_notifications(self):
"""Sync developer notifications from JSON file to database."""
@@ -37,22 +40,6 @@ class CoreConfig(AppConfig):
logger = logging.getLogger(__name__)
- # Check if tables exist (avoid running during migrations)
- try:
- with connection.cursor() as cursor:
- cursor.execute(
- "SELECT 1 FROM information_schema.tables WHERE table_name = 'core_systemnotification'"
- )
- if not cursor.fetchone():
- # For SQLite
- cursor.execute(
- "SELECT name FROM sqlite_master WHERE type='table' AND name='core_systemnotification'"
- )
- if not cursor.fetchone():
- return
- except Exception:
- # If we can't check, the table might not exist yet
- pass
try:
from core.developer_notifications import sync_developer_notifications
diff --git a/dispatcharr/app_initialization.py b/dispatcharr/app_initialization.py
new file mode 100644
index 00000000..e2fc12d6
--- /dev/null
+++ b/dispatcharr/app_initialization.py
@@ -0,0 +1,52 @@
+"""Utilities for managing app initialization across multiple processes."""
+
+import sys
+import os
+import psutil
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _is_worker_process():
+ """Check if this process is a worker spawned by uwsgi/gunicorn."""
+ try:
+ parent = psutil.Process(os.getppid())
+ parent_name = parent.name()
+ return parent_name in ['uwsgi', 'gunicorn']
+ except Exception:
+ # If we can't determine, assume it's not a worker (safe default)
+ return False
+
+
+def should_skip_initialization():
+ """
+ Determine if app initialization should be skipped in this process.
+
+ Returns True if:
+ - A management command is being run (migrate, celery, shell, etc.)
+ - The development server (daphne) is running
+ - This is a worker process (not the master)
+
+ This prevents redundant initialization across multiple worker processes.
+ """
+ # Skip management commands and background services
+ skip_commands = [
+ 'celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell',
+ 'collectstatic', 'loaddata'
+ ]
+ if any(cmd in sys.argv for cmd in skip_commands):
+ logger.debug(f"Skipping initialization due to command: {sys.argv}")
+ return True
+
+ # Skip daphne development server (single process, no need to guard)
+ if 'daphne' in sys.argv[0] if sys.argv else False:
+ logger.debug(f"Skipping initialization in daphne development server. Command: {sys.argv}")
+ return True
+
+ # Skip if this is a worker process spawned by uwsgi/gunicorn
+ if _is_worker_process():
+ logger.debug(f"Skipping initialization in worker process. Command: {sys.argv}")
+ return True
+
+ return False
From 21b7303e6009f8097748c1eaec47e3dde30afa81 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 5 Feb 2026 14:42:31 -0600
Subject: [PATCH 119/125] Bug fix: Fixed EPG filtering issues where short EPG
requests had no time-based filtering (returning expired programs) and regular
EPG requests used `start_time__gte` (missing the currently playing program).
Both now correctly use `end_time__gt` to show programs that haven't ended
yet, with short EPG additionally limiting results.
---
CHANGELOG.md | 1 +
apps/output/views.py | 12 ++++++++----
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index babec5d1..9a79a9cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results.
- Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention.
- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892)
diff --git a/apps/output/views.py b/apps/output/views.py
index 58707ae2..1d53237a 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -2307,18 +2307,22 @@ def xc_get_epg(request, user, short=False):
# Has stored programs, use them
if short == False:
programs = channel.epg_data.programs.filter(
- start_time__gte=django_timezone.now()
+ end_time__gt=django_timezone.now()
).order_by('start_time')
else:
- programs = channel.epg_data.programs.all().order_by('start_time')[:limit]
+ programs = channel.epg_data.programs.filter(
+ end_time__gt=django_timezone.now()
+ ).order_by('start_time')[:limit]
else:
# Regular EPG with stored programs
if short == False:
programs = channel.epg_data.programs.filter(
- start_time__gte=django_timezone.now()
+ end_time__gt=django_timezone.now()
).order_by('start_time')
else:
- programs = channel.epg_data.programs.all().order_by('start_time')[:limit]
+ programs = channel.epg_data.programs.filter(
+ end_time__gt=django_timezone.now()
+ ).order_by('start_time')[:limit]
else:
# No EPG data assigned, generate default dummy
programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None)
From 85d944894db136711781c58ce9217b77929da082 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 5 Feb 2026 14:44:40 -0600
Subject: [PATCH 120/125] changelog: Update changelog for PR 931
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a79a9cd..8b66d31e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected).
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible.
+- Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir)
## [0.18.1] - 2026-01-27
From b8e1785d0e118f814e5a50c810f2237a5cb263d9 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 5 Feb 2026 14:58:13 -0600
Subject: [PATCH 121/125] changelog: Update changelog to reference issue.
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b66d31e..6ffafc56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -50,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
-- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results.
+- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. (Fixes #915)
- Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention.
- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892)
From 78a53e03dbdd2976b9e0bd19079795d3bb3ae2d9 Mon Sep 17 00:00:00 2001
From: Dispatcharr
Date: Thu, 5 Feb 2026 20:05:29 -0600
Subject: [PATCH 122/125] Plugin fixes/updates
Added password fields - #616
Fixed multiple GUI issues - #494
Adds some QoL upgrades
See CHANGELOG.md for more info
---
CHANGELOG.md | 19 +
Plugins.md | 250 +++++++-
apps/plugins/api_urls.py | 2 +
apps/plugins/api_views.py | 236 ++++++--
apps/plugins/loader.py | 573 ++++++++++++++++--
apps/plugins/serializers.py | 25 +-
frontend/src/api.js | 1 +
frontend/src/components/Field.jsx | 37 +-
frontend/src/components/cards/PluginCard.jsx | 152 +++--
frontend/src/pages/Plugins.jsx | 13 +-
frontend/src/pages/__tests__/Plugins.test.jsx | 3 +
frontend/src/utils/pages/PluginsUtils.js | 5 +-
.../pages/__tests__/PluginsUtils.test.js | 18 +
13 files changed, 1192 insertions(+), 142 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ffafc56..9de64782 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `useWarningsStore` tests for warning suppression functionality
- Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen)
+- Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name.
+- Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing.
+- Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown.
+- Plugin action buttons can define `button_label`, `button_variant`, and `button_color` (e.g., Stop in red), falling back to “Run” for older plugins.
+- Plugin card metadata: plugins can specify `author` and `help_url` in `plugin.json` to show author and docs link in the UI.
+- Plugin cards can now be expanded/collapsed by clicking the header or chevron to hide settings and actions.
### Changed
@@ -58,6 +64,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible.
- Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir)
+- Hardened plugin loading to avoid executing plugin code unless the plugin is enabled.
+- Prevented plugin package names from shadowing standard library or installed modules by namespacing plugin imports with safe aliases.
+- Added safety limits to plugin ZIP imports (file count and size caps) and sanitized plugin keys derived from uploads.
+- Enforced strict boolean parsing for plugin enable/disable requests to avoid accidental enables from truthy strings.
+- Applied plugin field defaults server-side when running actions so plugins receive expected settings even before a user saves.
+- Plugin settings UI improvements: render `info`/`text` fields, support `input_type: password`, show descriptions/placeholders, surface save failures, and keep settings in sync after refresh.
+- Disabled plugins now collapse settings/actions to match the closed state before first enable.
+- Plugin card header controls (delete/version/toggle) now stay right-aligned even with long descriptions.
+- Improved plugin logo resolution (case-insensitive paths + absolute URLs), fixing dev UI logo loading without a Vite proxy.
+- Plugin reload now hits the backend, clears module caches across workers, and refreshes the UI so code changes apply without a full backend restart.
+- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths.
+- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action.
+- Plugin enable/disable toggles now update immediately without requiring a full page refresh.
## [0.18.1] - 2026-01-27
diff --git a/Plugins.md b/Plugins.md
index 62ea0d87..d528f1e4 100644
--- a/Plugins.md
+++ b/Plugins.md
@@ -8,7 +8,43 @@ This document explains how to build, install, and use Python plugins in Dispatch
1) Create a folder under `/app/data/plugins/my_plugin/` (host path `data/plugins/my_plugin/` in the repo).
-2) Add a `plugin.py` file exporting a `Plugin` class:
+2) Add a `plugin.json` manifest (new standard) and a `plugin.py` file:
+
+`/app/data/plugins/my_plugin/plugin.json`
+```json
+{
+ "name": "My Plugin",
+ "version": "0.1.0",
+ "description": "Does something useful",
+ "author": "Acme Labs",
+ "help_url": "https://example.com/docs/my-plugin",
+ "fields": [
+ { "id": "enabled", "label": "Enabled", "type": "boolean", "default": true },
+ { "id": "limit", "label": "Item limit", "type": "number", "default": 5 },
+ {
+ "id": "mode",
+ "label": "Mode",
+ "type": "select",
+ "default": "safe",
+ "options": [
+ { "value": "safe", "label": "Safe" },
+ { "value": "fast", "label": "Fast" }
+ ]
+ },
+ { "id": "note", "label": "Note", "type": "string", "default": "" }
+ ],
+ "actions": [
+ {
+ "id": "do_work",
+ "label": "Do Work",
+ "description": "Process items",
+ "button_label": "Run Job",
+ "button_variant": "filled",
+ "button_color": "blue"
+ }
+ ]
+}
+```
```
# /app/data/plugins/my_plugin/plugin.py
@@ -16,6 +52,8 @@ class Plugin:
name = "My Plugin"
version = "0.1.0"
description = "Does something useful"
+ author = "Acme Labs"
+ help_url = "https://example.com/docs/my-plugin"
# Settings fields rendered by the UI and persisted by the backend
fields = [
@@ -31,7 +69,14 @@ class Plugin:
# Actions appear as buttons. Clicking one calls run(action, params, context)
actions = [
- {"id": "do_work", "label": "Do Work", "description": "Process items"},
+ {
+ "id": "do_work",
+ "label": "Do Work",
+ "description": "Process items",
+ "button_label": "Run Job",
+ "button_variant": "filled",
+ "button_color": "blue",
+ },
]
def run(self, action: str, params: dict, context: dict):
@@ -59,8 +104,10 @@ class Plugin:
- Each plugin is a directory containing either:
- `plugin.py` exporting a `Plugin` class, or
- a Python package (`__init__.py`) exporting a `Plugin` class.
+- New standard: include a `plugin.json` manifest alongside your code for safe metadata discovery.
+- Optional: include `logo.png` next to `plugin.py` to show a logo in the UI.
-The directory name (lowercased, spaces as `_`) is used as the registry key and module import path (e.g. `my_plugin.plugin`).
+The directory name (lowercased, spaces as `_`) is used as the registry key. Plugins are imported under a safe internal package name; if the folder name is a valid identifier (and not reserved), it is also registered as an alias for convenience.
---
@@ -69,7 +116,8 @@ The directory name (lowercased, spaces as `_`) is used as the registry key and m
- Discovery runs at server startup and on-demand when:
- Fetching the plugins list from the UI
- Hitting `POST /api/plugins/plugins/reload/`
-- The loader imports each plugin module and instantiates `Plugin()`.
+- The loader reads `plugin.json` for metadata without executing plugin code.
+- Plugin code is only imported and instantiated when the plugin is enabled.
- Metadata (name, version, description) and a per-plugin settings JSON are stored in the DB.
Backend code:
@@ -80,6 +128,45 @@ Backend code:
---
+## Plugin Manifest (`plugin.json`)
+
+`plugin.json` lets Dispatcharr list your plugin safely without executing code. It should live next to `plugin.py`.
+
+Example:
+```
+{
+ "name": "My Plugin",
+ "version": "1.2.3",
+ "description": "Does something useful",
+ "author": "Acme Labs",
+ "help_url": "https://example.com/docs/my-plugin",
+ "fields": [
+ { "id": "limit", "label": "Item limit", "type": "number", "default": 5 }
+ ],
+ "actions": [
+ {
+ "id": "do_work",
+ "label": "Do Work",
+ "description": "Process items",
+ "button_label": "Run Job",
+ "button_variant": "filled",
+ "button_color": "blue"
+ }
+ ]
+}
+```
+
+Notes:
+- `author` and `help_url` are optional. If provided, the UI shows “By {author}” and a Docs link.
+- If your plugin includes a `logo.png` file next to `plugin.py`, it will be shown on the plugin card.
+
+If `plugin.json` is missing or invalid, the plugin is treated as **legacy**:
+- The name is inferred from the folder name.
+- `logo.png` still displays if present.
+- The UI shows a warning asking the developer to upgrade to the new standard.
+
+---
+
## Plugin Interface
Export a `Plugin` class. Supported attributes and behavior:
@@ -87,34 +174,72 @@ Export a `Plugin` class. Supported attributes and behavior:
- `name` (str): Human-readable name.
- `version` (str): Semantic version string.
- `description` (str): Short description.
+- `author` (str, optional): Author or team name shown on the card.
+- `help_url` (str, optional): Docs/support link shown on the card.
- `fields` (list): Settings schema used by the UI to render controls.
-- `actions` (list): Available actions; the UI renders a Run button for each.
+- `actions` (list): Available actions; the UI renders a button for each (defaults to Run).
- `run(action, params, context)` (callable): Invoked when a user clicks an action.
+- `stop(context)` (optional callable): Invoked when the plugin is disabled, deleted, or reloaded so you can gracefully shut down any processes you started. If `stop()` is not defined but you have an action with id `stop`, Dispatcharr will call `run("stop", {}, context)` as a fallback.
### Settings Schema
Supported field `type`s:
- `boolean`
- `number`
-- `string`
+- `string` (single-line text)
+- `text` (multi-line textarea)
- `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`)
+- `info` (display-only text; useful for headings or notes)
Common field keys:
- `id` (str): Settings key.
- `label` (str): Label shown in the UI.
- `type` (str): One of above.
- `default` (any): Default value used until saved.
-- `help_text` (str, optional): Shown under the control.
+- `help_text` / `description` (str, optional): Shown under the control.
+- `placeholder` (str, optional): Placeholder text for inputs.
+- `input_type` (str, optional): For `string` fields, set to `"password"` to mask input.
- `options` (list, for select): List of `{value, label}`.
+Notes:
+- For `info` fields, you can use `description`/`help_text` (or `value`) to show the text.
+
The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`.
+### Example: stop() Hook
+```
+import signal
+
+class Plugin:
+ name = "Example Plugin"
+ version = "1.0.0"
+ description = "Shows how to shut down gracefully."
+
+ def run(self, action: str, params: dict, context: dict):
+ # Start a subprocess or background task here and store its PID.
+ # Example: save pid in /data or in your own module-level variable.
+ return {"status": "ok"}
+
+ def stop(self, context: dict):
+ logger = context.get("logger")
+ pid = self._read_pid() # your helper
+ if pid:
+ try:
+ os.kill(pid, signal.SIGTERM)
+ logger.info("Stopped process %s", pid)
+ except Exception:
+ logger.exception("Failed to stop process %s", pid)
+```
+
Read settings in `run` via `context["settings"]`.
### Actions
Each action is a dict:
- `id` (str): Unique action id.
-- `label` (str): Button label.
+- `label` (str): Action label.
- `description` (str, optional): Helper text.
+- `button_label` (str, optional): Button text (defaults to “Run”).
+- `button_variant` (str, optional): Button style (Mantine variants like `filled`, `outline`, `subtle`).
+- `button_color` (str, optional): Button color (e.g., `red`, `blue`, `orange`).
Clicking an action calls your plugin’s `run(action, params, context)` and shows a notification with the result or error.
@@ -182,6 +307,110 @@ Plugins are server-side Python code running within the Django application. You c
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
+### Important: Don’t Ask Users for URL/User/Password
+Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities.
+Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because:
+
+- It encourages users to enter privileged credentials.
+- Malicious plugins could exfiltrate credentials.
+- It duplicates access that plugins already have internally.
+
+If you are writing a plugin, **use internal Python APIs** (models/tasks/utils) instead of making HTTP calls with user credentials.
+
+### When You Do Need HTTP
+In rare cases you may need to call a Dispatcharr HTTP endpoint (for example, to reuse an existing API response serializer). In that case:
+
+1. **Do not ask the user for credentials.**
+ Use the backend’s internal access where possible.
+
+2. Prefer **local/internal URLs** (never user-provided):
+ - Docker: `http://web:9191` (service name inside the container network)
+ - Dev: `http://127.0.0.1:5656`
+
+3. Use Django helpers when building URLs:
+ ```
+ from django.urls import reverse
+ path = reverse("api:channels:list") # example name
+ url = f"http://127.0.0.1:5656{path}"
+ ```
+
+4. Use a short timeout and robust error handling:
+ ```
+ import requests
+ resp = requests.get(url, timeout=10)
+ resp.raise_for_status()
+ data = resp.json()
+ ```
+
+### Examples: Preferred Internal Access (No HTTP, No Credentials)
+
+**Example 1: List channels directly from the DB**
+```
+from apps.channels.models import Channel
+
+channels = Channel.objects.all().values("id", "name", "number")[:50]
+return {"status": "ok", "channels": list(channels)}
+```
+
+**Example 2: Kick off an existing refresh task**
+```
+from apps.m3u.tasks import refresh_m3u_accounts
+from apps.epg.tasks import refresh_all_epg_data
+
+refresh_m3u_accounts.delay()
+refresh_all_epg_data.delay()
+return {"status": "queued"}
+```
+
+**Example 3: Send a WebSocket update to the UI**
+```
+from core.utils import send_websocket_update
+
+send_websocket_update(
+ "updates",
+ "update",
+ {"type": "plugin", "plugin": "my_plugin", "message": "Refresh queued"}
+)
+```
+
+### Example: HTTP Access (Only If You Must)
+
+**Find the endpoint**
+- Use `reverse()` with the named route when possible.
+- If you don’t know the route name, inspect `apps/*/api_urls.py` or Django’s URL config to find it.
+
+```
+from django.urls import reverse
+import requests
+
+path = reverse("api:channels:list") # named route from apps/channels/api_urls.py
+url = f"http://127.0.0.1:5656{path}"
+
+resp = requests.get(url, timeout=10)
+resp.raise_for_status()
+data = resp.json()
+```
+
+### How Developers Find the API
+
+1. **Prefer internal models/tasks** (best and safest).
+2. **Check `apps/*/api_urls.py`** for named routes and endpoint patterns.
+ - Example: `apps/channels/api_urls.py` for channel endpoints.
+3. **Find the view** referenced in the URL config to see required params.
+ - Example: `apps/channels/api_views.py` or `apps/epg/api_views.py`.
+4. **Use `reverse()`** with the named route to build the path.
+ - This avoids hardcoding paths and keeps plugins compatible if URLs change.
+5. **Only use internal hostnames** (never user-provided URL).
+
+### What Plugins Can Access
+Because plugins run inside the server process, they can:
+- Read and write database models (same permissions as the app)
+- Invoke Celery tasks
+- Send websocket updates
+- Read configuration and settings
+
+Treat plugins as **trusted server code** and avoid exposing sensitive data in plugin settings or logs.
+
---
## REST Endpoints (for UI and tooling)
@@ -203,7 +432,9 @@ Notes:
- In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder.
- The archive should contain either `plugin.py` or a Python package (`__init__.py`).
+- Include `plugin.json` in the plugin folder to provide metadata without executing code.
- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default).
+ - If `plugin.json` is missing, the plugin is marked as legacy and the UI will show a warning.
---
@@ -214,6 +445,7 @@ Notes:
- The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code.
- The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button.
- Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403.
+- Dispatcharr will not import or execute plugin code unless the plugin is enabled.
---
@@ -263,7 +495,7 @@ class Plugin:
## Troubleshooting
- Plugin not listed: ensure the folder exists and contains `plugin.py` with a `Plugin` class.
-- Import errors: the folder name is the import name; avoid spaces or exotic characters.
+- Import errors: ensure the folder contains `plugin.py` or a package `__init__.py`. Folder names with spaces or dashes are supported; if you need to import by folder name inside your plugin, use a valid Python identifier.
- No confirmation: include a boolean field with `id: "confirm"` and set it to true or default true.
- HTTP 403 on run: the plugin is disabled; enable it from the toggle or via the `enabled/` endpoint.
diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py
index a229a07c..5ba85be2 100644
--- a/apps/plugins/api_urls.py
+++ b/apps/plugins/api_urls.py
@@ -7,6 +7,7 @@ from .api_views import (
PluginEnabledAPIView,
PluginImportAPIView,
PluginDeleteAPIView,
+ PluginLogoAPIView,
)
app_name = "plugins"
@@ -19,4 +20,5 @@ urlpatterns = [
path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"),
path("plugins//run/", PluginRunAPIView.as_view(), name="run"),
path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
+ path("plugins//logo/", PluginLogoAPIView.as_view(), name="logo"),
]
diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py
index 0d68fc7d..624dcc4d 100644
--- a/apps/plugins/api_views.py
+++ b/apps/plugins/api_views.py
@@ -1,19 +1,21 @@
import logging
+import re
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
-from rest_framework.decorators import api_view
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
-import io
+from django.http import FileResponse
import os
import zipfile
import shutil
import tempfile
+from urllib.parse import urlparse
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_method,
)
+from dispatcharr.utils import network_access_allowed
from .loader import PluginManager
from .models import PluginConfig
@@ -21,6 +23,42 @@ from .models import PluginConfig
logger = logging.getLogger(__name__)
+MAX_PLUGIN_IMPORT_FILES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILES", 2000)
+MAX_PLUGIN_IMPORT_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_BYTES", 200 * 1024 * 1024)
+MAX_PLUGIN_IMPORT_FILE_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILE_BYTES", 200 * 1024 * 1024)
+
+
+def _parse_bool(value):
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, int) and value in (0, 1):
+ return bool(value)
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in ("true", "1", "yes", "y", "on"):
+ return True
+ if normalized in ("false", "0", "no", "n", "off"):
+ return False
+ return None
+
+
+def _sanitize_plugin_key(value: str) -> str:
+ base = os.path.basename(value or "")
+ base = base.replace(" ", "_").lower()
+ base = re.sub(r"[^a-z0-9_-]", "_", base)
+ base = base.strip("._-")
+ return base or "plugin"
+
+
+def _absolutize_logo_url(request, url: str | None) -> str | None:
+ if not url or not request:
+ return url
+ parsed = urlparse(url)
+ if parsed.scheme:
+ return url
+ return request.build_absolute_uri(url)
+
+
class PluginsListAPIView(APIView):
def get_permissions(self):
try:
@@ -32,9 +70,12 @@ class PluginsListAPIView(APIView):
def get(self, request):
pm = PluginManager.get()
- # Ensure registry is up-to-date on each request
- pm.discover_plugins()
- return Response({"plugins": pm.list_plugins()})
+ # Prefer cached registry; reload explicitly via the reload endpoint
+ pm.discover_plugins(sync_db=False, use_cache=True)
+ plugins = pm.list_plugins()
+ for plugin in plugins:
+ plugin["logo_url"] = _absolutize_logo_url(request, plugin.get("logo_url"))
+ return Response({"plugins": plugins})
class PluginReloadAPIView(APIView):
@@ -48,7 +89,8 @@ class PluginReloadAPIView(APIView):
def post(self, request):
pm = PluginManager.get()
- pm.discover_plugins()
+ pm.stop_all_plugins(reason="reload")
+ pm.discover_plugins(force_reload=True)
return Response({"success": True, "count": len(pm._registry)})
@@ -81,6 +123,19 @@ class PluginImportAPIView(APIView):
if not file_members:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST)
+ if len(file_members) > MAX_PLUGIN_IMPORT_FILES:
+ shutil.rmtree(tmp_root, ignore_errors=True)
+ return Response({"success": False, "error": "Archive has too many files"}, status=status.HTTP_400_BAD_REQUEST)
+
+ total_size = 0
+ for member in file_members:
+ total_size += member.file_size
+ if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES:
+ shutil.rmtree(tmp_root, ignore_errors=True)
+ return Response({"success": False, "error": "Archive contains a file that is too large"}, status=status.HTTP_400_BAD_REQUEST)
+ if total_size > MAX_PLUGIN_IMPORT_BYTES:
+ shutil.rmtree(tmp_root, ignore_errors=True)
+ return Response({"success": False, "error": "Archive is too large"}, status=status.HTTP_400_BAD_REQUEST)
for member in file_members:
name = member.filename
@@ -115,7 +170,31 @@ class PluginImportAPIView(APIView):
plugin_key = os.path.basename(chosen.rstrip(os.sep))
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
plugin_key = base_name
- plugin_key = plugin_key.replace(" ", "_").lower()
+ plugin_key = _sanitize_plugin_key(plugin_key)
+ if len(plugin_key) > 128:
+ plugin_key = plugin_key[:128]
+ logo_bytes = None
+ try:
+ logo_candidates = []
+ chosen_abs = os.path.abspath(chosen)
+ for dirpath, _, filenames in os.walk(tmp_root):
+ for filename in filenames:
+ if filename.lower() != "logo.png":
+ continue
+ full_path = os.path.join(dirpath, filename)
+ full_abs = os.path.abspath(full_path)
+ try:
+ in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs
+ except Exception:
+ in_chosen = False
+ depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep))
+ logo_candidates.append((0 if in_chosen else 1, depth, full_path))
+ if logo_candidates:
+ logo_candidates.sort()
+ with open(logo_candidates[0][2], "rb") as fh:
+ logo_bytes = fh.read()
+ except Exception:
+ logo_bytes = None
final_dir = os.path.join(plugins_dir, plugin_key)
if os.path.exists(final_dir):
@@ -136,6 +215,12 @@ class PluginImportAPIView(APIView):
shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item))
else:
shutil.move(chosen, final_dir)
+ if logo_bytes:
+ try:
+ with open(os.path.join(final_dir, "logo.png"), "wb") as fh:
+ fh.write(logo_bytes)
+ except Exception:
+ pass
# Cleanup temp
shutil.rmtree(tmp_root, ignore_errors=True)
target_dir = final_dir
@@ -145,49 +230,50 @@ class PluginImportAPIView(APIView):
except Exception:
pass
- # Reload discovery and validate plugin entry
- pm.discover_plugins()
- plugin = pm._registry.get(plugin_key)
- if not plugin:
- # Cleanup the copied folder to avoid leaving invalid plugin behind
- try:
- shutil.rmtree(target_dir, ignore_errors=True)
- except Exception:
- pass
- return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
-
- # Extra validation: ensure Plugin.run exists
- instance = getattr(plugin, "instance", None)
- run_method = getattr(instance, "run", None)
- if not callable(run_method):
- try:
- shutil.rmtree(target_dir, ignore_errors=True)
- except Exception:
- pass
- return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST)
-
- # Find DB config to return enabled/ever_enabled
+ # Ensure DB config exists (untrusted plugins are registered without loading)
try:
- cfg = PluginConfig.objects.get(key=plugin_key)
- enabled = cfg.enabled
- ever_enabled = getattr(cfg, "ever_enabled", False)
- except PluginConfig.DoesNotExist:
- enabled = False
- ever_enabled = False
+ cfg, _ = PluginConfig.objects.get_or_create(
+ key=plugin_key,
+ defaults={
+ "name": plugin_key,
+ "version": "",
+ "description": "",
+ "settings": {},
+ },
+ )
+ except Exception:
+ cfg = None
- return Response({
- "success": True,
- "plugin": {
- "key": plugin.key,
- "name": plugin.name,
- "version": plugin.version,
- "description": plugin.description,
- "enabled": enabled,
- "ever_enabled": ever_enabled,
- "fields": plugin.fields or [],
- "actions": plugin.actions or [],
+ # Reload discovery to register the plugin (trusted plugins will load)
+ pm.discover_plugins(force_reload=True)
+ plugin_entry = None
+ try:
+ plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == plugin_key), None)
+ except Exception:
+ plugin_entry = None
+
+ if not plugin_entry:
+ logo_path = os.path.join(plugins_dir, plugin_key, "logo.png")
+ logo_url = f"/api/plugins/plugins/{plugin_key}/logo/" if os.path.isfile(logo_path) else None
+ legacy = not os.path.isfile(os.path.join(plugins_dir, plugin_key, "plugin.json"))
+ plugin_entry = {
+ "key": plugin_key,
+ "name": cfg.name if cfg else plugin_key,
+ "version": cfg.version if cfg else "",
+ "description": cfg.description if cfg else "",
+ "enabled": cfg.enabled if cfg else False,
+ "ever_enabled": getattr(cfg, "ever_enabled", False) if cfg else False,
+ "fields": [],
+ "actions": [],
+ "trusted": bool(cfg and (cfg.ever_enabled or cfg.enabled)),
+ "loaded": False,
+ "missing": False,
+ "legacy": legacy,
+ "logo_url": logo_url,
}
- })
+
+ plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
+ return Response({"success": True, "plugin": plugin_entry})
class PluginSettingsAPIView(APIView):
@@ -254,21 +340,63 @@ class PluginEnabledAPIView(APIView):
return [Authenticated()]
def post(self, request, key):
- enabled = request.data.get("enabled")
- if enabled is None:
+ enabled_raw = request.data.get("enabled")
+ if enabled_raw is None:
return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
+ enabled = _parse_bool(enabled_raw)
+ if enabled is None:
+ return Response({"success": False, "error": "Invalid 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
try:
cfg = PluginConfig.objects.get(key=key)
- cfg.enabled = bool(enabled)
+ pm = PluginManager.get()
+ if not enabled and cfg.enabled:
+ try:
+ pm.stop_plugin(key, reason="disable")
+ except Exception:
+ logger.exception("Failed to stop plugin '%s' on disable", key)
+ cfg.enabled = enabled
# Mark that this plugin has been enabled at least once
if cfg.enabled and not cfg.ever_enabled:
cfg.ever_enabled = True
cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"])
- return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled})
+ pm.discover_plugins(force_reload=True)
+ plugin_entry = None
+ try:
+ plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == key), None)
+ except Exception:
+ plugin_entry = None
+ response = {"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled}
+ if plugin_entry:
+ plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
+ response["plugin"] = plugin_entry
+ return Response(response)
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
+class PluginLogoAPIView(APIView):
+ def get_permissions(self):
+ return []
+
+ def get(self, request, key):
+ if not network_access_allowed(request, "UI"):
+ return Response({"success": False, "error": "Network access denied"}, status=status.HTTP_403_FORBIDDEN)
+ pm = PluginManager.get()
+ pm.discover_plugins(use_cache=True)
+ plugins_dir = pm.plugins_dir
+ logo_path = os.path.join(plugins_dir, key, "logo.png")
+ lp = pm.get_plugin(key)
+ if lp and getattr(lp, "path", None):
+ logo_path = os.path.join(lp.path, "logo.png")
+ abs_plugins = os.path.abspath(plugins_dir) + os.sep
+ abs_target = os.path.abspath(logo_path)
+ if not abs_target.startswith(abs_plugins):
+ return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
+ if not os.path.isfile(logo_path):
+ return Response({"success": False, "error": "Logo not found"}, status=status.HTTP_404_NOT_FOUND)
+ return FileResponse(open(logo_path, "rb"), content_type="image/png")
+
+
class PluginDeleteAPIView(APIView):
def get_permissions(self):
try:
@@ -280,6 +408,10 @@ class PluginDeleteAPIView(APIView):
def delete(self, request, key):
pm = PluginManager.get()
+ try:
+ pm.stop_plugin(key, reason="delete")
+ except Exception:
+ logger.exception("Failed to stop plugin '%s' before delete", key)
plugins_dir = pm.plugins_dir
target_dir = os.path.join(plugins_dir, key)
# Safety: ensure path inside plugins_dir
@@ -302,5 +434,5 @@ class PluginDeleteAPIView(APIView):
pass
# Reload registry
- pm.discover_plugins()
+ pm.discover_plugins(force_reload=True)
return Response({"success": True})
diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py
index 5422ae7e..4b53e08b 100644
--- a/apps/plugins/loader.py
+++ b/apps/plugins/loader.py
@@ -1,8 +1,12 @@
import importlib
+import importlib.util
import json
import logging
import os
+import re
import sys
+import threading
+import types
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@@ -19,10 +23,17 @@ class LoadedPlugin:
name: str
version: str = ""
description: str = ""
+ author: str = ""
+ help_url: str = ""
module: Any = None
instance: Any = None
fields: List[Dict[str, Any]] = field(default_factory=list)
actions: List[Dict[str, Any]] = field(default_factory=list)
+ trusted: bool = False
+ loaded: bool = False
+ path: Optional[str] = None
+ folder_name: Optional[str] = None
+ legacy: bool = False
class PluginManager:
@@ -39,70 +50,254 @@ class PluginManager:
def __init__(self) -> None:
self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins")
self._registry: Dict[str, LoadedPlugin] = {}
+ self._package_names: Dict[str, str] = {}
+ self._alias_names: Dict[str, str] = {}
+ self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token")
+ self._last_reload_token = 0.0
+ self._lock = threading.RLock()
# Ensure plugins directory exists
os.makedirs(self.plugins_dir, exist_ok=True)
if self.plugins_dir not in sys.path:
sys.path.append(self.plugins_dir)
- def discover_plugins(self, *, sync_db: bool = True) -> Dict[str, LoadedPlugin]:
+ def discover_plugins(
+ self,
+ *,
+ sync_db: bool = True,
+ force_reload: bool = False,
+ use_cache: bool = False,
+ ) -> Dict[str, LoadedPlugin]:
+ token = self._get_reload_token()
+ if use_cache and not force_reload:
+ with self._lock:
+ if self._registry and token <= self._last_reload_token:
+ return self._registry
+ if token > self._last_reload_token:
+ force_reload = True
+ if force_reload:
+ self._touch_reload_token()
+ token = self._get_reload_token()
+
if sync_db:
logger.info(f"Discovering plugins in {self.plugins_dir}")
else:
logger.debug(f"Discovering plugins (no DB sync) in {self.plugins_dir}")
- self._registry.clear()
+
+ with self._lock:
+ previous_packages = dict(self._package_names)
+ previous_aliases = dict(self._alias_names)
+ previous_paths = {
+ key: lp.path for key, lp in self._registry.items() if lp and lp.path
+ }
try:
+ configs: Optional[Dict[str, PluginConfig]] = None
+ try:
+ configs = {c.key: c for c in PluginConfig.objects.all()}
+ except Exception:
+ # DB might not be ready; treat all plugins as untrusted
+ configs = None
+
+ new_registry: Dict[str, LoadedPlugin] = {}
+ new_packages: Dict[str, str] = {}
+ new_aliases: Dict[str, str] = {}
for entry in sorted(os.listdir(self.plugins_dir)):
path = os.path.join(self.plugins_dir, entry)
if not os.path.isdir(path):
continue
+ has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
+ has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
+ if not (has_pkg or has_pluginpy):
+ continue
+
plugin_key = entry.replace(" ", "_").lower()
+ alias_name = self._resolve_alias_name(entry, path)
+
+ if force_reload:
+ prev_alias = previous_aliases.get(plugin_key)
+ if prev_alias:
+ self._unload_alias(prev_alias)
+ prev_path = previous_paths.get(plugin_key)
+ if prev_path:
+ self._unload_path_modules(prev_path)
+
+ cfg = configs.get(plugin_key) if configs else None
+ enabled = bool(cfg and cfg.enabled)
+ trusted = bool(cfg and (cfg.ever_enabled or cfg.enabled))
+
+ manifest, has_manifest = self._read_manifest(path)
+ legacy = not has_manifest
+ manifest_name = None
+ manifest_version = None
+ manifest_description = None
+ manifest_author = None
+ manifest_help_url = None
+ manifest_fields: List[Dict[str, Any]] = []
+ manifest_actions: List[Dict[str, Any]] = []
+ if has_manifest and isinstance(manifest, dict):
+ manifest_name = manifest.get("name") if isinstance(manifest.get("name"), str) else None
+ manifest_version = manifest.get("version") if isinstance(manifest.get("version"), str) else None
+ manifest_description = manifest.get("description") if isinstance(manifest.get("description"), str) else None
+ manifest_author = manifest.get("author") if isinstance(manifest.get("author"), str) else None
+ manifest_help_url = manifest.get("help_url") if isinstance(manifest.get("help_url"), str) else None
+ manifest_fields = self._normalize_fields(manifest.get("fields", []))
+ manifest_actions = self._normalize_actions(manifest.get("actions", []))
+
+ display_name = manifest_name or entry
+ display_version = (
+ manifest_version if manifest_version is not None else (cfg.version if cfg else "")
+ )
+ display_description = (
+ manifest_description if manifest_description is not None else (cfg.description if cfg else "")
+ )
+
+ def _make_placeholder() -> LoadedPlugin:
+ return LoadedPlugin(
+ key=plugin_key,
+ name=display_name,
+ version=display_version,
+ description=display_description,
+ author=manifest_author or "",
+ help_url=manifest_help_url or "",
+ fields=manifest_fields if has_manifest else [],
+ actions=manifest_actions if has_manifest else [],
+ trusted=trusted,
+ loaded=False,
+ path=path,
+ folder_name=entry,
+ legacy=legacy,
+ )
+
+ if not enabled:
+ new_registry[plugin_key] = _make_placeholder()
+ continue
try:
- self._load_plugin(plugin_key, path)
+ lp, package_name = self._load_plugin(
+ plugin_key,
+ path,
+ folder_name=entry,
+ force_reload=force_reload,
+ previous_package=previous_packages.get(plugin_key),
+ )
+ if lp:
+ if manifest_name and (not lp.name or lp.name == plugin_key):
+ lp.name = manifest_name
+ if manifest_version is not None and not lp.version:
+ lp.version = manifest_version
+ if manifest_description is not None and not lp.description:
+ lp.description = manifest_description
+ if manifest_author is not None and not lp.author:
+ lp.author = manifest_author
+ if manifest_help_url is not None and not lp.help_url:
+ lp.help_url = manifest_help_url
+ if manifest_fields and not lp.fields:
+ lp.fields = manifest_fields
+ if manifest_actions and not lp.actions:
+ lp.actions = manifest_actions
+ lp.trusted = trusted
+ lp.loaded = True
+ lp.path = path
+ lp.folder_name = entry
+ lp.legacy = legacy
+ new_registry[plugin_key] = lp
+ if package_name:
+ new_packages[plugin_key] = package_name
+ if alias_name:
+ new_aliases[plugin_key] = alias_name
+ else:
+ new_registry[plugin_key] = _make_placeholder()
except Exception:
logger.exception(f"Failed to load plugin '{plugin_key}' from {path}")
+ new_registry[plugin_key] = _make_placeholder()
- logger.info(f"Discovered {len(self._registry)} plugin(s)")
+ if force_reload:
+ # Remove stale modules for plugins that no longer exist
+ removed_keys = set(previous_packages.keys()) - set(new_packages.keys())
+ for key in removed_keys:
+ self._unload_package(previous_packages[key])
+ prev_alias = previous_aliases.get(key)
+ if prev_alias:
+ self._unload_alias(prev_alias)
+ prev_path = previous_paths.get(key)
+ if prev_path:
+ self._unload_path_modules(prev_path)
+
+ with self._lock:
+ self._registry = new_registry
+ self._package_names = new_packages
+ self._alias_names = new_aliases
+ if token > self._last_reload_token:
+ self._last_reload_token = token
+
+ logger.info(f"Discovered {len(new_registry)} plugin(s)")
except FileNotFoundError:
logger.warning(f"Plugins directory not found: {self.plugins_dir}")
# Sync DB records (optional)
if sync_db:
try:
- self._sync_db_with_registry()
+ self._sync_db_with_registry(new_registry if 'new_registry' in locals() else None)
except Exception:
# Defer sync if database is not ready (e.g., first startup before migrate)
logger.exception("Deferring plugin DB sync; database not ready yet")
return self._registry
- def _load_plugin(self, key: str, path: str):
+ def _load_plugin(
+ self,
+ key: str,
+ path: str,
+ *,
+ folder_name: str,
+ force_reload: bool,
+ previous_package: Optional[str],
+ ) -> tuple[Optional[LoadedPlugin], Optional[str]]:
# Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
if not (has_pkg or has_pluginpy):
logger.debug(f"Skipping {path}: no plugin.py or package")
- return
+ return None, None
- candidate_modules = []
- if has_pluginpy:
- candidate_modules.append(f"{key}.plugin")
- if has_pkg:
- candidate_modules.append(key)
+ package_name = self._resolve_package_name(key)
+ alias_name = self._resolve_alias_name(folder_name, path)
+
+ if force_reload and previous_package:
+ self._unload_package(previous_package)
module = None
plugin_cls = None
last_error = None
- for module_name in candidate_modules:
+
+ # Ensure a package context exists for plugin.py (even without __init__.py)
+ if has_pluginpy:
+ self._ensure_namespace_package(package_name, path, alias=alias_name)
+
+ module_name = f"{package_name}.plugin"
+ plugin_path = os.path.join(path, "plugin.py")
try:
- logger.debug(f"Importing plugin module {module_name}")
- module = importlib.import_module(module_name)
+ logger.debug(f"Importing plugin module {module_name} from {plugin_path}")
+ module = self._load_module_from_path(module_name, plugin_path, is_package=False)
+ if alias_name:
+ self._register_alias_module(f"{alias_name}.plugin", module, path)
plugin_cls = getattr(module, "Plugin", None)
- if plugin_cls is not None:
- break
- else:
+ if plugin_cls is None:
+ logger.warning(f"Module {module_name} has no Plugin class")
+ except Exception as e:
+ last_error = e
+ logger.exception(f"Error importing module {module_name}")
+
+ if plugin_cls is None and has_pkg:
+ module_name = package_name
+ init_path = os.path.join(path, "__init__.py")
+ try:
+ logger.debug(f"Importing plugin package {module_name} from {init_path}")
+ module = self._load_module_from_path(module_name, init_path, is_package=True)
+ self._register_alias_module(alias_name, module, path)
+ plugin_cls = getattr(module, "Plugin", None)
+ if plugin_cls is None:
logger.warning(f"Module {module_name} has no Plugin class")
except Exception as e:
last_error = e
@@ -111,32 +306,43 @@ class PluginManager:
if plugin_cls is None:
if last_error:
raise last_error
- else:
- logger.warning(f"No Plugin class found for {key}; skipping")
- return
+ logger.warning(f"No Plugin class found for {key}; skipping")
+ return None, package_name
instance = plugin_cls()
name = getattr(instance, "name", key)
version = getattr(instance, "version", "")
description = getattr(instance, "description", "")
+ author = getattr(instance, "author", "")
+ help_url = getattr(instance, "help_url", "")
fields = getattr(instance, "fields", [])
actions = getattr(instance, "actions", [])
+ fields = self._normalize_fields(fields)
+ actions = self._normalize_actions(actions)
- self._registry[key] = LoadedPlugin(
+ lp = LoadedPlugin(
key=key,
name=name,
version=version,
description=description,
+ author=author or "",
+ help_url=help_url or "",
module=module,
instance=instance,
fields=fields,
actions=actions,
+ path=path,
+ folder_name=folder_name,
)
+ return lp, package_name
- def _sync_db_with_registry(self):
+ def _sync_db_with_registry(self, registry: Optional[Dict[str, LoadedPlugin]] = None):
+ if registry is None:
+ with self._lock:
+ registry = dict(self._registry)
with transaction.atomic():
- for key, lp in self._registry.items():
+ for key, lp in registry.items():
obj, _ = PluginConfig.objects.get_or_create(
key=key,
defaults={
@@ -164,6 +370,8 @@ class PluginManager:
from .models import PluginConfig
plugins: List[Dict[str, Any]] = []
+ with self._lock:
+ registry_snapshot = dict(self._registry)
try:
configs = {c.key: c for c in PluginConfig.objects.all()}
except Exception as e:
@@ -172,25 +380,33 @@ class PluginManager:
configs = {}
# First, include all discovered plugins
- for key, lp in self._registry.items():
+ for key, lp in registry_snapshot.items():
conf = configs.get(key)
+ trusted = bool(conf and (conf.ever_enabled or conf.enabled))
+ logo_url = self._get_logo_url(key, path=lp.path)
plugins.append(
{
"key": key,
"name": lp.name,
"version": lp.version,
"description": lp.description,
+ "author": getattr(lp, "author", "") or "",
+ "help_url": getattr(lp, "help_url", "") or "",
"enabled": conf.enabled if conf else False,
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
"missing": False,
+ "trusted": trusted,
+ "loaded": bool(lp.loaded),
+ "legacy": bool(getattr(lp, "legacy", False)),
+ "logo_url": logo_url,
}
)
# Then, include any DB-only configs (files missing or failed to load)
- discovered_keys = set(self._registry.keys())
+ discovered_keys = set(registry_snapshot.keys())
for key, conf in configs.items():
if key in discovered_keys:
continue
@@ -200,19 +416,26 @@ class PluginManager:
"name": conf.name,
"version": conf.version,
"description": conf.description,
+ "author": "",
+ "help_url": "",
"enabled": conf.enabled,
"ever_enabled": getattr(conf, "ever_enabled", False),
"fields": [],
"settings": conf.settings or {},
"actions": [],
"missing": True,
+ "trusted": bool(conf.ever_enabled or conf.enabled),
+ "loaded": False,
+ "legacy": False,
+ "logo_url": self._get_logo_url(key),
}
)
return plugins
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
- return self._registry.get(key)
+ with self._lock:
+ return self._registry.get(key)
def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]:
cfg = PluginConfig.objects.get(key=key)
@@ -223,19 +446,18 @@ class PluginManager:
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
lp = self.get_plugin(key)
if not lp or not lp.instance:
- raise ValueError(f"Plugin '{key}' not found")
+ # Attempt a lightweight re-discovery in case the registry was rebuilt
+ self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
+ lp = self.get_plugin(key)
+ if not lp or not lp.instance:
+ raise ValueError(f"Plugin '{key}' not found")
cfg = PluginConfig.objects.get(key=key)
if not cfg.enabled:
raise PermissionError(f"Plugin '{key}' is disabled")
params = params or {}
- # Provide a context object to the plugin
- context = {
- "settings": cfg.settings or {},
- "logger": logger,
- "actions": {a.get("id"): a for a in (lp.actions or [])},
- }
+ context = self._build_context(lp, cfg)
# Run either via Celery if plugin provides a delayed method, or inline
run_method = getattr(lp.instance, "run", None)
@@ -252,3 +474,286 @@ class PluginManager:
if isinstance(result, dict):
return result
return {"status": "ok", "result": result}
+
+ def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
+ lp = self.get_plugin(key)
+ if not lp or not lp.instance:
+ return False
+ try:
+ cfg = PluginConfig.objects.get(key=key)
+ except PluginConfig.DoesNotExist:
+ return False
+ if not cfg.enabled:
+ return False
+
+ context = self._build_context(lp, cfg)
+ if reason:
+ context["reason"] = reason
+
+ stop_method = getattr(lp.instance, "stop", None)
+ if callable(stop_method):
+ try:
+ stop_method(context)
+ return True
+ except TypeError:
+ try:
+ stop_method()
+ return True
+ except Exception:
+ logger.exception("Plugin '%s' stop() failed", key)
+ return False
+ except Exception:
+ logger.exception("Plugin '%s' stop() failed", key)
+ return False
+
+ run_method = getattr(lp.instance, "run", None)
+ if callable(run_method):
+ actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
+ if "stop" in actions:
+ try:
+ run_method("stop", {}, context)
+ return True
+ except Exception:
+ logger.exception("Plugin '%s' stop action failed", key)
+ return False
+ return False
+
+ def stop_all_plugins(self, reason: Optional[str] = None) -> int:
+ stopped = 0
+ with self._lock:
+ registry_snapshot = dict(self._registry)
+ for key in registry_snapshot.keys():
+ if self.stop_plugin(key, reason=reason):
+ stopped += 1
+ return stopped
+
+ def _resolve_package_name(self, key: str) -> str:
+ safe_key = self._safe_module_name(key)
+ return f"_dispatcharr_plugin_{safe_key}"
+
+ def _resolve_alias_name(self, folder_name: str, path: str) -> Optional[str]:
+ if not self._is_valid_identifier(folder_name):
+ return None
+ if self._is_reserved_module_name(folder_name, path):
+ return None
+ return folder_name
+
+ def _is_valid_identifier(self, name: str) -> bool:
+ return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is not None
+
+ def _safe_module_name(self, value: str) -> str:
+ safe = re.sub(r"[^0-9A-Za-z_]", "_", value)
+ if not safe or safe[0].isdigit():
+ safe = f"p_{safe}"
+ return safe
+
+ def _normalize_fields(self, fields: Any) -> List[Dict[str, Any]]:
+ try:
+ from .serializers import PluginFieldSerializer
+ except Exception:
+ return fields if isinstance(fields, list) else []
+ if not isinstance(fields, list):
+ return []
+ serializer = PluginFieldSerializer(data=fields, many=True)
+ if serializer.is_valid():
+ return serializer.validated_data
+ normalized: List[Dict[str, Any]] = []
+ for item in fields:
+ item_ser = PluginFieldSerializer(data=item)
+ if item_ser.is_valid():
+ normalized.append(item_ser.validated_data)
+ else:
+ logger.warning("Invalid plugin field entry ignored: %s", item_ser.errors)
+ return normalized
+
+ def _normalize_actions(self, actions: Any) -> List[Dict[str, Any]]:
+ try:
+ from .serializers import PluginActionSerializer
+ except Exception:
+ return actions if isinstance(actions, list) else []
+ if not isinstance(actions, list):
+ return []
+ serializer = PluginActionSerializer(data=actions, many=True)
+ if serializer.is_valid():
+ return serializer.validated_data
+ normalized: List[Dict[str, Any]] = []
+ for item in actions:
+ item_ser = PluginActionSerializer(data=item)
+ if item_ser.is_valid():
+ normalized.append(item_ser.validated_data)
+ else:
+ logger.warning("Invalid plugin action entry ignored: %s", item_ser.errors)
+ return normalized
+
+ def _merge_settings_with_defaults(self, settings: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]:
+ merged = dict(settings or {})
+ for field_def in fields or []:
+ field_id = field_def.get("id")
+ if not field_id:
+ continue
+ if field_id not in merged and "default" in field_def:
+ merged[field_id] = field_def.get("default")
+ return merged
+
+ def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig) -> Dict[str, Any]:
+ settings = self._merge_settings_with_defaults(cfg.settings or {}, lp.fields or [])
+ return {
+ "settings": settings,
+ "logger": logger,
+ "actions": {a.get("id"): a for a in (lp.actions or [])},
+ }
+
+ def _read_manifest(self, path: str) -> tuple[Optional[Dict[str, Any]], bool]:
+ manifest_path = os.path.join(path, "plugin.json")
+ if not os.path.isfile(manifest_path):
+ return None, False
+ try:
+ with open(manifest_path, "r", encoding="utf-8") as fh:
+ data = json.load(fh)
+ except Exception:
+ logger.warning("Invalid plugin.json for plugin at %s", path)
+ return None, False
+ if not isinstance(data, dict):
+ logger.warning("plugin.json must be an object for plugin at %s", path)
+ return None, False
+ return data, True
+
+ def _get_logo_url(self, key: str, *, path: Optional[str] = None) -> Optional[str]:
+ logo_path = os.path.join(self.plugins_dir, key, "logo.png")
+ if path:
+ logo_path = os.path.join(path, "logo.png")
+ try:
+ if os.path.isfile(logo_path):
+ return f"/api/plugins/plugins/{key}/logo/"
+ except Exception:
+ return None
+ return None
+
+ def _ensure_namespace_package(self, package_name: str, path: str, *, alias: Optional[str] = None) -> None:
+ existing = sys.modules.get(package_name)
+ if existing and getattr(existing, "__path__", None):
+ return
+ pkg = types.ModuleType(package_name)
+ pkg.__path__ = [path]
+ pkg.__package__ = package_name
+ sys.modules[package_name] = pkg
+ self._register_alias_module(alias, pkg, path)
+
+ def _register_alias_module(
+ self,
+ alias_name: Optional[str],
+ module: Any,
+ path: str,
+ *,
+ force: bool = False,
+ ) -> None:
+ if not alias_name:
+ return
+ if self._is_reserved_module_name(alias_name, path):
+ return
+ if alias_name in sys.modules:
+ if not force:
+ return
+ self._unload_alias(alias_name)
+ sys.modules[alias_name] = module
+
+ def _is_reserved_module_name(self, name: str, path: str) -> bool:
+ if name in sys.builtin_module_names:
+ return True
+ if hasattr(sys, "stdlib_module_names") and name in sys.stdlib_module_names:
+ return True
+ existing = sys.modules.get(name)
+ if existing:
+ origin = getattr(existing, "__file__", None)
+ if origin is None:
+ return True
+ try:
+ if not os.path.abspath(origin).startswith(os.path.abspath(path)):
+ return True
+ except Exception:
+ return True
+ try:
+ spec = importlib.util.find_spec(name)
+ except Exception:
+ spec = None
+ if spec:
+ if spec.origin is None:
+ return True
+ try:
+ if not os.path.abspath(spec.origin).startswith(os.path.abspath(path)):
+ return True
+ except Exception:
+ return True
+ return False
+
+ def _load_module_from_path(self, module_name: str, path: str, *, is_package: bool) -> Any:
+ importlib.invalidate_caches()
+ spec = importlib.util.spec_from_file_location(
+ module_name,
+ path,
+ submodule_search_locations=[os.path.dirname(path)] if is_package else None,
+ )
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Could not load spec for {module_name} from {path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+ spec.loader.exec_module(module)
+ return module
+
+ def _get_reload_token(self) -> float:
+ try:
+ return os.path.getmtime(self._reload_token_path)
+ except FileNotFoundError:
+ return 0.0
+ except Exception:
+ return 0.0
+
+ def _touch_reload_token(self) -> None:
+ try:
+ os.makedirs(self.plugins_dir, exist_ok=True)
+ with open(self._reload_token_path, "a", encoding="utf-8"):
+ pass
+ os.utime(self._reload_token_path, None)
+ except Exception:
+ logger.debug("Failed to update plugin reload token", exc_info=True)
+
+ def _unload_package(self, package_name: str) -> None:
+ if not package_name:
+ return
+ for name in list(sys.modules.keys()):
+ if name == package_name or name.startswith(f"{package_name}."):
+ sys.modules.pop(name, None)
+
+ def _unload_alias(self, alias_name: str) -> None:
+ if not alias_name:
+ return
+ for name in list(sys.modules.keys()):
+ if name == alias_name or name.startswith(f"{alias_name}."):
+ sys.modules.pop(name, None)
+
+ def _unload_path_modules(self, path: str) -> None:
+ if not path:
+ return
+ root = os.path.abspath(path)
+ for name, module in list(sys.modules.items()):
+ if not module:
+ continue
+ mod_path = getattr(module, "__file__", None)
+ if mod_path:
+ try:
+ abs_path = os.path.abspath(mod_path)
+ if abs_path == root or abs_path.startswith(f"{root}{os.sep}"):
+ sys.modules.pop(name, None)
+ continue
+ except Exception:
+ pass
+ mod_paths = getattr(module, "__path__", None)
+ if mod_paths:
+ try:
+ for pkg_path in mod_paths:
+ abs_pkg = os.path.abspath(pkg_path)
+ if abs_pkg == root or abs_pkg.startswith(f"{root}{os.sep}"):
+ sys.modules.pop(name, None)
+ break
+ except Exception:
+ continue
diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py
index cc7b1882..172af265 100644
--- a/apps/plugins/serializers.py
+++ b/apps/plugins/serializers.py
@@ -5,15 +5,31 @@ class PluginActionSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
description = serializers.CharField(required=False, allow_blank=True)
+ confirm = serializers.JSONField(required=False)
+ button_label = serializers.CharField(required=False, allow_blank=True)
+ button_variant = serializers.CharField(required=False, allow_blank=True)
+ button_color = serializers.CharField(required=False, allow_blank=True)
+
+
+class PluginFieldOptionSerializer(serializers.Serializer):
+ value = serializers.CharField()
+ label = serializers.CharField()
class PluginFieldSerializer(serializers.Serializer):
id = serializers.CharField()
- label = serializers.CharField()
- type = serializers.ChoiceField(choices=["string", "number", "boolean", "select"]) # simple types
+ label = serializers.CharField(required=False, allow_blank=True)
+ type = serializers.ChoiceField(choices=["string", "number", "boolean", "select", "text", "info"])
default = serializers.JSONField(required=False)
help_text = serializers.CharField(required=False, allow_blank=True)
- options = serializers.ListField(child=serializers.DictField(), required=False)
+ description = serializers.CharField(required=False, allow_blank=True)
+ placeholder = serializers.CharField(required=False, allow_blank=True)
+ input_type = serializers.CharField(required=False, allow_blank=True)
+ min = serializers.FloatField(required=False)
+ max = serializers.FloatField(required=False)
+ step = serializers.FloatField(required=False)
+ value = serializers.CharField(required=False, allow_blank=True)
+ options = PluginFieldOptionSerializer(many=True, required=False)
class PluginSerializer(serializers.Serializer):
@@ -21,8 +37,9 @@ class PluginSerializer(serializers.Serializer):
name = serializers.CharField()
version = serializers.CharField(allow_blank=True)
description = serializers.CharField(allow_blank=True)
+ author = serializers.CharField(required=False, allow_blank=True)
+ help_url = serializers.CharField(required=False, allow_blank=True)
enabled = serializers.BooleanField()
fields = PluginFieldSerializer(many=True)
settings = serializers.JSONField()
actions = PluginActionSerializer(many=True)
-
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 676e97d8..9cc5494c 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1770,6 +1770,7 @@ export default class API {
return response?.settings || {};
} catch (e) {
errorNotification('Failed to update plugin settings', e);
+ throw e;
}
}
diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx
index 1293bf7b..186ef7e0 100644
--- a/frontend/src/components/Field.jsx
+++ b/frontend/src/components/Field.jsx
@@ -1,17 +1,33 @@
-import { NumberInput, Select, Switch, TextInput } from '@mantine/core';
+import { NumberInput, Select, Switch, Text, Textarea, TextInput } from '@mantine/core';
import React from 'react';
export const Field = ({ field, value, onChange }) => {
- const common = { label: field.label, description: field.help_text };
+ const description = field.help_text ?? field.description ?? field.value;
+ const common = { label: field.label, description };
const effective = value ?? field.default;
switch (field.type) {
+ case 'info':
+ return (
+
+ {field.label && (
+
+ {field.label}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ );
case 'boolean':
return (
onChange(field.id, e.currentTarget.checked)}
label={field.label}
- description={field.help_text}
+ description={description}
/>
);
case 'number':
@@ -19,6 +35,7 @@ export const Field = ({ field, value, onChange }) => {
onChange(field.id, v)}
+ placeholder={field.placeholder}
{...common}
/>
);
@@ -31,6 +48,16 @@ export const Field = ({ field, value, onChange }) => {
label: o.label,
}))}
onChange={(v) => onChange(field.id, v)}
+ placeholder={field.placeholder}
+ {...common}
+ />
+ );
+ case 'text':
+ return (
+
- );
-});
-
-const HourTimeline = React.memo(({
- hourTimeline,
- timeFormat,
- formatDayLabel,
- handleTimeClick
- }) => {
- return (
- <>
- {hourTimeline.map((hourData) => (
-
- ))}
- >
- );
-});
+ >
+ );
+ }
+);
export default HourTimeline;
diff --git a/frontend/src/components/RecordingSynopsis.jsx b/frontend/src/components/RecordingSynopsis.jsx
index bf668afe..e8c92d79 100644
--- a/frontend/src/components/RecordingSynopsis.jsx
+++ b/frontend/src/components/RecordingSynopsis.jsx
@@ -1,4 +1,4 @@
-import { Text, } from '@mantine/core';
+import { Text } from '@mantine/core';
// Short preview that triggers the details modal when clicked
const RecordingSynopsis = ({ description, onOpen }) => {
@@ -23,4 +23,4 @@ const RecordingSynopsis = ({ description, onOpen }) => {
);
};
-export default RecordingSynopsis;
\ No newline at end of file
+export default RecordingSynopsis;
diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx
index 68df4728..e16cec34 100644
--- a/frontend/src/components/cards/PluginCard.jsx
+++ b/frontend/src/components/cards/PluginCard.jsx
@@ -29,7 +29,12 @@ const PluginFieldList = ({ plugin, settings, updateField }) => {
));
};
-const PluginActionList = ({ plugin, enabled, runningActionId, handlePluginRun }) => {
+const PluginActionList = ({
+ plugin,
+ enabled,
+ runningActionId,
+ handlePluginRun,
+}) => {
return plugin.actions.map((action) => (
@@ -226,7 +231,12 @@ const PluginCard = ({
style={{ opacity: !missing && enabled ? 1 : 0.6 }}
>
-
+
)}
- {expanded && !missing && enabled && plugin.fields && plugin.fields.length > 0 && (
-
-
-
-
- Save Settings
-
-
-
- )}
-
- {expanded && !missing && enabled && plugin.actions && plugin.actions.length > 0 && (
- <>
-
-
- 0 && (
+
+
-
+
+
+ Save Settings
+
+
- >
- )}
+ )}
+
+ {expanded &&
+ !missing &&
+ enabled &&
+ plugin.actions &&
+ plugin.actions.length > 0 && (
+ <>
+
+
+
+
+
+ >
+ )}
);
};
diff --git a/frontend/src/components/cards/SeriesCard.jsx b/frontend/src/components/cards/SeriesCard.jsx
index f010cb44..0d2c7ef3 100644
--- a/frontend/src/components/cards/SeriesCard.jsx
+++ b/frontend/src/components/cards/SeriesCard.jsx
@@ -8,8 +8,8 @@ import {
Stack,
Text,
} from '@mantine/core';
-import {Calendar, Play, Star} from "lucide-react";
-import React from "react";
+import { Calendar, Play, Star } from 'lucide-react';
+import React from 'react';
const SeriesCard = ({ series, onClick }) => {
return (
@@ -82,4 +82,4 @@ const SeriesCard = ({ series, onClick }) => {
);
};
-export default SeriesCard;
\ No newline at end of file
+export default SeriesCard;
diff --git a/frontend/src/components/cards/VODCard.jsx b/frontend/src/components/cards/VODCard.jsx
index 42468dae..ff7c86bd 100644
--- a/frontend/src/components/cards/VODCard.jsx
+++ b/frontend/src/components/cards/VODCard.jsx
@@ -140,4 +140,4 @@ const VODCard = ({ vod, onClick }) => {
);
};
-export default VODCard;
\ No newline at end of file
+export default VODCard;
diff --git a/frontend/src/components/forms/ProgramRecordingModal.jsx b/frontend/src/components/forms/ProgramRecordingModal.jsx
index 777c39d0..9503f26b 100644
--- a/frontend/src/components/forms/ProgramRecordingModal.jsx
+++ b/frontend/src/components/forms/ProgramRecordingModal.jsx
@@ -74,23 +74,33 @@ export default function ProgramRecordingModal({
Just this one
- {
- onRecordSeriesAll();
- onClose();
- }}>
+ {
+ onRecordSeriesAll();
+ onClose();
+ }}
+ >
Every episode
- {
- onRecordSeriesNew();
- onClose();
- }}>
+ {
+ onRecordSeriesNew();
+ onClose();
+ }}
+ >
New episodes only
{recording && (
<>
-
+
Remove this recording
diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx
index 90080676..640dcd18 100644
--- a/frontend/src/components/forms/Recording.jsx
+++ b/frontend/src/components/forms/Recording.jsx
@@ -44,7 +44,11 @@ const toIsoIfDate = (value) => {
const toTimeString = (value) => {
if (!value) return '00:00';
if (typeof value === 'string') {
- const parsed = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], true);
+ const parsed = dayjs(
+ value,
+ ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'],
+ true
+ );
if (parsed.isValid()) return parsed.format('HH:mm');
return value;
}
@@ -77,7 +81,12 @@ const timeChange = (setter) => (valOrEvent) => {
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
};
-const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => {
+const RecordingModal = ({
+ recording = null,
+ channel = null,
+ isOpen,
+ onClose,
+}) => {
const channels = useChannelsStore((s) => s.channels);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
@@ -93,9 +102,17 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
const singleForm = useForm({
mode: 'controlled',
initialValues: {
- channel_id: recording ? `${recording.channel}` : channel ? `${channel.id}` : '',
- start_time: recording ? asDate(recording.start_time) || defaultStart : defaultStart,
- end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd,
+ channel_id: recording
+ ? `${recording.channel}`
+ : channel
+ ? `${channel.id}`
+ : '',
+ start_time: recording
+ ? asDate(recording.start_time) || defaultStart
+ : defaultStart,
+ end_time: recording
+ ? asDate(recording.end_time) || defaultEnd
+ : defaultEnd,
},
validate: {
channel_id: isNotEmpty('Select a channel'),
@@ -126,13 +143,22 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
},
validate: {
channel_id: isNotEmpty('Select a channel'),
- days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'),
+ days_of_week: (value) =>
+ value && value.length ? null : 'Pick at least one day',
start_time: (value) => (value ? null : 'Select a start time'),
end_time: (value, values) => {
if (!value) return 'Select an end time';
- const start = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
+ const start = dayjs(
+ values.start_time,
+ ['HH:mm', 'hh:mm A', 'h:mm A'],
+ true
+ );
const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
- if (start.isValid() && end.isValid() && end.diff(start, 'minute') === 0) {
+ if (
+ start.isValid() &&
+ end.isValid() &&
+ end.diff(start, 'minute') === 0
+ ) {
return 'End time must differ from start time';
}
return null;
@@ -192,7 +218,10 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
return aNum - bNum;
});
- return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` }));
+ return list.map((item) => ({
+ value: `${item.id}`,
+ label: item.name || `Channel ${item.id}`,
+ }));
}, [channels]);
const resetForms = () => {
@@ -287,7 +316,8 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
icon={}
style={{ paddingBottom: 5, marginBottom: 12 }}
>
- Recordings may fail if active streams or overlapping recordings use up all available tuners.
+ Recordings may fail if active streams or overlapping recordings use up
+ all available tuners.
@@ -330,14 +360,24 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
key={singleForm.key('start_time')}
label="Start"
valueFormat="MMM D, YYYY h:mm A"
- timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }}
+ timeInputProps={{
+ format: '12',
+ withSeconds: false,
+ amLabel: 'AM',
+ pmLabel: 'PM',
+ }}
/>
>
) : (
@@ -364,14 +404,19 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
label="Start date"
value={recurringForm.values.start_date}
onChange={(value) =>
- recurringForm.setFieldValue('start_date', value || new Date())
+ recurringForm.setFieldValue(
+ 'start_date',
+ value || new Date()
+ )
}
valueFormat="MMM D, YYYY"
/>
recurringForm.setFieldValue('end_date', value)}
+ onChange={(value) =>
+ recurringForm.setFieldValue('end_date', value)
+ }
valueFormat="MMM D, YYYY"
minDate={recurringForm.values.start_date || undefined}
/>
@@ -382,11 +427,14 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
label="Start time"
value={recurringForm.values.start_time}
onChange={timeChange((val) =>
- recurringForm.setFieldValue('start_time', toTimeString(val))
+ recurringForm.setFieldValue(
+ 'start_time',
+ toTimeString(val)
+ )
)}
onBlur={() => recurringForm.validateField('start_time')}
withSeconds={false}
- format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
+ format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
inputMode="numeric"
amLabel="AM"
pmLabel="PM"
diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx
index 3d890971..f2e6d489 100644
--- a/frontend/src/components/forms/SeriesRecordingModal.jsx
+++ b/frontend/src/components/forms/SeriesRecordingModal.jsx
@@ -2,14 +2,17 @@ import React from 'react';
import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core';
import useChannelsStore from '../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
-import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js';
+import {
+ evaluateSeriesRulesByTvgId,
+ fetchRules,
+} from '../../pages/guideUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
export default function SeriesRecordingModal({
- opened,
- onClose,
- rules,
- onRulesUpdate
+ opened,
+ onClose,
+ rules,
+ onRulesUpdate,
}) {
const handleEvaluateNow = async (r) => {
await evaluateSeriesRulesByTvgId(r.tvg_id);
@@ -56,35 +59,36 @@ export default function SeriesRecordingModal({
No series rules configured
)}
- {rules && rules.map((r) => (
-
-
- {r.title || r.tvg_id} —{' '}
- {r.mode === 'new' ? 'New episodes' : 'Every episode'}
-
-
- handleEvaluateNow(r)}
- >
- Evaluate Now
-
- handleRemoveSeries(r)}
- >
- Remove this series (scheduled)
-
-
-
- ))}
+ {rules &&
+ rules.map((r) => (
+
+
+ {r.title || r.tvg_id} —{' '}
+ {r.mode === 'new' ? 'New episodes' : 'Every episode'}
+
+
+ handleEvaluateNow(r)}
+ >
+ Evaluate Now
+
+ handleRemoveSeries(r)}
+ >
+ Remove this series (scheduled)
+
+
+
+ ))}
);
diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx
index 64873643..8e987de7 100644
--- a/frontend/src/components/modals/EPGMatchModal.jsx
+++ b/frontend/src/components/modals/EPGMatchModal.jsx
@@ -22,10 +22,14 @@ const getEpgSettingsFromStore = (settings) => {
const epgSettings = settings?.['epg_settings']?.value;
return {
epg_match_mode: epgSettings?.epg_match_mode || 'default',
- epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes)
+ epg_match_ignore_prefixes: Array.isArray(
+ epgSettings?.epg_match_ignore_prefixes
+ )
? epgSettings.epg_match_ignore_prefixes
: [],
- epg_match_ignore_suffixes: Array.isArray(epgSettings?.epg_match_ignore_suffixes)
+ epg_match_ignore_suffixes: Array.isArray(
+ epgSettings?.epg_match_ignore_suffixes
+ )
? epgSettings.epg_match_ignore_suffixes
: [],
epg_match_ignore_custom: Array.isArray(epgSettings?.epg_match_ignore_custom)
@@ -34,11 +38,7 @@ const getEpgSettingsFromStore = (settings) => {
};
};
-const EPGMatchModal = ({
- opened,
- onClose,
- selectedChannelIds = [],
-}) => {
+const EPGMatchModal = ({ opened, onClose, selectedChannelIds = [] }) => {
const settings = useSettingsStore((s) => s.settings);
const [loading, setLoading] = useState(false);
@@ -107,9 +107,10 @@ const EPGMatchModal = ({
}
};
- const scopeText = selectedChannelIds.length > 0
- ? `${selectedChannelIds.length} selected channel(s)`
- : 'all channels without EPG';
+ const scopeText =
+ selectedChannelIds.length > 0
+ ? `${selectedChannelIds.length} selected channel(s)`
+ : 'all channels without EPG';
return (
- Channel display names are never modified. These settings only affect
- the matching algorithm.
+ Channel display names are never modified. These settings only
+ affect the matching algorithm.
>
)}
diff --git a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
index 3f149347..002b5a0c 100644
--- a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
+++ b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx
@@ -43,7 +43,14 @@ vi.mock('../../../store/settings', () => ({
vi.mock('@mantine/core', () => {
const React = require('react');
- const RadioComponent = ({ label, value, checked, description, groupValue, groupOnChange }) => {
+ const RadioComponent = ({
+ label,
+ value,
+ checked,
+ description,
+ groupValue,
+ groupOnChange,
+ }) => {
const isChecked = checked !== undefined ? checked : groupValue === value;
const handleChange = groupOnChange || (() => {});
@@ -67,17 +74,26 @@ vi.mock('@mantine/core', () => {
const enhancedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// If it's a Stack or other container, recursively enhance its children
- if (child.type?.name === 'Stack' || child.props['data-testid'] === 'stack') {
+ if (
+ child.type?.name === 'Stack' ||
+ child.props['data-testid'] === 'stack'
+ ) {
return React.cloneElement(child, {
- children: React.Children.map(child.props.children, (nestedChild) => {
- if (React.isValidElement(nestedChild) && nestedChild.type === RadioComponent) {
- return React.cloneElement(nestedChild, {
- groupValue: value,
- groupOnChange: onChange,
- });
+ children: React.Children.map(
+ child.props.children,
+ (nestedChild) => {
+ if (
+ React.isValidElement(nestedChild) &&
+ nestedChild.type === RadioComponent
+ ) {
+ return React.cloneElement(nestedChild, {
+ groupValue: value,
+ groupOnChange: onChange,
+ });
+ }
+ return nestedChild;
}
- return nestedChild;
- }),
+ ),
});
}
// If it's a Radio component, inject props directly
@@ -157,7 +173,9 @@ describe('EPGMatchModal', () => {
it('should not show advanced fields in default mode', () => {
render();
- expect(screen.queryByLabelText('Ignore Prefixes')).not.toBeInTheDocument();
+ expect(
+ screen.queryByLabelText('Ignore Prefixes')
+ ).not.toBeInTheDocument();
});
it('should show advanced fields when advanced mode is selected', async () => {
@@ -169,7 +187,9 @@ describe('EPGMatchModal', () => {
await waitFor(() => {
expect(screen.getByLabelText('Ignore Prefixes')).toBeInTheDocument();
expect(screen.getByLabelText('Ignore Suffixes')).toBeInTheDocument();
- expect(screen.getByLabelText('Ignore Custom Strings')).toBeInTheDocument();
+ expect(
+ screen.getByLabelText('Ignore Custom Strings')
+ ).toBeInTheDocument();
});
});
});
@@ -199,7 +219,9 @@ describe('EPGMatchModal', () => {
describe('Form Submission', () => {
it('should save mode and trigger auto-match', async () => {
- SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
+ SettingsUtils.getChangedSettings.mockReturnValue({
+ epg_match_mode: 'default',
+ });
SettingsUtils.saveChangedSettings.mockResolvedValue();
API.matchEpg.mockResolvedValue();
@@ -220,7 +242,9 @@ describe('EPGMatchModal', () => {
SettingsUtils.getChangedSettings.mockReturnValue({});
API.matchEpg.mockResolvedValue();
- render();
+ render(
+
+ );
const submitButton = screen.getByText('Start Auto-Match');
fireEvent.click(submitButton);
@@ -232,7 +256,9 @@ describe('EPGMatchModal', () => {
it('should handle save errors gracefully', async () => {
const error = new Error('Save failed');
- SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' });
+ SettingsUtils.getChangedSettings.mockReturnValue({
+ epg_match_mode: 'default',
+ });
SettingsUtils.saveChangedSettings.mockRejectedValue(error);
render();
@@ -270,13 +296,23 @@ describe('EPGMatchModal', () => {
describe('UI Text', () => {
it('should show correct text for selected channels', () => {
- render();
- expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument();
+ render(
+
+ );
+ expect(
+ screen.getByText(
+ /Match channels to EPG data for 3 selected channel\(s\)/
+ )
+ ).toBeInTheDocument();
});
it('should show correct text for all channels', () => {
render();
- expect(screen.getByText(/Match channels to EPG data for all channels without EPG/)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Match channels to EPG data for all channels without EPG/
+ )
+ ).toBeInTheDocument();
});
});
});
diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css
index 4626db4c..d3f492fb 100644
--- a/frontend/src/components/sidebar.css
+++ b/frontend/src/components/sidebar.css
@@ -13,21 +13,21 @@
/* Active state styles */
.navlink.navlink-active {
- color: #FFFFFF;
+ color: #ffffff;
background-color: #245043;
- border: 1px solid #3BA882;
+ border: 1px solid #3ba882;
}
/* Hover effect */
.navlink:hover {
- background-color: #2A2F34; /* Gray hover effect when not active */
- border: 1px solid #3D3D42;
+ background-color: #2a2f34; /* Gray hover effect when not active */
+ border: 1px solid #3d3d42;
}
/* Hover effect for active state */
.navlink.navlink-active:hover {
- background-color: #3A3A40;
- border: 1px solid #3BA882;
+ background-color: #3a3a40;
+ border: 1px solid #3ba882;
}
/* Collapse condition for justifyContent */
diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
index 4e549699..6b33f64f 100644
--- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
+++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx
@@ -180,7 +180,6 @@ const ChannelTableHeader = ({
}
};
-
const assignChannels = async () => {
try {
// Call our custom API endpoint
diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css
index 30fd033a..88869f57 100644
--- a/frontend/src/components/tables/table.css
+++ b/frontend/src/components/tables/table.css
@@ -92,7 +92,7 @@ html {
opacity: 0;
}
- *:hover>.resizer {
+ *:hover > .resizer {
opacity: 1;
}
}
@@ -104,7 +104,7 @@ html {
/* .table-striped .tbody .tr:nth-child(even), */
.table-striped .tbody .tr-even {
- background-color: #27272A;
+ background-color: #27272a;
}
/* Style for rows with no streams */
@@ -138,7 +138,7 @@ html {
/* Always allow text selection in editable elements */
.shift-key-active input,
.shift-key-active textarea,
-.shift-key-active [contenteditable="true"],
+.shift-key-active [contenteditable='true'],
.shift-key-active .table-input-header input {
user-select: text !important;
-webkit-user-select: text !important;
@@ -169,7 +169,7 @@ html {
/* Always allow text selection in inputs even when shift is pressed */
.shift-key-active input,
.shift-key-active textarea,
-.shift-key-active [contenteditable="true"],
+.shift-key-active [contenteditable='true'],
.shift-key-active select,
.shift-key-active .mantine-Select-input,
.shift-key-active .mantine-MultiSelect-input,
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
index 528c5f04..5b90b60a 100644
--- a/frontend/src/constants.js
+++ b/frontend/src/constants.js
@@ -326,22 +326,31 @@ export const REGION_CHOICES = [
export const VOD_TYPES = {
MOVIE: 'movie',
- EPISODE: 'episode'
+ EPISODE: 'episode',
};
export const VOD_FILTERS = {
ALL: 'all',
MOVIES: 'movies',
- SERIES: 'series'
+ SERIES: 'series',
};
export const VOD_SORT_OPTIONS = [
{ value: 'name', label: 'Name' },
{ value: 'year', label: 'Year' },
{ value: 'created_at', label: 'Date Added' },
- { value: 'rating', label: 'Rating' }
+ { value: 'rating', label: 'Rating' },
];
export const CONTAINER_EXTENSIONS = [
- 'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'mpg'
+ 'mp4',
+ 'mkv',
+ 'avi',
+ 'mov',
+ 'wmv',
+ 'flv',
+ 'webm',
+ 'm4v',
+ 'ts',
+ 'mpg',
];
diff --git a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx
index 8be21e28..763f38a1 100644
--- a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx
+++ b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx
@@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
- })
+ }),
};
})();
@@ -32,7 +32,9 @@ describe('useLocalStorage', () => {
});
it('should initialize with default value when localStorage is empty', () => {
- const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+ const { result } = renderHook(() =>
+ useLocalStorage('testKey', 'defaultValue')
+ );
expect(result.current[0]).toBe('defaultValue');
});
@@ -40,7 +42,9 @@ describe('useLocalStorage', () => {
it('should initialize with value from localStorage if available', () => {
localStorageMock.setItem('testKey', JSON.stringify('storedValue'));
- const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+ const { result } = renderHook(() =>
+ useLocalStorage('testKey', 'defaultValue')
+ );
expect(result.current[0]).toBe('storedValue');
});
@@ -52,14 +56,19 @@ describe('useLocalStorage', () => {
result.current[1]('updated');
});
- expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated'));
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'testKey',
+ JSON.stringify('updated')
+ );
expect(result.current[0]).toBe('updated');
});
it('should handle complex objects', () => {
const complexObject = { name: 'test', count: 42, nested: { value: true } };
- const { result } = renderHook(() => useLocalStorage('testKey', complexObject));
+ const { result } = renderHook(() =>
+ useLocalStorage('testKey', complexObject)
+ );
act(() => {
result.current[1]({ name: 'updated', count: 100 });
@@ -73,7 +82,9 @@ describe('useLocalStorage', () => {
throw new Error('Read error');
});
- const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+ const { result } = renderHook(() =>
+ useLocalStorage('testKey', 'defaultValue')
+ );
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalledWith(
@@ -102,7 +113,9 @@ describe('useLocalStorage', () => {
it('should handle invalid JSON in localStorage', () => {
localStorageMock.getItem.mockReturnValueOnce('invalid json{');
- const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
+ const { result } = renderHook(() =>
+ useLocalStorage('testKey', 'defaultValue')
+ );
expect(result.current[0]).toBe('defaultValue');
expect(console.error).toHaveBeenCalled();
diff --git a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx
index d8ef2711..66331b44 100644
--- a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx
+++ b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx
@@ -1,6 +1,10 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos';
+import {
+ useLogoSelection,
+ useChannelLogoSelection,
+ useLogosById,
+} from '../useSmartLogos';
import useLogosStore from '../../store/logos';
// Mock the logos store
@@ -13,10 +17,12 @@ describe('useSmartLogos', () => {
});
it('should initialize with empty state', () => {
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogos: vi.fn(),
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogos: vi.fn(),
+ })
+ );
const { result } = renderHook(() => useLogoSelection());
@@ -27,10 +33,12 @@ describe('useSmartLogos', () => {
it('should load logos when ensureLogosLoaded is called', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogos: mockFetchLogos,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogos: mockFetchLogos,
+ })
+ );
const { result } = renderHook(() => useLogoSelection());
@@ -43,10 +51,12 @@ describe('useSmartLogos', () => {
it('should not reload logos if already loaded', async () => {
const mockFetchLogos = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- logos: { logo1: { id: 'logo1' } },
- fetchLogos: mockFetchLogos,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: { logo1: { id: 'logo1' } },
+ fetchLogos: mockFetchLogos,
+ })
+ );
const { result } = renderHook(() => useLogoSelection());
@@ -62,12 +72,18 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching logos', async () => {
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogos: mockFetchLogos,
- }));
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const mockFetchLogos = vi
+ .fn()
+ .mockRejectedValue(new Error('Fetch failed'));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogos: mockFetchLogos,
+ })
+ );
const { result } = renderHook(() => useLogoSelection());
@@ -84,10 +100,12 @@ describe('useSmartLogos', () => {
});
it('should indicate hasLogos when logos are present', () => {
- useLogosStore.mockImplementation((selector) => selector({
- logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
- fetchLogos: vi.fn(),
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
+ fetchLogos: vi.fn(),
+ })
+ );
const { result } = renderHook(() => useLogoSelection());
@@ -101,12 +119,14 @@ describe('useSmartLogos', () => {
});
it('should initialize with channel logos state', () => {
- useLogosStore.mockImplementation((selector) => selector({
- channelLogos: {},
- hasLoadedChannelLogos: false,
- backgroundLoading: false,
- fetchChannelAssignableLogos: vi.fn(),
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: vi.fn(),
+ })
+ );
const { result } = renderHook(() => useChannelLogoSelection());
@@ -117,12 +137,14 @@ describe('useSmartLogos', () => {
it('should load channel logos when ensureLogosLoaded is called', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- channelLogos: {},
- hasLoadedChannelLogos: false,
- backgroundLoading: false,
- fetchChannelAssignableLogos: mockFetchChannelLogos,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ })
+ );
const { result } = renderHook(() => useChannelLogoSelection());
@@ -135,12 +157,14 @@ describe('useSmartLogos', () => {
it('should not reload if already loaded', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- channelLogos: { logo1: { id: 'logo1' } },
- hasLoadedChannelLogos: true,
- backgroundLoading: false,
- fetchChannelAssignableLogos: mockFetchChannelLogos,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ channelLogos: { logo1: { id: 'logo1' } },
+ hasLoadedChannelLogos: true,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ })
+ );
const { result } = renderHook(() => useChannelLogoSelection());
@@ -153,12 +177,14 @@ describe('useSmartLogos', () => {
it('should not load if backgroundLoading is true', async () => {
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- channelLogos: {},
- hasLoadedChannelLogos: false,
- backgroundLoading: true,
- fetchChannelAssignableLogos: mockFetchChannelLogos,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: true,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ })
+ );
const { result } = renderHook(() => useChannelLogoSelection());
@@ -170,14 +196,20 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching channel logos', async () => {
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
- useLogosStore.mockImplementation((selector) => selector({
- channelLogos: {},
- hasLoadedChannelLogos: false,
- backgroundLoading: false,
- fetchChannelAssignableLogos: mockFetchChannelLogos,
- }));
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const mockFetchChannelLogos = vi
+ .fn()
+ .mockRejectedValue(new Error('Fetch failed'));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ channelLogos: {},
+ hasLoadedChannelLogos: false,
+ backgroundLoading: false,
+ fetchChannelAssignableLogos: mockFetchChannelLogos,
+ })
+ );
const { result } = renderHook(() => useChannelLogoSelection());
@@ -200,10 +232,12 @@ describe('useSmartLogos', () => {
});
it('should initialize with empty logos', () => {
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogosByIds: vi.fn(),
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogosByIds: vi.fn(),
+ })
+ );
const { result } = renderHook(() => useLogosById([]));
@@ -214,10 +248,12 @@ describe('useSmartLogos', () => {
it('should fetch missing logos by IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogosByIds: mockFetchLogosByIds,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ })
+ );
renderHook(() => useLogosById(['logo1', 'logo2']));
@@ -228,10 +264,12 @@ describe('useSmartLogos', () => {
it('should not fetch logos that are already loaded', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- logos: { logo1: { id: 'logo1' } },
- fetchLogosByIds: mockFetchLogosByIds,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: { logo1: { id: 'logo1' } },
+ fetchLogosByIds: mockFetchLogosByIds,
+ })
+ );
renderHook(() => useLogosById(['logo1', 'logo2']));
@@ -241,12 +279,18 @@ describe('useSmartLogos', () => {
});
it('should handle errors when fetching logos by IDs', async () => {
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed'));
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogosByIds: mockFetchLogosByIds,
- }));
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const mockFetchLogosByIds = vi
+ .fn()
+ .mockRejectedValue(new Error('Fetch failed'));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ })
+ );
renderHook(() => useLogosById(['logo1']));
@@ -262,10 +306,12 @@ describe('useSmartLogos', () => {
it('should filter out null/undefined IDs', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogosByIds: mockFetchLogosByIds,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ })
+ );
renderHook(() => useLogosById(['logo1', null, undefined, 'logo2']));
@@ -276,10 +322,12 @@ describe('useSmartLogos', () => {
it('should not refetch the same IDs multiple times', async () => {
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
- useLogosStore.mockImplementation((selector) => selector({
- logos: {},
- fetchLogosByIds: mockFetchLogosByIds,
- }));
+ useLogosStore.mockImplementation((selector) =>
+ selector({
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ })
+ );
const { rerender } = renderHook(() => useLogosById(['logo1']));
diff --git a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx
index 2158df54..458b8714 100644
--- a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx
+++ b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx
@@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
- })
+ }),
};
})();
@@ -50,7 +50,10 @@ describe('useTablePreferences', () => {
});
it('should initialize headerPinned from localStorage', () => {
- localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
+ localStorageMock.setItem(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true })
+ );
const { result } = renderHook(() => useTablePreferences());
@@ -58,7 +61,10 @@ describe('useTablePreferences', () => {
});
it('should initialize tableSize from localStorage', () => {
- localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
+ localStorageMock.setItem(
+ 'table-preferences',
+ JSON.stringify({ tableSize: 'compact' })
+ );
const { result } = renderHook(() => useTablePreferences());
@@ -66,7 +72,10 @@ describe('useTablePreferences', () => {
});
it('should initialize both preferences from localStorage', () => {
- localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' }));
+ localStorageMock.setItem(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })
+ );
const { result } = renderHook(() => useTablePreferences());
@@ -83,7 +92,10 @@ describe('useTablePreferences', () => {
});
it('should prefer new localStorage location over old location', () => {
- localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' }));
+ localStorageMock.setItem(
+ 'table-preferences',
+ JSON.stringify({ tableSize: 'comfortable' })
+ );
localStorageMock.setItem('table-size', JSON.stringify('compact'));
const { result } = renderHook(() => useTablePreferences());
@@ -127,7 +139,10 @@ describe('useTablePreferences', () => {
});
it('should preserve existing preferences when updating headerPinned', () => {
- localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
+ localStorageMock.setItem(
+ 'table-preferences',
+ JSON.stringify({ tableSize: 'compact' })
+ );
const { result } = renderHook(() => useTablePreferences());
@@ -205,7 +220,10 @@ describe('useTablePreferences', () => {
});
it('should preserve existing preferences when updating tableSize', () => {
- localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
+ localStorageMock.setItem(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true })
+ );
const { result } = renderHook(() => useTablePreferences());
@@ -320,7 +338,10 @@ describe('useTablePreferences', () => {
});
it('should not update if value is the same', () => {
- localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
+ localStorageMock.setItem(
+ 'table-preferences',
+ JSON.stringify({ headerPinned: true })
+ );
const { result } = renderHook(() => useTablePreferences());
const initialHeaderPinned = result.current.headerPinned;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 5e902b6c..324f43fc 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -7,9 +7,9 @@
body {
margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
+ font-family:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
+ 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #18181b;
@@ -18,8 +18,8 @@ body {
}
code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
+ font-family:
+ source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
/* Example scrollbars - optional, to match a dark theme. */
@@ -28,7 +28,7 @@ code {
}
::-webkit-scrollbar-track {
- background: #3B3C41;
+ background: #3b3c41;
}
::-webkit-scrollbar-thumb {
@@ -45,7 +45,9 @@ code {
}
}
-table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Table-td-detail-panel {
+table.mrt-table
+ tr.mantine-Table-tr.mantine-Table-tr-detail-panel
+ td.mantine-Table-td-detail-panel {
width: 100% !important;
}
@@ -71,7 +73,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
/* Create a short vertical bar */
.sash.sash-vertical::before {
- content: "";
+ content: '';
position: absolute;
top: 50%;
left: 50%;
@@ -103,7 +105,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
}
/* For elements that specifically need transforms (like draggable elements) */
-[data-draggable="true"] {
+[data-draggable='true'] {
transform: translate3d(0, 0, 0) !important;
}
@@ -137,4 +139,4 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-}
\ No newline at end of file
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 2a7b83c0..d76b7587 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -4,6 +4,6 @@ import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
-
+
);
diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx
index c9eaaffc..c412fe90 100644
--- a/frontend/src/pages/ContentSources.jsx
+++ b/frontend/src/pages/ContentSources.jsx
@@ -2,7 +2,7 @@ import useUserAgentsStore from '../store/userAgents';
import M3UsTable from '../components/tables/M3UsTable';
import EPGsTable from '../components/tables/EPGsTable';
import { Box, Stack } from '@mantine/core';
-import ErrorBoundary from '../components/ErrorBoundary'
+import ErrorBoundary from '../components/ErrorBoundary';
const PageContent = () => {
const error = useUserAgentsStore((state) => state.error);
@@ -28,14 +28,14 @@ const PageContent = () => {
);
-}
+};
const M3UPage = () => {
return (
-
+
);
-}
+};
export default M3UPage;
diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx
index 7bd6e07f..7fa12b36 100644
--- a/frontend/src/pages/DVR.jsx
+++ b/frontend/src/pages/DVR.jsx
@@ -10,24 +10,23 @@ import {
Title,
useMantineTheme,
} from '@mantine/core';
-import {
- SquarePlus,
-} from 'lucide-react';
+import { SquarePlus } from 'lucide-react';
import useChannelsStore from '../store/channels';
import useSettingsStore from '../store/settings';
import useVideoStore from '../store/useVideoStore';
import RecordingForm from '../components/forms/Recording';
-import {
- isAfter,
- isBefore,
- useTimeHelpers,
-} from '../utils/dateTimeUtils.js';
-const RecordingDetailsModal = lazy(() =>
- import('../components/forms/RecordingDetailsModal'));
+import { isAfter, isBefore, useTimeHelpers } from '../utils/dateTimeUtils.js';
+const RecordingDetailsModal = lazy(
+ () => import('../components/forms/RecordingDetailsModal')
+);
import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
import RecordingCard from '../components/cards/RecordingCard.jsx';
import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
-import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
+import {
+ getPosterUrl,
+ getRecordingUrl,
+ getShowVideoUrl,
+} from '../utils/cards/RecordingCardUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
@@ -113,32 +112,35 @@ const DVRPage = () => {
const now = userNow();
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
- if(isAfter(now, s) && isBefore(now, e)) {
+ if (isAfter(now, s) && isBefore(now, e)) {
// call into child RecordingCard behavior by constructing a URL like there
const channel = channels[rec.channel];
if (!channel) return;
- const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode);
+ const url = getShowVideoUrl(
+ channel,
+ useSettingsStore.getState().environment.env_mode
+ );
useVideoStore.getState().showVideo(url, 'live');
}
- }
+ };
const handleOnWatchRecording = () => {
const url = getRecordingUrl(
- detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode);
- if(!url) return;
+ detailsRecording.custom_properties,
+ useSettingsStore.getState().environment.env_mode
+ );
+ if (!url) return;
useVideoStore.getState().showVideo(url, 'vod', {
- name:
- detailsRecording.custom_properties?.program?.title ||
- 'Recording',
+ name: detailsRecording.custom_properties?.program?.title || 'Recording',
logo: {
url: getPosterUrl(
detailsRecording.custom_properties?.poster_logo_id,
undefined,
channels[detailsRecording.channel]?.logo?.cache_url
- )
+ ),
},
});
- }
+ };
return (
{
{ maxWidth: '36rem', cols: 1 },
]}
>
- {}
+ {
+
+ }
{inProgress.length === 0 && (
Nothing recording right now.
@@ -196,11 +200,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {}
+ {
+
+ }
{upcoming.length === 0 && (
No upcoming recordings.
@@ -222,11 +228,13 @@ const DVRPage = () => {
{ maxWidth: '36rem', cols: 1 },
]}
>
- {}
+ {
+
+ }
{completed.length === 0 && (
No completed recordings yet.
@@ -286,4 +294,4 @@ const DVRPage = () => {
);
};
-export default DVRPage;
\ No newline at end of file
+export default DVRPage;
diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx
index f95212d6..aa467b8b 100644
--- a/frontend/src/pages/Logos.jsx
+++ b/frontend/src/pages/Logos.jsx
@@ -7,12 +7,11 @@ import VODLogosTable from '../components/tables/VODLogosTable';
import { showNotification } from '../utils/notificationUtils.js';
const LogosPage = () => {
- const logos = useLogosStore(s => s.logos);
- const totalCount = useVODLogosStore(s => s.totalCount);
+ const logos = useLogosStore((s) => s.logos);
+ const totalCount = useVODLogosStore((s) => s.totalCount);
const [activeTab, setActiveTab] = useState('channel');
- const logoCount = activeTab === 'channel'
- ? Object.keys(logos).length
- : totalCount;
+ const logoCount =
+ activeTab === 'channel' ? Object.keys(logos).length : totalCount;
const loadChannelLogos = useCallback(async () => {
try {
@@ -38,11 +37,7 @@ const LogosPage = () => {
return (
{/* Header with title and tabs */}
-
+
{
fz={'20px'}
fw={500}
lh={1}
- c='white'
+ c="white"
mb={0}
lts={'-0.3px'}
>
diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx
index fa892430..210c7e4c 100644
--- a/frontend/src/pages/Plugins.jsx
+++ b/frontend/src/pages/Plugins.jsx
@@ -22,7 +22,10 @@ import {
Text,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
-import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
+import {
+ showNotification,
+ updateNotification,
+} from '../utils/notificationUtils.js';
import { usePluginStore } from '../store/plugins.jsx';
import {
deletePluginByKey,
@@ -34,8 +37,9 @@ import {
} from '../utils/pages/PluginsUtils.js';
import { RefreshCcw } from 'lucide-react';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
-const PluginCard = React.lazy(() =>
- import('../components/cards/PluginCard.jsx'));
+const PluginCard = React.lazy(
+ () => import('../components/cards/PluginCard.jsx')
+);
const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
const plugins = usePluginStore((state) => state.plugins);
@@ -68,7 +72,7 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
return (
<>
- {plugins.length > 0 &&
+ {plugins.length > 0 && (
{
- }
+ )}
{plugins.length === 0 && (
- No plugins found. Drop a plugin into /data/plugins{' '}
- and reload.
+ No plugins found. Drop a plugin into /data/plugins and
+ reload.
)}
@@ -255,12 +259,15 @@ export default function PluginsPage() {
};
};
- const handleConfirm = useCallback((confirmed) => {
- const resolver = confirmConfig.resolve;
- setConfirmOpen(false);
- setConfirmConfig({ title: '', message: '', resolve: null });
- if (resolver) resolver(confirmed);
- }, [confirmConfig.resolve]);
+ const handleConfirm = useCallback(
+ (confirmed) => {
+ const resolver = confirmConfig.resolve;
+ setConfirmOpen(false);
+ setConfirmConfig({ title: '', message: '', resolve: null });
+ if (resolver) resolver(confirmed);
+ },
+ [confirmConfig.resolve]
+ );
return (
diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx
index e69f07f8..951f4bd9 100644
--- a/frontend/src/pages/Users.jsx
+++ b/frontend/src/pages/Users.jsx
@@ -12,12 +12,12 @@ const PageContent = () => {
);
-}
+};
const UsersPage = () => {
return (
-
+
);
};
diff --git a/frontend/src/pages/__tests__/Channels.test.jsx b/frontend/src/pages/__tests__/Channels.test.jsx
index e029952f..d94662a2 100644
--- a/frontend/src/pages/__tests__/Channels.test.jsx
+++ b/frontend/src/pages/__tests__/Channels.test.jsx
@@ -7,10 +7,10 @@ import ChannelsPage from '../Channels';
vi.mock('../../store/auth');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../components/tables/ChannelsTable', () => ({
- default: () => ChannelsTable
+ default: () => ChannelsTable
,
}));
vi.mock('../../components/tables/StreamsTable', () => ({
- default: () => StreamsTable
+ default: () => StreamsTable
,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => {children}
,
diff --git a/frontend/src/pages/__tests__/ContentSources.test.jsx b/frontend/src/pages/__tests__/ContentSources.test.jsx
index 3f2ce1c5..4ba4602d 100644
--- a/frontend/src/pages/__tests__/ContentSources.test.jsx
+++ b/frontend/src/pages/__tests__/ContentSources.test.jsx
@@ -5,10 +5,10 @@ import useUserAgentsStore from '../../store/userAgents';
vi.mock('../../store/userAgents');
vi.mock('../../components/tables/M3UsTable', () => ({
- default: () => M3UsTable
+ default: () => M3UsTable
,
}));
vi.mock('../../components/tables/EPGsTable', () => ({
- default: () => EPGsTable
+ default: () => EPGsTable
,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => {children}
,
@@ -30,4 +30,4 @@ describe('ContentSourcesPage', () => {
expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/pages/__tests__/Login.test.jsx b/frontend/src/pages/__tests__/Login.test.jsx
index 3db66883..e55db96f 100644
--- a/frontend/src/pages/__tests__/Login.test.jsx
+++ b/frontend/src/pages/__tests__/Login.test.jsx
@@ -5,10 +5,10 @@ import useAuthStore from '../../store/auth';
vi.mock('../../store/auth');
vi.mock('../../components/forms/LoginForm', () => ({
- default: () => LoginForm
+ default: () => LoginForm
,
}));
vi.mock('../../components/forms/SuperuserForm', () => ({
- default: () => SuperuserForm
+ default: () => SuperuserForm
,
}));
vi.mock('@mantine/core', () => ({
Text: ({ children }) => {children}
,
@@ -18,7 +18,7 @@ describe('Login', () => {
it('renders SuperuserForm when superuser does not exist', async () => {
useAuthStore.mockReturnValue(false);
- render();
+ render();
await waitFor(() => {
expect(screen.getByTestId('superuser-form')).toBeInTheDocument();
@@ -29,7 +29,7 @@ describe('Login', () => {
it('renders LoginForm when superuser exists', () => {
useAuthStore.mockReturnValue(true);
- render();
+ render();
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();
diff --git a/frontend/src/pages/__tests__/Logos.test.jsx b/frontend/src/pages/__tests__/Logos.test.jsx
index b710b2ef..d5da89ad 100644
--- a/frontend/src/pages/__tests__/Logos.test.jsx
+++ b/frontend/src/pages/__tests__/Logos.test.jsx
@@ -3,7 +3,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import LogosPage from '../Logos';
import useLogosStore from '../../store/logos';
import useVODLogosStore from '../../store/vodLogos';
-import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+import {
+ showNotification,
+ updateNotification,
+} from '../../utils/notificationUtils.js';
vi.mock('../../store/logos');
vi.mock('../../store/vodLogos');
@@ -12,16 +15,21 @@ vi.mock('../../utils/notificationUtils.js', () => ({
updateNotification: vi.fn(),
}));
vi.mock('../../components/tables/LogosTable', () => ({
- default: () => LogosTable
+ default: () => LogosTable
,
}));
vi.mock('../../components/tables/VODLogosTable', () => ({
- default: () => VODLogosTable
+ default: () => VODLogosTable
,
}));
vi.mock('@mantine/core', () => {
- const tabsComponent = ({ children, value, onChange }) =>
- onChange('vod')}>{children}
;
+ const tabsComponent = ({ children, value, onChange }) => (
+ onChange('vod')}>
+ {children}
+
+ );
tabsComponent.List = ({ children }) => {children}
;
- tabsComponent.Tab = ({ children, value }) => {children};
+ tabsComponent.Tab = ({ children, value }) => (
+ {children}
+ );
return {
Box: ({ children, ...props }) => {children}
,
@@ -113,7 +121,9 @@ describe('LogosPage', () => {
});
it('shows error notification when fetching logos fails', async () => {
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const error = new Error('Failed to fetch');
mockFetchAllLogos.mockRejectedValue(error);
diff --git a/frontend/src/pages/__tests__/Plugins.test.jsx b/frontend/src/pages/__tests__/Plugins.test.jsx
index 66e9b6f9..dd1f6771 100644
--- a/frontend/src/pages/__tests__/Plugins.test.jsx
+++ b/frontend/src/pages/__tests__/Plugins.test.jsx
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import PluginsPage from '../Plugins';
-import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+import {
+ showNotification,
+ updateNotification,
+} from '../../utils/notificationUtils.js';
import {
deletePluginByKey,
importPlugin,
@@ -47,7 +50,16 @@ vi.mock('@mantine/core', async () => {
{children}
),
- Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
+ Button: ({
+ children,
+ onClick,
+ leftSection,
+ variant,
+ color,
+ loading,
+ disabled,
+ fullWidth,
+ }) => (
{
),
Divider: ({ my }) =>
,
ActionIcon: ({ children, onClick, color, variant, title }) => (
-
+
{children}
),
- SimpleGrid: ({ children, cols }) => (
- {children}
- ),
+ SimpleGrid: ({ children, cols }) => {children}
,
Modal: ({ opened, onClose, title, children, size, centered }) =>
opened ? (
@@ -109,7 +124,9 @@ vi.mock('@mantine/dropzone', () => ({
data-accept={accept}
data-max-size={maxSize}
onClick={() => {
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
onDrop([file]);
}}
>
@@ -188,7 +205,11 @@ describe('PluginsPage', () => {
});
it('shows loader when loading and no plugins', () => {
- const loadingState = { plugins: [], loading: true, fetchPlugins: vi.fn() };
+ const loadingState = {
+ plugins: [],
+ loading: true,
+ fetchPlugins: vi.fn(),
+ };
usePluginStore.mockImplementation((selector) => {
return selector ? selector(loadingState) : loadingState;
});
@@ -219,7 +240,9 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
expect(screen.getByTestId('modal')).toBeInTheDocument();
- expect(screen.getByTestId('modal-title')).toHaveTextContent('Import Plugin');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Import Plugin'
+ );
});
it('shows dropzone and file input in import modal', () => {
@@ -228,7 +251,9 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
expect(screen.getByTestId('dropzone')).toBeInTheDocument();
- expect(screen.getByPlaceholderText('Select plugin .zip')).toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText('Select plugin .zip')
+ ).toBeInTheDocument();
});
it('closes import modal when close button is clicked', () => {
@@ -245,7 +270,11 @@ describe('PluginsPage', () => {
it('handles file upload via dropzone', async () => {
importPlugin.mockResolvedValue({
success: true,
- plugin: { key: 'new-plugin', name: 'New Plugin', description: 'New Description' },
+ plugin: {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ },
});
render(
);
@@ -255,9 +284,9 @@ describe('PluginsPage', () => {
fireEvent.click(dropzone);
await waitFor(() => {
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
expect(uploadButton).not.toBeDisabled();
});
});
@@ -279,12 +308,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
fireEvent.change(fileInput, { target: { files: [file] } });
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@@ -305,12 +336,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
fireEvent.change(fileInput, { target: { files: [file] } });
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@@ -340,12 +373,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
fireEvent.change(fileInput, { target: { files: [file] } });
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@@ -372,12 +407,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
fireEvent.change(fileInput, { target: { files: [file] } });
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@@ -387,9 +424,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
- const enableButton = screen.getAllByText('Enable').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const enableButton = screen
+ .getAllByText('Enable')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
@@ -417,12 +454,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
fireEvent.change(fileInput, { target: { files: [file] } });
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@@ -432,13 +471,15 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
- const enableButton = screen.getAllByText('Enable').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const enableButton = screen
+ .getAllByText('Enable')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
- expect(screen.getByText('Enable third-party plugins?')).toBeInTheDocument();
+ expect(
+ screen.getByText('Enable third-party plugins?')
+ ).toBeInTheDocument();
});
});
@@ -460,12 +501,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
fireEvent.change(fileInput, { target: { files: [file] } });
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@@ -475,9 +518,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
- const enableButton = screen.getAllByText('Enable').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const enableButton = screen
+ .getAllByText('Enable')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
@@ -508,12 +551,14 @@ describe('PluginsPage', () => {
fireEvent.click(screen.getByText('Import Plugin'));
const fileInput = screen.getByPlaceholderText('Select plugin .zip');
- const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const file = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
fireEvent.change(fileInput, { target: { files: [file] } });
- const uploadButton = screen.getAllByText('Upload').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const uploadButton = screen
+ .getAllByText('Upload')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(uploadButton);
await waitFor(() => {
@@ -523,9 +568,9 @@ describe('PluginsPage', () => {
const enableSwitch = screen.getByRole('checkbox');
fireEvent.click(enableSwitch);
- const enableButton = screen.getAllByText('Enable').find(btn =>
- btn.tagName === 'BUTTON'
- );
+ const enableButton = screen
+ .getAllByText('Enable')
+ .find((btn) => btn.tagName === 'BUTTON');
fireEvent.click(enableButton);
await waitFor(() => {
diff --git a/frontend/src/pages/__tests__/Users.test.jsx b/frontend/src/pages/__tests__/Users.test.jsx
index 3ee63627..b7ab4ae2 100644
--- a/frontend/src/pages/__tests__/Users.test.jsx
+++ b/frontend/src/pages/__tests__/Users.test.jsx
@@ -5,7 +5,7 @@ import useAuthStore from '../../store/auth';
vi.mock('../../store/auth');
vi.mock('../../components/tables/UsersTable', () => ({
- default: () =>
UsersTable
+ default: () =>
UsersTable
,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) =>
{children}
,
diff --git a/frontend/src/pages/__tests__/VODs.test.jsx b/frontend/src/pages/__tests__/VODs.test.jsx
index 6e7c00ec..a4d74337 100644
--- a/frontend/src/pages/__tests__/VODs.test.jsx
+++ b/frontend/src/pages/__tests__/VODs.test.jsx
@@ -16,7 +16,7 @@ vi.mock('../../components/SeriesModal', () => ({
{series?.name}
Close
- ) : null
+ ) : null,
}));
vi.mock('../../components/VODModal', () => ({
default: ({ opened, vod, onClose }) =>
@@ -25,26 +25,30 @@ vi.mock('../../components/VODModal', () => ({
{vod?.name}
Close
- ) : null
+ ) : null,
}));
vi.mock('../../components/cards/VODCard', () => ({
default: ({ vod, onClick }) => (
onClick(vod)}>
{vod.name}
- )
+ ),
}));
vi.mock('../../components/cards/SeriesCard', () => ({
default: ({ series, onClick }) => (
onClick(series)}>
{series.name}
- )
+ ),
}));
vi.mock('@mantine/core', () => {
- const gridComponent = ({ children, ...props }) => {children}
;
- gridComponent.Col = ({ children, ...props }) => {children}
;
+ const gridComponent = ({ children, ...props }) => (
+ {children}
+ );
+ gridComponent.Col = ({ children, ...props }) => (
+ {children}
+ );
return {
Box: ({ children, ...props }) => {children}
,
@@ -97,7 +101,9 @@ vi.mock('@mantine/core', () => {
onChange(page - 1)} disabled={page === 1}>
Prev
- {page} of {total}
+
+ {page} of {total}
+
onChange(page + 1)} disabled={page === total}>
Next
@@ -208,9 +214,7 @@ describe('VODsPage', () => {
it('renders series cards for series', async () => {
const stateWithSeries = {
...defaultStoreState,
- currentPageContent: [
- { id: 1, name: 'Series 1', contentType: 'series' },
- ],
+ currentPageContent: [{ id: 1, name: 'Series 1', contentType: 'series' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithSeries));
@@ -224,9 +228,7 @@ describe('VODsPage', () => {
it('opens VOD modal when VOD card is clicked', async () => {
const stateWithMovies = {
...defaultStoreState,
- currentPageContent: [
- { id: 1, name: 'Test Movie', contentType: 'movie' },
- ],
+ currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithMovies));
@@ -262,9 +264,7 @@ describe('VODsPage', () => {
it('closes VOD modal when close button is clicked', async () => {
const stateWithMovies = {
...defaultStoreState,
- currentPageContent: [
- { id: 1, name: 'Test Movie', contentType: 'movie' },
- ],
+ currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }],
};
useVODStore.mockImplementation((selector) => selector(stateWithMovies));
@@ -326,9 +326,7 @@ describe('VODsPage', () => {
});
it('updates filters and resets page when category changes', async () => {
- getCategoryOptions.mockReturnValue([
- { value: 'action', label: 'Action' },
- ]);
+ getCategoryOptions.mockReturnValue([{ value: 'action', label: 'Action' }]);
render();
@@ -370,9 +368,7 @@ describe('VODsPage', () => {
totalCount: 25,
pageSize: 12,
};
- useVODStore.mockImplementation((selector) =>
- selector(stateWithPagination)
- );
+ useVODStore.mockImplementation((selector) => selector(stateWithPagination));
render();
@@ -405,9 +401,7 @@ describe('VODsPage', () => {
pageSize: 12,
currentPage: 1,
};
- useVODStore.mockImplementation((selector) =>
- selector(stateWithPagination)
- );
+ useVODStore.mockImplementation((selector) => selector(stateWithPagination));
render();
diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js
index 01bbe846..8985be0d 100644
--- a/frontend/src/pages/__tests__/guideUtils.test.js
+++ b/frontend/src/pages/__tests__/guideUtils.test.js
@@ -63,9 +63,7 @@ describe('guideUtils', () => {
});
it('should use tvg_id from EPG data for regular sources', () => {
- const channels = [
- { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
- ];
+ const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
};
@@ -79,9 +77,7 @@ describe('guideUtils', () => {
});
it('should use channel UUID for dummy EPG sources', () => {
- const channels = [
- { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
- ];
+ const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
};
@@ -113,9 +109,7 @@ describe('guideUtils', () => {
});
it('should fall back to UUID when tvg_id is null', () => {
- const channels = [
- { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
- ];
+ const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }];
const tvgsById = {
'epg-1': { tvg_id: null, epg_source: 'source-1' },
};
@@ -160,7 +154,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
- const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+ const result = guideUtils.mapProgramsByChannel(
+ programs,
+ channelIdByTvgId
+ );
expect(result.get(1)).toHaveLength(1);
expect(result.get(1)[0]).toMatchObject({
@@ -185,7 +182,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
- const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+ const result = guideUtils.mapProgramsByChannel(
+ programs,
+ channelIdByTvgId
+ );
expect(result.get(1)[0]).toHaveProperty('startMs');
expect(result.get(1)[0]).toHaveProperty('endMs');
@@ -209,7 +209,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
- const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+ const result = guideUtils.mapProgramsByChannel(
+ programs,
+ channelIdByTvgId
+ );
expect(result.get(1)[0].isLive).toBe(true);
expect(result.get(1)[0].isPast).toBe(false);
@@ -233,7 +236,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
- const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+ const result = guideUtils.mapProgramsByChannel(
+ programs,
+ channelIdByTvgId
+ );
expect(result.get(1)[0].isLive).toBe(false);
expect(result.get(1)[0].isPast).toBe(true);
@@ -252,7 +258,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
- const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+ const result = guideUtils.mapProgramsByChannel(
+ programs,
+ channelIdByTvgId
+ );
expect(result.get(1)).toHaveLength(1);
expect(result.get(2)).toHaveLength(1);
@@ -282,7 +291,10 @@ describe('guideUtils', () => {
];
const channelIdByTvgId = new Map([['tvg-1', [1]]]);
- const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+ const result = guideUtils.mapProgramsByChannel(
+ programs,
+ channelIdByTvgId
+ );
expect(result.get(1)[0].id).toBe(1);
expect(result.get(1)[1].id).toBe(2);
@@ -300,9 +312,16 @@ describe('guideUtils', () => {
const channels = [{ id: 1 }, { id: 2 }];
const programsByChannelId = new Map();
- const result = guideUtils.computeRowHeights(channels, programsByChannelId, null);
+ const result = guideUtils.computeRowHeights(
+ channels,
+ programsByChannelId,
+ null
+ );
- expect(result).toEqual([guideUtils.PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ expect(result).toEqual([
+ guideUtils.PROGRAM_HEIGHT,
+ guideUtils.PROGRAM_HEIGHT,
+ ]);
});
it('should return expanded height for channel with expanded program', () => {
@@ -312,9 +331,16 @@ describe('guideUtils', () => {
[2, [{ id: 'program-2' }]],
]);
- const result = guideUtils.computeRowHeights(channels, programsByChannelId, 'program-1');
+ const result = guideUtils.computeRowHeights(
+ channels,
+ programsByChannelId,
+ 'program-1'
+ );
- expect(result).toEqual([guideUtils.EXPANDED_PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ expect(result).toEqual([
+ guideUtils.EXPANDED_PROGRAM_HEIGHT,
+ guideUtils.PROGRAM_HEIGHT,
+ ]);
});
it('should use custom heights when provided', () => {
@@ -393,7 +419,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'Channel 2' },
];
- const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {});
+ const result = guideUtils.filterGuideChannels(
+ channels,
+ '',
+ 'all',
+ 'all',
+ {}
+ );
expect(result).toHaveLength(2);
});
@@ -404,7 +436,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'CNN' },
];
- const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {});
+ const result = guideUtils.filterGuideChannels(
+ channels,
+ 'espn',
+ 'all',
+ 'all',
+ {}
+ );
expect(result).toHaveLength(1);
expect(result[0].name).toBe('ESPN');
@@ -416,7 +454,13 @@ describe('guideUtils', () => {
{ id: 2, name: 'Channel 2', channel_group_id: 2 },
];
- const result = guideUtils.filterGuideChannels(channels, '', '1', 'all', {});
+ const result = guideUtils.filterGuideChannels(
+ channels,
+ '',
+ '1',
+ 'all',
+ {}
+ );
expect(result).toHaveLength(1);
expect(result[0].channel_group_id).toBe(1);
@@ -436,7 +480,13 @@ describe('guideUtils', () => {
},
};
- const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+ const result = guideUtils.filterGuideChannels(
+ channels,
+ '',
+ 'all',
+ 'profile1',
+ profiles
+ );
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@@ -453,7 +503,13 @@ describe('guideUtils', () => {
},
};
- const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+ const result = guideUtils.filterGuideChannels(
+ channels,
+ '',
+ 'all',
+ 'profile1',
+ profiles
+ );
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@@ -474,7 +530,13 @@ describe('guideUtils', () => {
},
};
- const result = guideUtils.filterGuideChannels(channels, 'espn', '1', 'profile1', profiles);
+ const result = guideUtils.filterGuideChannels(
+ channels,
+ 'espn',
+ '1',
+ 'profile1',
+ profiles
+ );
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
@@ -491,8 +553,12 @@ describe('guideUtils', () => {
});
it('should return earliest program start', () => {
- dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
- dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+ dateTimeUtils.initializeTime.mockImplementation((time) =>
+ dayjs.utc(time)
+ );
+ dateTimeUtils.isBefore.mockImplementation((a, b) =>
+ dayjs(a).isBefore(dayjs(b))
+ );
const programs = [
{ start_time: '2024-01-15T12:00:00Z' },
@@ -501,7 +567,10 @@ describe('guideUtils', () => {
];
const defaultStart = dayjs.utc('2024-01-16T00:00:00Z');
- const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart);
+ const result = guideUtils.calculateEarliestProgramStart(
+ programs,
+ defaultStart
+ );
expect(result.hour()).toBe(10);
});
@@ -517,8 +586,12 @@ describe('guideUtils', () => {
});
it('should return latest program end', () => {
- dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
- dateTimeUtils.isAfter.mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b)));
+ dateTimeUtils.initializeTime.mockImplementation((time) =>
+ dayjs.utc(time)
+ );
+ dateTimeUtils.isAfter.mockImplementation((a, b) =>
+ dayjs(a).isAfter(dayjs(b))
+ );
const programs = [
{ end_time: '2024-01-15T12:00:00Z' },
@@ -638,8 +711,12 @@ describe('guideUtils', () => {
it('should return "Today" for today', () => {
const today = dayjs();
dateTimeUtils.getNow.mockReturnValue(today);
- dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) =>
+ dayjs(time).startOf('day')
+ );
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
dateTimeUtils.isSame.mockReturnValueOnce(true);
const result = guideUtils.formatTime(today, 'MM/DD');
@@ -651,8 +728,12 @@ describe('guideUtils', () => {
const today = dayjs();
const tomorrow = today.add(1, 'day');
dateTimeUtils.getNow.mockReturnValue(today);
- dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) =>
+ dayjs(time).startOf('day')
+ );
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true);
const result = guideUtils.formatTime(tomorrow, 'MM/DD');
@@ -664,8 +745,12 @@ describe('guideUtils', () => {
const today = dayjs();
const future = today.add(3, 'day');
dateTimeUtils.getNow.mockReturnValue(today);
- dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) =>
+ dayjs(time).startOf('day')
+ );
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
dateTimeUtils.isSame.mockReturnValue(false);
dateTimeUtils.isBefore.mockReturnValue(true);
dateTimeUtils.format.mockReturnValue('Wednesday');
@@ -679,8 +764,12 @@ describe('guideUtils', () => {
const today = dayjs();
const future = today.add(10, 'day');
dateTimeUtils.getNow.mockReturnValue(today);
- dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) =>
+ dayjs(time).startOf('day')
+ );
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
dateTimeUtils.isSame.mockReturnValue(false);
dateTimeUtils.isBefore.mockReturnValue(false);
dateTimeUtils.format.mockReturnValue('01/25');
@@ -695,13 +784,23 @@ describe('guideUtils', () => {
it('should generate hours between start and end', () => {
const start = dayjs('2024-01-15T10:00:00Z');
const end = dayjs('2024-01-15T13:00:00Z');
- dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
- dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.isBefore.mockImplementation((a, b) =>
+ dayjs(a).isBefore(dayjs(b))
+ );
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
+ dateTimeUtils.startOfDay.mockImplementation((time) =>
+ dayjs(time).startOf('day')
+ );
dateTimeUtils.isSame.mockReturnValue(true);
const formatDayLabel = vi.fn((time) => 'Today');
- const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+ const result = guideUtils.calculateHourTimeline(
+ start,
+ end,
+ formatDayLabel
+ );
expect(result).toHaveLength(3);
expect(formatDayLabel).toHaveBeenCalledTimes(3);
@@ -710,13 +809,25 @@ describe('guideUtils', () => {
it('should mark new day transitions', () => {
const start = dayjs('2024-01-15T23:00:00Z');
const end = dayjs('2024-01-16T02:00:00Z');
- dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
- dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
- dateTimeUtils.isSame.mockImplementation((a, b, unit) => dayjs(a).isSame(dayjs(b), unit));
+ dateTimeUtils.isBefore.mockImplementation((a, b) =>
+ dayjs(a).isBefore(dayjs(b))
+ );
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
+ dateTimeUtils.startOfDay.mockImplementation((time) =>
+ dayjs(time).startOf('day')
+ );
+ dateTimeUtils.isSame.mockImplementation((a, b, unit) =>
+ dayjs(a).isSame(dayjs(b), unit)
+ );
const formatDayLabel = vi.fn((time) => 'Day');
- const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+ const result = guideUtils.calculateHourTimeline(
+ start,
+ end,
+ formatDayLabel
+ );
expect(result[0].isNewDay).toBe(true);
});
@@ -791,7 +902,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map();
const channelById = new Map();
- const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+ const result = guideUtils.matchChannelByTvgId(
+ channelIdByTvgId,
+ channelById,
+ 'tvg-1'
+ );
expect(result).toBeNull();
});
@@ -801,7 +916,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
const channelById = new Map([[1, channel]]);
- const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+ const result = guideUtils.matchChannelByTvgId(
+ channelIdByTvgId,
+ channelById,
+ 'tvg-1'
+ );
expect(result).toBe(channel);
});
@@ -810,7 +929,11 @@ describe('guideUtils', () => {
const channelIdByTvgId = new Map([['tvg-1', [999]]]);
const channelById = new Map();
- const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+ const result = guideUtils.matchChannelByTvgId(
+ channelIdByTvgId,
+ channelById,
+ 'tvg-1'
+ );
expect(result).toBeNull();
});
@@ -933,7 +1056,9 @@ describe('guideUtils', () => {
start_time: '2024-01-15T10:30:00Z',
};
const start = '2024-01-15T10:00:00Z';
- dateTimeUtils.convertToMs.mockImplementation((time) => dayjs(time).valueOf());
+ dateTimeUtils.convertToMs.mockImplementation((time) =>
+ dayjs(time).valueOf()
+ );
const result = guideUtils.calculateLeftScrollPosition(program, start);
@@ -965,10 +1090,16 @@ describe('guideUtils', () => {
};
const clickedTime = dayjs('2024-01-15T10:00:00Z');
const start = dayjs('2024-01-15T09:00:00Z');
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
dateTimeUtils.diff.mockReturnValue(60);
- const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+ const result = guideUtils.calculateScrollPositionByTimeClick(
+ event,
+ clickedTime,
+ start
+ );
expect(result).toBeGreaterThanOrEqual(0);
});
@@ -982,7 +1113,9 @@ describe('guideUtils', () => {
};
const clickedTime = dayjs('2024-01-15T10:00:00Z');
const start = dayjs('2024-01-15T09:00:00Z');
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
dateTimeUtils.diff.mockReturnValue(75);
guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
@@ -999,12 +1132,22 @@ describe('guideUtils', () => {
};
const clickedTime = dayjs('2024-01-15T10:00:00Z');
const start = dayjs('2024-01-15T09:00:00Z');
- dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) =>
+ dayjs(time).add(amount, unit)
+ );
dateTimeUtils.diff.mockReturnValue(120);
- const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+ const result = guideUtils.calculateScrollPositionByTimeClick(
+ event,
+ clickedTime,
+ start
+ );
- expect(dateTimeUtils.add).toHaveBeenCalledWith(expect.anything(), 1, 'hour');
+ expect(dateTimeUtils.add).toHaveBeenCalledWith(
+ expect.anything(),
+ 1,
+ 'hour'
+ );
});
});
@@ -1037,9 +1180,7 @@ describe('guideUtils', () => {
1: { id: 1, name: 'Sports' },
2: { id: 2, name: 'News' },
};
- const channels = [
- { id: 1, channel_group_id: 1 },
- ];
+ const channels = [{ id: 1, channel_group_id: 1 }];
const result = guideUtils.getGroupOptions(channelGroups, channels);
diff --git a/frontend/src/pages/guide.css b/frontend/src/pages/guide.css
index 15ff6e0e..4c9216e2 100644
--- a/frontend/src/pages/guide.css
+++ b/frontend/src/pages/guide.css
@@ -7,22 +7,22 @@
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 8px;
- background: linear-gradient(to right, #2D2D2F, #1F1F20);
+ background: linear-gradient(to right, #2d2d2f, #1f1f20);
/* Default background */
color: #fff;
transition: all 0.2s ease-out;
}
.tv-guide .guide-program-container .guide-program.live {
- background: linear-gradient(to right, #3BA882, #245043);
+ background: linear-gradient(to right, #3ba882, #245043);
}
.tv-guide .guide-program-container .guide-program.live:hover {
- background: linear-gradient(to right, #2E9E80, #206E5E);
+ background: linear-gradient(to right, #2e9e80, #206e5e);
}
.tv-guide .guide-program-container .guide-program.not-live:hover {
- background: linear-gradient(to right, #2F3F3A, #1E2926);
+ background: linear-gradient(to right, #2f3f3a, #1e2926);
}
/* New styles for expanded programs */
@@ -33,15 +33,15 @@
}
.tv-guide .guide-program-container .guide-program.expanded.live {
- background: linear-gradient(to right, #226F5D, #3BA882);
+ background: linear-gradient(to right, #226f5d, #3ba882);
}
.tv-guide .guide-program-container .guide-program.expanded.not-live {
- background: linear-gradient(to right, #2C3F3A, #206E5E);
+ background: linear-gradient(to right, #2c3f3a, #206e5e);
}
.tv-guide .guide-program-container .guide-program.expanded.past {
- background: linear-gradient(to right, #1F2423, #2F3A37);
+ background: linear-gradient(to right, #1f2423, #2f3a37);
}
/* Ensure channel logo is always on top */
diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js
index 68bb74b2..98dc8e35 100644
--- a/frontend/src/pages/guideUtils.js
+++ b/frontend/src/pages/guideUtils.js
@@ -10,7 +10,7 @@ import {
format,
getNow,
getNowMs,
- roundToNearest
+ roundToNearest,
} from '../utils/dateTimeUtils.js';
import API from '../api.js';
@@ -28,7 +28,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
const tvgRecord = channel.epg_data_id
? tvgsById[channel.epg_data_id]
: null;
-
+
// For dummy EPG sources, ALWAYS use channel UUID to ensure unique programs per channel
// This prevents multiple channels with the same dummy EPG from showing identical data
let tvgId;
@@ -45,7 +45,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
// No EPG data: use channel UUID
tvgId = channel.uuid;
}
-
+
if (tvgId) {
const tvgKey = String(tvgId);
if (!map.has(tvgKey)) {
@@ -138,19 +138,25 @@ export const fetchPrograms = async () => {
export const sortChannels = (channels) => {
// Include ALL channels, sorted by channel number - don't filter by EPG data
const sortedChannels = Object.values(channels).sort(
- (a, b) =>
- (a.channel_number || Infinity) - (b.channel_number || Infinity)
+ (a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity)
);
console.log(`Using all ${sortedChannels.length} available channels`);
return sortedChannels;
-}
+};
-export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => {
+export const filterGuideChannels = (
+ guideChannels,
+ searchQuery,
+ selectedGroupId,
+ selectedProfileId,
+ profiles
+) => {
return guideChannels.filter((channel) => {
// Search filter
if (searchQuery) {
- if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
+ if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase()))
+ return false;
}
// Channel group filter
@@ -162,17 +168,17 @@ export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId,
if (selectedProfileId !== 'all') {
const profileChannels = profiles[selectedProfileId]?.channels || [];
const enabledChannelIds = Array.isArray(profileChannels)
- ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
- : profiles[selectedProfileId]?.channels instanceof Set
- ? Array.from(profiles[selectedProfileId].channels)
- : [];
+ ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
+ : profiles[selectedProfileId]?.channels instanceof Set
+ ? Array.from(profiles[selectedProfileId].channels)
+ : [];
if (!enabledChannelIds.includes(channel.id)) return false;
}
return true;
});
-}
+};
export const calculateEarliestProgramStart = (programs, defaultStart) => {
if (!programs.length) return defaultStart;
@@ -180,7 +186,7 @@ export const calculateEarliestProgramStart = (programs, defaultStart) => {
const s = initializeTime(p.start_time);
return isBefore(s, acc) ? s : acc;
}, defaultStart);
-}
+};
export const calculateLatestProgramEnd = (programs, defaultEnd) => {
if (!programs.length) return defaultEnd;
@@ -188,17 +194,17 @@ export const calculateLatestProgramEnd = (programs, defaultEnd) => {
const e = initializeTime(p.end_time);
return isAfter(e, acc) ? e : acc;
}, defaultEnd);
-}
+};
export const calculateStart = (earliestProgramStart, defaultStart) => {
return isBefore(earliestProgramStart, defaultStart)
? earliestProgramStart
: defaultStart;
-}
+};
export const calculateEnd = (latestProgramEnd, defaultEnd) => {
return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
-}
+};
export const mapChannelsById = (guideChannels) => {
const map = new Map();
@@ -206,7 +212,7 @@ export const mapChannelsById = (guideChannels) => {
map.set(channel.id, channel);
});
return map;
-}
+};
export const mapRecordingsByProgramId = (recordings) => {
const map = new Map();
@@ -217,7 +223,7 @@ export const mapRecordingsByProgramId = (recordings) => {
}
});
return map;
-}
+};
export const formatTime = (time, dateFormat) => {
const today = startOfDay(getNow());
@@ -236,7 +242,7 @@ export const formatTime = (time, dateFormat) => {
// Beyond a week, show month and day
return format(time, dateFormat);
}
-}
+};
export const calculateHourTimeline = (start, end, formatDayLabel) => {
const hours = [];
@@ -262,7 +268,7 @@ export const calculateHourTimeline = (start, end, formatDayLabel) => {
current = add(current, 1, 'hour');
}
return hours;
-}
+};
export const calculateNowPosition = (now, start, end) => {
if (isBefore(now, start) || isAfter(now, end)) return -1;
@@ -286,11 +292,11 @@ export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
}
// Return the first channel that matches this TVG ID
return channelById.get(channelIds[0]) || null;
-}
+};
export const fetchRules = async () => {
return await API.listSeriesRules();
-}
+};
export const getRuleByProgram = (rules, program) => {
return (rules || []).find(
@@ -298,7 +304,7 @@ export const getRuleByProgram = (rules, program) => {
String(r.tvg_id) === String(program.tvg_id) &&
(!r.title || r.title === program.title)
);
-}
+};
export const createRecording = async (channel, program) => {
await API.createRecording({
@@ -307,7 +313,7 @@ export const createRecording = async (channel, program) => {
end_time: program.end_time,
custom_properties: { program },
});
-}
+};
export const createSeriesRule = async (program, mode) => {
await API.createSeriesRule({
@@ -315,15 +321,14 @@ export const createSeriesRule = async (program, mode) => {
mode,
title: program.title,
});
-}
+};
export const evaluateSeriesRule = async (program) => {
await API.evaluateSeriesRules(program.tvg_id);
-}
+};
export const calculateLeftScrollPosition = (program, start) => {
- const programStartMs =
- program.startMs ?? convertToMs(program.start_time);
+ const programStartMs = program.startMs ?? convertToMs(program.start_time);
const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
@@ -331,9 +336,13 @@ export const calculateLeftScrollPosition = (program, start) => {
export const calculateDesiredScrollPosition = (leftPx) => {
return Math.max(0, leftPx - 20);
-}
+};
-export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
+export const calculateScrollPositionByTimeClick = (
+ event,
+ clickedTime,
+ start
+) => {
const rect = event.currentTarget.getBoundingClientRect();
const clickPositionX = event.clientX - rect.left;
const percentageAcross = clickPositionX / rect.width;
@@ -341,9 +350,10 @@ export const calculateScrollPositionByTimeClick = (event, clickedTime, start) =>
const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
- const adjustedTime = (snappedMinute === 60)
- ? add(clickedTime, 1, 'hour').minute(0)
- : clickedTime.minute(snappedMinute);
+ const adjustedTime =
+ snappedMinute === 60
+ ? add(clickedTime, 1, 'hour').minute(0)
+ : clickedTime.minute(snappedMinute);
const snappedOffset = diff(adjustedTime, start, 'minute');
return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
@@ -372,7 +382,7 @@ export const getGroupOptions = (channelGroups, guideChannels) => {
});
}
return options;
-}
+};
export const getProfileOptions = (profiles) => {
const options = [{ value: 'all', label: 'All Profiles' }];
@@ -390,12 +400,12 @@ export const getProfileOptions = (profiles) => {
}
return options;
-}
+};
export const deleteSeriesRuleByTvgId = async (tvg_id) => {
await API.deleteSeriesRule(tvg_id);
-}
+};
export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
await API.evaluateSeriesRules(tvg_id);
-}
\ No newline at end of file
+};
diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx
index 7dc8277b..6ec23873 100644
--- a/frontend/src/store/__tests__/auth.test.jsx
+++ b/frontend/src/store/__tests__/auth.test.jsx
@@ -54,36 +54,50 @@ describe('useAuthStore', () => {
localStorageMock.clear();
// Setup default store mocks
- useSettingsStore.mockImplementation((selector) => selector({
- fetchSettings: vi.fn().mockResolvedValue(),
- }));
+ useSettingsStore.mockImplementation((selector) =>
+ selector({
+ fetchSettings: vi.fn().mockResolvedValue(),
+ })
+ );
- useChannelsStore.mockImplementation((selector) => selector({
- fetchChannels: vi.fn().mockResolvedValue(),
- fetchChannelGroups: vi.fn().mockResolvedValue(),
- fetchChannelProfiles: vi.fn().mockResolvedValue(),
- }));
+ useChannelsStore.mockImplementation((selector) =>
+ selector({
+ fetchChannels: vi.fn().mockResolvedValue(),
+ fetchChannelGroups: vi.fn().mockResolvedValue(),
+ fetchChannelProfiles: vi.fn().mockResolvedValue(),
+ })
+ );
- usePlaylistsStore.mockImplementation((selector) => selector({
- fetchPlaylists: vi.fn().mockResolvedValue(),
- }));
+ usePlaylistsStore.mockImplementation((selector) =>
+ selector({
+ fetchPlaylists: vi.fn().mockResolvedValue(),
+ })
+ );
- useEPGsStore.mockImplementation((selector) => selector({
- fetchEPGs: vi.fn().mockResolvedValue(),
- fetchEPGData: vi.fn().mockResolvedValue(),
- }));
+ useEPGsStore.mockImplementation((selector) =>
+ selector({
+ fetchEPGs: vi.fn().mockResolvedValue(),
+ fetchEPGData: vi.fn().mockResolvedValue(),
+ })
+ );
- useStreamProfilesStore.mockImplementation((selector) => selector({
- fetchProfiles: vi.fn().mockResolvedValue(),
- }));
+ useStreamProfilesStore.mockImplementation((selector) =>
+ selector({
+ fetchProfiles: vi.fn().mockResolvedValue(),
+ })
+ );
- useUserAgentsStore.mockImplementation((selector) => selector({
- fetchUserAgents: vi.fn().mockResolvedValue(),
- }));
+ useUserAgentsStore.mockImplementation((selector) =>
+ selector({
+ fetchUserAgents: vi.fn().mockResolvedValue(),
+ })
+ );
- useUsersStore.mockImplementation((selector) => selector({
- fetchUsers: vi.fn().mockResolvedValue(),
- }));
+ useUsersStore.mockImplementation((selector) =>
+ selector({
+ fetchUsers: vi.fn().mockResolvedValue(),
+ })
+ );
});
afterEach(() => {
@@ -140,13 +154,25 @@ describe('useAuthStore', () => {
const { result } = renderHook(() => useAuthStore());
await act(async () => {
- await result.current.login({ username: 'testuser', password: 'password' });
+ await result.current.login({
+ username: 'testuser',
+ password: 'password',
+ });
});
expect(API.login).toHaveBeenCalledWith('testuser', 'password');
- expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken);
- expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken);
- expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number));
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'accessToken',
+ mockAccessToken
+ );
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'refreshToken',
+ mockRefreshToken
+ );
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'tokenExpiration',
+ expect.any(Number)
+ );
});
it('should handle login failure', async () => {
@@ -182,7 +208,10 @@ describe('useAuthStore', () => {
expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token');
expect(newToken).toBe(mockNewAccessToken);
- expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken);
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'accessToken',
+ mockNewAccessToken
+ );
});
it('should return false if no refresh token exists', async () => {
@@ -213,7 +242,9 @@ describe('useAuthStore', () => {
expect(result.current.isAuthenticated).toBe(false);
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
- expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith(
+ 'tokenExpiration'
+ );
});
});
@@ -277,7 +308,9 @@ describe('useAuthStore', () => {
expect(result.current.user).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
- expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith(
+ 'tokenExpiration'
+ );
});
it('should continue logout even if API call fails', async () => {
@@ -372,7 +405,6 @@ describe('useAuthStore', () => {
expect(fetchUsers).toHaveBeenCalled();
});
-
it('should not fetch users for non-admin user', async () => {
const mockUser = {
username: 'reseller',
@@ -418,7 +450,9 @@ describe('useAuthStore', () => {
API.me.mockResolvedValue(mockUser);
- const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed'));
+ const fetchChannels = vi
+ .fn()
+ .mockRejectedValue(new Error('Fetch failed'));
useChannelsStore.getState = vi.fn(() => ({
fetchChannels,
@@ -437,7 +471,11 @@ describe('useAuthStore', () => {
describe('setUser', () => {
it('should update user state', () => {
const { result } = renderHook(() => useAuthStore());
- const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN };
+ const newUser = {
+ username: 'test',
+ email: 'test@test.com',
+ user_level: USER_LEVELS.ADMIN,
+ };
act(() => {
result.current.setUser(newUser);
@@ -494,7 +532,10 @@ describe('useAuthStore', () => {
const { result } = renderHook(() => useAuthStore());
await act(async () => {
- await result.current.login({ username: 'testuser', password: 'password' });
+ await result.current.login({
+ username: 'testuser',
+ password: 'password',
+ });
});
expect(result.current.accessToken).toBeNull();
@@ -577,7 +618,7 @@ describe('useAuthStore', () => {
// Reset state before the test
useAuthStore.setState({
isInitializing: false,
- isInitialized: false
+ isInitialized: false,
});
API.me.mockRejectedValue(new Error('API error'));
@@ -620,7 +661,7 @@ describe('useAuthStore', () => {
// Wait for the background call to complete
await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 10));
+ await new Promise((resolve) => setTimeout(resolve, 10));
});
// The background fetchChannels is called synchronously without await
diff --git a/frontend/src/store/__tests__/channels.test.jsx b/frontend/src/store/__tests__/channels.test.jsx
index 6b452549..697f74ba 100644
--- a/frontend/src/store/__tests__/channels.test.jsx
+++ b/frontend/src/store/__tests__/channels.test.jsx
@@ -243,7 +243,11 @@ describe('useChannelsStore', () => {
const { result } = renderHook(() => useChannelsStore());
act(() => {
- result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] });
+ result.current.updateProfile({
+ id: '1',
+ name: 'Updated',
+ channels: [3],
+ });
});
expect(result.current.profiles['1'].name).toBe('Updated');
@@ -254,7 +258,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
- profiles: { '1': { id: '1' }, '2': { id: '2' } },
+ profiles: { 1: { id: '1' }, 2: { id: '2' } },
selectedProfileId: '1',
});
});
@@ -274,7 +278,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
- profiles: { '1': { id: '1', channels: new Set([1]) } },
+ profiles: { 1: { id: '1', channels: new Set([1]) } },
});
});
@@ -291,7 +295,7 @@ describe('useChannelsStore', () => {
act(() => {
useChannelsStore.setState({
- profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } },
+ profiles: { 1: { id: '1', channels: new Set([1, 2, 3]) } },
});
});
@@ -316,9 +320,7 @@ describe('useChannelsStore', () => {
});
const newStats = {
- channels: [
- { channel_id: 'uuid-1', clients: [] },
- ],
+ channels: [{ channel_id: 'uuid-1', clients: [] }],
};
act(() => {
diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx
index 0bf3e31a..0b98e9f6 100644
--- a/frontend/src/store/__tests__/channelsTable.test.jsx
+++ b/frontend/src/store/__tests__/channelsTable.test.jsx
@@ -40,7 +40,9 @@ describe('useChannelsTableStore', () => {
expect(result.current.channels).toEqual([]);
expect(result.current.pageCount).toBe(0);
expect(result.current.totalCount).toBe(0);
- expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]);
+ expect(result.current.sorting).toEqual([
+ { id: 'channel_number', desc: false },
+ ]);
expect(result.current.pagination.pageIndex).toBe(0);
expect(result.current.pagination.pageSize).toBe(50);
expect(result.current.selectedChannelIds).toEqual([]);
@@ -187,9 +189,7 @@ describe('useChannelsTableStore', () => {
it('should return empty array for channel without streams', () => {
const { result } = renderHook(() => useChannelsTableStore());
- const mockChannels = [
- { id: 1, name: 'Channel 1' },
- ];
+ const mockChannels = [{ id: 1, name: 'Channel 1' }];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
@@ -203,9 +203,7 @@ describe('useChannelsTableStore', () => {
it('should return empty array for non-existent channel', () => {
const { result } = renderHook(() => useChannelsTableStore());
- const mockChannels = [
- { id: 1, name: 'Channel 1', streams: ['stream1'] },
- ];
+ const mockChannels = [{ id: 1, name: 'Channel 1', streams: ['stream1'] }];
act(() => {
useChannelsTableStore.setState({ channels: mockChannels });
@@ -343,7 +341,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
- const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 };
+ const updatedChannel = {
+ id: 2,
+ name: 'Updated Channel 2',
+ channel_number: 22,
+ };
act(() => {
result.current.updateChannel(updatedChannel);
@@ -368,7 +370,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
- const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 };
+ const updatedChannel = {
+ id: 999,
+ name: 'Non-existent',
+ channel_number: 999,
+ };
act(() => {
result.current.updateChannel(updatedChannel);
@@ -389,7 +395,11 @@ describe('useChannelsTableStore', () => {
useChannelsTableStore.setState({ channels: mockChannels });
});
- const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 };
+ const updatedChannel = {
+ id: 1,
+ name: 'Updated Channel 1',
+ channel_number: 10,
+ };
act(() => {
result.current.updateChannel(updatedChannel);
diff --git a/frontend/src/store/__tests__/epgs.test.jsx b/frontend/src/store/__tests__/epgs.test.jsx
index 05f136d3..dfed7edb 100644
--- a/frontend/src/store/__tests__/epgs.test.jsx
+++ b/frontend/src/store/__tests__/epgs.test.jsx
@@ -65,7 +65,9 @@ describe('useEPGsStore', () => {
});
it('should set loading state while fetching', async () => {
- api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
+ api.getEPGs.mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve([]), 100))
+ );
const { result } = renderHook(() => useEPGsStore());
@@ -87,7 +89,9 @@ describe('useEPGsStore', () => {
const mockError = new Error('Network error');
api.getEPGs.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
@@ -96,7 +100,10 @@ describe('useEPGsStore', () => {
expect(result.current.error).toBe('Failed to load epgs.');
expect(result.current.isLoading).toBe(false);
- expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to fetch epgs:',
+ mockError
+ );
consoleSpy.mockRestore();
});
@@ -142,7 +149,9 @@ describe('useEPGsStore', () => {
});
it('should set loading state while fetching', async () => {
- api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
+ api.getEPGData.mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve([]), 100))
+ );
const { result } = renderHook(() => useEPGsStore());
@@ -163,7 +172,9 @@ describe('useEPGsStore', () => {
const mockError = new Error('API error');
api.getEPGData.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
await act(async () => {
@@ -173,7 +184,10 @@ describe('useEPGsStore', () => {
expect(result.current.error).toBe('Failed to load tvgs.');
expect(result.current.tvgsLoaded).toBe(true);
expect(result.current.isLoading).toBe(false);
- expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to fetch tvgs:',
+ mockError
+ );
consoleSpy.mockRestore();
});
@@ -274,7 +288,9 @@ describe('useEPGsStore', () => {
});
it('should not update state when called with invalid epg (null)', () => {
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@@ -287,13 +303,18 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
- expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'updateEPG called with invalid epg:',
+ null
+ );
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (missing id)', () => {
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@@ -308,13 +329,18 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
- expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'updateEPG called with invalid epg:',
+ invalidEPG
+ );
consoleSpy.mockRestore();
});
it('should not update state when called with invalid epg (non-object)', () => {
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
@@ -327,7 +353,10 @@ describe('useEPGsStore', () => {
});
expect(result.current.epgs).toEqual(initialEPGs);
- expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid');
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'updateEPG called with invalid epg:',
+ 'invalid'
+ );
consoleSpy.mockRestore();
});
@@ -518,7 +547,9 @@ describe('useEPGsStore', () => {
});
});
- expect(result.current.epgs.source1.last_message).toBe('Connection failed');
+ expect(result.current.epgs.source1.last_message).toBe(
+ 'Connection failed'
+ );
});
it('should use default error message if error is not provided', () => {
@@ -535,7 +566,9 @@ describe('useEPGsStore', () => {
});
it('should not update state when called with invalid data (null)', () => {
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
@@ -547,13 +580,18 @@ describe('useEPGsStore', () => {
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
- expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'updateEPGProgress called with invalid data:',
+ null
+ );
consoleSpy.mockRestore();
});
it('should not update state when called with invalid data (missing source)', () => {
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useEPGsStore());
const initialEPGs = { ...result.current.epgs };
@@ -565,7 +603,10 @@ describe('useEPGsStore', () => {
expect(result.current.epgs).toEqual(initialEPGs);
expect(result.current.refreshProgress).toEqual(initialProgress);
- expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 });
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'updateEPGProgress called with invalid data:',
+ { progress: 50 }
+ );
consoleSpy.mockRestore();
});
@@ -667,7 +708,11 @@ describe('useEPGsStore', () => {
act(() => {
useEPGsStore.setState({
epgs: {
- source1: { id: 'source1', status: 'parsing', last_message: 'Processing' },
+ source1: {
+ id: 'source1',
+ status: 'parsing',
+ last_message: 'Processing',
+ },
},
});
});
diff --git a/frontend/src/store/__tests__/logos.test.jsx b/frontend/src/store/__tests__/logos.test.jsx
index 59ac4283..fff82a95 100644
--- a/frontend/src/store/__tests__/logos.test.jsx
+++ b/frontend/src/store/__tests__/logos.test.jsx
@@ -58,7 +58,11 @@ describe('useLogosStore', () => {
it('should add logo to main logos store', () => {
const { result } = renderHook(() => useLogosStore());
- const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
+ const newLogo = {
+ id: 'logo1',
+ name: 'Logo 1',
+ url: 'http://example.com/logo1.png',
+ };
act(() => {
result.current.addLogo(newLogo);
@@ -76,7 +80,11 @@ describe('useLogosStore', () => {
useLogosStore.setState({ hasLoadedChannelLogos: true });
});
- const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
+ const newLogo = {
+ id: 'logo1',
+ name: 'Logo 1',
+ url: 'http://example.com/logo1.png',
+ };
act(() => {
result.current.addLogo(newLogo);
@@ -89,7 +97,11 @@ describe('useLogosStore', () => {
it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => {
const { result } = renderHook(() => useLogosStore());
- const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
+ const newLogo = {
+ id: 'logo1',
+ name: 'Logo 1',
+ url: 'http://example.com/logo1.png',
+ };
act(() => {
result.current.addLogo(newLogo);
@@ -104,8 +116,16 @@ describe('useLogosStore', () => {
it('should update logo in main logos store', () => {
const { result } = renderHook(() => useLogosStore());
- const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
- const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
+ const originalLogo = {
+ id: 'logo1',
+ name: 'Original',
+ url: 'http://example.com/original.png',
+ };
+ const updatedLogo = {
+ id: 'logo1',
+ name: 'Updated',
+ url: 'http://example.com/updated.png',
+ };
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
@@ -121,8 +141,16 @@ describe('useLogosStore', () => {
it('should update logo in channelLogos if it exists there', () => {
const { result } = renderHook(() => useLogosStore());
- const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
- const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
+ const originalLogo = {
+ id: 'logo1',
+ name: 'Original',
+ url: 'http://example.com/original.png',
+ };
+ const updatedLogo = {
+ id: 'logo1',
+ name: 'Updated',
+ url: 'http://example.com/updated.png',
+ };
act(() => {
useLogosStore.setState({
@@ -142,8 +170,16 @@ describe('useLogosStore', () => {
it('should not update channelLogos if logo does not exist there', () => {
const { result } = renderHook(() => useLogosStore());
- const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
- const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
+ const originalLogo = {
+ id: 'logo1',
+ name: 'Original',
+ url: 'http://example.com/original.png',
+ };
+ const updatedLogo = {
+ id: 'logo1',
+ name: 'Updated',
+ url: 'http://example.com/updated.png',
+ };
act(() => {
useLogosStore.setState({ logos: { logo1: originalLogo } });
@@ -249,7 +285,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@@ -261,7 +299,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load logos.');
expect(result.current.isLoading).toBe(false);
- expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to fetch logos:',
+ mockError
+ );
});
consoleSpy.mockRestore();
@@ -354,7 +395,9 @@ describe('useLogosStore', () => {
const mockError = new Error('API error');
api.getLogos.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@@ -366,7 +409,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load all logos.');
expect(result.current.isLoading).toBe(false);
- expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to fetch all logos:',
+ mockError
+ );
});
consoleSpy.mockRestore();
@@ -396,7 +442,10 @@ describe('useLogosStore', () => {
logo2: { id: 'logo2', name: 'Used Logo 2' },
});
expect(result.current.isLoading).toBe(false);
- expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 });
+ expect(api.getLogos).toHaveBeenCalledWith({
+ used: 'true',
+ page_size: 100,
+ });
expect(response).toEqual(mockResponse);
});
@@ -426,7 +475,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@@ -437,7 +488,10 @@ describe('useLogosStore', () => {
await waitFor(() => {
expect(result.current.error).toBe('Failed to load used logos.');
- expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to fetch used logos:',
+ mockError
+ );
});
consoleSpy.mockRestore();
@@ -537,7 +591,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogosByIds.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await expect(
@@ -546,7 +602,10 @@ describe('useLogosStore', () => {
})
).rejects.toThrow('Fetch error');
- expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Failed to fetch logos by IDs:',
+ mockError
+ );
consoleSpy.mockRestore();
});
@@ -565,9 +624,7 @@ describe('useLogosStore', () => {
next: null,
};
- api.getLogos
- .mockResolvedValueOnce(page1)
- .mockResolvedValueOnce(page2);
+ api.getLogos.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);
const { result } = renderHook(() => useLogosStore());
@@ -589,7 +646,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Network error');
api.getLogos.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
@@ -597,7 +656,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
- expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Background logo loading failed:',
+ mockError
+ );
consoleSpy.mockRestore();
});
@@ -665,7 +727,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.backgroundLoadAllLogos();
@@ -675,7 +739,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
- expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError);
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Background all logos loading failed:',
+ mockError
+ );
consoleSpy.mockRestore();
@@ -713,7 +780,10 @@ describe('useLogosStore', () => {
});
it('should not start if channelLogos already has many items', async () => {
- const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]);
+ const channelLogos = Array.from({ length: 150 }, (_, i) => [
+ `logo${i}`,
+ { id: `logo${i}` },
+ ]);
const channelLogosObj = Object.fromEntries(channelLogos);
const { result } = renderHook(() => useLogosStore());
@@ -747,8 +817,12 @@ describe('useLogosStore', () => {
expect(result.current.hasLoadedChannelLogos).toBe(true);
expect(result.current.backgroundLoading).toBe(false);
expect(Object.keys(result.current.channelLogos).length).toBe(2);
- expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...');
- expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos');
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Background loading channel logos...'
+ );
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Background loaded 2 channel logos'
+ );
consoleSpy.mockRestore();
});
@@ -757,8 +831,12 @@ describe('useLogosStore', () => {
const mockError = new Error('Fetch error');
api.getLogos.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const consoleLogSpy = vi
+ .spyOn(console, 'log')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
await act(async () => {
@@ -766,7 +844,10 @@ describe('useLogosStore', () => {
});
expect(result.current.backgroundLoading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Background channel logo loading failed:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
@@ -801,7 +882,9 @@ describe('useLogosStore', () => {
const mockError = new Error('Background error');
api.getLogos.mockRejectedValue(mockError);
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useLogosStore());
result.current.startBackgroundLoading();
diff --git a/frontend/src/store/__tests__/playlists.test.jsx b/frontend/src/store/__tests__/playlists.test.jsx
index 2d49791e..da653259 100644
--- a/frontend/src/store/__tests__/playlists.test.jsx
+++ b/frontend/src/store/__tests__/playlists.test.jsx
@@ -65,13 +65,17 @@ describe('usePlaylistsStore', () => {
expect(api.getPlaylist).toHaveBeenCalledWith('playlist1');
expect(result.current.playlists).toEqual([mockPlaylist]);
- expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
+ expect(result.current.profiles).toEqual({
+ playlist1: ['profile1', 'profile2'],
+ });
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should handle fetch playlist error', async () => {
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
api.getPlaylist.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
@@ -110,7 +114,9 @@ describe('usePlaylistsStore', () => {
});
it('should handle fetch playlists error', async () => {
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
api.getPlaylists.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => usePlaylistsStore());
@@ -142,8 +148,16 @@ describe('usePlaylistsStore', () => {
it('should update playlist', () => {
const { result } = renderHook(() => usePlaylistsStore());
- const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] };
- const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] };
+ const existingPlaylist = {
+ id: 'playlist1',
+ name: 'Old Name',
+ profiles: ['profile1'],
+ };
+ const updatedPlaylist = {
+ id: 'playlist1',
+ name: 'New Name',
+ profiles: ['profile1', 'profile2'],
+ };
act(() => {
result.current.playlists = [existingPlaylist];
@@ -155,7 +169,9 @@ describe('usePlaylistsStore', () => {
});
expect(result.current.playlists).toEqual([updatedPlaylist]);
- expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
+ expect(result.current.profiles).toEqual({
+ playlist1: ['profile1', 'profile2'],
+ });
});
it('should update profiles', () => {
@@ -166,10 +182,16 @@ describe('usePlaylistsStore', () => {
});
act(() => {
- result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']);
+ result.current.updateProfiles('playlist1', [
+ 'profile1',
+ 'profile2',
+ 'profile3',
+ ]);
});
- expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] });
+ expect(result.current.profiles).toEqual({
+ playlist1: ['profile1', 'profile2', 'profile3'],
+ });
});
it('should remove playlists', () => {
@@ -187,7 +209,9 @@ describe('usePlaylistsStore', () => {
result.current.removePlaylists(['playlist1', 'playlist3']);
});
- expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]);
+ expect(result.current.playlists).toEqual([
+ { id: 'playlist2', name: 'Playlist 2' },
+ ]);
});
it('should set refresh progress with two parameters', () => {
@@ -228,7 +252,11 @@ describe('usePlaylistsStore', () => {
expect(result.current.refreshProgress.account1.action).toBe('initializing');
act(() => {
- result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 });
+ result.current.setRefreshProgress({
+ account: 'account1',
+ action: 'refreshing',
+ progress: 25,
+ });
});
expect(result.current.refreshProgress.account1.action).toBe('refreshing');
diff --git a/frontend/src/store/__tests__/plugins.test.jsx b/frontend/src/store/__tests__/plugins.test.jsx
index 2db48095..b47d3450 100644
--- a/frontend/src/store/__tests__/plugins.test.jsx
+++ b/frontend/src/store/__tests__/plugins.test.jsx
@@ -136,7 +136,11 @@ describe('usePluginStore', () => {
result.current.updatePlugin('plugin1', { name: 'Updated Plugin' });
});
- expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false });
+ expect(result.current.plugins[1]).toEqual({
+ key: 'plugin2',
+ name: 'Plugin 2',
+ enabled: false,
+ });
});
it('should add plugin', () => {
@@ -152,7 +156,11 @@ describe('usePluginStore', () => {
it('should add plugin to existing plugins', () => {
const { result } = renderHook(() => usePluginStore());
- const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true };
+ const existingPlugin = {
+ key: 'plugin1',
+ name: 'Existing Plugin',
+ enabled: true,
+ };
const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false };
act(() => {
@@ -192,9 +200,7 @@ describe('usePluginStore', () => {
act(() => {
usePluginStore.setState({
- plugins: [
- { key: 'plugin1', name: 'Plugin 1', enabled: true },
- ],
+ plugins: [{ key: 'plugin1', name: 'Plugin 1', enabled: true }],
});
});
@@ -208,9 +214,7 @@ describe('usePluginStore', () => {
});
it('should invalidate plugins and refetch', async () => {
- const mockPlugins = [
- { key: 'plugin1', name: 'Plugin 1', enabled: true },
- ];
+ const mockPlugins = [{ key: 'plugin1', name: 'Plugin 1', enabled: true }];
API.getPlugins.mockResolvedValue(mockPlugins);
@@ -218,9 +222,7 @@ describe('usePluginStore', () => {
act(() => {
usePluginStore.setState({
- plugins: [
- { key: 'old-plugin', name: 'Old Plugin', enabled: false },
- ],
+ plugins: [{ key: 'old-plugin', name: 'Old Plugin', enabled: false }],
});
});
diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx
index 58f64724..016fce07 100644
--- a/frontend/src/store/__tests__/settings.test.jsx
+++ b/frontend/src/store/__tests__/settings.test.jsx
@@ -190,7 +190,10 @@ describe('useSettingsStore', () => {
result.current.updateSetting({ key: 'setting1', value: 'updated' });
});
- expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' });
+ expect(result.current.settings.setting2).toEqual({
+ key: 'setting2',
+ value: 'value2',
+ });
});
it('should handle empty settings array', async () => {
@@ -249,7 +252,10 @@ describe('useSettingsStore', () => {
},
});
- api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
+ api.getVersion.mockResolvedValue({
+ version: '2.0.0',
+ timestamp: '2024-01-01T00:00:00Z',
+ });
const { result } = renderHook(() => useSettingsStore());
@@ -336,7 +342,10 @@ describe('useSettingsStore', () => {
api.getSettings.mockResolvedValue(mockSettings);
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
- api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
+ api.getVersion.mockResolvedValue({
+ version: '2.0.0',
+ timestamp: '2024-01-01T00:00:00Z',
+ });
const { result } = renderHook(() => useSettingsStore());
diff --git a/frontend/src/store/__tests__/streamProfiles.test.jsx b/frontend/src/store/__tests__/streamProfiles.test.jsx
index 30380664..72f18221 100644
--- a/frontend/src/store/__tests__/streamProfiles.test.jsx
+++ b/frontend/src/store/__tests__/streamProfiles.test.jsx
@@ -47,7 +47,9 @@ describe('useStreamProfilesStore', () => {
const mockError = new Error('Network error');
api.getStreamProfiles.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useStreamProfilesStore());
@@ -57,7 +59,10 @@ describe('useStreamProfilesStore', () => {
expect(result.current.error).toBe('Failed to load profiles.');
expect(result.current.isLoading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch profiles:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -152,7 +157,11 @@ describe('useStreamProfilesStore', () => {
result.current.updateStreamProfile(updatedProfile);
});
- expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 });
+ expect(result.current.profiles[1]).toEqual({
+ id: 2,
+ name: 'Profile 2',
+ bitrate: 8000,
+ });
});
it('should not modify profiles when updating non-existent profile', () => {
@@ -166,7 +175,11 @@ describe('useStreamProfilesStore', () => {
});
const { result } = renderHook(() => useStreamProfilesStore());
- const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 };
+ const nonExistentProfile = {
+ id: 999,
+ name: 'Non-existent',
+ bitrate: 10000,
+ };
act(() => {
result.current.updateStreamProfile(nonExistentProfile);
@@ -246,9 +259,7 @@ describe('useStreamProfilesStore', () => {
});
it('should handle empty array when removing profiles', () => {
- const initialProfiles = [
- { id: 1, name: 'Profile 1', bitrate: 5000 },
- ];
+ const initialProfiles = [{ id: 1, name: 'Profile 1', bitrate: 5000 }];
useStreamProfilesStore.setState({
profiles: initialProfiles,
diff --git a/frontend/src/store/__tests__/streams.test.jsx b/frontend/src/store/__tests__/streams.test.jsx
index e6ed3952..c74d27ee 100644
--- a/frontend/src/store/__tests__/streams.test.jsx
+++ b/frontend/src/store/__tests__/streams.test.jsx
@@ -53,7 +53,9 @@ describe('useStreamsStore', () => {
const mockError = new Error('Network error');
api.getStreams.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useStreamsStore());
@@ -63,7 +65,10 @@ describe('useStreamsStore', () => {
expect(result.current.error).toBe('Failed to load streams.');
expect(result.current.isLoading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch streams:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -131,7 +136,11 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
- const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
+ const updatedStream = {
+ id: 1,
+ name: 'Updated Stream',
+ url: 'http://example.com/updated',
+ };
act(() => {
result.current.updateStream(updatedStream);
@@ -152,13 +161,21 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
- const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
+ const updatedStream = {
+ id: 1,
+ name: 'Updated Stream',
+ url: 'http://example.com/updated',
+ };
act(() => {
result.current.updateStream(updatedStream);
});
- expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' });
+ expect(result.current.streams[1]).toEqual({
+ id: 2,
+ name: 'Stream 2',
+ url: 'http://example.com/2',
+ });
});
it('should not modify streams when updating non-existent stream', () => {
@@ -172,7 +189,11 @@ describe('useStreamsStore', () => {
});
const { result } = renderHook(() => useStreamsStore());
- const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' };
+ const nonExistentStream = {
+ id: 999,
+ name: 'Non-existent',
+ url: 'http://example.com/999',
+ };
act(() => {
result.current.updateStream(nonExistentStream);
diff --git a/frontend/src/store/__tests__/streamsTable.test.jsx b/frontend/src/store/__tests__/streamsTable.test.jsx
index 62818107..49715ce4 100644
--- a/frontend/src/store/__tests__/streamsTable.test.jsx
+++ b/frontend/src/store/__tests__/streamsTable.test.jsx
@@ -16,7 +16,7 @@ const localStorageMock = (() => {
}),
removeItem: vi.fn((key) => {
delete store[key];
- })
+ }),
};
})();
@@ -58,7 +58,7 @@ describe('useStreamsTableStore', () => {
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
- }
+ },
});
const { result } = renderHook(() => useStreamsTableStore());
@@ -74,7 +74,7 @@ describe('useStreamsTableStore', () => {
pagination: {
pageIndex: 0,
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
- }
+ },
});
const { result } = renderHook(() => useStreamsTableStore());
@@ -112,10 +112,7 @@ describe('useStreamsTableStore', () => {
const mockParams = new URLSearchParams({ page_size: '25' });
act(() => {
- result.current.queryStreams(
- { results: [], count: 75 },
- mockParams
- );
+ result.current.queryStreams({ results: [], count: 75 }, mockParams);
});
expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
@@ -127,10 +124,7 @@ describe('useStreamsTableStore', () => {
const mockParams = new URLSearchParams({ page_size: '50' });
act(() => {
- result.current.queryStreams(
- { results: [], count: 0 },
- mockParams
- );
+ result.current.queryStreams({ results: [], count: 0 }, mockParams);
});
expect(result.current.streams).toEqual([]);
@@ -340,7 +334,9 @@ describe('useStreamsTableStore', () => {
expect(result.current.totalCount).toBe(50);
expect(result.current.pageCount).toBe(2);
expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
- expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]);
+ expect(result.current.sorting).toEqual([
+ { id: 'created_at', desc: true },
+ ]);
expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]);
expect(result.current.selectedStreamIds).toEqual([1, 2]);
expect(result.current.lastQueryParams).toBe(mockParams);
diff --git a/frontend/src/store/__tests__/useVODStore.test.jsx b/frontend/src/store/__tests__/useVODStore.test.jsx
index 5734e5a2..490fcbe3 100644
--- a/frontend/src/store/__tests__/useVODStore.test.jsx
+++ b/frontend/src/store/__tests__/useVODStore.test.jsx
@@ -103,7 +103,12 @@ describe('useVODStore', () => {
expect(api.getAllContent).toHaveBeenCalled();
expect(result.current.currentPageContent).toEqual([
{ id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' },
- { id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' },
+ {
+ id: 2,
+ name: 'Series 1',
+ content_type: 'series',
+ contentType: 'series',
+ },
]);
expect(result.current.totalCount).toBe(2);
expect(result.current.loading).toBe(false);
@@ -171,7 +176,9 @@ describe('useVODStore', () => {
const mockError = new Error('Network error');
api.getAllContent.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -181,7 +188,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load content.');
expect(result.current.loading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch content:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -189,7 +199,9 @@ describe('useVODStore', () => {
it('should handle invalid response format', async () => {
api.getAllContent.mockResolvedValue({ results: 'not-an-array' });
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -234,7 +246,9 @@ describe('useVODStore', () => {
const mockError = new Error('Not found');
api.getMovieDetails.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -248,7 +262,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load movie details.');
expect(result.current.loading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch movie details:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -283,7 +300,9 @@ describe('useVODStore', () => {
const mockError = new Error('Provider error');
api.getMovieProviderInfo.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -295,8 +314,13 @@ describe('useVODStore', () => {
}
});
- expect(result.current.error).toBe('Failed to load movie details from provider.');
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError);
+ expect(result.current.error).toBe(
+ 'Failed to load movie details from provider.'
+ );
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch movie details from provider:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -324,7 +348,9 @@ describe('useVODStore', () => {
const mockError = new Error('Providers error');
api.getMovieProviders.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -336,7 +362,10 @@ describe('useVODStore', () => {
}
});
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch movie providers:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -361,7 +390,9 @@ describe('useVODStore', () => {
const mockError = new Error('Series providers error');
api.getSeriesProviders.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -373,7 +404,10 @@ describe('useVODStore', () => {
}
});
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch series providers:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -428,7 +462,9 @@ describe('useVODStore', () => {
const mockError = new Error('Series not found');
api.getSeriesInfo.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -442,7 +478,10 @@ describe('useVODStore', () => {
expect(result.current.error).toBe('Failed to load series details.');
expect(result.current.loading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch series info:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -494,7 +533,9 @@ describe('useVODStore', () => {
const mockError = new Error('Categories error');
api.getVODCategories.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODStore());
@@ -503,7 +544,10 @@ describe('useVODStore', () => {
});
expect(result.current.error).toBe('Failed to load categories.');
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch VOD categories:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
diff --git a/frontend/src/store/__tests__/useVideoStore.test.jsx b/frontend/src/store/__tests__/useVideoStore.test.jsx
index b1f87d6d..53dfb594 100644
--- a/frontend/src/store/__tests__/useVideoStore.test.jsx
+++ b/frontend/src/store/__tests__/useVideoStore.test.jsx
@@ -68,7 +68,9 @@ describe('useVideoStore', () => {
const { result } = renderHook(() => useVideoStore());
act(() => {
- result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' });
+ result.current.showVideo('http://example.com/stream.ts', 'vod', {
+ title: 'Test',
+ });
});
expect(result.current.isVisible).toBe(true);
@@ -121,7 +123,7 @@ describe('useVideoStore', () => {
const metadata = {
title: 'Test Video',
duration: 120,
- thumbnailUrl: 'http://example.com/thumb.jpg'
+ thumbnailUrl: 'http://example.com/thumb.jpg',
};
act(() => {
@@ -139,13 +141,21 @@ describe('useVideoStore', () => {
const secondMetadata = { title: 'Second Video' };
act(() => {
- result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata);
+ result.current.showVideo(
+ 'http://example.com/first.mp4',
+ 'vod',
+ firstMetadata
+ );
});
expect(result.current.metadata).toEqual(firstMetadata);
act(() => {
- result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata);
+ result.current.showVideo(
+ 'http://example.com/second.mp4',
+ 'vod',
+ secondMetadata
+ );
});
expect(result.current.metadata).toEqual(secondMetadata);
diff --git a/frontend/src/store/__tests__/userAgents.test.jsx b/frontend/src/store/__tests__/userAgents.test.jsx
index 8e804bec..bfa1855d 100644
--- a/frontend/src/store/__tests__/userAgents.test.jsx
+++ b/frontend/src/store/__tests__/userAgents.test.jsx
@@ -47,7 +47,9 @@ describe('useUserAgentsStore', () => {
const mockError = new Error('Network error');
api.getUserAgents.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useUserAgentsStore());
@@ -57,7 +59,10 @@ describe('useUserAgentsStore', () => {
expect(result.current.error).toBe('Failed to load userAgents.');
expect(result.current.isLoading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch userAgents:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -125,7 +130,11 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
- const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
+ const updatedUserAgent = {
+ id: 1,
+ name: 'Chrome Updated',
+ string: 'Mozilla/5.0 Updated...',
+ };
act(() => {
result.current.updateUserAgent(updatedUserAgent);
@@ -146,13 +155,21 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
- const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
+ const updatedUserAgent = {
+ id: 1,
+ name: 'Chrome Updated',
+ string: 'Mozilla/5.0 Updated...',
+ };
act(() => {
result.current.updateUserAgent(updatedUserAgent);
});
- expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' });
+ expect(result.current.userAgents[1]).toEqual({
+ id: 2,
+ name: 'Firefox',
+ string: 'Mozilla/5.0...',
+ });
});
it('should not modify user agents when updating non-existent user agent', () => {
@@ -166,7 +183,11 @@ describe('useUserAgentsStore', () => {
});
const { result } = renderHook(() => useUserAgentsStore());
- const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' };
+ const nonExistentUserAgent = {
+ id: 999,
+ name: 'Non-existent',
+ string: 'Mozilla/5.0...',
+ };
act(() => {
result.current.updateUserAgent(nonExistentUserAgent);
diff --git a/frontend/src/store/__tests__/users.test.jsx b/frontend/src/store/__tests__/users.test.jsx
index 58b343ba..b48c954a 100644
--- a/frontend/src/store/__tests__/users.test.jsx
+++ b/frontend/src/store/__tests__/users.test.jsx
@@ -47,7 +47,9 @@ describe('useUsersStore', () => {
const mockError = new Error('Network error');
api.getUsers.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useUsersStore());
@@ -57,7 +59,10 @@ describe('useUsersStore', () => {
expect(result.current.error).toBe('Failed to load users.');
expect(result.current.isLoading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch users:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -125,7 +130,11 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
- const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
+ const updatedUser = {
+ id: 1,
+ name: 'Updated User',
+ email: 'updated@example.com',
+ };
act(() => {
result.current.updateUser(updatedUser);
@@ -146,13 +155,21 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
- const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
+ const updatedUser = {
+ id: 1,
+ name: 'Updated User',
+ email: 'updated@example.com',
+ };
act(() => {
result.current.updateUser(updatedUser);
});
- expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' });
+ expect(result.current.users[1]).toEqual({
+ id: 2,
+ name: 'User 2',
+ email: 'user2@example.com',
+ });
});
it('should not modify users when updating non-existent user', () => {
@@ -166,7 +183,11 @@ describe('useUsersStore', () => {
});
const { result } = renderHook(() => useUsersStore());
- const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' };
+ const nonExistentUser = {
+ id: 999,
+ name: 'Non-existent',
+ email: 'none@example.com',
+ };
act(() => {
result.current.updateUser(nonExistentUser);
@@ -252,7 +273,15 @@ describe('useUsersStore', () => {
result.current.removeUser(2);
});
- expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' });
- expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' });
+ expect(result.current.users[0]).toEqual({
+ id: 1,
+ name: 'User 1',
+ email: 'user1@example.com',
+ });
+ expect(result.current.users[1]).toEqual({
+ id: 3,
+ name: 'User 3',
+ email: 'user3@example.com',
+ });
});
});
diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx
index 116cbf9d..77c702b8 100644
--- a/frontend/src/store/__tests__/vodLogos.test.jsx
+++ b/frontend/src/store/__tests__/vodLogos.test.jsx
@@ -105,7 +105,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Network error');
api.getVODLogos.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@@ -119,7 +121,10 @@ describe('useVODLogosStore', () => {
expect(result.current.error).toBe('Failed to load VOD logos.');
expect(result.current.isLoading).toBe(false);
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch VOD logos:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -205,9 +210,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
- expect(result.current.logos).toEqual([
- { id: 2, name: 'Logo 2' },
- ]);
+ expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]);
expect(result.current.totalCount).toBe(1);
});
@@ -236,9 +239,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
2: { id: 2, name: 'Logo 2' },
});
- expect(result.current.logos).toEqual([
- { id: 2, name: 'Logo 2' },
- ]);
+ expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]);
expect(result.current.totalCount).toBe(1);
});
@@ -246,7 +247,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Delete failed');
api.deleteVODLogo.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@@ -258,7 +261,10 @@ describe('useVODLogosStore', () => {
}
});
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to delete VOD logo:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -290,9 +296,7 @@ describe('useVODLogosStore', () => {
expect(result.current.vodLogos).toEqual({
3: { id: 3, name: 'Logo 3' },
});
- expect(result.current.logos).toEqual([
- { id: 3, name: 'Logo 3' },
- ]);
+ expect(result.current.logos).toEqual([{ id: 3, name: 'Logo 3' }]);
expect(result.current.totalCount).toBe(1);
});
@@ -300,7 +304,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Bulk delete failed');
api.deleteVODLogos.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@@ -312,7 +318,10 @@ describe('useVODLogosStore', () => {
}
});
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to delete VOD logos:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -348,7 +357,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Cleanup failed');
api.cleanupUnusedVODLogos.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@@ -360,7 +371,10 @@ describe('useVODLogosStore', () => {
}
});
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to cleanup unused VOD logos:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
@@ -517,7 +531,9 @@ describe('useVODLogosStore', () => {
const mockError = new Error('Failed to fetch count');
api.getVODLogos.mockRejectedValue(mockError);
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
const { result } = renderHook(() => useVODLogosStore());
@@ -529,7 +545,10 @@ describe('useVODLogosStore', () => {
}
});
- expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to fetch unused logos count:',
+ mockError
+ );
consoleErrorSpy.mockRestore();
});
diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx
index a95959b2..7646fca9 100644
--- a/frontend/src/store/channels.jsx
+++ b/frontend/src/store/channels.jsx
@@ -12,9 +12,15 @@ const reduceChannels = (channels) => {
return acc;
}, {});
return { channelsByUUID, channelsByID };
-}
+};
-const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => {
+const showNotificationIfNewChannel = (
+ currentStats,
+ oldChannels,
+ ch,
+ channelsByUUID,
+ channels
+) => {
if (currentStats.channels) {
if (oldChannels[ch.channel_id] === undefined) {
// Add null checks to prevent accessing properties on undefined
@@ -30,7 +36,7 @@ const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByU
}
}
}
-}
+};
const showNotificationIfNewClient = (currentStats, oldClients, client) => {
// This check prevents the notifications if streams are active on page load
@@ -43,9 +49,15 @@ const showNotificationIfNewClient = (currentStats, oldClients, client) => {
});
}
}
-}
+};
-const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => {
+const showNotificationIfChannelStopped = (
+ currentStats,
+ oldChannels,
+ newChannels,
+ channelsByUUID,
+ channels
+) => {
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
for (const uuid in oldChannels) {
@@ -70,9 +82,13 @@ const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels
}
}
}
-}
+};
-const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => {
+const showNotificationIfClientStopped = (
+ currentStats,
+ oldClients,
+ newClients
+) => {
if (currentStats.channels) {
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
@@ -84,7 +100,7 @@ const showNotificationIfClientStopped = (currentStats, oldClients, newClients) =
}
}
}
-}
+};
const useChannelsStore = create((set, get) => ({
channels: [],
@@ -228,7 +244,7 @@ const useChannelsStore = create((set, get) => ({
);
return;
}
-
+
const { channelsByUUID, updatedChannels } = reduceChannels(channels);
set((state) => ({
@@ -383,24 +399,36 @@ const useChannelsStore = create((set, get) => ({
channelsByUUID,
} = state;
const newClients = {};
-
+
const newChannels = stats.channels.reduce((acc, ch) => {
acc[ch.channel_id] = ch;
return acc;
}, {});
- stats.channels.forEach(ch => {
- showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels);
+ stats.channels.forEach((ch) => {
+ showNotificationIfNewChannel(
+ currentStats,
+ oldChannels,
+ ch,
+ channelsByUUID,
+ channels
+ );
- ch.clients.forEach(client => {
- newClients[client.client_id] = client;
- showNotificationIfNewClient(currentStats, oldClients, client);
- });
+ ch.clients.forEach((client) => {
+ newClients[client.client_id] = client;
+ showNotificationIfNewClient(currentStats, oldClients, client);
+ });
});
- showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels);
+ showNotificationIfChannelStopped(
+ currentStats,
+ oldChannels,
+ newChannels,
+ channelsByUUID,
+ channels
+ );
showNotificationIfClientStopped(currentStats, oldClients, newClients);
-
+
return {
stats,
activeChannels: newChannels,
diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx
index e8f5e2ad..d8a54215 100644
--- a/frontend/src/store/epgs.jsx
+++ b/frontend/src/store/epgs.jsx
@@ -4,7 +4,8 @@ import api from '../api';
const determineEPGStatus = (data, currentEpg) => {
if (data.status) return data.status;
if (data.action === 'downloading') return 'fetching';
- if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing';
+ if (data.action === 'parsing_channels' || data.action === 'parsing_programs')
+ return 'parsing';
if (data.progress === 100) return 'success';
return currentEpg?.status || 'idle';
};
@@ -118,23 +119,26 @@ const useEPGsStore = create((set) => ({
// Only update epgs object if status or last_message actually changed
// This prevents unnecessary re-renders on every progress update
- const lastMessage = data.status === 'error'
- ? (data.error || 'Unknown error')
- : state.epgs[data.source]?.last_message;
+ const lastMessage =
+ data.status === 'error'
+ ? data.error || 'Unknown error'
+ : state.epgs[data.source]?.last_message;
const currentEpg = state.epgs[data.source];
- const shouldUpdateEpg = currentEpg &&
- (currentEpg.status !== status || currentEpg.last_message !== lastMessage);
+ const shouldUpdateEpg =
+ currentEpg &&
+ (currentEpg.status !== status ||
+ currentEpg.last_message !== lastMessage);
const epgs = shouldUpdateEpg
? {
- ...state.epgs,
- [data.source]: {
- ...currentEpg,
- status,
- last_message: lastMessage,
- },
- }
+ ...state.epgs,
+ [data.source]: {
+ ...currentEpg,
+ status,
+ last_message: lastMessage,
+ },
+ }
: state.epgs;
return { refreshProgress, epgs };
diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx
index f821c424..2270a84c 100644
--- a/frontend/src/store/logos.jsx
+++ b/frontend/src/store/logos.jsx
@@ -2,10 +2,8 @@ import { create } from 'zustand';
import api from '../api';
const getLogosArray = (response) => {
- return Array.isArray(response)
- ? response
- : response.results || [];
-}
+ return Array.isArray(response) ? response : response.results || [];
+};
const useLogosStore = create((set, get) => ({
logos: {},
diff --git a/frontend/src/store/plugins.jsx b/frontend/src/store/plugins.jsx
index e8d0b065..9fb61a47 100644
--- a/frontend/src/store/plugins.jsx
+++ b/frontend/src/store/plugins.jsx
@@ -38,4 +38,4 @@ export const usePluginStore = create((set, get) => ({
set({ plugins: [] });
get().fetchPlugins();
},
-}));
\ No newline at end of file
+}));
diff --git a/frontend/src/store/streamsTable.jsx b/frontend/src/store/streamsTable.jsx
index 2057a6e2..616be9c2 100644
--- a/frontend/src/store/streamsTable.jsx
+++ b/frontend/src/store/streamsTable.jsx
@@ -7,8 +7,7 @@ const useStreamsTableStore = create((set) => ({
sorting: [{ id: 'name', desc: false }],
pagination: {
pageIndex: 0,
- pageSize:
- JSON.parse(localStorage.getItem('streams-page-size')) || 50,
+ pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
},
selectedStreamIds: [],
allQueryIds: [],
diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx
index 0d8fe446..966c4a3e 100644
--- a/frontend/src/store/useVODStore.jsx
+++ b/frontend/src/store/useVODStore.jsx
@@ -14,7 +14,7 @@ const getFetchContentParams = (state) => {
params.append('category', state.filters.category);
}
return params;
-}
+};
const getMovieDetails = (response, movieId) => {
return {
@@ -35,7 +35,7 @@ const getMovieDetails = (response, movieId) => {
imdb_id: response.imdb_id || '',
m3u_account: response.m3u_account || '',
};
-}
+};
const getMovieDetailsWithProvider = (response, movieId) => {
return {
@@ -65,7 +65,7 @@ const getMovieDetailsWithProvider = (response, movieId) => {
video: response.video || {},
audio: response.audio || {},
};
-}
+};
const getSeriesDetails = (response, seriesId) => {
return {
@@ -92,7 +92,7 @@ const getSeriesDetails = (response, seriesId) => {
m3u_account: response.m3u_account || '',
youtube_trailer: response.custom_properties?.youtube_trailer || '',
};
-}
+};
const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
return {
@@ -117,7 +117,7 @@ const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
tmdb_id: episode.tmdb_id || '',
imdb_id: episode.imdb_id || '',
};
-}
+};
const useVODStore = create((set, get) => ({
content: {}, // Store for individual content details (when fetching movie/series details)
diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js
index 62d8190e..0299b240 100644
--- a/frontend/src/utils/__tests__/dateTimeUtils.test.js
+++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js
@@ -256,7 +256,7 @@ describe('dateTimeUtils', () => {
const setTimeZone = vi.fn();
useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
useSettingsStore.mockReturnValue({
- 'system_settings': { value: { time_zone: 'America/Los_Angeles' } }
+ system_settings: { value: { time_zone: 'America/Los_Angeles' } },
});
renderHook(() => dateTimeUtils.useUserTimeZone());
@@ -321,18 +321,25 @@ describe('dateTimeUtils', () => {
});
it('should start with Sunday', () => {
- expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ value: 6, label: 'Sun' });
+ expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({
+ value: 6,
+ label: 'Sun',
+ });
});
it('should include all weekdays', () => {
- const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(opt => opt.label);
+ const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(
+ (opt) => opt.label
+ );
expect(labels).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']);
});
});
describe('useDateTimeFormat', () => {
it('should return 12h format and mdy date format by default', () => {
- useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+ useLocalStorage
+ .mockReturnValueOnce(['12h', vi.fn()])
+ .mockReturnValueOnce(['mdy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@@ -341,7 +348,9 @@ describe('dateTimeUtils', () => {
});
it('should return 24h format when set', () => {
- useLocalStorage.mockReturnValueOnce(['24h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+ useLocalStorage
+ .mockReturnValueOnce(['24h', vi.fn()])
+ .mockReturnValueOnce(['mdy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@@ -349,7 +358,9 @@ describe('dateTimeUtils', () => {
});
it('should return dmy date format when set', () => {
- useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['dmy', vi.fn()]);
+ useLocalStorage
+ .mockReturnValueOnce(['12h', vi.fn()])
+ .mockReturnValueOnce(['dmy', vi.fn()]);
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
@@ -428,26 +439,28 @@ describe('dateTimeUtils', () => {
it('should sort by offset then name', () => {
const result = dateTimeUtils.buildTimeZoneOptions();
for (let i = 1; i < result.length; i++) {
- expect(result[i].numericOffset).toBeGreaterThanOrEqual(result[i - 1].numericOffset);
+ expect(result[i].numericOffset).toBeGreaterThanOrEqual(
+ result[i - 1].numericOffset
+ );
}
});
it('should include DST information when applicable', () => {
const result = dateTimeUtils.buildTimeZoneOptions();
- const dstZone = result.find(opt => opt.label.includes('DST range'));
+ const dstZone = result.find((opt) => opt.label.includes('DST range'));
expect(dstZone).toBeDefined();
});
it('should add preferred zone if not in list', () => {
const preferredZone = 'Custom/Zone';
const result = dateTimeUtils.buildTimeZoneOptions(preferredZone);
- const found = result.find(opt => opt.value === preferredZone);
+ const found = result.find((opt) => opt.value === preferredZone);
expect(found).toBeDefined();
});
it('should not duplicate existing zones', () => {
const result = dateTimeUtils.buildTimeZoneOptions('UTC');
- const utcOptions = result.filter(opt => opt.value === 'UTC');
+ const utcOptions = result.filter((opt) => opt.value === 'UTC');
expect(utcOptions).toHaveLength(1);
});
});
diff --git a/frontend/src/utils/__tests__/networkUtils.test.js b/frontend/src/utils/__tests__/networkUtils.test.js
index bb820589..01522403 100644
--- a/frontend/src/utils/__tests__/networkUtils.test.js
+++ b/frontend/src/utils/__tests__/networkUtils.test.js
@@ -8,7 +8,9 @@ describe('networkUtils', () => {
expect(networkUtils.IPV4_CIDR_REGEX.test('10.0.0.0/8')).toBe(true);
expect(networkUtils.IPV4_CIDR_REGEX.test('172.16.0.0/12')).toBe(true);
expect(networkUtils.IPV4_CIDR_REGEX.test('0.0.0.0/0')).toBe(true);
- expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(
+ true
+ );
});
it('should not match invalid IPv4 CIDR notation', () => {
@@ -29,7 +31,11 @@ describe('networkUtils', () => {
expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/32')).toBe(true);
expect(networkUtils.IPV6_CIDR_REGEX.test('fe80::/10')).toBe(true);
expect(networkUtils.IPV6_CIDR_REGEX.test('::/0')).toBe(true);
- expect(networkUtils.IPV6_CIDR_REGEX.test('2001:0db8:85a3:0000:0000:8a2e:0370:7334/64')).toBe(true);
+ expect(
+ networkUtils.IPV6_CIDR_REGEX.test(
+ '2001:0db8:85a3:0000:0000:8a2e:0370:7334/64'
+ )
+ ).toBe(true);
});
it('should match compressed IPv6 CIDR notation', () => {
@@ -38,7 +44,9 @@ describe('networkUtils', () => {
});
it('should match IPv6 with embedded IPv4', () => {
- expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(
+ true
+ );
});
it('should not match invalid IPv6 CIDR notation', () => {
diff --git a/frontend/src/utils/__tests__/notificationUtils.test.js b/frontend/src/utils/__tests__/notificationUtils.test.js
index bfea55d8..04442b73 100644
--- a/frontend/src/utils/__tests__/notificationUtils.test.js
+++ b/frontend/src/utils/__tests__/notificationUtils.test.js
@@ -74,7 +74,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, notificationObject);
- expect(notifications.update).toHaveBeenCalledWith(notificationId, notificationObject);
+ expect(notifications.update).toHaveBeenCalledWith(
+ notificationId,
+ notificationObject
+ );
expect(notifications.update).toHaveBeenCalledTimes(1);
});
@@ -82,7 +85,9 @@ describe('notificationUtils', () => {
const mockReturnValue = { success: true };
notifications.update.mockReturnValue(mockReturnValue);
- const result = notificationUtils.updateNotification('id', { message: 'test' });
+ const result = notificationUtils.updateNotification('id', {
+ message: 'test',
+ });
expect(result).toBe(mockReturnValue);
});
@@ -98,7 +103,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
- expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ expect(notifications.update).toHaveBeenCalledWith(
+ notificationId,
+ updateObject
+ );
});
it('should handle loading to error transition', () => {
@@ -112,7 +120,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
- expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ expect(notifications.update).toHaveBeenCalledWith(
+ notificationId,
+ updateObject
+ );
});
it('should handle partial updates', () => {
@@ -123,7 +134,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(notificationId, updateObject);
- expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ expect(notifications.update).toHaveBeenCalledWith(
+ notificationId,
+ updateObject
+ );
});
it('should handle empty notification id', () => {
@@ -139,7 +153,10 @@ describe('notificationUtils', () => {
notificationUtils.updateNotification(null, notificationObject);
- expect(notifications.update).toHaveBeenCalledWith(null, notificationObject);
+ expect(notifications.update).toHaveBeenCalledWith(
+ null,
+ notificationObject
+ );
});
});
});
diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js
index 65b3da3a..6232064b 100644
--- a/frontend/src/utils/cards/RecordingCardUtils.js
+++ b/frontend/src/utils/cards/RecordingCardUtils.js
@@ -89,4 +89,4 @@ export const getSeriesInfo = (customProps) => {
const cp = customProps || {};
const pr = cp.program || {};
return { tvg_id: pr.tvg_id, title: pr.title };
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js
index bf0a2219..5bec7d9a 100644
--- a/frontend/src/utils/cards/VodConnectionCardUtils.js
+++ b/frontend/src/utils/cards/VodConnectionCardUtils.js
@@ -24,7 +24,7 @@ export const formatTime = (seconds) => {
export const getMovieDisplayTitle = (vodContent) => {
return vodContent.content_name;
-}
+};
export const getEpisodeDisplayTitle = (metadata) => {
const season = metadata.season_number
@@ -34,18 +34,18 @@ export const getEpisodeDisplayTitle = (metadata) => {
? `E${metadata.episode_number.toString().padStart(2, '0')}`
: 'E??';
return `${metadata.series_name} - ${season}${episode}`;
-}
+};
export const getMovieSubtitle = (metadata) => {
const parts = [];
if (metadata.genre) parts.push(metadata.genre);
// We'll handle rating separately as a badge now
return parts;
-}
+};
export const getEpisodeSubtitle = (metadata) => {
return [metadata.episode_name || 'Episode'];
-}
+};
export const calculateProgress = (connection, duration_secs) => {
if (!connection || !duration_secs) {
@@ -92,7 +92,7 @@ export const calculateProgress = (connection, duration_secs) => {
currentTime: Math.max(0, currentTime), // Don't go negative
totalTime: totalSeconds,
};
-}
+};
export const calculateConnectionDuration = (connection) => {
// If duration is provided by API, use it
@@ -115,9 +115,12 @@ export const calculateConnectionDuration = (connection) => {
}
return 'Unknown duration';
-}
+};
-export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => {
+export const calculateConnectionStartTime = (
+ connection,
+ fullDateTimeFormat
+) => {
if (connection.connected_at) {
return format(connection.connected_at * 1000, fullDateTimeFormat);
}
@@ -136,4 +139,4 @@ export const calculateConnectionStartTime = (connection, fullDateTimeFormat) =>
}
return 'Unknown';
-}
\ No newline at end of file
+};
diff --git a/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
index a6074a4a..e17ff7eb 100644
--- a/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
+++ b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
@@ -1,7 +1,5 @@
import { describe, it, expect } from 'vitest';
-import {
- getConfirmationDetails,
-} from '../PluginCardUtils';
+import { getConfirmationDetails } from '../PluginCardUtils';
describe('PluginCardUtils', () => {
describe('getConfirmationDetails', () => {
@@ -13,7 +11,8 @@ describe('PluginCardUtils', () => {
expect(result).toEqual({
requireConfirm: true,
confirmTitle: 'Run Test Action?',
- confirmMessage: 'You\'re about to run "Test Action" from "Test Plugin".',
+ confirmMessage:
+ 'You\'re about to run "Test Action" from "Test Plugin".',
});
});
diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
index 3410c596..882b0fb1 100644
--- a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
+++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
@@ -156,7 +156,9 @@ describe('RecordingCardUtils', () => {
const channel = { uuid: 'channel-123' };
const result = getShowVideoUrl(channel, 'dev');
- expect(result).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/);
+ expect(result).toMatch(
+ /^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/
+ );
});
});
@@ -208,7 +210,9 @@ describe('RecordingCardUtils', () => {
it('handles bulk remove error gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation();
- API.bulkRemoveSeriesRecordings.mockRejectedValue(new Error('Bulk remove failed'));
+ API.bulkRemoveSeriesRecordings.mockRejectedValue(
+ new Error('Bulk remove failed')
+ );
API.deleteSeriesRule.mockResolvedValue();
const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
index a1596280..5675425f 100644
--- a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
+++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
@@ -14,31 +14,43 @@ describe('StreamConnectionCardUtils', () => {
describe('getBufferingSpeedThreshold', () => {
it('should return parsed buffering_speed from proxy settings', () => {
const proxySetting = {
- value: { buffering_speed: 2.5 }
+ value: { buffering_speed: 2.5 },
};
- expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5);
+ expect(
+ StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)
+ ).toBe(2.5);
});
it('should return 1.0 for invalid JSON', () => {
const proxySetting = { value: { buffering_speed: 'invalid' } };
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
- expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ const consoleSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ expect(
+ StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)
+ ).toBe(1.0);
consoleSpy.mockRestore();
});
it('should return 1.0 when buffering_speed is not a number', () => {
const proxySetting = {
- value: JSON.stringify({ buffering_speed: 'not a number' })
+ value: JSON.stringify({ buffering_speed: 'not a number' }),
};
- expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ expect(
+ StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)
+ ).toBe(1.0);
});
it('should return 1.0 when proxySetting is null', () => {
- expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(1.0);
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(
+ 1.0
+ );
});
it('should return 1.0 when value is missing', () => {
- expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(1.0);
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(
+ 1.0
+ );
});
});
@@ -60,17 +72,14 @@ describe('StreamConnectionCardUtils', () => {
it('should create map from m3u accounts array', () => {
const m3uAccounts = [
{ id: 1, name: 'Account 1' },
- { id: 2, name: 'Account 2' }
+ { id: 2, name: 'Account 2' },
];
const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
expect(result).toEqual({ 1: 'Account 1', 2: 'Account 2' });
});
it('should handle accounts without id', () => {
- const m3uAccounts = [
- { name: 'Account 1' },
- { id: 2, name: 'Account 2' }
- ];
+ const m3uAccounts = [{ name: 'Account 1' }, { id: 2, name: 'Account 2' }];
const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
expect(result).toEqual({ 2: 'Account 2' });
});
@@ -100,7 +109,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream when channelUrl includes stream url', () => {
const streamData = [
{ id: 1, url: 'http://example.com/stream1' },
- { id: 2, url: 'http://example.com/stream2' }
+ { id: 2, url: 'http://example.com/stream2' },
];
const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
streamData,
@@ -111,7 +120,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream when stream url includes channelUrl', () => {
const streamData = [
- { id: 1, url: 'http://example.com/stream1/playlist.m3u8' }
+ { id: 1, url: 'http://example.com/stream1/playlist.m3u8' },
];
const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
streamData,
@@ -134,7 +143,7 @@ describe('StreamConnectionCardUtils', () => {
it('should find stream by id as string', () => {
const streams = [
{ id: 1, name: 'Stream 1' },
- { id: 2, name: 'Stream 2' }
+ { id: 2, name: 'Stream 2' },
];
const result = StreamConnectionCardUtils.getSelectedStream(streams, '2');
expect(result).toEqual(streams[1]);
@@ -167,11 +176,20 @@ describe('StreamConnectionCardUtils', () => {
dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
- const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY, HH:mm:ss');
+ const accessor = StreamConnectionCardUtils.connectedAccessor(
+ 'MM/DD/YYYY, HH:mm:ss'
+ );
const result = accessor({ connected_since: 7200 });
- expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second');
- expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY, HH:mm:ss');
+ expect(dateTimeUtils.subtract).toHaveBeenCalledWith(
+ mockNow,
+ 7200,
+ 'second'
+ );
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(
+ mockConnectedTime,
+ 'MM/DD/YYYY, HH:mm:ss'
+ );
expect(result).toBe('01/01/2024 10:00:00');
});
@@ -181,7 +199,8 @@ describe('StreamConnectionCardUtils', () => {
dateTimeUtils.initializeTime.mockReturnValue(mockTime);
dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
- const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const accessor =
+ StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const result = accessor({ connected_at: 1704103200 });
expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000);
@@ -189,7 +208,8 @@ describe('StreamConnectionCardUtils', () => {
});
it('should return Unknown when no time data available', () => {
- const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const accessor =
+ StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
const result = accessor({});
expect(result).toBe('Unknown');
});
@@ -202,7 +222,10 @@ describe('StreamConnectionCardUtils', () => {
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connected_since: 9000 });
- expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(9000, 'seconds');
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
+ 9000,
+ 'seconds'
+ );
expect(result).toBe('2h 30m');
});
@@ -212,7 +235,10 @@ describe('StreamConnectionCardUtils', () => {
const accessor = StreamConnectionCardUtils.durationAccessor();
const result = accessor({ connection_duration: 4500 });
- expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(4500, 'seconds');
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
+ 4500,
+ 'seconds'
+ );
expect(result).toBe('1h 15m');
});
@@ -226,15 +252,23 @@ describe('StreamConnectionCardUtils', () => {
describe('getLogoUrl', () => {
it('should return cache_url from logos map when logoId exists', () => {
const logos = {
- 'logo-123': { cache_url: '/api/logos/logo-123/cache/' }
+ 'logo-123': { cache_url: '/api/logos/logo-123/cache/' },
};
- const result = StreamConnectionCardUtils.getLogoUrl('logo-123', logos, null);
+ const result = StreamConnectionCardUtils.getLogoUrl(
+ 'logo-123',
+ logos,
+ null
+ );
expect(result).toBe('/api/logos/logo-123/cache/');
});
it('should fallback to previewedStream logo_url when logoId not in map', () => {
const previewedStream = { logo_url: 'http://example.com/logo.png' };
- const result = StreamConnectionCardUtils.getLogoUrl('logo-456', {}, previewedStream);
+ const result = StreamConnectionCardUtils.getLogoUrl(
+ 'logo-456',
+ {},
+ previewedStream
+ );
expect(result).toBe('http://example.com/logo.png');
});
@@ -260,15 +294,18 @@ describe('StreamConnectionCardUtils', () => {
it('should format stream options with account names from map', () => {
const streams = [
{ id: 1, name: 'Stream 1', m3u_account: 100 },
- { id: 2, name: 'Stream 2', m3u_account: 200 }
+ { id: 2, name: 'Stream 2', m3u_account: 200 },
];
const accountsMap = { 100: 'Premium Account', 200: 'Basic Account' };
- const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+ const result = StreamConnectionCardUtils.getStreamOptions(
+ streams,
+ accountsMap
+ );
expect(result).toEqual([
{ value: '1', label: 'Stream 1 [Premium Account]' },
- { value: '2', label: 'Stream 2 [Basic Account]' }
+ { value: '2', label: 'Stream 2 [Basic Account]' },
]);
});
@@ -284,7 +321,10 @@ describe('StreamConnectionCardUtils', () => {
const streams = [{ id: 5, m3u_account: 100 }];
const accountsMap = { 100: 'Account' };
- const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+ const result = StreamConnectionCardUtils.getStreamOptions(
+ streams,
+ accountsMap
+ );
expect(result[0].label).toBe('Stream #5 [Account]');
});
diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
index 19b71a9d..489b781a 100644
--- a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
+++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
@@ -96,7 +96,7 @@ describe('VodConnectionCardUtils', () => {
const metadata = {
series_name: 'Breaking Bad',
season_number: 1,
- episode_number: 5
+ episode_number: 5,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Breaking Bad - S01E05');
@@ -106,7 +106,7 @@ describe('VodConnectionCardUtils', () => {
const metadata = {
series_name: 'The Office',
season_number: 3,
- episode_number: 9
+ episode_number: 9,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('The Office - S03E09');
@@ -115,7 +115,7 @@ describe('VodConnectionCardUtils', () => {
it('should use S?? when season_number is missing', () => {
const metadata = {
series_name: 'Lost',
- episode_number: 5
+ episode_number: 5,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Lost - S??E05');
@@ -124,7 +124,7 @@ describe('VodConnectionCardUtils', () => {
it('should use E?? when episode_number is missing', () => {
const metadata = {
series_name: 'Friends',
- season_number: 2
+ season_number: 2,
};
const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
expect(result).toBe('Friends - S02E??');
@@ -167,7 +167,7 @@ describe('VodConnectionCardUtils', () => {
it('should calculate progress from last_seek_percentage', () => {
const connection = {
last_seek_percentage: 50,
- last_seek_timestamp: 990 // 10 seconds ago
+ last_seek_timestamp: 990, // 10 seconds ago
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@@ -179,7 +179,7 @@ describe('VodConnectionCardUtils', () => {
it('should cap currentTime at duration when seeking', () => {
const connection = {
last_seek_percentage: 95,
- last_seek_timestamp: 900 // 100 seconds ago
+ last_seek_timestamp: 900, // 100 seconds ago
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@@ -189,7 +189,7 @@ describe('VodConnectionCardUtils', () => {
it('should fallback to position_seconds when seek data unavailable', () => {
const connection = {
- position_seconds: 75
+ position_seconds: 75,
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@@ -218,7 +218,7 @@ describe('VodConnectionCardUtils', () => {
it('should ensure currentTime is not negative', () => {
const connection = {
last_seek_percentage: 10,
- last_seek_timestamp: 2000 // In the future somehow
+ last_seek_timestamp: 2000, // In the future somehow
};
const result = VodConnectionCardUtils.calculateProgress(connection, 200);
@@ -231,9 +231,13 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 30m');
const connection = { duration: 5400 };
- const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+ const result =
+ VodConnectionCardUtils.calculateConnectionDuration(connection);
- expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(5400, 'seconds');
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
+ 5400,
+ 'seconds'
+ );
expect(result).toBe('1h 30m');
});
@@ -242,22 +246,28 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
const connection = { client_id: 'vod_900000_abc' };
- const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+ const result =
+ VodConnectionCardUtils.calculateConnectionDuration(connection);
- expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(100, 'seconds');
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(
+ 100,
+ 'seconds'
+ );
expect(result).toBe('45m');
});
it('should return Unknown duration when no data available', () => {
const connection = {};
- const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+ const result =
+ VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(result).toBe('Unknown duration');
});
it('should return Unknown duration when client_id is invalid format', () => {
const connection = { client_id: 'invalid_format' };
- const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+ const result =
+ VodConnectionCardUtils.calculateConnectionDuration(connection);
expect(result).toBe('Unknown duration');
});
@@ -267,7 +277,8 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
const connection = { client_id: 'vod_invalid_abc' };
- const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+ const result =
+ VodConnectionCardUtils.calculateConnectionDuration(connection);
// If parseInt fails, the code should still handle it
expect(result).toBe('45m'); // or 'Unknown duration' depending on implementation
@@ -279,9 +290,15 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00');
const connection = { connected_at: 1705329000 };
- const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(
+ connection,
+ 'MM/DD/YYYY, HH:mm:ss'
+ );
- expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY, HH:mm:ss');
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(
+ 1705329000000,
+ 'MM/DD/YYYY, HH:mm:ss'
+ );
expect(result).toBe('01/15/2024 14:30:00');
});
@@ -289,22 +306,34 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
const connection = { client_id: 'vod_1705323600000_abc' };
- const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(
+ connection,
+ 'MM/DD/YYYY, HH:mm:ss'
+ );
- expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY, HH:mm:ss');
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(
+ 1705323600000,
+ 'MM/DD/YYYY, HH:mm:ss'
+ );
expect(result).toBe('01/15/2024 13:00:00');
});
it('should return Unknown when no timestamp data available', () => {
const connection = {};
- const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss');
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(
+ connection,
+ 'MM/DD/YYYY, HH:mm:ss'
+ );
expect(result).toBe('Unknown');
});
it('should return Unknown when client_id is invalid format', () => {
const connection = { client_id: 'invalid_format' };
- const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(
+ connection,
+ 'MM/DD/YYYY'
+ );
expect(result).toBe('Unknown');
});
@@ -313,11 +342,13 @@ describe('VodConnectionCardUtils', () => {
dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
const connection = { client_id: 'vod_notanumber_abc' };
- const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(
+ connection,
+ 'MM/DD/YYYY'
+ );
// If parseInt succeeds on any number, format will be called
expect(result).toBe('01/15/2024 13:00:00'); // or 'Unknown' depending on implementation
});
-
});
});
diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js
index 2b9c0888..7993786d 100644
--- a/frontend/src/utils/dateTimeUtils.js
+++ b/frontend/src/utils/dateTimeUtils.js
@@ -40,7 +40,8 @@ export const format = (dateTime, formatStr) =>
export const getNow = () => dayjs();
-export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize();
+export const toFriendlyDuration = (dateTime, unit) =>
+ dayjs.duration(dateTime, unit).humanize();
export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
@@ -113,7 +114,8 @@ export const useDateTimeFormat = () => {
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
// Full format strings for detailed date-time displays
- const fullDateFormat = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
+ const fullDateFormat =
+ dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss';
const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`;
@@ -278,4 +280,4 @@ export const getDefaultTimeZone = () => {
} catch (error) {
return 'UTC';
}
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
index 805bc006..e3ea2496 100644
--- a/frontend/src/utils/forms/RecordingDetailsModalUtils.js
+++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
@@ -33,7 +33,7 @@ const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => {
const st = toUserTime(r.start_time);
return st.isAfter(userNow());
});
-}
+};
const dedupeByProgram = (filtered) => {
// Deduplicate by program.id if present, else by time+title
@@ -62,7 +62,7 @@ const dedupeByProgram = (filtered) => {
deduped.push(r);
}
return deduped;
-}
+};
export const getUpcomingEpisodes = (
isSeriesGroup,
diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js
index 1eb9194a..a1335c0c 100644
--- a/frontend/src/utils/forms/RecurringRuleModalUtils.js
+++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js
@@ -63,4 +63,4 @@ export const deleteRecurringRuleById = async (ruleId) => {
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
await API.updateRecurringRule(ruleId, { enabled: checked });
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
index af85dce4..ca689ff7 100644
--- a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
@@ -15,7 +15,7 @@ describe('RecordingDetailsModalUtils', () => {
audio_codec: 'AAC',
audio_channels: 2,
sample_rate: 48000,
- audio_bitrate: 128
+ audio_bitrate: 128,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
@@ -28,49 +28,45 @@ describe('RecordingDetailsModalUtils', () => {
['Audio Codec', 'AAC'],
['Audio Channels', 2],
['Sample Rate', '48000 Hz'],
- ['Audio Bitrate', '128 kb/s']
+ ['Audio Bitrate', '128 kb/s'],
]);
});
it('should use width x height when resolution is not present', () => {
const stats = {
width: 1280,
- height: 720
+ height: 720,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
- expect(result).toEqual([
- ['Resolution', '1280x720']
- ]);
+ expect(result).toEqual([['Resolution', '1280x720']]);
});
it('should prefer resolution over width/height', () => {
const stats = {
resolution: '1920x1080',
width: 1280,
- height: 720
+ height: 720,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
- expect(result).toEqual([
- ['Resolution', '1920x1080']
- ]);
+ expect(result).toEqual([['Resolution', '1920x1080']]);
});
it('should filter out null values', () => {
const stats = {
video_codec: 'H.264',
resolution: null,
- source_fps: 30
+ source_fps: 30,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264'],
- ['FPS', 30]
+ ['FPS', 30],
]);
});
@@ -78,74 +74,68 @@ describe('RecordingDetailsModalUtils', () => {
const stats = {
video_codec: 'H.264',
source_fps: undefined,
- audio_codec: 'AAC'
+ audio_codec: 'AAC',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Codec', 'H.264'],
- ['Audio Codec', 'AAC']
+ ['Audio Codec', 'AAC'],
]);
});
it('should filter out empty strings', () => {
const stats = {
video_codec: '',
- audio_codec: 'AAC'
+ audio_codec: 'AAC',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
- expect(result).toEqual([
- ['Audio Codec', 'AAC']
- ]);
+ expect(result).toEqual([['Audio Codec', 'AAC']]);
});
it('should handle missing width or height gracefully', () => {
const stats = {
width: 1920,
- video_codec: 'H.264'
+ video_codec: 'H.264',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
- expect(result).toEqual([
- ['Video Codec', 'H.264']
- ]);
+ expect(result).toEqual([['Video Codec', 'H.264']]);
});
it('should format bitrates correctly', () => {
const stats = {
video_bitrate: 2500,
- audio_bitrate: 192
+ audio_bitrate: 192,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
expect(result).toEqual([
['Video Bitrate', '2500 kb/s'],
- ['Audio Bitrate', '192 kb/s']
+ ['Audio Bitrate', '192 kb/s'],
]);
});
it('should format sample rate correctly', () => {
const stats = {
- sample_rate: 44100
+ sample_rate: 44100,
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
- expect(result).toEqual([
- ['Sample Rate', '44100 Hz']
- ]);
+ expect(result).toEqual([['Sample Rate', '44100 Hz']]);
});
it('should return empty array when no valid stats', () => {
const stats = {
video_codec: null,
resolution: undefined,
- source_fps: ''
+ source_fps: '',
};
const result = RecordingDetailsModalUtils.getStatRows(stats);
@@ -193,7 +183,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should return rating from program custom_properties', () => {
const customProps = {};
const program = {
- custom_properties: { rating: 'TV-14' }
+ custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@@ -204,7 +194,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should prefer customProps rating over program rating', () => {
const customProps = { rating: 'TV-MA' };
const program = {
- custom_properties: { rating: 'TV-14' }
+ custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@@ -215,7 +205,7 @@ describe('RecordingDetailsModalUtils', () => {
it('should prefer rating_value over program rating', () => {
const customProps = { rating_value: 'PG-13' };
const program = {
- custom_properties: { rating: 'TV-14' }
+ custom_properties: { rating: 'TV-14' },
};
const result = RecordingDetailsModalUtils.getRating(customProps, program);
@@ -294,16 +284,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show' }
- }
+ program: { tvg_id: 'show1', title: 'Test Show' },
+ },
},
{
start_time: '2024-01-02T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show2', title: 'Other Show' }
- }
- }
+ program: { tvg_id: 'show2', title: 'Other Show' },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -325,16 +315,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2023-12-31T12:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show' }
- }
+ program: { tvg_id: 'show1', title: 'Test Show' },
+ },
},
{
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show' }
- }
- }
+ program: { tvg_id: 'show1', title: 'Test Show' },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -358,8 +348,8 @@ describe('RecordingDetailsModalUtils', () => {
custom_properties: {
season: 1,
episode: 5,
- program: { tvg_id: 'show1', title: 'Test Show' }
- }
+ program: { tvg_id: 'show1', title: 'Test Show' },
+ },
},
{
start_time: '2024-01-02T18:00:00',
@@ -367,9 +357,9 @@ describe('RecordingDetailsModalUtils', () => {
custom_properties: {
season: 1,
episode: 5,
- program: { tvg_id: 'show1', title: 'Test Show' }
- }
- }
+ program: { tvg_id: 'show1', title: 'Test Show' },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -391,17 +381,17 @@ describe('RecordingDetailsModalUtils', () => {
channel: 'ch1',
custom_properties: {
onscreen_episode: 'S01E05',
- program: { tvg_id: 'show1', title: 'Test Show' }
- }
+ program: { tvg_id: 'show1', title: 'Test Show' },
+ },
},
{
start_time: '2024-01-02T18:00:00',
channel: 'ch2',
custom_properties: {
onscreen_episode: 's01e05',
- program: { tvg_id: 'show1', title: 'Test Show' }
- }
- }
+ program: { tvg_id: 'show1', title: 'Test Show' },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -425,9 +415,9 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
- sub_title: 'The Beginning'
- }
- }
+ sub_title: 'The Beginning',
+ },
+ },
},
{
start_time: '2024-01-02T18:00:00',
@@ -436,10 +426,10 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
- sub_title: 'The Beginning'
- }
- }
- }
+ sub_title: 'The Beginning',
+ },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -460,16 +450,16 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
- }
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 },
+ },
},
{
start_time: '2024-01-02T18:00:00',
channel: 'ch2',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
- }
- }
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -491,25 +481,25 @@ describe('RecordingDetailsModalUtils', () => {
end_time: '2024-01-03T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show', id: 3 }
- }
+ program: { tvg_id: 'show1', title: 'Test Show', id: 3 },
+ },
},
{
start_time: '2024-01-02T12:00:00',
end_time: '2024-01-02T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
- }
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 },
+ },
},
{
start_time: '2024-01-04T12:00:00',
end_time: '2024-01-04T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show', id: 4 }
- }
- }
+ program: { tvg_id: 'show1', title: 'Test Show', id: 4 },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -533,9 +523,9 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
- }
- }
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 },
+ },
+ },
};
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -556,9 +546,9 @@ describe('RecordingDetailsModalUtils', () => {
start_time: '2024-01-02T12:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'test show' }
- }
- }
+ program: { tvg_id: 'show1', title: 'test show' },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -582,9 +572,9 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
- custom_properties: { season: 2, episode: 3 }
- }
- }
+ custom_properties: { season: 2, episode: 3 },
+ },
+ },
},
{
start_time: '2024-01-02T18:00:00',
@@ -593,10 +583,10 @@ describe('RecordingDetailsModalUtils', () => {
program: {
tvg_id: 'show1',
title: 'Test Show',
- custom_properties: { season: 2, episode: 3 }
- }
- }
- }
+ custom_properties: { season: 2, episode: 3 },
+ },
+ },
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
@@ -615,8 +605,8 @@ describe('RecordingDetailsModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
- channel: 'ch1'
- }
+ channel: 'ch1',
+ },
];
const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
index e2cb95fd..04561926 100644
--- a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
@@ -6,8 +6,8 @@ import dayjs from 'dayjs';
vi.mock('../../../api.js', () => ({
default: {
updateRecurringRule: vi.fn(),
- deleteRecurringRule: vi.fn()
- }
+ deleteRecurringRule: vi.fn(),
+ },
}));
describe('RecurringRuleModalUtils', () => {
@@ -20,7 +20,7 @@ describe('RecurringRuleModalUtils', () => {
const channels = {
ch1: { id: 1, channel_number: '10', name: 'ABC' },
ch2: { id: 2, channel_number: '5', name: 'NBC' },
- ch3: { id: 3, channel_number: '15', name: 'CBS' }
+ ch3: { id: 3, channel_number: '15', name: 'CBS' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@@ -28,7 +28,7 @@ describe('RecurringRuleModalUtils', () => {
expect(result).toEqual([
{ value: '2', label: 'NBC' },
{ value: '1', label: 'ABC' },
- { value: '3', label: 'CBS' }
+ { value: '3', label: 'CBS' },
]);
});
@@ -36,7 +36,7 @@ describe('RecurringRuleModalUtils', () => {
const channels = {
ch1: { id: 1, channel_number: '10', name: 'ZBC' },
ch2: { id: 2, channel_number: '10', name: 'ABC' },
- ch3: { id: 3, channel_number: '10', name: 'MBC' }
+ ch3: { id: 3, channel_number: '10', name: 'MBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@@ -44,35 +44,35 @@ describe('RecurringRuleModalUtils', () => {
expect(result).toEqual([
{ value: '2', label: 'ABC' },
{ value: '3', label: 'MBC' },
- { value: '1', label: 'ZBC' }
+ { value: '1', label: 'ZBC' },
]);
});
it('should handle missing channel numbers', () => {
const channels = {
ch1: { id: 1, name: 'ABC' },
- ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '1', label: 'ABC' },
- { value: '2', label: 'NBC' }
+ { value: '2', label: 'NBC' },
]);
});
it('should use fallback label when name is missing', () => {
const channels = {
ch1: { id: 1, channel_number: '10' },
- ch2: { id: 2, channel_number: '5', name: '' }
+ ch2: { id: 2, channel_number: '5', name: '' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '2', label: 'Channel 2' },
- { value: '1', label: 'Channel 1' }
+ { value: '1', label: 'Channel 1' },
]);
});
@@ -96,7 +96,7 @@ describe('RecurringRuleModalUtils', () => {
it('should convert channel id to string value', () => {
const channels = {
- ch1: { id: 123, channel_number: '10', name: 'ABC' }
+ ch1: { id: 123, channel_number: '10', name: 'ABC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@@ -108,7 +108,7 @@ describe('RecurringRuleModalUtils', () => {
it('should handle non-numeric channel numbers', () => {
const channels = {
ch1: { id: 1, channel_number: 'HD1', name: 'ABC' },
- ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
@@ -131,16 +131,16 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
- custom_properties: { rule: { id: 1 } }
+ custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-03T12:00:00',
- custom_properties: { rule: { id: 1 } }
+ custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-04T12:00:00',
- custom_properties: { rule: { id: 2 } }
- }
+ custom_properties: { rule: { id: 2 } },
+ },
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@@ -159,12 +159,12 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2023-12-31T12:00:00',
- custom_properties: { rule: { id: 1 } }
+ custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-02T12:00:00',
- custom_properties: { rule: { id: 1 } }
- }
+ custom_properties: { rule: { id: 1 } },
+ },
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@@ -182,16 +182,16 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-04T12:00:00',
- custom_properties: { rule: { id: 1 } }
+ custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-02T12:00:00',
- custom_properties: { rule: { id: 1 } }
+ custom_properties: { rule: { id: 1 } },
},
{
start_time: '2024-01-03T12:00:00',
- custom_properties: { rule: { id: 1 } }
- }
+ custom_properties: { rule: { id: 1 } },
+ },
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@@ -211,12 +211,12 @@ describe('RecurringRuleModalUtils', () => {
const recordings = {
rec1: {
start_time: '2024-01-02T12:00:00',
- custom_properties: { rule: { id: 1 } }
+ custom_properties: { rule: { id: 1 } },
},
rec2: {
start_time: '2024-01-03T12:00:00',
- custom_properties: { rule: { id: 1 } }
- }
+ custom_properties: { rule: { id: 1 } },
+ },
};
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@@ -254,8 +254,8 @@ describe('RecurringRuleModalUtils', () => {
it('should handle recordings without custom_properties', () => {
const recordings = [
{
- start_time: '2024-01-02T12:00:00'
- }
+ start_time: '2024-01-02T12:00:00',
+ },
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@@ -272,8 +272,8 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@@ -290,8 +290,8 @@ describe('RecurringRuleModalUtils', () => {
const recordings = [
{
start_time: '2024-01-02T12:00:00',
- custom_properties: { rule: null }
- }
+ custom_properties: { rule: null },
+ },
];
const result = RecurringRuleModalUtils.getUpcomingOccurrences(
@@ -315,7 +315,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-01-01',
end_date: '2024-12-31',
rule_name: 'My Rule',
- enabled: true
+ enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -328,7 +328,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-01-01',
end_date: '2024-12-31',
name: 'My Rule',
- enabled: true
+ enabled: true,
});
});
@@ -338,7 +338,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: ['0', '6'],
start_time: '10:00',
end_time: '11:00',
- enabled: false
+ enabled: false,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -351,7 +351,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
- enabled: false
+ enabled: false,
});
});
@@ -360,7 +360,7 @@ describe('RecurringRuleModalUtils', () => {
channel_id: '5',
start_time: '10:00',
end_time: '11:00',
- enabled: true
+ enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -373,7 +373,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
- enabled: true
+ enabled: true,
});
});
@@ -385,7 +385,7 @@ describe('RecurringRuleModalUtils', () => {
end_time: '11:00',
start_date: dayjs('2024-06-15'),
end_date: dayjs('2024-12-25'),
- enabled: true
+ enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -398,7 +398,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: '2024-06-15',
end_date: '2024-12-25',
name: '',
- enabled: true
+ enabled: true,
});
});
@@ -410,7 +410,7 @@ describe('RecurringRuleModalUtils', () => {
end_time: '11:00',
start_date: null,
end_date: null,
- enabled: true
+ enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -423,7 +423,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
- enabled: true
+ enabled: true,
});
});
@@ -434,7 +434,7 @@ describe('RecurringRuleModalUtils', () => {
start_time: '10:00',
end_time: '11:00',
rule_name: ' Trimmed Name ',
- enabled: true
+ enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -447,7 +447,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: 'Trimmed Name',
- enabled: true
+ enabled: true,
});
});
@@ -457,7 +457,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: [],
start_time: '10:00',
end_time: '11:00',
- enabled: true
+ enabled: true,
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -470,7 +470,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
- enabled: true
+ enabled: true,
});
});
@@ -480,7 +480,7 @@ describe('RecurringRuleModalUtils', () => {
days_of_week: [],
start_time: '10:00',
end_time: '11:00',
- enabled: 'true'
+ enabled: 'true',
};
await RecurringRuleModalUtils.updateRecurringRule(1, values);
@@ -493,7 +493,7 @@ describe('RecurringRuleModalUtils', () => {
start_date: null,
end_date: null,
name: '',
- enabled: true
+ enabled: true,
});
});
});
@@ -518,7 +518,7 @@ describe('RecurringRuleModalUtils', () => {
await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, true);
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
- enabled: true
+ enabled: true,
});
});
@@ -526,7 +526,7 @@ describe('RecurringRuleModalUtils', () => {
await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, false);
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
- enabled: false
+ enabled: false,
});
});
});
diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
index bbb1085a..fcc5a4c0 100644
--- a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
+++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js
@@ -10,13 +10,13 @@ export const uploadComskipIni = async (file) => {
export const getDvrSettingsFormInitialValues = () => {
return {
- 'tv_template': '',
- 'movie_template': '',
- 'tv_fallback_template': '',
- 'movie_fallback_template': '',
- 'comskip_enabled': false,
- 'comskip_custom_path': '',
- 'pre_offset_minutes': 0,
- 'post_offset_minutes': 0,
+ tv_template: '',
+ movie_template: '',
+ tv_fallback_template: '',
+ movie_fallback_template: '',
+ comskip_enabled: false,
+ comskip_custom_path: '',
+ pre_offset_minutes: 0,
+ post_offset_minutes: 0,
};
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
index eaa535fb..1a871571 100644
--- a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
+++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
@@ -2,7 +2,8 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
-const M3U_EPG_DEFAULTS = '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
+const M3U_EPG_DEFAULTS =
+ '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
export const getNetworkAccessFormInitialValues = () => {
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
@@ -39,4 +40,4 @@ export const getNetworkAccessDefaults = () => {
XC_API: '0.0.0.0/0,::/0',
UI: '0.0.0.0/0,::/0',
};
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
index 864dd9b1..9ca185e8 100644
--- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
+++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
@@ -15,4 +15,4 @@ export const getProxySettingDefaults = () => {
channel_shutdown_delay: 0,
channel_init_grace_period: 5,
};
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
index db91480c..b1f356d9 100644
--- a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
+++ b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js
@@ -16,4 +16,4 @@ export const getStreamSettingsFormValidation = () => {
default_stream_profile: isNotEmpty('Select a stream profile'),
preferred_region: isNotEmpty('Select a region'),
};
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
index 9d67039e..0830c70c 100644
--- a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
+++ b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js
@@ -14,4 +14,4 @@ export const saveTimeZoneSetting = async (tzValue, settings) => {
value: newValue,
});
}
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
index 49a43eb1..35bb6501 100644
--- a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
@@ -13,7 +13,7 @@ describe('DvrSettingsFormUtils', () => {
it('should call API.getComskipConfig and return result', async () => {
const mockConfig = {
enabled: true,
- custom_path: '/path/to/comskip'
+ custom_path: '/path/to/comskip',
};
API.getComskipConfig.mockResolvedValue(mockConfig);
@@ -27,13 +27,17 @@ describe('DvrSettingsFormUtils', () => {
const error = new Error('API Error');
API.getComskipConfig.mockRejectedValue(error);
- await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow('API Error');
+ await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow(
+ 'API Error'
+ );
});
});
describe('uploadComskipIni', () => {
it('should call API.uploadComskipIni with file and return result', async () => {
- const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const mockFile = new File(['content'], 'comskip.ini', {
+ type: 'text/plain',
+ });
const mockResponse = { success: true };
API.uploadComskipIni.mockResolvedValue(mockResponse);
@@ -44,11 +48,15 @@ describe('DvrSettingsFormUtils', () => {
});
it('should handle API errors', async () => {
- const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const mockFile = new File(['content'], 'comskip.ini', {
+ type: 'text/plain',
+ });
const error = new Error('Upload failed');
API.uploadComskipIni.mockRejectedValue(error);
- await expect(DvrSettingsFormUtils.uploadComskipIni(mockFile)).rejects.toThrow('Upload failed');
+ await expect(
+ DvrSettingsFormUtils.uploadComskipIni(mockFile)
+ ).rejects.toThrow('Upload failed');
});
});
@@ -57,14 +65,14 @@ describe('DvrSettingsFormUtils', () => {
const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
expect(result).toEqual({
- 'tv_template': '',
- 'movie_template': '',
- 'tv_fallback_template': '',
- 'movie_fallback_template': '',
- 'comskip_enabled': false,
- 'comskip_custom_path': '',
- 'pre_offset_minutes': 0,
- 'post_offset_minutes': 0,
+ tv_template: '',
+ movie_template: '',
+ tv_fallback_template: '',
+ movie_fallback_template: '',
+ comskip_enabled: false,
+ comskip_custom_path: '',
+ pre_offset_minutes: 0,
+ post_offset_minutes: 0,
});
});
diff --git a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
index d924b430..11438d00 100644
--- a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
@@ -3,12 +3,12 @@ import * as NetworkAccessFormUtils from '../NetworkAccessFormUtils';
import * as constants from '../../../../constants.js';
vi.mock('../../../../constants.js', () => ({
- NETWORK_ACCESS_OPTIONS: {}
+ NETWORK_ACCESS_OPTIONS: {},
}));
vi.mock('../../../networkUtils.js', () => ({
IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
- IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/
+ IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/,
}));
describe('NetworkAccessFormUtils', () => {
@@ -21,7 +21,7 @@ describe('NetworkAccessFormUtils', () => {
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
'network-access-admin': 'Admin Access',
'network-access-api': 'API Access',
- 'network-access-streaming': 'Streaming Access'
+ 'network-access-streaming': 'Streaming Access',
};
const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
@@ -29,7 +29,7 @@ describe('NetworkAccessFormUtils', () => {
expect(result).toEqual({
'network-access-admin': '0.0.0.0/0,::/0',
'network-access-api': '0.0.0.0/0,::/0',
- 'network-access-streaming': '0.0.0.0/0,::/0'
+ 'network-access-streaming': '0.0.0.0/0,::/0',
});
});
@@ -43,11 +43,13 @@ describe('NetworkAccessFormUtils', () => {
it('should return a new object each time', () => {
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
- 'network-access-admin': 'Admin Access'
+ 'network-access-admin': 'Admin Access',
};
- const result1 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
- const result2 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+ const result1 =
+ NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+ const result2 =
+ NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
expect(result1).toEqual(result2);
expect(result1).not.toBe(result2);
@@ -58,20 +60,24 @@ describe('NetworkAccessFormUtils', () => {
beforeEach(() => {
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
'network-access-admin': 'Admin Access',
- 'network-access-api': 'API Access'
+ 'network-access-api': 'API Access',
};
});
it('should return validation functions for all network access options', () => {
const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
- expect(Object.keys(result)).toEqual(['network-access-admin', 'network-access-api']);
+ expect(Object.keys(result)).toEqual([
+ 'network-access-admin',
+ 'network-access-api',
+ ]);
expect(typeof result['network-access-admin']).toBe('function');
expect(typeof result['network-access-api']).toBe('function');
});
it('should validate valid IPv4 CIDR ranges', () => {
- const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validation =
+ NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24')).toBeNull();
@@ -80,7 +86,8 @@ describe('NetworkAccessFormUtils', () => {
});
it('should validate valid IPv6 CIDR ranges', () => {
- const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validation =
+ NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('2001:db8::/32')).toBeNull();
@@ -88,7 +95,8 @@ describe('NetworkAccessFormUtils', () => {
});
it('should validate multiple CIDR ranges separated by commas', () => {
- const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validation =
+ NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
@@ -97,7 +105,8 @@ describe('NetworkAccessFormUtils', () => {
});
it('should return error for invalid IPv4 CIDR ranges', () => {
- const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validation =
+ NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
@@ -106,16 +115,20 @@ describe('NetworkAccessFormUtils', () => {
});
it('should return error when any CIDR in comma-separated list is invalid', () => {
- const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validation =
+ NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range');
expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range');
- expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe('Invalid CIDR range');
+ expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe(
+ 'Invalid CIDR range'
+ );
});
it('should handle empty strings', () => {
- const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validation =
+ NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator('')).toBe('Invalid CIDR range');
diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
index d6fe3008..8feefccb 100644
--- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
@@ -3,7 +3,7 @@ import * as ProxySettingsFormUtils from '../ProxySettingsFormUtils';
import * as constants from '../../../../constants.js';
vi.mock('../../../../constants.js', () => ({
- PROXY_SETTINGS_OPTIONS: {}
+ PROXY_SETTINGS_OPTIONS: {},
}));
describe('ProxySettingsFormUtils', () => {
@@ -16,7 +16,7 @@ describe('ProxySettingsFormUtils', () => {
vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
'proxy-buffering-timeout': 'Buffering Timeout',
'proxy-buffering-speed': 'Buffering Speed',
- 'proxy-redis-chunk-ttl': 'Redis Chunk TTL'
+ 'proxy-redis-chunk-ttl': 'Redis Chunk TTL',
};
const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
@@ -24,7 +24,7 @@ describe('ProxySettingsFormUtils', () => {
expect(result).toEqual({
'proxy-buffering-timeout': '',
'proxy-buffering-speed': '',
- 'proxy-redis-chunk-ttl': ''
+ 'proxy-redis-chunk-ttl': '',
});
});
@@ -38,11 +38,13 @@ describe('ProxySettingsFormUtils', () => {
it('should return a new object each time', () => {
vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
- 'proxy-setting': 'Proxy Setting'
+ 'proxy-setting': 'Proxy Setting',
};
- const result1 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
- const result2 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+ const result1 =
+ ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+ const result2 =
+ ProxySettingsFormUtils.getProxySettingsFormInitialValues();
expect(result1).toEqual(result2);
expect(result1).not.toBe(result2);
diff --git a/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
index 9cf87c9a..2ef36bb8 100644
--- a/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
@@ -3,7 +3,7 @@ import * as StreamSettingsFormUtils from '../StreamSettingsFormUtils';
import { isNotEmpty } from '@mantine/form';
vi.mock('@mantine/form', () => ({
- isNotEmpty: vi.fn((message) => message)
+ isNotEmpty: vi.fn((message) => message),
}));
describe('StreamSettingsFormUtils', () => {
@@ -13,42 +13,49 @@ describe('StreamSettingsFormUtils', () => {
describe('getStreamSettingsFormInitialValues', () => {
it('should return initial values with correct defaults', () => {
- const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result =
+ StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result).toEqual({
- 'default_user_agent': '',
- 'default_stream_profile': '',
- 'preferred_region': '',
- 'auto_import_mapped_files': true,
- 'm3u_hash_key': []
+ default_user_agent: '',
+ default_stream_profile: '',
+ preferred_region: '',
+ auto_import_mapped_files: true,
+ m3u_hash_key: [],
});
});
it('should return boolean true for auto-import-mapped-files', () => {
- const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result =
+ StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result['auto_import_mapped_files']).toBe(true);
expect(typeof result['auto_import_mapped_files']).toBe('boolean');
});
it('should return empty array for m3u-hash-key', () => {
- const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result =
+ StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result['m3u_hash_key']).toEqual([]);
expect(Array.isArray(result['m3u_hash_key'])).toBe(true);
});
it('should return a new object each time', () => {
- const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
- const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result1 =
+ StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 =
+ StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result1).toEqual(result2);
expect(result1).not.toBe(result2);
});
it('should return a new array instance for m3u-hash-key each time', () => {
- const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
- const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result1 =
+ StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 =
+ StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
expect(result1['m3u_hash_key']).not.toBe(result2['m3u_hash_key']);
});
@@ -61,7 +68,7 @@ describe('StreamSettingsFormUtils', () => {
expect(Object.keys(result)).toEqual([
'default_user_agent',
'default_stream_profile',
- 'preferred_region'
+ 'preferred_region',
]);
});
diff --git a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
index 1bed3529..4f48bd16 100644
--- a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
@@ -4,30 +4,35 @@ import * as SystemSettingsFormUtils from '../SystemSettingsFormUtils';
describe('SystemSettingsFormUtils', () => {
describe('getSystemSettingsFormInitialValues', () => {
it('should return initial values with correct defaults', () => {
- const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result =
+ SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toEqual({
- 'max_system_events': 100
+ max_system_events: 100,
});
});
it('should return number value for max-system-events', () => {
- const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result =
+ SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result['max_system_events']).toBe(100);
expect(typeof result['max_system_events']).toBe('number');
});
it('should return a new object each time', () => {
- const result1 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
- const result2 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result1 =
+ SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result2 =
+ SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result1).toEqual(result2);
expect(result1).not.toBe(result2);
});
it('should have max-system-events property', () => {
- const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result =
+ SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
expect(result).toHaveProperty('max_system_events');
});
diff --git a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
index add9dc3a..83edb9f5 100644
--- a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
@@ -4,7 +4,7 @@ import * as SettingsUtils from '../../../pages/SettingsUtils.js';
vi.mock('../../../pages/SettingsUtils.js', () => ({
createSetting: vi.fn(),
- updateSetting: vi.fn()
+ updateSetting: vi.fn(),
}));
describe('UiSettingsFormUtils', () => {
@@ -16,12 +16,12 @@ describe('UiSettingsFormUtils', () => {
it('should update existing setting when id is present', async () => {
const tzValue = 'America/New_York';
const settings = {
- 'system_settings': {
+ system_settings: {
id: 123,
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: 'UTC' }
- }
+ value: { time_zone: 'UTC' },
+ },
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@@ -31,7 +31,7 @@ describe('UiSettingsFormUtils', () => {
id: 123,
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: 'America/New_York' }
+ value: { time_zone: 'America/New_York' },
});
expect(SettingsUtils.createSetting).not.toHaveBeenCalled();
});
@@ -39,11 +39,11 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when existing setting has no id', async () => {
const tzValue = 'Europe/London';
const settings = {
- 'system_settings': {
+ system_settings: {
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: 'UTC' }
- }
+ value: { time_zone: 'UTC' },
+ },
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@@ -52,7 +52,7 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: 'Europe/London' }
+ value: { time_zone: 'Europe/London' },
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
@@ -67,7 +67,7 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: 'Asia/Tokyo' }
+ value: { time_zone: 'Asia/Tokyo' },
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
@@ -75,7 +75,7 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when system_settings is null', async () => {
const tzValue = 'Pacific/Auckland';
const settings = {
- 'system_settings': null
+ system_settings: null,
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@@ -84,7 +84,7 @@ describe('UiSettingsFormUtils', () => {
expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: 'Pacific/Auckland' }
+ value: { time_zone: 'Pacific/Auckland' },
});
expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
});
@@ -92,11 +92,11 @@ describe('UiSettingsFormUtils', () => {
it('should create new setting when id is undefined', async () => {
const tzValue = 'America/Los_Angeles';
const settings = {
- 'system_settings': {
+ system_settings: {
id: undefined,
key: 'system_settings',
- value: { time_zone: 'UTC' }
- }
+ value: { time_zone: 'UTC' },
+ },
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@@ -108,13 +108,13 @@ describe('UiSettingsFormUtils', () => {
it('should preserve existing properties when updating', async () => {
const tzValue = 'UTC';
const settings = {
- 'system_settings': {
+ system_settings: {
id: 456,
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'America/New_York', some_other_setting: 'value' },
- extraProp: 'should be preserved'
- }
+ extraProp: 'should be preserved',
+ },
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@@ -124,19 +124,19 @@ describe('UiSettingsFormUtils', () => {
key: 'system_settings',
name: 'System Settings',
value: { time_zone: 'UTC', some_other_setting: 'value' },
- extraProp: 'should be preserved'
+ extraProp: 'should be preserved',
});
});
it('should handle empty string timezone value', async () => {
const tzValue = '';
const settings = {
- 'system_settings': {
+ system_settings: {
id: 789,
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: 'America/New_York' }
- }
+ value: { time_zone: 'America/New_York' },
+ },
};
await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
@@ -145,7 +145,7 @@ describe('UiSettingsFormUtils', () => {
id: 789,
key: 'system_settings',
name: 'System Settings',
- value: { time_zone: '' }
+ value: { time_zone: '' },
});
});
});
diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js
index 8efd2254..920ab1c2 100644
--- a/frontend/src/utils/networkUtils.js
+++ b/frontend/src/utils/networkUtils.js
@@ -1,5 +1,6 @@
// IPv4 CIDR regex - validates IP address and prefix length (0-32)
-export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;
+export const IPV4_CIDR_REGEX =
+ /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;
// IPv6 CIDR regex - validates IPv6 address and prefix length (0-128)
export const IPV6_CIDR_REGEX =
@@ -21,4 +22,4 @@ export function formatSpeed(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
-}
\ No newline at end of file
+}
diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js
index ba965343..1456130a 100644
--- a/frontend/src/utils/notificationUtils.js
+++ b/frontend/src/utils/notificationUtils.js
@@ -6,4 +6,4 @@ export function showNotification(notificationObject) {
export function updateNotification(notificationId, notificationObject) {
return notifications.update(notificationId, notificationObject);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/utils/pages/DVRUtils.js b/frontend/src/utils/pages/DVRUtils.js
index 139988d2..bcb200b2 100644
--- a/frontend/src/utils/pages/DVRUtils.js
+++ b/frontend/src/utils/pages/DVRUtils.js
@@ -40,7 +40,7 @@ const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => {
else completed.push(rec);
}
}
-}
+};
export const categorizeRecordings = (recordings, toUserTime, now) => {
const inProgress = [];
@@ -87,4 +87,4 @@ export const categorizeRecordings = (recordings, toUserTime, now) => {
upcoming: upcomingGrouped,
completed,
};
-}
\ No newline at end of file
+};
diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js
index c3bb9caf..cafe0d8c 100644
--- a/frontend/src/utils/pages/SettingsUtils.js
+++ b/frontend/src/utils/pages/SettingsUtils.js
@@ -27,12 +27,39 @@ export const saveChangedSettings = async (settings, changedSettings) => {
};
// Map of field prefixes to their groups
- const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files'];
- const epgFields = ['epg_match_mode', 'epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
- const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template',
- 'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules'];
- const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week',
- 'retention_count', 'schedule_cron_expression'];
+ const streamFields = [
+ 'default_user_agent',
+ 'default_stream_profile',
+ 'm3u_hash_key',
+ 'preferred_region',
+ 'auto_import_mapped_files',
+ ];
+ const epgFields = [
+ 'epg_match_mode',
+ 'epg_match_ignore_prefixes',
+ 'epg_match_ignore_suffixes',
+ 'epg_match_ignore_custom',
+ ];
+ const dvrFields = [
+ 'tv_template',
+ 'movie_template',
+ 'tv_fallback_dir',
+ 'tv_fallback_template',
+ 'movie_fallback_template',
+ 'comskip_enabled',
+ 'comskip_custom_path',
+ 'pre_offset_minutes',
+ 'post_offset_minutes',
+ 'series_rules',
+ ];
+ const backupFields = [
+ 'schedule_enabled',
+ 'schedule_frequency',
+ 'schedule_time',
+ 'schedule_day_of_week',
+ 'retention_count',
+ 'schedule_cron_expression',
+ ];
const systemFields = ['time_zone', 'max_system_events'];
for (const formKey in changedSettings) {
@@ -44,7 +71,11 @@ export const saveChangedSettings = async (settings, changedSettings) => {
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
- await createSetting({ key: 'proxy_settings', name: 'Proxy Settings', value });
+ await createSetting({
+ key: 'proxy_settings',
+ name: 'Proxy Settings',
+ value,
+ });
}
continue;
}
@@ -54,7 +85,11 @@ export const saveChangedSettings = async (settings, changedSettings) => {
if (existing?.id) {
await updateSetting({ ...existing, value });
} else {
- await createSetting({ key: 'network_access', name: 'Network Access', value });
+ await createSetting({
+ key: 'network_access',
+ name: 'Network Access',
+ value,
+ });
}
continue;
}
@@ -65,16 +100,29 @@ export const saveChangedSettings = async (settings, changedSettings) => {
value = value.join(',');
}
- if (['default_user_agent', 'default_stream_profile'].includes(formKey) && value != null) {
+ if (
+ ['default_user_agent', 'default_stream_profile'].includes(formKey) &&
+ value != null
+ ) {
value = parseInt(value, 10);
}
- const numericFields = ['pre_offset_minutes', 'post_offset_minutes', 'retention_count', 'schedule_day_of_week', 'max_system_events'];
+ const numericFields = [
+ 'pre_offset_minutes',
+ 'post_offset_minutes',
+ 'retention_count',
+ 'schedule_day_of_week',
+ 'max_system_events',
+ ];
if (numericFields.includes(formKey) && value != null) {
value = typeof value === 'number' ? value : parseInt(value, 10);
}
- const booleanFields = ['comskip_enabled', 'schedule_enabled', 'auto_import_mapped_files'];
+ const booleanFields = [
+ 'comskip_enabled',
+ 'schedule_enabled',
+ 'auto_import_mapped_files',
+ ];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
}
@@ -107,8 +155,15 @@ export const saveChangedSettings = async (settings, changedSettings) => {
throw new Error(`Failed to update ${groupKey}`);
}
} else {
- const name = groupKey.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
- const result = await createSetting({ key: groupKey, name: name, value: newValue });
+ const name = groupKey
+ .split('_')
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(' ');
+ const result = await createSetting({
+ key: groupKey,
+ name: name,
+ value: newValue,
+ });
if (!result) {
throw new Error(`Failed to create ${groupKey}`);
}
@@ -120,7 +175,11 @@ export const getChangedSettings = (values, settings) => {
const changedSettings = {};
// EPG fields that should be kept as arrays
- const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom'];
+ const epgFields = [
+ 'epg_match_ignore_prefixes',
+ 'epg_match_ignore_suffixes',
+ 'epg_match_ignore_custom',
+ ];
for (const settingKey in values) {
// Skip grouped settings that are handled by their own dedicated forms
@@ -181,8 +240,14 @@ export const parseSettings = (settings) => {
const streamSettings = settings['stream_settings']?.value;
if (streamSettings && typeof streamSettings === 'object') {
// IDs must be strings for Select components
- parsed.default_user_agent = streamSettings.default_user_agent != null ? String(streamSettings.default_user_agent) : null;
- parsed.default_stream_profile = streamSettings.default_stream_profile != null ? String(streamSettings.default_stream_profile) : null;
+ parsed.default_user_agent =
+ streamSettings.default_user_agent != null
+ ? String(streamSettings.default_user_agent)
+ : null;
+ parsed.default_stream_profile =
+ streamSettings.default_stream_profile != null
+ ? String(streamSettings.default_stream_profile)
+ : null;
parsed.preferred_region = streamSettings.preferred_region;
parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files;
@@ -200,9 +265,18 @@ export const parseSettings = (settings) => {
// EPG settings - direct mapping with underscore keys
const epgSettings = settings['epg_settings']?.value;
// Always set EPG fields (even if settings don't exist yet)
- parsed.epg_match_ignore_prefixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)) ? epgSettings.epg_match_ignore_prefixes : [];
- parsed.epg_match_ignore_suffixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)) ? epgSettings.epg_match_ignore_suffixes : [];
- parsed.epg_match_ignore_custom = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)) ? epgSettings.epg_match_ignore_custom : [];
+ parsed.epg_match_ignore_prefixes =
+ epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)
+ ? epgSettings.epg_match_ignore_prefixes
+ : [];
+ parsed.epg_match_ignore_suffixes =
+ epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)
+ ? epgSettings.epg_match_ignore_suffixes
+ : [];
+ parsed.epg_match_ignore_custom =
+ epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)
+ ? epgSettings.epg_match_ignore_custom
+ : [];
// DVR settings - direct mapping with underscore keys
const dvrSettings = settings['dvr_settings']?.value;
@@ -212,29 +286,52 @@ export const parseSettings = (settings) => {
parsed.tv_fallback_dir = dvrSettings.tv_fallback_dir;
parsed.tv_fallback_template = dvrSettings.tv_fallback_template;
parsed.movie_fallback_template = dvrSettings.movie_fallback_template;
- parsed.comskip_enabled = typeof dvrSettings.comskip_enabled === 'boolean' ? dvrSettings.comskip_enabled : Boolean(dvrSettings.comskip_enabled);
+ parsed.comskip_enabled =
+ typeof dvrSettings.comskip_enabled === 'boolean'
+ ? dvrSettings.comskip_enabled
+ : Boolean(dvrSettings.comskip_enabled);
parsed.comskip_custom_path = dvrSettings.comskip_custom_path;
- parsed.pre_offset_minutes = typeof dvrSettings.pre_offset_minutes === 'number' ? dvrSettings.pre_offset_minutes : parseInt(dvrSettings.pre_offset_minutes, 10) || 0;
- parsed.post_offset_minutes = typeof dvrSettings.post_offset_minutes === 'number' ? dvrSettings.post_offset_minutes : parseInt(dvrSettings.post_offset_minutes, 10) || 0;
+ parsed.pre_offset_minutes =
+ typeof dvrSettings.pre_offset_minutes === 'number'
+ ? dvrSettings.pre_offset_minutes
+ : parseInt(dvrSettings.pre_offset_minutes, 10) || 0;
+ parsed.post_offset_minutes =
+ typeof dvrSettings.post_offset_minutes === 'number'
+ ? dvrSettings.post_offset_minutes
+ : parseInt(dvrSettings.post_offset_minutes, 10) || 0;
parsed.series_rules = dvrSettings.series_rules;
}
// Backup settings - direct mapping with underscore keys
const backupSettings = settings['backup_settings']?.value;
if (backupSettings && typeof backupSettings === 'object') {
- parsed.schedule_enabled = typeof backupSettings.schedule_enabled === 'boolean' ? backupSettings.schedule_enabled : Boolean(backupSettings.schedule_enabled);
+ parsed.schedule_enabled =
+ typeof backupSettings.schedule_enabled === 'boolean'
+ ? backupSettings.schedule_enabled
+ : Boolean(backupSettings.schedule_enabled);
parsed.schedule_frequency = String(backupSettings.schedule_frequency || '');
parsed.schedule_time = String(backupSettings.schedule_time || '');
- parsed.schedule_day_of_week = typeof backupSettings.schedule_day_of_week === 'number' ? backupSettings.schedule_day_of_week : parseInt(backupSettings.schedule_day_of_week, 10) || 0;
- parsed.retention_count = typeof backupSettings.retention_count === 'number' ? backupSettings.retention_count : parseInt(backupSettings.retention_count, 10) || 0;
- parsed.schedule_cron_expression = String(backupSettings.schedule_cron_expression || '');
+ parsed.schedule_day_of_week =
+ typeof backupSettings.schedule_day_of_week === 'number'
+ ? backupSettings.schedule_day_of_week
+ : parseInt(backupSettings.schedule_day_of_week, 10) || 0;
+ parsed.retention_count =
+ typeof backupSettings.retention_count === 'number'
+ ? backupSettings.retention_count
+ : parseInt(backupSettings.retention_count, 10) || 0;
+ parsed.schedule_cron_expression = String(
+ backupSettings.schedule_cron_expression || ''
+ );
}
// System settings - direct mapping with underscore keys
const systemSettings = settings['system_settings']?.value;
if (systemSettings && typeof systemSettings === 'object') {
parsed.time_zone = String(systemSettings.time_zone || '');
- parsed.max_system_events = typeof systemSettings.max_system_events === 'number' ? systemSettings.max_system_events : parseInt(systemSettings.max_system_events, 10) || 100;
+ parsed.max_system_events =
+ typeof systemSettings.max_system_events === 'number'
+ ? systemSettings.max_system_events
+ : parseInt(systemSettings.max_system_events, 10) || 100;
}
// Proxy and network access are already grouped objects
@@ -246,4 +343,4 @@ export const parseSettings = (settings) => {
}
return parsed;
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/pages/StatsUtils.js b/frontend/src/utils/pages/StatsUtils.js
index 72198122..05f8c393 100644
--- a/frontend/src/utils/pages/StatsUtils.js
+++ b/frontend/src/utils/pages/StatsUtils.js
@@ -24,9 +24,11 @@ export const getCurrentPrograms = async (channelHistory, channelsByUUID) => {
try {
// Get all active channel IDs that have actual channels (not just streams)
const activeChannelIds = Object.values(channelHistory)
- .filter(ch => ch.name && channelsByUUID && channelsByUUID[ch.channel_id])
- .map(ch => channelsByUUID[ch.channel_id])
- .filter(id => id !== undefined);
+ .filter(
+ (ch) => ch.name && channelsByUUID && channelsByUUID[ch.channel_id]
+ )
+ .map((ch) => channelsByUUID[ch.channel_id])
+ .filter((id) => id !== undefined);
if (activeChannelIds.length === 0) {
return {};
@@ -37,7 +39,7 @@ export const getCurrentPrograms = async (channelHistory, channelsByUUID) => {
// Convert array to map keyed by channel UUID for easy lookup
const programsMap = {};
if (programs && Array.isArray(programs)) {
- programs.forEach(program => {
+ programs.forEach((program) => {
// Find the channel UUID from the channel ID
const channelEntry = Object.entries(channelsByUUID).find(
([uuid, id]) => id === program.channel_id
diff --git a/frontend/src/utils/pages/__tests__/DVRUtils.test.js b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
index 9c5bb15f..dd76784a 100644
--- a/frontend/src/utils/pages/__tests__/DVRUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
@@ -20,8 +20,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T11:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -39,8 +39,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -58,8 +58,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T10:00:00',
end_time: '2024-01-01T11:00:00',
channel: 'ch1',
- custom_properties: { status: 'completed' }
- }
+ custom_properties: { status: 'completed' },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -77,8 +77,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T11:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
- custom_properties: { status: 'interrupted' }
- }
+ custom_properties: { status: 'interrupted' },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -95,8 +95,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T09:00:00',
end_time: '2024-01-01T10:00:00',
channel: 'ch1',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -114,8 +114,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { id: 100 }
- }
+ program: { id: 100 },
+ },
},
{
id: 2,
@@ -123,9 +123,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch2',
custom_properties: {
- program: { id: 100 }
- }
- }
+ program: { id: 100 },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -141,8 +141,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { title: 'Show A' }
- }
+ program: { title: 'Show A' },
+ },
},
{
id: 2,
@@ -150,9 +150,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { title: 'Show A' }
- }
- }
+ program: { title: 'Show A' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -168,8 +168,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
custom_properties: {
- program: { title: 'Show A' }
- }
+ program: { title: 'Show A' },
+ },
},
{
id: 2,
@@ -177,9 +177,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T13:00:00',
channel: 'ch2',
custom_properties: {
- program: { title: 'Show A' }
- }
- }
+ program: { title: 'Show A' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -194,22 +194,22 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T10:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch1',
- custom_properties: { program: { id: 1 } }
+ custom_properties: { program: { id: 1 } },
},
{
id: 2,
start_time: '2024-01-01T11:30:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch2',
- custom_properties: { program: { id: 2 } }
+ custom_properties: { program: { id: 2 } },
},
{
id: 3,
start_time: '2024-01-01T11:00:00',
end_time: '2024-01-01T13:00:00',
channel: 'ch3',
- custom_properties: { program: { id: 3 } }
- }
+ custom_properties: { program: { id: 3 } },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -227,8 +227,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Show A' }
- }
+ program: { tvg_id: 'show1', title: 'Show A' },
+ },
},
{
id: 2,
@@ -236,8 +236,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Show A' }
- }
+ program: { tvg_id: 'show1', title: 'Show A' },
+ },
},
{
id: 3,
@@ -245,9 +245,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T17:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Show A' }
- }
- }
+ program: { tvg_id: 'show1', title: 'Show A' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -265,8 +265,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Show A' }
- }
+ program: { tvg_id: 'show1', title: 'Show A' },
+ },
},
{
id: 2,
@@ -274,9 +274,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'show a' }
- }
- }
+ program: { tvg_id: 'show1', title: 'show a' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -293,8 +293,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Show A' }
- }
+ program: { tvg_id: 'show1', title: 'Show A' },
+ },
},
{
id: 2,
@@ -302,9 +302,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show2', title: 'Show A' }
- }
- }
+ program: { tvg_id: 'show2', title: 'Show A' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -321,22 +321,28 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T16:00:00',
end_time: '2024-01-01T17:00:00',
channel: 'ch1',
- custom_properties: { program: { id: 1, tvg_id: 'show1', title: 'Show A' } }
+ custom_properties: {
+ program: { id: 1, tvg_id: 'show1', title: 'Show A' },
+ },
},
{
id: 2,
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch2',
- custom_properties: { program: { id: 2, tvg_id: 'show2', title: 'Show B' } }
+ custom_properties: {
+ program: { id: 2, tvg_id: 'show2', title: 'Show B' },
+ },
},
{
id: 3,
start_time: '2024-01-01T15:00:00',
end_time: '2024-01-01T16:00:00',
channel: 'ch3',
- custom_properties: { program: { id: 3, tvg_id: 'show3', title: 'Show C' } }
- }
+ custom_properties: {
+ program: { id: 3, tvg_id: 'show3', title: 'Show C' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -346,7 +352,6 @@ describe('DVRUtils', () => {
expect(result.upcoming[2].id).toBe(1);
});
-
it('should sort completed by end_time descending', () => {
const recordings = [
{
@@ -354,22 +359,22 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T08:00:00',
end_time: '2024-01-01T09:00:00',
channel: 'ch1',
- custom_properties: { status: 'completed' }
+ custom_properties: { status: 'completed' },
},
{
id: 2,
start_time: '2024-01-01T10:00:00',
end_time: '2024-01-01T11:00:00',
channel: 'ch2',
- custom_properties: { status: 'completed' }
+ custom_properties: { status: 'completed' },
},
{
id: 3,
start_time: '2024-01-01T09:00:00',
end_time: '2024-01-01T10:00:00',
channel: 'ch3',
- custom_properties: { status: 'completed' }
- }
+ custom_properties: { status: 'completed' },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -386,8 +391,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
};
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -418,15 +423,15 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
- custom_properties: {}
+ custom_properties: {},
},
{
id: 1,
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -440,8 +445,8 @@ describe('DVRUtils', () => {
id: 1,
start_time: '2024-01-01T11:00:00',
end_time: '2024-01-01T13:00:00',
- channel: 'ch1'
- }
+ channel: 'ch1',
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -456,8 +461,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -472,8 +477,8 @@ describe('DVRUtils', () => {
start_time: '2024-01-01T14:00:00',
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
- custom_properties: {}
- }
+ custom_properties: {},
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -489,8 +494,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
- program: { id: 100, tvg_id: 'show1', title: 'Show A' }
- }
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' },
+ },
},
{
id: 2,
@@ -498,8 +503,8 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch2',
custom_properties: {
- program: { id: 100, tvg_id: 'show1', title: 'Show A' }
- }
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' },
+ },
},
{
id: 3,
@@ -507,9 +512,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T16:00:00',
channel: 'ch1',
custom_properties: {
- program: { id: 101, tvg_id: 'show1', title: 'Show A' }
- }
- }
+ program: { id: 101, tvg_id: 'show1', title: 'Show A' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
@@ -526,9 +531,9 @@ describe('DVRUtils', () => {
end_time: '2024-01-01T15:00:00',
channel: 'ch1',
custom_properties: {
- program: { tvg_id: 'show1', title: 'Show A' }
- }
- }
+ program: { tvg_id: 'show1', title: 'Show A' },
+ },
+ },
];
const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
index 3b79fe38..a7539eeb 100644
--- a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
@@ -9,8 +9,8 @@ vi.mock('../../../api.js', () => ({
setPluginEnabled: vi.fn(),
importPlugin: vi.fn(),
reloadPlugins: vi.fn(),
- deletePlugin: vi.fn()
- }
+ deletePlugin: vi.fn(),
+ },
}));
describe('PluginsUtils', () => {
@@ -66,7 +66,9 @@ describe('PluginsUtils', () => {
API.updatePluginSettings.mockRejectedValue(error);
- await expect(PluginsUtils.updatePluginSettings(key, settings)).rejects.toThrow('API error');
+ await expect(
+ PluginsUtils.updatePluginSettings(key, settings)
+ ).rejects.toThrow('API error');
});
});
@@ -109,7 +111,9 @@ describe('PluginsUtils', () => {
API.runPluginAction.mockRejectedValue(error);
- await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow('Action not found');
+ await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow(
+ 'Action not found'
+ );
});
});
@@ -187,13 +191,17 @@ describe('PluginsUtils', () => {
API.setPluginEnabled.mockRejectedValue(error);
- await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow('Plugin not found');
+ await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow(
+ 'Plugin not found'
+ );
});
});
describe('importPlugin', () => {
it('should call API importPlugin with importFile', async () => {
- const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const importFile = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
await PluginsUtils.importPlugin(importFile);
@@ -202,7 +210,9 @@ describe('PluginsUtils', () => {
});
it('should return API response', async () => {
- const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const importFile = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
const mockResponse = { key: 'imported-plugin', success: true };
API.importPlugin.mockResolvedValue(mockResponse);
@@ -230,12 +240,16 @@ describe('PluginsUtils', () => {
});
it('should propagate API errors', async () => {
- const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const importFile = new File(['content'], 'plugin.zip', {
+ type: 'application/zip',
+ });
const error = new Error('Invalid plugin format');
API.importPlugin.mockRejectedValue(error);
- await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow('Invalid plugin format');
+ await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow(
+ 'Invalid plugin format'
+ );
});
});
diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
index 17783f24..b9efef94 100644
--- a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
@@ -7,8 +7,8 @@ vi.mock('../../../api.js', () => ({
checkSetting: vi.fn(),
updateSetting: vi.fn(),
createSetting: vi.fn(),
- rehashStreams: vi.fn()
- }
+ rehashStreams: vi.fn(),
+ },
}));
describe('SettingsUtils', () => {
@@ -36,7 +36,11 @@ describe('SettingsUtils', () => {
describe('createSetting', () => {
it('should call API createSetting with values', async () => {
- const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
+ const values = {
+ key: 'new-setting',
+ name: 'New Setting',
+ value: 'value',
+ };
await SettingsUtils.createSetting(values);
expect(API.createSetting).toHaveBeenCalledWith(values);
expect(API.createSetting).toHaveBeenCalledTimes(1);
@@ -59,13 +63,13 @@ describe('SettingsUtils', () => {
key: 'stream_settings',
value: {
default_user_agent: 5,
- m3u_hash_key: 'channel_name'
- }
- }
+ m3u_hash_key: 'channel_name',
+ },
+ },
};
const changedSettings = {
default_user_agent: 7,
- preferred_region: 'UK'
+ preferred_region: 'UK',
};
API.updateSetting.mockResolvedValue({});
@@ -78,8 +82,8 @@ describe('SettingsUtils', () => {
value: {
default_user_agent: 7,
m3u_hash_key: 'channel_name',
- preferred_region: 'UK'
- }
+ preferred_region: 'UK',
+ },
});
});
@@ -88,11 +92,11 @@ describe('SettingsUtils', () => {
stream_settings: {
id: 1,
key: 'stream_settings',
- value: {}
- }
+ value: {},
+ },
};
const changedSettings = {
- m3u_hash_key: ['channel_name', 'channel_number']
+ m3u_hash_key: ['channel_name', 'channel_number'],
};
API.updateSetting.mockResolvedValue({});
@@ -103,8 +107,8 @@ describe('SettingsUtils', () => {
id: 1,
key: 'stream_settings',
value: {
- m3u_hash_key: 'channel_name,channel_number'
- }
+ m3u_hash_key: 'channel_name,channel_number',
+ },
});
});
@@ -113,12 +117,12 @@ describe('SettingsUtils', () => {
stream_settings: {
id: 1,
key: 'stream_settings',
- value: {}
- }
+ value: {},
+ },
};
const changedSettings = {
default_user_agent: '5',
- default_stream_profile: '3'
+ default_stream_profile: '3',
};
API.updateSetting.mockResolvedValue({});
@@ -130,8 +134,8 @@ describe('SettingsUtils', () => {
key: 'stream_settings',
value: {
default_user_agent: 5,
- default_stream_profile: 3
- }
+ default_stream_profile: 3,
+ },
});
});
@@ -140,17 +144,17 @@ describe('SettingsUtils', () => {
dvr_settings: {
id: 2,
key: 'dvr_settings',
- value: {}
+ value: {},
},
stream_settings: {
id: 1,
key: 'stream_settings',
- value: {}
- }
+ value: {},
+ },
};
const changedSettings = {
comskip_enabled: true,
- auto_import_mapped_files: false
+ auto_import_mapped_files: false,
};
API.updateSetting.mockResolvedValue({});
@@ -166,15 +170,15 @@ describe('SettingsUtils', () => {
id: 5,
key: 'proxy_settings',
value: {
- buffering_speed: 1.0
- }
- }
+ buffering_speed: 1.0,
+ },
+ },
};
const changedSettings = {
proxy_settings: {
buffering_speed: 2.5,
- buffering_timeout: 15
- }
+ buffering_timeout: 15,
+ },
};
API.updateSetting.mockResolvedValue({});
@@ -186,8 +190,8 @@ describe('SettingsUtils', () => {
key: 'proxy_settings',
value: {
buffering_speed: 2.5,
- buffering_timeout: 15
- }
+ buffering_timeout: 15,
+ },
});
});
@@ -195,8 +199,8 @@ describe('SettingsUtils', () => {
const settings = {};
const changedSettings = {
proxy_settings: {
- buffering_speed: 2.5
- }
+ buffering_speed: 2.5,
+ },
};
API.createSetting.mockResolvedValue({});
@@ -207,8 +211,8 @@ describe('SettingsUtils', () => {
key: 'proxy_settings',
name: 'Proxy Settings',
value: {
- buffering_speed: 2.5
- }
+ buffering_speed: 2.5,
+ },
});
});
@@ -217,11 +221,11 @@ describe('SettingsUtils', () => {
network_access: {
id: 6,
key: 'network_access',
- value: []
- }
+ value: [],
+ },
};
const changedSettings = {
- network_access: ['192.168.1.0/24', '10.0.0.0/8']
+ network_access: ['192.168.1.0/24', '10.0.0.0/8'],
};
API.updateSetting.mockResolvedValue({});
@@ -231,7 +235,7 @@ describe('SettingsUtils', () => {
expect(API.updateSetting).toHaveBeenCalledWith({
id: 6,
key: 'network_access',
- value: ['192.168.1.0/24', '10.0.0.0/8']
+ value: ['192.168.1.0/24', '10.0.0.0/8'],
});
});
});
@@ -239,7 +243,7 @@ describe('SettingsUtils', () => {
describe('parseSettings', () => {
it('should parse grouped settings correctly', () => {
const mockSettings = {
- 'stream_settings': {
+ stream_settings: {
id: 1,
key: 'stream_settings',
value: {
@@ -247,19 +251,19 @@ describe('SettingsUtils', () => {
default_stream_profile: 3,
m3u_hash_key: 'channel_name,channel_number',
preferred_region: 'US',
- auto_import_mapped_files: true
- }
+ auto_import_mapped_files: true,
+ },
},
- 'dvr_settings': {
+ dvr_settings: {
id: 2,
key: 'dvr_settings',
value: {
tv_template: '/media/tv/{show}/{season}/',
comskip_enabled: false,
pre_offset_minutes: 2,
- post_offset_minutes: 5
- }
- }
+ post_offset_minutes: 5,
+ },
+ },
};
const result = SettingsUtils.parseSettings(mockSettings);
@@ -280,13 +284,13 @@ describe('SettingsUtils', () => {
it('should handle empty m3u_hash_key', () => {
const mockSettings = {
- 'stream_settings': {
+ stream_settings: {
id: 1,
key: 'stream_settings',
value: {
- m3u_hash_key: ''
- }
- }
+ m3u_hash_key: '',
+ },
+ },
};
const result = SettingsUtils.parseSettings(mockSettings);
@@ -295,30 +299,30 @@ describe('SettingsUtils', () => {
it('should handle proxy_settings', () => {
const mockSettings = {
- 'proxy_settings': {
+ proxy_settings: {
id: 5,
key: 'proxy_settings',
value: {
buffering_speed: 2.5,
- buffering_timeout: 15
- }
- }
+ buffering_timeout: 15,
+ },
+ },
};
const result = SettingsUtils.parseSettings(mockSettings);
expect(result.proxy_settings).toEqual({
buffering_speed: 2.5,
- buffering_timeout: 15
+ buffering_timeout: 15,
});
});
it('should handle network_access', () => {
const mockSettings = {
- 'network_access': {
+ network_access: {
id: 6,
key: 'network_access',
- value: ['192.168.1.0/24', '10.0.0.0/8']
- }
+ value: ['192.168.1.0/24', '10.0.0.0/8'],
+ },
};
const result = SettingsUtils.parseSettings(mockSettings);
@@ -331,12 +335,12 @@ describe('SettingsUtils', () => {
const values = {
time_zone: 'America/New_York',
max_system_events: 2000,
- comskip_enabled: true
+ comskip_enabled: true,
};
const settings = {
time_zone: { value: 'UTC' },
max_system_events: { value: 1000 },
- comskip_enabled: { value: false }
+ comskip_enabled: { value: false },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@@ -344,18 +348,18 @@ describe('SettingsUtils', () => {
expect(changes).toEqual({
time_zone: 'America/New_York',
max_system_events: 2000,
- comskip_enabled: true
+ comskip_enabled: true,
});
});
it('should not detect unchanged values', () => {
const values = {
time_zone: 'UTC',
- max_system_events: 1000
+ max_system_events: 1000,
};
const settings = {
time_zone: { value: 'UTC' },
- max_system_events: { value: 1000 }
+ max_system_events: { value: 1000 },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@@ -364,10 +368,10 @@ describe('SettingsUtils', () => {
it('should preserve type of numeric values', () => {
const values = {
- max_system_events: 2000
+ max_system_events: 2000,
};
const settings = {
- max_system_events: { value: 1000 }
+ max_system_events: { value: 1000 },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@@ -377,16 +381,16 @@ describe('SettingsUtils', () => {
it('should detect changes in array values', () => {
const values = {
- m3u_hash_key: ['channel_name', 'channel_number']
+ m3u_hash_key: ['channel_name', 'channel_number'],
};
const settings = {
- m3u_hash_key: { value: 'channel_name' }
+ m3u_hash_key: { value: 'channel_name' },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
// Arrays are converted to comma-separated strings internally
expect(changes).toEqual({
- m3u_hash_key: 'channel_name,channel_number'
+ m3u_hash_key: 'channel_name,channel_number',
});
});
@@ -394,12 +398,12 @@ describe('SettingsUtils', () => {
const values = {
time_zone: 'America/New_York',
proxy_settings: {
- buffering_speed: 2.5
+ buffering_speed: 2.5,
},
- network_access: ['192.168.1.0/24']
+ network_access: ['192.168.1.0/24'],
};
const settings = {
- time_zone: { value: 'UTC' }
+ time_zone: { value: 'UTC' },
};
const changes = SettingsUtils.getChangedSettings(values, settings);
@@ -456,8 +460,8 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: [],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
- }
- }
+ },
+ },
};
const changedSettings = {
epg_match_mode: 'advanced',
@@ -476,7 +480,7 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [],
epg_match_ignore_custom: [],
- }
+ },
});
});
@@ -497,7 +501,7 @@ describe('SettingsUtils', () => {
value: {
epg_match_mode: 'advanced',
epg_match_ignore_prefixes: ['Sling:'],
- }
+ },
});
});
@@ -511,8 +515,8 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [' 4K'],
epg_match_ignore_custom: ['Plus'],
- }
- }
+ },
+ },
};
const changedSettings = {
epg_match_mode: 'default',
@@ -530,7 +534,7 @@ describe('SettingsUtils', () => {
epg_match_ignore_prefixes: ['HD:'],
epg_match_ignore_suffixes: [' 4K'],
epg_match_ignore_custom: ['Plus'],
- }
+ },
});
});
});
diff --git a/frontend/src/utils/pages/__tests__/StatsUtils.test.js b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
index ccd422b1..8ae2421f 100644
--- a/frontend/src/utils/pages/__tests__/StatsUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
@@ -8,8 +8,8 @@ vi.mock('../../../api.js', () => ({
stopClient: vi.fn(),
stopVODClient: vi.fn(),
fetchActiveChannelStats: vi.fn(),
- getVODStats: vi.fn()
- }
+ getVODStats: vi.fn(),
+ },
}));
describe('StatsUtils', () => {
@@ -41,7 +41,9 @@ describe('StatsUtils', () => {
API.stopChannel.mockRejectedValue(error);
- await expect(StatsUtils.stopChannel(id)).rejects.toThrow('Failed to stop channel');
+ await expect(StatsUtils.stopChannel(id)).rejects.toThrow(
+ 'Failed to stop channel'
+ );
});
});
@@ -72,7 +74,9 @@ describe('StatsUtils', () => {
API.stopClient.mockRejectedValue(error);
- await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow('Failed to stop client');
+ await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow(
+ 'Failed to stop client'
+ );
});
});
@@ -100,7 +104,9 @@ describe('StatsUtils', () => {
API.stopVODClient.mockRejectedValue(error);
- await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow('Failed to stop VOD client');
+ await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow(
+ 'Failed to stop VOD client'
+ );
});
});
@@ -122,7 +128,9 @@ describe('StatsUtils', () => {
API.fetchActiveChannelStats.mockRejectedValue(error);
- await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow('Failed to fetch stats');
+ await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow(
+ 'Failed to fetch stats'
+ );
});
});
@@ -144,26 +152,29 @@ describe('StatsUtils', () => {
API.getVODStats.mockRejectedValue(error);
- await expect(StatsUtils.getVODStats()).rejects.toThrow('Failed to fetch VOD stats');
+ await expect(StatsUtils.getVODStats()).rejects.toThrow(
+ 'Failed to fetch VOD stats'
+ );
});
});
describe('getCombinedConnections', () => {
it('should combine channel history and VOD connections', () => {
const channelHistory = {
- 'ch1': { channel_id: 'ch1', uptime: 100 }
+ ch1: { channel_id: 'ch1', uptime: 100 },
};
const vodConnections = [
{
content_type: 'movie',
content_uuid: 'uuid1',
- connections: [
- { client_id: 'client1', connected_at: 50 }
- ]
- }
+ connections: [{ client_id: 'client1', connected_at: 50 }],
+ },
];
- const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+ const result = StatsUtils.getCombinedConnections(
+ channelHistory,
+ vodConnections
+ );
expect(result).toHaveLength(2);
expect(result[0].type).toBe('stream');
@@ -172,19 +183,20 @@ describe('StatsUtils', () => {
it('should sort by sortKey descending (newest first)', () => {
const channelHistory = {
- 'ch1': { channel_id: 'ch1', uptime: 50 }
+ ch1: { channel_id: 'ch1', uptime: 50 },
};
const vodConnections = [
{
content_type: 'movie',
content_uuid: 'uuid1',
- connections: [
- { client_id: 'client1', connected_at: 100 }
- ]
- }
+ connections: [{ client_id: 'client1', connected_at: 100 }],
+ },
];
- const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+ const result = StatsUtils.getCombinedConnections(
+ channelHistory,
+ vodConnections
+ );
expect(result[0].sortKey).toBe(100);
expect(result[1].sortKey).toBe(50);
@@ -197,9 +209,9 @@ describe('StatsUtils', () => {
content_uuid: 'uuid1',
connections: [
{ client_id: 'client1', connected_at: 100 },
- { client_id: 'client2', connected_at: 200 }
- ]
- }
+ { client_id: 'client2', connected_at: 200 },
+ ],
+ },
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@@ -218,9 +230,9 @@ describe('StatsUtils', () => {
content_uuid: 'uuid1',
connections: [
{ client_id: 'client1', connected_at: 100 },
- { client_id: 'client2', connected_at: 200 }
- ]
- }
+ { client_id: 'client2', connected_at: 200 },
+ ],
+ },
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@@ -231,7 +243,7 @@ describe('StatsUtils', () => {
it('should use uptime for stream sortKey', () => {
const channelHistory = {
- 'ch1': { channel_id: 'ch1', uptime: 150 }
+ ch1: { channel_id: 'ch1', uptime: 150 },
};
const result = StatsUtils.getCombinedConnections(channelHistory, []);
@@ -241,7 +253,7 @@ describe('StatsUtils', () => {
it('should default to 0 for missing uptime', () => {
const channelHistory = {
- 'ch1': { channel_id: 'ch1' }
+ ch1: { channel_id: 'ch1' },
};
const result = StatsUtils.getCombinedConnections(channelHistory, []);
@@ -254,10 +266,8 @@ describe('StatsUtils', () => {
{
content_type: 'movie',
content_uuid: 'uuid1',
- connections: [
- { client_id: 'client1', connected_at: 250 }
- ]
- }
+ connections: [{ client_id: 'client1', connected_at: 250 }],
+ },
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@@ -270,8 +280,8 @@ describe('StatsUtils', () => {
{
content_type: 'movie',
content_uuid: 'uuid1',
- connections: []
- }
+ connections: [],
+ },
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@@ -290,8 +300,8 @@ describe('StatsUtils', () => {
{
content_type: 'movie',
content_uuid: 'uuid1',
- connections: null
- }
+ connections: null,
+ },
];
const result = StatsUtils.getCombinedConnections({}, vodConnections);
@@ -303,13 +313,10 @@ describe('StatsUtils', () => {
describe('getClientStats', () => {
it('should extract clients from channel stats', () => {
const stats = {
- 'ch1': {
+ ch1: {
channel_id: 'ch1',
- clients: [
- { client_id: 'client1' },
- { client_id: 'client2' }
- ]
- }
+ clients: [{ client_id: 'client1' }, { client_id: 'client2' }],
+ },
};
const result = StatsUtils.getClientStats(stats);
@@ -321,13 +328,11 @@ describe('StatsUtils', () => {
it('should attach channel reference to each client', () => {
const stats = {
- 'ch1': {
+ ch1: {
channel_id: 'ch1',
name: 'Channel 1',
- clients: [
- { client_id: 'client1' }
- ]
- }
+ clients: [{ client_id: 'client1' }],
+ },
};
const result = StatsUtils.getClientStats(stats);
@@ -335,14 +340,14 @@ describe('StatsUtils', () => {
expect(result[0].channel).toEqual({
channel_id: 'ch1',
name: 'Channel 1',
- clients: [{ client_id: 'client1' }]
+ clients: [{ client_id: 'client1' }],
});
});
it('should handle channels without clients array', () => {
const stats = {
- 'ch1': { channel_id: 'ch1' },
- 'ch2': { channel_id: 'ch2', clients: null }
+ ch1: { channel_id: 'ch1' },
+ ch2: { channel_id: 'ch2', clients: null },
};
const result = StatsUtils.getClientStats(stats);
@@ -352,10 +357,10 @@ describe('StatsUtils', () => {
it('should handle empty clients array', () => {
const stats = {
- 'ch1': {
+ ch1: {
channel_id: 'ch1',
- clients: []
- }
+ clients: [],
+ },
};
const result = StatsUtils.getClientStats(stats);
@@ -365,14 +370,14 @@ describe('StatsUtils', () => {
it('should combine clients from multiple channels', () => {
const stats = {
- 'ch1': {
+ ch1: {
channel_id: 'ch1',
- clients: [{ client_id: 'client1' }]
+ clients: [{ client_id: 'client1' }],
},
- 'ch2': {
+ ch2: {
channel_id: 'ch2',
- clients: [{ client_id: 'client2' }]
- }
+ clients: [{ client_id: 'client2' }],
+ },
};
const result = StatsUtils.getClientStats(stats);
@@ -392,9 +397,7 @@ describe('StatsUtils', () => {
describe('getStatsByChannelId', () => {
it('should create stats indexed by channel_id', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1', total_bytes: 1000 }
- ]
+ channels: [{ channel_id: 'ch1', total_bytes: 1000 }],
};
const prevChannelHistory = {};
const channelsByUUID = {};
@@ -415,15 +418,13 @@ describe('StatsUtils', () => {
it('should calculate bitrates from previous history', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1', total_bytes: 2000 }
- ]
+ channels: [{ channel_id: 'ch1', total_bytes: 2000 }],
};
const prevChannelHistory = {
- 'ch1': {
+ ch1: {
total_bytes: 1000,
- bitrates: [500]
- }
+ bitrates: [500],
+ },
};
const result = StatsUtils.getStatsByChannelId(
@@ -440,15 +441,13 @@ describe('StatsUtils', () => {
it('should limit bitrates array to 15 entries', () => {
const prevBitrates = new Array(15).fill(100);
const channelStats = {
- channels: [
- { channel_id: 'ch1', total_bytes: 2000 }
- ]
+ channels: [{ channel_id: 'ch1', total_bytes: 2000 }],
};
const prevChannelHistory = {
- 'ch1': {
+ ch1: {
total_bytes: 1000,
- bitrates: prevBitrates
- }
+ bitrates: prevBitrates,
+ },
};
const result = StatsUtils.getStatsByChannelId(
@@ -466,15 +465,13 @@ describe('StatsUtils', () => {
it('should skip negative bitrates', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1', total_bytes: 500 }
- ]
+ channels: [{ channel_id: 'ch1', total_bytes: 500 }],
};
const prevChannelHistory = {
- 'ch1': {
+ ch1: {
total_bytes: 1000,
- bitrates: []
- }
+ bitrates: [],
+ },
};
const result = StatsUtils.getStatsByChannelId(
@@ -490,18 +487,16 @@ describe('StatsUtils', () => {
it('should merge channel data from channelsByUUID', () => {
const channelStats = {
- channels: [
- { channel_id: 'uuid1', total_bytes: 1000 }
- ]
+ channels: [{ channel_id: 'uuid1', total_bytes: 1000 }],
};
const channelsByUUID = {
- 'uuid1': 'channel-key-1'
+ uuid1: 'channel-key-1',
};
const channels = {
'channel-key-1': {
name: 'Channel 1',
- logo: 'logo.png'
- }
+ logo: 'logo.png',
+ },
};
const result = StatsUtils.getStatsByChannelId(
@@ -518,13 +513,11 @@ describe('StatsUtils', () => {
it('should find and attach stream profile', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1', stream_profile: '1' }
- ]
+ channels: [{ channel_id: 'ch1', stream_profile: '1' }],
};
const streamProfiles = [
{ id: 1, name: 'HD Profile' },
- { id: 2, name: 'SD Profile' }
+ { id: 2, name: 'SD Profile' },
];
const result = StatsUtils.getStatsByChannelId(
@@ -540,13 +533,9 @@ describe('StatsUtils', () => {
it('should default to Unknown for missing stream profile', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1', stream_profile: '999' }
- ]
+ channels: [{ channel_id: 'ch1', stream_profile: '999' }],
};
- const streamProfiles = [
- { id: 1, name: 'HD Profile' }
- ];
+ const streamProfiles = [{ id: 1, name: 'HD Profile' }];
const result = StatsUtils.getStatsByChannelId(
channelStats,
@@ -561,9 +550,7 @@ describe('StatsUtils', () => {
it('should preserve stream_id from channel stats', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1', stream_id: 'stream-123' }
- ]
+ channels: [{ channel_id: 'ch1', stream_id: 'stream-123' }],
};
const result = StatsUtils.getStatsByChannelId(
@@ -579,9 +566,7 @@ describe('StatsUtils', () => {
it('should set stream_id to null if missing', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1' }
- ]
+ channels: [{ channel_id: 'ch1' }],
};
const result = StatsUtils.getStatsByChannelId(
@@ -600,8 +585,8 @@ describe('StatsUtils', () => {
const channelStats = {
channels: [
{ total_bytes: 1000 },
- { channel_id: 'ch1', total_bytes: 2000 }
- ]
+ { channel_id: 'ch1', total_bytes: 2000 },
+ ],
};
const result = StatsUtils.getStatsByChannelId(
@@ -614,7 +599,10 @@ describe('StatsUtils', () => {
expect(result).not.toHaveProperty('undefined');
expect(result).toHaveProperty('ch1');
- expect(consoleSpy).toHaveBeenCalledWith('Found channel without channel_id:', { total_bytes: 1000 });
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Found channel without channel_id:',
+ { total_bytes: 1000 }
+ );
consoleSpy.mockRestore();
});
@@ -635,9 +623,7 @@ describe('StatsUtils', () => {
it('should initialize empty bitrates array for new channels', () => {
const channelStats = {
- channels: [
- { channel_id: 'ch1', total_bytes: 1000 }
- ]
+ channels: [{ channel_id: 'ch1', total_bytes: 1000 }],
};
const result = StatsUtils.getStatsByChannelId(
diff --git a/frontend/src/utils/pages/__tests__/VODsUtils.test.js b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
index e058ff0e..0905914c 100644
--- a/frontend/src/utils/pages/__tests__/VODsUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
@@ -5,8 +5,8 @@ describe('VODsUtils', () => {
describe('getCategoryOptions', () => {
it('should return all categories option plus formatted categories', () => {
const categories = {
- 'cat1': { name: 'Action', category_type: 'movie' },
- 'cat2': { name: 'Drama', category_type: 'series' }
+ cat1: { name: 'Action', category_type: 'movie' },
+ cat2: { name: 'Drama', category_type: 'series' },
};
const filters = { type: 'all' };
@@ -14,15 +14,21 @@ describe('VODsUtils', () => {
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ value: '', label: 'All Categories' });
- expect(result[1]).toEqual({ value: 'Action|movie', label: 'Action (movie)' });
- expect(result[2]).toEqual({ value: 'Drama|series', label: 'Drama (series)' });
+ expect(result[1]).toEqual({
+ value: 'Action|movie',
+ label: 'Action (movie)',
+ });
+ expect(result[2]).toEqual({
+ value: 'Drama|series',
+ label: 'Drama (series)',
+ });
});
it('should filter to only movies when type is movies', () => {
const categories = {
- 'cat1': { name: 'Action', category_type: 'movie' },
- 'cat2': { name: 'Drama', category_type: 'series' },
- 'cat3': { name: 'Comedy', category_type: 'movie' }
+ cat1: { name: 'Action', category_type: 'movie' },
+ cat2: { name: 'Drama', category_type: 'series' },
+ cat3: { name: 'Comedy', category_type: 'movie' },
};
const filters = { type: 'movies' };
@@ -36,9 +42,9 @@ describe('VODsUtils', () => {
it('should filter to only series when type is series', () => {
const categories = {
- 'cat1': { name: 'Action', category_type: 'movie' },
- 'cat2': { name: 'Drama', category_type: 'series' },
- 'cat3': { name: 'Sitcom', category_type: 'series' }
+ cat1: { name: 'Action', category_type: 'movie' },
+ cat2: { name: 'Drama', category_type: 'series' },
+ cat3: { name: 'Sitcom', category_type: 'series' },
};
const filters = { type: 'series' };
@@ -52,8 +58,8 @@ describe('VODsUtils', () => {
it('should show all categories when type is all', () => {
const categories = {
- 'cat1': { name: 'Action', category_type: 'movie' },
- 'cat2': { name: 'Drama', category_type: 'series' }
+ cat1: { name: 'Action', category_type: 'movie' },
+ cat2: { name: 'Drama', category_type: 'series' },
};
const filters = { type: 'all' };
@@ -74,7 +80,7 @@ describe('VODsUtils', () => {
it('should create value with name and category_type separated by pipe', () => {
const categories = {
- 'cat1': { name: 'Action', category_type: 'movie' }
+ cat1: { name: 'Action', category_type: 'movie' },
};
const filters = { type: 'all' };
@@ -85,8 +91,8 @@ describe('VODsUtils', () => {
it('should handle undefined type filter', () => {
const categories = {
- 'cat1': { name: 'Action', category_type: 'movie' },
- 'cat2': { name: 'Drama', category_type: 'series' }
+ cat1: { name: 'Action', category_type: 'movie' },
+ cat2: { name: 'Drama', category_type: 'series' },
};
const filters = {};
@@ -97,9 +103,9 @@ describe('VODsUtils', () => {
it('should filter out categories that do not match type', () => {
const categories = {
- 'cat1': { name: 'Action', category_type: 'movie' },
- 'cat2': { name: 'Drama', category_type: 'series' },
- 'cat3': { name: 'Comedy', category_type: 'movie' }
+ cat1: { name: 'Action', category_type: 'movie' },
+ cat2: { name: 'Drama', category_type: 'series' },
+ cat3: { name: 'Comedy', category_type: 'movie' },
};
const filters = { type: 'series' };
@@ -113,18 +119,14 @@ describe('VODsUtils', () => {
describe('filterCategoriesToEnabled', () => {
it('should return only categories with enabled m3u_accounts', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
- m3u_accounts: [
- { id: 1, enabled: true }
- ]
+ m3u_accounts: [{ id: 1, enabled: true }],
},
- 'cat2': {
+ cat2: {
name: 'Drama',
- m3u_accounts: [
- { id: 2, enabled: false }
- ]
- }
+ m3u_accounts: [{ id: 2, enabled: false }],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@@ -135,14 +137,14 @@ describe('VODsUtils', () => {
it('should include category if any m3u_account is enabled', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: false },
{ id: 2, enabled: true },
- { id: 3, enabled: false }
- ]
- }
+ { id: 3, enabled: false },
+ ],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@@ -152,13 +154,13 @@ describe('VODsUtils', () => {
it('should exclude category if all m3u_accounts are disabled', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: false },
- { id: 2, enabled: false }
- ]
- }
+ { id: 2, enabled: false },
+ ],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@@ -168,10 +170,10 @@ describe('VODsUtils', () => {
it('should exclude category with empty m3u_accounts array', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
- m3u_accounts: []
- }
+ m3u_accounts: [],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@@ -181,13 +183,11 @@ describe('VODsUtils', () => {
it('should preserve original category data', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
category_type: 'movie',
- m3u_accounts: [
- { id: 1, enabled: true }
- ]
- }
+ m3u_accounts: [{ id: 1, enabled: true }],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@@ -203,18 +203,18 @@ describe('VODsUtils', () => {
it('should filter multiple categories correctly', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
- m3u_accounts: [{ id: 1, enabled: true }]
+ m3u_accounts: [{ id: 1, enabled: true }],
},
- 'cat2': {
+ cat2: {
name: 'Drama',
- m3u_accounts: [{ id: 2, enabled: false }]
+ m3u_accounts: [{ id: 2, enabled: false }],
},
- 'cat3': {
+ cat3: {
name: 'Comedy',
- m3u_accounts: [{ id: 3, enabled: true }]
- }
+ m3u_accounts: [{ id: 3, enabled: true }],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@@ -227,10 +227,10 @@ describe('VODsUtils', () => {
it('should handle category with null m3u_accounts', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
- m3u_accounts: null
- }
+ m3u_accounts: null,
+ },
};
expect(() => {
@@ -240,13 +240,13 @@ describe('VODsUtils', () => {
it('should handle truthy enabled values', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
m3u_accounts: [
{ id: 1, enabled: 1 },
- { id: 2, enabled: false }
- ]
- }
+ { id: 2, enabled: false },
+ ],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
@@ -256,12 +256,10 @@ describe('VODsUtils', () => {
it('should only match strict true for enabled', () => {
const allCategories = {
- 'cat1': {
+ cat1: {
name: 'Action',
- m3u_accounts: [
- { id: 1, enabled: 'true' }
- ]
- }
+ m3u_accounts: [{ id: 1, enabled: 'true' }],
+ },
};
const result = VODsUtils.filterCategoriesToEnabled(allCategories);
From 8e7139f3af5e81d7b7ed9c4639978597927f0316 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Mon, 9 Feb 2026 17:27:52 -0600
Subject: [PATCH 124/125] Re-add note to uncomment the following lines for
nvidia.
---
docker/docker-compose.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 89585fc7..519b288a 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -65,6 +65,7 @@ services:
# - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API)
# NVIDIA GPU Support (requires NVIDIA Container Toolkit)
+ # Uncomment the following lines for NVIDIA GPU support
#deploy:
# resources:
# reservations:
From 7748142a76c6f83b2d4964467b2dc3060fcb8957 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Mon, 9 Feb 2026 17:35:02 -0600
Subject: [PATCH 125/125] changelog: Update changelog for Redis auth PR.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9de64782..f0be7d34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `useWarningsStore` tests for warning suppression functionality
- Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810)
- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen)
+- Redis authentication support for modular deployments: Added support for authentication when connecting to external Redis instances using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). REDIS_PASSWORD and REDIS_USER environment variables with URL encoding for special characters. (Closes #937) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name.
- Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing.
- Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown.