diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ccd942d6..ab206afb 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -265,8 +265,8 @@ class ChannelViewSet(viewsets.ModelViewSet): stream_custom_props = json.loads(stream.custom_properties) if stream.custom_properties else {} channel_number = None - if 'tv-chno' in stream_custom_props: - channel_number = int(stream_custom_props['tv-chno']) + if 'tvg-chno' in stream_custom_props: + channel_number = int(stream_custom_props['tvg-chno']) elif 'channel-number' in stream_custom_props: channel_number = int(stream_custom_props['channel-number']) @@ -386,8 +386,8 @@ class ChannelViewSet(viewsets.ModelViewSet): stream_custom_props = json.loads(stream.custom_properties) if stream.custom_properties else {} channel_number = None - if 'tv-chno' in stream_custom_props: - channel_number = int(stream_custom_props['tv-chno']) + if 'tvg-chno' in stream_custom_props: + channel_number = int(stream_custom_props['tvg-chno']) elif 'channel-number' in stream_custom_props: channel_number = int(stream_custom_props['channel-number']) @@ -600,6 +600,8 @@ class ChannelViewSet(viewsets.ModelViewSet): parse_programs_for_tvg_id.delay(epg_id) programs_refreshed += 1 + + return Response({ 'success': True, 'channels_updated': channels_updated, diff --git a/apps/epg/models.py b/apps/epg/models.py index 2f7d5990..a0e5343b 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -38,8 +38,14 @@ class EPGSource(models.Model): # Build full path in MEDIA_ROOT/cached_epg cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg") + + # Create directory if it doesn't exist + os.makedirs(cache_dir, exist_ok=True) + cache = os.path.join(cache_dir, filename) + return cache + class EPGData(models.Model): # Removed the Channel foreign key. We now just store the original tvg_id # and a name (which might simply be the tvg_id if no real channel exists). diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 35ca3648..ef232fd2 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -24,6 +24,7 @@ from .services.channel_service import ChannelService from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch, get_stream_object, get_alternate_streams from .utils import get_logger from uuid import UUID +import gevent logger = get_logger() @@ -119,7 +120,7 @@ def stream_ts(request, channel_id): # Wait before retrying (using exponential backoff with a cap) wait_time = min(0.5 * (2 ** attempt), 2.0) # Caps at 2 seconds logger.info(f"[{client_id}] Waiting {wait_time:.1f}s for a connection to become available (attempt {attempt+1}/{max_retries})") - time.sleep(wait_time) + gevent.sleep(wait_time) # FIXED: Using gevent.sleep instead of time.sleep if stream_url is None: # Make sure to release any stream locks that might have been acquired @@ -258,7 +259,7 @@ def stream_ts(request, channel_id): proxy_server.stop_channel(channel_id) return JsonResponse({'error': 'Failed to connect'}, status=502) - time.sleep(0.1) + gevent.sleep(0.1) # FIXED: Using gevent.sleep instead of time.sleep logger.info(f"[{client_id}] Successfully initialized channel {channel_id}") channel_initializing = True diff --git a/docker/Dockerfile b/docker/Dockerfile index 4d313e2c..26b54975 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -41,11 +41,12 @@ RUN cd /app && \ pip install --no-cache-dir -r requirements.txt # Use a dedicated Node.js stage for frontend building -FROM node:20-slim AS frontend-builder +FROM node:20 AS frontend-builder WORKDIR /app/frontend COPY --from=builder /app /app -RUN npm install --legacy-peer-deps && \ - npm run build && \ +RUN corepack enable && corepack prepare yarn@stable --activate && \ + yarn install && \ + yarn build && \ find . -maxdepth 1 ! -name '.' ! -name 'dist' -exec rm -rf '{}' \; FROM python:3.13-slim diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 478d94d0..d2afb3a3 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -40,8 +40,14 @@ export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} # Extract version information from version.py export DISPATCHARR_VERSION=$(python -c "import sys; sys.path.append('/app'); import version; print(version.__version__)") -export DISPATCHARR_BUILD=$(python -c "import sys; sys.path.append('/app'); import version; print(version.__build__)") -echo "📦 Dispatcharr version: ${DISPATCHARR_VERSION}-${DISPATCHARR_BUILD}" +export DISPATCHARR_TIMESTAMP=$(python -c "import sys; sys.path.append('/app'); import version; print(version.__timestamp__ or '')") + +# Display version information with timestamp if available +if [ -n "$DISPATCHARR_TIMESTAMP" ]; then + echo "📦 Dispatcharr version: ${DISPATCHARR_VERSION} (build: ${DISPATCHARR_TIMESTAMP})" +else + echo "📦 Dispatcharr version: ${DISPATCHARR_VERSION}" +fi # READ-ONLY - don't let users change these export POSTGRES_DIR=/data/db @@ -64,7 +70,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then echo "export POSTGRES_DIR=$POSTGRES_DIR" >> /etc/profile.d/dispatcharr.sh echo "export DISPATCHARR_PORT=$DISPATCHARR_PORT" >> /etc/profile.d/dispatcharr.sh echo "export DISPATCHARR_VERSION=$DISPATCHARR_VERSION" >> /etc/profile.d/dispatcharr.sh - echo "export DISPATCHARR_BUILD=$DISPATCHARR_BUILD" >> /etc/profile.d/dispatcharr.sh + echo "export DISPATCHARR_TIMESTAMP=$DISPATCHARR_TIMESTAMP" >> /etc/profile.d/dispatcharr.sh fi chmod +x /etc/profile.d/dispatcharr.sh diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index 326f4b5d..b1ff362b 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -41,6 +41,7 @@ lazy-apps = true # Improve memory efficiency # Async mode (use gevent for high concurrency) gevent = 100 async = 100 +gevent-monkey-patch = true ; Ensure all blocking operations are patched (especially important for Ryzen CPUs) # Performance tuning thunder-lock = true diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 0f5c4404..f538ee29 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -128,9 +128,6 @@ export const WebsocketProvider = ({ children }) => { // Check if we have associations data and use the more efficient batch API if (event.data.associations && event.data.associations.length > 0) { API.batchSetEPG(event.data.associations); - } else { - // Fall back to legacy full refresh method - API.requeryChannels(); } break; diff --git a/frontend/src/api.js b/frontend/src/api.js index c164697d..274ff1fa 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1305,6 +1305,9 @@ export default class API { color: 'blue', }); + // First fetch the complete channel data + await useChannelsStore.getState().fetchChannels(); + // Then refresh the current table view this.requeryChannels(); } diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 33eca5ea..3253d67b 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -161,6 +161,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { console.error('Error saving channel:', error); } + formik.resetForm(); API.requeryChannels(); setSubmitting(false); setTvgFilter(''); diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index 2de99750..d1052c07 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -21,20 +21,33 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { const profileSearchPreview = usePlaylistsStore((s) => s.profileSearchPreview); const profileResult = usePlaylistsStore((s) => s.profileResult); + const [streamUrl, setStreamUrl] = useState(''); const [searchPattern, setSearchPattern] = useState(''); const [replacePattern, setReplacePattern] = useState(''); const [debouncedPatterns, setDebouncedPatterns] = useState({}); + useEffect(() => { + async function fetchStreamUrl() { + const params = new URLSearchParams(); + params.append('page', 1); + params.append('page_size', 1); + params.append('m3u_account', m3u.id); + const response = await API.queryStreams(params); + setStreamUrl(response.results[0].url); + } + fetchStreamUrl(); + }, []); + useEffect(() => { sendMessage( JSON.stringify({ type: 'm3u_profile_test', - url: m3u.server_url, + url: streamUrl, search: debouncedPatterns['search'] || '', replace: debouncedPatterns['replace'] || '', }) ); - }, [m3u, debouncedPatterns]); + }, [m3u, debouncedPatterns, streamUrl]); useEffect(() => { const handler = setTimeout(() => { @@ -155,7 +168,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { Search @@ -163,7 +176,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { Replace - {profileResult || m3u.server_url} + {profileResult || streamUrl} ); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index c3ab40cc..bdca0722 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -95,19 +95,19 @@ const ChannelRowActions = React.memo( }) => { const onEdit = useCallback(() => { editChannel(row.original); - }, []); + }, [row.original]); const onDelete = useCallback(() => { deleteChannel(row.original.id); - }, []); + }, [row.original]); const onPreview = useCallback(() => { handleWatchStream(row.original); - }, []); + }, [row.original]); const onRecord = useCallback(() => { createRecording(row.original); - }, []); + }, [row.original]); return ( @@ -523,9 +523,9 @@ const ChannelsTable = ({}) => { }, }, { + id: 'channel_number', accessorKey: 'channel_number', - size: 30, - header: () => #, + size: 40, cell: ({ getValue }) => ( {getValue()} @@ -706,6 +706,7 @@ const ChannelsTable = ({}) => { }, headerCellRenderFns: { name: renderHeaderCell, + channel_number: renderHeaderCell, channel_group: renderHeaderCell, enabled: renderHeaderCell, }, diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 07f4128d..1f76eb33 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -42,20 +42,39 @@ const EPGsTable = () => { { header: 'Name', accessorKey: 'name', + size: 150, + minSize: 100, }, { header: 'Source Type', accessorKey: 'source_type', + size: 120, + minSize: 100, }, { header: 'URL / API Key', accessorKey: 'url', + size: 200, + minSize: 120, enableSorting: false, + Cell: ({ cell }) => ( +
+ {cell.getValue()} +
+ ), }, { header: 'Active', accessorKey: 'is_active', - size: 100, + size: 80, + minSize: 60, sortingFn: 'basic', mantineTableBodyCellProps: { align: 'left', @@ -73,6 +92,8 @@ const EPGsTable = () => { { header: 'Updated', accessorFn: (row) => dayjs(row.updated_at).format('MMMM D, YYYY h:mma'), + size: 180, + minSize: 100, enableSorting: false, }, ], @@ -144,6 +165,13 @@ const EPGsTable = () => { density: 'compact', }, enableRowActions: true, + positionActionsColumn: 'last', + displayColumnDefOptions: { + 'mrt-row-actions': { + size: 120, // Make action column wider + minSize: 120, // Ensure minimum width for action buttons + }, + }, renderRowActions: ({ row }) => ( <> { mantineTableContainerProps: { style: { height: 'calc(40vh - 10px)', - }, - }, - displayColumnDefOptions: { - 'mrt-row-actions': { - size: 10, + overflowX: 'auto', // Ensure horizontal scrolling works }, }, }); diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 9765ca66..63a25118 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -99,16 +99,21 @@ const M3UTable = () => { { header: 'Name', accessorKey: 'name', + size: 150, + minSize: 100, // Minimum width }, { header: 'URL / File', accessorKey: 'server_url', + size: 200, + minSize: 120, Cell: ({ cell }) => (
{cell.getValue()} @@ -118,7 +123,8 @@ const M3UTable = () => { { header: 'Max Streams', accessorKey: 'max_streams', - size: 200, + size: 120, + minSize: 80, }, { header: 'Status', @@ -132,12 +138,14 @@ const M3UTable = () => { return generateStatusString(refreshProgress[row.id]); }, - size: 200, + size: 150, + minSize: 80, }, { header: 'Active', accessorKey: 'is_active', - size: 100, + size: 80, + minSize: 60, sortingFn: 'basic', mantineTableBodyCellProps: { align: 'left', @@ -155,6 +163,8 @@ const M3UTable = () => { { header: 'Updated', accessorFn: (row) => dayjs(row.updated_at).format('MMMM D, YYYY h:mma'), + size: 180, + minSize: 100, enableSorting: false, }, ], @@ -239,6 +249,13 @@ const M3UTable = () => { density: 'compact', }, enableRowActions: true, + positionActionsColumn: 'last', + displayColumnDefOptions: { + 'mrt-row-actions': { + size: 120, // Make action column wider + minSize: 120, // Ensure minimum width for action buttons + }, + }, renderRowActions: ({ row }) => ( <> { mantineTableContainerProps: { style: { height: 'calc(40vh - 10px)', + overflowX: 'auto', // Ensure horizontal scrolling works }, }, }); diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index c899fda9..078e24d9 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -156,7 +156,7 @@ const StreamRowActions = ({ ); }; -const StreamsTable = ({}) => { +const StreamsTable = ({ }) => { const theme = useMantineTheme(); /** @@ -177,7 +177,7 @@ const StreamsTable = ({}) => { // const [allRowsSelected, setAllRowsSelected] = useState(false); const [pagination, setPagination] = useState({ pageIndex: 0, - pageSize: 250, + pageSize: 50, }); const [filters, setFilters] = useState({ name: '', @@ -606,23 +606,22 @@ const StreamsTable = ({}) => { {/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */} - {selectedStreamIds.length > 0 && ( - - )} + { ), }, { - header: 'Desecription', + header: 'Description', accessorKey: 'description', enableSorting: false, Cell: ({ cell }) => ( diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx index eb62fe49..24e736d4 100644 --- a/frontend/src/pages/ContentSources.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -15,7 +15,9 @@ const M3UPage = () => { diff --git a/version.py b/version.py index 9339fcbd..25e27d60 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.3.3' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.4.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process