From 22f0a4078bafa671c9c564e02b128b4615ed1c1e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 09:22:02 -0500 Subject: [PATCH 01/65] Implement column resizing functionality in Channels Table, Streams Table and CustomTableHeader --- .../src/components/tables/ChannelsTable.jsx | 21 +++++++++++ .../tables/CustomTable/CustomTableHeader.jsx | 35 +++++++++++++++++++ .../components/tables/CustomTable/index.jsx | 3 ++ frontend/src/components/tables/table.css | 32 +++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 313d2b4b..d216d44a 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -306,6 +306,9 @@ const ChannelsTable = ({}) => { const [isBulkDelete, setIsBulkDelete] = useState(false); const [channelToDelete, setChannelToDelete] = useState(null); + // Column sizing state for resizable columns + const [columnSizing, setColumnSizing] = useLocalStorage('channels-table-column-sizing', {}); + // M3U and EPG URL configuration state const [m3uParams, setM3uParams] = useState({ cachedlogos: true, @@ -697,14 +700,17 @@ const ChannelsTable = ({}) => { { id: 'expand', size: 20, + enableResizing: false, }, { id: 'select', size: 30, + enableResizing: false, }, { id: 'enabled', size: 45, + enableResizing: false, cell: ({ row, table }) => { return ( { id: 'channel_number', accessorKey: 'channel_number', size: 40, + minSize: 30, + maxSize: 100, cell: ({ getValue }) => { const value = getValue(); // Format as integer if no decimal component @@ -739,6 +747,7 @@ const ChannelsTable = ({}) => { { id: 'name', accessorKey: 'name', + minSize: 100, cell: ({ getValue }) => ( { ); }, size: 120, + minSize: 80, + maxSize: 200, }, { id: 'channel_group', @@ -813,6 +824,8 @@ const ChannelsTable = ({}) => { ), size: 175, + minSize: 100, + maxSize: 300, }, { id: 'logo', @@ -821,6 +834,9 @@ const ChannelsTable = ({}) => { return row.logo_id; }, size: 75, + minSize: 50, + maxSize: 120, + enableResizing: false, header: '', cell: ({ getValue }) => { const logoId = getValue(); @@ -839,6 +855,7 @@ const ChannelsTable = ({}) => { { id: 'actions', size: tableSize == 'compact' ? 75 : 100, + enableResizing: false, header: '', cell: ({ row }) => ( { manualFiltering: true, enableRowSelection: true, onRowSelectionChange: onRowSelectionChange, + state: { + columnSizing, + }, + onColumnSizingChange: setColumnSizing, getExpandedRowHeight: (row) => { return 20 + 28 * row.original.streams.length; }, diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx index 1e32e258..17ceecd7 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx @@ -67,6 +67,7 @@ const CustomTableHeader = ({ ? header.getSize() : undefined, minWidth: 0, + position: 'relative', // ...(tableCellProps && tableCellProps({ cell: header })), }} > @@ -80,6 +81,40 @@ const CustomTableHeader = ({ > {renderHeaderCell(header)} + {header.column.getCanResize() && ( + { + e.target.style.opacity = '1'; + e.target.style.backgroundColor = '#6b7280'; + }} + onMouseLeave={(e) => { + if (!header.column.getIsResizing()) { + e.target.style.opacity = '0.5'; + e.target.style.backgroundColor = 'transparent'; + } + }} + /> + )} ); })} diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 3be2dc2f..3a1ab69b 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -92,8 +92,11 @@ const useTable = ({ state: { data: options.data, selectedTableIds, + columnSizing: options.state?.columnSizing || {}, }, getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(), + enableColumnResizing: true, + columnResizeMode: 'onChange', }); const selectedTableIdsSet = useMemo( diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css index 5392d1f8..15304103 100644 --- a/frontend/src/components/tables/table.css +++ b/frontend/src/components/tables/table.css @@ -180,3 +180,35 @@ html { -ms-user-select: text !important; cursor: text !important; } + +/* Column resize handle styles */ +.resizer { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 5px; + cursor: col-resize; + user-select: none; + touch-action: none; + background-color: transparent; + opacity: 0; + transition: all 0.2s ease; +} + +.resizer:hover { + opacity: 1 !important; + background-color: #6b7280 !important; +} + +.resizer.isResizing { + opacity: 1 !important; + background-color: #3b82f6 !important; + width: 3px; +} + +/* Show resize handle on header hover */ +.th:hover .resizer { + opacity: 0.5; + background-color: #6b7280; +} From 09ba42d36c57e13ac44b97e1660cfb9bfff61eda Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 10:08:46 -0500 Subject: [PATCH 02/65] Enhance column resizing functionality in ChannelsTable, CustomTableHeader, and CustomTableBody components --- .../src/components/tables/ChannelsTable.jsx | 16 +++++++++---- .../tables/CustomTable/CustomTableBody.jsx | 15 ++++++++---- .../tables/CustomTable/CustomTableHeader.jsx | 23 +++++++++++++------ .../components/tables/CustomTable/index.jsx | 7 +++--- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index d216d44a..bd200c67 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -307,7 +307,10 @@ const ChannelsTable = ({}) => { const [channelToDelete, setChannelToDelete] = useState(null); // Column sizing state for resizable columns - const [columnSizing, setColumnSizing] = useLocalStorage('channels-table-column-sizing', {}); + const [columnSizing, setColumnSizing] = useLocalStorage( + 'channels-table-column-sizing', + {} + ); // M3U and EPG URL configuration state const [m3uParams, setM3uParams] = useState({ @@ -724,7 +727,7 @@ const ChannelsTable = ({}) => { { id: 'channel_number', accessorKey: 'channel_number', - size: 40, + size: columnSizing.channel_number || 40, minSize: 30, maxSize: 100, cell: ({ getValue }) => { @@ -747,6 +750,7 @@ const ChannelsTable = ({}) => { { id: 'name', accessorKey: 'name', + size: columnSizing.name || 200, minSize: 100, cell: ({ getValue }) => ( { ); }, - size: 120, + size: columnSizing.epg || 120, minSize: 80, maxSize: 200, }, @@ -823,7 +827,7 @@ const ChannelsTable = ({}) => { {getValue()} ), - size: 175, + size: columnSizing.channel_group || 175, minSize: 100, maxSize: 300, }, @@ -870,7 +874,7 @@ const ChannelsTable = ({}) => { ), }, ], - [selectedProfileId, channelGroups, logos, theme] + [selectedProfileId, channelGroups, logos, theme, columnSizing] ); const renderHeaderCell = (header) => { @@ -972,6 +976,8 @@ const ChannelsTable = ({}) => { onRowSelectionChange: onRowSelectionChange, state: { columnSizing, + pagination, + sorting, }, onColumnSizingChange: setColumnSizing, getExpandedRowHeight: (row) => { diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index 8a271373..81f73455 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -106,11 +106,18 @@ const CustomTableBody = ({ className="td" key={`td-${cell.id}`} style={{ - flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0', + flex: cell.column.columnDef.size + ? `0 0 ${cell.column.getSize()}px` + : '1 1 0%', width: cell.column.columnDef.size - ? cell.column.getSize() - : undefined, - minWidth: 0, + ? `${cell.column.getSize()}px` + : 'auto', + maxWidth: cell.column.columnDef.size + ? `${cell.column.getSize()}px` + : 'none', + minWidth: cell.column.columnDef.minSize + ? `${cell.column.columnDef.minSize}px` + : '0px', ...(tableCellProps && tableCellProps({ cell })), }} > diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx index 17ceecd7..06ae5aba 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx @@ -62,11 +62,18 @@ const CustomTableHeader = ({ className="th" key={header.id} style={{ - flex: header.column.columnDef.size ? '0 0 auto' : '1 1 0', + flex: header.column.columnDef.size + ? `0 0 ${header.getSize()}px` + : '1 1 0%', width: header.column.columnDef.size - ? header.getSize() - : undefined, - minWidth: 0, + ? `${header.getSize()}px` + : 'auto', + maxWidth: header.column.columnDef.size + ? `${header.getSize()}px` + : 'none', + minWidth: header.column.columnDef.minSize + ? `${header.column.columnDef.minSize}px` + : '0px', position: 'relative', // ...(tableCellProps && tableCellProps({ cell: header })), }} @@ -77,12 +84,13 @@ const CustomTableHeader = ({ ...(header.column.columnDef.style && header.column.columnDef.style), height: '100%', + paddingRight: header.column.getCanResize() ? '8px' : '0px', // Add padding for resize handle }} > {renderHeaderCell(header)} {header.column.getCanResize() && ( - { e.target.style.opacity = '1'; diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 3a1ab69b..ecd2e04d 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -85,15 +85,16 @@ const useTable = ({ const table = useReactTable({ defaultColumn: { - size: undefined, minSize: 0, + maxSize: Number.MAX_SAFE_INTEGER, + size: 150, }, ...options, state: { - data: options.data, + ...options.state, selectedTableIds, - columnSizing: options.state?.columnSizing || {}, }, + onStateChange: options.onStateChange, getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(), enableColumnResizing: true, columnResizeMode: 'onChange', From 51352ba734f21f113891a436da953d448a51d806 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 10:24:23 -0500 Subject: [PATCH 03/65] Save stream table header sizing to local storage. --- .../src/components/tables/CustomTable/index.jsx | 4 ++++ frontend/src/components/tables/StreamsTable.jsx | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index ecd2e04d..ec5182c9 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -18,6 +18,8 @@ const useTable = ({ onRowSelectionChange = null, getExpandedRowHeight = null, state = [], + columnSizing, + setColumnSizing, ...options }) => { const [selectedTableIds, setSelectedTableIds] = useState([]); @@ -93,8 +95,10 @@ const useTable = ({ state: { ...options.state, selectedTableIds, + ...(columnSizing && { columnSizing }), }, onStateChange: options.onStateChange, + ...(setColumnSizing && { onColumnSizingChange: setColumnSizing }), 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 95d44bee..c5159e7e 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -212,6 +212,10 @@ const StreamsTable = ({}) => { channel_group: '', m3u_account: '', }); + const [columnSizing, setColumnSizing] = useLocalStorage( + 'streams-table-column-sizing', + {} + ); const debouncedFilters = useDebounce(filters, 500, () => { // Reset to first page whenever filters change to avoid "Invalid page" errors setPagination((prev) => ({ @@ -256,15 +260,16 @@ const StreamsTable = ({}) => { () => [ { id: 'actions', - size: tableSize == 'compact' ? 60 : 80, + size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80), }, { id: 'select', - size: 30, + size: columnSizing.select || 30, }, { header: 'Name', accessorKey: 'name', + size: columnSizing.name || 200, cell: ({ getValue }) => ( { channelGroups[row.channel_group] ? channelGroups[row.channel_group].name : '', + size: columnSizing.group || 150, cell: ({ getValue }) => ( { }, { id: 'm3u', - size: 150, + size: columnSizing.m3u || 150, accessorFn: (row) => playlists.find((playlist) => playlist.id === row.m3u_account)?.name, cell: ({ getValue }) => ( @@ -315,7 +321,7 @@ const StreamsTable = ({}) => { ), }, ], - [channelGroups, playlists] + [channelGroups, playlists, columnSizing, tableSize] ); /** @@ -652,6 +658,8 @@ const StreamsTable = ({}) => { filters, pagination, sorting, + columnSizing, + setColumnSizing, onRowSelectionChange: onRowSelectionChange, manualPagination: true, manualSorting: true, From a9ca6502ed3cd308b70d4caef40616dae2fad0d7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 10:31:35 -0500 Subject: [PATCH 04/65] Better calculation for horizontal scroll bar in channel and stream tables. --- frontend/src/components/tables/ChannelsTable.jsx | 2 +- frontend/src/components/tables/StreamsTable.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index bd200c67..110c332f 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -1328,7 +1328,7 @@ const ChannelsTable = ({}) => { style={{ flex: 1, overflowY: 'auto', - overflowX: 'hidden', + overflowX: 'auto', border: 'solid 1px rgb(68,68,68)', borderRadius: 'var(--mantine-radius-default)', }} diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index c5159e7e..a0a95811 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -863,7 +863,7 @@ const StreamsTable = ({}) => { style={{ flex: 1, overflowY: 'auto', - overflowX: 'hidden', + overflowX: 'auto', border: 'solid 1px rgb(68,68,68)', borderRadius: 'var(--mantine-radius-default)', }} From 448ef16987d2d674635dd7f864b7acd82114f240 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 10:56:17 -0500 Subject: [PATCH 05/65] Expand header width to fill container. --- .../tables/CustomTable/CustomTable.jsx | 19 ++++++++++++++- .../tables/CustomTable/CustomTableBody.jsx | 11 +++++++++ .../tables/CustomTable/CustomTableHeader.jsx | 23 +++++++++++++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index 7d811b6f..ee665188 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -1,6 +1,6 @@ import { Box, Flex } from '@mantine/core'; import CustomTableHeader from './CustomTableHeader'; -import { useCallback, useState, useRef } from 'react'; +import { useCallback, useState, useRef, useMemo } from 'react'; import { flexRender } from '@tanstack/react-table'; import table from '../../../helpers/table'; import CustomTableBody from './CustomTableBody'; @@ -9,11 +9,28 @@ import useLocalStorage from '../../../hooks/useLocalStorage'; const CustomTable = ({ table }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); + // Get column sizing state for dependency tracking + const columnSizing = table.getState().columnSizing; + + // Calculate minimum table width reactively based on column sizes + const minTableWidth = useMemo(() => { + const headerGroups = table.getHeaderGroups(); + if (!headerGroups || headerGroups.length === 0) return 0; + + const width = + headerGroups[0]?.headers.reduce((total, header) => { + return total + header.getSize(); + }, 0) || 0; + + return width; + }, [table, columnSizing]); + return ( { + if (rows.length === 0) return 0; + + return rows[0].getVisibleCells().reduce((total, cell) => { + return total + cell.column.getSize(); + }, 0); + }, [rows]); + const renderTableBodyContents = () => { const virtualized = false; @@ -94,6 +104,7 @@ const CustomTableBody = ({ style={{ display: 'flex', width: '100%', + minWidth: `${totalWidth}px`, ...(row.getIsSelected() && { backgroundColor: '#163632', }), diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx index 06ae5aba..4aafccaa 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx @@ -1,6 +1,6 @@ import { Box, Center, Checkbox, Flex } from '@mantine/core'; import { flexRender } from '@tanstack/react-table'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; const CustomTableHeader = ({ getHeaderGroups, @@ -40,6 +40,21 @@ const CustomTableHeader = ({ } }; + // Get header groups for dependency tracking + const headerGroups = getHeaderGroups(); + + // Calculate total width of all columns reactively + const totalWidth = useMemo(() => { + if (!headerGroups || headerGroups.length === 0) return 0; + + const width = + headerGroups[0]?.headers.reduce((total, header) => { + return total + header.getSize(); + }, 0) || 0; + + return width; + }, [headerGroups]); + return ( {headerGroup.headers.map((header) => { return ( From fa41b8eb2301c234f71b9c892993d53c136572bb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 11:12:27 -0500 Subject: [PATCH 06/65] Save divider location in local storage. --- frontend/src/pages/Channels.jsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index c265e3ef..7663276d 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -5,9 +5,22 @@ import { Box } from '@mantine/core'; import { Allotment } from 'allotment'; import { USER_LEVELS } from '../constants'; import useAuthStore from '../store/auth'; +import useLocalStorage from '../hooks/useLocalStorage'; const ChannelsPage = () => { const authUser = useAuthStore((s) => s.user); + const [allotmentSizes, setAllotmentSizes] = useLocalStorage( + 'channels-splitter-sizes', + [50, 50] + ); + + const handleSplitChange = (sizes) => { + setAllotmentSizes(sizes); + }; + + const handleResize = (sizes) => { + setAllotmentSizes(sizes); + }; if (!authUser.id) { return <>; @@ -30,10 +43,12 @@ const ChannelsPage = () => { }} >
From 900ce7320014e7b218931d073e2d9d3c5f4e6926 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 16:38:18 -0500 Subject: [PATCH 07/65] Better sizing for epg and m3u tables. Rearranged m3u table slightly. --- .../tables/CustomTable/CustomTableBody.jsx | 37 +++++++++++-------- .../tables/CustomTable/CustomTableHeader.jsx | 34 +++++++++-------- frontend/src/components/tables/EPGsTable.jsx | 4 +- frontend/src/components/tables/M3UsTable.jsx | 16 ++++---- frontend/src/components/tables/table.css | 2 +- 5 files changed, 52 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index 50ff0581..9e43c57a 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -24,12 +24,16 @@ const CustomTableBody = ({ const rows = getRowModel().rows; - // Calculate total width of all columns reactively - const totalWidth = useMemo(() => { + // Calculate minimum width based only on fixed-size columns + const minTableWidth = useMemo(() => { if (rows.length === 0) return 0; return rows[0].getVisibleCells().reduce((total, cell) => { - return total + cell.column.getSize(); + // Only count columns with fixed sizes, flexible columns will expand + const columnSize = cell.column.columnDef.size + ? cell.column.getSize() + : cell.column.columnDef.minSize || 150; // Default min for flexible columns + return total + columnSize; }, 0); }, [rows]); @@ -104,7 +108,7 @@ const CustomTableBody = ({ style={{ display: 'flex', width: '100%', - minWidth: `${totalWidth}px`, + minWidth: '100%', // Force full width ...(row.getIsSelected() && { backgroundColor: '#163632', }), @@ -112,23 +116,24 @@ const CustomTableBody = ({ }} > {row.getVisibleCells().map((cell) => { + const hasFixedSize = cell.column.columnDef.size; + const isFlexible = !hasFixedSize; + return ( diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx index 4aafccaa..92643fc9 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx @@ -43,13 +43,17 @@ const CustomTableHeader = ({ // Get header groups for dependency tracking const headerGroups = getHeaderGroups(); - // Calculate total width of all columns reactively - const totalWidth = useMemo(() => { + // Calculate minimum width based only on fixed-size columns + const minTableWidth = useMemo(() => { if (!headerGroups || headerGroups.length === 0) return 0; const width = headerGroups[0]?.headers.reduce((total, header) => { - return total + header.getSize(); + // Only count columns with fixed sizes, flexible columns will expand + const columnSize = header.column.columnDef.size + ? header.getSize() + : header.column.columnDef.minSize || 150; // Default min for flexible columns + return total + columnSize; }, 0) || 0; return width; @@ -72,7 +76,7 @@ const CustomTableHeader = ({ style={{ display: 'flex', width: '100%', - minWidth: `${totalWidth}px`, + minWidth: '100%', // Force full width }} > {headerGroup.headers.map((header) => { @@ -81,18 +85,16 @@ const CustomTableHeader = ({ className="th" key={header.id} style={{ - flex: header.column.columnDef.size - ? `0 0 ${header.getSize()}px` - : '1 1 0%', - width: header.column.columnDef.size - ? `${header.getSize()}px` - : 'auto', - maxWidth: header.column.columnDef.size - ? `${header.getSize()}px` - : 'none', - minWidth: header.column.columnDef.minSize - ? `${header.column.columnDef.minSize}px` - : '0px', + ...(header.column.columnDef.grow + ? { + flex: '1 1 0%', + minWidth: 0, + } + : { + flex: `0 0 ${header.getSize ? header.getSize() : 150}px`, + width: `${header.getSize ? header.getSize() : 150}px`, + maxWidth: `${header.getSize ? header.getSize() : 150}px`, + }), position: 'relative', // ...(tableCellProps && tableCellProps({ cell: header })), }} diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 9c40a14a..4ea66b30 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -195,6 +195,7 @@ const EPGsTable = () => { header: 'URL / API Key / File Path', accessorKey: 'url', enableSorting: false, + minSize: 250, cell: ({ cell, row }) => { const value = cell.getValue() || @@ -220,7 +221,7 @@ const EPGsTable = () => { { header: 'Status', accessorKey: 'status', - size: 150, + size: 100, cell: ({ row }) => { const data = row.original; @@ -236,6 +237,7 @@ const EPGsTable = () => { header: 'Status Message', accessorKey: 'last_message', enableSorting: false, + grow: true, cell: ({ row }) => { const data = row.original; diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index d72fbeb4..d31da0e6 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -443,6 +443,7 @@ const M3UTable = () => { { header: 'URL / File', accessorKey: 'server_url', + size: 250, cell: ({ cell, row }) => { const value = cell.getValue() || row.original.file_path || ''; return ( @@ -461,16 +462,10 @@ const M3UTable = () => { ); }, }, - { - header: 'Max Streams', - accessorKey: 'max_streams', - sortable: true, - size: 150, - }, { header: 'Status', accessorKey: 'status', - size: 150, + size: 100, cell: ({ cell }) => { const value = cell.getValue(); if (!value) return null; @@ -486,6 +481,7 @@ const M3UTable = () => { { header: 'Status Message', accessorKey: 'last_message', + grow: true, cell: ({ cell, row }) => { const value = cell.getValue(); const data = row.original; @@ -569,6 +565,12 @@ const M3UTable = () => { ); }, }, + { + header: 'Max Streams', + accessorKey: 'max_streams', + sortable: true, + size: 125, + }, { header: 'Updated', accessorKey: 'updated_at', diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css index 15304103..30fd033a 100644 --- a/frontend/src/components/tables/table.css +++ b/frontend/src/components/tables/table.css @@ -8,8 +8,8 @@ html { } .divTable { + width: 100%; /* border: 1px solid lightgray; */ - /* width: fit-content; */ /* display: flex; flex-direction: column; */ } From 65e947c95b9c235450c493cf6bc7287b87df5d41 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 16:40:30 -0500 Subject: [PATCH 08/65] Rename type columns. --- frontend/src/components/tables/EPGsTable.jsx | 4 ++-- frontend/src/components/tables/M3UsTable.jsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 4ea66b30..f16d404e 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -187,9 +187,9 @@ const EPGsTable = () => { size: 200, }, { - header: 'Source Type', + header: 'Type', accessorKey: 'source_type', - size: 150, + size: 100, }, { header: 'URL / API Key / File Path', diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index d31da0e6..fcbe1076 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -431,10 +431,10 @@ const M3UTable = () => { sortable: true, }, { - header: 'Account Type', + header: 'Type', accessorKey: 'account_type', sortable: true, - size: 150, + size: 100, cell: ({ cell }) => { const value = cell.getValue(); return value === 'XC' ? 'XC' : 'M3U'; From 5e2794a62ed7eb04cec5d10d99a1290d85935379 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 12 Sep 2025 17:00:59 -0500 Subject: [PATCH 09/65] Fix sizing on logo table and user table. --- frontend/src/components/tables/EPGsTable.jsx | 1 + frontend/src/components/tables/LogosTable.jsx | 3 ++- frontend/src/components/tables/M3UsTable.jsx | 1 + frontend/src/components/tables/UsersTable.jsx | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index f16d404e..7cac7d7d 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -237,6 +237,7 @@ const EPGsTable = () => { header: 'Status Message', accessorKey: 'last_message', enableSorting: false, + minSize: 250, grow: true, cell: ({ row }) => { const data = row.original; diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 46c29e4c..0c4f32b4 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -394,7 +394,7 @@ const LogosTable = () => { { header: 'Name', accessorKey: 'name', - size: 200, + size: 250, cell: ({ getValue }) => ( {getValue()} @@ -479,6 +479,7 @@ const LogosTable = () => { { header: 'URL', accessorKey: 'url', + grow: true, cell: ({ getValue }) => ( { header: 'Status Message', accessorKey: 'last_message', grow: true, + minSize: 250, cell: ({ cell, row }) => { const value = cell.getValue(); const data = row.original; diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 76d4d729..3e9e4971 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -184,6 +184,7 @@ const UsersTable = () => { { header: 'Email', accessorKey: 'email', + grow: true, cell: ({ getValue }) => ( Date: Fri, 12 Sep 2025 17:04:52 -0500 Subject: [PATCH 10/65] Better sizing of stream profiles table --- frontend/src/components/tables/StreamProfilesTable.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx index 76be0462..da2acd30 100644 --- a/frontend/src/components/tables/StreamProfilesTable.jsx +++ b/frontend/src/components/tables/StreamProfilesTable.jsx @@ -72,7 +72,7 @@ const StreamProfiles = () => { { header: 'Name', accessorKey: 'name', - size: 150, + size: 175, cell: ({ cell }) => (
{ { header: 'Command', accessorKey: 'command', - size: 150, + size: 100, cell: ({ cell }) => (
{ { header: 'Parameters', accessorKey: 'parameters', - // size: 200, + grow: true, cell: ({ cell }) => (
Date: Fri, 12 Sep 2025 17:10:49 -0500 Subject: [PATCH 11/65] Better sizing on user-agents table. --- frontend/src/components/tables/UserAgentsTable.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx index 265ed125..48573926 100644 --- a/frontend/src/components/tables/UserAgentsTable.jsx +++ b/frontend/src/components/tables/UserAgentsTable.jsx @@ -61,11 +61,13 @@ const UserAgentsTable = () => { { header: 'Name', accessorKey: 'name', + size: 125, }, { header: 'User-Agent', accessorKey: 'user_agent', enableSorting: false, + grow: true, cell: ({ cell }) => (
Date: Sat, 13 Sep 2025 16:39:11 -0500 Subject: [PATCH 12/65] Remove unnecessary destructuring in StreamsTable component definition --- frontend/src/components/tables/StreamsTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index a0a95811..44b6d91c 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -178,7 +178,7 @@ const StreamRowActions = ({ ); }; -const StreamsTable = ({}) => { +const StreamsTable = () => { const theme = useMantineTheme(); /** From 0d659f4435e74f01040b256778ff8f6c0048a319 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 13 Sep 2025 16:55:50 -0500 Subject: [PATCH 13/65] Set name to expand/contract automatically when growing/shrinking the stream and channel table. --- frontend/src/components/tables/ChannelsTable.jsx | 1 + frontend/src/components/tables/StreamsTable.jsx | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 110c332f..75719f67 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -752,6 +752,7 @@ const ChannelsTable = ({}) => { accessorKey: 'name', size: columnSizing.name || 200, minSize: 100, + grow: true, cell: ({ getValue }) => ( { { header: 'Name', accessorKey: 'name', + grow: true, size: columnSizing.name || 200, cell: ({ getValue }) => ( Date: Sat, 13 Sep 2025 17:02:40 -0500 Subject: [PATCH 14/65] Fix bug where a channel created could use streams from another channel. The form wasn't being fully cleared. --- frontend/src/components/forms/Channel.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 574c5d3a..ae466a57 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -242,6 +242,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { formik.resetForm(); setTvgFilter(''); setLogoFilter(''); + setChannelStreams([]); // Ensure streams are cleared when adding a new channel } }, [channel, tvgsById, channelGroups]); From 92a26caf03c46b7b3b9f003a253daf0486f8c92d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 13 Sep 2025 17:09:52 -0500 Subject: [PATCH 15/65] Fix bug where clicking add channel with a channel selected opened the edit form. --- frontend/src/components/tables/ChannelsTable.jsx | 8 +++++++- .../tables/ChannelsTable/ChannelTableHeader.jsx | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 75719f67..76484980 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -433,7 +433,13 @@ const ChannelsTable = ({}) => { })); }; - const editChannel = async (ch = null) => { + const editChannel = async (ch = null, opts = {}) => { + // If forceAdd is set, always open a blank form + if (opts.forceAdd) { + setChannel(null); + setChannelModalOpen(true); + return; + } // Use table's selected state instead of store state to avoid stale selections const currentSelection = table ? table.selectedTableIds : []; console.log('editChannel called with:', { diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index c2d2cb51..54fa2f8d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -259,7 +259,7 @@ const ChannelTableHeader = ({ leftSection={} variant="light" size="xs" - onClick={() => editChannel()} + onClick={() => editChannel(null, { forceAdd: true })} disabled={authUser.user_level != USER_LEVELS.ADMIN} p={5} color={theme.tailwind.green[5]} From 40cbb745cd873bc8773e2eea1b7c772875b97401 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 13 Sep 2025 17:15:59 -0500 Subject: [PATCH 16/65] Fix bug where not setting a channel number would cause an error when creating a channel. --- frontend/src/api.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index 110d6b75..74e138fa 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -323,14 +323,27 @@ export default class API { static async addChannel(channel) { try { let body = null; + // Prepare a copy to safely mutate + const channelData = { ...channel }; + + // Remove channel_number if empty, null, or not a valid number + if ( + channelData.channel_number === '' || + channelData.channel_number === null || + channelData.channel_number === undefined || + (typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '') + ) { + delete channelData.channel_number; + } + if (channel.logo_file) { // Must send FormData for file upload body = new FormData(); - for (const prop in channel) { - body.append(prop, channel[prop]); + for (const prop in channelData) { + body.append(prop, channelData[prop]); } } else { - body = { ...channel }; + body = { ...channelData }; delete body.logo_file; } From fe2edf16b0c8ff29c9f26bf224832060ac3406af Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 13 Sep 2025 18:17:09 -0500 Subject: [PATCH 17/65] Better rendering of horizontal scroll bar. --- frontend/src/components/tables/CustomTable/CustomTable.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index ee665188..90407d49 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -30,9 +30,11 @@ const CustomTable = ({ table }) => { className={`divTable table-striped table-size-${tableSize}`} style={{ width: '100%', + maxWidth: '100%', minWidth: `${minTableWidth}px`, display: 'flex', flexDirection: 'column', + overflow: 'hidden', }} > Date: Sat, 13 Sep 2025 19:10:47 -0500 Subject: [PATCH 18/65] Add POST endpoint to retrieve streams by IDs and enhance channel creation process --- apps/channels/api_views.py | 29 +++++ frontend/src/api.js | 27 +++- .../src/components/tables/StreamsTable.jsx | 120 +++++++++++++++--- 3 files changed, 149 insertions(+), 27 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 0cdd716a..efc9073a 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -178,6 +178,35 @@ class StreamViewSet(viewsets.ModelViewSet): # Return the response with the list of unique group names return Response(list(group_names)) + @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" + ), + }, + ), + responses={200: StreamSerializer(many=True)}, + ) + @action(detail=False, methods=["post"], url_path="by-ids") + def get_by_ids(self, request, *args, **kwargs): + ids = request.data.get("ids", []) + if not isinstance(ids, list): + return Response( + {"error": "ids must be a list of integers"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + streams = Stream.objects.filter(id__in=ids) + serializer = self.get_serializer(streams, many=True) + return Response(serializer.data) + # ───────────────────────────────────────────────────────── # 2) Channel Group Management (CRUD) diff --git a/frontend/src/api.js b/frontend/src/api.js index 74e138fa..b3cf5205 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2036,15 +2036,28 @@ export default class API { static async getStreamsByIds(ids) { try { - const params = new URLSearchParams(); - params.append('ids', ids.join(',')); - const response = await request( - `${host}/api/channels/streams/?${params.toString()}` - ); - - return response.results || response; + // Use POST for large ID lists to avoid URL length limitations + if (ids.length > 50) { + const response = await request( + `${host}/api/channels/streams/by-ids/`, + { + method: 'POST', + body: { ids }, + } + ); + return response; + } else { + // Use GET for small ID lists for backward compatibility + const params = new URLSearchParams(); + params.append('ids', ids.join(',')); + const response = await request( + `${host}/api/channels/streams/?${params.toString()}` + ); + return response.results || response; + } } catch (e) { errorNotification('Failed to retrieve streams by IDs', e); + throw e; // Re-throw to allow proper error handling in calling code } } diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index fa45b8ed..a880f39b 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -196,6 +196,8 @@ const StreamsTable = () => { const [isLoading, setIsLoading] = useState(true); const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]); const [selectedStreamIds, setSelectedStreamIds] = useState([]); + const [isCreatingChannels, setIsCreatingChannels] = useState(false); + const [creationProgress, setCreationProgress] = useState(''); // const [allRowsSelected, setAllRowsSelected] = useState(false); // Add local storage for page size @@ -421,30 +423,82 @@ const StreamsTable = () => { // Bulk creation: create channels from selected streams in one API call const createChannelsFromStreams = async () => { setIsLoading(true); + setIsCreatingChannels(true); + setCreationProgress('Preparing channel creation...'); + try { const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId; - // Try to fetch the actual stream data for selected streams - let streamsData = []; - try { - streamsData = await API.getStreamsByIds(selectedStreamIds); - } catch (error) { - console.warn('Could not fetch stream details, using IDs only:', error); + // For very large selections (>1000), process in smaller batches + const BATCH_SIZE = 1000; + const shouldBatch = selectedStreamIds.length > BATCH_SIZE; + + if (shouldBatch) { + console.log(`Processing ${selectedStreamIds.length} streams in batches of ${BATCH_SIZE}`); + + for (let i = 0; i < selectedStreamIds.length; i += BATCH_SIZE) { + const batchIds = selectedStreamIds.slice(i, i + BATCH_SIZE); + const batchNumber = Math.floor(i / BATCH_SIZE) + 1; + const totalBatches = Math.ceil(selectedStreamIds.length / BATCH_SIZE); + + setCreationProgress(`Processing batch ${batchNumber}/${totalBatches} (${batchIds.length} streams)...`); + console.log(`Processing batch ${batchNumber}/${totalBatches}`); + + // Try to fetch the actual stream data for this batch + let streamsData = []; + try { + streamsData = await API.getStreamsByIds(batchIds); + console.log(`Successfully fetched ${streamsData.length} streams for batch`); + } catch (error) { + console.warn('Could not fetch stream details for batch, using IDs only:', error); + } + + const streamData = batchIds.map((streamId) => { + const stream = streamsData.find((s) => s.id === streamId); + return { + stream_id: streamId, + name: stream?.name || `Stream ${streamId}`, + ...(selectedChannelProfileId !== '0' && { + channel_profile_ids: selectedChannelProfileId, + }), + }; + }); + + await API.createChannelsFromStreams(streamData); + } + } else { + // Process all at once for smaller selections + setCreationProgress(`Fetching stream data for ${selectedStreamIds.length} streams...`); + + let streamsData = []; + try { + streamsData = await API.getStreamsByIds(selectedStreamIds); + console.log(`Successfully fetched ${streamsData.length} streams`); + } catch (error) { + console.warn('Could not fetch stream details, using IDs only:', error); + // If fetching streams fails, we'll create channels with fallback names + // This happens when there are too many streams or network issues + } + + setCreationProgress(`Creating ${selectedStreamIds.length} channels...`); + + const streamData = selectedStreamIds.map((streamId) => { + const stream = streamsData.find((s) => s.id === streamId); + return { + stream_id: streamId, + name: stream?.name || `Stream ${streamId}`, + ...(selectedChannelProfileId !== '0' && { + channel_profile_ids: selectedChannelProfileId, + }), + }; + }); + + console.log(`Creating ${streamData.length} channels from selected streams`); + await API.createChannelsFromStreams(streamData); } - const streamData = selectedStreamIds.map((streamId) => { - const stream = streamsData.find((s) => s.id === streamId); - return { - stream_id: streamId, - name: stream?.name || `Stream ${streamId}`, - ...(selectedChannelProfileId !== '0' && { - channel_profile_ids: selectedChannelProfileId, - }), - }; - }); - - await API.createChannelsFromStreams(streamData); + setCreationProgress('Refreshing channels...'); await API.requeryChannels(); // Refresh channel profiles to update the membership information @@ -455,10 +509,16 @@ const StreamsTable = () => { // Clear selection and refresh data setSelectedStreamIds([]); await fetchData(); + + setCreationProgress(''); } catch (error) { console.error('Error creating channels from streams:', error); + setCreationProgress('Error occurred during channel creation'); } finally { setIsLoading(false); + setIsCreatingChannels(false); + // Clear progress after a short delay to let user see completion/error + setTimeout(() => setCreationProgress(''), 3000); } }; @@ -775,9 +835,13 @@ const StreamsTable = () => { size="xs" onClick={createChannelsFromStreams} p={5} - disabled={selectedStreamIds.length == 0} + disabled={selectedStreamIds.length == 0 || isCreatingChannels} + loading={isCreatingChannels} > - Create Channels + {isCreatingChannels + ? (creationProgress || 'Creating Channels...') + : `Create Channels (${selectedStreamIds.length})` + } + + + + ); }; From 58a3da386a0081756c9259cd1435ad8e77bc5d9d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 13:24:11 -0500 Subject: [PATCH 27/65] Allow single channel creation from stream to specify channel number --- apps/channels/api_views.py | 61 +++--- .../src/components/tables/StreamsTable.jsx | 174 ++++++++++++++++-- 2 files changed, 190 insertions(+), 45 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 2cbde99b..43f0eca6 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -588,40 +588,47 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_group = stream.channel_group name = request.data.get("name") + + 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 {} - - channel_number = None - 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 = request.data.get("channel_number") if channel_number is None: - provided_number = request.data.get("channel_number") - if provided_number is None: - channel_number = Channel.get_next_available_channel_number() - else: - try: - channel_number = float(provided_number) - except ValueError: - return Response( - {"error": "channel_number must be an integer."}, - status=status.HTTP_400_BAD_REQUEST, - ) - # If the provided number is already used, return an error. - if Channel.objects.filter(channel_number=channel_number).exists(): - return Response( - { - "error": f"Channel number {channel_number} is already in use. Please choose a different number." - }, - status=status.HTTP_400_BAD_REQUEST, - ) + # 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"]) + elif channel_number == 0: + # Special case: 0 means ignore provider numbers and auto-assign + channel_number = None + + if channel_number is None: + # Still None, auto-assign the next available channel number + channel_number = Channel.get_next_available_channel_number() + + + try: + channel_number = float(channel_number) + except ValueError: + return Response( + {"error": "channel_number must be an integer."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # If the provided number is already used, return an error. + if Channel.objects.filter(channel_number=channel_number).exists(): + return Response( + { + "error": f"Channel number {channel_number} is already in use. Please choose a different number." + }, + status=status.HTTP_400_BAD_REQUEST, + ) # Get the tvc_guide_stationid from custom properties if it exists tvc_guide_stationid = None if "tvc-guide-stationid" in stream_custom_props: diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 0214c7cd..ef944bbd 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -59,6 +59,7 @@ const StreamRowActions = ({ deleteStream, handleWatchStream, selectedChannelIds, + createChannelFromStream, }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); const channelSelectionStreams = useChannelsTableStore( @@ -67,23 +68,6 @@ const StreamRowActions = ({ ); const fetchLogos = useLogosStore((s) => s.fetchLogos); - const createChannelFromStream = async () => { - const selectedChannelProfileId = - useChannelsStore.getState().selectedProfileId; - - await API.createChannelFromStream({ - name: row.original.name, - channel_number: null, - stream_id: row.original.id, - // Only pass channel_profile_ids if a specific profile is selected (not "All") - ...(selectedChannelProfileId !== '0' && { - channel_profile_ids: selectedChannelProfileId, - }), - }); - await API.requeryChannels(); - fetchLogos(); - }; - const addStreamToChannel = async () => { await API.updateChannel({ id: selectedChannelIds[0], @@ -145,7 +129,7 @@ const StreamRowActions = ({ size={iconSize} color={theme.tailwind.green[5]} variant="transparent" - onClick={createChannelFromStream} + onClick={() => createChannelFromStream(row.original)} > @@ -209,6 +193,13 @@ const StreamsTable = () => { const [customStartNumber, setCustomStartNumber] = useState(1); const [rememberChoice, setRememberChoice] = useState(false); + // Single channel numbering modal state + const [singleChannelModalOpen, setSingleChannelModalOpen] = useState(false); + const [singleChannelMode, setSingleChannelMode] = useState('provider'); // 'provider', 'auto', or 'specific' + const [specificChannelNumber, setSpecificChannelNumber] = useState(1); + const [rememberSingleChoice, setRememberSingleChoice] = useState(false); + const [currentStreamForChannel, setCurrentStreamForChannel] = useState(null); + // const [allRowsSelected, setAllRowsSelected] = useState(false); // Add local storage for page size @@ -534,6 +525,80 @@ const StreamsTable = () => { fetchData(); }; + // Single channel creation functions + const createChannelFromStream = async (stream) => { + // Check if user has suppressed the single channel numbering dialog + const actionKey = 'single-channel-numbering-choice'; + if (isWarningSuppressed(actionKey)) { + // Use the remembered settings or default to 'provider' mode + const savedMode = + localStorage.getItem('single-channel-numbering-mode') || 'provider'; + const savedChannelNumber = + localStorage.getItem('single-channel-numbering-specific') || '1'; + + const channelNumberValue = + savedMode === 'provider' + ? null + : savedMode === 'auto' + ? 0 + : Number(savedChannelNumber); + + await executeSingleChannelCreation(stream, channelNumberValue); + } else { + // Show the modal to let user choose + setCurrentStreamForChannel(stream); + setSingleChannelModalOpen(true); + } + }; + + // Separate function to actually execute single channel creation + const executeSingleChannelCreation = async (stream, channelNumber = null) => { + const selectedChannelProfileId = + useChannelsStore.getState().selectedProfileId; + + await API.createChannelFromStream({ + name: stream.name, + channel_number: channelNumber, + stream_id: stream.id, + // Only pass channel_profile_ids if a specific profile is selected (not "All") + ...(selectedChannelProfileId !== '0' && { + channel_profile_ids: selectedChannelProfileId, + }), + }); + await API.requeryChannels(); + const fetchLogos = useChannelsStore.getState().fetchLogos; + fetchLogos(); + }; + + // Handle confirming the single channel numbering modal + const handleSingleChannelNumberingConfirm = async () => { + // Save the choice if user wants to remember it + if (rememberSingleChoice) { + suppressWarning('single-channel-numbering-choice'); + localStorage.setItem('single-channel-numbering-mode', singleChannelMode); + if (singleChannelMode === 'specific') { + localStorage.setItem( + 'single-channel-numbering-specific', + specificChannelNumber.toString() + ); + } + } + + // Convert mode to API value + const channelNumberValue = + singleChannelMode === 'provider' + ? null + : singleChannelMode === 'auto' + ? 0 + : Number(specificChannelNumber); + + setSingleChannelModalOpen(false); + await executeSingleChannelCreation( + currentStreamForChannel, + channelNumberValue + ); + }; + const addStreamsToChannel = async () => { await API.updateChannel({ id: selectedChannelIds[0], @@ -689,6 +754,7 @@ const StreamsTable = () => { deleteStream={deleteStream} handleWatchStream={handleWatchStream} selectedChannelIds={selectedChannelIds} + createChannelFromStream={createChannelFromStream} /> ); } @@ -1037,6 +1103,78 @@ const StreamsTable = () => { + + {/* Single Channel Numbering Modal */} + setSingleChannelModalOpen(false)} + title="Channel Number Assignment" + size="md" + centered + > + + + Choose how to assign the channel number for " + {currentStreamForChannel?.name}": + + + + + + + + + + + {singleChannelMode === 'specific' && ( + + )} + + + setRememberSingleChoice(event.currentTarget.checked) + } + label="Remember this choice and don't ask again" + /> + + + + + + + ); }; From 6c1dbff91cb2e52084848b32869b1d45656f0f11 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 13:27:31 -0500 Subject: [PATCH 28/65] Send websocket when channel is created. --- apps/channels/api_views.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 43f0eca6..fb1037a4 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -703,6 +703,16 @@ class ChannelViewSet(viewsets.ModelViewSet): for profile in profiles ]) + # Send WebSocket notification for single channel creation + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + 'type': 'channels_created', + 'count': 1, + 'channel_id': channel.id, + 'channel_name': channel.name, + 'channel_number': channel.channel_number + }) + return Response(serializer.data, status=status.HTTP_201_CREATED) @swagger_auto_schema( From a1d35a8dad5cec2891b822131d75bb578fb9ba5f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 13:34:03 -0500 Subject: [PATCH 29/65] Additional check if channel number is in use before creating. --- apps/channels/api_views.py | 7 +------ frontend/src/WebSocket.jsx | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index fb1037a4..92755252 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -623,12 +623,7 @@ class ChannelViewSet(viewsets.ModelViewSet): ) # If the provided number is already used, return an error. if Channel.objects.filter(channel_number=channel_number).exists(): - return Response( - { - "error": f"Channel number {channel_number} is already in use. Please choose a different number." - }, - status=status.HTTP_400_BAD_REQUEST, - ) + 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: diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 3414a9a2..d917d115 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -553,7 +553,7 @@ export const WebsocketProvider = ({ children }) => { // General notification for channel creation notifications.show({ title: 'Channels Created', - message: `Successfully created ${parsedEvent.data.count || 'multiple'} channels`, + message: `Successfully created ${parsedEvent.data.count || 'multiple'} channel(s)`, color: 'green', autoClose: 4000, }); From f84a3475141dfea79faa45cec5d66543bd36fdf0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 13:53:02 -0500 Subject: [PATCH 30/65] Don't put channel name in EPG name when no epg is assigned. --- frontend/src/components/forms/Channel.jsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index ae466a57..d07fa44c 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -718,8 +718,6 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { return `${epgSource.name} - ${tvgLabel}`; } else if (tvgLabel) { return tvgLabel; - } else if (formik.values.name) { - return formik.values.name; } else { return 'Dummy'; } From d7129d6195d36ecd3741c77edd260454af5cea7e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 14:35:51 -0500 Subject: [PATCH 31/65] Change EPG column to have (EPG ID - TVG-ID) and standardize header label. --- frontend/src/components/tables/ChannelsTable.jsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 76484980..1955946b 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -778,14 +778,15 @@ const ChannelsTable = ({}) => { 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 epgDataName = epgObj?.name; - const tvgId = epgObj?.tvg_id; + const tooltip = epgObj - ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgDataName ? `TVG Name: ${epgDataName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim() + ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim() : ''; return ( { withArrow position="top" > - {epgName} + + {epgObj.epg_source} - {tvgId} + ) : epgObj ? ( {epgObj.name} ) : ( - Not linked + Not Assigned )} ); @@ -901,6 +904,7 @@ const ChannelsTable = ({}) => { placeholder="EPG" variant="unstyled" data={epgOptions} + className="table-input-header" size="xs" searchable clearable @@ -955,6 +959,7 @@ const ChannelsTable = ({}) => { return ( Date: Sun, 14 Sep 2025 14:53:55 -0500 Subject: [PATCH 32/65] Remove max size on group and epg columns. Increase default column size for epg --- frontend/src/components/tables/ChannelsTable.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 1955946b..b9da2318 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -816,9 +816,8 @@ const ChannelsTable = ({}) => { ); }, - size: columnSizing.epg || 120, + size: columnSizing.epg || 200, minSize: 80, - maxSize: 200, }, { id: 'channel_group', @@ -839,7 +838,6 @@ const ChannelsTable = ({}) => { ), size: columnSizing.channel_group || 175, minSize: 100, - maxSize: 300, }, { id: 'logo', From f8e91155e2d010a9f94842a7aafebaa92de79874 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 15:19:51 -0500 Subject: [PATCH 33/65] Fix bug: cannot access local variable 'total_chunks' where it is not associated with a value --- apps/vod/tasks.py | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 93cef3e1..3c429e15 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -135,11 +135,11 @@ def refresh_movies(client, account, categories_by_provider, relations, scan_star # Process movies in chunks using the simple approach chunk_size = 1000 total_movies = len(all_movies_data) + total_chunks = (total_movies + chunk_size - 1) // chunk_size if total_movies > 0 else 0 for i in range(0, total_movies, chunk_size): chunk = all_movies_data[i:i + chunk_size] chunk_num = (i // chunk_size) + 1 - total_chunks = (total_movies + chunk_size - 1) // chunk_size logger.info(f"Processing movie chunk {chunk_num}/{total_chunks} ({len(chunk)} movies)") process_movie_batch(account, chunk, categories_by_provider, relations, scan_start_time) @@ -158,11 +158,11 @@ def refresh_series(client, account, categories_by_provider, relations, scan_star # Process series in chunks using the simple approach chunk_size = 1000 total_series = len(all_series_data) + total_chunks = (total_series + chunk_size - 1) // chunk_size if total_series > 0 else 0 for i in range(0, total_series, chunk_size): chunk = all_series_data[i:i + chunk_size] chunk_num = (i // chunk_size) + 1 - total_chunks = (total_series + chunk_size - 1) // chunk_size logger.info(f"Processing series chunk {chunk_num}/{total_chunks} ({len(chunk)} series)") process_series_batch(account, chunk, categories_by_provider, relations, scan_start_time) @@ -427,17 +427,18 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N updated = True # Handle logo assignment for existing movies + logo_updated = False if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: new_logo = existing_logos[logo_url] if movie.logo != new_logo: - movie.logo = new_logo - updated = True + movie._logo_to_update = new_logo + logo_updated = True elif (not logo_url or len(logo_url) > 500) and movie.logo: # Clear logo if no logo URL provided or URL is too long - movie.logo = None - updated = True + movie._logo_to_update = None + logo_updated = True - if updated: + if updated or logo_updated: movies_to_update.append(movie) else: # Create new movie @@ -509,11 +510,18 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N # Update existing movies if movies_to_update: + # First, update all fields except logo to avoid unsaved related object issues Movie.objects.bulk_update(movies_to_update, [ 'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id', - 'duration_secs', 'custom_properties', 'logo' + 'duration_secs', 'custom_properties' ]) + # Handle logo updates separately to avoid bulk_update issues + for movie in movies_to_update: + if hasattr(movie, '_logo_to_update'): + movie.logo = movie._logo_to_update + movie.save(update_fields=['logo']) + # Update relations to reference the correct movie objects for relation in relations_to_create: if id(relation.movie) in created_movies: @@ -741,17 +749,18 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= updated = True # Handle logo assignment for existing series + logo_updated = False if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: new_logo = existing_logos[logo_url] if series.logo != new_logo: - series.logo = new_logo - updated = True + series._logo_to_update = new_logo + logo_updated = True elif (not logo_url or len(logo_url) > 500) and series.logo: # Clear logo if no logo URL provided or URL is too long - series.logo = None - updated = True + series._logo_to_update = None + logo_updated = True - if updated: + if updated or logo_updated: series_to_update.append(series) else: # Create new series @@ -823,11 +832,18 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= # Update existing series if series_to_update: + # First, update all fields except logo to avoid unsaved related object issues Series.objects.bulk_update(series_to_update, [ 'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id', - 'custom_properties', 'logo' + 'custom_properties' ]) + # Handle logo updates separately to avoid bulk_update issues + for series in series_to_update: + if hasattr(series, '_logo_to_update'): + series.logo = series._logo_to_update + series.save(update_fields=['logo']) + # Update relations to reference the correct series objects for relation in relations_to_create: if id(relation.series) in created_series: From 5e661ea208f6ce8ca44ad0e47350a2d3bb2ddda2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 19:27:11 -0500 Subject: [PATCH 34/65] Fix VOD page not displaying correct order while changing pages. --- apps/vod/api_urls.py | 2 + apps/vod/api_views.py | 200 +++++++++++++++++++++++++++++ apps/vod/urls.py | 4 +- frontend/src/api.js | 43 ++++++- frontend/src/pages/VODs.jsx | 112 ++++------------ frontend/src/store/useVODStore.jsx | 176 +++++++++++++++---------- 6 files changed, 380 insertions(+), 157 deletions(-) diff --git a/apps/vod/api_urls.py b/apps/vod/api_urls.py index b49e79e3..ffccc3f5 100644 --- a/apps/vod/api_urls.py +++ b/apps/vod/api_urls.py @@ -5,6 +5,7 @@ from .api_views import ( EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, + UnifiedContentViewSet, ) app_name = 'vod' @@ -14,5 +15,6 @@ router.register(r'movies', MovieViewSet, basename='movie') router.register(r'episodes', EpisodeViewSet, basename='episode') router.register(r'series', SeriesViewSet, basename='series') router.register(r'categories', VODCategoryViewSet, basename='vodcategory') +router.register(r'all', UnifiedContentViewSet, basename='unified-content') urlpatterns = router.urls diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index b72ae035..517038a6 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -469,3 +469,203 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] + + +class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet that combines Movies and Series for unified 'All' view""" + queryset = Movie.objects.none() # Empty queryset, we override list method + serializer_class = MovieSerializer # Default serializer, overridden in list + pagination_class = VODPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def list(self, request, *args, **kwargs): + """Override list to handle unified content properly - database-level approach""" + import logging + from django.db import connection + + logger = logging.getLogger(__name__) + logger.error("=== UnifiedContentViewSet.list() called ===") + + try: + # Get pagination parameters + page_size = int(request.query_params.get('page_size', 24)) + page_number = int(request.query_params.get('page', 1)) + + logger.error(f"Page {page_number}, page_size {page_size}") + + # Calculate offset for unified pagination + offset = (page_number - 1) * page_size + + # For high page numbers, use raw SQL for efficiency + # This avoids loading and sorting massive amounts of data in Python + + search = request.query_params.get('search', '') + category = request.query_params.get('category', '') + + # Build WHERE clauses + where_conditions = [ + # Only active content + "movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id WHERE ma.is_active = true)", + "series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)" + ] + + params = [] + + if search: + where_conditions[0] += " AND LOWER(movies.name) LIKE %s" + where_conditions[1] += " AND LOWER(series.name) LIKE %s" + search_param = f"%{search.lower()}%" + params.extend([search_param, search_param]) + + if category: + if '|' in category: + cat_name, cat_type = category.split('|', 1) + if cat_type == 'movie': + where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" + where_conditions[1] = "1=0" # Exclude series + params.append(cat_name) + elif cat_type == 'series': + where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" + where_conditions[0] = "1=0" # Exclude movies + params.append(cat_name) + else: + where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" + where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" + params.extend([category, category]) + + # Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination + # This is much more efficient than Python sorting + sql = f""" + WITH unified_content AS ( + SELECT + movies.id, + movies.uuid, + movies.name, + movies.description, + movies.year, + movies.rating, + movies.genre, + movies.duration_secs as duration, + movies.created_at, + movies.updated_at, + movies.custom_properties, + movies.logo_id, + logo.name as logo_name, + logo.url as logo_url, + 'movie' as content_type + FROM vod_movie movies + LEFT JOIN dispatcharr_channels_logo logo ON movies.logo_id = logo.id + WHERE {where_conditions[0]} + + UNION ALL + + SELECT + series.id, + series.uuid, + series.name, + series.description, + series.year, + series.rating, + series.genre, + NULL as duration, + series.created_at, + series.updated_at, + series.custom_properties, + series.logo_id, + logo.name as logo_name, + logo.url as logo_url, + 'series' as content_type + FROM vod_series series + LEFT JOIN dispatcharr_channels_logo logo ON series.logo_id = logo.id + WHERE {where_conditions[1]} + ) + SELECT * FROM unified_content + ORDER BY LOWER(name), id + LIMIT %s OFFSET %s + """ + + params.extend([page_size, offset]) + + logger.error(f"Executing SQL with LIMIT {page_size} OFFSET {offset}") + + with connection.cursor() as cursor: + cursor.execute(sql, params) + columns = [col[0] for col in cursor.description] + results = [] + + for row in cursor.fetchall(): + item_dict = dict(zip(columns, row)) + + # Build logo object in the format expected by frontend + logo_data = None + if item_dict['logo_id']: + logo_data = { + 'id': item_dict['logo_id'], + 'name': item_dict['logo_name'], + 'url': item_dict['logo_url'], + 'cache_url': f"/media/logo_cache/{item_dict['logo_id']}.png" if item_dict['logo_id'] else None, + 'channel_count': 0, # We don't need this for VOD + 'is_used': True, + 'channel_names': [] # We don't need this for VOD + } + + # Convert to the format expected by frontend + formatted_item = { + 'id': item_dict['id'], + 'uuid': str(item_dict['uuid']), + 'name': item_dict['name'], + 'description': item_dict['description'] or '', + 'year': item_dict['year'], + 'rating': float(item_dict['rating']) if item_dict['rating'] else 0.0, + 'genre': item_dict['genre'] or '', + 'duration': item_dict['duration'], + 'created_at': item_dict['created_at'].isoformat() if item_dict['created_at'] else None, + 'updated_at': item_dict['updated_at'].isoformat() if item_dict['updated_at'] else None, + 'custom_properties': item_dict['custom_properties'] or {}, + 'logo': logo_data, + 'content_type': item_dict['content_type'] + } + results.append(formatted_item) + + logger.error(f"Retrieved {len(results)} results via SQL") + + # Get total count estimate (for pagination info) + # Use a separate efficient count query + count_sql = f""" + SELECT COUNT(*) FROM ( + SELECT 1 FROM vod_movie movies WHERE {where_conditions[0]} + UNION ALL + SELECT 1 FROM vod_series series WHERE {where_conditions[1]} + ) as total_count + """ + + count_params = params[:-2] # Remove LIMIT and OFFSET params + + with connection.cursor() as cursor: + cursor.execute(count_sql, count_params) + total_count = cursor.fetchone()[0] + + response_data = { + 'count': total_count, + 'next': offset + page_size < total_count, + 'previous': page_number > 1, + 'results': results + } + + return Response(response_data) + + except Exception as e: + logger.error(f"Error in UnifiedContentViewSet.list(): {e}") + import traceback + logger.error(traceback.format_exc()) + return Response({'error': str(e)}, status=500) \ No newline at end of file diff --git a/apps/vod/urls.py b/apps/vod/urls.py index f90e3fb6..3cea96a5 100644 --- a/apps/vod/urls.py +++ b/apps/vod/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet +from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, UnifiedContentViewSet app_name = 'vod' @@ -9,7 +9,7 @@ router.register(r'movies', MovieViewSet) router.register(r'episodes', EpisodeViewSet) router.register(r'series', SeriesViewSet) router.register(r'categories', VODCategoryViewSet) -router.register(r'connections', VODConnectionViewSet) +router.register(r'all', UnifiedContentViewSet, basename='unified-content') urlpatterns = [ path('api/', include(router.urls)), diff --git a/frontend/src/api.js b/frontend/src/api.js index 1c25f1cf..956f3ece 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2077,7 +2077,14 @@ export default class API { ); return response; } catch (e) { - errorNotification('Failed to retrieve movies', e); + // Don't show error notification for "Invalid page" errors as they're handled gracefully + const isInvalidPage = e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); + + if (!isInvalidPage) { + errorNotification('Failed to retrieve movies', e); + } + throw e; } } @@ -2088,7 +2095,39 @@ export default class API { ); return response; } catch (e) { - errorNotification('Failed to retrieve series', e); + // Don't show error notification for "Invalid page" errors as they're handled gracefully + const isInvalidPage = e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); + + if (!isInvalidPage) { + errorNotification('Failed to retrieve series', e); + } + throw e; + } + } + + static async getAllContent(params = new URLSearchParams()) { + try { + console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`); + const response = await request( + `${host}/api/vod/all/?${params.toString()}` + ); + console.log('getAllContent raw response:', response); + return response; + } catch (e) { + console.error('getAllContent error:', e); + console.error('Error status:', e.status); + console.error('Error body:', e.body); + console.error('Error message:', e.message); + + // Don't show error notification for "Invalid page" errors as they're handled gracefully + const isInvalidPage = e.body?.detail?.includes('Invalid page') || + e.message?.includes('Invalid page'); + + if (!isInvalidPage) { + errorNotification('Failed to retrieve content', e); + } + throw e; } } diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx index 16d43a9a..3c9e2b0f 100644 --- a/frontend/src/pages/VODs.jsx +++ b/frontend/src/pages/VODs.jsx @@ -264,8 +264,7 @@ const useCardColumns = () => { }; const VODsPage = () => { - const movies = useVODStore((s) => s.movies); - const series = useVODStore((s) => s.series); + const currentPageContent = useVODStore((s) => s.currentPageContent); // Direct subscription const allCategories = useVODStore((s) => s.categories); const filters = useVODStore((s) => s.filters); const currentPage = useVODStore((s) => s.currentPage); @@ -288,8 +287,7 @@ const VODsPage = () => { setPageSize(Number(value)); localStorage.setItem('vodsPageSize', value); }; - const fetchMovies = useVODStore((s) => s.fetchMovies); - const fetchSeries = useVODStore((s) => s.fetchSeries); + const fetchContent = useVODStore((s) => s.fetchContent); const fetchCategories = useVODStore((s) => s.fetchCategories); // const showVideo = useVideoStore((s) => s.showVideo); - removed as unused @@ -307,36 +305,10 @@ const VODsPage = () => { // Helper function to get display data based on current filters const getDisplayData = () => { - if (filters.type === 'series') { - return Object.values(series).map((item) => ({ - ...item, - _vodType: 'series', - })); - } else if (filters.type === 'movies') { - return Object.values(movies).map((item) => ({ - ...item, - _vodType: 'movie', - })); - } else { - // 'all' - combine movies and series, tagging each with its type, then sort alphabetically by name/title - const combined = [ - ...Object.values(movies).map((item) => ({ - ...item, - _vodType: 'movie', - })), - ...Object.values(series).map((item) => ({ - ...item, - _vodType: 'series', - })), - ]; - return combined.sort((a, b) => { - const nameA = (a.name || a.title || '').toLowerCase(); - const nameB = (b.name || b.title || '').toLowerCase(); - if (nameA < nameB) return -1; - if (nameA > nameB) return 1; - return 0; - }); - } + return (currentPageContent || []).map((item) => ({ + ...item, + _vodType: item.contentType === 'movie' ? 'movie' : 'series', + })); }; useEffect(() => { @@ -360,17 +332,8 @@ const VODsPage = () => { }, [fetchCategories]); useEffect(() => { - if (filters.type === 'series') { - fetchSeries().finally(() => setInitialLoad(false)); - } else if (filters.type === 'movies') { - fetchMovies().finally(() => setInitialLoad(false)); - } else { - // 'all': fetch both movies and series - Promise.all([fetchMovies(), fetchSeries()]).finally(() => - setInitialLoad(false) - ); - } - }, [filters, currentPage, pageSize, fetchMovies, fetchSeries]); + fetchContent().finally(() => setInitialLoad(false)); + }, [filters, currentPage, pageSize, fetchContent]); const handleVODCardClick = (vod) => { setSelectedVOD(vod); @@ -464,46 +427,25 @@ const VODsPage = () => { ) : ( <> - {filters.type === 'series' ? ( - - {Object.values(series).map((seriesItem) => ( - - - - ))} - - ) : ( - - {getDisplayData().map((item) => ( - - {item._vodType === 'series' ? ( - - ) : ( - - )} - - ))} - - )} + + {getDisplayData().map((item) => ( + + {item.contentType === 'series' ? ( + + ) : ( + + )} + + ))} + {/* Pagination */} {totalPages > 1 && ( diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index dd3f5b8a..b0aecd61 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -2,8 +2,8 @@ import { create } from 'zustand'; import api from '../api'; const useVODStore = create((set, get) => ({ - movies: {}, - series: {}, + content: {}, // Store for individual content details (when fetching movie/series details) + currentPageContent: [], // Store the current page's results episodes: {}, categories: {}, loading: false, @@ -34,12 +34,12 @@ const useVODStore = create((set, get) => ({ currentPage: 1, // Reset to first page when page size changes })), - fetchMovies: async () => { + fetchContent: async () => { try { set({ loading: true, error: null }); const state = get(); - const params = new URLSearchParams(); + const params = new URLSearchParams(); params.append('page', state.currentPage); params.append('page_size', state.pageSize); @@ -51,60 +51,62 @@ const useVODStore = create((set, get) => ({ params.append('category', state.filters.category); } - const response = await api.getMovies(params); + let allResults = []; + let totalCount = 0; - // Handle both paginated and non-paginated responses - const results = response.results || response; - const count = response.count || results.length; + if (state.filters.type === 'movies') { + // 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', + })); + totalCount = response.count || results.length; + } else { + // 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)) { + console.error('Results is not an array:', results); + throw new Error('Invalid response format - results is not an array'); + } + + // The backend already provides content_type and proper sorting/pagination + allResults = results.map((item) => ({ + ...item, + contentType: item.content_type, // Backend provides this field + })); + totalCount = response.count || results.length; + } + + // Store the current page results directly (don't accumulate all pages) set({ - movies: results.reduce((acc, movie) => { - acc[movie.id] = movie; - return acc; - }, {}), - totalCount: count, + currentPageContent: allResults, // This is the paginated data for current page + totalCount, loading: false, }); } catch (error) { - console.error('Failed to fetch movies:', error); - set({ error: 'Failed to load movies.', loading: false }); - } - }, - - fetchSeries: async () => { - set({ loading: true, error: null }); - try { - 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 response = await api.getSeries(params); - - // Handle both paginated and non-paginated responses - const results = response.results || response; - const count = response.count || results.length; - - set({ - series: results.reduce((acc, series) => { - acc[series.id] = series; - return acc; - }, {}), - totalCount: count, - loading: false, - }); - } catch (error) { - console.error('Failed to fetch series:', error); - set({ error: 'Failed to load series.', loading: false }); + console.error('Failed to fetch content:', error); + set({ error: 'Failed to load content.', loading: false }); } }, @@ -158,9 +160,12 @@ const useVODStore = create((set, get) => ({ }; console.log('Fetched Movie Details:', movieDetails); set((state) => ({ - movies: { - ...state.movies, - [movieDetails.id]: movieDetails, + content: { + ...state.content, + [`movie_${movieDetails.id}`]: { + ...movieDetails, + contentType: 'movie', + }, }, loading: false, })); @@ -261,36 +266,48 @@ const useVODStore = create((set, get) => ({ addMovie: (movie) => set((state) => ({ - movies: { ...state.movies, [movie.id]: movie }, + content: { + ...state.content, + [`movie_${movie.id}`]: { ...movie, contentType: 'movie' }, + }, })), updateMovie: (movie) => set((state) => ({ - movies: { ...state.movies, [movie.id]: movie }, + content: { + ...state.content, + [`movie_${movie.id}`]: { ...movie, contentType: 'movie' }, + }, })), removeMovie: (movieId) => set((state) => { - const updatedMovies = { ...state.movies }; - delete updatedMovies[movieId]; - return { movies: updatedMovies }; + const updatedContent = { ...state.content }; + delete updatedContent[`movie_${movieId}`]; + return { content: updatedContent }; }), addSeries: (series) => set((state) => ({ - series: { ...state.series, [series.id]: series }, + content: { + ...state.content, + [`series_${series.id}`]: { ...series, contentType: 'series' }, + }, })), updateSeries: (series) => set((state) => ({ - series: { ...state.series, [series.id]: series }, + content: { + ...state.content, + [`series_${series.id}`]: { ...series, contentType: 'series' }, + }, })), removeSeries: (seriesId) => set((state) => { - const updatedSeries = { ...state.series }; - delete updatedSeries[seriesId]; - return { series: updatedSeries }; + const updatedContent = { ...state.content }; + delete updatedContent[`series_${seriesId}`]; + return { content: updatedContent }; }), fetchSeriesInfo: async (seriesId) => { @@ -369,9 +386,9 @@ const useVODStore = create((set, get) => ({ } set((state) => ({ - series: { - ...state.series, - [seriesInfo.id]: seriesInfo, + content: { + ...state.content, + [`series_${seriesInfo.id}`]: { ...seriesInfo, contentType: 'series' }, }, loading: false, })); @@ -387,6 +404,29 @@ const useVODStore = create((set, get) => ({ throw error; } }, + + // Helper methods for getting filtered content + getFilteredContent: () => { + const state = get(); + // Return the current page content directly - backend handles all filtering/pagination + return state.currentPageContent; + }, + + getMovies: () => { + const state = get(); + return Object.values(state.content).filter( + (item) => item.contentType === 'movie' + ); + }, + + getSeries: () => { + const state = get(); + return Object.values(state.content).filter( + (item) => item.contentType === 'series' + ); + }, + + clearContent: () => set({ content: {}, totalCount: 0 }), })); export default useVODStore; From 22e1a8cc057b88ce50df2e910b2b63811a4315e4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 19:34:32 -0500 Subject: [PATCH 35/65] Fix: "ReferenceError: setIsInitialized is not defined" when logging into web ui. --- frontend/src/hooks/useSmartLogos.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useSmartLogos.jsx b/frontend/src/hooks/useSmartLogos.jsx index a500a530..148aded0 100644 --- a/frontend/src/hooks/useSmartLogos.jsx +++ b/frontend/src/hooks/useSmartLogos.jsx @@ -42,6 +42,7 @@ export const useLogoSelection = () => { * (unused + channel-used, excluding VOD-only logos) */ export const useChannelLogoSelection = () => { + const [isInitialized, setIsInitialized] = useState(false); const channelLogos = useLogosStore((s) => s.channelLogos); const hasLoadedChannelLogos = useLogosStore((s) => s.hasLoadedChannelLogos); const backgroundLoading = useLogosStore((s) => s.backgroundLoading); @@ -56,7 +57,7 @@ export const useChannelLogoSelection = () => { return; } - if (hasLoadedChannelLogos && hasLogos) { + if ((hasLoadedChannelLogos && hasLogos) || isInitialized) { return; } @@ -71,6 +72,7 @@ export const useChannelLogoSelection = () => { hasLoadedChannelLogos, hasLogos, fetchChannelAssignableLogos, + isInitialized, ]); return { From dfaae6e6174314b1587b7818825bad63a73f52d4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 14 Sep 2025 19:47:40 -0500 Subject: [PATCH 36/65] Enhance UserViewSet queryset to prefetch related channel_profiles for improved performance --- apps/accounts/api_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index c4b544b0..bf87c2ab 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -134,7 +134,7 @@ class AuthViewSet(viewsets.ViewSet): class UserViewSet(viewsets.ModelViewSet): """Handles CRUD operations for Users""" - queryset = User.objects.all() + queryset = User.objects.all().prefetch_related('channel_profiles') serializer_class = UserSerializer def get_permissions(self): From ed0b291237ddd249d74e8c7f80922c252ea3c4f5 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 15 Sep 2025 17:36:31 -0500 Subject: [PATCH 37/65] Skip disabled m3u accounts when choosing streams during playback. Closes #402 --- apps/channels/models.py | 16 +++++++--------- apps/proxy/ts_proxy/url_utils.py | 4 +++- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 0a4f72e7..af66178d 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -379,14 +379,17 @@ class Channel(models.Model): if not m3u_account: logger.debug(f"Stream {stream.id} has no M3U account") continue + if m3u_account.is_active == False: + logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.") + continue - m3u_profiles = m3u_account.profiles.all() + m3u_profiles = m3u_account.profiles.filter(is_active=True) default_profile = next( (obj for obj in m3u_profiles if obj.is_default), None ) if not default_profile: - logger.debug(f"M3U account {m3u_account.id} has no default profile") + logger.debug(f"M3U account {m3u_account.id} has no active default profile") continue profiles = [default_profile] + [ @@ -394,11 +397,6 @@ class Channel(models.Model): ] for profile in profiles: - # Skip inactive profiles - if not profile.is_active: - logger.debug(f"Skipping inactive profile {profile.id}") - continue - has_active_profiles = True profile_connections_key = f"profile_connections:{profile.id}" @@ -433,9 +431,9 @@ class Channel(models.Model): # No available streams - determine specific reason if has_streams_but_maxed_out: - error_reason = "All M3U profiles have reached maximum connection limits" + error_reason = "All active M3U profiles have reached maximum connection limits" elif has_active_profiles: - error_reason = "No compatible profile found for any assigned stream" + error_reason = "No compatible active profile found for any assigned stream" else: error_reason = "No active profiles found for any assigned stream" diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 0c8dfee2..f7825971 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -281,7 +281,9 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No if not m3u_account: logger.debug(f"Stream {stream.id} has no M3U account") continue - + if m3u_account.is_active == False: + logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.") + continue m3u_profiles = m3u_account.profiles.all() default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) From 56aa5c77d27ed8715041b66f4d1103549df0ec16 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 15 Sep 2025 20:02:40 -0500 Subject: [PATCH 38/65] Filter out profiles during db query that are inactive. --- apps/proxy/ts_proxy/url_utils.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index f7825971..75e7653e 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -142,7 +142,7 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] if not m3u_account: return {'error': 'Stream has no M3U account'} - m3u_profiles = m3u_account.profiles.all() + m3u_profiles = m3u_account.profiles.filter(is_active=True) default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) if not default_profile: @@ -153,10 +153,6 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] selected_profile = None for profile in profiles: - # Skip inactive profiles - if not profile.is_active: - logger.debug(f"Skipping inactive profile {profile.id}") - continue # Check connection availability if redis_client: @@ -284,7 +280,7 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No if m3u_account.is_active == False: logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.") continue - m3u_profiles = m3u_account.profiles.all() + m3u_profiles = m3u_account.profiles.filter(is_active=True) default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) if not default_profile: @@ -296,11 +292,6 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No selected_profile = None for profile in profiles: - # Skip inactive profiles - if not profile.is_active: - logger.debug(f"Skipping inactive profile {profile.id}") - continue - # Check connection availability if redis_client: profile_connections_key = f"profile_connections:{profile.id}" From eccc5d709a8c7aab8963e9cce89eea65b7240804 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 15 Sep 2025 20:38:18 -0500 Subject: [PATCH 39/65] Sort categories by name during api retrieval. --- apps/output/views.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/output/views.py b/apps/output/views.py index 902c8f71..6c18629c 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -19,6 +19,7 @@ from tzlocal import get_localzone from urllib.parse import urlparse import base64 import logging +from django.db.models.functions import Lower import os from apps.m3u.utils import calculate_tuner_count @@ -859,11 +860,11 @@ def xc_get_live_categories(user): channel_profiles ) - channel_groups = ChannelGroup.objects.filter(**filters).distinct() + channel_groups = ChannelGroup.objects.filter(**filters).distinct().order_by(Lower("name")) else: channel_groups = ChannelGroup.objects.filter( channels__isnull=False, channels__user_level__lte=user.user_level - ).distinct() + ).distinct().order_by(Lower("name")) for group in channel_groups: response.append( @@ -1025,13 +1026,13 @@ def xc_get_vod_categories(user): categories = VODCategory.objects.filter( category_type='movie', m3umovierelation__m3u_account__in=m3u_accounts - ).distinct() + ).distinct().order_by(Lower("name")) else: # Admins can see all categories that have active movie relations categories = VODCategory.objects.filter( category_type='movie', m3umovierelation__m3u_account__is_active=True - ).distinct() + ).distinct().order_by(Lower("name")) for category in categories: response.append({ @@ -1142,12 +1143,12 @@ def xc_get_series_categories(user): categories = VODCategory.objects.filter( category_type='series', m3useriesrelation__m3u_account__in=m3u_accounts - ).distinct() + ).distinct().order_by(Lower("name")) else: categories = VODCategory.objects.filter( category_type='series', m3useriesrelation__m3u_account__is_active=True - ).distinct() + ).distinct().order_by(Lower("name")) for category in categories: response.append({ From f1739f23944eade0ecc46541b13cf5efe6b289c4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 08:55:10 -0500 Subject: [PATCH 40/65] Add EPG auto-match functionality for specific channels and update UI --- apps/channels/api_views.py | 29 ++++- apps/channels/tasks.py | 122 ++++++++++++++++++++++ frontend/src/api.js | 20 ++++ frontend/src/components/forms/Channel.jsx | 58 +++++++++- 4 files changed, 227 insertions(+), 2 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 92755252..6537e6b8 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -39,7 +39,7 @@ from .serializers import ( ChannelProfileSerializer, RecordingSerializer, ) -from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl +from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg import django_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter @@ -789,6 +789,33 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": "EPG matching task initiated."}, 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"}, + ) + @action(detail=True, methods=["post"], url_path="match-epg") + def match_channel_epg(self, request, pk=None): + channel = self.get_object() + + # Import the matching logic + from apps.channels.tasks import match_single_channel_epg + + try: + # Try to match this specific channel - call synchronously for immediate response + result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30) + + # Refresh the channel from DB to get any updates + channel.refresh_from_db() + + return Response({ + "message": result.get("message", "Channel matching completed"), + "matched": result.get("matched", False), + "channel": self.get_serializer(channel).data + }) + except Exception as e: + return Response({"error": str(e)}, status=400) + # ───────────────────────────────────────────────────────── # 7) Set EPG and Refresh # ───────────────────────────────────────────────────────── diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index e0954210..f4d58f46 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -241,6 +241,128 @@ def match_epg_channels(): cleanup_memory(log_usage=True, force_collection=True) +@shared_task +def match_single_channel_epg(channel_id): + """ + Try to match a single channel with EPG data using the same logic as match_epg_channels + but for just one channel. Returns a dict with match status and message. + """ + try: + from apps.channels.models import Channel + from apps.epg.models import EPGData + import tempfile + import subprocess + import json + + logger.info(f"Starting single channel EPG matching for channel ID {channel_id}") + + # Get the channel + try: + channel = Channel.objects.get(id=channel_id) + except Channel.DoesNotExist: + return {"matched": False, "message": "Channel not found"} + + # If channel already has EPG data, skip + if channel.epg_data: + return {"matched": False, "message": f"Channel '{channel.name}' already has EPG data assigned"} + + # Get region preference + try: + region_obj = CoreSettings.objects.get(key="preferred-region") + region_code = region_obj.value.strip().lower() + except CoreSettings.DoesNotExist: + region_code = None + + # Prepare channel data for matching script + normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" + channel_json = { + "id": channel.id, + "name": channel.name, + "tvg_id": normalized_tvg_id, + "original_tvg_id": channel.tvg_id, + "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, + "norm_chan": normalize_name(normalized_tvg_id if normalized_tvg_id else channel.name) + } + + # Prepare EPG data + epg_json = [] + for epg in EPGData.objects.all(): + normalized_epg_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" + epg_json.append({ + 'id': epg.id, + 'tvg_id': normalized_epg_tvg_id, + 'original_tvg_id': epg.tvg_id, + 'name': epg.name, + 'norm_name': normalize_name(epg.name), + 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + }) + + # Create payload for matching script + payload = { + "channels": [channel_json], # Only one channel + "epg_data": epg_json, + } + + if region_code: + payload["region_code"] = region_code + + # Write to temporary file and run the matching script + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + json.dump(payload, temp_file) + temp_file_path = temp_file.name + + try: + # Run the matching script + from django.conf import settings + import os + + project_root = settings.BASE_DIR + script_path = os.path.join(project_root, 'scripts', 'epg_match.py') + + process = subprocess.Popen( + ['python', script_path, temp_file_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=project_root + ) + + stdout, stderr = process.communicate(timeout=60) # 1 minute timeout for single channel + + if process.returncode != 0: + logger.error(f"EPG matching script failed: {stderr}") + return {"matched": False, "message": "EPG matching failed"} + + result = json.loads(stdout) + channels_to_update = result.get("channels_to_update", []) + + if channels_to_update: + # Update the channel with the matched EPG data + epg_data_id = channels_to_update[0].get("epg_data_id") + if epg_data_id: + try: + epg_data = EPGData.objects.get(id=epg_data_id) + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + + return { + "matched": True, + "message": f"Channel '{channel.name}' matched with EPG '{epg_data.name}' (TVG ID: {epg_data.tvg_id})" + } + except EPGData.DoesNotExist: + return {"matched": False, "message": "Matched EPG data not found"} + + return {"matched": False, "message": f"No suitable EPG match found for channel '{channel.name}'"} + + finally: + # Clean up temp file + os.remove(temp_file_path) + + except Exception as e: + logger.error(f"Error in single channel EPG matching: {e}", exc_info=True) + return {"matched": False, "message": f"Error during matching: {str(e)}"} + + def evaluate_series_rules_impl(tvg_id: str | None = None): """Synchronous implementation of series rule evaluation; returns details for debugging.""" from django.utils import timezone diff --git a/frontend/src/api.js b/frontend/src/api.js index 956f3ece..d3e222d2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1452,6 +1452,26 @@ export default class API { } } + static async matchChannelEpg(channelId) { + try { + const response = await request( + `${host}/api/channels/channels/${channelId}/match-epg/`, + { + method: 'POST', + } + ); + + // Update the channel in the store with the refreshed data if provided + if (response.channel) { + useChannelsStore.getState().updateChannel(response.channel); + } + + return response; + } catch (e) { + errorNotification('Failed to run EPG auto-match for channel', e); + } + } + static async fetchActiveChannelStats() { try { const response = await request(`${host}/proxy/ts/status`); diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index d07fa44c..62da50c1 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -34,7 +34,7 @@ import { UnstyledButton, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react'; +import { ListOrdered, SquarePlus, SquareX, X, Zap } from 'lucide-react'; import useEPGsStore from '../../store/epgs'; import { Dropzone } from '@mantine/dropzone'; import { FixedSizeList as List } from 'react-window'; @@ -121,6 +121,48 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } }; + const handleAutoMatchEpg = async () => { + // Only attempt auto-match for existing channels (editing mode) + if (!channel || !channel.id) { + notifications.show({ + title: 'Info', + message: 'Auto-match is only available when editing existing channels.', + color: 'blue', + }); + return; + } + + try { + const response = await API.matchChannelEpg(channel.id); + + if (response.matched) { + // Update the form with the new EPG data + if (response.channel && response.channel.epg_data_id) { + formik.setFieldValue('epg_data_id', response.channel.epg_data_id); + } + + notifications.show({ + title: 'Success', + message: response.message, + color: 'green', + }); + } else { + notifications.show({ + title: 'No Match Found', + message: response.message, + color: 'orange', + }); + } + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to auto-match EPG data', + color: 'red', + }); + console.error('Auto-match error:', error); + } + }; + const formik = useFormik({ initialValues: { name: '', @@ -707,6 +749,20 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { > Use Dummy + } readOnly From f6be6bc3a9e07d6ffbfb9bc210734f64a4a81a5b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 09:18:41 -0500 Subject: [PATCH 41/65] Don't use matching script --- apps/channels/api_views.py | 8 +- apps/channels/tasks.py | 202 ++++++++++++---------- frontend/src/components/forms/Channel.jsx | 10 +- 3 files changed, 118 insertions(+), 102 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 6537e6b8..e522b618 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -797,17 +797,17 @@ class ChannelViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["post"], url_path="match-epg") def match_channel_epg(self, request, pk=None): channel = self.get_object() - + # Import the matching logic from apps.channels.tasks import match_single_channel_epg - + try: # Try to match this specific channel - call synchronously for immediate response result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30) - + # Refresh the channel from DB to get any updates channel.refresh_from_db() - + return Response({ "message": result.get("message", "Channel matching completed"), "matched": result.get("matched", False), diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index f4d58f46..f67fe563 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -12,6 +12,7 @@ import gc from celery import shared_task from django.utils.text import slugify +from rapidfuzz import fuzz from apps.channels.models import Channel from apps.epg.models import EPGData @@ -244,122 +245,133 @@ def match_epg_channels(): @shared_task def match_single_channel_epg(channel_id): """ - Try to match a single channel with EPG data using the same logic as match_epg_channels - but for just one channel. Returns a dict with match status and message. + Try to match a single channel with EPG data using optimized logic + that doesn't require loading all EPG data or running the external script. + Returns a dict with match status and message. """ try: from apps.channels.models import Channel from apps.epg.models import EPGData - import tempfile - import subprocess - import json - - logger.info(f"Starting single channel EPG matching for channel ID {channel_id}") - + import re + + logger.info(f"Starting optimized single channel EPG matching for channel ID {channel_id}") + # Get the channel try: channel = Channel.objects.get(id=channel_id) except Channel.DoesNotExist: return {"matched": False, "message": "Channel not found"} - + # If channel already has EPG data, skip if channel.epg_data: return {"matched": False, "message": f"Channel '{channel.name}' already has EPG data assigned"} - + # Get region preference try: region_obj = CoreSettings.objects.get(key="preferred-region") region_code = region_obj.value.strip().lower() except CoreSettings.DoesNotExist: region_code = None - - # Prepare channel data for matching script + + # Prepare channel data normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" - channel_json = { - "id": channel.id, - "name": channel.name, - "tvg_id": normalized_tvg_id, - "original_tvg_id": channel.tvg_id, - "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, - "norm_chan": normalize_name(normalized_tvg_id if normalized_tvg_id else channel.name) + normalized_channel_name = normalize_name(channel.name) + + logger.info(f"Matching channel '{channel.name}' (TVG ID: '{channel.tvg_id}') against EPG data") + + # Step 1: Try exact TVG ID match first (most efficient) + if normalized_tvg_id: + epg_exact_match = EPGData.objects.filter(tvg_id__iexact=channel.tvg_id).first() + if epg_exact_match: + logger.info(f"Channel '{channel.name}' matched with EPG '{epg_exact_match.name}' by exact TVG ID match") + channel.epg_data = epg_exact_match + channel.save(update_fields=["epg_data"]) + return { + "matched": True, + "message": f"Channel '{channel.name}' matched with EPG '{epg_exact_match.name}' by exact TVG ID match" + } + + # Step 2: Try case-insensitive TVG ID match + if normalized_tvg_id: + epg_case_match = EPGData.objects.filter(tvg_id__icontains=normalized_tvg_id).first() + if epg_case_match: + logger.info(f"Channel '{channel.name}' matched with EPG '{epg_case_match.name}' by case-insensitive TVG ID match") + channel.epg_data = epg_case_match + channel.save(update_fields=["epg_data"]) + return { + "matched": True, + "message": f"Channel '{channel.name}' matched with EPG '{epg_case_match.name}' by case-insensitive TVG ID match" + } + + # Step 3: Fuzzy name matching (only if name-based matching is needed) + if not normalized_channel_name: + return {"matched": False, "message": f"Channel '{channel.name}' has no usable name for matching"} + + # Query EPG data with name filtering to reduce dataset + epg_candidates = EPGData.objects.filter(name__isnull=False).exclude(name='').values('id', 'name', 'tvg_id') + epg_count = epg_candidates.count() + logger.info(f"Fuzzy matching against {epg_count} EPG entries (optimized - not loading all EPG data)") + + best_score = 0 + best_epg = None + + for epg in epg_candidates: + if not epg['name']: + continue + + epg_normalized_name = normalize_name(epg['name']) + if not epg_normalized_name: + continue + + # Calculate base fuzzy score + base_score = fuzz.ratio(normalized_channel_name, epg_normalized_name) + bonus = 0 + + # Apply region-based bonus/penalty if applicable + if region_code and epg['tvg_id']: + combined_text = epg['tvg_id'].lower() + " " + epg['name'].lower() + dot_regions = re.findall(r'\.([a-z]{2})', combined_text) + + if dot_regions: + if region_code in dot_regions: + bonus = 30 # Bigger bonus for matching region + else: + bonus = -15 # Penalty for different region + elif region_code in combined_text: + bonus = 15 + + final_score = base_score + bonus + + if final_score > best_score: + best_score = final_score + best_epg = epg + + # Apply matching thresholds (same as the ML script) + BEST_FUZZY_THRESHOLD = 85 + + if best_epg and best_score >= BEST_FUZZY_THRESHOLD: + try: + logger.info(f"Channel '{channel.name}' matched with EPG '{best_epg['name']}' (score: {best_score})") + epg_data = EPGData.objects.get(id=best_epg['id']) + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + + return { + "matched": True, + "message": f"Channel '{channel.name}' matched with EPG '{epg_data.name}' (score: {best_score})" + } + except EPGData.DoesNotExist: + return {"matched": False, "message": "Matched EPG data not found"} + + # No good match found + logger.info(f"No suitable EPG match found for channel '{channel.name}' (best score: {best_score})") + return { + "matched": False, + "message": f"No suitable EPG match found for channel '{channel.name}' (best score: {best_score})" } - - # Prepare EPG data - epg_json = [] - for epg in EPGData.objects.all(): - normalized_epg_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" - epg_json.append({ - 'id': epg.id, - 'tvg_id': normalized_epg_tvg_id, - 'original_tvg_id': epg.tvg_id, - 'name': epg.name, - 'norm_name': normalize_name(epg.name), - 'epg_source_id': epg.epg_source.id if epg.epg_source else None, - }) - - # Create payload for matching script - payload = { - "channels": [channel_json], # Only one channel - "epg_data": epg_json, - } - - if region_code: - payload["region_code"] = region_code - - # Write to temporary file and run the matching script - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: - json.dump(payload, temp_file) - temp_file_path = temp_file.name - - try: - # Run the matching script - from django.conf import settings - import os - - project_root = settings.BASE_DIR - script_path = os.path.join(project_root, 'scripts', 'epg_match.py') - - process = subprocess.Popen( - ['python', script_path, temp_file_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=project_root - ) - - stdout, stderr = process.communicate(timeout=60) # 1 minute timeout for single channel - - if process.returncode != 0: - logger.error(f"EPG matching script failed: {stderr}") - return {"matched": False, "message": "EPG matching failed"} - - result = json.loads(stdout) - channels_to_update = result.get("channels_to_update", []) - - if channels_to_update: - # Update the channel with the matched EPG data - epg_data_id = channels_to_update[0].get("epg_data_id") - if epg_data_id: - try: - epg_data = EPGData.objects.get(id=epg_data_id) - channel.epg_data = epg_data - channel.save(update_fields=["epg_data"]) - - return { - "matched": True, - "message": f"Channel '{channel.name}' matched with EPG '{epg_data.name}' (TVG ID: {epg_data.tvg_id})" - } - except EPGData.DoesNotExist: - return {"matched": False, "message": "Matched EPG data not found"} - - return {"matched": False, "message": f"No suitable EPG match found for channel '{channel.name}'"} - - finally: - # Clean up temp file - os.remove(temp_file_path) - + except Exception as e: - logger.error(f"Error in single channel EPG matching: {e}", exc_info=True) + logger.error(f"Error in optimized single channel EPG matching: {e}", exc_info=True) return {"matched": False, "message": f"Error during matching: {str(e)}"} diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 62da50c1..d3d6a94b 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -134,13 +134,13 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { try { const response = await API.matchChannelEpg(channel.id); - + if (response.matched) { // Update the form with the new EPG data if (response.channel && response.channel.epg_data_id) { formik.setFieldValue('epg_data_id', response.channel.epg_data_id); } - + notifications.show({ title: 'Success', message: response.message, @@ -758,7 +758,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { handleAutoMatchEpg(); }} disabled={!channel || !channel.id} - title={!channel || !channel.id ? "Auto-match is only available for existing channels" : "Automatically match EPG data"} + title={ + !channel || !channel.id + ? 'Auto-match is only available for existing channels' + : 'Automatically match EPG data' + } leftSection={} > Auto Match From d2085d57f84966bd0d1ffd42f7ed9965a3fa1749 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 12:43:21 -0500 Subject: [PATCH 42/65] Add sentence transformers to new matching function. --- apps/channels/tasks.py | 697 +++++++++++++++++++++++++++++------------ 1 file changed, 500 insertions(+), 197 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index f67fe563..1619843f 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -28,6 +28,113 @@ from urllib.parse import quote logger = logging.getLogger(__name__) +# Lazy loading for ML models - only imported/loaded when needed +_ml_model_cache = { + 'sentence_transformer': None, + 'model_path': os.path.join("/data", "models", "all-MiniLM-L6-v2"), # Use /data for persistence + 'model_name': "sentence-transformers/all-MiniLM-L6-v2" +} + +def get_sentence_transformer(): + """Lazy load the sentence transformer model only when needed""" + if _ml_model_cache['sentence_transformer'] is None: + try: + from sentence_transformers import SentenceTransformer + from sentence_transformers import util + + model_path = _ml_model_cache['model_path'] + model_name = _ml_model_cache['model_name'] + cache_dir = os.path.dirname(model_path) # /data/models + + # Check environment variable to disable downloads + disable_downloads = os.environ.get('DISABLE_ML_DOWNLOADS', 'false').lower() == 'true' + + # Ensure directory exists and is writable + os.makedirs(cache_dir, exist_ok=True) + + # Debug: List what's actually in the cache directory + try: + if os.path.exists(cache_dir): + logger.info(f"Cache directory contents: {os.listdir(cache_dir)}") + for item in os.listdir(cache_dir): + item_path = os.path.join(cache_dir, item) + if os.path.isdir(item_path): + logger.info(f" Subdirectory '{item}' contains: {os.listdir(item_path)}") + except Exception as e: + logger.info(f"Could not list cache directory: {e}") + + # Check if model files exist in our expected location + config_path = os.path.join(model_path, "config.json") + + logger.info(f"Checking for cached model at {model_path}") + logger.info(f"Config exists: {os.path.exists(config_path)}") + + # Also check if the model exists in the sentence-transformers default naming convention + alt_model_name = model_name.replace("/", "_") + alt_model_path = os.path.join(cache_dir, alt_model_name) + alt_config_path = os.path.join(alt_model_path, "config.json") + logger.info(f"Alternative path check - {alt_model_path}, config exists: {os.path.exists(alt_config_path)}") + + # Check for Hugging Face Hub cache format (newer format) + hf_model_name = f"models--{model_name.replace('/', '--')}" + hf_model_path = os.path.join(cache_dir, hf_model_name) + hf_snapshots_path = os.path.join(hf_model_path, "snapshots") + + logger.info(f"Hugging Face cache path check - {hf_model_path}, snapshots exists: {os.path.exists(hf_snapshots_path)}") + + # If HF cache exists, find the latest snapshot + hf_config_exists = False + hf_snapshot_path = None + if os.path.exists(hf_snapshots_path): + try: + snapshots = os.listdir(hf_snapshots_path) + if snapshots: + # Use the first (and likely only) snapshot + hf_snapshot_path = os.path.join(hf_snapshots_path, snapshots[0]) + hf_config_path = os.path.join(hf_snapshot_path, "config.json") + hf_config_exists = os.path.exists(hf_config_path) + logger.info(f"HF snapshot path: {hf_snapshot_path}, config exists: {hf_config_exists}") + except Exception as e: + logger.info(f"Error checking HF cache: {e}") + + # First try to load from our specific path + if os.path.exists(config_path): + logger.info(f"Loading cached sentence transformer from {model_path}") + _ml_model_cache['sentence_transformer'] = SentenceTransformer(model_path) + elif os.path.exists(alt_config_path): + logger.info(f"Loading cached sentence transformer from alternative path {alt_model_path}") + _ml_model_cache['sentence_transformer'] = SentenceTransformer(alt_model_path) + elif hf_config_exists and hf_snapshot_path: + logger.info(f"Loading cached sentence transformer from HF cache {hf_snapshot_path}") + _ml_model_cache['sentence_transformer'] = SentenceTransformer(hf_snapshot_path) + elif disable_downloads: + logger.warning(f"ML model not found and downloads disabled (DISABLE_ML_DOWNLOADS=true). Skipping ML matching.") + return None, None + else: + logger.info(f"Model cache not found, downloading {model_name}") + # Let sentence-transformers handle the download with its cache folder + _ml_model_cache['sentence_transformer'] = SentenceTransformer( + model_name, + cache_folder=cache_dir + ) + logger.info(f"Model downloaded and loaded successfully") + + return _ml_model_cache['sentence_transformer'], util + except ImportError: + logger.warning("sentence-transformers not available - ML-enhanced matching disabled") + return None, None + except Exception as e: + logger.error(f"Failed to load sentence transformer: {e}") + return None, None + else: + from sentence_transformers import util + return _ml_model_cache['sentence_transformer'], util + +# ML matching thresholds (same as original script) +BEST_FUZZY_THRESHOLD = 85 +LOWER_FUZZY_THRESHOLD = 40 +EMBED_SIM_THRESHOLD = 0.65 + # Words we remove to help with fuzzy + embedding matching COMMON_EXTRANEOUS_WORDS = [ "tv", "channel", "network", "television", @@ -50,155 +157,373 @@ def normalize_name(name: str) -> str: norm = name.lower() norm = re.sub(r"\[.*?\]", "", norm) + + # Extract and preserve important call signs from parentheses before removing them + # This captures call signs like (KVLY), (KING), (KARE), etc. + call_sign_match = re.search(r"\(([A-Z]{3,5})\)", name) + preserved_call_sign = "" + if call_sign_match: + preserved_call_sign = " " + call_sign_match.group(1).lower() + + # Now remove all parentheses content norm = re.sub(r"\(.*?\)", "", norm) + + # Add back the preserved call sign + norm = norm + preserved_call_sign + norm = re.sub(r"[^\w\s]", "", norm) tokens = norm.split() tokens = [t for t in tokens if t not in COMMON_EXTRANEOUS_WORDS] norm = " ".join(tokens).strip() return norm +def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True): + """ + EPG matching logic that finds the best EPG matches for channels using + multiple matching strategies including fuzzy matching and ML models. + """ + channels_to_update = [] + matched_channels = [] + + # Try to get ML models if requested (but don't load yet - lazy loading) + st_model, util = None, None + epg_embeddings = None + ml_available = use_ml + + # Process each channel + for chan in channels_data: + normalized_tvg_id = chan.get("tvg_id", "") + fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] + + # Step 1: Exact TVG ID match + epg_by_tvg_id = next((epg for epg in epg_data if epg["tvg_id"] == normalized_tvg_id), None) + if normalized_tvg_id and epg_by_tvg_id: + chan["epg_data_id"] = epg_by_tvg_id["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], fallback_name, epg_by_tvg_id["tvg_id"])) + logger.info(f"Channel {chan['id']} '{fallback_name}' => EPG found by exact tvg_id={epg_by_tvg_id['tvg_id']}") + continue + + # Step 2: Secondary TVG ID check (legacy compatibility) + if chan["tvg_id"]: + epg_match = [epg["id"] for epg in epg_data if epg["tvg_id"] == chan["tvg_id"]] + if epg_match: + chan["epg_data_id"] = epg_match[0] + channels_to_update.append(chan) + matched_channels.append((chan['id'], fallback_name, chan["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => EPG found by secondary tvg_id={chan['tvg_id']}") + continue + + # Step 3: Name-based fuzzy matching + if not chan["norm_chan"]: + logger.debug(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping") + continue + + best_score = 0 + best_epg = None + + # Debug: show what we're matching against + logger.debug(f"Fuzzy matching '{chan['norm_chan']}' against EPG entries...") + + # Find best fuzzy match + for row in epg_data: + if not row.get("norm_name"): + continue + + base_score = fuzz.ratio(chan["norm_chan"], row["norm_name"]) + bonus = 0 + + # Apply region-based bonus/penalty + if region_code and row.get("tvg_id"): + combined_text = row["tvg_id"].lower() + " " + row["name"].lower() + dot_regions = re.findall(r'\.([a-z]{2})', combined_text) + + if dot_regions: + if region_code in dot_regions: + bonus = 30 # Bigger bonus for matching region + else: + bonus = -15 # Penalty for different region + elif region_code in combined_text: + bonus = 15 + + score = base_score + bonus + + # Debug the best few matches + if score > 50: # Only show decent matches + logger.debug(f" EPG '{row['name']}' (norm: '{row['norm_name']}') => score: {score} (base: {base_score}, bonus: {bonus})") + + if score > best_score: + best_score = score + best_epg = row + + # Log the best score we found + if best_epg: + logger.info(f"Channel {chan['id']} '{chan['name']}' => best match: '{best_epg['name']}' (score: {best_score})") + + # Debug: Show some other potential matches for analysis + def score_epg_entry(epg_row): + base_score = fuzz.ratio(chan["norm_chan"], epg_row.get("norm_name", "")) + bonus = 0 + if region_code and epg_row.get("tvg_id"): + combined_text = epg_row["tvg_id"].lower() + " " + epg_row["name"].lower() + dot_regions = re.findall(r'\.([a-z]{2})', combined_text) + if dot_regions: + if region_code in dot_regions: + bonus = 30 + else: + bonus = -15 + elif region_code in combined_text: + bonus = 15 + return base_score + bonus + + # Check specifically for entries matching the channel's call sign or name parts + channel_keywords = chan["norm_chan"].split() + potential_matches = [] + for keyword in channel_keywords: + if len(keyword) >= 3: # Only check meaningful keywords + matching_entries = [row for row in epg_data if keyword.lower() in row['name'].lower() or keyword.lower() in row['tvg_id'].lower()] + potential_matches.extend(matching_entries) + + # Remove duplicates + unique_matches = [] + seen_ids = set() + for match in potential_matches: + if match['tvg_id'] not in seen_ids: + seen_ids.add(match['tvg_id']) + unique_matches.append(match) + + if unique_matches: + logger.info(f"Found {len(unique_matches)} entries containing channel keywords {channel_keywords}:") + for match_row in unique_matches: + match_score = score_epg_entry(match_row) + # Show original name vs normalized name to debug normalization + logger.info(f" Match: '{match_row['name']}' → normalized: '{match_row.get('norm_name', 'MISSING')}' (tvg_id: {match_row['tvg_id']}) => score: {match_score}") + else: + logger.warning(f"No entries found containing any of the channel keywords: {channel_keywords}") + + sorted_scores = sorted([(row, score_epg_entry(row)) for row in epg_data if row.get("norm_name") and score_epg_entry(row) > 20], key=lambda x: x[1], reverse=True) + + # Remove duplicates based on tvg_id + seen_tvg_ids = set() + unique_sorted_scores = [] + for row, score in sorted_scores: + if row['tvg_id'] not in seen_tvg_ids: + seen_tvg_ids.add(row['tvg_id']) + unique_sorted_scores.append((row, score)) + + logger.debug(f"Channel {chan['id']} '{chan['name']}' => top 10 unique fuzzy matches:") + for i, (epg_row, score) in enumerate(unique_sorted_scores[:10]): + # Highlight entries that contain any of the channel's keywords + channel_keywords = chan["norm_chan"].split() + is_keyword_match = any(keyword in epg_row['name'].lower() or keyword in epg_row['tvg_id'].lower() for keyword in channel_keywords if len(keyword) >= 3) + + if is_keyword_match: + logger.info(f" {i+1}. 🎯 KEYWORD MATCH: '{epg_row['name']}' (tvg_id: {epg_row['tvg_id']}) => score: {score} (norm_name: '{epg_row.get('norm_name', 'MISSING')}')") + else: + logger.debug(f" {i+1}. '{epg_row['name']}' (tvg_id: {epg_row['tvg_id']}) => score: {score}") + else: + logger.debug(f"Channel {chan['id']} '{chan['name']}' => no EPG entries with valid norm_name found") + continue + + # High confidence match - accept immediately + if best_score >= BEST_FUZZY_THRESHOLD: + chan["epg_data_id"] = best_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], best_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} (score={best_score})") + + # Medium confidence - use ML if available (lazy load models here) + elif best_score >= LOWER_FUZZY_THRESHOLD and ml_available: + logger.debug(f"Channel {chan['id']} entering ML matching path at {time.time()}") + + # Note: If experiencing 5+ second delays here, check if Celery Beat + # task 'scan_and_process_files' is running too frequently and blocking execution + + # Lazy load ML models only when we actually need them + if st_model is None: + logger.debug(f"Channel {chan['id']} about to load ML model at {time.time()}") + st_model, util = get_sentence_transformer() + logger.debug(f"Channel {chan['id']} finished loading ML model at {time.time()}") + + # Lazy generate embeddings only when we actually need them + if epg_embeddings is None and st_model and any(row.get("norm_name") for row in epg_data): + try: + logger.info("Generating embeddings for EPG data using ML model (lazy loading)") + epg_embeddings = st_model.encode( + [row["norm_name"] for row in epg_data if row.get("norm_name")], + convert_to_tensor=True + ) + except Exception as e: + logger.warning(f"Failed to generate embeddings: {e}") + epg_embeddings = None + + if epg_embeddings is not None and st_model: + try: + # Generate embedding for this channel + chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True) + + # Calculate similarity with all EPG embeddings + sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0] + top_index = int(sim_scores.argmax()) + top_value = float(sim_scores[top_index]) + + if top_value >= EMBED_SIM_THRESHOLD: + # Find the EPG entry that corresponds to this embedding index + epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] + matched_epg = epg_with_names[top_index] + + chan["epg_data_id"] = matched_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => matched EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") + else: + logger.info(f"Channel {chan['id']} '{chan['name']}' => fuzzy={best_score}, ML-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, trying last resort...") + + # Last resort: try ML with very low fuzzy threshold + if top_value >= 0.45: # Lower ML threshold as last resort + epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] + matched_epg = epg_with_names[top_index] + + chan["epg_data_id"] = matched_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => LAST RESORT match EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") + else: + logger.info(f"Channel {chan['id']} '{chan['name']}' => even last resort ML-sim {top_value:.2f} < 0.45, skipping") + + except Exception as e: + logger.warning(f"ML matching failed for channel {chan['id']}: {e}") + # Fall back to non-ML decision + logger.info(f"Channel {chan['id']} '{chan['name']}' => fuzzy score {best_score} below threshold, skipping") + + # Last resort: Try ML matching even with very low fuzzy scores + elif best_score >= 20 and ml_available: + logger.debug(f"Channel {chan['id']} entering last resort ML matching at {time.time()}") + + # Lazy load ML models for last resort attempts + if st_model is None: + logger.debug(f"Channel {chan['id']} loading ML model for last resort at {time.time()}") + st_model, util = get_sentence_transformer() + logger.debug(f"Channel {chan['id']} finished loading ML model for last resort at {time.time()}") + + # Lazy generate embeddings for last resort attempts + if epg_embeddings is None and st_model and any(row.get("norm_name") for row in epg_data): + try: + logger.info("Generating embeddings for EPG data using ML model (last resort lazy loading)") + epg_embeddings = st_model.encode( + [row["norm_name"] for row in epg_data if row.get("norm_name")], + convert_to_tensor=True + ) + except Exception as e: + logger.warning(f"Failed to generate embeddings for last resort: {e}") + epg_embeddings = None + + if epg_embeddings is not None and st_model: + try: + logger.info(f"Channel {chan['id']} '{chan['name']}' => trying ML as last resort (fuzzy={best_score})") + # Generate embedding for this channel + chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True) + + # Calculate similarity with all EPG embeddings + sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0] + top_index = int(sim_scores.argmax()) + top_value = float(sim_scores[top_index]) + + if top_value >= 0.50: # Even lower threshold for last resort + # Find the EPG entry that corresponds to this embedding index + epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] + matched_epg = epg_with_names[top_index] + + chan["epg_data_id"] = matched_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => DESPERATE LAST RESORT match EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") + else: + logger.info(f"Channel {chan['id']} '{chan['name']}' => desperate last resort ML-sim {top_value:.2f} < 0.50, giving up") + except Exception as e: + logger.warning(f"Last resort ML matching failed for channel {chan['id']}: {e}") + logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {LOWER_FUZZY_THRESHOLD}, giving up") + else: + # No ML available or very low fuzzy score + logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {LOWER_FUZZY_THRESHOLD}, no ML fallback available") + + return { + "channels_to_update": channels_to_update, + "matched_channels": matched_channels + } + @shared_task def match_epg_channels(): """ - Goes through all Channels and tries to find a matching EPGData row by: - 1) If channel.tvg_id is valid in EPGData, skip. - 2) If channel has a tvg_id but not found in EPGData, attempt direct EPGData lookup. - 3) Otherwise, perform name-based fuzzy matching with optional region-based bonus. - 4) If a match is found, we set channel.tvg_id - 5) Summarize and log results. + Uses integrated EPG matching instead of external script. + Provides the same functionality with better performance and maintainability. """ try: - logger.info("Starting EPG matching logic...") + logger.info("Starting integrated EPG matching...") - # Attempt to retrieve a "preferred-region" if configured + # Get region preference try: region_obj = CoreSettings.objects.get(key="preferred-region") region_code = region_obj.value.strip().lower() except CoreSettings.DoesNotExist: region_code = None - matched_channels = [] - channels_to_update = [] - # Get channels that don't have EPG data assigned channels_without_epg = Channel.objects.filter(epg_data__isnull=True) logger.info(f"Found {channels_without_epg.count()} channels without EPG data") - channels_json = [] + channels_data = [] for channel in channels_without_epg: - # Normalize TVG ID - strip whitespace and convert to lowercase normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" - if normalized_tvg_id: - logger.info(f"Processing channel {channel.id} '{channel.name}' with TVG ID='{normalized_tvg_id}'") - - channels_json.append({ + channels_data.append({ "id": channel.id, "name": channel.name, - "tvg_id": normalized_tvg_id, # Use normalized TVG ID - "original_tvg_id": channel.tvg_id, # Keep original for reference + "tvg_id": normalized_tvg_id, + "original_tvg_id": channel.tvg_id, "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, - "norm_chan": normalize_name(normalized_tvg_id if normalized_tvg_id else channel.name) + "norm_chan": normalize_name(channel.name) # Always use channel name for fuzzy matching! }) - # Similarly normalize EPG data TVG IDs - epg_json = [] + # Get all EPG data + epg_data = [] for epg in EPGData.objects.all(): normalized_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" - epg_json.append({ + epg_data.append({ 'id': epg.id, - 'tvg_id': normalized_tvg_id, # Use normalized TVG ID - 'original_tvg_id': epg.tvg_id, # Keep original for reference + 'tvg_id': normalized_tvg_id, + 'original_tvg_id': epg.tvg_id, 'name': epg.name, 'norm_name': normalize_name(epg.name), 'epg_source_id': epg.epg_source.id if epg.epg_source else None, }) - # Log available EPG data TVG IDs for debugging - unique_epg_tvg_ids = set(e['tvg_id'] for e in epg_json if e['tvg_id']) - logger.info(f"Available EPG TVG IDs: {', '.join(sorted(unique_epg_tvg_ids))}") + logger.info(f"Processing {len(channels_data)} channels against {len(epg_data)} EPG entries") - payload = { - "channels": channels_json, - "epg_data": epg_json, - "region_code": region_code, - } - - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - temp_file.write(json.dumps(payload).encode('utf-8')) - temp_file_path = temp_file.name - - # After writing to the file but before subprocess - # Explicitly delete the large data structures - del payload - gc.collect() - - process = subprocess.Popen( - ['python', '/app/scripts/epg_match.py', temp_file_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - stdout = '' - block_size = 1024 - - while True: - # Monitor stdout and stderr for readability - readable, _, _ = select.select([process.stdout, process.stderr], [], [], 1) # timeout of 1 second - - if not readable: # timeout expired - if process.poll() is not None: # check if process finished - break - else: # process still running, continue - continue - - for stream in readable: - if stream == process.stdout: - stdout += stream.read(block_size) - elif stream == process.stderr: - error = stream.readline() - if error: - logger.info(error.strip()) - - if process.poll() is not None: - break - - process.wait() - os.remove(temp_file_path) - - if process.returncode != 0: - return f"Failed to process EPG matching" - - result = json.loads(stdout) - # This returns lists of dicts, not model objects + # Run EPG matching + result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True) channels_to_update_dicts = result["channels_to_update"] matched_channels = result["matched_channels"] - # Explicitly clean up large objects - del stdout, result - gc.collect() - - # Convert your dict-based 'channels_to_update' into real Channel objects + # Update channels in database if channels_to_update_dicts: - # Extract IDs of the channels that need updates channel_ids = [d["id"] for d in channels_to_update_dicts] - - # Fetch them from DB channels_qs = Channel.objects.filter(id__in=channel_ids) channels_list = list(channels_qs) - # Build a map from channel_id -> epg_data_id (or whatever fields you need) - epg_mapping = { - d["id"]: d["epg_data_id"] for d in channels_to_update_dicts - } + # Create mapping from channel_id to epg_data_id + epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts} - # Populate each Channel object with the updated epg_data_id + # Update each channel with matched EPG data for channel_obj in channels_list: - # The script sets 'epg_data_id' in the returned dict - # We either assign directly, or fetch the EPGData instance if needed. - channel_obj.epg_data_id = epg_mapping.get(channel_obj.id) + epg_data_id = epg_mapping.get(channel_obj.id) + if epg_data_id: + try: + epg_data_obj = EPGData.objects.get(id=epg_data_id) + channel_obj.epg_data = epg_data_obj + except EPGData.DoesNotExist: + logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}") - # Now we have real model objects, so bulk_update will work + # Bulk update all channels Channel.objects.bulk_update(channels_list, ["epg_data"]) total_matched = len(matched_channels) @@ -209,9 +534,9 @@ def match_epg_channels(): else: logger.info("No new channels were matched.") - logger.info("Finished EPG matching logic.") + logger.info("Finished integrated EPG matching.") - # Send update with additional information for refreshing UI + # Send WebSocket update channel_layer = get_channel_layer() associations = [ {"channel_id": chan["id"], "epg_data_id": chan["epg_data_id"]} @@ -225,19 +550,19 @@ def match_epg_channels(): "data": { "success": True, "type": "epg_match", - "refresh_channels": True, # Flag to tell frontend to refresh channels + "refresh_channels": True, "matches_count": total_matched, "message": f"EPG matching complete: {total_matched} channel(s) matched", - "associations": associations # Add the associations data + "associations": associations } } ) return f"Done. Matched {total_matched} channel(s)." + finally: - # Final cleanup + # Memory cleanup gc.collect() - # Use our standardized cleanup function for more thorough memory management from core.utils import cleanup_memory cleanup_memory(log_usage=True, force_collection=True) @@ -245,16 +570,14 @@ def match_epg_channels(): @shared_task def match_single_channel_epg(channel_id): """ - Try to match a single channel with EPG data using optimized logic - that doesn't require loading all EPG data or running the external script. - Returns a dict with match status and message. + Try to match a single channel with EPG data using the integrated matching logic + that includes both fuzzy and ML-enhanced matching. Returns a dict with match status and message. """ try: from apps.channels.models import Channel from apps.epg.models import EPGData - import re - logger.info(f"Starting optimized single channel EPG matching for channel ID {channel_id}") + logger.info(f"Starting integrated single channel EPG matching for channel ID {channel_id}") # Get the channel try: @@ -266,112 +589,92 @@ def match_single_channel_epg(channel_id): if channel.epg_data: return {"matched": False, "message": f"Channel '{channel.name}' already has EPG data assigned"} - # Get region preference - try: - region_obj = CoreSettings.objects.get(key="preferred-region") - region_code = region_obj.value.strip().lower() - except CoreSettings.DoesNotExist: - region_code = None - - # Prepare channel data + # Prepare single channel data for matching (same format as bulk matching) normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" - normalized_channel_name = normalize_name(channel.name) + channel_data = { + "id": channel.id, + "name": channel.name, + "tvg_id": normalized_tvg_id, + "original_tvg_id": channel.tvg_id, + "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, + "norm_chan": normalize_name(channel.name) # Always use channel name for fuzzy matching! + } - logger.info(f"Matching channel '{channel.name}' (TVG ID: '{channel.tvg_id}') against EPG data") + logger.info(f"Channel data prepared: name='{channel.name}', tvg_id='{normalized_tvg_id}', norm_chan='{channel_data['norm_chan']}'") - # Step 1: Try exact TVG ID match first (most efficient) - if normalized_tvg_id: - epg_exact_match = EPGData.objects.filter(tvg_id__iexact=channel.tvg_id).first() - if epg_exact_match: - logger.info(f"Channel '{channel.name}' matched with EPG '{epg_exact_match.name}' by exact TVG ID match") - channel.epg_data = epg_exact_match - channel.save(update_fields=["epg_data"]) - return { - "matched": True, - "message": f"Channel '{channel.name}' matched with EPG '{epg_exact_match.name}' by exact TVG ID match" - } + # Debug: Test what the normalization does to preserve call signs + test_name = "NBC 11 (KVLY) - Fargo" # Example for testing + test_normalized = normalize_name(test_name) + logger.debug(f"DEBUG normalization example: '{test_name}' → '{test_normalized}' (call sign preserved)") - # Step 2: Try case-insensitive TVG ID match - if normalized_tvg_id: - epg_case_match = EPGData.objects.filter(tvg_id__icontains=normalized_tvg_id).first() - if epg_case_match: - logger.info(f"Channel '{channel.name}' matched with EPG '{epg_case_match.name}' by case-insensitive TVG ID match") - channel.epg_data = epg_case_match - channel.save(update_fields=["epg_data"]) - return { - "matched": True, - "message": f"Channel '{channel.name}' matched with EPG '{epg_case_match.name}' by case-insensitive TVG ID match" - } + # Get all EPG data for matching - must include norm_name field + epg_data_list = [] + for epg in EPGData.objects.filter(name__isnull=False).exclude(name=''): + normalized_epg_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" + epg_data_list.append({ + 'id': epg.id, + 'tvg_id': normalized_epg_tvg_id, + 'original_tvg_id': epg.tvg_id, + 'name': epg.name, + 'norm_name': normalize_name(epg.name), + 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + }) - # Step 3: Fuzzy name matching (only if name-based matching is needed) - if not normalized_channel_name: - return {"matched": False, "message": f"Channel '{channel.name}' has no usable name for matching"} + if not epg_data_list: + return {"matched": False, "message": "No EPG data available for matching"} - # Query EPG data with name filtering to reduce dataset - epg_candidates = EPGData.objects.filter(name__isnull=False).exclude(name='').values('id', 'name', 'tvg_id') - epg_count = epg_candidates.count() - logger.info(f"Fuzzy matching against {epg_count} EPG entries (optimized - not loading all EPG data)") + logger.info(f"Matching single channel '{channel.name}' against {len(epg_data_list)} EPG entries") - best_score = 0 - best_epg = None + # Use the EPG matching function + result = match_channels_to_epg([channel_data], epg_data_list) + channels_to_update = result.get("channels_to_update", []) + matched_channels = result.get("matched_channels", []) - for epg in epg_candidates: - if not epg['name']: - continue + if channels_to_update: + # Find our channel in the results + channel_match = None + for update in channels_to_update: + if update["id"] == channel.id: + channel_match = update + break - epg_normalized_name = normalize_name(epg['name']) - if not epg_normalized_name: - continue + if channel_match: + # Apply the match to the channel + try: + epg_data = EPGData.objects.get(id=channel_match['epg_data_id']) + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) - # Calculate base fuzzy score - base_score = fuzz.ratio(normalized_channel_name, epg_normalized_name) - bonus = 0 + # Find match details from matched_channels for better reporting + match_details = None + for match_info in matched_channels: + if match_info[0] == channel.id: # matched_channels format: (channel_id, channel_name, epg_info) + match_details = match_info + break - # Apply region-based bonus/penalty if applicable - if region_code and epg['tvg_id']: - combined_text = epg['tvg_id'].lower() + " " + epg['name'].lower() - dot_regions = re.findall(r'\.([a-z]{2})', combined_text) + success_msg = f"Channel '{channel.name}' matched with EPG '{epg_data.name}'" + if match_details: + success_msg += f" (matched via: {match_details[2]})" - if dot_regions: - if region_code in dot_regions: - bonus = 30 # Bigger bonus for matching region - else: - bonus = -15 # Penalty for different region - elif region_code in combined_text: - bonus = 15 + logger.info(success_msg) - final_score = base_score + bonus + return { + "matched": True, + "message": success_msg, + "epg_name": epg_data.name, + "epg_id": epg_data.id + } + except EPGData.DoesNotExist: + return {"matched": False, "message": "Matched EPG data not found"} - if final_score > best_score: - best_score = final_score - best_epg = epg - - # Apply matching thresholds (same as the ML script) - BEST_FUZZY_THRESHOLD = 85 - - if best_epg and best_score >= BEST_FUZZY_THRESHOLD: - try: - logger.info(f"Channel '{channel.name}' matched with EPG '{best_epg['name']}' (score: {best_score})") - epg_data = EPGData.objects.get(id=best_epg['id']) - channel.epg_data = epg_data - channel.save(update_fields=["epg_data"]) - - return { - "matched": True, - "message": f"Channel '{channel.name}' matched with EPG '{epg_data.name}' (score: {best_score})" - } - except EPGData.DoesNotExist: - return {"matched": False, "message": "Matched EPG data not found"} - - # No good match found - logger.info(f"No suitable EPG match found for channel '{channel.name}' (best score: {best_score})") + # No match found return { "matched": False, - "message": f"No suitable EPG match found for channel '{channel.name}' (best score: {best_score})" + "message": f"No suitable EPG match found for channel '{channel.name}'" } except Exception as e: - logger.error(f"Error in optimized single channel EPG matching: {e}", exc_info=True) + logger.error(f"Error in integrated single channel EPG matching: {e}", exc_info=True) return {"matched": False, "message": f"Error during matching: {str(e)}"} From fedc98f848a03ae860b85c260bd0457519860778 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 12:54:19 -0500 Subject: [PATCH 43/65] Removed unneeded debug logging. --- apps/channels/tasks.py | 73 ------------------------------------------ 1 file changed, 73 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 1619843f..12584bc3 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -259,68 +259,6 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True # Log the best score we found if best_epg: logger.info(f"Channel {chan['id']} '{chan['name']}' => best match: '{best_epg['name']}' (score: {best_score})") - - # Debug: Show some other potential matches for analysis - def score_epg_entry(epg_row): - base_score = fuzz.ratio(chan["norm_chan"], epg_row.get("norm_name", "")) - bonus = 0 - if region_code and epg_row.get("tvg_id"): - combined_text = epg_row["tvg_id"].lower() + " " + epg_row["name"].lower() - dot_regions = re.findall(r'\.([a-z]{2})', combined_text) - if dot_regions: - if region_code in dot_regions: - bonus = 30 - else: - bonus = -15 - elif region_code in combined_text: - bonus = 15 - return base_score + bonus - - # Check specifically for entries matching the channel's call sign or name parts - channel_keywords = chan["norm_chan"].split() - potential_matches = [] - for keyword in channel_keywords: - if len(keyword) >= 3: # Only check meaningful keywords - matching_entries = [row for row in epg_data if keyword.lower() in row['name'].lower() or keyword.lower() in row['tvg_id'].lower()] - potential_matches.extend(matching_entries) - - # Remove duplicates - unique_matches = [] - seen_ids = set() - for match in potential_matches: - if match['tvg_id'] not in seen_ids: - seen_ids.add(match['tvg_id']) - unique_matches.append(match) - - if unique_matches: - logger.info(f"Found {len(unique_matches)} entries containing channel keywords {channel_keywords}:") - for match_row in unique_matches: - match_score = score_epg_entry(match_row) - # Show original name vs normalized name to debug normalization - logger.info(f" Match: '{match_row['name']}' → normalized: '{match_row.get('norm_name', 'MISSING')}' (tvg_id: {match_row['tvg_id']}) => score: {match_score}") - else: - logger.warning(f"No entries found containing any of the channel keywords: {channel_keywords}") - - sorted_scores = sorted([(row, score_epg_entry(row)) for row in epg_data if row.get("norm_name") and score_epg_entry(row) > 20], key=lambda x: x[1], reverse=True) - - # Remove duplicates based on tvg_id - seen_tvg_ids = set() - unique_sorted_scores = [] - for row, score in sorted_scores: - if row['tvg_id'] not in seen_tvg_ids: - seen_tvg_ids.add(row['tvg_id']) - unique_sorted_scores.append((row, score)) - - logger.debug(f"Channel {chan['id']} '{chan['name']}' => top 10 unique fuzzy matches:") - for i, (epg_row, score) in enumerate(unique_sorted_scores[:10]): - # Highlight entries that contain any of the channel's keywords - channel_keywords = chan["norm_chan"].split() - is_keyword_match = any(keyword in epg_row['name'].lower() or keyword in epg_row['tvg_id'].lower() for keyword in channel_keywords if len(keyword) >= 3) - - if is_keyword_match: - logger.info(f" {i+1}. 🎯 KEYWORD MATCH: '{epg_row['name']}' (tvg_id: {epg_row['tvg_id']}) => score: {score} (norm_name: '{epg_row.get('norm_name', 'MISSING')}')") - else: - logger.debug(f" {i+1}. '{epg_row['name']}' (tvg_id: {epg_row['tvg_id']}) => score: {score}") else: logger.debug(f"Channel {chan['id']} '{chan['name']}' => no EPG entries with valid norm_name found") continue @@ -334,16 +272,9 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True # Medium confidence - use ML if available (lazy load models here) elif best_score >= LOWER_FUZZY_THRESHOLD and ml_available: - logger.debug(f"Channel {chan['id']} entering ML matching path at {time.time()}") - - # Note: If experiencing 5+ second delays here, check if Celery Beat - # task 'scan_and_process_files' is running too frequently and blocking execution - # Lazy load ML models only when we actually need them if st_model is None: - logger.debug(f"Channel {chan['id']} about to load ML model at {time.time()}") st_model, util = get_sentence_transformer() - logger.debug(f"Channel {chan['id']} finished loading ML model at {time.time()}") # Lazy generate embeddings only when we actually need them if epg_embeddings is None and st_model and any(row.get("norm_name") for row in epg_data): @@ -398,13 +329,9 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True # Last resort: Try ML matching even with very low fuzzy scores elif best_score >= 20 and ml_available: - logger.debug(f"Channel {chan['id']} entering last resort ML matching at {time.time()}") - # Lazy load ML models for last resort attempts if st_model is None: - logger.debug(f"Channel {chan['id']} loading ML model for last resort at {time.time()}") st_model, util = get_sentence_transformer() - logger.debug(f"Channel {chan['id']} finished loading ML model for last resort at {time.time()}") # Lazy generate embeddings for last resort attempts if epg_embeddings is None and st_model and any(row.get("norm_name") for row in epg_data): From c55dcfd26a29bb3e853a331d5032a8fac7dc3200 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 13:01:43 -0500 Subject: [PATCH 44/65] Remove unnecessary checking of cache directories. Lets sentence transformers handle it. --- apps/channels/tasks.py | 90 ++++++++---------------------------------- 1 file changed, 17 insertions(+), 73 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 12584bc3..ac3c4f0d 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -30,9 +30,7 @@ logger = logging.getLogger(__name__) # Lazy loading for ML models - only imported/loaded when needed _ml_model_cache = { - 'sentence_transformer': None, - 'model_path': os.path.join("/data", "models", "all-MiniLM-L6-v2"), # Use /data for persistence - 'model_name': "sentence-transformers/all-MiniLM-L6-v2" + 'sentence_transformer': None } def get_sentence_transformer(): @@ -42,82 +40,28 @@ def get_sentence_transformer(): from sentence_transformers import SentenceTransformer from sentence_transformers import util - model_path = _ml_model_cache['model_path'] - model_name = _ml_model_cache['model_name'] - cache_dir = os.path.dirname(model_path) # /data/models + model_name = "sentence-transformers/all-MiniLM-L6-v2" + cache_dir = "/data/models" # Check environment variable to disable downloads disable_downloads = os.environ.get('DISABLE_ML_DOWNLOADS', 'false').lower() == 'true' - # Ensure directory exists and is writable + if disable_downloads: + # Check if model exists before attempting to load + hf_model_path = os.path.join(cache_dir, f"models--{model_name.replace('/', '--')}") + if not os.path.exists(hf_model_path): + logger.warning("ML model not found and downloads disabled (DISABLE_ML_DOWNLOADS=true). Skipping ML matching.") + return None, None + + # Ensure cache directory exists os.makedirs(cache_dir, exist_ok=True) - # Debug: List what's actually in the cache directory - try: - if os.path.exists(cache_dir): - logger.info(f"Cache directory contents: {os.listdir(cache_dir)}") - for item in os.listdir(cache_dir): - item_path = os.path.join(cache_dir, item) - if os.path.isdir(item_path): - logger.info(f" Subdirectory '{item}' contains: {os.listdir(item_path)}") - except Exception as e: - logger.info(f"Could not list cache directory: {e}") - - # Check if model files exist in our expected location - config_path = os.path.join(model_path, "config.json") - - logger.info(f"Checking for cached model at {model_path}") - logger.info(f"Config exists: {os.path.exists(config_path)}") - - # Also check if the model exists in the sentence-transformers default naming convention - alt_model_name = model_name.replace("/", "_") - alt_model_path = os.path.join(cache_dir, alt_model_name) - alt_config_path = os.path.join(alt_model_path, "config.json") - logger.info(f"Alternative path check - {alt_model_path}, config exists: {os.path.exists(alt_config_path)}") - - # Check for Hugging Face Hub cache format (newer format) - hf_model_name = f"models--{model_name.replace('/', '--')}" - hf_model_path = os.path.join(cache_dir, hf_model_name) - hf_snapshots_path = os.path.join(hf_model_path, "snapshots") - - logger.info(f"Hugging Face cache path check - {hf_model_path}, snapshots exists: {os.path.exists(hf_snapshots_path)}") - - # If HF cache exists, find the latest snapshot - hf_config_exists = False - hf_snapshot_path = None - if os.path.exists(hf_snapshots_path): - try: - snapshots = os.listdir(hf_snapshots_path) - if snapshots: - # Use the first (and likely only) snapshot - hf_snapshot_path = os.path.join(hf_snapshots_path, snapshots[0]) - hf_config_path = os.path.join(hf_snapshot_path, "config.json") - hf_config_exists = os.path.exists(hf_config_path) - logger.info(f"HF snapshot path: {hf_snapshot_path}, config exists: {hf_config_exists}") - except Exception as e: - logger.info(f"Error checking HF cache: {e}") - - # First try to load from our specific path - if os.path.exists(config_path): - logger.info(f"Loading cached sentence transformer from {model_path}") - _ml_model_cache['sentence_transformer'] = SentenceTransformer(model_path) - elif os.path.exists(alt_config_path): - logger.info(f"Loading cached sentence transformer from alternative path {alt_model_path}") - _ml_model_cache['sentence_transformer'] = SentenceTransformer(alt_model_path) - elif hf_config_exists and hf_snapshot_path: - logger.info(f"Loading cached sentence transformer from HF cache {hf_snapshot_path}") - _ml_model_cache['sentence_transformer'] = SentenceTransformer(hf_snapshot_path) - elif disable_downloads: - logger.warning(f"ML model not found and downloads disabled (DISABLE_ML_DOWNLOADS=true). Skipping ML matching.") - return None, None - else: - logger.info(f"Model cache not found, downloading {model_name}") - # Let sentence-transformers handle the download with its cache folder - _ml_model_cache['sentence_transformer'] = SentenceTransformer( - model_name, - cache_folder=cache_dir - ) - logger.info(f"Model downloaded and loaded successfully") + # Let sentence-transformers handle all cache detection and management + logger.info(f"Loading sentence transformer model (cache: {cache_dir})") + _ml_model_cache['sentence_transformer'] = SentenceTransformer( + model_name, + cache_folder=cache_dir + ) return _ml_model_cache['sentence_transformer'], util except ImportError: From d6bb9e40b2f6547df490ae2b32c089a34199df06 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 13:15:32 -0500 Subject: [PATCH 45/65] Implement memory cleanup for ML models after channel matching operations --- apps/channels/tasks.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index ac3c4f0d..7e645a71 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -318,6 +318,12 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True # No ML available or very low fuzzy score logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {LOWER_FUZZY_THRESHOLD}, no ML fallback available") + # Clean up ML models from memory after matching (infrequent operation) + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + return { "channels_to_update": channels_to_update, "matched_channels": matched_channels @@ -432,6 +438,11 @@ def match_epg_channels(): return f"Done. Matched {total_matched} channel(s)." finally: + # Clean up ML models from memory after bulk matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + # Memory cleanup gc.collect() from core.utils import cleanup_memory @@ -529,6 +540,12 @@ def match_single_channel_epg(channel_id): logger.info(success_msg) + # Clean up ML models from memory after single channel matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + return { "matched": True, "message": success_msg, @@ -539,6 +556,12 @@ def match_single_channel_epg(channel_id): return {"matched": False, "message": "Matched EPG data not found"} # No match found + # Clean up ML models from memory after single channel matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + return { "matched": False, "message": f"No suitable EPG match found for channel '{channel.name}'" @@ -546,6 +569,13 @@ def match_single_channel_epg(channel_id): except Exception as e: logger.error(f"Error in integrated single channel EPG matching: {e}", exc_info=True) + + # Clean up ML models from memory even on error + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory after error") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + return {"matched": False, "message": f"Error during matching: {str(e)}"} From 6384f4f56ff217ae0005560d835c1f5b444646dc Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 13:47:59 -0500 Subject: [PATCH 46/65] Add progress notifications for EPG matching process --- apps/channels/tasks.py | 75 +++++++++++++++++++++-- frontend/src/WebSocket.jsx | 45 ++++++++++++++ frontend/src/components/forms/Channel.jsx | 5 ++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 7e645a71..5ad291a1 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -28,6 +28,36 @@ from urllib.parse import quote logger = logging.getLogger(__name__) +def send_epg_matching_progress(total_channels, matched_channels, current_channel_name="", stage="matching"): + """ + Send EPG matching progress via WebSocket + """ + try: + channel_layer = get_channel_layer() + if channel_layer: + progress_data = { + 'type': 'epg_matching_progress', + 'total': total_channels, + 'matched': len(matched_channels) if isinstance(matched_channels, list) else matched_channels, + 'remaining': total_channels - (len(matched_channels) if isinstance(matched_channels, list) else matched_channels), + 'current_channel': current_channel_name, + 'stage': stage, + 'progress_percent': round((len(matched_channels) if isinstance(matched_channels, list) else matched_channels) / total_channels * 100, 1) if total_channels > 0 else 0 + } + + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": { + "type": "epg_matching_progress", + **progress_data + } + } + ) + except Exception as e: + logger.warning(f"Failed to send EPG matching progress: {e}") + # Lazy loading for ML models - only imported/loaded when needed _ml_model_cache = { 'sentence_transformer': None @@ -121,13 +151,18 @@ def normalize_name(name: str) -> str: norm = " ".join(tokens).strip() return norm -def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True): +def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True, send_progress=True): """ EPG matching logic that finds the best EPG matches for channels using multiple matching strategies including fuzzy matching and ML models. """ channels_to_update = [] matched_channels = [] + total_channels = len(channels_data) + + # Send initial progress + if send_progress: + send_epg_matching_progress(total_channels, 0, stage="starting") # Try to get ML models if requested (but don't load yet - lazy loading) st_model, util = None, None @@ -135,7 +170,18 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True ml_available = use_ml # Process each channel - for chan in channels_data: + for index, chan in enumerate(channels_data): + normalized_tvg_id = chan.get("tvg_id", "") + fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] + + # Send progress update every 5 channels or for the first few + if send_progress and (index < 5 or index % 5 == 0 or index == total_channels - 1): + send_epg_matching_progress( + total_channels, + len(matched_channels), + current_channel_name=chan["name"][:50], # Truncate long names + stage="matching" + ) normalized_tvg_id = chan.get("tvg_id", "") fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] @@ -324,6 +370,14 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True _ml_model_cache['sentence_transformer'] = None gc.collect() + # Send final progress update + if send_progress: + send_epg_matching_progress( + total_channels, + len(matched_channels), + stage="completed" + ) + return { "channels_to_update": channels_to_update, "matched_channels": matched_channels @@ -376,8 +430,8 @@ def match_epg_channels(): logger.info(f"Processing {len(channels_data)} channels against {len(epg_data)} EPG entries") - # Run EPG matching - result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True) + # Run EPG matching with progress updates + result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True) channels_to_update_dicts = result["channels_to_update"] matched_channels = result["matched_channels"] @@ -507,8 +561,11 @@ def match_single_channel_epg(channel_id): logger.info(f"Matching single channel '{channel.name}' against {len(epg_data_list)} EPG entries") - # Use the EPG matching function - result = match_channels_to_epg([channel_data], epg_data_list) + # Send progress for single channel matching + send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="matching") + + # Use the EPG matching function (no progress updates for single channel to avoid spam) + result = match_channels_to_epg([channel_data], epg_data_list, send_progress=False) channels_to_update = result.get("channels_to_update", []) matched_channels = result.get("matched_channels", []) @@ -540,6 +597,9 @@ def match_single_channel_epg(channel_id): logger.info(success_msg) + # Send completion progress for single channel + send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed") + # Clean up ML models from memory after single channel matching if _ml_model_cache['sentence_transformer'] is not None: logger.info("Cleaning up ML models from memory") @@ -556,6 +616,9 @@ def match_single_channel_epg(channel_id): return {"matched": False, "message": "Matched EPG data not found"} # No match found + # Send completion progress for single channel (failed) + send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed") + # Clean up ML models from memory after single channel matching if _ml_model_cache['sentence_transformer'] is not None: logger.info("Cleaning up ML models from memory") diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index d917d115..2c57a37e 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -330,6 +330,51 @@ export const WebsocketProvider = ({ children }) => { } break; + case 'epg_matching_progress': { + const progress = parsedEvent.data; + const id = 'epg-matching-progress'; + + if (progress.stage === 'starting') { + notifications.show({ + id, + title: 'EPG Matching in Progress', + message: `Starting to match ${progress.total} channels...`, + color: 'blue.5', + autoClose: false, + withCloseButton: false, + loading: true, + }); + } else if (progress.stage === 'matching') { + let message = `Matched ${progress.matched} of ${progress.total} channels`; + if (progress.remaining > 0) { + message += ` (${progress.remaining} remaining)`; + } + if (progress.current_channel) { + message += `\nCurrently processing: ${progress.current_channel}`; + } + + notifications.update({ + id, + title: 'EPG Matching in Progress', + message, + color: 'blue.5', + autoClose: false, + withCloseButton: false, + loading: true, + }); + } else if (progress.stage === 'completed') { + notifications.update({ + id, + title: 'EPG Matching Complete', + message: `Successfully matched ${progress.matched} of ${progress.total} channels (${progress.progress_percent}%)`, + color: progress.matched > 0 ? 'green.5' : 'orange', + loading: false, + autoClose: 6000, + }); + } + break; + } + case 'm3u_profile_test': setProfilePreview( parsedEvent.data.search_preview, diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index d3d6a94b..6e5eabec 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -78,6 +78,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); const [groupFilter, setGroupFilter] = useState(''); + const [autoMatchLoading, setAutoMatchLoading] = useState(false); const groupOptions = Object.values(channelGroups); const addStream = (stream) => { @@ -132,6 +133,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { return; } + setAutoMatchLoading(true); try { const response = await API.matchChannelEpg(channel.id); @@ -160,6 +162,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { color: 'red', }); console.error('Auto-match error:', error); + } finally { + setAutoMatchLoading(false); } }; @@ -758,6 +762,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { handleAutoMatchEpg(); }} disabled={!channel || !channel.id} + loading={autoMatchLoading} title={ !channel || !channel.id ? 'Auto-match is only available for existing channels' From c7235f66bac226181e51e1bb3e4a90ffb5e5a59c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 14:12:45 -0500 Subject: [PATCH 47/65] Use stricter matching for bulk matching. --- apps/channels/tasks.py | 51 ++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 5ad291a1..f2ff1a2b 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -155,6 +155,10 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True """ EPG matching logic that finds the best EPG matches for channels using multiple matching strategies including fuzzy matching and ML models. + + Automatically uses conservative thresholds for bulk matching (multiple channels) + to avoid bad matches that create user cleanup work, and aggressive thresholds + for single channel matching where users specifically requested a match attempt. """ channels_to_update = [] matched_channels = [] @@ -169,7 +173,26 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True epg_embeddings = None ml_available = use_ml - # Process each channel + # Automatically determine matching strategy based on number of channels + is_bulk_matching = len(channels_data) > 1 + + # Adjust matching thresholds based on operation type + if is_bulk_matching: + # Conservative thresholds for bulk matching to avoid creating cleanup work + FUZZY_HIGH_CONFIDENCE = 90 # Only very high fuzzy scores + FUZZY_MEDIUM_CONFIDENCE = 70 # Higher threshold for ML enhancement + ML_HIGH_CONFIDENCE = 0.75 # Higher ML confidence required + ML_LAST_RESORT = 0.65 # More conservative last resort + FUZZY_LAST_RESORT_MIN = 50 # Higher fuzzy minimum for last resort + logger.info(f"Using conservative thresholds for bulk matching ({total_channels} channels)") + else: + # More aggressive thresholds for single channel matching (user requested specific match) + FUZZY_HIGH_CONFIDENCE = 85 # Original threshold + FUZZY_MEDIUM_CONFIDENCE = 40 # Original threshold + ML_HIGH_CONFIDENCE = 0.65 # Original threshold + ML_LAST_RESORT = 0.50 # Original desperate threshold + FUZZY_LAST_RESORT_MIN = 20 # Original minimum + logger.info("Using aggressive thresholds for single channel matching") # Process each channel for index, chan in enumerate(channels_data): normalized_tvg_id = chan.get("tvg_id", "") fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] @@ -254,14 +277,14 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True continue # High confidence match - accept immediately - if best_score >= BEST_FUZZY_THRESHOLD: + if best_score >= FUZZY_HIGH_CONFIDENCE: chan["epg_data_id"] = best_epg["id"] channels_to_update.append(chan) matched_channels.append((chan['id'], chan['name'], best_epg["tvg_id"])) logger.info(f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} (score={best_score})") # Medium confidence - use ML if available (lazy load models here) - elif best_score >= LOWER_FUZZY_THRESHOLD and ml_available: + elif best_score >= FUZZY_MEDIUM_CONFIDENCE and ml_available: # Lazy load ML models only when we actually need them if st_model is None: st_model, util = get_sentence_transformer() @@ -288,7 +311,7 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True top_index = int(sim_scores.argmax()) top_value = float(sim_scores[top_index]) - if top_value >= EMBED_SIM_THRESHOLD: + if top_value >= ML_HIGH_CONFIDENCE: # Find the EPG entry that corresponds to this embedding index epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] matched_epg = epg_with_names[top_index] @@ -298,10 +321,10 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) logger.info(f"Channel {chan['id']} '{chan['name']}' => matched EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") else: - logger.info(f"Channel {chan['id']} '{chan['name']}' => fuzzy={best_score}, ML-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, trying last resort...") + logger.info(f"Channel {chan['id']} '{chan['name']}' => fuzzy={best_score}, ML-sim={top_value:.2f} < {ML_HIGH_CONFIDENCE}, trying last resort...") # Last resort: try ML with very low fuzzy threshold - if top_value >= 0.45: # Lower ML threshold as last resort + if top_value >= ML_LAST_RESORT: # Dynamic last resort threshold epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] matched_epg = epg_with_names[top_index] @@ -310,7 +333,7 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) logger.info(f"Channel {chan['id']} '{chan['name']}' => LAST RESORT match EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") else: - logger.info(f"Channel {chan['id']} '{chan['name']}' => even last resort ML-sim {top_value:.2f} < 0.45, skipping") + logger.info(f"Channel {chan['id']} '{chan['name']}' => even last resort ML-sim {top_value:.2f} < {ML_LAST_RESORT}, skipping") except Exception as e: logger.warning(f"ML matching failed for channel {chan['id']}: {e}") @@ -318,7 +341,7 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True logger.info(f"Channel {chan['id']} '{chan['name']}' => fuzzy score {best_score} below threshold, skipping") # Last resort: Try ML matching even with very low fuzzy scores - elif best_score >= 20 and ml_available: + elif best_score >= FUZZY_LAST_RESORT_MIN and ml_available: # Lazy load ML models for last resort attempts if st_model is None: st_model, util = get_sentence_transformer() @@ -346,7 +369,7 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True top_index = int(sim_scores.argmax()) top_value = float(sim_scores[top_index]) - if top_value >= 0.50: # Even lower threshold for last resort + if top_value >= ML_LAST_RESORT: # Dynamic threshold for desperate attempts # Find the EPG entry that corresponds to this embedding index epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] matched_epg = epg_with_names[top_index] @@ -356,13 +379,13 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) logger.info(f"Channel {chan['id']} '{chan['name']}' => DESPERATE LAST RESORT match EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") else: - logger.info(f"Channel {chan['id']} '{chan['name']}' => desperate last resort ML-sim {top_value:.2f} < 0.50, giving up") + logger.info(f"Channel {chan['id']} '{chan['name']}' => desperate last resort ML-sim {top_value:.2f} < {ML_LAST_RESORT}, giving up") except Exception as e: logger.warning(f"Last resort ML matching failed for channel {chan['id']}: {e}") - logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {LOWER_FUZZY_THRESHOLD}, giving up") + logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {FUZZY_MEDIUM_CONFIDENCE}, giving up") else: # No ML available or very low fuzzy score - logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {LOWER_FUZZY_THRESHOLD}, no ML fallback available") + logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {FUZZY_MEDIUM_CONFIDENCE}, no ML fallback available") # Clean up ML models from memory after matching (infrequent operation) if _ml_model_cache['sentence_transformer'] is not None: @@ -430,7 +453,7 @@ def match_epg_channels(): logger.info(f"Processing {len(channels_data)} channels against {len(epg_data)} EPG entries") - # Run EPG matching with progress updates + # Run EPG matching with progress updates - automatically uses conservative thresholds for bulk operations result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True) channels_to_update_dicts = result["channels_to_update"] matched_channels = result["matched_channels"] @@ -564,7 +587,7 @@ def match_single_channel_epg(channel_id): # Send progress for single channel matching send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="matching") - # Use the EPG matching function (no progress updates for single channel to avoid spam) + # Use the EPG matching function - automatically uses aggressive thresholds for single channel result = match_channels_to_epg([channel_data], epg_data_list, send_progress=False) channels_to_update = result.get("channels_to_update", []) matched_channels = result.get("matched_channels", []) From 20685b8344f3474bde351e5cba29562317852b2a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 14:27:07 -0500 Subject: [PATCH 48/65] Lower regional bonus. Remove epg_match script. --- apps/channels/tasks.py | 4 +- scripts/epg_match.py | 182 ----------------------------------------- 2 files changed, 2 insertions(+), 184 deletions(-) delete mode 100644 scripts/epg_match.py diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index f2ff1a2b..17d40ca2 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -253,11 +253,11 @@ def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True if dot_regions: if region_code in dot_regions: - bonus = 30 # Bigger bonus for matching region + bonus = 15 # Bigger bonus for matching region else: bonus = -15 # Penalty for different region elif region_code in combined_text: - bonus = 15 + bonus = 10 score = base_score + bonus diff --git a/scripts/epg_match.py b/scripts/epg_match.py deleted file mode 100644 index 890ffe3a..00000000 --- a/scripts/epg_match.py +++ /dev/null @@ -1,182 +0,0 @@ -# ml_model.py - -import sys -import json -import re -import os -import logging - -from rapidfuzz import fuzz -from sentence_transformers import util -from sentence_transformers import SentenceTransformer as st - -# Set up logger -logger = logging.getLogger(__name__) - -# Load the sentence-transformers model once at the module level -SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" -MODEL_PATH = os.path.join("/app", "models", "all-MiniLM-L6-v2") - -# Thresholds -BEST_FUZZY_THRESHOLD = 85 -LOWER_FUZZY_THRESHOLD = 40 -EMBED_SIM_THRESHOLD = 0.65 - -def process_data(input_data): - os.makedirs(MODEL_PATH, exist_ok=True) - - # If not present locally, download: - if not os.path.exists(os.path.join(MODEL_PATH, "config.json")): - logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...") - st_model = st(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH) - else: - logger.info(f"Loading local model from {MODEL_PATH}") - st_model = st(MODEL_PATH) - - channels = input_data["channels"] - epg_data = input_data["epg_data"] - region_code = input_data.get("region_code", None) - - epg_embeddings = None - if any(row["norm_name"] for row in epg_data): - epg_embeddings = st_model.encode( - [row["norm_name"] for row in epg_data], - convert_to_tensor=True - ) - - channels_to_update = [] - matched_channels = [] - - for chan in channels: - normalized_tvg_id = chan.get("tvg_id", "") - fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] - - # Exact TVG ID match (direct match) - epg_by_tvg_id = next((epg for epg in epg_data if epg["tvg_id"] == normalized_tvg_id), None) - if normalized_tvg_id and epg_by_tvg_id: - chan["epg_data_id"] = epg_by_tvg_id["id"] - channels_to_update.append(chan) - - # Add to matched_channels list so it's counted in the total - matched_channels.append((chan['id'], fallback_name, epg_by_tvg_id["tvg_id"])) - - logger.info(f"Channel {chan['id']} '{fallback_name}' => EPG found by tvg_id={epg_by_tvg_id['tvg_id']}") - continue - - # If channel has a tvg_id that doesn't exist in EPGData, do direct check. - # I don't THINK this should happen now that we assign EPG on channel creation. - if chan["tvg_id"]: - epg_match = [epg["id"] for epg in epg_data if epg["tvg_id"] == chan["tvg_id"]] - if epg_match: - chan["epg_data_id"] = epg_match[0] - logger.info(f"Channel {chan['id']} '{chan['name']}' => EPG found by tvg_id={chan['tvg_id']}") - channels_to_update.append(chan) - continue - - # C) Perform name-based fuzzy matching - if not chan["norm_chan"]: - logger.debug(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping") - continue - - best_score = 0 - best_epg = None - for row in epg_data: - if not row["norm_name"]: - continue - - base_score = fuzz.ratio(chan["norm_chan"], row["norm_name"]) - bonus = 0 - # Region-based bonus/penalty - combined_text = row["tvg_id"].lower() + " " + row["name"].lower() - dot_regions = re.findall(r'\.([a-z]{2})', combined_text) - if region_code: - if dot_regions: - if region_code in dot_regions: - bonus = 30 # bigger bonus if .us or .ca matches - else: - bonus = -15 - elif region_code in combined_text: - bonus = 15 - score = base_score + bonus - - logger.debug( - f"Channel {chan['id']} '{fallback_name}' => EPG row {row['id']}: " - f"name='{row['name']}', norm_name='{row['norm_name']}', " - f"combined_text='{combined_text}', dot_regions={dot_regions}, " - f"base_score={base_score}, bonus={bonus}, total_score={score}" - ) - - if score > best_score: - best_score = score - best_epg = row - - # If no best match was found, skip - if not best_epg: - logger.debug(f"Channel {chan['id']} '{fallback_name}' => no EPG match at all.") - continue - - # If best_score is above BEST_FUZZY_THRESHOLD => direct accept - if best_score >= BEST_FUZZY_THRESHOLD: - chan["epg_data_id"] = best_epg["id"] - channels_to_update.append(chan) - - matched_channels.append((chan['id'], fallback_name, best_epg["tvg_id"])) - logger.info( - f"Channel {chan['id']} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} " - f"(score={best_score})" - ) - - # If best_score is in the “middle range,” do embedding check - elif best_score >= LOWER_FUZZY_THRESHOLD and epg_embeddings is not None: - chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True) - sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0] - top_index = int(sim_scores.argmax()) - top_value = float(sim_scores[top_index]) - if top_value >= EMBED_SIM_THRESHOLD: - matched_epg = epg_data[top_index] - chan["epg_data_id"] = matched_epg["id"] - channels_to_update.append(chan) - - matched_channels.append((chan['id'], fallback_name, matched_epg["tvg_id"])) - logger.info( - f"Channel {chan['id']} '{fallback_name}' => matched EPG tvg_id={matched_epg['tvg_id']} " - f"(fuzzy={best_score}, cos-sim={top_value:.2f})" - ) - else: - logger.info( - f"Channel {chan['id']} '{fallback_name}' => fuzzy={best_score}, " - f"cos-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, skipping" - ) - else: - # No good match found - fuzzy score is too low - logger.info( - f"Channel {chan['id']} '{fallback_name}' => best fuzzy match score={best_score} < {LOWER_FUZZY_THRESHOLD}, skipping" - ) - - return { - "channels_to_update": channels_to_update, - "matched_channels": matched_channels - } - -def main(): - # Configure logging - logging_level = os.environ.get('DISPATCHARR_LOG_LEVEL', 'INFO') - logging.basicConfig( - level=getattr(logging, logging_level), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - stream=sys.stderr - ) - - # Read input data from a file - input_file_path = sys.argv[1] - with open(input_file_path, 'r') as f: - input_data = json.load(f) - - # Process data with the ML model (or your logic) - result = process_data(input_data) - - # Output result to stdout - print(json.dumps(result)) - -if __name__ == "__main__": - main() From 60e378b1ced71931d02e76e956c509e048c74d8b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 14:38:16 -0500 Subject: [PATCH 49/65] Add support for matching selected channels with EPG data - Updated API to accept optional channel IDs for EPG matching. - Enhanced match_epg method to process only specified channels if provided. - Implemented new task for matching selected channels in the backend. - Updated frontend to trigger EPG matching for selected channels with notifications. --- apps/channels/api_views.py | 30 +++- apps/channels/tasks.py | 144 ++++++++++++++++++ frontend/src/api.js | 8 +- .../ChannelsTable/ChannelTableHeader.jsx | 24 ++- 4 files changed, 195 insertions(+), 11 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index e522b618..4e3851b7 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -39,7 +39,7 @@ from .serializers import ( ChannelProfileSerializer, RecordingSerializer, ) -from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg +from .tasks import match_epg_channels, evaluate_series_rules, evaluate_series_rules_impl, match_single_channel_epg, match_selected_channels_epg import django_filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter @@ -779,14 +779,36 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── @swagger_auto_schema( method="post", - operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data.", + 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.' + ) + } + ), responses={202: "EPG matching task initiated"}, ) @action(detail=False, methods=["post"], url_path="match-epg") def match_epg(self, request): - match_epg_channels.delay() + # Get channel IDs from request body if provided + channel_ids = request.data.get('channel_ids', []) + + if channel_ids: + # Process only selected channels + from .tasks import match_selected_channels_epg + match_selected_channels_epg.delay(channel_ids) + message = f"EPG matching task initiated for {len(channel_ids)} selected channel(s)." + else: + # Process all channels without EPG (original behavior) + match_epg_channels.delay() + message = "EPG matching task initiated for all channels without EPG." + return Response( - {"message": "EPG matching task initiated."}, status=status.HTTP_202_ACCEPTED + {"message": message}, status=status.HTTP_202_ACCEPTED ) @swagger_auto_schema( diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 17d40ca2..6352ee84 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -526,6 +526,150 @@ def match_epg_channels(): cleanup_memory(log_usage=True, force_collection=True) +@shared_task +def match_selected_channels_epg(channel_ids): + """ + Match EPG data for only the specified selected channels. + Uses the same integrated EPG matching logic but processes only selected channels. + """ + try: + logger.info(f"Starting integrated EPG matching for {len(channel_ids)} selected channels...") + + # Get region preference + try: + region_obj = CoreSettings.objects.get(key="preferred-region") + region_code = region_obj.value.strip().lower() + except CoreSettings.DoesNotExist: + region_code = None + + # Get only the specified channels that don't have EPG data assigned + channels_without_epg = Channel.objects.filter( + id__in=channel_ids, + epg_data__isnull=True + ) + logger.info(f"Found {channels_without_epg.count()} selected channels without EPG data") + + if not channels_without_epg.exists(): + logger.info("No selected channels need EPG matching.") + + # Send WebSocket update + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": { + "success": True, + "type": "epg_match", + "refresh_channels": True, + "matches_count": 0, + "message": "No selected channels need EPG matching", + "associations": [] + } + } + ) + return "No selected channels needed EPG matching." + + channels_data = [] + for channel in channels_without_epg: + normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" + channels_data.append({ + "id": channel.id, + "name": channel.name, + "tvg_id": normalized_tvg_id, + "original_tvg_id": channel.tvg_id, + "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, + "norm_chan": normalize_name(channel.name) + }) + + # Get all EPG data + epg_data = [] + for epg in EPGData.objects.all(): + normalized_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" + epg_data.append({ + 'id': epg.id, + 'tvg_id': normalized_tvg_id, + 'original_tvg_id': epg.tvg_id, + 'name': epg.name, + 'norm_name': normalize_name(epg.name), + 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + }) + + logger.info(f"Processing {len(channels_data)} selected channels against {len(epg_data)} EPG entries") + + # Run EPG matching with progress updates - automatically uses appropriate thresholds + result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True) + channels_to_update_dicts = result["channels_to_update"] + matched_channels = result["matched_channels"] + + # Update channels in database + if channels_to_update_dicts: + channel_ids_to_update = [d["id"] for d in channels_to_update_dicts] + channels_qs = Channel.objects.filter(id__in=channel_ids_to_update) + channels_list = list(channels_qs) + + # Create mapping from channel_id to epg_data_id + epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts} + + # Update each channel with matched EPG data + for channel_obj in channels_list: + epg_data_id = epg_mapping.get(channel_obj.id) + if epg_data_id: + try: + epg_data_obj = EPGData.objects.get(id=epg_data_id) + channel_obj.epg_data = epg_data_obj + except EPGData.DoesNotExist: + logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}") + + # Bulk update all channels + Channel.objects.bulk_update(channels_list, ["epg_data"]) + + total_matched = len(matched_channels) + if total_matched: + logger.info(f"Selected Channel Match Summary: {total_matched} channel(s) matched.") + for (cid, cname, tvg) in matched_channels: + logger.info(f" - Channel ID={cid}, Name='{cname}' => tvg_id='{tvg}'") + else: + logger.info("No selected channels were matched.") + + logger.info("Finished integrated EPG matching for selected channels.") + + # Send WebSocket update + channel_layer = get_channel_layer() + associations = [ + {"channel_id": chan["id"], "epg_data_id": chan["epg_data_id"]} + for chan in channels_to_update_dicts + ] + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": { + "success": True, + "type": "epg_match", + "refresh_channels": True, + "matches_count": total_matched, + "message": f"EPG matching complete: {total_matched} selected channel(s) matched", + "associations": associations + } + } + ) + + return f"Done. Matched {total_matched} selected channel(s)." + + finally: + # Clean up ML models from memory after bulk matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + + # Memory cleanup + gc.collect() + from core.utils import cleanup_memory + cleanup_memory(log_usage=True, force_collection=True) + + @shared_task def match_single_channel_epg(channel_id): """ diff --git a/frontend/src/api.js b/frontend/src/api.js index d3e222d2..09f8c3c1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1437,12 +1437,18 @@ export default class API { } } - static async matchEpg() { + static async matchEpg(channelIds = null) { try { + const requestBody = channelIds ? { channel_ids: channelIds } : {}; + const response = await request( `${host}/api/channels/channels/match-epg/`, { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), } ); diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 54fa2f8d..e9f5172d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -143,11 +143,18 @@ const ChannelTableHeader = ({ const matchEpg = async () => { try { // Hit our new endpoint that triggers the fuzzy matching Celery task - await API.matchEpg(); - - notifications.show({ - title: 'EPG matching task started!', - }); + // 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}`); } @@ -298,7 +305,12 @@ const ChannelTableHeader = ({ disabled={authUser.user_level != USER_LEVELS.ADMIN} onClick={matchEpg} > - Auto-Match + + {selectedTableIds.length > 0 + ? `Auto-Match (${selectedTableIds.length} selected)` + : 'Auto-Match EPG' + } + Date: Tue, 16 Sep 2025 14:39:04 -0500 Subject: [PATCH 50/65] Minor formatting adjustment. --- apps/channels/api_views.py | 4 ++-- apps/channels/tasks.py | 2 +- frontend/src/api.js | 2 +- .../components/tables/ChannelsTable/ChannelTableHeader.jsx | 7 +++---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 4e3851b7..c1f7034e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -796,7 +796,7 @@ class ChannelViewSet(viewsets.ModelViewSet): def match_epg(self, request): # Get channel IDs from request body if provided channel_ids = request.data.get('channel_ids', []) - + if channel_ids: # Process only selected channels from .tasks import match_selected_channels_epg @@ -806,7 +806,7 @@ class ChannelViewSet(viewsets.ModelViewSet): # Process all channels without EPG (original behavior) match_epg_channels.delay() message = "EPG matching task initiated for all channels without EPG." - + return Response( {"message": message}, status=status.HTTP_202_ACCEPTED ) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 6352ee84..c1e63658 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -551,7 +551,7 @@ def match_selected_channels_epg(channel_ids): if not channels_without_epg.exists(): logger.info("No selected channels need EPG matching.") - + # Send WebSocket update channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( diff --git a/frontend/src/api.js b/frontend/src/api.js index 09f8c3c1..a1c761c6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1440,7 +1440,7 @@ export default class API { static async matchEpg(channelIds = null) { try { const requestBody = channelIds ? { channel_ids: channelIds } : {}; - + const response = await request( `${host}/api/channels/channels/match-epg/`, { diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index e9f5172d..b7e04d7d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -306,10 +306,9 @@ const ChannelTableHeader = ({ onClick={matchEpg} > - {selectedTableIds.length > 0 - ? `Auto-Match (${selectedTableIds.length} selected)` - : 'Auto-Match EPG' - } + {selectedTableIds.length > 0 + ? `Auto-Match (${selectedTableIds.length} selected)` + : 'Auto-Match EPG'} From 64a019597d9799ea667f0201e3bdb11c1e709ba0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 16:20:16 -0500 Subject: [PATCH 51/65] Add ability to channel edit form and bulk channel editor to set logos and names from assigned epg. Closes #157 [Feature]: Logo from EPG --- frontend/src/components/forms/Channel.jsx | 132 +++++++++-- .../src/components/forms/ChannelBatch.jsx | 208 +++++++++++++++++- 2 files changed, 317 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 6e5eabec..610f112e 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -167,6 +167,87 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } }; + const handleSetNameFromEpg = () => { + const epgDataId = formik.values.epg_data_id; + if (!epgDataId) { + notifications.show({ + title: 'No EPG Selected', + message: 'Please select an EPG source first.', + color: 'orange', + }); + return; + } + + const tvg = tvgsById[epgDataId]; + if (tvg && tvg.name) { + formik.setFieldValue('name', tvg.name); + notifications.show({ + title: 'Success', + message: `Channel name set to "${tvg.name}"`, + color: 'green', + }); + } else { + notifications.show({ + title: 'No Name Available', + message: 'No name found in the selected EPG data.', + color: 'orange', + }); + } + }; + + const handleSetLogoFromEpg = async () => { + const epgDataId = formik.values.epg_data_id; + if (!epgDataId) { + notifications.show({ + title: 'No EPG Selected', + message: 'Please select an EPG source first.', + color: 'orange', + }); + return; + } + + const tvg = tvgsById[epgDataId]; + if (!tvg || !tvg.name) { + notifications.show({ + title: 'No EPG Name', + message: 'EPG data does not have a name to match against logos.', + color: 'orange', + }); + return; + } + + try { + // Try to find a logo that matches the EPG name + const matchingLogo = Object.values(logos).find( + (logo) => + logo.name.toLowerCase().includes(tvg.name.toLowerCase()) || + tvg.name.toLowerCase().includes(logo.name.toLowerCase()) + ); + + if (matchingLogo) { + formik.setFieldValue('logo_id', matchingLogo.id); + notifications.show({ + title: 'Success', + message: `Logo set to "${matchingLogo.name}"`, + color: 'green', + }); + } else { + notifications.show({ + title: 'No Matching Logo', + message: `No existing logo found that matches "${tvg.name}". Consider uploading a logo or using the smart logo selection.`, + color: 'orange', + }); + } + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to set logo from EPG data', + color: 'red', + }); + console.error('Set logo from EPG error:', error); + } + }; + const formik = useFormik({ initialValues: { name: '', @@ -349,15 +430,27 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
- + + + + { - + + + + diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 9973ea57..226565a9 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import useChannelsStore from '../../store/channels'; import API from '../../api'; import useStreamProfilesStore from '../../store/streamProfiles'; +import useEPGsStore from '../../store/epgs'; import ChannelGroupForm from './ChannelGroup'; import { Box, @@ -27,6 +28,7 @@ import { import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react'; import { FixedSizeList as List } from 'react-window'; import { useForm } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { @@ -38,6 +40,9 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); const streamProfiles = useStreamProfilesStore((s) => s.profiles); + const epgs = useEPGsStore((s) => s.epgs); + const tvgs = useEPGsStore((s) => s.tvgs); + const tvgsById = useEPGsStore((s) => s.tvgsById); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1'); @@ -134,6 +139,137 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { } }; + const handleSetNamesFromEpg = async () => { + if (!channelIds || channelIds.length === 0) { + notifications.show({ + title: 'No Channels Selected', + message: 'No channels to update.', + color: 'orange', + }); + return; + } + + try { + const channelsMap = useChannelsStore.getState().channels; + const updates = []; + + for (const id of channelIds) { + const channel = channelsMap[id]; + if (channel && channel.epg_data_id) { + const tvg = tvgsById[channel.epg_data_id]; + if (tvg && tvg.name) { + updates.push({ + id, + name: tvg.name, + }); + } + } + } + + if (updates.length === 0) { + notifications.show({ + title: 'No Updates Available', + message: 'No selected channels have EPG data with names.', + color: 'orange', + }); + return; + } + + await API.bulkUpdateChannels(updates); + await Promise.all([ + API.requeryChannels(), + useChannelsStore.getState().fetchChannels(), + ]); + + notifications.show({ + title: 'Success', + message: `Updated names for ${updates.length} channels from EPG data.`, + color: 'green', + }); + } catch (error) { + console.error('Failed to set names from EPG:', error); + notifications.show({ + title: 'Error', + message: 'Failed to set names from EPG data.', + color: 'red', + }); + } + }; + + const handleSetLogosFromEpg = async () => { + if (!channelIds || channelIds.length === 0) { + notifications.show({ + title: 'No Channels Selected', + message: 'No channels to update.', + color: 'orange', + }); + return; + } + + try { + // First, get all available logos + const logosResponse = await API.getLogos(); + const logos = logosResponse.reduce((acc, logo) => { + acc[logo.id] = logo; + return acc; + }, {}); + + const channelsMap = useChannelsStore.getState().channels; + const updates = []; + + for (const id of channelIds) { + const channel = channelsMap[id]; + if (channel && channel.epg_data_id) { + const tvg = tvgsById[channel.epg_data_id]; + if (tvg && tvg.name) { + // Try to find a matching logo + const matchingLogo = Object.values(logos).find( + (logo) => + logo.name.toLowerCase().includes(tvg.name.toLowerCase()) || + tvg.name.toLowerCase().includes(logo.name.toLowerCase()) + ); + + if (matchingLogo) { + updates.push({ + id, + logo_id: matchingLogo.id, + }); + } + } + } + } + + if (updates.length === 0) { + notifications.show({ + title: 'No Matching Logos', + message: + 'No matching logos found for the selected channels based on their EPG names.', + color: 'orange', + }); + return; + } + + await API.bulkUpdateChannels(updates); + await Promise.all([ + API.requeryChannels(), + useChannelsStore.getState().fetchChannels(), + ]); + + notifications.show({ + title: 'Success', + message: `Updated logos for ${updates.length} channels based on EPG names.`, + color: 'green', + }); + } catch (error) { + console.error('Failed to set logos from EPG:', error); + notifications.show({ + title: 'Error', + message: 'Failed to set logos from EPG data.', + color: 'red', + }); + } + }; + // useEffect(() => { // // const sameStreamProfile = channels.every( // // (channel) => channel.stream_profile_id == channels[0].stream_profile_id @@ -183,7 +319,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { @@ -197,7 +333,9 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { - Channel Name + + Channel Name + { /> + + + + EPG Operations + + + + + + + + Updates channel names and logos based on their assigned EPG + data + + + { export default ChannelBatchForm; // Lightweight inline preview component to visualize rename results for a subset -const RegexPreview = ({ channelIds, find, replace}) => { +const RegexPreview = ({ channelIds, find, replace }) => { const channelsMap = useChannelsStore((s) => s.channels); const previewItems = useMemo(() => { const items = []; @@ -412,7 +580,8 @@ const RegexPreview = ({ channelIds, find, replace}) => { let re; try { re = new RegExp(find, flags); - } catch (e) { + } catch (error) { + console.error('Invalid regex:', error); return [{ before: 'Invalid regex', after: '' }]; } for (let i = 0; i < Math.min(channelIds.length, 25); i++) { @@ -431,20 +600,41 @@ const RegexPreview = ({ channelIds, find, replace}) => { return ( - Preview (first {Math.min(channelIds.length, 25)} of {channelIds.length} selected) + Preview (first {Math.min(channelIds.length, 25)} of {channelIds.length}{' '} + selected) {previewItems.length === 0 ? ( - No changes with current pattern. + + No changes with current pattern. + ) : ( previewItems.map((row, idx) => ( - + {row.before} - - + + → + + {row.after} From 388d9e7171da1e56ef77f2113807eccc80b51943 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 16:25:50 -0500 Subject: [PATCH 52/65] Fix logos not being set. --- frontend/src/components/forms/ChannelBatch.jsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 226565a9..1025194c 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -4,6 +4,7 @@ import API from '../../api'; import useStreamProfilesStore from '../../store/streamProfiles'; import useEPGsStore from '../../store/epgs'; import ChannelGroupForm from './ChannelGroup'; +import { useLogoSelection } from '../../hooks/useSmartLogos'; import { Box, Button, @@ -44,6 +45,12 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const tvgs = useEPGsStore((s) => s.tvgs); const tvgsById = useEPGsStore((s) => s.tvgsById); + const { + logos, + ensureLogosLoaded, + isLoading: logosLoading, + } = useLogoSelection(); + const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1'); const [isSubmitting, setIsSubmitting] = useState(false); @@ -207,12 +214,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { } try { - // First, get all available logos - const logosResponse = await API.getLogos(); - const logos = logosResponse.reduce((acc, logo) => { - acc[logo.id] = logo; - return acc; - }, {}); + // Ensure logos are loaded first + await ensureLogosLoaded(); const channelsMap = useChannelsStore.getState().channels; const updates = []; From 8607d626fa34e7a91ac0e8b439097b00ba004ee2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 16:32:32 -0500 Subject: [PATCH 53/65] Update logo store when bulk changing logos. --- frontend/src/components/forms/ChannelBatch.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 1025194c..3492d6ac 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -253,9 +253,12 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { } await API.bulkUpdateChannels(updates); + + // Refresh both channels and logos data await Promise.all([ API.requeryChannels(), useChannelsStore.getState().fetchChannels(), + ensureLogosLoaded(), // Ensure logos are refreshed ]); notifications.show({ From d2d1984797285df95cb3fb7bbabf6daa7b912a2b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 17:17:07 -0500 Subject: [PATCH 54/65] Switch bulk epg name and logo to backend celery tasks for efficiency and scrape epg channel logo during epg scanning. --- apps/channels/api_views.py | 62 +++++ apps/channels/tasks.py | 224 ++++++++++++++++++ apps/epg/migrations/0016_epgdata_icon_url.py | 18 ++ apps/epg/models.py | 1 + apps/epg/serializers.py | 1 + apps/epg/tasks.py | 20 +- frontend/src/api.js | 46 ++++ frontend/src/components/forms/Channel.jsx | 58 ++++- .../src/components/forms/ChannelBatch.jsx | 122 ++-------- 9 files changed, 437 insertions(+), 115 deletions(-) create mode 100644 apps/epg/migrations/0016_epgdata_icon_url.py diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index c1f7034e..7a3d5135 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -493,6 +493,68 @@ class ChannelViewSet(viewsets.ModelViewSet): "channels": serialized_channels }) + @action(detail=False, methods=["post"], url_path="set-names-from-epg") + def set_names_from_epg(self, request): + """ + Trigger a Celery task to set channel names from EPG data + """ + from .tasks import set_channels_names_from_epg + + data = request.data + channel_ids = data.get("channel_ids", []) + + if not channel_ids: + return Response( + {"error": "channel_ids is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be a list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Start the Celery task + task = set_channels_names_from_epg.delay(channel_ids) + + return Response({ + "message": f"Started EPG name setting task for {len(channel_ids)} channels", + "task_id": task.id, + "channel_count": len(channel_ids) + }) + + @action(detail=False, methods=["post"], url_path="set-logos-from-epg") + def set_logos_from_epg(self, request): + """ + Trigger a Celery task to set channel logos from EPG data + """ + from .tasks import set_channels_logos_from_epg + + data = request.data + channel_ids = data.get("channel_ids", []) + + if not channel_ids: + return Response( + {"error": "channel_ids is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be a list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Start the Celery task + task = set_channels_logos_from_epg.delay(channel_ids) + + return Response({ + "message": f"Started EPG logo setting task for {len(channel_ids)} channels", + "task_id": task.id, + "channel_count": len(channel_ids) + }) + @action(detail=False, methods=["get"], url_path="ids") def get_ids(self, request, *args, **kwargs): # Get the filtered queryset diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index c1e63658..2760d1a7 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -2465,3 +2465,227 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None 'error': str(e) }) raise + + +@shared_task(bind=True) +def set_channels_names_from_epg(self, channel_ids): + """ + Celery task to set channel names from EPG data for multiple channels + """ + from core.utils import send_websocket_update + + task_id = self.request.id + total_channels = len(channel_ids) + updated_count = 0 + errors = [] + + try: + logger.info(f"Starting EPG name setting task for {total_channels} channels") + + # Send initial progress + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'running', + 'message': 'Starting EPG name setting...' + }) + + batch_size = 100 + for i in range(0, total_channels, batch_size): + batch_ids = channel_ids[i:i + batch_size] + batch_updates = [] + + # Get channels and their EPG data + channels = Channel.objects.filter(id__in=batch_ids).select_related('epg_data') + + for channel in channels: + try: + if channel.epg_data and channel.epg_data.name: + if channel.name != channel.epg_data.name: + channel.name = channel.epg_data.name + batch_updates.append(channel) + updated_count += 1 + except Exception as e: + errors.append(f"Channel {channel.id}: {str(e)}") + logger.error(f"Error processing channel {channel.id}: {e}") + + # Bulk update the batch + if batch_updates: + Channel.objects.bulk_update(batch_updates, ['name']) + + # Send progress update + progress = min(i + batch_size, total_channels) + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': progress, + 'total': total_channels, + 'status': 'running', + 'message': f'Updated {updated_count} channel names...', + 'updated_count': updated_count + }) + + # Send completion notification + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': total_channels, + 'total': total_channels, + 'status': 'completed', + 'message': f'Successfully updated {updated_count} channel names from EPG data', + 'updated_count': updated_count, + 'error_count': len(errors), + 'errors': errors + }) + + logger.info(f"EPG name setting task completed. Updated {updated_count} channels") + return { + 'status': 'completed', + 'updated_count': updated_count, + 'error_count': len(errors), + 'errors': errors + } + + except Exception as e: + logger.error(f"EPG name setting task failed: {e}") + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'failed', + 'message': f'Task failed: {str(e)}', + 'error': str(e) + }) + raise + + +@shared_task(bind=True) +def set_channels_logos_from_epg(self, channel_ids): + """ + Celery task to set channel logos from EPG data for multiple channels + Creates logos from EPG icon URLs if they don't exist + """ + from .models import Logo + from core.utils import send_websocket_update + import requests + from urllib.parse import urlparse + + task_id = self.request.id + total_channels = len(channel_ids) + updated_count = 0 + created_logos_count = 0 + errors = [] + + try: + logger.info(f"Starting EPG logo setting task for {total_channels} channels") + + # Send initial progress + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'running', + 'message': 'Starting EPG logo setting...' + }) + + batch_size = 50 # Smaller batch for logo processing + for i in range(0, total_channels, batch_size): + batch_ids = channel_ids[i:i + batch_size] + batch_updates = [] + + # Get channels and their EPG data + channels = Channel.objects.filter(id__in=batch_ids).select_related('epg_data', 'logo') + + for channel in channels: + try: + if channel.epg_data and channel.epg_data.icon_url: + icon_url = channel.epg_data.icon_url.strip() + + # Try to find existing logo with this URL + try: + logo = Logo.objects.get(url=icon_url) + except Logo.DoesNotExist: + # Create new logo from EPG icon URL + try: + # Generate a name for the logo + logo_name = channel.epg_data.name or f"Logo for {channel.epg_data.tvg_id}" + + # Create the logo record + logo = Logo.objects.create( + name=logo_name, + url=icon_url + ) + created_logos_count += 1 + logger.info(f"Created new logo from EPG: {logo_name} - {icon_url}") + + except Exception as create_error: + errors.append(f"Channel {channel.id}: Failed to create logo from {icon_url}: {str(create_error)}") + logger.error(f"Failed to create logo for channel {channel.id}: {create_error}") + continue + + # Update channel logo if different + if channel.logo != logo: + channel.logo = logo + batch_updates.append(channel) + updated_count += 1 + + except Exception as e: + errors.append(f"Channel {channel.id}: {str(e)}") + logger.error(f"Error processing channel {channel.id}: {e}") + + # Bulk update the batch + if batch_updates: + Channel.objects.bulk_update(batch_updates, ['logo']) + + # Send progress update + progress = min(i + batch_size, total_channels) + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': progress, + 'total': total_channels, + 'status': 'running', + 'message': f'Updated {updated_count} channel logos, created {created_logos_count} new logos...', + 'updated_count': updated_count, + 'created_logos_count': created_logos_count + }) + + # Send completion notification + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': total_channels, + 'total': total_channels, + 'status': 'completed', + 'message': f'Successfully updated {updated_count} channel logos and created {created_logos_count} new logos from EPG data', + 'updated_count': updated_count, + 'created_logos_count': created_logos_count, + 'error_count': len(errors), + 'errors': errors + }) + + logger.info(f"EPG logo setting task completed. Updated {updated_count} channels, created {created_logos_count} logos") + return { + 'status': 'completed', + 'updated_count': updated_count, + 'created_logos_count': created_logos_count, + 'error_count': len(errors), + 'errors': errors + } + + except Exception as e: + logger.error(f"EPG logo setting task failed: {e}") + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'failed', + 'message': f'Task failed: {str(e)}', + 'error': str(e) + }) + raise diff --git a/apps/epg/migrations/0016_epgdata_icon_url.py b/apps/epg/migrations/0016_epgdata_icon_url.py new file mode 100644 index 00000000..b934b024 --- /dev/null +++ b/apps/epg/migrations/0016_epgdata_icon_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-09-16 22:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0015_alter_programdata_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='epgdata', + name='icon_url', + field=models.URLField(blank=True, max_length=500, null=True), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 22f2bd28..3ed33ab2 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -127,6 +127,7 @@ class EPGData(models.Model): # and a name (which might simply be the tvg_id if no real channel exists). tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) name = models.CharField(max_length=255) + icon_url = models.URLField(max_length=500, null=True, blank=True) epg_source = models.ForeignKey( EPGSource, on_delete=models.CASCADE, diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 2f97cebf..85186cae 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -52,5 +52,6 @@ class EPGDataSerializer(serializers.ModelSerializer): 'id', 'tvg_id', 'name', + 'icon_url', 'epg_source', ] diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 0d0ebbb3..dc0fdbe6 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -873,10 +873,12 @@ def parse_channels_only(source): tvg_id = elem.get('id', '').strip() if tvg_id: display_name = None + icon_url = None for child in elem: if child.tag == 'display-name' and child.text: display_name = child.text.strip() - break + elif child.tag == 'icon': + icon_url = child.get('src', '').strip() if not display_name: display_name = tvg_id @@ -894,17 +896,24 @@ def parse_channels_only(source): epgs_to_create.append(EPGData( tvg_id=tvg_id, name=display_name, + icon_url=icon_url, epg_source=source, )) logger.debug(f"[parse_channels_only] Added new channel to epgs_to_create 1: {tvg_id} - {display_name}") processed_channels += 1 continue - # We use the cached object to check if the name has changed + # We use the cached object to check if the name or icon_url has changed epg_obj = existing_epgs[tvg_id] + needs_update = False if epg_obj.name != display_name: - # Only update if the name actually changed epg_obj.name = display_name + needs_update = True + if epg_obj.icon_url != icon_url: + epg_obj.icon_url = icon_url + needs_update = True + + if needs_update: epgs_to_update.append(epg_obj) logger.debug(f"[parse_channels_only] Added channel to update to epgs_to_update: {tvg_id} - {display_name}") else: @@ -915,6 +924,7 @@ def parse_channels_only(source): epgs_to_create.append(EPGData( tvg_id=tvg_id, name=display_name, + icon_url=icon_url, epg_source=source, )) logger.debug(f"[parse_channels_only] Added new channel to epgs_to_create 2: {tvg_id} - {display_name}") @@ -937,7 +947,7 @@ def parse_channels_only(source): logger.info(f"[parse_channels_only] Bulk updating {len(epgs_to_update)} EPG entries") if process: logger.info(f"[parse_channels_only] Memory before bulk_update: {process.memory_info().rss / 1024 / 1024:.2f} MB") - EPGData.objects.bulk_update(epgs_to_update, ["name"]) + EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"]) if process: logger.info(f"[parse_channels_only] Memory after bulk_update: {process.memory_info().rss / 1024 / 1024:.2f} MB") epgs_to_update = [] @@ -1004,7 +1014,7 @@ def parse_channels_only(source): logger.debug(f"[parse_channels_only] Created final batch of {len(epgs_to_create)} EPG entries") if epgs_to_update: - EPGData.objects.bulk_update(epgs_to_update, ["name"]) + EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"]) logger.debug(f"[parse_channels_only] Updated final batch of {len(epgs_to_update)} EPG entries") if process: logger.debug(f"[parse_channels_only] Memory after final batch creation: {process.memory_info().rss / 1024 / 1024:.2f} MB") diff --git a/frontend/src/api.js b/frontend/src/api.js index a1c761c6..01186bf6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -516,6 +516,52 @@ export default class API { } } + static async setChannelNamesFromEpg(channelIds) { + try { + const response = await request( + `${host}/api/channels/channels/set-names-from-epg/`, + { + method: 'POST', + body: { channel_ids: channelIds }, + } + ); + + notifications.show({ + title: 'Task Started', + message: response.message, + color: 'blue', + }); + + return response; + } catch (e) { + errorNotification('Failed to start EPG name setting task', e); + throw e; + } + } + + static async setChannelLogosFromEpg(channelIds) { + try { + const response = await request( + `${host}/api/channels/channels/set-logos-from-epg/`, + { + method: 'POST', + body: { channel_ids: channelIds }, + } + ); + + notifications.show({ + title: 'Task Started', + message: response.message, + color: 'blue', + }); + + return response; + } catch (e) { + errorNotification('Failed to start EPG logo setting task', e); + throw e; + } + } + static async assignChannelNumbers(channelIds, startingNum = 1) { try { const response = await request(`${host}/api/channels/channels/assign/`, { diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 610f112e..f8c0f15b 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -207,21 +207,19 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } const tvg = tvgsById[epgDataId]; - if (!tvg || !tvg.name) { + if (!tvg || !tvg.icon_url) { notifications.show({ - title: 'No EPG Name', - message: 'EPG data does not have a name to match against logos.', + title: 'No EPG Icon', + message: 'EPG data does not have an icon URL.', color: 'orange', }); return; } try { - // Try to find a logo that matches the EPG name - const matchingLogo = Object.values(logos).find( - (logo) => - logo.name.toLowerCase().includes(tvg.name.toLowerCase()) || - tvg.name.toLowerCase().includes(logo.name.toLowerCase()) + // Try to find a logo that matches the EPG icon URL + let matchingLogo = Object.values(logos).find( + (logo) => logo.url === tvg.icon_url ); if (matchingLogo) { @@ -232,11 +230,47 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { color: 'green', }); } else { + // Logo doesn't exist - create it notifications.show({ - title: 'No Matching Logo', - message: `No existing logo found that matches "${tvg.name}". Consider uploading a logo or using the smart logo selection.`, - color: 'orange', + id: 'creating-logo', + title: 'Creating Logo', + message: `Creating new logo from EPG icon URL...`, + loading: true, }); + + try { + const newLogoData = { + name: tvg.name || `Logo for ${tvg.icon_url}`, + url: tvg.icon_url, + }; + + // Create logo by calling the Logo API directly + const newLogo = await API.createLogo(newLogoData); + + formik.setFieldValue('logo_id', newLogo.id); + + // Refresh logos to update the cache + await ensureLogosLoaded(); + + notifications.update({ + id: 'creating-logo', + title: 'Success', + message: `Created and assigned new logo "${newLogo.name}"`, + loading: false, + color: 'green', + autoClose: 5000, + }); + } catch (createError) { + notifications.update({ + id: 'creating-logo', + title: 'Error', + message: 'Failed to create logo from EPG icon URL', + loading: false, + color: 'red', + autoClose: 5000, + }); + throw createError; + } } } catch (error) { notifications.show({ @@ -751,7 +785,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { variant="light" onClick={handleSetLogoFromEpg} disabled={!formik.values.epg_data_id} - title="Find matching logo based on EPG name" + title="Find matching logo based on EPG icon URL" > Use EPG Logo diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 3492d6ac..ad61fb26 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -2,9 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import useChannelsStore from '../../store/channels'; import API from '../../api'; import useStreamProfilesStore from '../../store/streamProfiles'; -import useEPGsStore from '../../store/epgs'; import ChannelGroupForm from './ChannelGroup'; -import { useLogoSelection } from '../../hooks/useSmartLogos'; import { Box, Button, @@ -38,18 +36,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); - const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); const streamProfiles = useStreamProfilesStore((s) => s.profiles); - const epgs = useEPGsStore((s) => s.epgs); - const tvgs = useEPGsStore((s) => s.tvgs); - const tvgsById = useEPGsStore((s) => s.tvgsById); - - const { - logos, - ensureLogosLoaded, - isLoading: logosLoading, - } = useLogoSelection(); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1'); @@ -157,47 +145,24 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { } try { - const channelsMap = useChannelsStore.getState().channels; - const updates = []; - - for (const id of channelIds) { - const channel = channelsMap[id]; - if (channel && channel.epg_data_id) { - const tvg = tvgsById[channel.epg_data_id]; - if (tvg && tvg.name) { - updates.push({ - id, - name: tvg.name, - }); - } - } - } - - if (updates.length === 0) { - notifications.show({ - title: 'No Updates Available', - message: 'No selected channels have EPG data with names.', - color: 'orange', - }); - return; - } - - await API.bulkUpdateChannels(updates); - await Promise.all([ - API.requeryChannels(), - useChannelsStore.getState().fetchChannels(), - ]); + // Start the backend task + await API.setChannelNamesFromEpg(channelIds); + // The task will send WebSocket updates for progress + // Just show that it started successfully notifications.show({ - title: 'Success', - message: `Updated names for ${updates.length} channels from EPG data.`, - color: 'green', + title: 'Task Started', + message: `Started setting names from EPG for ${channelIds.length} channels. Progress will be shown in notifications.`, + color: 'blue', }); + + // Close the modal since the task is now running in background + onClose(); } catch (error) { - console.error('Failed to set names from EPG:', error); + console.error('Failed to start EPG name setting task:', error); notifications.show({ title: 'Error', - message: 'Failed to set names from EPG data.', + message: 'Failed to start EPG name setting task.', color: 'red', }); } @@ -214,63 +179,24 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { } try { - // Ensure logos are loaded first - await ensureLogosLoaded(); - - const channelsMap = useChannelsStore.getState().channels; - const updates = []; - - for (const id of channelIds) { - const channel = channelsMap[id]; - if (channel && channel.epg_data_id) { - const tvg = tvgsById[channel.epg_data_id]; - if (tvg && tvg.name) { - // Try to find a matching logo - const matchingLogo = Object.values(logos).find( - (logo) => - logo.name.toLowerCase().includes(tvg.name.toLowerCase()) || - tvg.name.toLowerCase().includes(logo.name.toLowerCase()) - ); - - if (matchingLogo) { - updates.push({ - id, - logo_id: matchingLogo.id, - }); - } - } - } - } - - if (updates.length === 0) { - notifications.show({ - title: 'No Matching Logos', - message: - 'No matching logos found for the selected channels based on their EPG names.', - color: 'orange', - }); - return; - } - - await API.bulkUpdateChannels(updates); - - // Refresh both channels and logos data - await Promise.all([ - API.requeryChannels(), - useChannelsStore.getState().fetchChannels(), - ensureLogosLoaded(), // Ensure logos are refreshed - ]); + // Start the backend task + await API.setChannelLogosFromEpg(channelIds); + // The task will send WebSocket updates for progress + // Just show that it started successfully notifications.show({ - title: 'Success', - message: `Updated logos for ${updates.length} channels based on EPG names.`, - color: 'green', + title: 'Task Started', + message: `Started setting logos from EPG for ${channelIds.length} channels. Progress will be shown in notifications.`, + color: 'blue', }); + + // Close the modal since the task is now running in background + onClose(); } catch (error) { - console.error('Failed to set logos from EPG:', error); + console.error('Failed to start EPG logo setting task:', error); notifications.show({ title: 'Error', - message: 'Failed to set logos from EPG data.', + message: 'Failed to start EPG logo setting task.', color: 'red', }); } From 3cb5a061c9d2d61ab824dd7f6275bfb9b39aab59 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 17:35:38 -0500 Subject: [PATCH 55/65] Show progress as notifications. --- frontend/src/WebSocket.jsx | 109 +++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 2c57a37e..b2b733c2 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -375,6 +375,115 @@ export const WebsocketProvider = ({ children }) => { break; } + case 'epg_logo_setting_progress': { + const progress = parsedEvent.data; + const id = 'epg-logo-setting-progress'; + + if (progress.status === 'running' && progress.progress === 0) { + // Initial message + notifications.show({ + id, + title: 'Setting Logos from EPG', + message: `Processing ${progress.total} channels...`, + color: 'blue.5', + autoClose: false, + withCloseButton: false, + loading: true, + }); + } else if (progress.status === 'running') { + // Progress update + let message = `Processed ${progress.progress} of ${progress.total} channels`; + if (progress.updated_count !== undefined) { + message += ` (${progress.updated_count} updated)`; + } + if (progress.created_logos_count !== undefined) { + message += `, created ${progress.created_logos_count} logos`; + } + + notifications.update({ + id, + title: 'Setting Logos from EPG', + message, + color: 'blue.5', + autoClose: false, + withCloseButton: false, + loading: true, + }); + } else if (progress.status === 'completed') { + notifications.update({ + id, + title: 'Logo Setting Complete', + message: `Successfully updated ${progress.updated_count || 0} channel logos${progress.created_logos_count ? `, created ${progress.created_logos_count} new logos` : ''}`, + color: progress.updated_count > 0 ? 'green.5' : 'orange', + loading: false, + autoClose: 6000, + }); + // Refresh channels data + try { + await useChannelsStore.getState().fetchChannels(); + } catch (e) { + console.warn( + 'Failed to refresh channels after logo setting:', + e + ); + } + } + break; + } + + case 'epg_name_setting_progress': { + const progress = parsedEvent.data; + const id = 'epg-name-setting-progress'; + + if (progress.status === 'running' && progress.progress === 0) { + // Initial message + notifications.show({ + id, + title: 'Setting Names from EPG', + message: `Processing ${progress.total} channels...`, + color: 'blue.5', + autoClose: false, + withCloseButton: false, + loading: true, + }); + } else if (progress.status === 'running') { + // Progress update + let message = `Processed ${progress.progress} of ${progress.total} channels`; + if (progress.updated_count !== undefined) { + message += ` (${progress.updated_count} updated)`; + } + + notifications.update({ + id, + title: 'Setting Names from EPG', + message, + color: 'blue.5', + autoClose: false, + withCloseButton: false, + loading: true, + }); + } else if (progress.status === 'completed') { + notifications.update({ + id, + title: 'Name Setting Complete', + message: `Successfully updated ${progress.updated_count || 0} channel names from EPG data`, + color: progress.updated_count > 0 ? 'green.5' : 'orange', + loading: false, + autoClose: 6000, + }); + // Refresh channels data + try { + await useChannelsStore.getState().fetchChannels(); + } catch (e) { + console.warn( + 'Failed to refresh channels after name setting:', + e + ); + } + } + break; + } + case 'm3u_profile_test': setProfilePreview( parsedEvent.data.search_preview, From 7e13e511986b5ba5f550bc776951b3e0e3bed6eb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 17:55:55 -0500 Subject: [PATCH 56/65] Update the frontend on logo change. --- frontend/src/WebSocket.jsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index b2b733c2..249ee437 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -418,9 +418,21 @@ export const WebsocketProvider = ({ children }) => { loading: false, autoClose: 6000, }); - // Refresh channels data + // Refresh channels data and logos try { + await API.requeryChannels(); await useChannelsStore.getState().fetchChannels(); + + // Get updated channel data and extract logo IDs to load + const channels = useChannelsStore.getState().channels; + const logoIds = Object.values(channels) + .filter((channel) => channel.logo_id) + .map((channel) => channel.logo_id); + + // Fetch the specific logos that were just assigned + if (logoIds.length > 0) { + await useLogosStore.getState().fetchLogosByIds(logoIds); + } } catch (e) { console.warn( 'Failed to refresh channels after logo setting:', From 8b740fc3acf3c0ab3f840096ca0e794209f3901f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 18:49:02 -0500 Subject: [PATCH 57/65] Move buttons for use epg name and use epg logo . --- frontend/src/components/forms/Channel.jsx | 74 +++++++++++++---------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index f8c0f15b..e9e6e686 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -464,27 +464,32 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { - - - - + + Channel Name + {formik.values.epg_data_id && ( + + )} + + } + value={formik.values.name} + onChange={formik.handleChange} + error={formik.errors.name ? formik.touched.name : ''} + size="xs" + style={{ flex: 1 }} + /> { + Logo + {formik.values.epg_data_id && ( + + )} + + } readOnly value={logos[formik.values.logo_id]?.name || 'Default'} onClick={() => { @@ -780,15 +801,6 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { alt="channel logo" style={{ height: 40 }} /> - From 2e5280c46a9a53c3c01a27d256e47a82fb3f6a7b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 19:17:31 -0500 Subject: [PATCH 58/65] Remove unneeded logo call. --- frontend/src/components/forms/Channel.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index e9e6e686..9e329614 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -249,9 +249,6 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { formik.setFieldValue('logo_id', newLogo.id); - // Refresh logos to update the cache - await ensureLogosLoaded(); - notifications.update({ id: 'creating-logo', title: 'Success', From ab3350d08d3ed84c358ad0885aa35da2f58bb909 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 19:41:11 -0500 Subject: [PATCH 59/65] Search all logos instead of just channel assignable. --- frontend/src/components/forms/Channel.jsx | 8 +++- frontend/src/store/logos.jsx | 50 +++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 9e329614..48edb3ac 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -9,6 +9,7 @@ import ChannelGroupForm from './ChannelGroup'; import usePlaylistsStore from '../../store/playlists'; import logo from '../../images/logo.png'; import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; +import useLogosStore from '../../store/logos'; import LazyLogo from '../LazyLogo'; import { Box, @@ -56,6 +57,9 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { isLoading: logosLoading, } = useChannelLogoSelection(); + // Import the full logos store for duplicate checking + const allLogos = useLogosStore((s) => s.logos); + // Ensure logos are loaded when component mounts useEffect(() => { ensureLogosLoaded(); @@ -217,8 +221,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } try { - // Try to find a logo that matches the EPG icon URL - let matchingLogo = Object.values(logos).find( + // Try to find a logo that matches the EPG icon URL - check ALL logos to avoid duplicates + let matchingLogo = Object.values(allLogos).find( (logo) => logo.url === tvg.icon_url ); diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 4a0b945c..eb2a7597 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -3,7 +3,7 @@ import api from '../api'; const useLogosStore = create((set, get) => ({ logos: {}, - channelLogos: {}, // Separate state for channel-assignable logos + channelLogos: {}, // Keep this for simplicity, but we'll be more careful about when we populate it isLoading: false, backgroundLoading: false, hasLoadedAll: false, // Track if we've loaded all logos @@ -21,12 +21,29 @@ const useLogosStore = create((set, get) => ({ }, addLogo: (newLogo) => - set((state) => ({ - logos: { + set((state) => { + // Add to main logos store always + const newLogos = { ...state.logos, [newLogo.id]: { ...newLogo }, - }, - })), + }; + + // Add to channelLogos if the user has loaded channel-assignable logos + // This means they're using channel forms and the new logo should be available there + // Newly created logos are channel-assignable (they start unused) + let newChannelLogos = state.channelLogos; + if (state.hasLoadedChannelLogos) { + newChannelLogos = { + ...state.channelLogos, + [newLogo.id]: { ...newLogo }, + }; + } + + return { + logos: newLogos, + channelLogos: newChannelLogos, + }; + }), updateLogo: (logo) => set((state) => ({ @@ -34,13 +51,25 @@ const useLogosStore = create((set, get) => ({ ...state.logos, [logo.id]: { ...logo }, }, + // Update in channelLogos if it exists there + channelLogos: state.channelLogos[logo.id] + ? { + ...state.channelLogos, + [logo.id]: { ...logo }, + } + : state.channelLogos, })), removeLogo: (logoId) => set((state) => { const newLogos = { ...state.logos }; + const newChannelLogos = { ...state.channelLogos }; delete newLogos[logoId]; - return { logos: newLogos }; + delete newChannelLogos[logoId]; + return { + logos: newLogos, + channelLogos: newChannelLogos, + }; }), // Smart loading methods @@ -155,8 +184,15 @@ const useLogosStore = create((set, get) => ({ console.log(`Fetched ${logos.length} channel-assignable logos`); - // Store in separate channelLogos state + // Store in both places, but this is intentional and only when specifically requested set({ + logos: { + ...get().logos, // Keep existing logos + ...logos.reduce((acc, logo) => { + acc[logo.id] = { ...logo }; + return acc; + }, {}), + }, channelLogos: logos.reduce((acc, logo) => { acc[logo.id] = { ...logo }; return acc; From 9ef2aa966d2c3344d00a1304bbdebc0894a6173f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 19:44:41 -0500 Subject: [PATCH 60/65] Requery channels when setting channel names from epg. --- frontend/src/WebSocket.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 249ee437..1101c9f8 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -485,6 +485,7 @@ export const WebsocketProvider = ({ children }) => { }); // Refresh channels data try { + await API.requeryChannels(); await useChannelsStore.getState().fetchChannels(); } catch (e) { console.warn( From 00da23332274a8ba9b24213dbe89b08631d3bc00 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 19:49:41 -0500 Subject: [PATCH 61/65] Rename logos variable to channelLogos to avoid future confusion. --- frontend/src/components/forms/Channel.jsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 48edb3ac..5941f9d4 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -52,7 +52,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); const { - logos, + logos: channelLogos, ensureLogosLoaded, isLoading: logosLoading, } = useChannelLogoSelection(); @@ -410,9 +410,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { // Memoize logo options to prevent infinite re-renders during background loading const logoOptions = useMemo(() => { - const options = [{ id: '0', name: 'Default' }].concat(Object.values(logos)); + const options = [{ id: '0', name: 'Default' }].concat( + Object.values(channelLogos) + ); return options; - }, [logos]); // Only depend on logos object + }, [channelLogos]); // Only depend on channelLogos object // Update the handler for when channel group modal is closed const handleChannelGroupModalClose = (newGroup) => { @@ -689,7 +691,9 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } readOnly - value={logos[formik.values.logo_id]?.name || 'Default'} + value={ + channelLogos[formik.values.logo_id]?.name || 'Default' + } onClick={() => { console.log( 'Logo input clicked, setting popover opened to true' From edc18e07fe09bfbf955077c3a2f1231507f37a03 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 16 Sep 2025 20:10:49 -0500 Subject: [PATCH 62/65] Auto-focus filter for epg. --- frontend/src/components/forms/Channel.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 5941f9d4..53ecddd2 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -987,6 +987,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } mb="xs" size="xs" + autoFocus /> From f4f29a0e273de02155ada167c9ae8c41addc1cc2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 18 Sep 2025 10:34:51 -0500 Subject: [PATCH 63/65] Break out of channel child elements once required data is acquired. --- apps/epg/tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index dc0fdbe6..45b17bb7 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -879,6 +879,8 @@ def parse_channels_only(source): display_name = child.text.strip() elif child.tag == 'icon': icon_url = child.get('src', '').strip() + if display_name and icon_url: + break # No need to continue if we have both if not display_name: display_name = tvg_id From 48a2f2da3992e5f28c56d70cdbd0d78a95739752 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 18 Sep 2025 11:17:49 -0500 Subject: [PATCH 64/65] Simplify VOD and series access for all authenticated users by removing user-level restrictions on M3U accounts. --- apps/output/views.py | 144 +++++-------------------------------------- 1 file changed, 16 insertions(+), 128 deletions(-) diff --git a/apps/output/views.py b/apps/output/views.py index 6c18629c..bbf79acf 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1008,31 +1008,11 @@ def xc_get_vod_categories(user): response = [] - # Filter categories based on user's M3U accounts - if user.user_level == 0: - # For regular users, get categories from their accessible M3U accounts - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - # Get M3U accounts accessible through user's profiles - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - else: - m3u_accounts = [] - - # Get categories that have movie relations with these accounts - categories = VODCategory.objects.filter( - category_type='movie', - m3umovierelation__m3u_account__in=m3u_accounts - ).distinct().order_by(Lower("name")) - else: - # Admins can see all categories that have active movie relations - categories = VODCategory.objects.filter( - category_type='movie', - m3umovierelation__m3u_account__is_active=True - ).distinct().order_by(Lower("name")) + # All authenticated users get access to VOD from all active M3U accounts + categories = VODCategory.objects.filter( + category_type='movie', + m3umovierelation__m3u_account__is_active=True + ).distinct().order_by(Lower("name")) for category in categories: response.append({ @@ -1051,22 +1031,9 @@ def xc_get_vod_streams(request, user, category_id=None): streams = [] - # Build filters for movies based on user access + # All authenticated users get access to VOD from all active M3U accounts filters = {"m3u_relations__m3u_account__is_active": True} - if user.user_level == 0: - # For regular users, filter by accessible M3U accounts - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - filters["m3u_relations__m3u_account__in"] = m3u_accounts - else: - return [] # No accessible accounts - if category_id: filters["m3u_relations__category_id"] = category_id @@ -1127,28 +1094,11 @@ def xc_get_series_categories(user): response = [] - # Similar filtering as VOD categories but for series - if user.user_level == 0: - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - else: - m3u_accounts = [] - - # Get categories that have series relations with these accounts - categories = VODCategory.objects.filter( - category_type='series', - m3useriesrelation__m3u_account__in=m3u_accounts - ).distinct().order_by(Lower("name")) - else: - categories = VODCategory.objects.filter( - category_type='series', - m3useriesrelation__m3u_account__is_active=True - ).distinct().order_by(Lower("name")) + # All authenticated users get access to series from all active M3U accounts + categories = VODCategory.objects.filter( + category_type='series', + m3useriesrelation__m3u_account__is_active=True + ).distinct().order_by(Lower("name")) for category in categories: response.append({ @@ -1166,21 +1116,9 @@ def xc_get_series(request, user, category_id=None): series_list = [] - # Build filters based on user access + # All authenticated users get access to series from all active M3U accounts filters = {"m3u_account__is_active": True} - if user.user_level == 0: - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - filters["m3u_account__in"] = m3u_accounts - else: - return [] - if category_id: filters["category_id"] = category_id @@ -1228,21 +1166,9 @@ def xc_get_series_info(request, user, series_id): if not series_id: raise Http404() - # Get series relation with user access filtering + # All authenticated users get access to series from all active M3U accounts filters = {"id": series_id, "m3u_account__is_active": True} - if user.user_level == 0: - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - filters["m3u_account__in"] = m3u_accounts - else: - raise Http404() - try: series_relation = M3USeriesRelation.objects.select_related('series', 'series__logo').get(**filters) series = series_relation.series @@ -1439,21 +1365,9 @@ def xc_get_vod_info(request, user, vod_id): if not vod_id: raise Http404() - # Get movie relation with user access filtering - use movie ID instead of relation ID + # All authenticated users get access to VOD from all active M3U accounts filters = {"movie_id": vod_id, "m3u_account__is_active": True} - if user.user_level == 0: - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - filters["m3u_account__in"] = m3u_accounts - else: - raise Http404() - try: # Order by account priority to get the best relation when multiple exist movie_relation = M3UMovieRelation.objects.select_related('movie', 'movie__logo').filter(**filters).order_by('-m3u_account__priority', 'id').first() @@ -1602,22 +1516,9 @@ def xc_movie_stream(request, username, password, stream_id, extension): if custom_properties["xc_password"] != password: return JsonResponse({"error": "Invalid credentials"}, status=401) - # Get movie relation based on user access level - use movie ID instead of relation ID + # All authenticated users get access to VOD from all active M3U accounts filters = {"movie_id": stream_id, "m3u_account__is_active": True} - if user.user_level < 10: - # For regular users, filter by accessible M3U accounts - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - filters["m3u_account__in"] = m3u_accounts - else: - return JsonResponse({"error": "No accessible content"}, status=403) - try: # Order by account priority to get the best relation when multiple exist movie_relation = M3UMovieRelation.objects.select_related('movie').filter(**filters).order_by('-m3u_account__priority', 'id').first() @@ -1652,22 +1553,9 @@ def xc_series_stream(request, username, password, stream_id, extension): if custom_properties["xc_password"] != password: return JsonResponse({"error": "Invalid credentials"}, status=401) - # Get episode relation based on user access level - use episode ID instead of stream_id + # All authenticated users get access to series/episodes from all active M3U accounts filters = {"episode_id": stream_id, "m3u_account__is_active": True} - if user.user_level < 10: - # For regular users, filter by accessible M3U accounts - if user.channel_profiles.count() > 0: - channel_profiles = user.channel_profiles.all() - from apps.m3u.models import M3UAccount - m3u_accounts = M3UAccount.objects.filter( - is_active=True, - profiles__in=channel_profiles - ).distinct() - filters["m3u_account__in"] = m3u_accounts - else: - return JsonResponse({"error": "No accessible content"}, status=403) - try: episode_relation = M3UEpisodeRelation.objects.select_related('episode').get(**filters) except M3UEpisodeRelation.DoesNotExist: From b3debcd01410b1a4715c1b877bcdab42256c6279 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 18 Sep 2025 14:14:04 -0500 Subject: [PATCH 65/65] Fix bug during VOD cleanup where all VODs not from the current m3u scan would be deleted. --- apps/vod/tasks.py | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 3c429e15..504b09fc 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -62,9 +62,9 @@ def refresh_vod_content(account_id): logger.info(f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds") - # Cleanup orphaned VOD content after refresh - logger.info("Starting cleanup of orphaned VOD content") - cleanup_result = cleanup_orphaned_vod_content(scan_start_time=start_time) + # Cleanup orphaned VOD content after refresh (scoped to this account only) + logger.info(f"Starting cleanup of orphaned VOD content for account {account.name}") + cleanup_result = cleanup_orphaned_vod_content(account_id=account_id, scan_start_time=start_time) logger.info(f"VOD cleanup completed: {cleanup_result}") # Send completion notification @@ -1308,7 +1308,7 @@ def batch_refresh_series_episodes(account_id, series_ids=None): @shared_task -def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None): +def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=None): """Clean up VOD content that has no M3U relations or has stale relations""" from datetime import timedelta @@ -1318,30 +1318,44 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None): # Calculate cutoff date for stale relations cutoff_date = reference_time - timedelta(days=stale_days) + # Build base query filters + base_filters = {'last_seen__lt': cutoff_date} + if account_id: + base_filters['m3u_account_id'] = account_id + logger.info(f"Cleaning up stale VOD content for account {account_id}") + else: + logger.info("Cleaning up stale VOD content across all accounts") + # Clean up stale movie relations (haven't been seen in the specified days) - stale_movie_relations = M3UMovieRelation.objects.filter(last_seen__lt=cutoff_date) + stale_movie_relations = M3UMovieRelation.objects.filter(**base_filters) stale_movie_count = stale_movie_relations.count() stale_movie_relations.delete() # Clean up stale series relations - stale_series_relations = M3USeriesRelation.objects.filter(last_seen__lt=cutoff_date) + stale_series_relations = M3USeriesRelation.objects.filter(**base_filters) stale_series_count = stale_series_relations.count() stale_series_relations.delete() # Clean up stale episode relations - stale_episode_relations = M3UEpisodeRelation.objects.filter(last_seen__lt=cutoff_date) + stale_episode_relations = M3UEpisodeRelation.objects.filter(**base_filters) stale_episode_count = stale_episode_relations.count() stale_episode_relations.delete() - # Clean up movies with no relations (orphaned) - orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True) - orphaned_movie_count = orphaned_movies.count() - orphaned_movies.delete() + # Clean up movies with no relations (orphaned) - only if no account_id specified (global cleanup) + if not account_id: + orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True) + orphaned_movie_count = orphaned_movies.count() + orphaned_movies.delete() - # Clean up series with no relations (orphaned) - orphaned_series = Series.objects.filter(m3u_relations__isnull=True) - orphaned_series_count = orphaned_series.count() - orphaned_series.delete() + # Clean up series with no relations (orphaned) - only if no account_id specified (global cleanup) + orphaned_series = Series.objects.filter(m3u_relations__isnull=True) + orphaned_series_count = orphaned_series.count() + orphaned_series.delete() + else: + # When cleaning up for specific account, we don't remove orphaned content + # as other accounts might still reference it + orphaned_movie_count = 0 + orphaned_series_count = 0 # Episodes will be cleaned up via CASCADE when series are deleted