From 3746b614a28e2020d058c03058ac9f5bfc249654 Mon Sep 17 00:00:00 2001 From: Damien Date: Wed, 7 Jan 2026 19:56:09 +0000 Subject: [PATCH 01/63] Updated UsersTable Component to add Channel Profiles Column --- frontend/src/components/tables/UsersTable.jsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 3e9e4971..a5ba02b2 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -2,6 +2,7 @@ import React, { useMemo, useCallback, useState } from 'react'; import API from '../../api'; import UserForm from '../forms/User'; import useUsersStore from '../../store/users'; +import useChannelsStore from '../../store/channels'; import useAuthStore from '../../store/auth'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; import useWarningsStore from '../../store/warnings'; @@ -26,6 +27,7 @@ import { UnstyledButton, LoadingOverlay, Stack, + Badge, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; @@ -83,6 +85,7 @@ const UsersTable = () => { * STORES */ const users = useUsersStore((s) => s.users); + const profiles = useChannelsStore((s) => s.profiles); const authUser = useAuthStore((s) => s.user); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -259,6 +262,37 @@ const UsersTable = () => { ); }, }, + { + header: 'Channel Profiles', + accessorKey: 'channel_profiles', + grow: true, + cell: ({ getValue }) => { + const userProfiles = getValue() || []; + const profileNames = Object.values(profiles) + .filter((profile) => userProfiles.includes(profile.id)) + .map((profile) => profile.name); + return ( + + {profileNames.length > 0 ? ( + profileNames.map((name, index) => ( + + {name} + + )) + ) : ( + + All + + )} + + ); + }, + }, { id: 'actions', size: 80, @@ -313,6 +347,7 @@ const UsersTable = () => { user_level: renderHeaderCell, last_login: renderHeaderCell, date_joined: renderHeaderCell, + channel_profiles: renderHeaderCell, custom_properties: renderHeaderCell, }, }); @@ -327,7 +362,7 @@ const UsersTable = () => { minHeight: '100vh', }} > - + Date: Wed, 7 Jan 2026 20:10:10 +0000 Subject: [PATCH 02/63] Added Vertical Spacing to ensure badges look like they're within the table row. --- frontend/src/components/tables/UsersTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index a5ba02b2..bd4f17f6 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -272,7 +272,7 @@ const UsersTable = () => { .filter((profile) => userProfiles.includes(profile.id)) .map((profile) => profile.name); return ( - + {profileNames.length > 0 ? ( profileNames.map((name, index) => ( Date: Wed, 7 Jan 2026 20:24:51 +0000 Subject: [PATCH 03/63] Second Thought.. We should useMemo same as we do for the other data to prevent unnecessary compute. --- frontend/src/components/tables/UsersTable.jsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index bd4f17f6..eecb8fc5 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -141,6 +141,15 @@ const UsersTable = () => { /** * useMemo */ + // Create a profile ID to name lookup map for efficient rendering + const profileIdToName = useMemo(() => { + const map = {}; + Object.values(profiles).forEach((profile) => { + map[profile.id] = profile.name; + }); + return map; + }, [profiles]); + const columns = useMemo( () => [ { @@ -268,9 +277,9 @@ const UsersTable = () => { grow: true, cell: ({ getValue }) => { const userProfiles = getValue() || []; - const profileNames = Object.values(profiles) - .filter((profile) => userProfiles.includes(profile.id)) - .map((profile) => profile.name); + const profileNames = userProfiles + .map((id) => profileIdToName[id]) + .filter(Boolean); // Filter out any undefined values return ( {profileNames.length > 0 ? ( @@ -308,7 +317,7 @@ const UsersTable = () => { ), }, ], - [theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility] + [theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility, profileIdToName] ); const closeUserForm = () => { From 6c71115514ff7ef2db029469e6d4b20915a02751 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Fri, 17 Apr 2026 09:11:14 -0400 Subject: [PATCH 04/63] Enhancement: Add additional fields to plugin manifest including maintainers, deprecated status, repo URL, Discord thread, and latest size. --- Plugin_repo.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/Plugin_repo.md b/Plugin_repo.md index d846526d..6df4dec5 100644 --- a/Plugin_repo.md +++ b/Plugin_repo.md @@ -55,12 +55,17 @@ This is the simplest valid repo manifest - one plugin with enough info to show i "name": "Weather Display", "description": "Shows weather info on the dashboard", "author": "Acme Labs", + "maintainers": ["alice", "bob"], "license": "MIT", + "deprecated": false, + "repo_url": "https://github.com/acmelabs/dispatcharr-weather", + "discord_thread": "https://discord.com/channels/123456/789012", "latest_version": "1.2.5", "last_updated": "2025-01-20T15:30:00Z", "manifest_url": "plugins/weather_display/manifest.json", "latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip", "latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "latest_size": 142, "icon_url": "plugins/weather_display/logo.png", "min_dispatcharr_version": "2.5.0", "max_dispatcharr_version": null @@ -128,13 +133,18 @@ If the name contains any of these, the repo will be rejected on add and skipped | `name` | **Yes** | Human-readable display name. | | `description` | No | Short description shown on the plugin card. | | `author` | No | Author or organization name. | +| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. | | `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | +| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. | +| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). | +| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. | | `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | | `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | | `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | | `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | | `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | | `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | +| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. | | `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | | `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | | `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | @@ -216,11 +226,14 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "author": "Acme Labs", "license": "MIT", "latest_version": "1.2.5", + "registry_name": "Acme Labs Plugins", + "registry_url": "https://github.com/acmelabs/dispatcharr-plugins", "versions": [ { "version": "1.2.5", "url": "releases/weather_display-1.2.5.zip", "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 142, "build_timestamp": "2025-01-20T15:30:00Z", "commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648", "commit_sha_short": "4e8f1b1", @@ -231,6 +244,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "version": "1.2.5-rc.1", "url": "releases/weather_display-1.2.5-rc.1.zip", "checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "size": 141, "prerelease": true, "build_timestamp": "2025-01-18T09:00:00Z", "min_dispatcharr_version": "2.5.0" @@ -239,6 +253,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "version": "1.2.4", "url": "releases/weather_display-1.2.4.zip", "checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6", + "size": 138, "build_timestamp": "2025-01-15T10:00:00Z", "min_dispatcharr_version": "2.4.0" } @@ -246,7 +261,9 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. "latest": { "version": "1.2.5", "url": "releases/weather_display-1.2.5.zip", + "latest_url": "releases/weather_display-latest.zip", "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 142, "build_timestamp": "2025-01-20T15:30:00Z", "min_dispatcharr_version": "2.5.0" } @@ -263,8 +280,10 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. | `author` | No | Author/org name shown in the detail modal. | | `license` | No | SPDX license identifier. | | `latest_version` | No | Latest version string. | +| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. | +| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. | | `versions` | No | Array of version objects (newest first recommended). | -| `latest` | No | Object mirroring the latest version entry for quick access. | +| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. | ### Version Object Fields @@ -277,6 +296,7 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest. | `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | | `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | | `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | +| `size` | No | Size of this version's zip in kilobytes. Informational only. | | `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | | `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | @@ -536,12 +556,18 @@ You can host release zips as GitHub Release assets and reference them with absol "name": "string (required)", "description": "string", "author": "string", + "maintainers": ["string"], "license": "string (SPDX)", + "deprecated": "boolean", + "repo_url": "string (URL)", + "discord_thread": "string (URL)", "latest_version": "string (semver)", "last_updated": "string (ISO 8601)", "manifest_url": "string (URL or relative path)", "latest_url": "string (URL or relative path)", "latest_sha256": "string (64-char hex)", + "latest_md5": "string", + "latest_size": "number (KB)", "icon_url": "string (URL or relative path)", "min_dispatcharr_version": "string (semver)", "max_dispatcharr_version": "string (semver) or null" @@ -562,11 +588,15 @@ You can host release zips as GitHub Release assets and reference them with absol "author": "string", "license": "string (SPDX)", "latest_version": "string (semver)", + "registry_name": "string", + "registry_url": "string (URL)", "versions": [ { "version": "string (required)", "url": "string (required, URL or relative path)", "checksum_sha256": "string (64-char hex)", + "size": "number (KB)", + "prerelease": "boolean", "build_timestamp": "string (ISO 8601)", "commit_sha": "string", "commit_sha_short": "string", @@ -577,7 +607,9 @@ You can host release zips as GitHub Release assets and reference them with absol "latest": { "version": "string", "url": "string", + "latest_url": "string (stable symlink URL)", "checksum_sha256": "string", + "size": "number (KB)", "build_timestamp": "string", "min_dispatcharr_version": "string", "max_dispatcharr_version": "string or null" From 110f51f711cfd702097e551358092dd295a7bb21 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Fri, 17 Apr 2026 09:44:46 -0400 Subject: [PATCH 05/63] Add file size display to plugin details and install buttons and information popups --- frontend/src/components/PluginDetailPanel.jsx | 9 ++- .../components/cards/AvailablePluginCard.jsx | 74 +++++++++++++++++-- frontend/src/components/cards/PluginCard.jsx | 4 +- frontend/src/pages/PluginBrowse.jsx | 13 ++++ frontend/src/utils/networkUtils.js | 10 +++ 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/PluginDetailPanel.jsx b/frontend/src/components/PluginDetailPanel.jsx index 8a7fc0c0..bbce3430 100644 --- a/frontend/src/components/PluginDetailPanel.jsx +++ b/frontend/src/components/PluginDetailPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { ActionIcon, Alert, @@ -14,6 +14,7 @@ import { } from '@mantine/core'; import { AlertTriangle, Ban, Check, Download, RefreshCw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; import { compareVersions } from './pluginUtils.js'; +import { formatKB } from '../utils/networkUtils.js'; export const GitHubIcon = ({ size = 16 }) => ( @@ -367,6 +368,12 @@ const PluginDetailPanel = ({ {new Date(selectedVersionData.build_timestamp).toLocaleString()} )} + {Number.isFinite(selectedVersionData.size) && ( + + File Size + {formatKB(selectedVersionData.size)} + + )} {selectedVersionData.min_dispatcharr_version && ( Min Version diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx index af6657d7..d4377b35 100644 --- a/frontend/src/components/cards/AvailablePluginCard.jsx +++ b/frontend/src/components/cards/AvailablePluginCard.jsx @@ -20,6 +20,7 @@ import API from '../../api'; import { usePluginStore } from '../../store/plugins'; import PluginDetailPanel from '../PluginDetailPanel.jsx'; import { compareVersions } from '../pluginUtils.js'; +import { formatKB } from '../../utils/networkUtils.js'; const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { if (isOfficial) { @@ -112,6 +113,56 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst return null; }; +const SizedInstallButton = ({ latest_size, children, color, loading, disabled, onClick, ...buttonProps }) => { + const [hovered, setHovered] = useState(false); + if (!Number.isFinite(latest_size)) { + return ; + } + const isDisabled = disabled || loading; + const colorVar = color ? `var(--mantine-color-${color}-filled)` : 'var(--mantine-primary-color-filled)'; + return ( + { if (!isDisabled) setHovered(true); }} + onMouseLeave={() => setHovered(false)} + > + + + {formatKB(latest_size)} + + + ); +}; + const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDetail = false, onDetailClose, onInstalled, onUninstalled, onBeforeInstall }) => { const meetsMinVersion = !plugin.min_dispatcharr_version || compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0; const meetsMaxVersion = !plugin.max_dispatcharr_version || compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0; @@ -236,6 +287,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe min_dispatcharr_version: plugin.min_dispatcharr_version, max_dispatcharr_version: plugin.max_dispatcharr_version, build_timestamp: plugin.last_updated, + size: plugin.latest_size, }] : [], latest: plugin.latest_version ? { version: plugin.latest_version } : null, }, @@ -290,6 +342,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe radius="sm" size={48} alt={`${plugin.name} logo`} + imageProps={{ draggable: false }} > {plugin.name?.[0]?.toUpperCase()} @@ -402,30 +455,32 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {(plugin.install_status === 'unmanaged') && plugin.latest_version && plugin.latest_url && ( - : } disabled={!meetsVersion || installing} onClick={() => doInstall(latestInstallParams)} > {installing ? 'Installing...' : 'Overwrite'} - + )} {(plugin.install_status === 'different_repo') && plugin.latest_url && ( - : } disabled={!meetsVersion || installing} onClick={() => doInstall(latestInstallParams)} > {installing ? 'Installing...' : 'Overwrite'} - + )} {(plugin.install_status === 'installed') && ( @@ -440,10 +495,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe )} {(plugin.install_status === 'update_available') && ( - : isLatestDowngrade ? : } disabled={!meetsVersion || installing} onClick={() => doInstall(latestInstallParams)} @@ -451,18 +507,19 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {installing ? (isLatestDowngrade ? 'Downgrading...' : 'Updating...') : (isLatestDowngrade ? 'Downgrade' : 'Update')} - + )} {(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && ( - : } disabled={!meetsVersion || installing} onClick={() => doInstall(latestInstallParams)} > {installing ? 'Installing...' : 'Install'} - + )} @@ -478,6 +535,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe radius="sm" size={28} alt={`${plugin.name} logo`} + imageProps={{ draggable: false }} > {plugin.name?.[0]?.toUpperCase()} diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index 59dd9250..9cecc966 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -177,6 +177,7 @@ const PluginCard = ({ min_dispatcharr_version: avail.min_dispatcharr_version, max_dispatcharr_version: avail.max_dispatcharr_version, build_timestamp: avail.last_updated, + size: avail.latest_size, }] : [], latest: avail.latest_version ? { version: avail.latest_version } : null, }, @@ -363,6 +364,7 @@ const PluginCard = ({ alt={`${plugin.name} logo`} onClick={isManaged ? () => openModal('details') : undefined} style={isManaged ? { cursor: 'pointer' } : undefined} + imageProps={{ draggable: false }} > {plugin.name?.[0]?.toUpperCase()} @@ -529,7 +531,7 @@ const PluginCard = ({ onClose={() => setModalOpen(false)} title={ - + {plugin.name?.[0]?.toUpperCase()} {plugin.name} diff --git a/frontend/src/pages/PluginBrowse.jsx b/frontend/src/pages/PluginBrowse.jsx index 35ecc1ec..ccc81b28 100644 --- a/frontend/src/pages/PluginBrowse.jsx +++ b/frontend/src/pages/PluginBrowse.jsx @@ -26,6 +26,7 @@ import { KeyRound, ShieldCheck, ShieldAlert, + Package, } from 'lucide-react'; import { usePluginStore } from '../store/plugins.jsx'; import useSettingsStore from '../store/settings.jsx'; @@ -364,6 +365,18 @@ export default function PluginBrowsePage() { )} + + + ) : ( - - ) : ( - - )} - {!selCompatible && selectedVersionData && !isSelSame && (() => { - const parts = []; - if (!selMeetsMin) parts.push(`${selectedVersionData.min_dispatcharr_version} or newer`); - if (!selMeetsMax) parts.push(`${selectedVersionData.max_dispatcharr_version} or older`); - const label = !selMeetsMin - ? `Min ${selectedVersionData.min_dispatcharr_version}` - : `Max ${selectedVersionData.max_dispatcharr_version}`; - return ( - - - - {label} - - - ); - })()} - - - {selectedVersionData && ( - - - {selectedVersionData.build_timestamp && ( - - Built - {new Date(selectedVersionData.build_timestamp).toLocaleString()} - - )} - {Number.isFinite(selectedVersionData.size) && ( - - File Size - {formatKB(selectedVersionData.size)} - - )} - {selectedVersionData.min_dispatcharr_version && ( - - Min Version - {selectedVersionData.min_dispatcharr_version} - - )} - {selectedVersionData.max_dispatcharr_version && ( - - Max Version - {selectedVersionData.max_dispatcharr_version} - - )} - {selectedVersionData.commit_sha_short && ( - - Commit - - {manifest.registry_url ? ( - { + const parts = []; + if (!selMeetsMin) + parts.push( + `${selectedVersionData.min_dispatcharr_version} or newer` + ); + if (!selMeetsMax) + parts.push( + `${selectedVersionData.max_dispatcharr_version} or older` + ); + const label = !selMeetsMin + ? `Min ${selectedVersionData.min_dispatcharr_version}` + : `Max ${selectedVersionData.max_dispatcharr_version}`; + return ( + - {selectedVersionData.commit_sha_short} - - ) : ( - selectedVersionData.commit_sha_short + + + + {label} + + + + ); + })()} + + + {selectedVersionData && ( +
+ + {selectedVersionData.build_timestamp && ( + + + Built + + + {new Date( + selectedVersionData.build_timestamp + ).toLocaleString()} + + + )} + {Number.isFinite(selectedVersionData.size) && + selectedVersionData.size > 0 && ( + + + File Size + + + {formatKB(selectedVersionData.size)} + + )} - - - )} - {selectedVersionData.url && ( - - Download - - - {selectedVersionData.url.split('/').pop()} - - - - )} - -
- )} - - ); - })()} + {selectedVersionData.min_dispatcharr_version && ( + + + Min Version + + + {selectedVersionData.min_dispatcharr_version} + + + )} + {selectedVersionData.max_dispatcharr_version && ( + + + Max Version + + + {selectedVersionData.max_dispatcharr_version} + + + )} + {selectedVersionData.commit_sha_short && ( + + + Commit + + + {manifest.registry_url ? ( + + {selectedVersionData.commit_sha_short} + + ) : ( + selectedVersionData.commit_sha_short + )} + + + )} + {selectedVersionData.url && ( + + + Download + + + + {selectedVersionData.url.split('/').pop()} + + + + )} + + + )} + + ); + })()}
); }; diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx index d4377b35..5866f51a 100644 --- a/frontend/src/components/cards/AvailablePluginCard.jsx +++ b/frontend/src/components/cards/AvailablePluginCard.jsx @@ -14,13 +14,25 @@ import { Text, Tooltip, } from '@mantine/core'; -import { AlertTriangle, Ban, Check, Download, FlaskConical, Info, RefreshCw, RotateCcw, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; +import { + AlertTriangle, + Ban, + Check, + Download, + FlaskConical, + Info, + RefreshCw, + RotateCcw, + ShieldAlert, + ShieldCheck, + Trash2, +} from 'lucide-react'; import { useNavigate, useLocation } from 'react-router-dom'; import API from '../../api'; import { usePluginStore } from '../../store/plugins'; import PluginDetailPanel from '../PluginDetailPanel.jsx'; import { compareVersions } from '../pluginUtils.js'; -import { formatKB } from '../../utils/networkUtils.js'; +import SizedInstallButton from '../theme/SizedInstallButton.jsx'; const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { if (isOfficial) { @@ -28,15 +40,34 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { : ) : undefined} + style={{ + backgroundColor: + signatureVerified === false + ? 'var(--mantine-color-red-9)' + : '#14917E', + }} + leftSection={ + signatureVerified != null ? ( + signatureVerified ? ( + + ) : ( + + ) + ) : undefined + } > Official Repo ); return signatureVerified != null ? ( - {badge} - ) : badge; + + {badge} + + ) : ( + badge + ); } if (!repoName) return null; const badge = ( @@ -44,58 +75,118 @@ const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => { size="xs" variant="filled" color={signatureVerified === false ? 'red.9' : 'gray'} - leftSection={signatureVerified != null ? (signatureVerified ? : ) : undefined} + leftSection={ + signatureVerified != null ? ( + signatureVerified ? ( + + ) : ( + + ) + ) : undefined + } > {repoName} ); return signatureVerified != null ? ( - {badge} - ) : badge; + + {badge} + + ) : ( + badge + ); }; -const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, installedSourceRepoName }) => { +const StatusBadge = ({ + status, + deprecated, + isPrerelease, + isLatestDowngrade, + installedSourceRepoName, +}) => { if (status === 'installed') { const baseLabel = isPrerelease ? 'Prerelease' : 'Installed'; if (!deprecated) { return ( - : }> + : + } + > {baseLabel} ); } return ( - - }> + + } + > {baseLabel} · Deprecated ); } if (status === 'update_available') { - const baseLabel = isLatestDowngrade ? 'Newer Installed' : 'Update Available'; + const baseLabel = isLatestDowngrade + ? 'Newer Installed' + : 'Update Available'; if (!deprecated) { return ( - : }> + + ) : ( + + ) + } + > {baseLabel} ); } return ( - }> + } + > {baseLabel} · Deprecated ); } if (status === 'unmanaged' || status === 'different_repo') { - const tooltip = status === 'unmanaged' - ? (deprecated ? 'Installed manually (deprecated) - installing from this repo will take over management' : 'Installed manually - installing from this repo will take over management') - : `Managed by ${installedSourceRepoName || 'another repo'}${deprecated ? ' (deprecated)' : ''}`; + const tooltip = + status === 'unmanaged' + ? deprecated + ? 'Installed manually (deprecated) - installing from this repo will take over management' + : 'Installed manually - installing from this repo will take over management' + : `Managed by ${installedSourceRepoName || 'another repo'}${deprecated ? ' (deprecated)' : ''}`; return ( - : }> + : } + > {deprecated ? 'Installed · Deprecated' : 'Installed'} @@ -104,7 +195,12 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst if (deprecated) { return ( - }> + } + > Deprecated @@ -113,59 +209,22 @@ const StatusBadge = ({ status, deprecated, isPrerelease, isLatestDowngrade, inst return null; }; -const SizedInstallButton = ({ latest_size, children, color, loading, disabled, onClick, ...buttonProps }) => { - const [hovered, setHovered] = useState(false); - if (!Number.isFinite(latest_size)) { - return ; - } - const isDisabled = disabled || loading; - const colorVar = color ? `var(--mantine-color-${color}-filled)` : 'var(--mantine-primary-color-filled)'; - return ( - { if (!isDisabled) setHovered(true); }} - onMouseLeave={() => setHovered(false)} - > - - - {formatKB(latest_size)} - - - ); -}; - -const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDetail = false, onDetailClose, onInstalled, onUninstalled, onBeforeInstall }) => { - const meetsMinVersion = !plugin.min_dispatcharr_version || compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0; - const meetsMaxVersion = !plugin.max_dispatcharr_version || compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0; +const AvailablePluginCard = ({ + plugin, + appVersion, + multiRepo = false, + autoOpenDetail = false, + onDetailClose, + onInstalled, + onUninstalled, + onBeforeInstall, +}) => { + const meetsMinVersion = + !plugin.min_dispatcharr_version || + compareVersions(appVersion, plugin.min_dispatcharr_version) >= 0; + const meetsMaxVersion = + !plugin.max_dispatcharr_version || + compareVersions(appVersion, plugin.max_dispatcharr_version) <= 0; const meetsVersion = meetsMinVersion && meetsMaxVersion; const [detailOpen, setDetailOpen] = useState(false); const [detail, setDetail] = useState(null); @@ -184,14 +243,17 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe const [uninstallConfirmOpen, setUninstallConfirmOpen] = useState(false); const [uninstalling, setUninstalling] = useState(false); const [deprecationWarnOpen, setDeprecationWarnOpen] = useState(false); - const [pendingDeprecatedInstall, setPendingDeprecatedInstall] = useState(null); + const [pendingDeprecatedInstall, setPendingDeprecatedInstall] = + useState(null); const installPlugin = usePluginStore((s) => s.installPlugin); const navigate = useNavigate(); const { pathname } = useLocation(); const onMyPlugins = pathname === '/plugins'; - const isLatestDowngrade = plugin.install_status === 'update_available' && - plugin.latest_version && plugin.installed_version && + const isLatestDowngrade = + plugin.install_status === 'update_available' && + plugin.latest_version && + plugin.installed_version && compareVersions(plugin.latest_version, plugin.installed_version) < 0; const doInstall = (params) => { @@ -220,7 +282,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe const executeInstall = async (params) => { const wasInstalled = plugin.installed; - const wasDowngrade = plugin.installed_version && params.version && + const wasDowngrade = + plugin.installed_version && + params.version && compareVersions(params.version, plugin.installed_version) < 0; onBeforeInstall?.(plugin.slug); setInstalling(true); @@ -228,7 +292,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe setInstalling(false); setPendingInstall(null); if (result?.success) { - setInstallAction(wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed'); + setInstallAction( + wasDowngrade ? 'downgraded' : wasInstalled ? 'updated' : 'installed' + ); setInstalledKey(result.plugin?.key || params.slug); setPluginIsDisabled(result.plugin?.enabled === false); setEnableNow(false); @@ -280,16 +346,22 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe description: plugin.description, author: plugin.author, license: plugin.license, - versions: plugin.latest_version ? [{ - version: plugin.latest_version, - url: plugin.latest_url, - checksum_sha256: plugin.latest_sha256, - min_dispatcharr_version: plugin.min_dispatcharr_version, - max_dispatcharr_version: plugin.max_dispatcharr_version, - build_timestamp: plugin.last_updated, - size: plugin.latest_size, - }] : [], - latest: plugin.latest_version ? { version: plugin.latest_version } : null, + versions: plugin.latest_version + ? [ + { + version: plugin.latest_version, + url: plugin.latest_url, + checksum_sha256: plugin.latest_sha256, + min_dispatcharr_version: plugin.min_dispatcharr_version, + max_dispatcharr_version: plugin.max_dispatcharr_version, + build_timestamp: plugin.last_updated, + size: plugin.latest_size, + }, + ] + : [], + latest: plugin.latest_version + ? { version: plugin.latest_version } + : null, }, signature_verified: plugin.signature_verified ?? null, }); @@ -297,7 +369,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe return; } setDetailLoading(true); - const result = await API.getPluginDetailManifest(plugin.repo_id, plugin.manifest_url); + const result = await API.getPluginDetailManifest( + plugin.repo_id, + plugin.manifest_url + ); if (result) { setDetail(result); if (result.manifest?.versions?.length) { @@ -309,7 +384,7 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe React.useEffect(() => { if (autoOpenDetail) handleMoreInfo(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const latestInstallParams = { @@ -332,11 +407,18 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe flexDirection: 'column', minHeight: 220, backgroundColor: '#27272A', - ...(multiRepo && plugin.is_official_repo ? { borderColor: '#0e6459' } : {}), + ...(multiRepo && plugin.is_official_repo + ? { borderColor: '#0e6459' } + : {}), }} > - + {plugin.author && ( - + {plugin.author} )} @@ -375,7 +462,15 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe - +
{plugin.description} @@ -383,11 +478,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
- + {plugin.latest_version && ( - LATEST - v{plugin.latest_version} + LATESTv + {plugin.latest_version} )} {plugin.license && ( @@ -427,107 +522,144 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe
- {!meetsVersion && (() => { - const parts = []; - if (!meetsMinVersion) parts.push(`${plugin.min_dispatcharr_version} or newer`); - if (!meetsMaxVersion) parts.push(`${plugin.max_dispatcharr_version} or older`); - const label = !meetsMinVersion - ? `Min ${plugin.min_dispatcharr_version}` - : `Max ${plugin.max_dispatcharr_version}`; - return ( - - - - {label} - - - ); - })()} + {!meetsVersion && + (() => { + const parts = []; + if (!meetsMinVersion) + parts.push(`${plugin.min_dispatcharr_version} or newer`); + if (!meetsMaxVersion) + parts.push(`${plugin.max_dispatcharr_version} or older`); + const label = !meetsMinVersion + ? `Min ${plugin.min_dispatcharr_version}` + : `Max ${plugin.max_dispatcharr_version}`; + return ( + + + + + {label} + + + + ); + })()} {meetsVersion && } - - {(plugin.install_status === 'unmanaged') && plugin.latest_version && plugin.latest_url && ( - - : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing ? 'Installing...' : 'Overwrite'} - - - )} - {(plugin.install_status === 'different_repo') && plugin.latest_url && ( - - : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing ? 'Installing...' : 'Overwrite'} - - - )} - {(plugin.install_status === 'installed') && ( - )} - {(plugin.install_status === 'update_available') && ( - : isLatestDowngrade ? : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing - ? (isLatestDowngrade ? 'Downgrading...' : 'Updating...') - : (isLatestDowngrade ? 'Downgrade' : 'Update')} - - )} - {(!plugin.install_status || plugin.install_status === 'not_installed') && plugin.latest_url && ( - : } - disabled={!meetsVersion || installing} - onClick={() => doInstall(latestInstallParams)} - > - {installing ? 'Installing...' : 'Install'} - - )} + {plugin.install_status === 'unmanaged' && + plugin.latest_version && + plugin.latest_url && ( + + : + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing ? 'Installing...' : 'Overwrite'} + + + )} + {plugin.install_status === 'different_repo' && plugin.latest_url && ( + + : + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing ? 'Installing...' : 'Overwrite'} + + + )} + {plugin.install_status === 'installed' && ( + + )} + {plugin.install_status === 'update_available' && ( + + ) : isLatestDowngrade ? ( + + ) : ( + + ) + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing + ? isLatestDowngrade + ? 'Downgrading...' + : 'Updating...' + : isLatestDowngrade + ? 'Downgrade' + : 'Update'} + + )} + {(!plugin.install_status || + plugin.install_status === 'not_installed') && + plugin.latest_url && ( + : + } + disabled={!meetsVersion || installing} + onClick={() => doInstall(latestInstallParams)} + > + {installing ? 'Installing...' : 'Install'} + + )} {/* Detail Modal */} { setDetailOpen(false); onDetailClose?.(); }} + onClose={() => { + setDetailOpen(false); + onDetailClose?.(); + }} title={ } @@ -555,7 +689,9 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} installedVersion={plugin.installed_version} - installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease} + installedVersionIsPrerelease={ + !!plugin.installed_version_is_prerelease + } appVersion={appVersion} installing={installing} uninstalling={uninstalling} @@ -571,7 +707,10 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {/* Deprecation warning modal */} { setDeprecationWarnOpen(false); setPendingDeprecatedInstall(null); }} + onClose={() => { + setDeprecationWarnOpen(false); + setPendingDeprecatedInstall(null); + }} zIndex={300} title={ @@ -583,18 +722,25 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe > - {plugin.name} has been marked as deprecated by its maintainer. + {plugin.name} has been marked as deprecated by its + maintainer. - Deprecated plugins may no longer receive updates or fixes, and could stop working with future - versions of Dispatcharr. It is recommended to look for an alternative. + Deprecated plugins may no longer receive updates or fixes, and could + stop working with future versions of Dispatcharr. It is recommended + to look for an alternative. + + + Do you still want to proceed? - Do you still want to proceed? @@ -612,26 +758,49 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe {/* Unified install confirmation modal */} {(() => { - const isDowngrade = pendingInstall && plugin.installed_version && + const isDowngrade = + pendingInstall && + plugin.installed_version && compareVersions(pendingInstall.version, plugin.installed_version) < 0; - const isUpdate = pendingInstall && plugin.installed_version && + const isUpdate = + pendingInstall && + plugin.installed_version && !isDowngrade && compareVersions(pendingInstall.version, plugin.installed_version) > 0; const isBadSig = plugin.signature_verified === false; - const actionLabel = isDowngrade ? 'Downgrade' : isUpdate ? 'Update' : 'Install'; - const btnColor = (isDowngrade && isBadSig) ? 'red' : isDowngrade ? 'orange' : isBadSig ? 'red' : undefined; + const actionLabel = isDowngrade + ? 'Downgrade' + : isUpdate + ? 'Update' + : 'Install'; + const btnColor = + isDowngrade && isBadSig + ? 'red' + : isDowngrade + ? 'orange' + : isBadSig + ? 'red' + : undefined; return ( { setConfirmOpen(false); setPendingInstall(null); }} + onClose={() => { + setConfirmOpen(false); + setPendingInstall(null); + }} zIndex={300} title={ - {isBadSig - ? - : isDowngrade - ? - : } + {isBadSig ? ( + + ) : isDowngrade ? ( + + ) : ( + + )} Confirm {actionLabel} } @@ -639,55 +808,76 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe > - You are about to {actionLabel.toLowerCase()} {plugin.name}{' '} - {isUpdate || isDowngrade - ? <>from v{plugin.installed_version} to v{pendingInstall?.version} - : <>v{pendingInstall?.version}} - {plugin.repo_name ? <> from {plugin.repo_name} : ''}. + You are about to {actionLabel.toLowerCase()}{' '} + {plugin.name}{' '} + {isUpdate || isDowngrade ? ( + <> + from v{plugin.installed_version} to{' '} + v{pendingInstall?.version} + + ) : ( + <> + v{pendingInstall?.version} + + )} + {plugin.repo_name ? ( + <> + {' '} + from {plugin.repo_name} + + ) : ( + '' + )} + . - Plugins run server-side code with full access to your Dispatcharr instance and its - data. Only install plugins from developers you trust. Malicious plugins could read - or modify data, call internal APIs, or perform unwanted actions. + Plugins run server-side code with full access to your + Dispatcharr instance and its data. Only install plugins from + developers you trust. Malicious plugins could read or modify + data, call internal APIs, or perform unwanted actions. {isDowngrade && ( - Warning: Downgrading may cause issues with saved settings or data. + Warning: Downgrading may cause issues with saved + settings or data. )} {isBadSig && ( - Warning: This repository has an invalid or unverified signature. - Installing plugins from unverified sources may be risky. + Warning: This repository has an invalid or unverified + signature. Installing plugins from unverified sources may be + risky. )} {plugin.install_status === 'unmanaged' && ( - Note: This plugin was installed manually. Installing from this repo - will bring it under repo management and enable future update checks. + Note: This plugin was installed manually. Installing + from this repo will bring it under repo management and enable + future update checks. )} {plugin.install_status === 'different_repo' && ( - Note: This plugin is currently managed - by {plugin.installed_source_repo_name || 'another repo'}. + Note: This plugin is currently managed by{' '} + {plugin.installed_source_repo_name || 'another repo'}. Installing will transfer management to this repo. )} - Are you sure you want to proceed? + + Are you sure you want to proceed? + - @@ -755,7 +945,11 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe A restart of Dispatcharr may be required to fully unload the plugin. - @@ -771,7 +965,12 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe - Plugin {installAction === 'installed' ? 'Installed' : installAction === 'downgraded' ? 'Downgraded' : 'Updated'} + Plugin{' '} + {installAction === 'installed' + ? 'Installed' + : installAction === 'downgraded' + ? 'Downgraded' + : 'Updated'} } @@ -779,15 +978,18 @@ const AvailablePluginCard = ({ plugin, appVersion, multiRepo = false, autoOpenDe > - {plugin.name} has been {installAction || 'installed'} successfully. + {plugin.name} has been {installAction || 'installed'}{' '} + successfully. - A restart of Dispatcharr may be required for the plugin to be fully loaded. + A restart of Dispatcharr may be required for the plugin to be fully + loaded. {pluginIsDisabled && ( <> - This plugin is currently disabled. You can enable it now or at any time from My Plugins. + This plugin is currently disabled. You can enable it now or at + any time from My Plugins. Enable plugin diff --git a/frontend/src/components/theme/SizedInstallButton.jsx b/frontend/src/components/theme/SizedInstallButton.jsx new file mode 100644 index 00000000..780aedb5 --- /dev/null +++ b/frontend/src/components/theme/SizedInstallButton.jsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { Box, Button, Group } from '@mantine/core'; +import { formatKB } from '../../utils/networkUtils.js'; + +const SizedInstallButton = ({ + latest_size, + children, + color, + loading, + disabled, + onClick, + ...buttonProps +}) => { + const [hovered, setHovered] = useState(false); + if (!Number.isFinite(latest_size) || latest_size <= 0) { + return ( + + ); + } + const isDisabled = disabled || loading; + const colorVar = color + ? `var(--mantine-color-${color}-filled)` + : 'var(--mantine-primary-color-filled)'; + return ( + { + if (!isDisabled) setHovered(true); + }} + onMouseLeave={() => setHovered(false)} + > + + + {formatKB(latest_size)} + + + ); +}; + +export default SizedInstallButton; From 6f99d8ea85ce2db8d1383fdb3eca58cfc1dc4093 Mon Sep 17 00:00:00 2001 From: damien-alt-sudo <135709986+damien-alt-sudo@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:59:20 +0100 Subject: [PATCH 08/63] Styling fix UsersTable.jsx --- frontend/src/components/tables/UsersTable.jsx | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 16588775..a0fc714b 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -19,6 +19,7 @@ import { LoadingOverlay, Stack, Badge, + Tooltip, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; @@ -155,6 +156,7 @@ const UsersTable = () => { header: 'User Level', accessorKey: 'user_level', size: 120, + minSize: 80, cell: ({ getValue }) => ( {USER_LEVEL_LABELS[getValue()]} ), @@ -162,7 +164,8 @@ const UsersTable = () => { { header: 'Username', accessorKey: 'username', - size: 150, + size: 120, + minSize: 75, cell: ({ getValue }) => ( { { id: 'name', header: 'Name', + size: 120, + minSize: 50, accessorFn: (row) => `${row.first_name || ''} ${row.last_name || ''}`.trim(), cell: ({ getValue }) => ( @@ -195,7 +200,8 @@ const UsersTable = () => { { header: 'Email', accessorKey: 'email', - grow: true, + size: 200, + minSize: 50, cell: ({ getValue }) => ( { { header: 'Date Joined', accessorKey: 'date_joined', - size: 125, + size: 90, + minSize: 90, cell: ({ getValue }) => { const date = getValue(); return ( @@ -223,6 +230,7 @@ const UsersTable = () => { header: 'Last Login', accessorKey: 'last_login', size: 175, + minSize: 85, cell: ({ getValue }) => { const date = getValue(); return ( @@ -235,7 +243,8 @@ const UsersTable = () => { { header: 'XC Password', accessorKey: 'custom_properties', - size: 125, + size: 100, + minSize: 95, enableSorting: false, cell: ({ getValue, row }) => { const userId = row.original.id; @@ -247,10 +256,17 @@ const UsersTable = () => { password = customProps.xc_password || 'N/A'; return ( - + {password === 'N/A' ? 'N/A' : isVisible ? password : '••••••••'} @@ -271,6 +287,8 @@ const UsersTable = () => { { header: 'Channel Profiles', accessorKey: 'channel_profiles', + size: 120, + minSize: 116, grow: true, cell: ({ getValue }) => { const userProfiles = getValue() || []; @@ -281,14 +299,15 @@ const UsersTable = () => { {profileNames.length > 0 ? ( profileNames.map((name, index) => ( - - {name} - + + + {name} + + )) ) : ( @@ -301,9 +320,10 @@ const UsersTable = () => { }, { id: 'actions', - size: 80, + size: 65, header: 'Actions', enableSorting: false, + enableResizing: false, cell: ({ row }) => ( { minHeight: '100vh', }} > - + Date: Sat, 18 Apr 2026 17:36:58 -0400 Subject: [PATCH 09/63] Add security and support disclaimers for plugins, normalize warnings across plugin-system actions --- frontend/src/components/PluginWarnings.jsx | 113 ++++++++++++++++++ .../components/cards/AvailablePluginCard.jsx | 51 +++++--- frontend/src/components/cards/PluginCard.jsx | 82 ++++++++++++- frontend/src/pages/Plugins.jsx | 22 ++-- 4 files changed, 232 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/PluginWarnings.jsx diff --git a/frontend/src/components/PluginWarnings.jsx b/frontend/src/components/PluginWarnings.jsx new file mode 100644 index 00000000..f8404098 --- /dev/null +++ b/frontend/src/components/PluginWarnings.jsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Box, Text } from '@mantine/core'; +import { AlertTriangle, OctagonAlert } from 'lucide-react'; +import { DiscordIcon } from './PluginDetailPanel.jsx'; + +export const PluginSecurityWarning = ({ children }) => ( + + + + + + {children} + + +); + +export const PluginSupportDisclaimer = () => ( + + + + + + Dispatcharr community support cannot assist with third-party plugin + issues. For help, use the plugin's Discord thread or submit an issue + on the plugin's repository. + + +); + +export const PluginDowngradeWarning = ({ children }) => ( + + + + + + {children} + + +); + +export const PluginInfoNote = ({ children }) => ( + + + + + + {children} + + +); + +export const PluginRestartWarning = () => ( + + + + + + Importing a plugin may briefly restart the backend (you might see a + temporary disconnect). Please wait a few seconds and the app will + reconnect automatically. + + +); diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx index 5866f51a..8e94b2fb 100644 --- a/frontend/src/components/cards/AvailablePluginCard.jsx +++ b/frontend/src/components/cards/AvailablePluginCard.jsx @@ -27,6 +27,13 @@ import { ShieldCheck, Trash2, } from 'lucide-react'; +import { DiscordIcon } from '../PluginDetailPanel.jsx'; +import { + PluginDowngradeWarning, + PluginInfoNote, + PluginSecurityWarning, + PluginSupportDisclaimer, +} from '../PluginWarnings.jsx'; import { useNavigate, useLocation } from 'react-router-dom'; import API from '../../api'; import { usePluginStore } from '../../store/plugins'; @@ -730,6 +737,13 @@ const AvailablePluginCard = ({ stop working with future versions of Dispatcharr. It is recommended to look for an alternative. + + Plugins run server-side code with full access to your Dispatcharr + instance and its data. Only install plugins from developers you + trust. Malicious plugins could read or modify data, call internal + APIs, or perform unwanted actions. + + Do you still want to proceed? @@ -830,38 +844,37 @@ const AvailablePluginCard = ({ )} . - + Plugins run server-side code with full access to your Dispatcharr instance and its data. Only install plugins from developers you trust. Malicious plugins could read or modify data, call internal APIs, or perform unwanted actions. - + + {isDowngrade && ( - - Warning: Downgrading may cause issues with saved - settings or data. - + + Downgrading may cause issues with saved settings or data. + )} {isBadSig && ( - - Warning: This repository has an invalid or unverified - signature. Installing plugins from unverified sources may be - risky. - + + This repository has an invalid or unverified signature. + Installing plugins from unverified sources may be risky. + )} {plugin.install_status === 'unmanaged' && ( - - Note: This plugin was installed manually. Installing - from this repo will bring it under repo management and enable - future update checks. - + + This plugin was installed manually. Installing from this repo + will bring it under repo management and enable future update + checks. + )} {plugin.install_status === 'different_repo' && ( - - Note: This plugin is currently managed by{' '} + + This plugin is currently managed by{' '} {plugin.installed_source_repo_name || 'another repo'}. Installing will transfer management to this repo. - + )} Are you sure you want to proceed? diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index 9cecc966..7ff77234 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -17,7 +17,7 @@ import { Text, Tooltip, } from '@mantine/core'; -import { Ban, Check, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react'; +import { Ban, Check, Download, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react'; import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js'; import { SUBSCRIPTION_EVENTS } from '../../constants.js'; import useSettingsStore from '../../store/settings.jsx'; @@ -25,6 +25,10 @@ import { usePluginStore } from '../../store/plugins.jsx'; import API from '../../api'; import PluginDetailPanel from '../PluginDetailPanel.jsx'; import { compareVersions } from '../pluginUtils.js'; +import { + PluginSecurityWarning, + PluginSupportDisclaimer, +} from '../PluginWarnings.jsx'; const PluginFieldList = ({ plugin, settings, updateField }) => { return plugin.fields.map((f) => ( @@ -128,6 +132,8 @@ const PluginCard = ({ const [selectedVersion, setSelectedVersion] = useState(null); const [installing, setInstalling] = useState(false); const [uninstalling] = useState(false); + const [installConfirmOpen, setInstallConfirmOpen] = useState(false); + const [pendingInstallParams, setPendingInstallParams] = useState(null); const installPlugin = usePluginStore((s) => s.installPlugin); @@ -311,14 +317,18 @@ const PluginCard = ({ }; const handleDetailInstall = async (params) => { + setPendingInstallParams(params); + setInstallConfirmOpen(true); + }; + + const confirmAndInstall = async () => { + if (!pendingInstallParams) return; + const params = pendingInstallParams; const selVer = params.version; const isDown = plugin.version && compareVersions(selVer, plugin.version) < 0; const action = isDown ? 'downgrade' : 'update'; - const confirmed = await onRequestConfirm( - `${isDown ? 'Downgrade' : 'Update'} ${plugin.name}?`, - `${isDown ? 'Downgrade' : 'Update'} from v${plugin.version} to v${selVer}?` - ); - if (!confirmed) return; + setInstallConfirmOpen(false); + setPendingInstallParams(null); setInstalling(true); try { const result = await installPlugin(params); @@ -616,6 +626,66 @@ const PluginCard = ({ )} + + {/* Install confirmation modal */} + {(() => { + const selVer = pendingInstallParams?.version; + const isDown = plugin.version && selVer && compareVersions(selVer, plugin.version) < 0; + const actionLabel = isDown ? 'Downgrade' : 'Update'; + return ( + { + setInstallConfirmOpen(false); + setPendingInstallParams(null); + }} + zIndex={300} + title={ + + {isDown + ? + : } + Confirm {actionLabel} + + } + size="sm" + > + + + You are about to {actionLabel.toLowerCase()} {plugin.name}{' '} + from v{plugin.version} to v{selVer}. + + + Plugins run server-side code with full access to your Dispatcharr + instance and its data. Only install plugins from developers you + trust. Malicious plugins could read or modify data, call internal + APIs, or perform unwanted actions. + + + Are you sure you want to proceed? + + + + + + + ); + })()} ); }; diff --git a/frontend/src/pages/Plugins.jsx b/frontend/src/pages/Plugins.jsx index 36002776..98b8d926 100644 --- a/frontend/src/pages/Plugins.jsx +++ b/frontend/src/pages/Plugins.jsx @@ -41,6 +41,11 @@ import { } from '../utils/pages/PluginsUtils.js'; import { RefreshCcw, Search } from 'lucide-react'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; +import { + PluginRestartWarning, + PluginSecurityWarning, + PluginSupportDisclaimer, +} from '../components/PluginWarnings.jsx'; const PluginCard = React.lazy( () => import('../components/cards/PluginCard.jsx') ); @@ -426,11 +431,8 @@ export default function PluginsPage() { Upload a ZIP containing your plugin folder or package. - - Importing a plugin may briefly restart the backend (you might see a - temporary disconnect). Please wait a few seconds and the app will - reconnect automatically. - + + files[0] && setImportFile(files[0])} onReject={() => {}} @@ -536,16 +538,14 @@ export default function PluginsPage() { zIndex={300} > - + Plugins run server-side code with full access to your Dispatcharr instance and its data. Only enable plugins from developers you - trust. - - - Why: Malicious plugins could read or modify data, call internal + trust. Malicious plugins could read or modify data, call internal APIs, or perform unwanted actions. Review the source or trust the author before enabling. - + + + + )} + + + + + + + + + {stream.stream_stats_updated_at && ( + + Last updated:{' '} + {new Date(stream.stream_stats_updated_at).toLocaleString()} + + )} + + +
+ ); + } +); + +// ── Main component ─────────────────────────────────────────────────────────── + +const ChannelStreams = ({ channel }) => { const theme = useMantineTheme(); const channelStreams = useChannelsTableStore( @@ -140,13 +496,17 @@ const ChannelStreams = ({ channel, isExpanded }) => { const authUser = useAuthStore((s) => s.user); const showVideo = useVideoStore((s) => s.showVideo); const env_mode = useSettingsStore((s) => s.environment.env_mode); - function handleWatchStream(streamHash, streamName) { - let vidUrl = `/proxy/ts/stream/${streamHash}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); - } + + const handleWatchStream = useCallback( + (streamHash, streamName) => { + let vidUrl = `/proxy/ts/stream/${streamHash}`; + if (env_mode === 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); + }, + [env_mode, showVideo] + ); const [data, setData] = useState(channelStreams || []); @@ -154,19 +514,22 @@ const ChannelStreams = ({ channel, isExpanded }) => { setData(channelStreams); }, [channelStreams]); - const dataIds = data?.map(({ id }) => id); + const dataIds = useMemo(() => data?.map(({ id }) => id), [data]); - const removeStream = async (stream) => { - const newStreamList = data.filter((s) => s.id !== stream.id); - await API.updateChannel({ - ...channel, - streams: newStreamList.map((s) => s.id), - }); - await API.requeryChannels(); - await API.requeryStreams(); - }; + const removeStream = useCallback( + async (stream) => { + const newStreamList = data.filter((s) => s.id !== stream.id); + await API.updateChannel({ + ...channel, + streams: newStreamList.map((s) => s.id), + }); + await API.requeryChannels(); + await API.requeryStreams(); + }, + [channel, data] + ); - // Create M3U account map for quick lookup + // M3U account map for quick lookup const m3uAccountsMap = useMemo(() => { const map = {}; if (playlists && Array.isArray(playlists)) { @@ -179,426 +542,121 @@ const ChannelStreams = ({ channel, isExpanded }) => { return map; }, [playlists]); - // Add state for tracking which streams have advanced stats expanded - const [expandedAdvancedStats, setExpandedAdvancedStats] = useState(new Set()); + // Track expanded advanced stats via ref so toggling doesn't recreate columns + const expandedAdvancedStatsRef = useRef(new Set()); - // Helper function to categorize stream stats - const categorizeStreamStats = (stats) => { - if (!stats) - return { basic: {}, video: {}, audio: {}, technical: {}, other: {} }; - - const categories = { - basic: {}, - video: {}, - audio: {}, - technical: {}, - other: {}, - }; - - // Define which stats go in which category - const categoryMapping = { - basic: [ - 'resolution', - 'video_codec', - 'source_fps', - 'audio_codec', - 'audio_channels', - ], - video: [ - 'video_bitrate', - 'pixel_format', - 'width', - 'height', - 'aspect_ratio', - 'frame_rate', - ], - audio: [ - 'audio_bitrate', - 'sample_rate', - 'audio_format', - 'audio_channels_layout', - ], - technical: [ - 'stream_type', - 'container_format', - 'duration', - 'file_size', - 'ffmpeg_output_bitrate', - 'input_bitrate', - ], - other: [], // Will catch anything not categorized above - }; - - // Categorize each stat - Object.entries(stats).forEach(([key, value]) => { - let categorized = false; - - for (const [category, keys] of Object.entries(categoryMapping)) { - if (keys.includes(key)) { - categories[category][key] = value; - categorized = true; - break; - } - } - - // If not categorized, put it in 'other' - if (!categorized) { - categories.other[key] = value; - } - }); - - return categories; - }; - - // Function to format stat values for display - const formatStatValue = (key, value) => { - if (value === null || value === undefined) return 'N/A'; - - // Handle specific formatting cases - switch (key) { - case 'video_bitrate': - case 'audio_bitrate': - case 'ffmpeg_output_bitrate': - return `${value} kbps`; - case 'source_fps': - case 'frame_rate': - return `${value} fps`; - case 'sample_rate': - return `${value} Hz`; - case 'file_size': - // Convert bytes to appropriate unit - if (typeof value === 'number') { - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`; - if (value < 1024 * 1024 * 1024) - return `${(value / (1024 * 1024)).toFixed(2)} MB`; - return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; - } - return value; - case 'duration': - // Format duration if it's in seconds - if (typeof value === 'number') { - const hours = Math.floor(value / 3600); - const minutes = Math.floor((value % 3600) / 60); - const seconds = Math.floor(value % 60); - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - return value; - default: - return value.toString(); - } - }; - - // Function to render a stats category - const renderStatsCategory = (categoryName, stats) => { - if (!stats || Object.keys(stats).length === 0) return null; - - return ( - - - {categoryName} - - - {Object.entries(stats).map(([key, value]) => ( - - - {key - .replace(/_/g, ' ') - .replace(/\b\w/g, (l) => l.toUpperCase())} - : {formatStatValue(key, value)} - - - ))} - - - ); - }; - - // Function to toggle advanced stats for a stream - const toggleAdvancedStats = (streamId) => { - const newExpanded = new Set(expandedAdvancedStats); - if (newExpanded.has(streamId)) { - newExpanded.delete(streamId); + const toggleAdvancedStats = useCallback((streamId) => { + const current = expandedAdvancedStatsRef.current; + if (current.has(streamId)) { + current.delete(streamId); } else { - newExpanded.add(streamId); + current.add(streamId); } - setExpandedAdvancedStats(newExpanded); - }; + }, []); + + // Columns are now stable — they don't depend on data or expandedAdvancedStats. + // Cell renderers receive the row from TanStack at render time. + // StreamInfoCell reads the expanded stats ref directly. + const columns = useMemo( + () => [ + { + id: 'drag-handle', + header: 'Move', + cell: ({ row }) => , + size: 30, + }, + { + id: 'name', + header: 'Stream Info', + accessorKey: 'name', + cell: ({ row }) => { + const stream = row.original; + const playlistName = playlists[stream.m3u_account]?.name || 'Unknown'; + const accountName = + m3uAccountsMap[stream.m3u_account] || playlistName; + + return ( + + ); + }, + }, + { + id: 'actions', + header: '', + size: 30, + cell: ({ row }) => ( +
+ + removeStream(row.original)} + disabled={authUser.user_level != USER_LEVELS.ADMIN} + /> + +
+ ), + }, + ], + [ + playlists, + m3uAccountsMap, + theme, + authUser.user_level, + removeStream, + toggleAdvancedStats, + handleWatchStream, + ] + ); const table = useReactTable({ - columns: useMemo( - () => [ - { - id: 'drag-handle', - header: 'Move', - cell: ({ row }) => , - size: 30, - }, - { - id: 'name', - header: 'Stream Info', - accessorKey: 'name', - cell: ({ row }) => { - const stream = row.original; - const playlistName = - playlists[stream.m3u_account]?.name || 'Unknown'; - const accountName = - m3uAccountsMap[stream.m3u_account] || playlistName; - - // Categorize stream stats - const categorizedStats = categorizeStreamStats(stream.stream_stats); - const hasAdvancedStats = Object.values(categorizedStats).some( - (category) => Object.keys(category).length > 0 - ); - - return ( - - - - {stream.name} - - - {accountName} - - {stream.quality && ( - - {stream.quality} - - )} - {stream.url && ( - <> - - { - e.stopPropagation(); - await copyToClipboard(stream.url, { - successTitle: 'URL Copied', - successMessage: 'Stream URL copied to clipboard', - }); - }} - > - URL - - - - - handleWatchStream( - stream.stream_hash || stream.id, - stream.name - ) - } - style={{ marginLeft: 2 }} - > - - - - - )} - - - {/* Basic Stream Stats (always shown) */} - {stream.stream_stats && ( - - {/* Video Information */} - {(stream.stream_stats.video_codec || - stream.stream_stats.resolution || - stream.stream_stats.video_bitrate || - stream.stream_stats.source_fps) && ( - <> - - Video: - - {stream.stream_stats.resolution && ( - - {stream.stream_stats.resolution} - - )} - {stream.stream_stats.video_bitrate && ( - - {stream.stream_stats.video_bitrate} kbps - - )} - {stream.stream_stats.source_fps && ( - - {stream.stream_stats.source_fps} FPS - - )} - {stream.stream_stats.video_codec && ( - - {stream.stream_stats.video_codec.toUpperCase()} - - )} - - )} - - {/* Audio Information */} - {(stream.stream_stats.audio_codec || - stream.stream_stats.audio_channels) && ( - <> - - Audio: - - {stream.stream_stats.audio_channels && ( - - {stream.stream_stats.audio_channels} - - )} - {stream.stream_stats.audio_codec && ( - - {stream.stream_stats.audio_codec.toUpperCase()} - - )} - - )} - - {/* Output Bitrate */} - {stream.stream_stats.ffmpeg_output_bitrate && ( - <> - - Output Bitrate: - - {stream.stream_stats.ffmpeg_output_bitrate && ( - - {stream.stream_stats.ffmpeg_output_bitrate} kbps - - )} - - )} - - )} - - {/* Advanced Stats Toggle Button */} - {hasAdvancedStats && ( - - - - )} - - {/* Advanced Stats (expandable) */} - - - {renderStatsCategory('Video', categorizedStats.video)} - {renderStatsCategory('Audio', categorizedStats.audio)} - {renderStatsCategory( - 'Technical', - categorizedStats.technical - )} - {renderStatsCategory('Other', categorizedStats.other)} - - {/* Show when stats were last updated */} - {stream.stream_stats_updated_at && ( - - Last updated:{' '} - {new Date( - stream.stream_stats_updated_at - ).toLocaleString()} - - )} - - - - ); - }, - }, - { - id: 'actions', - header: '', - size: 30, - cell: ({ row }) => ( -
- - removeStream(row.original)} - disabled={authUser.user_level != USER_LEVELS.ADMIN} - /> - -
- ), - }, - ], - [data, playlists, m3uAccountsMap, expandedAdvancedStats] - ), + columns, data, state: { data, }, - defaultColumn: { - size: undefined, - minSize: 0, - }, + defaultColumn: defaultColumnConfig, manualPagination: true, manualSorting: true, manualFiltering: true, enableRowSelection: true, getRowId: (row) => row.id, - getCoreRowModel: getCoreRowModel(), + getCoreRowModel: coreRowModel, }); - const handleDragEnd = (event) => { - if (authUser.user_level != USER_LEVELS.ADMIN) { - return; - } + const handleDragEnd = useCallback( + (event) => { + if (authUser.user_level != USER_LEVELS.ADMIN) { + return; + } - const { active, over } = event; - if (active && over && active.id !== over.id) { - setData((data) => { - const oldIndex = dataIds.indexOf(active.id); - const newIndex = dataIds.indexOf(over.id); - const retval = arrayMove(data, oldIndex, newIndex); + const { active, over } = event; + if (active && over && active.id !== over.id) { + setData((prevData) => { + const currentIds = prevData.map(({ id }) => id); + const oldIndex = currentIds.indexOf(active.id); + const newIndex = currentIds.indexOf(over.id); + const retval = arrayMove(prevData, oldIndex, newIndex); - const { streams: _, ...channelUpdate } = channel; - API.updateChannel({ - ...channelUpdate, - streams: retval.map((row) => row.id), - }).then(() => { - API.requeryChannels(); + const { streams: _, ...channelUpdate } = channel; + API.updateChannel({ + ...channelUpdate, + streams: retval.map((row) => row.id), + }).then(() => { + API.requeryChannels(); + }); + + return retval; }); - - return retval; //this is just a splice util - }); - } - }; + } + }, + [authUser.user_level, channel] + ); const sensors = useSensors( useSensor(MouseSensor, {}), @@ -606,10 +664,6 @@ const ChannelStreams = ({ channel, isExpanded }) => { useSensor(KeyboardSensor, {}) ); - if (!isExpanded) { - return <>; - } - const rows = table.getRowModel().rows; return ( @@ -623,7 +677,6 @@ const ChannelStreams = ({ channel, isExpanded }) => { onDragEnd={handleDragEnd} sensors={sensors} > - {' '} { )} {rows.length > 0 && - table - .getRowModel() - .rows.map((row) => )} + rows.map((row, index) => ( + + ))} diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index b531cff6..05c548fd 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -288,6 +288,9 @@ const ChannelsTable = ({ onReady }) => { (s) => s.setSelectedChannelIds ); const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); + const setExpandedChannelId = useChannelsTableStore( + (s) => s.setExpandedChannelId + ); const pagination = useChannelsTableStore((s) => s.pagination); const setPagination = useChannelsTableStore((s) => s.setPagination); const sorting = useChannelsTableStore((s) => s.sorting); @@ -695,6 +698,13 @@ const ChannelsTable = ({ onReady }) => { setSelectedChannelIds(newSelection); }; + const onRowExpansionChange = useCallback( + (expandedIds) => { + setExpandedChannelId(expandedIds.length > 0 ? expandedIds[0] : null); + }, + [setExpandedChannelId] + ); + const onPageSizeChange = (e) => { setPagination({ ...pagination, @@ -1136,6 +1146,7 @@ const ChannelsTable = ({ onReady }) => { enableRowSelection: true, enableDragDrop: true, onRowSelectionChange: onRowSelectionChange, + onRowExpansionChange: onRowExpansionChange, state: { pagination, sorting, @@ -1151,7 +1162,7 @@ const ChannelsTable = ({ onReady }) => { className="tr" style={{ display: 'flex', width: '100%' }} > - + ); }, diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index f1f081dd..55af4724 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -57,11 +57,10 @@ const CustomTable = ({ table }) => { expandedRowIds={table.expandedRowIds} expandedRowRenderer={table.expandedRowRenderer} renderBodyCell={table.renderBodyCell} - getExpandedRowHeight={table.getExpandedRowHeight} getRowStyles={table.getRowStyles} - tableBodyProps={table.tableBodyProps} tableCellProps={table.tableCellProps} enableDragDrop={table.enableDragDrop} + selectedTableIdsSet={table.selectedTableIdsSet} /> ); diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index 0f098835..40fd5678 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -1,68 +1,35 @@ import { Box, Flex } from '@mantine/core'; -import { VariableSizeList as List } from 'react-window'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { useMemo } from 'react'; -import table from '../../../helpers/table'; +import React, { useRef } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { GripVertical } from 'lucide-react'; import useChannelsTableStore from '../../../store/channelsTable'; -const CustomTableBody = ({ - getRowModel, - expandedRowIds, - expandedRowRenderer, - renderBodyCell, - getExpandedRowHeight, - getRowStyles, - tableBodyProps, - tableCellProps, - enableDragDrop = false, -}) => { - const renderExpandedRow = (row) => { - if (expandedRowRenderer) { - return expandedRowRenderer({ row }); - } - - return <>; - }; - - const rows = getRowModel().rows; - - // 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) => { - // 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]); - - const renderTableBodyContents = () => { - return ( - - {rows.map((row, index) => renderTableBodyRow(row, index))} - - ); - }; - - const renderTableBodyRow = (row, index, style = {}) => { - // Get custom styles for this row if the function exists +// Memoized row — only re-renders when this specific row's data, expansion +// state, or drag-drop config actually changes. Callback functions are read +// from refs so the memoized row always uses the latest version when it *does* +// re-render, without needing them as comparator inputs. +const MemoizedTableRow = React.memo( + ({ + row, + index, + isExpanded, + isSelected, + renderBodyCellRef, + expandedRowRendererRef, + getRowStyles, + tableCellProps, + enableDragDrop, + }) => { + const renderBodyCell = renderBodyCellRef.current; const customRowStyles = getRowStyles ? getRowStyles(row) : {}; - - // Extract any className from customRowStyles const customClassName = customRowStyles.className || ''; - delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style + delete customRowStyles.className; return ( {row.getVisibleCells().map((cell) => { @@ -107,12 +74,63 @@ const CustomTableBody = ({ ); })} - {expandedRowIds.includes(row.original.id) && renderExpandedRow(row)} + {isExpanded && expandedRowRendererRef.current({ row })} ); - }; + }, + (prev, next) => { + return ( + prev.row.original === next.row.original && + prev.index === next.index && + prev.isExpanded === next.isExpanded && + prev.isSelected === next.isSelected && + prev.enableDragDrop === next.enableDragDrop + ); + } +); - return renderTableBodyContents(); +const CustomTableBody = ({ + getRowModel, + expandedRowIds, + expandedRowRenderer, + renderBodyCell, + getRowStyles, + tableCellProps, + enableDragDrop = false, + selectedTableIdsSet, +}) => { + // Store callbacks in refs so memoized rows always access the latest versions + // without the function references themselves triggering re-renders. + const renderBodyCellRef = useRef(renderBodyCell); + renderBodyCellRef.current = renderBodyCell; + + const expandedRowRendererRef = useRef(expandedRowRenderer); + expandedRowRendererRef.current = expandedRowRenderer; + + const rows = getRowModel().rows; + + return ( + + {rows.map((row, index) => ( + + ))} + + ); }; const DraggableRowWrapper = ({ diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index c87b57bb..0c081721 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -8,7 +8,7 @@ import { getCoreRowModel, flexRender, } from '@tanstack/react-table'; -import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; const useTable = ({ @@ -17,6 +17,7 @@ const useTable = ({ bodyCellRenderFns = {}, expandedRowRenderer = () => <>, onRowSelectionChange = null, + onRowExpansionChange = null, getExpandedRowHeight = null, state = {}, columnSizing, @@ -25,6 +26,8 @@ const useTable = ({ ...options }) => { const [selectedTableIds, setSelectedTableIds] = useState([]); + const selectedTableIdsRef = useRef(selectedTableIds); + selectedTableIdsRef.current = selectedTableIds; const [expandedRowIds, setExpandedRowIds] = useState([]); const [lastClickedId, setLastClickedId] = useState(null); const [isShiftKeyDown, setIsShiftKeyDown] = useState(false); @@ -146,12 +149,15 @@ const useTable = ({ }; const onRowExpansion = (row) => { - let isExpanded = false; + const rowId = row.original.id; + let newIds; setExpandedRowIds((prev) => { - isExpanded = prev.includes(row.original.id) ? [] : [row.original.id]; - return isExpanded; + newIds = prev.includes(rowId) ? [] : [rowId]; + return newIds; }); - updateSelectedTableIds([row.original.id]); + if (onRowExpansionChange) { + onRowExpansionChange(newIds); + } }; // Handle the shift+click selection @@ -174,7 +180,7 @@ const useTable = ({ const rangeIds = allRowIds.slice(startIndex, endIndex + 1); // Preserve existing selections outside the range - const idsOutsideRange = selectedTableIds.filter( + const idsOutsideRange = selectedTableIdsRef.current.filter( (id) => !rangeIds.includes(id) ); const newSelection = [...new Set([...rangeIds, ...idsOutsideRange])]; @@ -206,7 +212,7 @@ const useTable = ({ // Try to handle with shift-select logic first if (!handleShiftSelect(rowId, isShiftKey)) { // If not handled by shift-select, do regular toggle - const newSet = new Set(selectedTableIds); + const newSet = new Set(selectedTableIdsRef.current); if (e.target.checked) { newSet.add(rowId); } else { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 1d2e972e..a164b885 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -74,19 +74,23 @@ const StreamRowActions = ({ editStream, deleteStream, handleWatchStream, - selectedChannelIds, createChannelFromStream, table, }) => { const tableSize = table?.tableSize ?? 'default'; + const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId); + const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); + const targetChannelId = + expandedChannelId || + (selectedChannelIds.length === 1 ? selectedChannelIds[0] : null); const channelSelectionStreams = useChannelsTableStore( (state) => - state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams + state.channels.find((chan) => chan.id === targetChannelId)?.streams ); const addStreamToChannel = async () => { await API.updateChannel({ - id: selectedChannelIds[0], + id: targetChannelId, streams: [ ...new Set( channelSelectionStreams.map((s) => s.id).concat([row.original.id]) @@ -129,7 +133,7 @@ const StreamRowActions = ({ onClick={addStreamToChannel} style={{ background: 'none' }} disabled={ - selectedChannelIds.length !== 1 || + !targetChannelId || (channelSelectionStreams && channelSelectionStreams .map((s) => s.id) @@ -331,10 +335,14 @@ const StreamsTable = ({ onReady }) => { const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const channelGroups = useChannelsStore((s) => s.channelGroups); + const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId); const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); + const targetChannelId = + expandedChannelId || + (selectedChannelIds.length === 1 ? selectedChannelIds[0] : null); const channelSelectionStreams = useChannelsTableStore( (state) => - state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams + state.channels.find((chan) => chan.id === targetChannelId)?.streams ); const channelProfiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); @@ -1033,7 +1041,7 @@ const StreamsTable = ({ onReady }) => { const addStreamsToChannel = async () => { await API.updateChannel({ - id: selectedChannelIds[0], + id: targetChannelId, streams: [ ...new Set( channelSelectionStreams.map((s) => s.id).concat(selectedStreamIds) @@ -1267,20 +1275,12 @@ const StreamsTable = ({ onReady }) => { editStream={editStream} deleteStream={deleteStream} handleWatchStream={handleWatchStream} - selectedChannelIds={selectedChannelIds} createChannelFromStream={createChannelFromStream} /> ); } }, - [ - selectedChannelIds, - channelSelectionStreams, - theme, - editStream, - deleteStream, - handleWatchStream, - ] + [theme, editStream, deleteStream, handleWatchStream] ); const table = useTable({ @@ -1462,14 +1462,13 @@ const StreamsTable = ({ onReady }) => { > diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx index 7c1f05f2..baa20925 100644 --- a/frontend/src/store/channelsTable.jsx +++ b/frontend/src/store/channelsTable.jsx @@ -1,5 +1,8 @@ import { create } from 'zustand'; +// Stable empty array to avoid creating new references in getChannelStreams +const emptyStreams = []; + const useChannelsTableStore = create((set, get) => ({ channels: [], pageCount: 0, @@ -12,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({ JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50, }, selectedChannelIds: [], + expandedChannelId: null, allQueryIds: [], isUnlocked: false, @@ -38,9 +42,15 @@ const useChannelsTableStore = create((set, get) => ({ }); }, + setExpandedChannelId: (expandedChannelId) => { + set({ + expandedChannelId, + }); + }, + getChannelStreams: (id) => { const channel = get().channels.find((c) => c.id === id); - return channel?.streams ?? []; + return channel?.streams || emptyStreams; }, setPagination: (pagination) => { From 452a493df35fe31449f34eed5e9aa3e80bb9aacf Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 19 Apr 2026 09:55:56 -0500 Subject: [PATCH 15/63] Performance: Fixed N+1 `UPDATE` queries in the stream-order write path --- apps/channels/api_views.py | 6 +++++- apps/channels/serializers.py | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 10d855db..3e786ff1 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -797,17 +797,21 @@ class ChannelViewSet(viewsets.ModelViewSet): if to_remove: channel.channelstream_set.filter(stream_id__in=to_remove).delete() + to_update = [] for order, stream_id in enumerate(normalized_ids): if stream_id in current_links: cs = current_links[stream_id] if cs.order != order: cs.order = order - cs.save(update_fields=["order"]) + to_update.append(cs) else: ChannelStream.objects.create( channel=channel, stream_id=stream_id, order=order ) + if to_update: + ChannelStream.objects.bulk_update(to_update, ["order"]) + # Return the updated objects (already in memory) serialized_channels = ChannelSerializer( [channel for channel, _ in validated_updates], diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index abf26e91..039386a3 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -365,7 +365,6 @@ class ChannelSerializer(serializers.ModelSerializer): normalized_ids = [ stream.id if hasattr(stream, "id") else stream for stream in streams ] - print(normalized_ids) # Get current mapping of stream_id -> ChannelStream current_links = { @@ -382,17 +381,21 @@ class ChannelSerializer(serializers.ModelSerializer): instance.channelstream_set.filter(stream_id__in=to_remove).delete() # Update or create with new order + to_update = [] for order, stream_id in enumerate(normalized_ids): if stream_id in current_links: cs = current_links[stream_id] if cs.order != order: cs.order = order - cs.save(update_fields=["order"]) + to_update.append(cs) else: ChannelStream.objects.create( channel=instance, stream_id=stream_id, order=order ) + if to_update: + ChannelStream.objects.bulk_update(to_update, ["order"]) + return instance def validate_channel_number(self, value): From bf60bbab547766d17764868a816cb2bf011fab88 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 19 Apr 2026 09:56:26 -0500 Subject: [PATCH 16/63] Performance: Stream reorder in `ChannelTableStreams` now completes in a single PATCH request instead of three. --- CHANGELOG.md | 2 ++ frontend/src/api.js | 29 +++++++++++++++++++ .../components/tables/ChannelTableStreams.jsx | 11 +++---- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc1f5dd..9305290b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization. - Fixed `getChannelStreams` store selector to return a stable empty-array reference instead of creating a new `[]` on every call for channels without streams, preventing unnecessary re-renders via the `shallow` comparator. - Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator. + - Stream reorder in `ChannelTableStreams` now completes in a single PATCH request instead of three. Previously dragging a stream to a new position triggered a PATCH, then `requeryStreams()` (re-fetching all streams), then `requeryChannels()` (re-fetching the entire paginated channel list with all embedded stream objects). The reorder now uses a dedicated `API.reorderChannelStreams()` path that issues only the PATCH, then updates the store in-place by reordering the existing stream objects without any network round-trips. On failure, `requeryChannels()` is called to restore correct state. + - Fixed N+1 `UPDATE` queries in the stream-order write path. `ChannelSerializer.update()` and the bulk-edit view were calling `ChannelStream.save(update_fields=["order"])` once per stream whose position changed. Both now collect all modified `ChannelStream` objects and issue a single `ChannelStream.objects.bulk_update(…, ["order"])` call. Also removed an accidental `print(normalized_ids)` debug statement left in the serializer. - Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138) ### Changed diff --git a/frontend/src/api.js b/frontend/src/api.js index 79d6c435..aa15864b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -637,6 +637,35 @@ export default class API { } } + /** + * Lightweight stream reorder: PATCHes only the stream order for a channel + * without triggering requeryStreams or requeryChannels. The caller is + * responsible for optimistic UI updates. + */ + static async reorderChannelStreams(channelId, streamIds) { + try { + await request(`${host}/api/channels/channels/${channelId}/`, { + method: 'PATCH', + body: { id: channelId, streams: streamIds }, + }); + // Update the channelsTable store in-place with the new stream order + const store = useChannelsTableStore.getState(); + const channel = store.channels.find((c) => c.id === channelId); + if (channel) { + // Reorder the existing stream objects to match streamIds + const streamMap = new Map(channel.streams.map((s) => [s.id, s])); + const reorderedStreams = streamIds + .map((id) => streamMap.get(id)) + .filter(Boolean); + store.updateChannel({ ...channel, streams: reorderedStreams }); + } + } catch (e) { + errorNotification('Failed to reorder streams', e); + // On failure, requery to restore correct state + await API.requeryChannels(); + } + } + static async updateChannels(ids, values) { const body = []; for (const id of ids) { diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 4f67dd18..b4ef53c3 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -643,13 +643,10 @@ const ChannelStreams = ({ channel }) => { const newIndex = currentIds.indexOf(over.id); const retval = arrayMove(prevData, oldIndex, newIndex); - const { streams: _, ...channelUpdate } = channel; - API.updateChannel({ - ...channelUpdate, - streams: retval.map((row) => row.id), - }).then(() => { - API.requeryChannels(); - }); + API.reorderChannelStreams( + channel.id, + retval.map((row) => row.id) + ); return retval; }); From e1e60aa05651936778502ad5df86990c837ea8d1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 19 Apr 2026 10:34:34 -0500 Subject: [PATCH 17/63] Performance: - Applied the same lightweight store-update approach from stream reorder to stream removal. `removeStream` previously called `API.updateChannel` (which internally triggered `requeryStreams`), then also explicitly called `requeryChannels()` and `requeryStreams()`. Removal now calls `API.reorderChannelStreams` with the remaining stream list (optimistic local `setData` first), matching the one-request pattern used by drag reorder. - `removeStream` in `ChannelTableStreams` was capturing `data` and `channel` in its closure, causing the `columns` `useMemo` to recreate the entire column array (and new TanStack table instance) on every reorder or remove. Both values are now read through refs (`channelRef`, `dataRef`) so `removeStream` has no dependencies and is stable for the lifetime of the component. Removed `removeStream` and `playlists` from the `columns` dep array. - `DraggableRow` (stream rows inside the expanded channel) is now wrapped in `React.memo` with a comparator on `row.original` identity and `index`, so rows whose stream data and position haven't changed are skipped entirely during re-renders caused by a sibling row moving. - Removed dead code in the `name` column cell: `playlists[stream.m3u_account]?.name` was indexing an array by an integer ID, which always returns `undefined`, the value was immediately overridden by `m3uAccountsMap`. The dead access and its fallback variable are gone; `m3uAccountsMap` is now the sole lookup. - Removed spurious `state: { data }` from the `useReactTable` call. `data` is a root-level TanStack Table option, not a controlled-state entry; passing it in `state` was a no-op but misleading. --- CHANGELOG.md | 5 + .../components/tables/ChannelTableStreams.jsx | 137 +++++++++--------- 2 files changed, 73 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9305290b..5248ed3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator. - Stream reorder in `ChannelTableStreams` now completes in a single PATCH request instead of three. Previously dragging a stream to a new position triggered a PATCH, then `requeryStreams()` (re-fetching all streams), then `requeryChannels()` (re-fetching the entire paginated channel list with all embedded stream objects). The reorder now uses a dedicated `API.reorderChannelStreams()` path that issues only the PATCH, then updates the store in-place by reordering the existing stream objects without any network round-trips. On failure, `requeryChannels()` is called to restore correct state. - Fixed N+1 `UPDATE` queries in the stream-order write path. `ChannelSerializer.update()` and the bulk-edit view were calling `ChannelStream.save(update_fields=["order"])` once per stream whose position changed. Both now collect all modified `ChannelStream` objects and issue a single `ChannelStream.objects.bulk_update(…, ["order"])` call. Also removed an accidental `print(normalized_ids)` debug statement left in the serializer. + - Applied the same lightweight store-update approach from stream reorder to stream removal. `removeStream` previously called `API.updateChannel` (which internally triggered `requeryStreams`), then also explicitly called `requeryChannels()` and `requeryStreams()`. Removal now calls `API.reorderChannelStreams` with the remaining stream list (optimistic local `setData` first), matching the one-request pattern used by drag reorder. + - `removeStream` in `ChannelTableStreams` was capturing `data` and `channel` in its closure, causing the `columns` `useMemo` to recreate the entire column array (and new TanStack table instance) on every reorder or remove. Both values are now read through refs (`channelRef`, `dataRef`) so `removeStream` has no dependencies and is stable for the lifetime of the component. Removed `removeStream` and `playlists` from the `columns` dep array. + - `DraggableRow` (stream rows inside the expanded channel) is now wrapped in `React.memo` with a comparator on `row.original` identity and `index`, so rows whose stream data and position haven't changed are skipped entirely during re-renders caused by a sibling row moving. + - Removed dead code in the `name` column cell: `playlists[stream.m3u_account]?.name` was indexing an array by an integer ID, which always returns `undefined`, the value was immediately overridden by `m3uAccountsMap`. The dead access and its fallback variable are gone; `m3uAccountsMap` is now the sole lookup. + - Removed spurious `state: { data }` from the `useReactTable` call. `data` is a root-level TanStack Table option, not a controlled-state entry; passing it in `state` was a no-op but misleading. - Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138) ### Changed diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index b4ef53c3..2e3ddb96 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -191,56 +191,60 @@ const RowDragHandleCell = ({ rowId }) => { }; // Row Component -const DraggableRow = ({ row, index }) => { - const { transform, transition, setNodeRef, isDragging } = useSortable({ - id: row.original.id, - }); +const DraggableRow = React.memo( + ({ row, index }) => { + const { transform, transition, setNodeRef, isDragging } = useSortable({ + id: row.original.id, + }); - const style = { - transform: CSS.Transform.toString(transform), //let dnd-kit do its thing - transition: transition, - opacity: isDragging ? 0.8 : 1, - zIndex: isDragging ? 1 : 0, - position: 'relative', - }; - return ( - - {row.getVisibleCells().map((cell) => { - return ( - - - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - - - ); - })} - - ); -}; + const style = { + transform: CSS.Transform.toString(transform), //let dnd-kit do its thing + transition: transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1 : 0, + position: 'relative', + }; + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + + ); + })} + + ); + }, + (prev, next) => + prev.row.original === next.row.original && prev.index === next.index +); // Stats category display component const StatsCategory = ({ categoryName, stats }) => { @@ -514,20 +518,22 @@ const ChannelStreams = ({ channel }) => { setData(channelStreams); }, [channelStreams]); + // Refs so stable callbacks always see the latest values without being in deps + const channelRef = useRef(channel); + channelRef.current = channel; + const dataRef = useRef(data); + dataRef.current = data; + const dataIds = useMemo(() => data?.map(({ id }) => id), [data]); - const removeStream = useCallback( - async (stream) => { - const newStreamList = data.filter((s) => s.id !== stream.id); - await API.updateChannel({ - ...channel, - streams: newStreamList.map((s) => s.id), - }); - await API.requeryChannels(); - await API.requeryStreams(); - }, - [channel, data] - ); + const removeStream = useCallback(async (stream) => { + const newStreamList = dataRef.current.filter((s) => s.id !== stream.id); + setData(newStreamList); + await API.reorderChannelStreams( + channelRef.current.id, + newStreamList.map((s) => s.id) + ); + }, []); // M3U account map for quick lookup const m3uAccountsMap = useMemo(() => { @@ -571,9 +577,7 @@ const ChannelStreams = ({ channel }) => { accessorKey: 'name', cell: ({ row }) => { const stream = row.original; - const playlistName = playlists[stream.m3u_account]?.name || 'Unknown'; - const accountName = - m3uAccountsMap[stream.m3u_account] || playlistName; + const accountName = m3uAccountsMap[stream.m3u_account] || 'Unknown'; return ( { }, ], [ - playlists, m3uAccountsMap, theme, authUser.user_level, - removeStream, toggleAdvancedStats, handleWatchStream, ] @@ -617,9 +619,6 @@ const ChannelStreams = ({ channel }) => { const table = useReactTable({ columns, data, - state: { - data, - }, defaultColumn: defaultColumnConfig, manualPagination: true, manualSorting: true, From e7cb13e8b0a29652dba0339237e4c94b256b98df Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 19 Apr 2026 10:51:35 -0500 Subject: [PATCH 18/63] Performance: - Adding streams to a channel (per-row "Add to Channel" button and bulk "Add selected to Channel") now completes in a single PATCH request instead of three. Previously each operation called `API.updateChannel` (which internally triggered `requeryStreams`), then `requeryChannels()`. Both paths now use a dedicated `API.addStreamsToChannel()` method that issues only the PATCH, merges the existing channel streams with the newly added stream objects locally, and updates the store in-place, no `requeryStreams` or `requeryChannels` round-trips. - Removed an unnecessary `requeryStreams()` call from `API.createChannelFromStream()`. Stream data does not change when a channel is created from it; the caller already calls `requeryChannels()` to display the new channel. --- CHANGELOG.md | 2 + frontend/src/api.js | 45 ++++++++++++++++++- .../src/components/tables/StreamsTable.jsx | 29 +++++------- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5248ed3a..251ee799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DraggableRow` (stream rows inside the expanded channel) is now wrapped in `React.memo` with a comparator on `row.original` identity and `index`, so rows whose stream data and position haven't changed are skipped entirely during re-renders caused by a sibling row moving. - Removed dead code in the `name` column cell: `playlists[stream.m3u_account]?.name` was indexing an array by an integer ID, which always returns `undefined`, the value was immediately overridden by `m3uAccountsMap`. The dead access and its fallback variable are gone; `m3uAccountsMap` is now the sole lookup. - Removed spurious `state: { data }` from the `useReactTable` call. `data` is a root-level TanStack Table option, not a controlled-state entry; passing it in `state` was a no-op but misleading. + - Adding streams to a channel (per-row "Add to Channel" button and bulk "Add selected to Channel") now completes in a single PATCH request instead of three. Previously each operation called `API.updateChannel` (which internally triggered `requeryStreams`), then `requeryChannels()`. Both paths now use a dedicated `API.addStreamsToChannel()` method that issues only the PATCH, merges the existing channel streams with the newly added stream objects locally, and updates the store in-place, no `requeryStreams` or `requeryChannels` round-trips. + - Removed an unnecessary `requeryStreams()` call from `API.createChannelFromStream()`. Stream data does not change when a channel is created from it; the caller already calls `requeryChannels()` to display the new channel. - Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138) ### Changed diff --git a/frontend/src/api.js b/frontend/src/api.js index aa15864b..761cb259 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -638,7 +638,7 @@ export default class API { } /** - * Lightweight stream reorder: PATCHes only the stream order for a channel + * PATCHes only the stream order for a channel * without triggering requeryStreams or requeryChannels. The caller is * responsible for optimistic UI updates. */ @@ -666,6 +666,48 @@ export default class API { } } + /** + * PATCHes the channel with the + * combined stream list and updates the channelsTable store in-place + * using the stream objects the caller already has. Skips requeryStreams + * (stream data doesn't change) and requeryChannels (we build the + * result locally). + * + * @param {number} channelId + * @param {Array} existingStreams - current channel.streams (full objects) + * @param {Array} newStreams - stream objects to append + */ + static async addStreamsToChannel(channelId, existingStreams, newStreams) { + try { + const existing = existingStreams || []; + // Deduplicate by ID, preserving order (existing first, new appended) + const seen = new Set(existing.map((s) => s.id)); + const merged = [...existing]; + for (const s of newStreams) { + if (!seen.has(s.id)) { + seen.add(s.id); + merged.push(s); + } + } + + await request(`${host}/api/channels/channels/${channelId}/`, { + method: 'PATCH', + body: { id: channelId, streams: merged.map((s) => s.id) }, + }); + + // Update the channelsTable store in-place with the merged streams + const store = useChannelsTableStore.getState(); + const channel = store.channels.find((c) => c.id === channelId); + if (channel) { + store.updateChannel({ ...channel, streams: merged }); + } + } catch (e) { + errorNotification('Failed to add streams to channel', e); + // On failure, requery to restore correct state + await API.requeryChannels(); + } + } + static async updateChannels(ids, values) { const body = []; for (const id of ids) { @@ -898,7 +940,6 @@ export default class API { useChannelsStore.getState().addChannel(response); } - await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to create channel', e); diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index a164b885..0197631b 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -89,15 +89,9 @@ const StreamRowActions = ({ ); const addStreamToChannel = async () => { - await API.updateChannel({ - id: targetChannelId, - streams: [ - ...new Set( - channelSelectionStreams.map((s) => s.id).concat([row.original.id]) - ), - ], - }); - await API.requeryChannels(); + await API.addStreamsToChannel(targetChannelId, channelSelectionStreams, [ + row.original, + ]); }; const onEdit = useCallback(() => { @@ -1040,15 +1034,14 @@ const StreamsTable = ({ onReady }) => { }; const addStreamsToChannel = async () => { - await API.updateChannel({ - id: targetChannelId, - streams: [ - ...new Set( - channelSelectionStreams.map((s) => s.id).concat(selectedStreamIds) - ), - ], - }); - await API.requeryChannels(); + // Look up full stream objects from the current page data + const selectedIdSet = new Set(selectedStreamIds); + const newStreams = data.filter((s) => selectedIdSet.has(s.id)); + await API.addStreamsToChannel( + targetChannelId, + channelSelectionStreams, + newStreams + ); }; const onRowSelectionChange = (updatedIds) => { From 844ef4667a1c092297bebd0e29bfe6f920d8b990 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 19 Apr 2026 11:25:43 -0500 Subject: [PATCH 19/63] changelog: Update changelog for plugin warning PR. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 251ee799..728d9108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) - **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) ### Performance From 69e0cba86ef21d866bf255b046fc4c8756f79f36 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Apr 2026 09:23:35 -0500 Subject: [PATCH 20/63] Bug Fix: EPG channel name vlaue too long for type character varying(255) (Fixes #1134) --- CHANGELOG.md | 4 ++++ ...35_alter_channel_name_alter_stream_name.py | 23 +++++++++++++++++++ apps/channels/models.py | 4 ++-- .../epg/migrations/0022_alter_epgdata_name.py | 18 +++++++++++++++ apps/epg/models.py | 2 +- apps/epg/tasks.py | 5 ++++ apps/m3u/tasks.py | 7 ++++++ 7 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 apps/channels/migrations/0035_alter_channel_name_alter_stream_name.py create mode 100644 apps/epg/migrations/0022_alter_epgdata_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 728d9108..6bfa2934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **EPG channel name truncation**: EPG sources that include long `` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134) + ### Added - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) diff --git a/apps/channels/migrations/0035_alter_channel_name_alter_stream_name.py b/apps/channels/migrations/0035_alter_channel_name_alter_stream_name.py new file mode 100644 index 00000000..21906b87 --- /dev/null +++ b/apps/channels/migrations/0035_alter_channel_name_alter_stream_name.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.4 on 2026-04-21 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0034_remove_stream_dispatcharr_stream_id_idx_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='name', + field=models.CharField(max_length=512), + ), + migrations.AlterField( + model_name='stream', + name='name', + field=models.CharField(default='Default Stream', max_length=512), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 51629db3..1c989855 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -56,7 +56,7 @@ class Stream(models.Model): Represents a single stream (e.g. from an M3U source or custom URL). """ - name = models.CharField(max_length=255, default="Default Stream") + name = models.CharField(max_length=512, default="Default Stream") url = models.URLField(max_length=4096, blank=True, null=True) m3u_account = models.ForeignKey( M3UAccount, @@ -294,7 +294,7 @@ class ChannelManager(models.Manager): class Channel(models.Model): channel_number = models.FloatField(db_index=True) - name = models.CharField(max_length=255) + name = models.CharField(max_length=512) logo = models.ForeignKey( "Logo", on_delete=models.SET_NULL, diff --git a/apps/epg/migrations/0022_alter_epgdata_name.py b/apps/epg/migrations/0022_alter_epgdata_name.py new file mode 100644 index 00000000..3da56637 --- /dev/null +++ b/apps/epg/migrations/0022_alter_epgdata_name.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-21 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0021_epgsource_priority'), + ] + + operations = [ + migrations.AlterField( + model_name='epgdata', + name='name', + field=models.CharField(max_length=512), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index b3696edc..d5758ce2 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -137,7 +137,7 @@ 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). tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) - name = models.CharField(max_length=255) + name = models.CharField(max_length=512) icon_url = models.URLField(max_length=500, null=True, blank=True) epg_source = models.ForeignKey( EPGSource, diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 7700ad1b..8efe3618 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -966,6 +966,7 @@ def parse_channels_only(source): batch_size = 500 # Process in batches to limit memory usage progress = 0 # Initialize progress variable here icon_url_max_length = EPGData._meta.get_field('icon_url').max_length # Get max length for icon_url field + name_max_length = EPGData._meta.get_field('name').max_length # Get max length for name field # Track memory at key points if process: @@ -1022,6 +1023,10 @@ def parse_channels_only(source): if not display_name: display_name = tvg_id + if display_name and len(display_name) > name_max_length: + logger.warning(f"EPG display name too long ({len(display_name)} > {name_max_length}), truncating: {display_name[:80]}...") + display_name = display_name[:name_max_length] + # Use lazy loading approach to reduce memory usage if tvg_id in existing_tvg_ids: # Only fetch the object if we need to update it and it hasn't been loaded yet diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 8f446b6e..e724e655 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1087,6 +1087,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): streams_to_update = [] stream_hashes = {} + name_max_length = Stream._meta.get_field('name').max_length + logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}") if compiled_filters: logger.debug(f"Using compiled filters: {[f[1].regex_pattern for f in compiled_filters]}") @@ -1099,6 +1101,11 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): logger.warning(f"Skipping stream '{name}': URL too long ({len(url)} characters, max 4096)") continue + # Truncate name if it exceeds the model field limit + if name and len(name) > name_max_length: + logger.warning(f"Stream name too long ({len(name)} > {name_max_length}), truncating: {name[:80]}...") + name = name[:name_max_length] + tvg_id, tvg_logo = get_case_insensitive_attr( stream_info["attributes"], "tvg-id", "" ), get_case_insensitive_attr(stream_info["attributes"], "tvg-logo", "") From 9564e864875d70acb808eb8253c32779aa6e2128 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Apr 2026 10:20:00 -0500 Subject: [PATCH 21/63] fix: Don't wrap actions to multiple rows. --- frontend/src/components/tables/UsersTable.jsx | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index a0fc714b..8635257c 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -42,32 +42,30 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => { tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; return ( - - - - - + + + + - - - - - + + + +
); }; @@ -156,7 +154,7 @@ const UsersTable = () => { header: 'User Level', accessorKey: 'user_level', size: 120, - minSize: 80, + minSize: 80, cell: ({ getValue }) => ( {USER_LEVEL_LABELS[getValue()]} ), @@ -201,7 +199,7 @@ const UsersTable = () => { header: 'Email', accessorKey: 'email', size: 200, - minSize: 50, + minSize: 50, cell: ({ getValue }) => ( { header: 'Date Joined', accessorKey: 'date_joined', size: 90, - minSize: 90, + minSize: 90, cell: ({ getValue }) => { const date = getValue(); return ( @@ -256,7 +254,14 @@ const UsersTable = () => { password = customProps.xc_password || 'N/A'; return ( - + { header: 'Channel Profiles', accessorKey: 'channel_profiles', size: 120, - minSize: 116, + minSize: 116, grow: true, cell: ({ getValue }) => { const userProfiles = getValue() || []; @@ -300,11 +305,7 @@ const UsersTable = () => { {profileNames.length > 0 ? ( profileNames.map((name, index) => ( - + {name} @@ -317,7 +318,7 @@ const UsersTable = () => { ); }, - }, + }, { id: 'actions', size: 65, From 36c2bd3eeee9098d9d5f543e99e451611721771f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Apr 2026 10:22:57 -0500 Subject: [PATCH 22/63] changelog: Update changelog for PR. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfa2934..7dd4980c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) - **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) From 7e21e3bb1a16d58e838c55d3d271fc2fd3a13383 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Apr 2026 11:00:18 -0500 Subject: [PATCH 23/63] Bug Fix: column widths are now propagated to body cells via CSS custom properties --- CHANGELOG.md | 1 + .../components/tables/CustomTable/CustomTable.jsx | 15 +++++++++++++++ .../tables/CustomTable/CustomTableBody.jsx | 6 +++--- .../tables/CustomTable/CustomTableHeader.jsx | 6 +++--- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd4980c..483bd0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Column resizing in `CustomTable`**: column widths are now propagated to body cells via CSS custom properties (`--header-{id}-size`) injected on the table wrapper, rather than reading `column.getSize()` directly in each cell's React style. This decouples body-cell widths from React renders so that memoized rows (which skip re-renders for performance) still reflect resize changes instantly via CSS cascade. - Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state. - Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows. - Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths. diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index 55af4724..8b0a9f0c 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -27,6 +27,20 @@ const CustomTable = ({ table }) => { return width; }, [table, columnSizing]); + // CSS custom properties for each fixed-width column's current size. + // These are injected on the table wrapper and cascade to all descendant cells, + // so body cells (which are memoized and don't re-render on resize) still pick + // up the new width via CSS cascade without needing a React re-render. + const columnSizeVars = useMemo(() => { + void columnSizing; + return table.getFlatHeaders().reduce((vars, header) => { + if (!header.column.columnDef.grow) { + vars[`--header-${header.id}-size`] = `${header.getSize()}px`; + } + return vars; + }, {}); + }, [table, columnSizing]); + return ( { minWidth: `${minTableWidth}px`, display: 'flex', flexDirection: 'column', + ...columnSizeVars, }} > Date: Tue, 21 Apr 2026 11:23:17 -0500 Subject: [PATCH 24/63] Bug fix: Move the XCPasswordCell to its own component. This is required after our table optimizations. --- frontend/src/components/tables/UsersTable.jsx | 107 +++++++----------- 1 file changed, 43 insertions(+), 64 deletions(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 8635257c..adba3b38 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -26,6 +26,47 @@ import ConfirmationDialog from '../ConfirmationDialog'; import useLocalStorage from '../../hooks/useLocalStorage'; import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js'; +const XCPasswordCell = ({ getValue }) => { + const [isVisible, setIsVisible] = useState(false); + const customProps = getValue() || {}; + const password = customProps.xc_password || 'N/A'; + + return ( + + + {password === 'N/A' ? 'N/A' : isVisible ? password : '••••••••'} + + {password !== 'N/A' && ( + setIsVisible((v) => !v)} + > + {isVisible ? : } + + )} + + ); +}; + const UserRowActions = ({ theme, row, editUser, deleteUser }) => { const [tableSize, _] = useLocalStorage('table-size', 'default'); const authUser = useAuthStore((s) => s.user); @@ -92,17 +133,6 @@ const UsersTable = () => { const [userToDelete, setUserToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); const [deleting, setDeleting] = useState(false); - const [visiblePasswords, setVisiblePasswords] = useState({}); - - /** - * Functions - */ - const togglePasswordVisibility = useCallback((userId) => { - setVisiblePasswords((prev) => ({ - ...prev, - [userId]: !prev[userId], - })); - }, []); const executeDeleteUser = useCallback(async (id) => { setIsLoading(true); @@ -244,50 +274,7 @@ const UsersTable = () => { size: 100, minSize: 95, enableSorting: false, - cell: ({ getValue, row }) => { - const userId = row.original.id; - const isVisible = visiblePasswords[userId]; - - // Extract xc_password from custom_properties - let password = 'N/A'; - const customProps = getValue() || {}; - password = customProps.xc_password || 'N/A'; - - return ( - - - {password === 'N/A' ? 'N/A' : isVisible ? password : '••••••••'} - - {password !== 'N/A' && ( - togglePasswordVisibility(userId)} - > - {isVisible ? : } - - )} - - ); - }, + cell: XCPasswordCell, }, { header: 'Channel Profiles', @@ -335,15 +322,7 @@ const UsersTable = () => { ), }, ], - [ - theme, - editUser, - deleteUser, - visiblePasswords, - togglePasswordVisibility, - fullDateFormat, - fullDateTimeFormat, - ] + [theme, editUser, deleteUser, fullDateFormat, fullDateTimeFormat] ); const closeUserForm = () => { From 6d3616d5249bb25935f472dcc4dfe12f055d2d9d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Apr 2026 11:27:50 -0500 Subject: [PATCH 25/63] Enhancement: Adjust column sizes in user table. --- frontend/src/components/tables/UsersTable.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index adba3b38..7ec9504c 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -209,7 +209,7 @@ const UsersTable = () => { { id: 'name', header: 'Name', - size: 120, + size: 125, minSize: 50, accessorFn: (row) => `${row.first_name || ''} ${row.last_name || ''}`.trim(), @@ -228,7 +228,7 @@ const UsersTable = () => { { header: 'Email', accessorKey: 'email', - size: 200, + size: 125, minSize: 50, cell: ({ getValue }) => ( { { header: 'XC Password', accessorKey: 'custom_properties', - size: 100, + size: 125, minSize: 95, enableSorting: false, cell: XCPasswordCell, From 8a15f3477f7b75d3065bb483a20e978d97a742e0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Apr 2026 12:31:30 -0500 Subject: [PATCH 26/63] Bug Fix: Creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. (Fixes #545) --- CHANGELOG.md | 1 + frontend/src/components/forms/Channel.jsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 483bd0b8..59085b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545) - **EPG channel name truncation**: EPG sources that include long `` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134) ### Added diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 8ac62555..cc503209 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -293,7 +293,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const defaultValues = useMemo( () => getChannelFormDefaultValues(channel, channelGroups), - [channel, channelGroups] + // eslint-disable-next-line react-hooks/exhaustive-deps + [channel] ); const { From 625dab03574f5e549e61729736b41ac3b5aca520 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 21 Apr 2026 17:20:45 -0500 Subject: [PATCH 27/63] Enhancement: The default profile in the M3U profiles editor now exposes Search Pattern and Replace Pattern fields --- CHANGELOG.md | 1 + apps/m3u/serializers.py | 6 +- apps/proxy/ts_proxy/url_utils.py | 7 +- frontend/src/components/forms/M3UProfile.jsx | 70 ++++++++++++++++---- 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59085b33..9dc1260f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) - **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) +- **Editable default M3U profile patterns**: the default profile in the M3U profiles editor now exposes Search Pattern and Replace Pattern fields, allowing users to apply a URL transformation to every stream in the playlist (useful for replacing a local IP address, for example). A warning alert explains the fallback behaviour. A "Reset to Defaults" button restores the pass-through patterns (`^(.*)$` / `$1`). The live regex demonstration panel is also shown for the default profile. The backend serializer and `transform_url` were updated accordingly: `search_pattern` and `replace_pattern` are now permitted fields when updating a default profile, and `transform_url` uses `regex.subn()` to detect a genuine non-matches, logging a `WARNING` when the pattern does not match any part of the URL. ### Performance diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 587d98cb..f72566cf 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -93,14 +93,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): if instance.is_default: - # For default profiles, only allow updating name, custom_properties, and exp_date - allowed_fields = {'name', 'custom_properties', 'exp_date'} + # For default profiles, only allow updating name, custom_properties, exp_date, and patterns + allowed_fields = {'name', 'custom_properties', 'exp_date', 'search_pattern', 'replace_pattern'} # Remove any fields that aren't allowed for default profiles disallowed_fields = set(validated_data.keys()) - allowed_fields if disallowed_fields: raise serializers.ValidationError( - f"Default profiles can only modify name, notes, and expiration. " + f"Default profiles can only modify name, notes, expiration, and URL patterns. " f"Cannot modify: {', '.join(disallowed_fields)}" ) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 5f4615a4..d397244d 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -153,8 +153,11 @@ def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> logger.debug(f" safe replace: {safe_replace_pattern}") # Apply the transformation (regex module accepts JS-style (?...) natively) - stream_url = regex.sub(search_pattern, safe_replace_pattern, input_url) - logger.info(f"Generated stream url: {stream_url}") + stream_url, match_count = regex.subn(search_pattern, safe_replace_pattern, input_url) + if match_count == 0: + logger.warning(f"URL pattern '{search_pattern}' did not match, falling back to original URL: {input_url}") + else: + logger.info(f"Generated stream url: {stream_url}") return stream_url except Exception as e: diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index 65da3e17..f92039d0 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -4,6 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import * as Yup from 'yup'; import API from '../../api'; import { + Alert, Flex, Modal, TextInput, @@ -17,6 +18,7 @@ import { NumberInput, SegmentedControl, } from '@mantine/core'; +import { TriangleAlert } from 'lucide-react'; import { DateTimePicker } from '@mantine/dates'; import { useWebSocket } from '../../WebSocket'; @@ -115,11 +117,13 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { } } - // For default profiles, only send name and custom_properties (notes) + // Build submit values let submitValues; if (isDefaultProfile) { submitValues = { name: values.name, + search_pattern: searchPattern || '', + replace_pattern: replacePattern || '', custom_properties: { // Preserve existing custom_properties and add/update notes ...(profile?.custom_properties || {}), @@ -318,11 +322,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
@@ -348,8 +348,39 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { /> )} - {/* Only show search/replace fields for non-default profiles */} - {!isDefaultProfile && ( + {/* Search/replace fields */} + {isDefaultProfile ? ( + <> + } + color="yellow" + title="Default Profile" + mt="xs" + > + + These patterns are applied to every stream in this playlist. If + the search pattern doesn't match a stream URL, the original + URL is used as-is. + + + + + + ) : ( <> {isXC && ( { + {isDefaultProfile && ( + + )} - {/* Only show regex demonstration for non-default profiles in advanced mode */} - {!isDefaultProfile && (!isXC || xcMode === 'advanced') && ( + {/* Show regex demonstration for default profiles and non-default profiles in advanced mode */} + {(isDefaultProfile || !isXC || xcMode === 'advanced') && ( <> Live Regex Demonstration From 29228612aae4ab90cf7821788c85c19575b31869 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Tue, 21 Apr 2026 18:55:38 -0500 Subject: [PATCH 28/63] perf/fix: eliminate per-tick DB queries in stats; fix channel notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Write channel_name (and stream_name fallback) into Redis metadata at channel init time; read directly from Redis in get_basic_channel_info, removing Stream and M3UAccountProfile DB queries on every stats tick - Pass channel.name from views.py into ChannelService.initialize_channel via new optional channel_name param, skipping the redundant Channel DB lookup on the normal streaming path - Pass stream_name from get_stream_info_for_switch through change_stream_url into _update_channel_metadata, skipping the Stream DB lookup on switches - Resolve m3u_profile_name on the frontend from the playlists store instead of querying the DB per tick; StreamConnectionCard builds an id→profile map - Fix channel start notification showing no name and stop notification showing UUID: both now use channel_name from the stats WebSocket payload --- CHANGELOG.md | 4 ++ apps/proxy/ts_proxy/channel_status.py | 35 +++++----------- apps/proxy/ts_proxy/constants.py | 2 + .../ts_proxy/services/channel_service.py | 35 ++++++++++++++-- apps/proxy/ts_proxy/url_utils.py | 3 +- apps/proxy/ts_proxy/views.py | 1 + .../components/cards/StreamConnectionCard.jsx | 33 +++++++++++++-- frontend/src/store/channels.jsx | 40 +++++++++---------- 8 files changed, 100 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc1260f..b9483725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name. - **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545) - **EPG channel name truncation**: EPG sources that include long `<display-name>` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134) @@ -36,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Adding streams to a channel (per-row "Add to Channel" button and bulk "Add selected to Channel") now completes in a single PATCH request instead of three. Previously each operation called `API.updateChannel` (which internally triggered `requeryStreams`), then `requeryChannels()`. Both paths now use a dedicated `API.addStreamsToChannel()` method that issues only the PATCH, merges the existing channel streams with the newly added stream objects locally, and updates the store in-place, no `requeryStreams` or `requeryChannels` round-trips. - Removed an unnecessary `requeryStreams()` call from `API.createChannelFromStream()`. Stream data does not change when a channel is created from it; the caller already calls `requeryChannels()` to display the new channel. - Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138) +- Eliminated per-tick DB queries from the channel stats system. Previously `get_basic_channel_info()` in `channel_status.py` issued a `Stream.objects.filter()` and an `M3UAccountProfile.objects.filter()` on every stats tick for each active channel to resolve display names. Channel name and stream name are now written into the Redis metadata hash at channel-init time (via `initialize_channel`) and read back directly during stats collection, zero DB queries per tick. `m3u_profile_name` is now resolved on the frontend from the already-loaded playlists store rather than being pushed from the backend. +- Eliminated a redundant `Channel` DB lookup inside `ChannelService.initialize_channel()`. `views.py` already fetches the `Channel` object via `get_stream_object()` before calling `initialize_channel()`; `channel.name` is now passed as an optional `channel_name` parameter, so the service uses the caller-supplied value and only falls back to a DB query when the name is not provided (e.g. stream-preview paths). A matching `stream_name` parameter was added for the same reason; the `stream_name` DB query is skipped entirely on normal channel start since a channel name is always available. +- Eliminated a redundant `Stream` DB lookup on stream switches. `get_stream_info_for_switch()` already fetches the `Stream` object to build the URL; it now includes `stream_name` in its return dict. `change_stream_url()` captures that value and passes it through to `_update_channel_metadata()`, which skips its own `Stream.objects.filter()` when the name is already known. ### Changed diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py index c4a54cdc..2f6d58af 100644 --- a/apps/proxy/ts_proxy/channel_status.py +++ b/apps/proxy/ts_proxy/channel_status.py @@ -399,24 +399,21 @@ class ChannelStatus: 'uptime': uptime } - # Add stream ID and name information + channel_name = metadata.get(ChannelMetadataField.CHANNEL_NAME) + if channel_name: + info['channel_name'] = channel_name + stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID) if stream_id_bytes: try: - stream_id = int(stream_id_bytes) - info['stream_id'] = stream_id - - # Look up stream name from database - try: - from apps.channels.models import Stream - stream = Stream.objects.filter(id=stream_id).first() - if stream: - info['stream_name'] = stream.name - except (ImportError, DatabaseError) as e: - logger.warning(f"Failed to get stream name for ID {stream_id}: {e}") + info['stream_id'] = int(stream_id_bytes) except ValueError: logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}") + stream_name = metadata.get(ChannelMetadataField.STREAM_NAME) + if stream_name: + info['stream_name'] = stream_name + # Add data throughput information to basic info total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES) if total_bytes_bytes: @@ -485,21 +482,11 @@ class ChannelStatus: info['clients'] = clients info['client_count'] = client_count - # Add M3U profile information + # Add M3U profile ID from Redis metadata (name resolved on frontend from playlists store) m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE) if m3u_profile_id: try: - m3u_profile_id = int(m3u_profile_id) - info['m3u_profile_id'] = m3u_profile_id - - # Look up M3U profile name from database - try: - from apps.m3u.models import M3UAccountProfile - m3u_profile = M3UAccountProfile.objects.filter(id=m3u_profile_id).first() - if m3u_profile: - info['m3u_profile_name'] = m3u_profile.name - except (ImportError, DatabaseError) as e: - logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}") + info['m3u_profile_id'] = int(m3u_profile_id) except ValueError: logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}") diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py index d134c8e8..8a83849d 100644 --- a/apps/proxy/ts_proxy/constants.py +++ b/apps/proxy/ts_proxy/constants.py @@ -50,6 +50,8 @@ class ChannelMetadataField: STATE = "state" OWNER = "owner" STREAM_ID = "stream_id" + CHANNEL_NAME = "channel_name" + STREAM_NAME = "stream_name" # Profile fields STREAM_PROFILE = "stream_profile" diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index ff6eb416..92f94f3d 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -23,7 +23,7 @@ class ChannelService: """Service class for channel operations""" @staticmethod - def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None): + def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None, channel_name=None, stream_name=None): """ Initialize a channel with the given parameters. @@ -35,6 +35,8 @@ class ChannelService: stream_profile_value: Stream profile value to store in metadata stream_id: ID of the stream being used m3u_profile_id: ID of the M3U profile being used + channel_name: Channel name (avoids DB lookup if already known) + stream_name: Stream name (avoids DB lookup if already known) Returns: bool: Success status @@ -79,6 +81,23 @@ class ChannelService: if m3u_profile_id: update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id) + # Store channel name and stream name so stats workers don't need DB calls + try: + if not channel_name: + from apps.channels.models import Channel + channel_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first() + if channel_name: + update_data[ChannelMetadataField.CHANNEL_NAME] = channel_name + else: + # No channel name means stream preview mode, use stream name as display fallback + if stream_id and not stream_name: + from apps.channels.models import Stream + stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first() + if stream_name: + update_data[ChannelMetadataField.STREAM_NAME] = stream_name + except Exception as e: + logger.warning(f"Failed to store channel/stream names in Redis for {channel_id}: {e}") + if update_data: proxy_server.redis_client.hset(metadata_key, mapping=update_data) @@ -103,6 +122,7 @@ class ChannelService: # If no direct URL is provided but a target stream is, get URL from target stream stream_id = None + stream_name = None if not new_url and target_stream_id: stream_info = get_stream_info_for_switch(channel_id, target_stream_id) if 'error' in stream_info: @@ -113,6 +133,7 @@ class ChannelService: new_url = stream_info['url'] user_agent = stream_info['user_agent'] stream_id = target_stream_id + stream_name = stream_info.get('stream_name') # Extract M3U profile ID from stream info if available if 'm3u_profile_id' in stream_info: m3u_profile_id = stream_info['m3u_profile_id'] @@ -186,7 +207,7 @@ class ChannelService: if proxy_server.redis_client: try: if success: - ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id) + ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id, stream_name) else: ChannelService._update_channel_metadata(channel_id, manager.url, user_agent) result['metadata_updated'] = True @@ -579,7 +600,7 @@ class ChannelService: # Helper methods for Redis operations @staticmethod - def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None): + def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None, stream_name=None): """Update channel metadata in Redis""" proxy_server = ProxyServer.get_instance() @@ -598,6 +619,14 @@ class ChannelService: metadata[ChannelMetadataField.USER_AGENT] = user_agent if stream_id: metadata[ChannelMetadataField.STREAM_ID] = str(stream_id) + if not stream_name: + try: + from apps.channels.models import Stream + stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first() + except Exception as e: + logger.warning(f"Failed to update stream name in Redis for stream {stream_id}: {e}") + if stream_name: + metadata[ChannelMetadataField.STREAM_NAME] = stream_name if m3u_profile_id: metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index d397244d..5227a3fa 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -274,7 +274,8 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] 'transcode': transcode, 'stream_profile': profile_value, 'stream_id': stream_id, - 'm3u_profile_id': m3u_profile_id + 'm3u_profile_id': m3u_profile_id, + 'stream_name': stream.name, } except Exception as e: logger.error(f"Error getting stream info for switch: {e}", exc_info=True) diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index da1c29f1..7170c5dc 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -373,6 +373,7 @@ def stream_ts(request, channel_id, user=None): profile_value, stream_id, m3u_profile_id, + channel_name=channel.name, ) if not success: diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index eb655702..ee90a650 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -123,6 +123,7 @@ const StreamConnectionCard = ({ // Get M3U account data from the playlists store const m3uAccounts = usePlaylistsStore((s) => s.playlists); + const m3uProfiles = usePlaylistsStore((s) => s.profiles); // Get users for resolving user_id → username on client rows const users = useUsersStore((s) => s.users); // Get settings for speed threshold and environment mode @@ -135,6 +136,19 @@ const StreamConnectionCard = ({ // Get user's date/time format preferences const { fullDateTimeFormat } = useDateTimeFormat(); + // Flat map of profile id -> profile for quick lookup + const m3uProfilesById = useMemo(() => { + const map = {}; + Object.values(m3uProfiles).forEach((profileList) => { + if (Array.isArray(profileList)) { + profileList.forEach((p) => { + map[p.id] = p; + }); + } + }); + return map; + }, [m3uProfiles]); + // Create a map of M3U account IDs to names for quick lookup const m3uAccountsMap = useMemo(() => { return getM3uAccountsMap(m3uAccounts); @@ -151,16 +165,29 @@ const StreamConnectionCard = ({ // Update M3U profile information when channel data changes useEffect(() => { - // If the channel data includes M3U profile information, update our state - if (channel.m3u_profile || channel.m3u_profile_name) { + if ( + channel.m3u_profile || + channel.m3u_profile_name || + channel.m3u_profile_id + ) { + const profileFromStore = channel.m3u_profile_id + ? m3uProfilesById[channel.m3u_profile_id] + : null; setCurrentM3UProfile({ name: channel.m3u_profile?.name || + profileFromStore?.name || channel.m3u_profile_name || 'Default M3U', }); } - }, [channel.m3u_profile, channel.m3u_profile_name, channel.stream_id]); + }, [ + channel.m3u_profile, + channel.m3u_profile_name, + channel.m3u_profile_id, + channel.stream_id, + m3uProfilesById, + ]); // Fetch available streams for this channel useEffect(() => { diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 24ad2d9d..4cfee577 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -23,17 +23,16 @@ const showNotificationIfNewChannel = ( ) => { if (currentStats.channels) { if (oldChannels[ch.channel_id] === undefined) { - // Add null checks to prevent accessing properties on undefined const channelId = channelsByUUID[ch.channel_id]; const channel = channelId ? channels[channelId] : null; + const channelName = + channel?.name || ch.channel_name || `Channel (${ch.channel_id})`; - if (channel) { - showNotification({ - title: 'New channel streaming', - message: channel.name, - color: 'blue.5', - }); - } + showNotification({ + title: 'New channel streaming', + message: channelName, + color: 'blue.5', + }); } } }; @@ -66,19 +65,15 @@ const showNotificationIfChannelStopped = ( const channelId = channelsByUUID[uuid]; const channel = channelId && channels[channelId]; - if (channel) { - showNotification({ - title: 'Channel streaming stopped', - message: channel.name, - color: 'blue.5', - }); - } else { - showNotification({ - title: 'Channel streaming stopped', - message: `Channel (${uuid})`, - color: 'blue.5', - }); - } + const channelName = + channel?.name || + oldChannels[uuid]?.channel_name || + `Channel (${uuid})`; + showNotification({ + title: 'Channel streaming stopped', + message: channelName, + color: 'blue.5', + }); } } } @@ -498,7 +493,8 @@ const useChannelsStore = create((set, get) => ({ }; } if (current && typeof current === 'object') { - if (!Object.values(current).some((r) => String(r?.id) === target)) return {}; + if (!Object.values(current).some((r) => String(r?.id) === target)) + return {}; const next = { ...current }; for (const k of Object.keys(next)) { try { From 09a43e4d810a000e7b52989946a3d8b2e8342b2e Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Wed, 22 Apr 2026 16:18:01 -0500 Subject: [PATCH 29/63] tests: Update mock implementation in StreamConnectionCard tests to include profiles in playlists store --- .../components/cards/__tests__/StreamConnectionCard.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx index 1a62cc2c..3b3eb1dc 100644 --- a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx +++ b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx @@ -256,7 +256,7 @@ const setupLocation = (pathname = '/stats') => { const setupStores = () => { vi.mocked(usePlaylistsStore).mockImplementation((selector) => - selector({ playlists: [] }) + selector({ playlists: [], profiles: {} }) ); vi.mocked(useSettingsStore).mockImplementation((selector) => selector({ From efefd9038f21109bf42d537bdf26a3263954aeae Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Wed, 22 Apr 2026 17:23:02 -0500 Subject: [PATCH 30/63] tests: Update StreamConnectionCard tests to mock users store and adjust state implementations --- .../__tests__/StreamConnectionCard.test.jsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx index 3b3eb1dc..25f6b239 100644 --- a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx +++ b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx @@ -16,6 +16,9 @@ vi.mock('../../../store/settings.jsx', () => ({ vi.mock('../../../store/useVideoStore', () => ({ default: vi.fn(), })); +vi.mock('../../../store/users.jsx', () => ({ + default: vi.fn(), +})); // ── dateTimeUtils ───────────────────────────────────────────────────────────── vi.mock('../../../utils/dateTimeUtils.js', () => ({ @@ -167,7 +170,6 @@ vi.mock('lucide-react', () => ({ SquareX: () => <svg data-testid="icon-square-x" />, Timer: () => <svg data-testid="icon-timer" />, Users: () => <svg data-testid="icon-users" />, - Video: () => <svg data-testid="icon-video" />, Package: () => <svg data-testid="icon-package" />, Download: () => <svg data-testid="icon-download" />, })); @@ -177,6 +179,7 @@ import { useLocation } from 'react-router-dom'; import usePlaylistsStore from '../../../store/playlists.jsx'; import useSettingsStore from '../../../store/settings.jsx'; import useVideoStore from '../../../store/useVideoStore'; +import useUsersStore from '../../../store/users.jsx'; import { showNotification } from '../../../utils/notificationUtils.js'; import { getChannelStreams, @@ -254,18 +257,27 @@ const setupLocation = (pathname = '/stats') => { }); }; +const mockShowVideo = vi.fn(); +const mockPlaylistsState = { playlists: [], profiles: {} }; +const mockSettingsState = { + settings: { proxy_settings: {} }, + environment: { env_mode: 'production' }, +}; +const mockVideoState = { showVideo: mockShowVideo }; +const mockUsersState = { users: [] }; + const setupStores = () => { vi.mocked(usePlaylistsStore).mockImplementation((selector) => - selector({ playlists: [], profiles: {} }) + selector(mockPlaylistsState) ); vi.mocked(useSettingsStore).mockImplementation((selector) => - selector({ - settings: { proxy_settings: {} }, - environment: { env_mode: 'production' }, - }) + selector(mockSettingsState) ); vi.mocked(useVideoStore).mockImplementation((selector) => - selector({ showVideo: vi.fn() }) + selector(mockVideoState) + ); + vi.mocked(useUsersStore).mockImplementation((selector) => + selector(mockUsersState) ); }; From eb9dd391d5429b80745154495bd39a16c1c65bb7 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 23 Apr 2026 11:03:02 -0500 Subject: [PATCH 31/63] Enhancement: Consolidate client connection notifications and update timestamp handling; replace 'connected_since' with 'connected_at' for improved accuracy in duration calculations. --- CHANGELOG.md | 3 + apps/proxy/ts_proxy/channel_status.py | 7 +- .../components/cards/StreamConnectionCard.jsx | 5 +- .../__tests__/StreamConnectionCard.test.jsx | 3 +- frontend/src/store/channels.jsx | 197 +++++++++++------- .../utils/cards/StreamConnectionCardUtils.js | 29 +-- .../StreamConnectionCardUtils.test.js | 59 ++---- 7 files changed, 155 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9483725..78bf29f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name. - **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545) - **EPG channel name truncation**: EPG sources that include long `<display-name>` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134) +- **Channel start/client connect notifications suppressed on first stats poll after page load**: after logging in or navigating to the Stats page, the notification logic was not firing for any connections present in the first stats response, even for streams that had started after the page loaded. Replaced the flag-based approach with a `pageLoadTime` module-level constant compared against each client's `connected_at` Unix timestamp from Redis; connections that pre-date the page load are filtered out, while genuinely new ones fire immediately. `get_basic_channel_info` now also includes `connected_at` in each client entry so this check works for the Stats page's API poll path as well as the WebSocket path. ### Added @@ -48,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows. - Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths. - Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed. +- **Improved channel start/client connect notifications**: when a channel starts streaming, the redundant separate "new channel" and "new client" toasts are now combined into a single **"Channel started streaming"** notification. All connect/disconnect notifications display both the channel name and the user identity (username + IP, or IP alone) on separate lines. A module-level `pageLoadTime` timestamp compared against each client's `connected_at` field from Redis filters out pre-existing connections on page load, while connections that genuinely start after the page loads (even if they arrive in the very first stats poll) correctly fire notifications. +- **Client timing fields consolidated to `connected_at`**: `get_basic_channel_info` was sending only a pre-computed `connected_since` elapsed-seconds value (no raw timestamp), while `get_detailed_channel_info` was sending `connected_at` alongside a redundant `connection_duration`. Both paths now emit only `connected_at` (Unix timestamp). The frontend derives connection display time and duration from that single value at render time, which also fixes the "timestamp jumping" seen on the Stats page, the connected-at wall-clock time is now stable across polls instead of being recomputed each response. ## [0.23.0] - 2026-04-17 diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py index 2f6d58af..5149348e 100644 --- a/apps/proxy/ts_proxy/channel_status.py +++ b/apps/proxy/ts_proxy/channel_status.py @@ -148,9 +148,7 @@ class ChannelStatus: } if 'connected_at' in client_data: - connected_at = float(client_data['connected_at']) - client_info['connected_at'] = connected_at - client_info['connection_duration'] = time.time() - connected_at + client_info['connected_at'] = float(client_data['connected_at']) if 'last_active' in client_data: last_active = float(client_data['last_active']) @@ -469,8 +467,7 @@ class ChannelStatus: connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') if connected_at_bytes: - connected_at = float(connected_at_bytes) - client_info['connected_since'] = time.time() - connected_at + client_info['connected_at'] = float(connected_at_bytes) user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id') if user_id_bytes: diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index ee90a650..1673c901 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -403,8 +403,9 @@ const StreamConnectionCard = ({ minSize: 60, accessorFn: durationAccessor(), cell: ({ cell, row }) => { - const exactDuration = - row.original.connected_since || row.original.connection_duration; + const exactDuration = row.original.connected_at + ? Date.now() / 1000 - row.original.connected_at + : null; return ( <Tooltip label={ diff --git a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx index 25f6b239..1010747c 100644 --- a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx +++ b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx @@ -219,8 +219,7 @@ const makeClients = (channelId = 'ch-uuid-1') => [ client_id: 'client-1', channel: { channel_id: channelId, uuid: 'ch-uuid-1' }, ip_address: '192.168.1.10', - connected_since: 330, - connection_duration: 330, + connected_at: Date.now() / 1000 - 330, user_agent: 'VLC/3.0', streams: [{ id: 1 }], }, diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 4cfee577..6c0b946a 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -1,9 +1,44 @@ import { create } from 'zustand'; import api from '../api'; import { showNotification } from '../utils/notificationUtils.js'; +import useUsersStore from './users'; const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } }; +// Seconds-precision timestamp recorded when this module is first loaded. +// Compared against client.connected_at (also in seconds) to distinguish connections +// that were already active before the page loaded from ones that started after. +const pageLoadTime = Date.now() / 1000; + +// Returns true when a client connected after the page was loaded (genuinely new). +// Falls back to true when connected_at is absent so we don't silently drop notifications. +const isClientNewSincePageLoad = (client) => + !client.connected_at || client.connected_at >= pageLoadTime; + +// Resolve identity info for a client: { username, ip }. +// username is null when no user account is linked. +const getClientIdentity = (client) => { + let username = null; + if (client?.user_id && client.user_id !== '0') { + const users = useUsersStore.getState().users; + const user = users.find((u) => String(u.id) === String(client.user_id)); + if (user?.username) username = user.username; + } + return { username, ip: client?.ip_address || 'unknown' }; +}; + +// Build a two-line notification message: channel name on top, identity below. +const clientMessage = (channelName, client) => { + const { username, ip } = getClientIdentity(client); + const identity = username ? `${username} (${ip})` : ip; + return ( + <> + <div>{channelName}</div> + <div style={{ marginTop: 2 }}>{identity}</div> + </> + ); +}; + const reduceChannels = (channels) => { const channelsByUUID = {}; const channelsByID = channels.reduce((acc, channel) => { @@ -14,22 +49,21 @@ const reduceChannels = (channels) => { return { channelsByUUID, channelsByID }; }; -const showNotificationIfNewChannel = ( - currentStats, +const showNotificationIfChannelStopped = ( oldChannels, - ch, + newChannels, channelsByUUID, channels ) => { - if (currentStats.channels) { - if (oldChannels[ch.channel_id] === undefined) { - const channelId = channelsByUUID[ch.channel_id]; - const channel = channelId ? channels[channelId] : null; + // Safe on first poll: oldChannels is {} so the loop body never runs and no false "stopped" notifications fire. + for (const uuid in oldChannels) { + if (newChannels[uuid] === undefined) { + const channelId = channelsByUUID[uuid]; + const channel = channelId && channels[channelId]; const channelName = - channel?.name || ch.channel_name || `Channel (${ch.channel_id})`; - + channel?.name || oldChannels[uuid]?.channel_name || `Channel (${uuid})`; showNotification({ - title: 'New channel streaming', + title: 'Channel streaming stopped', message: channelName, color: 'blue.5', }); @@ -37,62 +71,38 @@ const showNotificationIfNewChannel = ( } }; -const showNotificationIfNewClient = (currentStats, oldClients, client) => { - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - if (oldClients[client.client_id] === undefined) { - showNotification({ - title: 'New client started streaming', - message: `Client streaming from ${client.ip_address}`, - color: 'blue.5', - }); - } - } -}; - -const showNotificationIfChannelStopped = ( - currentStats, - oldChannels, - newChannels, +const showNotificationIfClientStopped = ( + oldClients, + newClients, channelsByUUID, channels ) => { - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - for (const uuid in oldChannels) { - if (newChannels[uuid] === undefined) { - // Add null check for channel name - const channelId = channelsByUUID[uuid]; - const channel = channelId && channels[channelId]; - - const channelName = - channel?.name || - oldChannels[uuid]?.channel_name || - `Channel (${uuid})`; - showNotification({ - title: 'Channel streaming stopped', - message: channelName, - color: 'blue.5', - }); - } - } - } -}; - -const showNotificationIfClientStopped = ( - currentStats, - oldClients, - newClients -) => { - if (currentStats.channels) { - for (const clientId in oldClients) { - if (newClients[clientId] === undefined) { - showNotification({ - title: 'Client stopped streaming', - message: `Client stopped streaming from ${oldClients[clientId].ip_address}`, - color: 'blue.5', - }); - } + // Safe on first poll: oldClients is {} so the loop body never runs and no false "stopped" notifications fire. + for (const clientId in oldClients) { + if (newClients[clientId] === undefined) { + const client = oldClients[clientId]; + const channelId = client?.channel_id + ? channelsByUUID[client.channel_id] + : undefined; + const channel = channelId && channels[channelId]; + const channelName = + channel?.name || + client?.channel_name || + (client?.channel_id ? `Channel (${client.channel_id})` : null); + const { username, ip } = getClientIdentity(client); + const identity = username ? `${username} (${ip})` : ip; + showNotification({ + title: 'Client stopped streaming', + message: channelName ? ( + <> + <div>{channelName}</div> + <div style={{ marginTop: 2 }}>{identity}</div> + </> + ) : ( + identity + ), + color: 'blue.5', + }); } } }; @@ -409,7 +419,6 @@ const useChannelsStore = create((set, get) => ({ return set((state) => { const { channels, - stats: currentStats, activeChannels: oldChannels, activeClients: oldClients, channelsByUUID, @@ -422,28 +431,68 @@ const useChannelsStore = create((set, get) => ({ }, {}); stats.channels.forEach((ch) => { - showNotificationIfNewChannel( - currentStats, - oldChannels, - ch, - channelsByUUID, - channels - ); + const channelId = channelsByUUID[ch.channel_id]; + const channel = channelId ? channels[channelId] : null; + const channelName = + channel?.name || ch.channel_name || `Channel (${ch.channel_id})`; + const isNewChannel = oldChannels[ch.channel_id] === undefined; ch.clients.forEach((client) => { newClients[client.client_id] = client; - showNotificationIfNewClient(currentStats, oldClients, client); }); + + if (isNewChannel) { + // Only notify for clients that connected after the page loaded. + // This naturally suppresses pre-existing connections on the first poll + // while still firing for connections that started mid-session. + const genuinelyNewClients = ch.clients.filter( + (client) => + oldClients[client.client_id] === undefined && + isClientNewSincePageLoad(client) + ); + if (genuinelyNewClients.length > 0) { + showNotification({ + title: 'Channel started streaming', + message: clientMessage(channelName, genuinelyNewClients[0]), + color: 'blue.5', + }); + genuinelyNewClients.slice(1).forEach((client) => { + showNotification({ + title: 'New client started streaming', + message: clientMessage(channelName, client), + color: 'blue.5', + }); + }); + } + } else { + // Existing channel, notify only for clients that just joined. + ch.clients.forEach((client) => { + if ( + oldClients[client.client_id] === undefined && + isClientNewSincePageLoad(client) + ) { + showNotification({ + title: 'New client started streaming', + message: clientMessage(channelName, client), + color: 'blue.5', + }); + } + }); + } }); showNotificationIfChannelStopped( - currentStats, oldChannels, newChannels, channelsByUUID, channels ); - showNotificationIfClientStopped(currentStats, oldClients, newClients); + showNotificationIfClientStopped( + oldClients, + newClients, + channelsByUUID, + channels + ); return { stats, diff --git a/frontend/src/utils/cards/StreamConnectionCardUtils.js b/frontend/src/utils/cards/StreamConnectionCardUtils.js index 75d8e9b2..e3c5720e 100644 --- a/frontend/src/utils/cards/StreamConnectionCardUtils.js +++ b/frontend/src/utils/cards/StreamConnectionCardUtils.js @@ -1,9 +1,7 @@ import API from '../../api.js'; import { format, - getNow, initializeTime, - subtract, toFriendlyDuration, } from '../dateTimeUtils.js'; @@ -70,33 +68,24 @@ export const switchStream = (channel, streamId) => { export const connectedAccessor = (fullDateTimeFormat) => { return (row) => { - // Check for connected_since (which is seconds since connection) - if (row.connected_since) { - // Calculate the actual connection time by subtracting the seconds from current time - const connectedTime = subtract(getNow(), row.connected_since, 'second'); - return format(connectedTime, fullDateTimeFormat); - } - - // Fallback to connected_at if it exists if (row.connected_at) { - const connectedTime = initializeTime(row.connected_at * 1000); - return format(connectedTime, fullDateTimeFormat); + return format( + initializeTime(row.connected_at * 1000), + fullDateTimeFormat + ); } - return 'Unknown'; }; }; export const durationAccessor = () => { return (row) => { - if (row.connected_since) { - return toFriendlyDuration(row.connected_since, 'seconds'); + if (row.connected_at) { + return toFriendlyDuration( + Date.now() / 1000 - row.connected_at, + 'seconds' + ); } - - if (row.connection_duration) { - return toFriendlyDuration(row.connection_duration, 'seconds'); - } - return '-'; }; }; diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js index 5675425f..56b83ded 100644 --- a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js @@ -168,42 +168,22 @@ describe('StreamConnectionCardUtils', () => { }); describe('connectedAccessor', () => { - it('should format connected_since correctly', () => { - const mockNow = new Date('2024-01-01T12:00:00'); - const mockConnectedTime = new Date('2024-01-01T10:00:00'); - - dateTimeUtils.getNow.mockReturnValue(mockNow); - dateTimeUtils.subtract.mockReturnValue(mockConnectedTime); - dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00'); - - const accessor = StreamConnectionCardUtils.connectedAccessor( - 'MM/DD/YYYY, HH:mm:ss' - ); - const result = accessor({ connected_since: 7200 }); - - expect(dateTimeUtils.subtract).toHaveBeenCalledWith( - mockNow, - 7200, - 'second' - ); - expect(dateTimeUtils.format).toHaveBeenCalledWith( - mockConnectedTime, - 'MM/DD/YYYY, HH:mm:ss' - ); - expect(result).toBe('01/01/2024 10:00:00'); - }); - - it('should fallback to connected_at when connected_since is missing', () => { + it('should format connected_at correctly', () => { const mockTime = new Date('2024-01-01T10:00:00'); dateTimeUtils.initializeTime.mockReturnValue(mockTime); dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00'); - const accessor = - StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY'); + const accessor = StreamConnectionCardUtils.connectedAccessor( + 'MM/DD/YYYY, HH:mm:ss' + ); const result = accessor({ connected_at: 1704103200 }); expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000); + expect(dateTimeUtils.format).toHaveBeenCalledWith( + mockTime, + 'MM/DD/YYYY, HH:mm:ss' + ); expect(result).toBe('01/01/2024 10:00:00'); }); @@ -216,33 +196,22 @@ describe('StreamConnectionCardUtils', () => { }); describe('durationAccessor', () => { - it('should format connected_since duration', () => { + it('should compute duration from connected_at', () => { dateTimeUtils.toFriendlyDuration.mockReturnValue('2h 30m'); const accessor = StreamConnectionCardUtils.durationAccessor(); - const result = accessor({ connected_since: 9000 }); + // connected_at 9000 seconds before "now" (Date.now() / 1000) + const connectedAt = Date.now() / 1000 - 9000; + const result = accessor({ connected_at: connectedAt }); expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith( - 9000, + expect.closeTo(9000, 1), 'seconds' ); expect(result).toBe('2h 30m'); }); - it('should fallback to connection_duration', () => { - dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 15m'); - - const accessor = StreamConnectionCardUtils.durationAccessor(); - const result = accessor({ connection_duration: 4500 }); - - expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith( - 4500, - 'seconds' - ); - expect(result).toBe('1h 15m'); - }); - - it('should return - when no duration data available', () => { + it('should return - when no connected_at available', () => { const accessor = StreamConnectionCardUtils.durationAccessor(); const result = accessor({}); expect(result).toBe('-'); From 60e82b7b018c82544f2574f82dd7a71c793f4b91 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 23 Apr 2026 11:19:11 -0500 Subject: [PATCH 32/63] Enhancement: Add formatExactDuration utility for improved duration formatting in seconds, minutes, hours, and days; update StreamConnectionCard to utilize new formatting function for duration tooltip. --- CHANGELOG.md | 1 + .../components/cards/StreamConnectionCard.jsx | 3 +- .../__tests__/StreamConnectionCard.test.jsx | 1 + .../src/utils/__tests__/dateTimeUtils.test.js | 46 +++++++++++++++++++ frontend/src/utils/dateTimeUtils.js | 19 +++++++- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78bf29f2..49adf1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed. - **Improved channel start/client connect notifications**: when a channel starts streaming, the redundant separate "new channel" and "new client" toasts are now combined into a single **"Channel started streaming"** notification. All connect/disconnect notifications display both the channel name and the user identity (username + IP, or IP alone) on separate lines. A module-level `pageLoadTime` timestamp compared against each client's `connected_at` field from Redis filters out pre-existing connections on page load, while connections that genuinely start after the page loads (even if they arrive in the very first stats poll) correctly fire notifications. - **Client timing fields consolidated to `connected_at`**: `get_basic_channel_info` was sending only a pre-computed `connected_since` elapsed-seconds value (no raw timestamp), while `get_detailed_channel_info` was sending `connected_at` alongside a redundant `connection_duration`. Both paths now emit only `connected_at` (Unix timestamp). The frontend derives connection display time and duration from that single value at render time, which also fixes the "timestamp jumping" seen on the Stats page, the connected-at wall-clock time is now stable across polls instead of being recomputed each response. +- **Duration tooltip on Stats page connection table**: the hover tooltip on the Duration column now shows a human-readable breakdown instead of a raw seconds count. Seconds only under a minute, minutes and seconds under an hour, hours and minutes under a day, and days and hours beyond that (e.g. `2 hours, 15 minutes`). ## [0.23.0] - 2026-04-17 diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 1673c901..7205ac8d 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -32,6 +32,7 @@ import { } from 'lucide-react'; import { toFriendlyDuration, + formatExactDuration, useDateTimeFormat, } from '../../utils/dateTimeUtils.js'; import { CustomTable, useTable } from '../tables/CustomTable/index.jsx'; @@ -410,7 +411,7 @@ const StreamConnectionCard = ({ <Tooltip label={ exactDuration - ? `${exactDuration.toFixed(1)} seconds` + ? formatExactDuration(exactDuration) : 'Unknown duration' } > diff --git a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx index 1010747c..bde17ac1 100644 --- a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx +++ b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx @@ -23,6 +23,7 @@ vi.mock('../../../store/users.jsx', () => ({ // ── dateTimeUtils ───────────────────────────────────────────────────────────── vi.mock('../../../utils/dateTimeUtils.js', () => ({ toFriendlyDuration: vi.fn(() => '1h 23m'), + formatExactDuration: vi.fn((s) => `${s.toFixed(1)} seconds`), useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })), })); diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index b2177af2..bfdc4fbd 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -203,6 +203,52 @@ describe('dateTimeUtils', () => { }); }); + describe('formatExactDuration', () => { + it('should show seconds with one decimal place under a minute', () => { + expect(dateTimeUtils.formatExactDuration(45.6)).toBe('45.6 seconds'); + }); + + it('should use singular second when exactly 1 second', () => { + expect(dateTimeUtils.formatExactDuration(1.0)).toBe('1.0 seconds'); + }); + + it('should show minutes and seconds between 1 and 60 minutes', () => { + expect(dateTimeUtils.formatExactDuration(5 * 60 + 23)).toBe( + '5 minutes, 23 seconds' + ); + }); + + it('should use singular minute/second at exactly 1m 1s', () => { + expect(dateTimeUtils.formatExactDuration(61)).toBe('1 minute, 1 second'); + }); + + it('should show hours and minutes between 1 and 24 hours', () => { + expect(dateTimeUtils.formatExactDuration(2 * 3600 + 15 * 60)).toBe( + '2 hours, 15 minutes' + ); + }); + + it('should use singular hour/minute at exactly 1h 1m', () => { + expect(dateTimeUtils.formatExactDuration(3660)).toBe('1 hour, 1 minute'); + }); + + it('should show days and hours at 24 hours or more', () => { + expect(dateTimeUtils.formatExactDuration(2 * 86400 + 4 * 3600)).toBe( + '2 days, 4 hours' + ); + }); + + it('should use singular day/hour at exactly 1d 1h', () => { + expect(dateTimeUtils.formatExactDuration(86400 + 3600)).toBe( + '1 day, 1 hour' + ); + }); + + it('should show 0 seconds correctly', () => { + expect(dateTimeUtils.formatExactDuration(0)).toBe('0.0 seconds'); + }); + }); + describe('fromNow', () => { it('should return relative time from now', () => { const pastDate = dayjs().subtract(1, 'hour').toISOString(); diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 852a39a6..3930188b 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -43,6 +43,23 @@ export const getNow = () => dayjs(); export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize(); +export const formatExactDuration = (seconds) => { + if (seconds < 60) return `${seconds.toFixed(1)} seconds`; + if (seconds < 3600) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m} minute${m !== 1 ? 's' : ''}, ${s} second${s !== 1 ? 's' : ''}`; + } + if (seconds < 86400) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h} hour${h !== 1 ? 's' : ''}, ${m} minute${m !== 1 ? 's' : ''}`; + } + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + return `${d} day${d !== 1 ? 's' : ''}, ${h} hour${h !== 1 ? 's' : ''}`; +}; + export const fromNow = (dateTime) => dayjs(dateTime).fromNow(); export const setTz = (dateTime, timeZone) => dayjs(dateTime).tz(timeZone); @@ -336,4 +353,4 @@ export const MONTH_ABBR = [ 'oct', 'nov', 'dec', -]; \ No newline at end of file +]; From 29739ede36acba0606d5e07709187e2d697aebd4 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 23 Apr 2026 18:03:02 -0500 Subject: [PATCH 33/63] Enhancement: VOD start/stop notifications and system events. --- CHANGELOG.md | 1 + .../0003_alter_eventsubscription_event.py | 18 ++++ apps/connect/models.py | 2 + .../multi_worker_connection_manager.py | 96 +++++++++++++++++-- apps/proxy/vod_proxy/views.py | 38 +++++--- .../0023_alter_systemevent_event_type.py | 18 ++++ core/models.py | 2 + frontend/src/WebSocket.jsx | 51 +++++++++- frontend/src/components/SystemEvents.jsx | 30 +++--- frontend/src/constants.js | 2 + frontend/src/pages/Stats.jsx | 7 +- frontend/src/store/channels.jsx | 5 + 12 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 apps/connect/migrations/0003_alter_eventsubscription_event.py create mode 100644 core/migrations/0023_alter_systemevent_event_type.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49adf1ea..38c590bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **VOD start/stop notifications**: the frontend now shows a toast notification when a VOD stream starts or stops. `vod_started` and `vod_stopped` WebSocket events are fired from the backend when a new provider connection is opened or the last active stream on a session ends. The 1-second delayed-cleanup window is used as a settle period before firing `vod_stopped`, so seek reconnects that re-establish `active_streams` within that window suppress the notification. A `vod_stats` WebSocket push is sent alongside every event to keep the Stats page connection table in sync in real time. `vod_start` and `vod_stop` system events are also written to the system event log for each transition, and are now selectable as integration trigger events (webhook/script) in the Connect page. - **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) - **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) diff --git a/apps/connect/migrations/0003_alter_eventsubscription_event.py b/apps/connect/migrations/0003_alter_eventsubscription_event.py new file mode 100644 index 00000000..00e8f073 --- /dev/null +++ b/apps/connect/migrations/0003_alter_eventsubscription_event.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-23 23:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_connect', '0002_alter_eventsubscription_event'), + ] + + operations = [ + migrations.AlterField( + model_name='eventsubscription', + name='event', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], max_length=100), + ), + ] diff --git a/apps/connect/models.py b/apps/connect/models.py index 0b33a85e..e4b08bfc 100644 --- a/apps/connect/models.py +++ b/apps/connect/models.py @@ -16,6 +16,8 @@ SUPPORTED_EVENTS = { "login_failed": "Login Failed", "epg_blocked": "EPG Blocked", "m3u_blocked": "M3U Blocked", + "vod_start": "VOD Started", + "vod_stop": "VOD Stopped", } class Integration(models.Model): diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 073df2ca..7d367531 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -797,6 +797,67 @@ class MultiWorkerVODConnectionManager: logger.error(f"Error incrementing profile connections: {e}") return None + def _trigger_vod_stats_update(self): + """Trigger a VOD stats WebSocket update in a background thread.""" + threading.Thread(target=self._do_vod_stats_update, daemon=True).start() + + def _send_vod_event(self, event_type, session_id, content_name, content_uuid, client_ip, user_id, username=None): + """Send a vod_started or vod_stopped WebSocket event, log a system event, then update stats.""" + try: + from core.utils import send_websocket_update, log_system_event + if not self.redis_client: + return + + send_websocket_update( + "updates", + "update", + { + "type": event_type, + "content_name": content_name, + "content_uuid": content_uuid, + "client_ip": client_ip, + "user_id": user_id, + } + ) + + system_event_type = 'vod_start' if event_type == 'vod_started' else 'vod_stop' + try: + log_system_event( + system_event_type, + content_name=content_name, + content_uuid=content_uuid, + client_ip=client_ip, + username=username, + ) + except Exception as e: + logger.error(f"Could not log system event {system_event_type}: {e}") + + self._trigger_vod_stats_update() + + except Exception as e: + logger.error(f"Failed to send {event_type}: {e}") + + def _do_vod_stats_update(self): + """Collect active VOD connections (with full DB metadata) and push via WebSocket.""" + try: + from core.utils import send_websocket_update + from apps.proxy.vod_proxy.views import build_vod_stats_data + if not self.redis_client: + return + + stats = build_vod_stats_data(self.redis_client) + + send_websocket_update( + "updates", + "update", + { + "type": "vod_stats", + "stats": json.dumps(stats) + } + ) + except Exception as e: + logger.error(f"Failed to trigger VOD stats update: {e}") + def _decrement_profile_connections(self, m3u_profile_id: int): """Decrement profile connection count. @@ -1005,6 +1066,16 @@ class MultiWorkerVODConnectionManager: # to prevent cleanup race conditions with GeneratorExit. if not existing_state: redis_connection.increment_active_streams() + threading.Thread( + target=self._send_vod_event, + args=( + 'vod_started', client_id, content_name, + content_uuid, client_ip, + str(user.id) if user else '0', + user.username if user else None + ), + daemon=True + ).start() else: logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path") @@ -1061,12 +1132,18 @@ class MultiWorkerVODConnectionManager: def delayed_cleanup(): time.sleep(1) # Wait 1 second - # Smart cleanup: check active streams and ownership + # Re-check active_streams: a seeking/reconnecting client may + # have incremented it within the settle window. + if not redis_connection.has_active_streams(): + self._send_vod_event( + 'vod_stopped', client_id, content_name, + content_uuid, client_ip, + str(user.id) if user else '0', + user.username if user else None + ) logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion") - # No connection_manager — profile already decremented above redis_connection.cleanup(current_worker_id=self.worker_id) - import threading cleanup_thread = threading.Thread(target=delayed_cleanup) cleanup_thread.daemon = True cleanup_thread.start() @@ -1090,12 +1167,18 @@ class MultiWorkerVODConnectionManager: def delayed_cleanup(): time.sleep(1) # Wait 1 second - # Smart cleanup: check active streams and ownership + # Re-check active_streams: a seeking/reconnecting client may + # have incremented it within the settle window. + if not redis_connection.has_active_streams(): + self._send_vod_event( + 'vod_stopped', client_id, content_name, + content_uuid, client_ip, + str(user.id) if user else '0', + user.username if user else None + ) logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect") - # No connection_manager — profile already decremented above redis_connection.cleanup(current_worker_id=self.worker_id) - import threading cleanup_thread = threading.Thread(target=delayed_cleanup) cleanup_thread.daemon = True cleanup_thread.start() @@ -1142,7 +1225,6 @@ class MultiWorkerVODConnectionManager: # No connection_manager — profile already decremented above redis_connection.cleanup(current_worker_id=self.worker_id) - import threading cleanup_thread = threading.Thread(target=delayed_cleanup) cleanup_thread.daemon = True cleanup_thread.start() diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index a1dcf9a3..924f6906 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -684,17 +684,13 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True) return HttpResponse(f"HEAD error: {str(e)}", status=500) -@api_view(["GET"]) -@permission_classes([IsAdmin]) -def vod_stats(request): - """Get current VOD connection statistics""" +def build_vod_stats_data(redis_client): + """ + Build the full VOD stats payload (with DB lookups) from Redis connection data. + Returns a dict: {'vod_connections': [...], 'total_connections': N, 'timestamp': T} + Used by both the vod_stats API view and the WebSocket push in _do_vod_stats_update. + """ try: - connection_manager = MultiWorkerVODConnectionManager.get_instance() - redis_client = connection_manager.redis_client - - if not redis_client: - return JsonResponse({'error': 'Redis not available'}, status=500) - # Get all VOD persistent connections (consolidated data) pattern = "vod_persistent_connection:*" cursor = 0 @@ -936,11 +932,29 @@ def vod_stats(request): content_stats[content_key]['connection_count'] += 1 content_stats[content_key]['connections'].append(conn) - return JsonResponse({ + return { 'vod_connections': list(content_stats.values()), 'total_connections': len(connections), 'timestamp': current_time - }) + } + + except Exception as e: + logger.error(f"Error building VOD stats: {e}") + return {'vod_connections': [], 'total_connections': 0, 'timestamp': time.time()} + + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def vod_stats(request): + """Get current VOD connection statistics""" + try: + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return JsonResponse({'error': 'Redis not available'}, status=500) + + return JsonResponse(build_vod_stats_data(redis_client)) except Exception as e: logger.error(f"Error getting VOD stats: {e}") diff --git a/core/migrations/0023_alter_systemevent_event_type.py b/core/migrations/0023_alter_systemevent_event_type.py new file mode 100644 index 00000000..b4a34beb --- /dev/null +++ b/core/migrations/0023_alter_systemevent_event_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-23 22:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '022_default_user_limit_settings'), + ] + + operations = [ + migrations.AlterField( + model_name='systemevent', + name='event_type', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), ('login_success', 'Login Successful'), ('login_failed', 'Login Failed'), ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], db_index=True, max_length=50), + ), + ] diff --git a/core/models.py b/core/models.py index 5e86e98f..713fc9a4 100644 --- a/core/models.py +++ b/core/models.py @@ -399,6 +399,8 @@ class SystemEvent(models.Model): ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked'), + ('vod_start', 'VOD Started'), + ('vod_stop', 'VOD Stopped'), ] event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 1df2e295..31ad1fc5 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -16,6 +16,7 @@ import { Box, Button, Stack, Alert, Group } from '@mantine/core'; import API from './api'; import useSettingsStore from './store/settings'; import useAuthStore from './store/auth'; +import useUsersStore from './store/users'; export const WebsocketContext = createContext([false, () => {}, null]); @@ -211,13 +212,18 @@ export const WebsocketProvider = ({ children }) => { scheduleRecordingFetch(); } else if (status === 'skipped') { const reasonMap = { - no_commercials_detected: 'No commercials were detected in this recording', - no_commercials: 'No commercials were detected in this recording', + no_commercials_detected: + 'No commercials were detected in this recording', + no_commercials: + 'No commercials were detected in this recording', }; notifications.update({ id, title: 'No commercials to remove', - message: reasonMap[parsedEvent.data.reason] || parsedEvent.data.reason || '', + message: + reasonMap[parsedEvent.data.reason] || + parsedEvent.data.reason || + '', color: 'teal', loading: false, autoClose: 3000, @@ -311,6 +317,36 @@ export const WebsocketProvider = ({ children }) => { setChannelStats(JSON.parse(parsedEvent.data.stats)); break; + case 'vod_stats': + setVodStats(JSON.parse(parsedEvent.data.stats)); + break; + + case 'vod_started': + case 'vod_stopped': { + const { content_name, client_ip, user_id } = parsedEvent.data; + const isStart = parsedEvent.data.type === 'vod_started'; + let identity = client_ip || 'unknown'; + if (user_id && user_id !== '0') { + const allUsers = useUsersStore.getState().users; + const matched = allUsers.find( + (u) => String(u.id) === String(user_id) + ); + if (matched?.username) + identity = `${matched.username} (${client_ip})`; + } + notifications.show({ + title: isStart ? 'VOD started' : 'VOD ended', + message: ( + <> + <div>{content_name}</div> + <div style={{ marginTop: 2 }}>{identity}</div> + </> + ), + color: 'blue.5', + }); + break; + } + case 'epg_channels': notifications.show({ message: 'EPG channels updated!', @@ -559,7 +595,9 @@ export const WebsocketProvider = ({ children }) => { case 'recording_cancelled': notifications.show({ - title: parsedEvent.data.was_in_progress ? 'Recording cancelled' : 'Recording deleted', + title: parsedEvent.data.was_in_progress + ? 'Recording cancelled' + : 'Recording deleted', message: parsedEvent.data.was_in_progress ? 'Recording cancelled and content removed.' : 'Recording deleted.', @@ -568,7 +606,9 @@ export const WebsocketProvider = ({ children }) => { // Surgical removal by ID avoids a full fetchRecordings() re-render. // Fall back to a full refresh if the ID is missing (e.g. older server). if (parsedEvent.data.recording_id != null) { - useChannelsStore.getState().removeRecording(parsedEvent.data.recording_id); + useChannelsStore + .getState() + .removeRecording(parsedEvent.data.recording_id); } else { scheduleRecordingFetch(); } @@ -990,6 +1030,7 @@ export const WebsocketProvider = ({ children }) => { }, [connectWebSocket, clearReconnectTimer, isAuthenticated, accessToken]); const setChannelStats = useChannelsStore((s) => s.setChannelStats); + const setVodStats = useChannelsStore((s) => s.setVodStats); const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists); const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress); const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview); diff --git a/frontend/src/components/SystemEvents.jsx b/frontend/src/components/SystemEvents.jsx index 1fcfd9db..861f678a 100644 --- a/frontend/src/components/SystemEvents.jsx +++ b/frontend/src/components/SystemEvents.jsx @@ -53,6 +53,10 @@ const getEventIcon = (eventType) => { return <Video size={16} />; case 'recording_end': return <Video size={16} />; + case 'vod_start': + return <CirclePlay size={16} />; + case 'vod_stop': + return <SquareX size={16} />; case 'stream_switch': return <HardDriveDownload size={16} />; case 'm3u_refresh': @@ -83,6 +87,7 @@ const getEventColor = (eventType) => { case 'channel_start': case 'client_connect': case 'recording_start': + case 'vod_start': case 'login_success': return 'green'; case 'channel_reconnect': @@ -90,6 +95,7 @@ const getEventColor = (eventType) => { case 'channel_stop': case 'client_disconnect': case 'recording_end': + case 'vod_stop': case 'logout': return 'gray'; case 'channel_buffering': @@ -116,7 +122,7 @@ const getEventColor = (eventType) => { const getSystemEvents = (eventsLimit, offset) => { return API.getSystemEvents(eventsLimit, offset); -} +}; const Event = ({ event }) => { const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); @@ -147,16 +153,14 @@ const Event = ({ event }) => { </Text> )} </Group> - {event.details && - Object.keys(event.details).length > 0 && ( - <Text size="xs" c="dimmed"> - {Object.entries(event.details) - .filter(([key]) => - !['stream_url', 'new_url'].includes(key)) - .map(([key, value]) => `${key}: ${value}`) - .join(', ')} - </Text> - )} + {event.details && Object.keys(event.details).length > 0 && ( + <Text size="xs" c="dimmed"> + {Object.entries(event.details) + .filter(([key]) => !['stream_url', 'new_url'].includes(key)) + .map(([key, value]) => `${key}: ${value}`) + .join(', ')} + </Text> + )} </Stack> </Group> <Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}> @@ -321,9 +325,7 @@ const SystemEvents = () => { No events recorded yet </Text> ) : ( - events.map((event) => ( - <Event key={event.id} event={event} /> - )) + events.map((event) => <Event key={event.id} event={event} />) )} </Stack> </> diff --git a/frontend/src/constants.js b/frontend/src/constants.js index e8638cc8..92aba531 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -403,4 +403,6 @@ export const SUBSCRIPTION_EVENTS = { login_failed: 'Login Failed', epg_blocked: 'EPG Blocked', m3u_blocked: 'M3U Blocked', + vod_start: 'VOD Started', + vod_stop: 'VOD Stopped', }; diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index c1e2f7e7..ec5f7042 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -99,10 +99,11 @@ const Connections = ({ const StatsPage = () => { const channelStats = useChannelsStore((s) => s.stats); const setChannelStats = useChannelsStore((s) => s.setChannelStats); + const vodConnections = useChannelsStore((s) => s.activeVodConnections); + const setVodStats = useChannelsStore((s) => s.setVodStats); const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [clients, setClients] = useState([]); - const [vodConnections, setVodConnections] = useState([]); const [channelHistory, setChannelHistory] = useState({}); const [isPollingActive, setIsPollingActive] = useState(false); const [currentPrograms, setCurrentPrograms] = useState({}); @@ -197,7 +198,7 @@ const StatsPage = () => { try { const response = await getVODStats(); if (response) { - setVodConnections(response.vod_connections || []); + setVodStats(response); } else { console.log('VOD API response was empty or null'); } @@ -209,7 +210,7 @@ const StatsPage = () => { body: error.body, }); } - }, []); + }, [setVodStats]); // Set up polling for stats when on stats page useEffect(() => { diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 6c0b946a..c71ed1bd 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -118,6 +118,7 @@ const useChannelsStore = create((set, get) => ({ stats: {}, activeChannels: {}, activeClients: {}, + activeVodConnections: [], recordings: [], recurringRules: [], isLoading: false, @@ -502,6 +503,10 @@ const useChannelsStore = create((set, get) => ({ }); }, + setVodStats: (stats) => { + set({ activeVodConnections: stats.vod_connections || [] }); + }, + fetchRecordings: async () => { try { set({ recordings: await api.getRecordings() }); From 972d214631fbaf64b56900cb5467bb5b322b8f26 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 23 Apr 2026 19:30:39 -0500 Subject: [PATCH 34/63] tests: Fix stats test --- frontend/src/pages/__tests__/Stats.test.jsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index ffc90f3f..d809a47a 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -159,12 +159,14 @@ describe('StatsPage', () => { let mockSetChannelStats; let mockSetRefreshInterval; + let mockSetVodStats; beforeEach(() => { vi.clearAllMocks(); mockSetChannelStats = vi.fn(); mockSetRefreshInterval = vi.fn(); + mockSetVodStats = vi.fn(); // Setup store mocks useChannelsStore.mockImplementation((selector) => { @@ -173,6 +175,8 @@ describe('StatsPage', () => { channelsByUUID: mockChannelsByUUID, stats: { channels: mockChannelStats.channels }, setChannelStats: mockSetChannelStats, + activeVodConnections: mockVODStats.vod_connections, + setVodStats: mockSetVodStats, }; return selector ? selector(state) : state; }); @@ -505,6 +509,18 @@ describe('StatsPage', () => { }; getVODStats.mockResolvedValue(multiVODStats); + useChannelsStore.mockImplementation((selector) => { + const state = { + channels: mockChannels, + channelsByUUID: mockChannelsByUUID, + stats: { channels: mockChannelStats.channels }, + setChannelStats: mockSetChannelStats, + activeVodConnections: multiVODStats.vod_connections, + setVodStats: mockSetVodStats, + }; + return selector ? selector(state) : state; + }); + render(<StatsPage />); await waitFor(() => { From d77eb9e2ec46bc7eb126e957dab1d47ea1785cfb Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 23 Apr 2026 19:37:46 -0500 Subject: [PATCH 35/63] tests: Update channel stats test to include clients in channel data --- frontend/src/store/__tests__/channels.test.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/store/__tests__/channels.test.jsx b/frontend/src/store/__tests__/channels.test.jsx index d86eca29..5be97e76 100644 --- a/frontend/src/store/__tests__/channels.test.jsx +++ b/frontend/src/store/__tests__/channels.test.jsx @@ -310,7 +310,9 @@ describe('useChannelsStore', () => { }); const newStats = { - channels: [{ channel_id: 'uuid-1', clients: [] }], + channels: [ + { channel_id: 'uuid-1', clients: [{ client_id: 'client-1' }] }, + ], }; act(() => { From 14a6bf4473bc4636d679ad2bf7d2d11e8957dc85 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 09:06:48 -0500 Subject: [PATCH 36/63] Initial DVR HLS refactor. --- apps/channels/api_urls.py | 5 + apps/channels/api_views.py | 105 +- apps/channels/tasks.py | 1126 +++++++++-------- .../tests/test_dvr_port_resolution.py | 88 +- docker/docker-compose.yml | 6 +- 5 files changed, 742 insertions(+), 588 deletions(-) diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index bd53ae45..b2af3167 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -49,6 +49,11 @@ urlpatterns = [ path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'), path('series-rules/<path:tvg_id>/', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'), path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'), + path( + 'recordings/<int:pk>/hls/<path:seg_path>', + RecordingViewSet.as_view({'get': 'hls'}), + name='recording-hls', + ), path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'), ] diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 3e786ff1..2907c48d 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2444,7 +2444,7 @@ class RecordingViewSet(viewsets.ModelViewSet): def get_permissions(self): # Allow unauthenticated playback of recording files (like other streaming endpoints) - if self.action == 'file': + if self.action in ('file', 'hls'): return [AllowAny()] try: return [perm() for perm in permission_classes_by_action[self.action]] @@ -2464,14 +2464,29 @@ class RecordingViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["get"], url_path="file") def file(self, request, pk=None): - """Stream a recorded file with HTTP Range support for seeking.""" + """Stream a completed recording file with HTTP Range support for seeking. + + For in-progress recordings, file_url in custom_properties points to + /hls/index.m3u8. If a client hits this endpoint while the recording + is still running (or the MKV is not yet produced), it is redirected to + the HLS playlist endpoint. + """ recording = get_object_or_404(Recording, pk=pk) cp = recording.custom_properties or {} file_path = cp.get("file_path") file_name = cp.get("file_name") or "recording" - if not file_path or not os.path.exists(file_path): - raise Http404("Recording file not found") + if not file_path or not os.path.exists(file_path) or os.path.getsize(file_path) == 0: + # Redirect to HLS if recording is still in progress + hls_dir = cp.get("_hls_dir") + if hls_dir and os.path.isdir(hls_dir): + from django.http import HttpResponseRedirect + hls_url = request.build_absolute_uri( + f"/api/channels/recordings/{pk}/hls/index.m3u8" + ) + return HttpResponseRedirect(hls_url) + if not file_path or not os.path.exists(file_path): + raise Http404("Recording file not found") # Guess content type ext = os.path.splitext(file_path)[1].lower() @@ -2532,6 +2547,73 @@ class RecordingViewSet(viewsets.ModelViewSet): response["Content-Disposition"] = f"inline; filename=\"{file_name}\"" return response + @action(detail=True, methods=["get"], url_path="hls/(?P<seg_path>.+)") + def hls(self, request, pk=None, seg_path="index.m3u8"): + """Serve HLS playlist and segment files for an in-progress (or completed) recording. + + Clients connecting during recording should use the m3u8 URL returned in + custom_properties.file_url. Segment URLs inside the playlist are rewritten + to route through this endpoint so authentication and path isolation are + preserved. + """ + recording = get_object_or_404(Recording, pk=pk) + cp = recording.custom_properties or {} + hls_dir = cp.get("_hls_dir") + + if not hls_dir or not os.path.isdir(hls_dir): + # HLS dir is gone, recording is likely complete. Redirect to the + # permanent MKV endpoint for .m3u8 requests so clients that still + # have the HLS URL bookmarked get a useful response. + cp = recording.custom_properties or {} + file_path = cp.get("file_path") + if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0: + from django.http import HttpResponseRedirect + return HttpResponseRedirect( + request.build_absolute_uri(f"/api/channels/recordings/{pk}/file/") + ) + raise Http404("HLS content not available for this recording") + + # Security: prevent path traversal outside the HLS directory + safe_dir = os.path.realpath(hls_dir) + requested = os.path.realpath(os.path.join(hls_dir, seg_path)) + if not requested.startswith(safe_dir + os.sep) and requested != safe_dir: + return Response({"error": "Forbidden"}, status=403) + + if not os.path.isfile(requested): + raise Http404(f"HLS file not found: {seg_path}") + + if seg_path.endswith(".m3u8"): + # Rewrite relative segment lines to absolute URLs through this API + base_url = request.build_absolute_uri( + f"/api/channels/recordings/{pk}/hls/" + ) + lines = [] + with open(requested) as _f: + for line in _f: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + lines.append(f"{base_url}{stripped}\n") + else: + lines.append(line) + from django.http import HttpResponse as _HR + return _HR("".join(lines), content_type="application/x-mpegURL") + + if seg_path.endswith(".ts"): + # Refresh the viewer heartbeat in Redis so the Celery task knows an + # active client is still fetching segments. TTL is 20 s, enough for + # three 4-second segments plus network margin. + try: + from core.utils import RedisClient + _rv = RedisClient.get_client(max_retries=1, retry_interval=0) + if _rv: + _rv.set(f"dvr:hls_viewer:{pk}", "1", ex=20) + except Exception: + pass + from django.http import FileResponse as _FR + return _FR(open(requested, "rb"), content_type="video/mp2t") + + raise Http404("Unsupported HLS file type") + @action(detail=True, methods=["post"], url_path="stop") def stop(self, request, pk=None): """Stop a recording early while retaining the partial content for playback.""" @@ -2838,7 +2920,7 @@ class RecordingViewSet(viewsets.ModelViewSet): cp = instance.custom_properties or {} rec_status = cp.get("status", "") file_path = cp.get("file_path") - temp_ts_path = cp.get("_temp_file_path") + hls_dir = cp.get("_hls_dir") channel_uuid = str(instance.channel.uuid) # 1. Delete the DB record (also fires post_delete → revoke_task_on_delete) @@ -2871,6 +2953,17 @@ class RecordingViewSet(viewsets.ModelViewSet): except Exception as ex: logger.warning(f"Failed to delete recording artifact {path}: {ex}") + def _safe_rmtree(path: str): + if not path or not isinstance(path, str): + return + try: + import shutil as _shutil + if any(path.startswith(root) for root in allowed_roots) and os.path.isdir(path): + _shutil.rmtree(path) + logger.info(f"Deleted recording HLS directory: {path}") + except Exception as ex: + logger.warning(f"Failed to delete HLS directory {path}: {ex}") + def _background_cancel(): # Only stop the DVR client if the recording was actively streaming. # Stopping for completed/upcoming recordings would kill an unrelated @@ -2888,7 +2981,7 @@ class RecordingViewSet(viewsets.ModelViewSet): # Best-effort file cleanup in case run_recording already exited # before the DB delete. _safe_remove(file_path) - _safe_remove(temp_ts_path) + _safe_rmtree(hls_dir) try: from django.db import connection as _conn diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 59122d9f..67bec477 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -8,6 +8,8 @@ import time import json import subprocess import signal +import threading +from collections import deque from zoneinfo import ZoneInfo from datetime import datetime, timedelta import gc @@ -1654,22 +1656,18 @@ def _build_output_paths(channel, program, start_time, end_time): final_path = os.path.normpath(final_path) # Avoid overwriting an existing file from a different recording. - # Check BOTH .mkv and .ts — a pre-restart TS segment may exist at - # the same base name even when the MKV is a 0-byte placeholder. + # Check the MKV file and its associated HLS working directory. base, ext = os.path.splitext(final_path) counter = 1 while True: candidate_base = final_path[:-len(ext)] # strip extension - ts_candidate = candidate_base + '.ts' + hls_dir_candidate = candidate_base + '_hls' try: mkv_occupied = os.stat(final_path).st_size > 0 except OSError: mkv_occupied = False - try: - ts_occupied = os.stat(ts_candidate).st_size > 0 - except OSError: - ts_occupied = False - if not mkv_occupied and not ts_occupied: + hls_occupied = os.path.isdir(hls_dir_candidate) + if not mkv_occupied and not hls_occupied: break counter += 1 final_path = f"{base}_{counter}{ext}" @@ -1677,37 +1675,36 @@ def _build_output_paths(channel, program, start_time, end_time): # Ensure directory exists os.makedirs(os.path.dirname(final_path), exist_ok=True) - # Derive temp TS path in same directory + # Derive HLS working directory alongside the final MKV base_no_ext = os.path.splitext(os.path.basename(final_path))[0] - temp_ts_path = os.path.join(os.path.dirname(final_path), f"{base_no_ext}.ts") - return final_path, temp_ts_path, os.path.basename(final_path) + hls_dir = os.path.join(os.path.dirname(final_path), f"{base_no_ext}_hls") + return final_path, hls_dir, os.path.basename(final_path) -def build_dvr_candidates(): - """Build ordered list of candidate base URLs for DVR TS streaming. +def get_dvr_stream_base_url(): + """Return the single correct base URL for DVR to reach the TS stream proxy. - Reads environment variables to determine which URLs to try: - - DISPATCHARR_INTERNAL_TS_BASE_URL: explicit override (first priority) - - DISPATCHARR_PORT: the external port (default 9191) - - DISPATCHARR_ENV/DISPATCHARR_DEBUG/REDIS_HOST: dev-mode detection - - DISPATCHARR_INTERNAL_API_BASE: override for the docker service URL + Priority: + 1. DISPATCHARR_INTERNAL_TS_BASE_URL — explicit override, always wins. + 2. Modular mode (DISPATCHARR_ENV=modular) — celery runs in a separate container + and must reach the web container by its Docker service name on DISPATCHARR_PORT. + Override the host with DISPATCHARR_WEB_HOST for non-standard compose setups. + 3. AIO / dev / debug — celery shares the container with uwsgi which binds on + port 5656; use 127.0.0.1 to avoid any nginx layer. """ explicit = os.environ.get('DISPATCHARR_INTERNAL_TS_BASE_URL') - dispatcharr_port = os.environ.get('DISPATCHARR_PORT', '9191') - is_dev = (os.environ.get('DISPATCHARR_ENV', '').lower() == 'dev') or \ - (os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true') or \ - (os.environ.get('REDIS_HOST', 'redis') in ('localhost', '127.0.0.1')) - candidates = [] if explicit: - candidates.append(explicit) - if is_dev: - # Debug container typically exposes API on 5656 (uwsgi internal port) - candidates.extend(['http://127.0.0.1:5656', f'http://127.0.0.1:{dispatcharr_port}']) - # Docker service name fallback — use DISPATCHARR_PORT so modular mode works with custom ports - candidates.append(os.environ.get('DISPATCHARR_INTERNAL_API_BASE', f'http://web:{dispatcharr_port}')) - # Last-resort localhost ports - candidates.extend(['http://localhost:5656', f'http://localhost:{dispatcharr_port}']) - return candidates + return explicit.rstrip('/') + + dispatcharr_env = os.environ.get('DISPATCHARR_ENV', 'aio').lower() + + if dispatcharr_env == 'modular': + host = os.environ.get('DISPATCHARR_WEB_HOST', 'web') + port = os.environ.get('DISPATCHARR_PORT', '9191') + return f'http://{host}:{port}' + + # AIO, dev, debug: celery and uwsgi share the container, reach uwsgi directly + return 'http://127.0.0.1:5656' @shared_task @@ -1759,11 +1756,10 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): start_time = datetime.fromisoformat(start_time_str) end_time = datetime.fromisoformat(end_time_str) - duration_seconds = int((end_time - start_time).total_seconds()) # Build output paths from templates (refined after loading Recording cp below) filename = None final_path = None - temp_ts_path = None + hls_dir = None channel_layer = get_channel_layer() @@ -1810,7 +1806,7 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): "started_at": str(datetime.now()), }) # Provide a predictable playback URL for the frontend - cp["file_url"] = f"/api/channels/recordings/{recording_id}/file/" + cp["file_url"] = f"/api/channels/recordings/{recording_id}/hls/index.m3u8" cp["output_file_url"] = cp["file_url"] # Determine program info (may include id for deeper details) @@ -1825,10 +1821,10 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): program.update(epg_match) cp["program"] = program - final_path, temp_ts_path, filename = _build_output_paths(channel, program, start_time, end_time) + final_path, hls_dir, filename = _build_output_paths(channel, program, start_time, end_time) cp["file_name"] = filename cp["file_path"] = final_path - cp["_temp_file_path"] = temp_ts_path + cp["_hls_dir"] = hls_dir # Resolve poster art via the shared pipeline (EPG → VOD → TMDB/OMDb → # TVMaze/iTunes → direct program fields → Logo table → channel logo). @@ -1863,7 +1859,7 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # Merge only the keys explicitly set into the fresh copy for key in ("status", "started_at", "file_url", "output_file_url", - "file_name", "file_path", "_temp_file_path", + "file_name", "file_path", "_hls_dir", "program", "poster_logo_id", "poster_url"): if key in cp: fresh_cp[key] = cp[key] @@ -1886,15 +1882,8 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): interrupted_reason = None bytes_written = 0 - from requests.exceptions import ReadTimeout, ConnectionError as ReqConnectionError, ChunkedEncodingError - - candidates = build_dvr_candidates() - - chosen_base = None - last_error = None - bytes_written = 0 - interrupted = False - interrupted_reason = None + base_url = get_dvr_stream_base_url() + logger.info(f"DVR recording {recording_id}: using stream base URL {base_url}") def _check_recording_cancelled(rid): """Check if a recording was stopped by user or deleted. @@ -1912,232 +1901,331 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): pass return False, False, None - # --- Retry / reconnection constants --- - # Stream reconnection: retry the same TS proxy base on transient - # connectivity loss. Counter resets when data resumes. - _dvr_max_reconnects = 5 - _dvr_reconnect_delay = 2.0 # seconds - # DB save retry: exponential backoff (1s, 2s, 4s) for transient errors. + # --- DB retry constants --- _dvr_db_max_retries = 3 _dvr_db_retry_interval = 1 # seconds (base for exponential backoff) - # FFmpeg remux retry: covers transient I/O errors. - _dvr_remux_max_retries = 2 - _dvr_remux_retry_interval = 2 # seconds (base for exponential backoff) - for base in candidates: - test_url = f"{base.rstrip('/')}/proxy/ts/stream/{channel.uuid}" - logger.info(f"DVR recording {recording_id}: trying TS base {base}") + # Redis key used to signal that an HLS client is actively fetching segments. + # The hls endpoint refreshes this on every .ts request; we check it before + # deleting the HLS directory so in-flight downloads are not cut short. + _hls_viewer_key = f"dvr:hls_viewer:{recording_id}" - _reconnects = 0 - _file_mode = 'wb' - _stream_started_at = None - _done = False + # --- HLS recording pipeline --- + # Launch FFmpeg with the deterministic base URL resolved above. Wait up to + # _first_segment_timeout seconds for the first segment to appear before + # treating the stream as unavailable. + from django.utils import timezone as _tz - while True: # Reconnection loop for this base + last_error = None + ffmpeg_proc = None + hls_m3u8 = None + hls_seg_pattern = None + _stream_confirmed = False + + if hls_dir: + os.makedirs(hls_dir, exist_ok=True) + hls_m3u8 = os.path.join(hls_dir, "index.m3u8") + hls_seg_pattern = os.path.join(hls_dir, "seg_%05d.ts") + + base = base_url + + if not interrupted: + # Check for stop/delete before starting + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit: + interrupted = is_int + interrupted_reason = reason + + if not interrupted: + stream_url = f"{base}/proxy/ts/stream/{channel.uuid}" + logger.info(f"DVR recording {recording_id}: stream URL: {stream_url}") + + if not interrupted: + + # Continue segment numbering from any previous session (server-restart resume) + existing_segs = sorted( + f for f in os.listdir(hls_dir) if f.startswith("seg_") and f.endswith(".ts") + ) if hls_dir else [] + hls_start_number = len(existing_segs) + + ffmpeg_cmd = [ + "ffmpeg", "-y", + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "5", + "-user_agent", f"Dispatcharr-DVR/recording-{recording_id}", + # Regenerate monotonic PTS to handle erratic/discontinuous timestamps + # from IPTV sources. + "-fflags", "+genpts", + "-i", stream_url, + "-c", "copy", + # Shift output timestamps so they start from 0, fixing negative PTS + # values that can prevent segment boundary detection in the HLS muxer. + "-avoid_negative_ts", "make_zero", + "-f", "hls", + "-hls_time", "4", + "-hls_list_size", "0", + "-hls_flags", "append_list+omit_endlist+independent_segments", + "-start_number", str(hls_start_number), + "-hls_segment_filename", hls_seg_pattern, + hls_m3u8, + ] + + logger.info(f"DVR recording {recording_id}: starting FFmpeg — stream URL: {stream_url}") + logger.info(f"DVR recording {recording_id}: HLS output dir: {hls_dir}") + logger.debug(f"DVR recording {recording_id}: FFmpeg command: {' '.join(str(a) for a in ffmpeg_cmd)}") + # Rolling tail of FFmpeg stderr lines for post-mortem diagnostics + _ffmpeg_stderr_tail = deque(maxlen=200) + try: + ffmpeg_proc = subprocess.Popen( + ffmpeg_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except Exception as _fe: + last_error = str(_fe) + logger.warning(f"DVR recording {recording_id}: failed to launch FFmpeg: {_fe}") + ffmpeg_proc = None + + # Drain FFmpeg stderr in a background thread to prevent the OS pipe + # buffer (~64 KB on Linux) from filling up, which would block FFmpeg's + # writes and cause it to silently stop demuxing/segmenting after a few + # minutes of normal progress output. Lines are logged to the Dispatcharr + # log and tail-buffered for diagnostics. + def _drain_ffmpeg_stderr(proc, rec_id, tail): + # FFmpeg emits progress lines terminated with \r (carriage return) + # so it can rewrite them in place; only non-progress messages end + # with \n. Split on either to capture both kinds. try: - with requests.get( - test_url, - headers={ - 'User-Agent': f'Dispatcharr-DVR/recording-{recording_id}', - }, - stream=True, - timeout=(10, 15), - ) as response: - response.raise_for_status() - - _test_window = 3.0 - _window_start = time.time() - _stop_poll_interval = 2.0 - _last_stop_poll = time.time() - - with open(temp_ts_path, _file_mode) as file: - if _stream_started_at is None: - _stream_started_at = time.time() - - for chunk in response.iter_content(chunk_size=8192): - if not chunk: - if not chosen_base and (time.time() - _window_start) > _test_window: - break - continue - - if not chosen_base: - chosen_base = base - - # Data received after reconnect — connection restored - if _reconnects > 0: - logger.info( - f"DVR recording {recording_id}: " - f"stream resumed after reconnect" - ) - _reconnects = 0 - - file.write(chunk) - bytes_written += len(chunk) - - elapsed = time.time() - _stream_started_at - if elapsed > duration_seconds: - break - - # Periodic DB poll: stop, delete, end_time extension - _now = time.time() - if _now - _last_stop_poll >= _stop_poll_interval: - _last_stop_poll = _now - try: - _sc = Recording.objects.filter( - id=recording_id - ).only("custom_properties", "end_time").first() - if _sc is None: - logger.info( - f"DVR recording {recording_id}: " - f"deleted — exiting stream loop" - ) - interrupted = False - break - if (_sc.custom_properties or {}).get("status") == "stopped": - logger.info( - f"DVR recording {recording_id}: " - f"stop requested — exiting stream loop" - ) - break - try: - new_end = _sc.end_time - if new_end is not None: - from django.utils import timezone as _tz - if _tz.is_naive(new_end): - new_end = _tz.make_aware(new_end) - _ref = start_time - if _tz.is_naive(_ref): - _ref = _tz.make_aware(_ref) - new_duration = int( - (new_end - _ref).total_seconds() - ) - if new_duration > duration_seconds: - logger.info( - f"DVR recording {recording_id}: " - f"end_time extended to {new_end}, " - f"new duration {new_duration}s" - ) - duration_seconds = new_duration - except Exception: - pass - except Exception: - pass - - # iter_content exhausted or loop exited normally - if bytes_written > 0: - logger.info( - f"DVR recording {recording_id}: " - f"stream complete, {bytes_written} bytes written" - ) - _done = True - else: - last_error = f"no_data_from_{base}" - logger.warning( - f"DVR recording {recording_id}: no data from " - f"{base} within {_test_window}s, trying next base" - ) - try: - if os.path.exists(temp_ts_path) and os.path.getsize(temp_ts_path) == 0: - os.remove(temp_ts_path) - except FileNotFoundError: - pass - break # Exit reconnection loop - - except (ReadTimeout, ReqConnectionError, ChunkedEncodingError) as e: - if bytes_written > 0: - # Active stream lost — check cancellation before reconnecting - should_exit, is_int, reason = _check_recording_cancelled(recording_id) - if should_exit: - interrupted = is_int - interrupted_reason = reason - if reason == "stopped_by_user": - logger.info( - f"DVR recording {recording_id}: " - f"stopped by user — ending stream" - ) - _done = True + buf = bytearray() + stream = proc.stderr + while True: + byte = stream.read(1) + if not byte: break - - _reconnects += 1 - if _reconnects <= _dvr_max_reconnects: - logger.warning( - f"DVR recording {recording_id}: connection lost " - f"({type(e).__name__}), reconnecting " - f"({_reconnects}/{_dvr_max_reconnects}) " - f"in {_dvr_reconnect_delay}s..." - ) - time.sleep(_dvr_reconnect_delay) - _file_mode = 'ab' + if byte in (b'\r', b'\n'): + if buf: + line = buf.decode('utf-8', errors='replace').strip() + buf.clear() + if line: + tail.append(line) + low = line.lower() + if 'error' in low or 'failed' in low or 'invalid' in low: + logger.warning(f"DVR recording {rec_id} ffmpeg: {line}") + else: + logger.debug(f"DVR recording {rec_id} ffmpeg: {line}") continue + buf.append(byte[0]) + # Safety net: flush absurdly long lines so a malformed + # stream can't grow the buffer without bound. + if len(buf) > 4096: + line = buf.decode('utf-8', errors='replace').strip() + buf.clear() + if line: + tail.append(line) + logger.debug(f"DVR recording {rec_id} ffmpeg: {line}") + # Flush any trailing content + if buf: + line = buf.decode('utf-8', errors='replace').strip() + if line: + tail.append(line) + logger.debug(f"DVR recording {rec_id} ffmpeg: {line}") + except Exception as _de: + logger.debug(f"DVR recording {rec_id}: stderr drain ended: {_de}") - logger.error( - f"DVR recording {recording_id}: max reconnects " - f"({_dvr_max_reconnects}) exceeded — ending recording" - ) - interrupted = True - interrupted_reason = ( - f"stream_interrupted: max reconnects exceeded ({e})" - ) - _done = True - break + if ffmpeg_proc is not None and ffmpeg_proc.stderr is not None: + _stderr_thread = threading.Thread( + target=_drain_ffmpeg_stderr, + args=(ffmpeg_proc, recording_id, _ffmpeg_stderr_tail), + daemon=True, + name=f"dvr-ffmpeg-stderr-{recording_id}", + ) + _stderr_thread.start() - # No data received yet — retry same base before moving on - should_exit, is_int, reason = _check_recording_cancelled(recording_id) - if should_exit: - interrupted = is_int - interrupted_reason = reason - _done = True - break - _reconnects += 1 - if _reconnects <= _dvr_max_reconnects: + end_timestamp = ( + end_time.timestamp() + if hasattr(end_time, 'timestamp') + else time.mktime(end_time.timetuple()) + ) + _stop_poll_interval = 2.0 + _last_stop_poll = time.time() + _ffmpeg_start = time.time() + _first_segment_timeout = 15.0 + _stall_timeout = 60.0 # seconds without new segments → source stream gone + _stream_confirmed = False + _last_seg_count = hls_start_number + _last_new_seg_time = time.time() + + while ffmpeg_proc.poll() is None: + time.sleep(0.5) + now = time.time() + + segs_now = [ + f for f in os.listdir(hls_dir) + if f.startswith("seg_") and f.endswith(".ts") + ] if hls_dir else [] + + # Wait for the first segment to confirm data is flowing + if not _stream_confirmed: + if segs_now: + _stream_confirmed = True + _last_seg_count = len(segs_now) + _last_new_seg_time = now + logger.info( + f"DVR recording {recording_id}: first HLS segment written, stream confirmed" + ) + elif now - _ffmpeg_start > _first_segment_timeout: logger.warning( - f"DVR recording {recording_id}: initial connection " - f"to {base} failed ({type(e).__name__}), retrying " - f"({_reconnects}/{_dvr_max_reconnects}) " - f"in {_dvr_reconnect_delay}s..." + f"DVR recording {recording_id}: no HLS segments produced after " + f"{_first_segment_timeout}s from {base} — stream unavailable" ) - time.sleep(_dvr_reconnect_delay) - continue - last_error = str(e) - logger.warning( - f"DVR recording {recording_id}: base {base} exhausted " - f"retries ({_dvr_max_reconnects}): {e}" - ) - break - - except Exception as e: - last_error = str(e) - logger.warning(f"DVR recording {recording_id}: base {base} failed: {e}") - if bytes_written > 0: - should_exit, is_int, reason = _check_recording_cancelled(recording_id) - if should_exit and reason == "stopped_by_user": - interrupted = False - logger.info( - f"DVR recording {recording_id}: " - f"stopped by user — ending stream" - ) - else: - interrupted = True - interrupted_reason = f"stream_interrupted: {e}" - _done = True + ffmpeg_proc.kill() + try: + ffmpeg_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + last_error = f"no_segments_in_{_first_segment_timeout}s_from_{base}" + ffmpeg_proc = None break - should_exit, is_int, reason = _check_recording_cancelled(recording_id) - if should_exit: - interrupted = is_int - interrupted_reason = reason - _done = True + else: + # Stream was confirmed, detect source stalls (e.g. proxy ghost-kills the client) + if len(segs_now) > _last_seg_count: + _last_seg_count = len(segs_now) + _last_new_seg_time = now + else: + # Also treat a recently-modified segment file as activity. + # With erratic source timestamps, FFmpeg may buffer data inside + # a partially-written segment for longer than hls_time, so the + # segment count won't increase even though data is flowing. + try: + if segs_now and hls_dir: + _newest_mtime = max( + os.path.getmtime(os.path.join(hls_dir, f)) + for f in segs_now + ) + if _newest_mtime > _last_new_seg_time: + _last_new_seg_time = _newest_mtime + except Exception: + pass + if now - _last_new_seg_time > _stall_timeout: + logger.warning( + f"DVR recording {recording_id}: no new HLS segments for " + f"{_stall_timeout:.0f}s — source stream stalled, stopping FFmpeg" + ) + ffmpeg_proc.send_signal(signal.SIGINT) + try: + ffmpeg_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + ffmpeg_proc.kill() + break + + # Duration check — SIGINT lets FFmpeg write #EXT-X-ENDLIST cleanly + if now >= end_timestamp: + logger.info( + f"DVR recording {recording_id}: scheduled end time reached, stopping FFmpeg" + ) + ffmpeg_proc.send_signal(signal.SIGINT) + try: + ffmpeg_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + ffmpeg_proc.kill() break - if _done: - break + # Periodic DB poll: stop, delete, end_time extension + if now - _last_stop_poll >= _stop_poll_interval: + _last_stop_poll = now + try: + _sc = Recording.objects.filter( + id=recording_id + ).only("custom_properties", "end_time").first() + if _sc is None: + logger.info( + f"DVR recording {recording_id}: deleted — stopping FFmpeg" + ) + ffmpeg_proc.send_signal(signal.SIGINT) + try: + ffmpeg_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + ffmpeg_proc.kill() + interrupted = False + break + if (_sc.custom_properties or {}).get("status") == "stopped": + logger.info( + f"DVR recording {recording_id}: stop requested — stopping FFmpeg" + ) + ffmpeg_proc.send_signal(signal.SIGINT) + try: + ffmpeg_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + ffmpeg_proc.kill() + break + # Handle end_time extension — just update the deadline, no restart needed + try: + new_end = _sc.end_time + if new_end is not None: + if _tz.is_naive(new_end): + new_end = _tz.make_aware(new_end) + new_ts = new_end.timestamp() + if new_ts > end_timestamp: + logger.info( + f"DVR recording {recording_id}: end_time extended to {new_end}" + ) + end_timestamp = new_ts + except Exception: + pass + except Exception: + pass - if chosen_base is None and bytes_written == 0: + # If FFmpeg exited after confirming the stream, log the exit code so we know why it stopped. + if _stream_confirmed and ffmpeg_proc is not None: + _exit_code = ffmpeg_proc.poll() + if _exit_code is not None and _exit_code != 0: + logger.warning( + f"DVR recording {recording_id}: FFmpeg exited unexpectedly (rc={_exit_code}) " + f"after stream was confirmed — source stream likely disconnected" + ) + + # If FFmpeg exited without the stream being confirmed, log the tail of + # captured stderr for diagnosis. Lines were already logged live by the + # drain thread; this surfaces the recent context in a single message. + elif not _stream_confirmed and ffmpeg_proc is not None: + try: + _exit_code = ffmpeg_proc.poll() + last_error = f"rc={_exit_code} from {base}" + _tail_text = "\n".join(_ffmpeg_stderr_tail) + if _tail_text: + logger.warning( + f"DVR recording {recording_id}: FFmpeg exited (rc={_exit_code}) " + f"for {base} without producing segments.\nFFmpeg stderr tail:\n{_tail_text[-1000:]}" + ) + else: + logger.warning( + f"DVR recording {recording_id}: FFmpeg exited (rc={_exit_code}) " + f"for {base} without producing segments (no stderr output)" + ) + except Exception: + pass + + # Ensure FFmpeg has fully exited (covers cases where the loop broke early) + if ffmpeg_proc is not None and ffmpeg_proc.poll() is None: + try: + ffmpeg_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + ffmpeg_proc.kill() + + if not _stream_confirmed and not interrupted: interrupted = True - interrupted_reason = f"no_stream_data: {last_error or 'all_bases_failed'}" + interrupted_reason = f"no_stream_data: {last_error or 'ffmpeg_failed'}" - # If no bytes were written at all, check whether this was a deliberate stop or a - # genuine failure. The exception handler above already sets interrupted=False when - # it detects "stopped" status, but do not override that decision here. + # Measure bytes from HLS segment files + try: + bytes_written = sum( + os.path.getsize(os.path.join(hls_dir, f)) + for f in os.listdir(hls_dir) + if f.startswith("seg_") and f.endswith(".ts") + ) if hls_dir and os.path.isdir(hls_dir) else 0 + except Exception: + bytes_written = 0 if bytes_written == 0 and not interrupted: _deliberately_stopped = False try: @@ -2178,19 +2266,19 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # After the loop, the file and response are closed automatically. logger.info(f"Finished recording for channel {channel.name}") - # Log system event for recording end - try: - from core.utils import log_system_event - log_system_event( - 'recording_end', - channel_id=channel.uuid, - channel_name=channel.name, - recording_id=recording_id, - interrupted=interrupted, - bytes_written=bytes_written - ) - except Exception as e: - logger.error(f"Could not log recording end event: {e}") + # Log system event for recording end + try: + from core.utils import log_system_event + log_system_event( + 'recording_end', + channel_id=channel.uuid, + channel_name=channel.name, + recording_id=recording_id, + interrupted=interrupted, + bytes_written=bytes_written + ) + except Exception as e: + logger.error(f"Could not log recording end event: {e}") # If the Recording was deleted (cancelled by user), skip post-processing recording_cancelled = not Recording.objects.filter(id=recording_id).exists() @@ -2199,261 +2287,164 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # Clean up all artifacts for the cancelled recording, # including any pre-restart .ts segments from server recovery. # Use the in-memory recording_obj since the DB row is already deleted. - _cancel_cleanup = [temp_ts_path, final_path] - _cancel_cp = (recording_obj.custom_properties or {}) if recording_obj else {} - _cancel_cleanup.extend(_cancel_cp.get("_pre_restart_ts_paths", [])) - for _cleanup_path in _cancel_cleanup: - if not _cleanup_path: - continue + import shutil as _shutil + if hls_dir and os.path.isdir(hls_dir): try: - os.remove(_cleanup_path) - logger.info(f"Cleaned up cancelled recording artifact: {_cleanup_path}") - except FileNotFoundError: - pass + _shutil.rmtree(hls_dir) + logger.info(f"Cleaned up cancelled recording HLS directory: {hls_dir}") + except Exception as _e: + logger.warning(f"Failed to remove HLS directory {hls_dir}: {_e}") + if final_path: + try: + if os.path.exists(final_path): + os.remove(final_path) + logger.info(f"Cleaned up cancelled recording MKV placeholder: {final_path}") except Exception: pass return - # Concatenate pre-restart .ts segments with the current segment. - # Instead of creating an intermediate combined.ts and then remuxing to - # MKV (which loses timestamp boundary info and causes playback freezes - # at the splice point), go directly from the concat list → MKV. - # This lets ffmpeg's MKV muxer see each segment boundary and write - # correct cue points / clusters for seamless seeking. - _concat_did_remux = False - try: - _rec_obj_for_concat = Recording.objects.filter(id=recording_id).only("custom_properties").first() - _concat_cp = (_rec_obj_for_concat.custom_properties or {}) if _rec_obj_for_concat else {} - pre_restart_segments = _concat_cp.get("_pre_restart_ts_paths", []) - # Filter to segments that still exist on disk and have data - def _has_data(p): - try: - return os.stat(p).st_size > 0 - except OSError: - return False - pre_restart_segments = [p for p in pre_restart_segments if p and _has_data(p)] - if pre_restart_segments and temp_ts_path and os.path.exists(temp_ts_path): - all_segments = pre_restart_segments + [temp_ts_path] - concat_list_path = temp_ts_path + ".concat.txt" - try: - with open(concat_list_path, "w") as cl: - for seg in all_segments: - cl.write(f"file '{seg}'\n") + # --- Post-processing: concat HLS segments → final MKV --- + remux_success = False + hls_m3u8 = os.path.join(hls_dir, "index.m3u8") if hls_dir else None - # Direct concat → MKV in a single pass. - # -reset_timestamps 1 tells the concat demuxer to reset - # timestamps at each segment boundary, eliminating the - # discontinuity that causes playback to freeze at the - # splice point. - concat_result = subprocess.run( - [ - "ffmpeg", "-y", - "-fflags", "+genpts+igndts+discardcorrupt", - "-err_detect", "ignore_err", - "-f", "concat", "-safe", "0", - "-segment_time_metadata", "1", - "-i", concat_list_path, - "-reset_timestamps", "1", - "-map", "0", - "-c", "copy", - final_path, - ], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - ) - if concat_result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: - _concat_did_remux = True - # Clean up individual TS segments (including current) - for seg in all_segments: - try: - os.remove(seg) - except OSError: - pass - logger.info( - f"DVR recording {recording_id}: concat→MKV succeeded — " - f"{len(all_segments)} segments → {os.path.basename(final_path)} " - f"({os.path.getsize(final_path):,} bytes)" - ) - else: - logger.warning( - f"DVR recording {recording_id}: direct concat→MKV failed " - f"(rc={concat_result.returncode}), falling back to " - f"normal remux with current segment only. " - f"stderr: {(concat_result.stderr or '')[:500]}" - ) - finally: - try: - os.remove(concat_list_path) - except OSError: - pass - # Clear the pre-restart paths from custom_properties - if _rec_obj_for_concat: - _ccp = _rec_obj_for_concat.custom_properties or {} - _ccp.pop("_pre_restart_ts_paths", None) - _ccp.pop("interrupted_reason", None) - _rec_obj_for_concat.custom_properties = _ccp - _rec_obj_for_concat.save(update_fields=["custom_properties"]) - except Exception as e: - logger.warning( - f"DVR recording {recording_id}: segment concatenation error " - f"({type(e).__name__}: {e}), proceeding with current segment only." - ) - - # Remux TS to MKV container with retry for transient I/O errors - # (Skip if concat already produced the final MKV directly.) - remux_success = _concat_did_remux - existing_mkv_size = 0 - try: - if final_path and os.path.exists(final_path): - existing_mkv_size = os.path.getsize(final_path) - except OSError: - pass - for _remux_attempt in range(_dvr_remux_max_retries): - if remux_success: - break + def _get_hls_segments(m3u8_path, seg_dir): + """Return ordered segment paths from an HLS m3u8 playlist.""" + segs = [] try: - if temp_ts_path and os.path.exists(temp_ts_path): - # First attempt: Direct TS to MKV remux - result = subprocess.run([ + with open(m3u8_path) as _f: + for _line in _f: + _line = _line.strip() + if _line and not _line.startswith('#'): + sp = os.path.join(seg_dir, _line) if not os.path.isabs(_line) else _line + if os.path.exists(sp): + segs.append(sp) + except Exception as _e: + logger.warning(f"DVR recording {recording_id}: failed to parse m3u8: {_e}") + return segs + + segments = ( + _get_hls_segments(hls_m3u8, hls_dir) + if (hls_m3u8 and os.path.exists(hls_m3u8)) + else [] + ) + if not segments and hls_dir and os.path.isdir(hls_dir): + # Fallback: sort all segment files by name if m3u8 is missing or empty + try: + segments = sorted( + os.path.join(hls_dir, f) + for f in os.listdir(hls_dir) + if f.startswith("seg_") and f.endswith(".ts") + ) + except Exception: + segments = [] + + if segments: + concat_list_path = os.path.join(hls_dir, "concat.txt") + try: + with open(concat_list_path, "w") as _cl: + for seg in segments: + _cl.write(f"file '{seg}'\n") + + concat_result = subprocess.run( + [ "ffmpeg", "-y", - "-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS - "-err_detect", "ignore_err", # Ignore minor stream errors - "-i", temp_ts_path, - "-map", "0", # Map all streams + "-f", "concat", "-safe", "0", + "-i", concat_list_path, "-c", "copy", - final_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - # Check if FFmpeg succeeded (return code 0) and output file is valid - if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: - remux_success = True - logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}") - else: - # Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps - logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV") - - # Clean up partial/failed MKV - try: - if os.path.exists(final_path): - os.remove(final_path) - except Exception: - pass - - # Step 1: TS → MP4 (MP4 container handles broken timestamps better) - temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4" - result_mp4 = subprocess.run([ - "ffmpeg", "-y", - "-fflags", "+genpts+igndts+discardcorrupt", - "-err_detect", "ignore_err", - "-i", temp_ts_path, - "-map", "0", - "-c", "copy", - temp_mp4_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0: - logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV") - - # Step 2: MP4 → MKV (clean timestamps from MP4) - result_mkv = subprocess.run([ - "ffmpeg", "-y", - "-i", temp_mp4_path, - "-map", "0", - "-c", "copy", - final_path - ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: - remux_success = True - logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}") - else: - logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})") - - # Clean up temp MP4 - try: - if os.path.exists(temp_mp4_path): - os.remove(temp_mp4_path) - except Exception: - pass - else: - logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})") - - # Sanity-check the remuxed file. Two checks: - # 1. If a pre-existing MKV was overwritten, reject a - # file that is drastically smaller (duplicate-task - # overwrite protection). - # 2. If the MKV is smaller than the .ts source, the - # remux likely produced a corrupt or truncated file. - if remux_success: - try: - new_size = os.path.getsize(final_path) - ts_size = os.path.getsize(temp_ts_path) if temp_ts_path and os.path.exists(temp_ts_path) else 0 - reject = False - if existing_mkv_size > 0 and new_size < existing_mkv_size * 0.5: - logger.error( - f"DVR recording {recording_id}: new MKV " - f"({new_size:,} bytes) is less than 50%% of " - f"the previous MKV ({existing_mkv_size:,} bytes) " - f"— refusing to overwrite. Keeping .ts for " - f"manual recovery." - ) - reject = True - elif ts_size > 0 and new_size < ts_size * 0.1: - logger.error( - f"DVR recording {recording_id}: remuxed MKV " - f"({new_size:,} bytes) is less than 10%% of " - f"the source TS ({ts_size:,} bytes) — likely " - f"corrupt. Keeping .ts for manual recovery." - ) - reject = True - if reject: - remux_success = False - try: - os.remove(final_path) - except OSError: - pass - except OSError: - pass - - # Clean up temp TS file only on successful remux - if remux_success: - try: - os.remove(temp_ts_path) - logger.debug(f"Cleaned up temp TS file: {temp_ts_path}") - except Exception as e: - logger.warning(f"Failed to remove temp TS file: {e}") - else: - # Keep TS file for debugging/manual recovery if remux failed - logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}") - # Clean up any partial MKV - try: - if os.path.exists(final_path): - os.remove(final_path) - logger.debug(f"Cleaned up partial MKV file: {final_path}") - except Exception: - pass - break # Completed (success or deterministic failure) - - except (OSError, subprocess.SubprocessError) as e: - # Clean up partial output before potential retry - try: - if os.path.exists(final_path): - os.remove(final_path) - except Exception: - pass - if _remux_attempt + 1 < _dvr_remux_max_retries: - _wait = _dvr_remux_retry_interval * (2 ** _remux_attempt) - logger.warning( - f"DVR recording {recording_id}: remux failed " - f"({type(e).__name__}), retrying in {_wait}s " - f"({_remux_attempt + 1}/{_dvr_remux_max_retries})..." + final_path, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if ( + concat_result.returncode == 0 + and os.path.exists(final_path) + and os.path.getsize(final_path) > 0 + ): + remux_success = True + logger.info( + f"DVR recording {recording_id}: HLS→MKV concat succeeded — " + f"{len(segments)} segments → {os.path.basename(final_path)} " + f"({os.path.getsize(final_path):,} bytes)" ) - time.sleep(_wait) + # Update DB so *new* client requests go to /file/ (the final MKV) + # rather than the soon-to-be-removed /hls/ endpoint. Important: + # we do NOT clear _hls_dir here, active viewers still in the + # 20s heartbeat window need it to keep fetching .ts segments + # during the viewer-wait grace period below. _hls_dir is + # cleared only after the directory is actually removed. + try: + _pre_rmtree_rec = Recording.objects.filter(id=recording_id).first() + if _pre_rmtree_rec: + _pre_rmtree_cp = _pre_rmtree_rec.custom_properties or {} + _pre_rmtree_cp["file_url"] = f"/api/channels/recordings/{recording_id}/file/" + _pre_rmtree_cp["output_file_url"] = _pre_rmtree_cp["file_url"] + _pre_rmtree_rec.custom_properties = _pre_rmtree_cp + _pre_rmtree_rec.save(update_fields=["custom_properties"]) + except Exception as _pre_e: + logger.warning(f"DVR recording {recording_id}: pre-rmtree DB update failed: {_pre_e}") + import shutil as _shutil + # Wait for active HLS viewers to finish downloading segments + # before deleting the directory. The hls endpoint refreshes + # _hls_viewer_key on every .ts request with a 20s TTL, so the + # key expiring naturally means no segment was fetched in the + # last 20 seconds (client has stopped). We loop until the key + # is gone with a 4-hour hard safety cap in case Redis gets stuck. + _viewer_wait_start = time.time() + _safety_timeout = 14400 # 4 hours absolute maximum + try: + from core.utils import RedisClient as _RC + _rv = _RC.get_client(max_retries=1, retry_interval=0) + if _rv and _rv.exists(_hls_viewer_key): + logger.info( + f"DVR recording {recording_id}: active HLS viewer detected, " + f"deferring HLS directory cleanup until client disconnects" + ) + while _rv.exists(_hls_viewer_key): + if time.time() - _viewer_wait_start > _safety_timeout: + logger.warning( + f"DVR recording {recording_id}: viewer wait safety timeout " + f"({_safety_timeout}s) reached, proceeding with HLS directory cleanup" + ) + break + time.sleep(2) + logger.info( + f"DVR recording {recording_id}: viewer wait complete after " + f"{time.time() - _viewer_wait_start:.1f}s" + ) + except Exception as _ve: + logger.debug(f"DVR recording {recording_id}: viewer wait check failed: {_ve}") + try: + _shutil.rmtree(hls_dir) + logger.debug(f"Cleaned up HLS directory: {hls_dir}") + except Exception as _e: + logger.warning(f"Failed to remove HLS directory {hls_dir}: {_e}") + # Now that the HLS dir is gone, clear _hls_dir from custom_properties + # so the hls endpoint will redirect (.m3u8) or 404 (.ts) cleanly. + try: + _post_rmtree_rec = Recording.objects.filter(id=recording_id).first() + if _post_rmtree_rec: + _post_rmtree_cp = _post_rmtree_rec.custom_properties or {} + if "_hls_dir" in _post_rmtree_cp: + _post_rmtree_cp.pop("_hls_dir", None) + _post_rmtree_rec.custom_properties = _post_rmtree_cp + _post_rmtree_rec.save(update_fields=["custom_properties"]) + except Exception as _post_e: + logger.warning(f"DVR recording {recording_id}: post-rmtree DB update failed: {_post_e}") else: - logger.warning( - f"DVR recording {recording_id}: remux failed " - f"after {_dvr_remux_max_retries} attempts: {e}. " - f"Keeping .ts for manual recovery: {temp_ts_path}" + logger.error( + f"DVR recording {recording_id}: HLS→MKV concat failed " + f"(rc={concat_result.returncode}). Keeping HLS segments for recovery. " + f"stderr: {(concat_result.stderr or '')[:500]}" ) + except Exception as _ce: + logger.error(f"DVR recording {recording_id}: concat exception: {_ce}") + finally: + try: + os.remove(concat_list_path) + except OSError: + pass + else: + logger.warning(f"DVR recording {recording_id}: no HLS segments found. Nothing to concat") # Persist final metadata to Recording (status, ended_at, and stream stats if available) try: @@ -2465,6 +2456,11 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): cp = recording_obj.custom_properties or {} cp["ended_at"] = str(datetime.now()) + # Restore file_url to the permanent MKV endpoint now that recording has ended. + # During recording it pointed to /hls/index.m3u8; clients should use /file/ hereafter. + cp["file_url"] = f"/api/channels/recordings/{recording_id}/file/" + cp["output_file_url"] = cp["file_url"] + # Final status priority: stopped > completed > interrupted. # "stopped" is set by the stop endpoint before stream teardown, so # refresh_from_db() above guarantees it is visible here. @@ -2591,7 +2587,7 @@ def recover_recordings_on_startup(): from django.utils import timezone from .models import Recording from core.utils import RedisClient - from .signals import schedule_recording_task + from .signals import schedule_recording_task, revoke_task redis = RedisClient.get_client() if redis: @@ -2637,14 +2633,12 @@ def recover_recordings_on_startup(): # Preserve the pre-restart .ts segment path so run_recording # can concatenate it with the resumed segment later. - old_ts = cp.get("_temp_file_path") - if old_ts and os.path.exists(old_ts) and os.path.getsize(old_ts) > 0: - prior_segments = cp.get("_pre_restart_ts_paths", []) - prior_segments.append(old_ts) - cp["_pre_restart_ts_paths"] = prior_segments + hls_dir_path = cp.get("_hls_dir") + if hls_dir_path and os.path.isdir(hls_dir_path): + existing_segs = [f for f in os.listdir(hls_dir_path) if f.endswith(".ts")] logger.info( f"recover_recordings_on_startup: recording {rec.id} — " - f"preserving pre-restart TS segment: {old_ts}" + f"HLS dir has {len(existing_segs)} existing segment(s), will resume" ) rec.custom_properties = cp @@ -2687,38 +2681,86 @@ def recover_recordings_on_startup(): for rec in expired: try: cp = rec.custom_properties or {} - ts_path = cp.get("_temp_file_path") + hls_dir_path = cp.get("_hls_dir") mkv_path = cp.get("file_path") - if ts_path and os.path.exists(ts_path) and os.path.getsize(ts_path) > 0 and mkv_path: - logger.info( - f"recover_recordings_on_startup: recording {rec.id} expired " - f"during downtime — remuxing partial TS ({os.path.getsize(ts_path):,} bytes)" - ) - os.makedirs(os.path.dirname(mkv_path), exist_ok=True) - result = subprocess.run( - [ - "ffmpeg", "-y", - "-fflags", "+genpts+igndts+discardcorrupt", - "-err_detect", "ignore_err", - "-i", ts_path, "-map", "0", "-c", "copy", mkv_path, - ], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - ) - if result.returncode == 0 and os.path.exists(mkv_path) and os.path.getsize(mkv_path) > 0: - cp["status"] = "interrupted" - cp["interrupted_reason"] = "server_restarted_after_end" - cp["remux_success"] = True + if hls_dir_path and os.path.isdir(hls_dir_path) and mkv_path: + import shutil as _shutil + # Parse m3u8 for ordered segment list; fall back to sorted filenames + _m3u8 = os.path.join(hls_dir_path, "index.m3u8") + _segs = [] + if os.path.exists(_m3u8): try: - os.remove(ts_path) - except OSError: + with open(_m3u8) as _f: + for _l in _f: + _l = _l.strip() + if _l and not _l.startswith('#'): + _sp = os.path.join(hls_dir_path, _l) if not os.path.isabs(_l) else _l + if os.path.exists(_sp): + _segs.append(_sp) + except Exception: pass - logger.info(f"recover_recordings_on_startup: recording {rec.id} remuxed successfully") + if not _segs: + _segs = sorted( + os.path.join(hls_dir_path, f) + for f in os.listdir(hls_dir_path) + if f.startswith("seg_") and f.endswith(".ts") + ) + + if _segs: + logger.info( + f"recover_recordings_on_startup: recording {rec.id} expired " + f"during downtime, concat {len(_segs)} HLS segment(s) \u2192 MKV" + ) + os.makedirs(os.path.dirname(mkv_path), exist_ok=True) + _concat_txt = os.path.join(hls_dir_path, "concat.txt") + try: + with open(_concat_txt, "w") as _cl: + for _s in _segs: + _cl.write(f"file '{_s}'\n") + _res = subprocess.run( + [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", + "-i", _concat_txt, + "-c", "copy", mkv_path, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if _res.returncode == 0 and os.path.exists(mkv_path) and os.path.getsize(mkv_path) > 0: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = True + try: + _shutil.rmtree(hls_dir_path) + except OSError: + pass + logger.info( + f"recover_recordings_on_startup: recording {rec.id} HLS\u2192MKV concat succeeded" + ) + else: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = False + logger.warning( + f"recover_recordings_on_startup: recording {rec.id} concat failed, keeping HLS dir" + ) + except Exception as _ce: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = False + logger.warning( + f"recover_recordings_on_startup: recording {rec.id} concat error: {_ce}" + ) + finally: + try: + os.remove(_concat_txt) + except OSError: + pass else: cp["status"] = "interrupted" cp["interrupted_reason"] = "server_restarted_after_end" cp["remux_success"] = False - logger.warning(f"recover_recordings_on_startup: recording {rec.id} remux failed, keeping .ts") else: cp["status"] = "interrupted" cp["interrupted_reason"] = "server_restarted_after_end" diff --git a/apps/channels/tests/test_dvr_port_resolution.py b/apps/channels/tests/test_dvr_port_resolution.py index c7373959..61f74454 100644 --- a/apps/channels/tests/test_dvr_port_resolution.py +++ b/apps/channels/tests/test_dvr_port_resolution.py @@ -2,58 +2,68 @@ import os from django.test import SimpleTestCase from unittest.mock import patch -from apps.channels.tasks import build_dvr_candidates +from apps.channels.tasks import get_dvr_stream_base_url -class DVRPortResolutionTests(SimpleTestCase): +class DVRStreamBaseURLTests(SimpleTestCase): """ - Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT - environment variable instead of hardcoding port 9191. + Tests that get_dvr_stream_base_url() returns the correct single URL + for each deployment mode. """ - @patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True) - def test_default_port_uses_9191(self): - """Without DISPATCHARR_PORT set, candidates default to 9191.""" - candidates = build_dvr_candidates() - self.assertIn('http://web:9191', candidates) - self.assertIn('http://localhost:9191', candidates) + @patch.dict(os.environ, {}, clear=True) + def test_aio_default_uses_localhost_5656(self): + """AIO mode (default) reaches uwsgi directly on loopback port 5656.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://127.0.0.1:5656') - @patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True) - def test_custom_port_reflected_in_candidates(self): - """DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references.""" - candidates = build_dvr_candidates() - self.assertIn('http://web:8080', candidates) - self.assertIn('http://localhost:8080', candidates) - self.assertNotIn('http://web:9191', candidates) - self.assertNotIn('http://localhost:9191', candidates) + @patch.dict(os.environ, {'DISPATCHARR_ENV': 'aio'}, clear=True) + def test_aio_explicit_uses_localhost_5656(self): + """Explicit DISPATCHARR_ENV=aio also uses loopback port 5656.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://127.0.0.1:5656') + + @patch.dict(os.environ, {'DISPATCHARR_ENV': 'dev'}, clear=True) + def test_dev_mode_uses_localhost_5656(self): + """Dev mode shares the container with uwsgi — uses loopback port 5656.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://127.0.0.1:5656') + + @patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular', 'DISPATCHARR_PORT': '9191'}, clear=True) + def test_modular_uses_web_service_name(self): + """Modular mode uses the 'web' Docker service name by default.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://web:9191') + + @patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular', 'DISPATCHARR_PORT': '8080'}, clear=True) + def test_modular_custom_port(self): + """Modular mode respects DISPATCHARR_PORT.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://web:8080') @patch.dict(os.environ, { - 'DISPATCHARR_PORT': '7777', - 'DISPATCHARR_ENV': 'dev', - 'REDIS_HOST': 'redis', + 'DISPATCHARR_ENV': 'modular', + 'DISPATCHARR_PORT': '9191', + 'DISPATCHARR_WEB_HOST': 'dispatcharr_web', }, clear=True) - def test_dev_mode_includes_5656_and_custom_port(self): - """Dev mode includes both uwsgi internal port (5656) and custom port.""" - candidates = build_dvr_candidates() - self.assertIn('http://127.0.0.1:5656', candidates) - self.assertIn('http://127.0.0.1:7777', candidates) + def test_modular_custom_web_host(self): + """DISPATCHARR_WEB_HOST overrides the default 'web' service name.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://dispatcharr_web:9191') @patch.dict(os.environ, { 'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234', - 'REDIS_HOST': 'redis', + 'DISPATCHARR_ENV': 'modular', }, clear=True) - def test_explicit_override_is_first(self): - """DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate.""" - candidates = build_dvr_candidates() - self.assertEqual(candidates[0], 'http://custom:1234') + def test_explicit_override_always_wins(self): + """DISPATCHARR_INTERNAL_TS_BASE_URL takes priority over all other settings.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://custom:1234') @patch.dict(os.environ, { - 'DISPATCHARR_PORT': '3000', - 'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000', - 'REDIS_HOST': 'redis', + 'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234/', }, clear=True) - def test_internal_api_base_overrides_web_fallback(self): - """DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default.""" - candidates = build_dvr_candidates() - self.assertIn('http://myhost:4000', candidates) - self.assertNotIn('http://web:3000', candidates) + def test_explicit_override_strips_trailing_slash(self): + """Trailing slash is stripped from DISPATCHARR_INTERNAL_TS_BASE_URL.""" + url = get_dvr_stream_base_url() + self.assertEqual(url, 'http://custom:1234') diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 547ac249..58b6e4f5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -118,8 +118,12 @@ services: - DISPATCHARR_ENV=modular # Internal Service Communication - # Must match the web service port for DVR recording and internal API calls + # Celery uses these to reach the web container for DVR recording. + # DISPATCHARR_PORT must match the port exposed by the web service. + # DISPATCHARR_WEB_HOST defaults to "web" (the service name above). + # Only set DISPATCHARR_WEB_HOST if you rename the web service in this file. - DISPATCHARR_PORT=9191 + #- DISPATCHARR_WEB_HOST=web # PostgreSQL — must match web service settings - POSTGRES_HOST=db From c9566725870100570059d7f3c91cf06d43a230ae Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 09:34:20 -0500 Subject: [PATCH 37/63] Enhancement: Route long-running DVR recordings to a dedicated queue. --- dispatcharr/celery.py | 5 +++++ docker/entrypoint.celery.sh | 6 +++++- docker/uwsgi.debug.ini | 7 ++++--- docker/uwsgi.dev.ini | 7 ++++--- docker/uwsgi.ini | 7 ++++--- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 8ccb3c33..0d63f063 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -49,6 +49,11 @@ app.conf.update( worker_task_log_format='%(asctime)s %(levelname)s %(task_name)s: %(message)s', ) +# Route long-running DVR recordings to a dedicated `dvr` queue consumed by a thread-pool worker. +app.conf.task_routes = { + 'apps.channels.tasks.run_recording': {'queue': 'dvr'}, +} + # Add memory cleanup after task completion @task_postrun.connect # Use the imported signal def cleanup_task_memory(**kwargs): diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh index b38b6ac3..c795d781 100644 --- a/docker/entrypoint.celery.sh +++ b/docker/entrypoint.celery.sh @@ -67,4 +67,8 @@ NICE_LEVEL="${CELERY_NICE_LEVEL:-5}" if [ "$NICE_LEVEL" -lt 0 ] 2>/dev/null; then echo "Warning: CELERY_NICE_LEVEL=$NICE_LEVEL is negative, requires SYS_NICE capability" fi -nice -n "$NICE_LEVEL" celery -A dispatcharr worker -l info --autoscale=6,1 + +# DVR worker: thread pool for the long-running, I/O-bound run_recording task. +nice -n "$NICE_LEVEL" celery -A dispatcharr worker -Q dvr -n dvr@%h --pool=threads --concurrency=20 -l info & +# Default prefork worker: every queue except `dvr`. +nice -n "$NICE_LEVEL" celery -A dispatcharr worker -Q celery -n default@%h --autoscale=6,1 -l info diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini index d2847335..bc21275d 100644 --- a/docker/uwsgi.debug.ini +++ b/docker/uwsgi.debug.ini @@ -7,9 +7,10 @@ exec-before = python /app/scripts/wait_for_redis.py ; Start Redis first attach-daemon = redis-server -; Then start other services with configurable nice level (default: 5 for low priority) -; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose -attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1 +; Default prefork worker: every queue except `dvr`. +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q celery -n default@%%h --autoscale=6,1 +; DVR worker: thread pool for the long-running, I/O-bound run_recording task. +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q dvr -n dvr@%%h --pool=threads --concurrency=20 attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application attach-daemon = cd /app/frontend && npm run dev diff --git a/docker/uwsgi.dev.ini b/docker/uwsgi.dev.ini index 51aae9a5..dd799500 100644 --- a/docker/uwsgi.dev.ini +++ b/docker/uwsgi.dev.ini @@ -9,9 +9,10 @@ exec-pre = python /app/scripts/wait_for_redis.py ; Start Redis first attach-daemon = redis-server --protected-mode no -; Then start other services with configurable nice level (default: 5 for low priority) -; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose -attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1 +; Default prefork worker: every queue except `dvr`. +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q celery -n default@%%h --autoscale=6,1 +; DVR worker: thread pool for the long-running, I/O-bound run_recording task. +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q dvr -n dvr@%%h --pool=threads --concurrency=20 attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application attach-daemon = cd /app/frontend && npm run dev diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index 920bac48..3972c3d9 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -9,9 +9,10 @@ exec-pre = python /app/scripts/wait_for_redis.py ; Start Redis first attach-daemon = redis-server -; Then start other services with configurable nice level (default: 5 for low priority) -; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose -attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1 +; Default prefork worker: every queue except `dvr`. +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q celery -n default@%%h --autoscale=6,1 +; DVR worker: thread pool for the long-running, I/O-bound run_recording task. +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker -Q dvr -n dvr@%%h --pool=threads --concurrency=20 attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application From 76f2f2abc49df6d8eb13d59ff2159ed9f8ba5248 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 10:21:40 -0500 Subject: [PATCH 38/63] changelog: Update changelog for DVR enahncements --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c590bd..83cf0f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Watch DVR recordings live while they are still recording**: `run_recording` has been refactored from a single TS file capture to an FFmpeg HLS segmentation pipeline. While recording is in progress, the `file_url` exposed on the recording points to a new `/api/channels/recordings/{id}/hls/index.m3u8` endpoint instead of the eventual MKV path, so any HLS-capable client (the built-in web player, VLC, infuse, channels DVR clients, etc.) can join the stream at any time and watch from the live edge. When the recording ends, the segments are concatenated into the final MKV, `file_url` is updated to point to the existing `/file/` endpoint, and the HLS working directory is cleaned up. Hitting `/file/` while a recording is still in progress now redirects to the HLS playlist rather than 404, and hitting `/hls/index.m3u8` after the recording has finalized redirects to `/file/`, so URLs cached by clients in either form continue to work across the transition. +- New HLS playback endpoint (`RecordingViewSet.hls`) serves `.m3u8` and `.ts` files out of the recording's working directory with path traversal protection (`os.path.realpath` containment check) and `AllowAny` permissions consistent with the existing `/file/` endpoint. Playlist files are rewritten on the fly so each segment line becomes an absolute URL routed back through this endpoint, preserving authentication and path isolation. +- Explicit `path('recordings/<int:pk>/hls/<path:seg_path>', ...)` route registered in `apps/channels/api_urls.py` _before_ `router.urls`. DRF's `DefaultRouter` appends a mandatory trailing slash to every URL it generates, but HLS players request segments by their natural filenames (`seg_00001.ts`) without a trailing slash, so the explicit route is required for the player to ever reach the view. +- `DISPATCHARR_WEB_HOST` environment variable for modular deployments. The Celery container needs to reach the uWSGI / web container to fetch HLS segments while recording (FFmpeg pulls from the public stream URL, the same way any other client does). `get_dvr_stream_base_url()` resolves the base URL deterministically: AIO / dev / debug containers reach uWSGI on `127.0.0.1:$DISPATCHARR_PORT` since Celery and uWSGI share the container, while modular deployments use `http://$DISPATCHARR_WEB_HOST:$DISPATCHARR_PORT` and default `DISPATCHARR_WEB_HOST` to `web` (the compose service name). Documented as a commented-out override in `docker/docker-compose.yml`. +- HLS viewer heartbeat. Every `.ts` request refreshes a Redis key `dvr:hls_viewer:{id}` with a 20 second TTL. After concat succeeds, the recording task waits for that key to expire naturally before deleting the HLS working directory, so an actively-watching client is never cut off mid-segment when the recording ends. Cleanup happens within 20 seconds of the last client stopping, with a 4 hour safety cap as a guard against a stuck Redis state. - **VOD start/stop notifications**: the frontend now shows a toast notification when a VOD stream starts or stops. `vod_started` and `vod_stopped` WebSocket events are fired from the backend when a new provider connection is opened or the last active stream on a session ends. The 1-second delayed-cleanup window is used as a settle period before firing `vod_stopped`, so seek reconnects that re-establish `active_streams` within that window suppress the notification. A `vod_stats` WebSocket push is sent alongside every event to keep the Stats page connection table in sync in real time. `vod_start` and `vod_stop` system events are also written to the system event log for each transition, and are now selectable as integration trigger events (webhook/script) in the Connect page. - **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) @@ -42,9 +47,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Eliminated per-tick DB queries from the channel stats system. Previously `get_basic_channel_info()` in `channel_status.py` issued a `Stream.objects.filter()` and an `M3UAccountProfile.objects.filter()` on every stats tick for each active channel to resolve display names. Channel name and stream name are now written into the Redis metadata hash at channel-init time (via `initialize_channel`) and read back directly during stats collection, zero DB queries per tick. `m3u_profile_name` is now resolved on the frontend from the already-loaded playlists store rather than being pushed from the backend. - Eliminated a redundant `Channel` DB lookup inside `ChannelService.initialize_channel()`. `views.py` already fetches the `Channel` object via `get_stream_object()` before calling `initialize_channel()`; `channel.name` is now passed as an optional `channel_name` parameter, so the service uses the caller-supplied value and only falls back to a DB query when the name is not provided (e.g. stream-preview paths). A matching `stream_name` parameter was added for the same reason; the `stream_name` DB query is skipped entirely on normal channel start since a channel name is always available. - Eliminated a redundant `Stream` DB lookup on stream switches. `get_stream_info_for_switch()` already fetches the `Stream` object to build the URL; it now includes `stream_name` in its return dict. `change_stream_url()` captures that value and passes it through to `_update_channel_metadata()`, which skips its own `Stream.objects.filter()` when the name is already known. +- **Dedicated thread-pool Celery worker for DVR recordings**. `run_recording` is long-running and almost entirely I/O-bound: it loops in short ticks polling FFmpeg, the DB, and Redis for the full duration of the recording. Running it on the default prefork worker pool (`--autoscale=6,1`) meant at most 6 concurrent recordings, and only if no other background work was running. M3U refreshes, EPG parsing, channel matching, comskip, etc. all competed for the same 6 slots, so if every worker was busy when a recording's start time arrived, the task would queue in Redis and FFmpeg would not start until a worker freed up, causing the user to miss the beginning of the show. + - A second Celery worker is now started alongside the default one, configured with `--pool=threads --concurrency=20` and bound to a dedicated `dvr` queue. Threads fit this workload because every blocking call in `run_recording` releases the GIL (sleep, subprocess wait, file / DB / Redis I/O) and FFmpeg itself runs in a separate OS process. A `task_routes` entry in `dispatcharr/celery.py` routes `apps.channels.tasks.run_recording` to the `dvr` queue regardless of how it is dispatched (Celery Beat `PeriodicTask`, `.delay()`, etc.). The default prefork worker now subscribes only to the `celery` queue (`-Q celery -n default@%h --autoscale=6,1`), so recordings can no longer starve background tasks and background tasks can no longer delay recordings. + + - Net result: up to 20 concurrent recordings, zero startup delay regardless of EPG / M3U refresh activity, and a memory cost of roughly 80 to 120 MB for the second always-on worker process. Each additional concurrent recording costs nearly nothing on top because all 20 threads share that single process. Applied to AIO (`docker/uwsgi.ini`), dev (`docker/uwsgi.dev.ini`), debug (`docker/uwsgi.debug.ini`), and the modular celery container (`docker/entrypoint.celery.sh`). ### Changed +- DVR recording pipeline rewritten around FFmpeg + HLS. The previous implementation wrote a single growing `.ts` file via the proxy and remuxed it to `.mkv` at the end; the new implementation runs FFmpeg with `-f hls`, regenerates monotonic PTS to handle erratic IPTV source timestamps, shifts output timestamps so they start at 0 (fixing negative-PTS streams that prevented HLS segment boundary detection), and concatenates the resulting segments into the final MKV at the end of the recording. Server-restart resume now continues segment numbering from the previous session rather than overwriting earlier segments. +- Stall detection added to the recording loop. Once the first segment confirms the stream is flowing, the loop watches both segment count and segment file mtime. If neither advances for the configured stall window (e.g. when an upstream proxy ghost-kills the client), FFmpeg is signaled and the recording ends cleanly rather than hanging until `end_time`. Recording duration is honored via SIGINT so FFmpeg writes `#EXT-X-ENDLIST` cleanly into the final playlist. +- `end_time` extension is now picked up by the running recording without restarting FFmpeg. The DB poll loop simply updates the in-memory deadline. +- Recording cancellation cleanup updated for the new layout. `RecordingViewSet.destroy()` now removes the HLS working directory via a `_safe_rmtree` helper (with the same `allowed_roots` containment check used for the existing `_safe_remove`) instead of trying to delete the obsolete `_temp_file_path`. +- HLS working directory lifecycle in `custom_properties` is now two-phase. After concat succeeds, only `file_url` and `output_file_url` are updated so new client requests are redirected to the final MKV via `/file/`; `_hls_dir` is intentionally preserved through the viewer-wait grace period so in-flight `.ts` requests keep resolving. `_hls_dir` is cleared from `custom_properties` only after `shutil.rmtree` actually removes the directory. - **Column resizing in `CustomTable`**: column widths are now propagated to body cells via CSS custom properties (`--header-{id}-size`) injected on the table wrapper, rather than reading `column.getSize()` directly in each cell's React style. This decouples body-cell widths from React renders so that memoized rows (which skip re-renders for performance) still reflect resize changes instantly via CSS cascade. - Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state. - Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows. From 914c612db98528bab71dfaf7578a1bbc20a3df0d Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 11:40:46 -0500 Subject: [PATCH 39/63] Enhancement: Implement network access checks for streaming and update recording status handling during worker shutdown --- apps/channels/api_views.py | 7 +- apps/channels/tasks.py | 208 ++++++++++++++++-- .../src/components/cards/RecordingCard.jsx | 9 +- .../forms/RecordingDetailsModal.jsx | 3 +- 4 files changed, 198 insertions(+), 29 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 2907c48d..ae941af9 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -62,13 +62,14 @@ from rest_framework.filters import SearchFilter, OrderingFilter from apps.epg.models import EPGData from apps.vod.models import Movie, Series from django.db.models import Q -from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404 +from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404, JsonResponse from django.utils import timezone import mimetypes from django.conf import settings from rest_framework.pagination import PageNumberPagination +from dispatcharr.utils import network_access_allowed logger = logging.getLogger(__name__) @@ -2471,6 +2472,8 @@ class RecordingViewSet(viewsets.ModelViewSet): is still running (or the MKV is not yet produced), it is redirected to the HLS playlist endpoint. """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) recording = get_object_or_404(Recording, pk=pk) cp = recording.custom_properties or {} file_path = cp.get("file_path") @@ -2556,6 +2559,8 @@ class RecordingViewSet(viewsets.ModelViewSet): to route through this endpoint so authentication and path isolation are preserved. """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) recording = get_object_or_404(Recording, pk=pk) cp = recording.custom_properties or {} hls_dir = cp.get("_hls_dir") diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 67bec477..dd1fd3d8 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -15,6 +15,7 @@ from datetime import datetime, timedelta import gc from celery import shared_task +from celery.signals import worker_shutting_down from django.utils.text import slugify from rapidfuzz import fuzz @@ -32,6 +33,21 @@ from urllib.parse import quote logger = logging.getLogger(__name__) +# Flag set by the Celery `worker_shutting_down` signal so in-flight +# `run_recording` tasks can distinguish a graceful worker shutdown (e.g. +# `docker stop`, container update) from the natural end of a recording. +# When set, the post-FFmpeg path leaves the HLS working directory intact +# and marks the recording "interrupted" so `recover_recordings_on_startup` +# can resume it on the next boot via the existing path-reuse logic. +_DVR_SHUTTING_DOWN = False + + +@worker_shutting_down.connect +def _dvr_mark_shutting_down(**_kwargs): + global _DVR_SHUTTING_DOWN + _DVR_SHUTTING_DOWN = True + + _url_validation_cache = {} _URL_CACHE_TTL = 300 # seconds @@ -1586,9 +1602,14 @@ def _parse_epg_tv_movie_info(program): return is_movie, season, episode, year, sub_title -def _build_output_paths(channel, program, start_time, end_time): +def _build_output_paths(channel, program, start_time, end_time, recording_id): """ - Build (final_path, temp_ts_path, final_filename) using DVR templates. + Build (final_path, hls_dir, final_filename) using DVR templates. + + The HLS working directory is hidden and tagged with the recording id + (e.g. ``.dvr_71_hls``) so it cannot collide with another recording's + files and so orphan-cleanup utilities can identify internal scratch + directories unambiguously. """ from core.models import CoreSettings # Root for DVR recordings: fixed to /data/recordings inside the container @@ -1655,19 +1676,17 @@ def _build_output_paths(channel, program, start_time, end_time): final_path = rel_path if rel_path.startswith('/') else os.path.join(library_root, rel_path) final_path = os.path.normpath(final_path) - # Avoid overwriting an existing file from a different recording. - # Check the MKV file and its associated HLS working directory. + # Avoid overwriting an existing MKV from a different recording. The HLS + # working directory is keyed by recording id, so it cannot collide and is + # not part of this check. base, ext = os.path.splitext(final_path) counter = 1 while True: - candidate_base = final_path[:-len(ext)] # strip extension - hls_dir_candidate = candidate_base + '_hls' try: mkv_occupied = os.stat(final_path).st_size > 0 except OSError: mkv_occupied = False - hls_occupied = os.path.isdir(hls_dir_candidate) - if not mkv_occupied and not hls_occupied: + if not mkv_occupied: break counter += 1 final_path = f"{base}_{counter}{ext}" @@ -1675,9 +1694,8 @@ def _build_output_paths(channel, program, start_time, end_time): # Ensure directory exists os.makedirs(os.path.dirname(final_path), exist_ok=True) - # Derive HLS working directory alongside the final MKV - base_no_ext = os.path.splitext(os.path.basename(final_path))[0] - hls_dir = os.path.join(os.path.dirname(final_path), f"{base_no_ext}_hls") + # HLS working directory: hidden and id-tagged alongside the final MKV. + hls_dir = os.path.join(os.path.dirname(final_path), f".dvr_{recording_id}_hls") return final_path, hls_dir, os.path.basename(final_path) @@ -1821,7 +1839,37 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): program.update(epg_match) cp["program"] = program - final_path, hls_dir, filename = _build_output_paths(channel, program, start_time, end_time) + # Resume into the existing working set if this task is a recovery run. + # `recover_recordings_on_startup` re-dispatches `run_recording` for an + # interrupted recording with `_hls_dir` and `file_path` already set in + # custom_properties. Re-running `_build_output_paths` would allocate a + # fresh `Foo_2.mkv` / `.dvr_{id}_hls` pair and orphan the original + # segments, producing two MKVs at the end. Reuse the original paths + # whenever the HLS directory is still on disk. + existing_hls = cp.get("_hls_dir") + existing_file = cp.get("file_path") + if ( + existing_hls + and existing_file + and os.path.isdir(existing_hls) + ): + final_path = existing_file + hls_dir = existing_hls + filename = os.path.basename(final_path) + try: + _seg_count = sum( + 1 for f in os.listdir(hls_dir) if f.endswith(".ts") + ) + except OSError: + _seg_count = 0 + logger.info( + f"run_recording {recording_id}: resuming into existing HLS dir " + f"{hls_dir} ({_seg_count} segment(s)), final={final_path}" + ) + else: + final_path, hls_dir, filename = _build_output_paths( + channel, program, start_time, end_time, recording_id + ) cp["file_name"] = filename cp["file_path"] = final_path cp["_hls_dir"] = hls_dir @@ -2213,6 +2261,37 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): except subprocess.TimeoutExpired: ffmpeg_proc.kill() + # If the loop broke because the Celery worker is shutting down (e.g. + # docker stop, container update) and the recording window is still open, + # leave the HLS working directory intact and mark the recording as + # interrupted. `recover_recordings_on_startup` will re-dispatch this task + # on the next boot, and the resume-path logic in the prep block will + # adopt the existing `_hls_dir` and `file_path` so FFmpeg appends to the + # same playlist and the eventual concat produces a single MKV. + if _DVR_SHUTTING_DOWN and time.time() < end_timestamp: + try: + _shutdown_rec = Recording.objects.filter(id=recording_id).first() + if _shutdown_rec: + _shutdown_cp = _shutdown_rec.custom_properties or {} + # Preserve _hls_dir / file_path / file_url exactly as they are + # so the HLS endpoint keeps serving until the worker dies and + # so recovery can find the working directory on next boot. + _shutdown_cp["status"] = "interrupted" + _shutdown_cp["interrupted_reason"] = "server_shutdown" + _shutdown_cp["ended_at"] = str(datetime.now()) + _shutdown_rec.custom_properties = _shutdown_cp + _shutdown_rec.save(update_fields=["custom_properties"]) + except Exception as _se: + logger.warning( + f"DVR recording {recording_id}: failed to flag interrupted on shutdown: {_se}" + ) + logger.info( + f"DVR recording {recording_id}: worker shutting down with " + f"{int(end_timestamp - time.time())}s of recording window remaining; " + f"leaving HLS dir intact for resume on next boot, skipping concat." + ) + return + if not _stream_confirmed and not interrupted: interrupted = True interrupted_reason = f"no_stream_data: {last_error or 'ffmpeg_failed'}" @@ -2355,17 +2434,101 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) - if ( + _ok = ( concat_result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0 - ): - remux_success = True - logger.info( - f"DVR recording {recording_id}: HLS→MKV concat succeeded — " - f"{len(segments)} segments → {os.path.basename(final_path)} " - f"({os.path.getsize(final_path):,} bytes)" + ) + _fallback_used = False + # MP4-intermediate fallback: some MPEG-TS streams (parameter set + # changes mid-stream, weird PMT updates, audio discontinuities) + # fail to mux directly into Matroska but go through an MP4 + # container cleanly, which can then be remuxed losslessly to + # MKV. Try this before declaring the recording lost. + if not _ok: + logger.warning( + f"DVR recording {recording_id}: direct HLS\u2192MKV concat failed " + f"(rc={concat_result.returncode}); attempting MP4-intermediate " + f"fallback. stderr: {(concat_result.stderr or '')[:300]}" ) + try: + if os.path.exists(final_path) and os.path.getsize(final_path) == 0: + os.remove(final_path) + except OSError: + pass + _intermediate_mp4 = os.path.join( + hls_dir, f".dvr_{recording_id}_intermediate.mp4" + ) + try: + if os.path.exists(_intermediate_mp4): + os.remove(_intermediate_mp4) + except OSError: + pass + _mp4_concat = subprocess.run( + [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", + "-i", concat_list_path, + "-c", "copy", + "-bsf:a", "aac_adtstoasc", + _intermediate_mp4, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if ( + _mp4_concat.returncode == 0 + and os.path.exists(_intermediate_mp4) + and os.path.getsize(_intermediate_mp4) > 0 + ): + _mp4_to_mkv = subprocess.run( + [ + "ffmpeg", "-y", + "-i", _intermediate_mp4, + "-c", "copy", + final_path, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if ( + _mp4_to_mkv.returncode == 0 + and os.path.exists(final_path) + and os.path.getsize(final_path) > 0 + ): + _ok = True + _fallback_used = True + else: + logger.error( + f"DVR recording {recording_id}: MP4\u2192MKV remux step " + f"failed (rc={_mp4_to_mkv.returncode}). stderr: " + f"{(_mp4_to_mkv.stderr or '')[:300]}" + ) + else: + logger.error( + f"DVR recording {recording_id}: HLS\u2192MP4 concat fallback " + f"failed (rc={_mp4_concat.returncode}). stderr: " + f"{(_mp4_concat.stderr or '')[:300]}" + ) + try: + if os.path.exists(_intermediate_mp4): + os.remove(_intermediate_mp4) + except OSError: + pass + + if _ok: + remux_success = True + if _fallback_used: + logger.info( + f"DVR recording {recording_id}: HLS\u2192MP4\u2192MKV fallback " + f"concat succeeded \u2014 {len(segments)} segments \u2192 " + f"{os.path.basename(final_path)} " + f"({os.path.getsize(final_path):,} bytes)" + ) + else: + logger.info( + f"DVR recording {recording_id}: HLS\u2192MKV concat succeeded \u2014 " + f"{len(segments)} segments \u2192 {os.path.basename(final_path)} " + f"({os.path.getsize(final_path):,} bytes)" + ) # Update DB so *new* client requests go to /file/ (the final MKV) # rather than the soon-to-be-removed /hls/ endpoint. Important: # we do NOT clear _hls_dir here, active viewers still in the @@ -2432,9 +2595,10 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): logger.warning(f"DVR recording {recording_id}: post-rmtree DB update failed: {_post_e}") else: logger.error( - f"DVR recording {recording_id}: HLS→MKV concat failed " - f"(rc={concat_result.returncode}). Keeping HLS segments for recovery. " - f"stderr: {(concat_result.stderr or '')[:500]}" + f"DVR recording {recording_id}: all HLS\u2192MKV concat attempts " + f"failed (direct rc={concat_result.returncode}, MP4 fallback also " + f"failed). Keeping HLS segments for recovery. stderr: " + f"{(concat_result.stderr or '')[:500]}" ) except Exception as _ce: logger.error(f"DVR recording {recording_id}: concat exception: {_ce}") diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx index 80b5ba80..97481deb 100644 --- a/frontend/src/components/cards/RecordingCard.jsx +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -285,7 +285,9 @@ const RecordingCard = ({ <Tooltip label={ customProps.file_url || customProps.output_file_url - ? 'Watch recording' + ? isInProgress + ? 'Watch in progress recording' + : 'Watch recording' : 'Recording playback not available yet' } > @@ -296,10 +298,7 @@ const RecordingCard = ({ e.stopPropagation(); handleWatchRecording(); }} - disabled={ - customProps.status === 'recording' || - !(customProps.file_url || customProps.output_file_url) - } + disabled={!(customProps.file_url || customProps.output_file_url)} > Watch </Button> diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index 39643d27..bc8b245f 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -122,7 +122,8 @@ const RecordingDetailsModal = ({ const canWatchRecording = (customProps.status === 'completed' || customProps.status === 'stopped' || - customProps.status === 'interrupted') && + customProps.status === 'interrupted' || + customProps.status === 'recording') && Boolean(fileUrl); const isSeriesGroup = Boolean( From aa693f7a6a778a7245a53c78e8b41e38da10a826 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 11:48:02 -0500 Subject: [PATCH 40/63] Enhancement: Integrate HLS.js for improved handling of live and recorded streams --- frontend/src/components/FloatingVideo.jsx | 80 ++++++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 380f7153..752ab865 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -4,6 +4,7 @@ import Draggable from 'react-draggable'; import useVideoStore from '../store/useVideoStore'; import useAuthStore from '../store/auth'; import mpegts from 'mpegts.js'; +import Hls from 'hls.js'; import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core'; import { applyConstraints, @@ -279,9 +280,75 @@ export default function FloatingVideo() { video.addEventListener('error', handleError); video.addEventListener('progress', handleProgress); - // Set the source - video.src = streamUrl; - video.load(); + // HLS handling: in-progress recordings expose .m3u8 playlists (and ended + // recordings whose MKV concat hasn't completed yet still serve via /hls/). + // Native <video src="*.m3u8"> only works on Safari/iOS, and even there it + // cannot follow a growing playlist with a useful seekable window. Use + // hls.js when supported so the user gets a full DVR/timeshift window + // across all browsers. The HLS playlist uses #EXT-X-PLAYLIST-TYPE=EVENT- + // style growth (omit_endlist + hls_list_size 0), so hls.js will treat the + // entire recorded duration as the seekable range while the recording is + // still in progress and as a complete VOD once it ends. + const isHls = + typeof streamUrl === 'string' && + (streamUrl.includes('.m3u8') || + streamUrl.includes('/hls/index') || + streamUrl.includes('application/vnd.apple.mpegurl')); + let hls = null; + + if (isHls && Hls.isSupported()) { + hls = new Hls({ + // Allow seeking back to the start of the recording, regardless of + // current playhead position. Recordings can be hours long and the + // user may want to scrub anywhere; we explicitly disable buffer + // eviction by setting a very large back-buffer length. + backBufferLength: 90 * 60, // 90 minutes + maxBufferLength: 60, + maxMaxBufferLength: 600, + // For an in-progress recording, hls.js refreshes the playlist on + // its target-duration cadence; let it follow the live edge but keep + // the full DVR window seekable. + liveSyncDurationCount: 3, + liveMaxLatencyDurationCount: 10, + enableWorker: true, + lowLatencyMode: false, + }); + hls.on(Hls.Events.ERROR, (_evt, data) => { + if (data.fatal) { + // eslint-disable-next-line no-console + console.error('HLS fatal error:', data.type, data.details); + if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { + try { + hls.startLoad(); + } catch { + // ignore + } + } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + try { + hls.recoverMediaError(); + } catch { + // ignore + } + } else { + setLoadError( + `HLS playback error: ${data.details || data.type}` + ); + } + } + }); + hls.attachMedia(video); + hls.on(Hls.Events.MEDIA_ATTACHED, () => { + hls.loadSource(streamUrl); + }); + } else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) { + // Safari path: native HLS support, including seekable DVR windows. + video.src = streamUrl; + video.load(); + } else { + // Plain progressive file (MKV/MP4): native HTML5. + video.src = streamUrl; + video.load(); + } // Store cleanup function playerRef.current = { @@ -291,6 +358,13 @@ export default function FloatingVideo() { video.removeEventListener('canplay', handleCanPlay); video.removeEventListener('error', handleError); video.removeEventListener('progress', handleProgress); + if (hls) { + try { + hls.destroy(); + } catch { + // ignore + } + } video.removeAttribute('src'); video.load(); }, From 8a89f43afdb2908be5a1d60c0a3eb6fd81c09347 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 11:54:01 -0500 Subject: [PATCH 41/63] Enhancement: Ensure playback starts from the beginning of the seekable range for live and recorded streams --- frontend/src/components/FloatingVideo.jsx | 42 ++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 752ab865..d13d9bb6 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -242,10 +242,40 @@ export default function FloatingVideo() { const { volume: savedVolume, muted: savedMuted } = getPlayerPrefs(); if (typeof savedVolume === 'number') video.volume = savedVolume; if (typeof savedMuted === 'boolean') video.muted = savedMuted; + // Always start playback from the beginning of the seekable range. + let hasSeekedToStart = false; + const seekToStart = () => { + if (hasSeekedToStart) return; + try { + let target = 0; + if (video.seekable && video.seekable.length > 0) { + target = video.seekable.start(0); + } + // Only apply if we're not already at/near the start. Avoid + // setting currentTime when the video has no duration yet. + if ( + Number.isFinite(target) && + Math.abs((video.currentTime || 0) - target) > 0.25 + ) { + video.currentTime = target; + } + hasSeekedToStart = true; + } catch { + // ignore + } + }; + const handleLoadStart = () => setIsLoading(true); - const handleLoadedData = () => setIsLoading(false); + const handleLoadedMetadata = () => { + seekToStart(); + }; + const handleLoadedData = () => { + setIsLoading(false); + seekToStart(); + }; const handleCanPlay = () => { setIsLoading(false); + seekToStart(); // Auto-play for VOD content video.play().catch((e) => { console.log('Auto-play prevented:', e); @@ -275,6 +305,7 @@ export default function FloatingVideo() { // Add event listeners video.addEventListener('loadstart', handleLoadStart); + video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('canplay', handleCanPlay); video.addEventListener('error', handleError); @@ -298,6 +329,10 @@ export default function FloatingVideo() { if (isHls && Hls.isSupported()) { hls = new Hls({ + // Start playback at the very beginning of the recording rather than + // the live edge. Without this, an in-progress recording would + // open at "now" and hide all already-recorded content. + startPosition: 0, // Allow seeking back to the start of the recording, regardless of // current playhead position. Recordings can be hours long and the // user may want to scrub anywhere; we explicitly disable buffer @@ -330,9 +365,7 @@ export default function FloatingVideo() { // ignore } } else { - setLoadError( - `HLS playback error: ${data.details || data.type}` - ); + setLoadError(`HLS playback error: ${data.details || data.type}`); } } }); @@ -354,6 +387,7 @@ export default function FloatingVideo() { playerRef.current = { destroy: () => { video.removeEventListener('loadstart', handleLoadStart); + video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener('loadeddata', handleLoadedData); video.removeEventListener('canplay', handleCanPlay); video.removeEventListener('error', handleError); From 0cca34a2dc5390638b07be659692d97e668be054 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 12:25:05 -0500 Subject: [PATCH 42/63] Enhancement: Implement user authorization for recording playback and inject JWT into HLS requests --- apps/channels/api_views.py | 49 +++++++++++++++++++++++ frontend/src/components/FloatingVideo.jsx | 6 +++ 2 files changed, 55 insertions(+) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ae941af9..38126959 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2452,6 +2452,51 @@ class RecordingViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def _user_can_play_recording(self, request, recording): + """Authorization gate for recording playback (file/hls actions). + + Mirrors how live stream endpoints authorize non-admin users, but + unlike the XC-style endpoints these URLs carry no credentials of + their own, so we require an authenticated session/JWT: + * Unauthenticated requests → denied. + * Admins (user_level >= 10) → allowed. + * Authenticated non-admins → allowed only if the recording's + source channel is visible under their channel-profile + assignments and within their user_level. + + The network_access_allowed(request, "STREAMS") check applied + before this is a network-perimeter gate (e.g. block external IPs + from streaming at all); it is not a substitute for per-user + authorization. + """ + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): + return False + if getattr(user, "user_level", 0) >= 10: + return True + + channel = getattr(recording, "channel", None) + if channel is None: + # Recording with no source channel, only admins can play. + return False + + try: + user_profile_count = user.channel_profiles.count() + except Exception: + user_profile_count = 0 + + filters = { + "id": channel.id, + "user_level__lte": user.user_level, + } + if user_profile_count > 0: + filters["channelprofilemembership__enabled"] = True + filters["channelprofilemembership__channel_profile__in"] = ( + user.channel_profiles.all() + ) + return Channel.objects.filter(**filters).distinct().exists() + return Channel.objects.filter(**filters).exists() + @action(detail=True, methods=["post"], url_path="comskip") def comskip(self, request, pk=None): """Trigger comskip processing for this recording.""" @@ -2475,6 +2520,8 @@ class RecordingViewSet(viewsets.ModelViewSet): if not network_access_allowed(request, "STREAMS"): return JsonResponse({"error": "Forbidden"}, status=403) recording = get_object_or_404(Recording, pk=pk) + if not self._user_can_play_recording(request, recording): + return JsonResponse({"error": "Forbidden"}, status=403) cp = recording.custom_properties or {} file_path = cp.get("file_path") file_name = cp.get("file_name") or "recording" @@ -2562,6 +2609,8 @@ class RecordingViewSet(viewsets.ModelViewSet): if not network_access_allowed(request, "STREAMS"): return JsonResponse({"error": "Forbidden"}, status=403) recording = get_object_or_404(Recording, pk=pk) + if not self._user_can_play_recording(request, recording): + return JsonResponse({"error": "Forbidden"}, status=403) cp = recording.custom_properties or {} hls_dir = cp.get("_hls_dir") diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index d13d9bb6..239e394b 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -347,6 +347,12 @@ export default function FloatingVideo() { liveMaxLatencyDurationCount: 10, enableWorker: true, lowLatencyMode: false, + // Inject the JWT into every playlist + segment XHR. + xhrSetup: (xhr) => { + if (accessToken) { + xhr.setRequestHeader('Authorization', `Bearer ${accessToken}`); + } + }, }); hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) { From 4f0312b44b47191e73860861b3e3f3e55c9c3665 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 12:33:58 -0500 Subject: [PATCH 43/63] changelog: Update changelog to document security improvements and new features for DVR recording playback --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83cf0f3e..4ec8cad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Authorization on DVR recording playback endpoints**. `RecordingViewSet.file` and `RecordingViewSet.hls` now require an authenticated session and enforce a per-user channel-access check before serving any bytes. Admins (`user_level >= 10`) are always allowed; standard users are allowed only when the recording's source channel is visible under their channel-profile assignments and within their `user_level`, mirroring the same logic used by `stream_xc` for live channels. Unauthenticated requests now receive `403 Forbidden` instead of being served. The pre-existing `network_access_allowed(request, "STREAMS")` perimeter check is retained as a separate, prior gate so external IPs can be blocked from streaming entirely even with a valid token. + ### Fixed +- **Premature DVR concat on graceful Celery worker shutdown**. A `worker_shutting_down` signal handler in `apps/channels/tasks.py` now sets a module-level `_DVR_SHUTTING_DOWN` flag. The `run_recording` loop checks this flag after FFmpeg exits and, if the recording's `end_time` has not yet passed, skips concatenation and persists `status="interrupted"` with `interrupted_reason="server_shutdown"`. The HLS working directory is preserved across the restart so the recovery path on the next worker startup can resume segment numbering from where it left off rather than truncating the show. - **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name. - **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545) - **EPG channel name truncation**: EPG sources that include long `<display-name>` values (e.g. event-based channels with descriptions appended to the name) would crash the channel-parse task with a `value too long for type character varying(255)` PostgreSQL error and silently discard the entire batch. The `EPGData.name`, `Stream.name`, and `Channel.name` fields have been widened to 512 characters, and names exceeding this limit are now truncated with a warning log rather than aborting the import. (Fixes #1134) @@ -16,8 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **In-progress recording playback from the DVR page**. The Watch button on a recording card is now enabled while the recording is still in progress, and the in-app floating player can play the live HLS playlist with full timeshift / scrub-back to the start of the recording. + - The frontend now bundles `hls.js` and routes any `.m3u8` URL through it (Chrome, Edge, Firefox, and other Chromium-based browsers). Native HLS via `<video src=>` is reserved for Safari, where it Just Works for the same playlist. + - hls.js requests are authenticated via `xhrSetup`, attaching the same `Authorization: Bearer <accessToken>` header the live mpegts.js player already uses, so the new per-user authorization check on the recording endpoints (see Security) is satisfied for every playlist refresh and segment fetch. - **Watch DVR recordings live while they are still recording**: `run_recording` has been refactored from a single TS file capture to an FFmpeg HLS segmentation pipeline. While recording is in progress, the `file_url` exposed on the recording points to a new `/api/channels/recordings/{id}/hls/index.m3u8` endpoint instead of the eventual MKV path, so any HLS-capable client (the built-in web player, VLC, infuse, channels DVR clients, etc.) can join the stream at any time and watch from the live edge. When the recording ends, the segments are concatenated into the final MKV, `file_url` is updated to point to the existing `/file/` endpoint, and the HLS working directory is cleaned up. Hitting `/file/` while a recording is still in progress now redirects to the HLS playlist rather than 404, and hitting `/hls/index.m3u8` after the recording has finalized redirects to `/file/`, so URLs cached by clients in either form continue to work across the transition. -- New HLS playback endpoint (`RecordingViewSet.hls`) serves `.m3u8` and `.ts` files out of the recording's working directory with path traversal protection (`os.path.realpath` containment check) and `AllowAny` permissions consistent with the existing `/file/` endpoint. Playlist files are rewritten on the fly so each segment line becomes an absolute URL routed back through this endpoint, preserving authentication and path isolation. +- New HLS playback endpoint (`RecordingViewSet.hls`) serves `.m3u8` and `.ts` files out of the recording's working directory with path traversal protection (`os.path.realpath` containment check). Playlist files are rewritten on the fly so each segment line becomes an absolute URL routed back through this endpoint, preserving authentication and path isolation. Both this and the existing `/file/` endpoint are gated by the `STREAMS` network policy and a per-user authorization check (see Security). - Explicit `path('recordings/<int:pk>/hls/<path:seg_path>', ...)` route registered in `apps/channels/api_urls.py` _before_ `router.urls`. DRF's `DefaultRouter` appends a mandatory trailing slash to every URL it generates, but HLS players request segments by their natural filenames (`seg_00001.ts`) without a trailing slash, so the explicit route is required for the player to ever reach the view. - `DISPATCHARR_WEB_HOST` environment variable for modular deployments. The Celery container needs to reach the uWSGI / web container to fetch HLS segments while recording (FFmpeg pulls from the public stream URL, the same way any other client does). `get_dvr_stream_base_url()` resolves the base URL deterministically: AIO / dev / debug containers reach uWSGI on `127.0.0.1:$DISPATCHARR_PORT` since Celery and uWSGI share the container, while modular deployments use `http://$DISPATCHARR_WEB_HOST:$DISPATCHARR_PORT` and default `DISPATCHARR_WEB_HOST` to `web` (the compose service name). Documented as a commented-out override in `docker/docker-compose.yml`. - HLS viewer heartbeat. Every `.ts` request refreshes a Redis key `dvr:hls_viewer:{id}` with a 20 second TTL. After concat succeeds, the recording task waits for that key to expire naturally before deleting the HLS working directory, so an actively-watching client is never cut off mid-segment when the recording ends. Cleanup happens within 20 seconds of the last client stopping, with a 4 hour safety cap as a guard against a stuck Redis state. From a7b8e547476406dab2df97ab82ea16d7b40da23e Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 13:27:32 -0500 Subject: [PATCH 44/63] Enhancement: Refactor HLS handling in RecordingViewSet and implement cleanup for empty parent directories --- apps/channels/api_views.py | 42 ++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 38126959..d3c76f88 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -62,7 +62,7 @@ from rest_framework.filters import SearchFilter, OrderingFilter from apps.epg.models import EPGData from apps.vod.models import Movie, Series from django.db.models import Q -from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404, JsonResponse +from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404, JsonResponse, HttpResponseRedirect from django.utils import timezone import mimetypes from django.conf import settings @@ -2530,7 +2530,6 @@ class RecordingViewSet(viewsets.ModelViewSet): # Redirect to HLS if recording is still in progress hls_dir = cp.get("_hls_dir") if hls_dir and os.path.isdir(hls_dir): - from django.http import HttpResponseRedirect hls_url = request.build_absolute_uri( f"/api/channels/recordings/{pk}/hls/index.m3u8" ) @@ -2598,7 +2597,7 @@ class RecordingViewSet(viewsets.ModelViewSet): return response @action(detail=True, methods=["get"], url_path="hls/(?P<seg_path>.+)") - def hls(self, request, pk=None, seg_path="index.m3u8"): + def hls(self, request, pk=None, seg_path=None): """Serve HLS playlist and segment files for an in-progress (or completed) recording. Clients connecting during recording should use the m3u8 URL returned in @@ -2621,7 +2620,6 @@ class RecordingViewSet(viewsets.ModelViewSet): cp = recording.custom_properties or {} file_path = cp.get("file_path") if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0: - from django.http import HttpResponseRedirect return HttpResponseRedirect( request.build_absolute_uri(f"/api/channels/recordings/{pk}/file/") ) @@ -2649,8 +2647,7 @@ class RecordingViewSet(viewsets.ModelViewSet): lines.append(f"{base_url}{stripped}\n") else: lines.append(line) - from django.http import HttpResponse as _HR - return _HR("".join(lines), content_type="application/x-mpegURL") + return HttpResponse("".join(lines), content_type="application/x-mpegURL") if seg_path.endswith(".ts"): # Refresh the viewer heartbeat in Redis so the Celery task knows an @@ -2663,8 +2660,7 @@ class RecordingViewSet(viewsets.ModelViewSet): _rv.set(f"dvr:hls_viewer:{pk}", "1", ex=20) except Exception: pass - from django.http import FileResponse as _FR - return _FR(open(requested, "rb"), content_type="video/mp2t") + return FileResponse(open(requested, "rb"), content_type="video/mp2t") raise Http404("Unsupported HLS file type") @@ -3018,6 +3014,30 @@ class RecordingViewSet(viewsets.ModelViewSet): except Exception as ex: logger.warning(f"Failed to delete HLS directory {path}: {ex}") + # Clean up empty parent directories up to the recordings root to prevent orphaned folders from accumulating over time. + recordings_root = os.path.normpath('/data/recordings') + + def _prune_empty_parents(path: str): + if not path or not isinstance(path, str): + return + try: + parent = os.path.dirname(os.path.normpath(path)) + while ( + parent + and parent != recordings_root + and parent.startswith(recordings_root + os.sep) + and os.path.isdir(parent) + and not os.listdir(parent) + ): + try: + os.rmdir(parent) + logger.info(f"Removed empty recording directory: {parent}") + except OSError: + break + parent = os.path.dirname(parent) + except Exception as ex: + logger.debug(f"Unable to prune empty parents for {path}: {ex}") + def _background_cancel(): # Only stop the DVR client if the recording was actively streaming. # Stopping for completed/upcoming recordings would kill an unrelated @@ -3037,6 +3057,12 @@ class RecordingViewSet(viewsets.ModelViewSet): _safe_remove(file_path) _safe_rmtree(hls_dir) + # If removing the file/HLS dir leaves the show/season folder + # empty, clean those up too. Both paths share the same parent + # in normal layouts, but run the prune for each just in case. + _prune_empty_parents(file_path) + _prune_empty_parents(hls_dir) + try: from django.db import connection as _conn _conn.close() From 1eda68db7ade343b0ab38c648e087aed2ef700b7 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 13:27:50 -0500 Subject: [PATCH 45/63] Enhancement: Refactor run_recording to improve directory handling and remove unused cancellation check --- apps/channels/tasks.py | 76 ++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 39 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index dd1fd3d8..ee38c1f3 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -6,6 +6,7 @@ import re import requests import time import json +import shutil import subprocess import signal import threading @@ -1884,11 +1885,16 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): if poster_url and "poster_url" not in cp: cp["poster_url"] = poster_url - # Ensure destination exists so it's visible immediately + # Ensure destination directory exists. No placeholder MKV is created + # here on purpose: with the HLS pipeline, the final MKV is materialized + # by the post-recording concat step, and `/file/` redirects to `/hls/` + # while no MKV exists, so a 0-byte placeholder serves no functional + # purpose. Writing one previously caused an orphan-file bug on server + # restart in cases where the resume path could not adopt the original + # final_path, since the second run would generate a fresh placeholder + # at a new path and leave the original behind. try: os.makedirs(os.path.dirname(final_path), exist_ok=True) - if not os.path.exists(final_path): - open(final_path, 'ab').close() except Exception: pass @@ -1933,22 +1939,6 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): base_url = get_dvr_stream_base_url() logger.info(f"DVR recording {recording_id}: using stream base URL {base_url}") - def _check_recording_cancelled(rid): - """Check if a recording was stopped by user or deleted. - - Returns (should_exit, is_interrupted, reason) where should_exit - indicates the stream loop must terminate. - """ - try: - rec = Recording.objects.filter(id=rid).only("custom_properties").first() - if rec is None: - return True, True, "recording_deleted" - if (rec.custom_properties or {}).get("status") == "stopped": - return True, False, "stopped_by_user" - except Exception: - pass - return False, False, None - # --- DB retry constants --- _dvr_db_max_retries = 3 _dvr_db_retry_interval = 1 # seconds (base for exponential backoff) @@ -1979,10 +1969,16 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): if not interrupted: # Check for stop/delete before starting - should_exit, is_int, reason = _check_recording_cancelled(recording_id) - if should_exit: - interrupted = is_int - interrupted_reason = reason + try: + _pre = Recording.objects.filter(id=recording_id).only("custom_properties").first() + if _pre is None: + interrupted = True + interrupted_reason = "recording_deleted" + elif (_pre.custom_properties or {}).get("status") == "stopped": + interrupted = False + interrupted_reason = "stopped_by_user" + except Exception: + pass if not interrupted: stream_url = f"{base}/proxy/ts/stream/{channel.uuid}" @@ -2090,11 +2086,7 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): ) _stderr_thread.start() - end_timestamp = ( - end_time.timestamp() - if hasattr(end_time, 'timestamp') - else time.mktime(end_time.timetuple()) - ) + end_timestamp = end_time.timestamp() _stop_poll_interval = 2.0 _last_stop_poll = time.time() _ffmpeg_start = time.time() @@ -2145,11 +2137,13 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # With erratic source timestamps, FFmpeg may buffer data inside # a partially-written segment for longer than hls_time, so the # segment count won't increase even though data is flowing. + # Only stat the most recent segment (highest filename) to keep + # this O(1) per tick instead of O(N) over a long recording. try: if segs_now and hls_dir: - _newest_mtime = max( - os.path.getmtime(os.path.join(hls_dir, f)) - for f in segs_now + _newest = max(segs_now) + _newest_mtime = os.path.getmtime( + os.path.join(hls_dir, _newest) ) if _newest_mtime > _last_new_seg_time: _last_new_seg_time = _newest_mtime @@ -2268,6 +2262,11 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # on the next boot, and the resume-path logic in the prep block will # adopt the existing `_hls_dir` and `file_path` so FFmpeg appends to the # same playlist and the eventual concat produces a single MKV. + # + # Note: `_DVR_SHUTTING_DOWN` is per-process. The DVR queue currently uses + # a threads pool worker (one process, all threads see the flag). If the + # DVR queue is ever switched back to prefork, the parent's flag will not + # propagate to in-flight child workers. if _DVR_SHUTTING_DOWN and time.time() < end_timestamp: try: _shutdown_rec = Recording.objects.filter(id=recording_id).first() @@ -2366,10 +2365,9 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): # Clean up all artifacts for the cancelled recording, # including any pre-restart .ts segments from server recovery. # Use the in-memory recording_obj since the DB row is already deleted. - import shutil as _shutil if hls_dir and os.path.isdir(hls_dir): try: - _shutil.rmtree(hls_dir) + shutil.rmtree(hls_dir) logger.info(f"Cleaned up cancelled recording HLS directory: {hls_dir}") except Exception as _e: logger.warning(f"Failed to remove HLS directory {hls_dir}: {_e}") @@ -2422,7 +2420,8 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): try: with open(concat_list_path, "w") as _cl: for seg in segments: - _cl.write(f"file '{seg}'\n") + _escaped = seg.replace("'", "'\\''") + _cl.write(f"file '{_escaped}'\n") concat_result = subprocess.run( [ @@ -2545,7 +2544,6 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): _pre_rmtree_rec.save(update_fields=["custom_properties"]) except Exception as _pre_e: logger.warning(f"DVR recording {recording_id}: pre-rmtree DB update failed: {_pre_e}") - import shutil as _shutil # Wait for active HLS viewers to finish downloading segments # before deleting the directory. The hls endpoint refreshes # _hls_viewer_key on every .ts request with a 20s TTL, so the @@ -2577,7 +2575,7 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): except Exception as _ve: logger.debug(f"DVR recording {recording_id}: viewer wait check failed: {_ve}") try: - _shutil.rmtree(hls_dir) + shutil.rmtree(hls_dir) logger.debug(f"Cleaned up HLS directory: {hls_dir}") except Exception as _e: logger.warning(f"Failed to remove HLS directory {hls_dir}: {_e}") @@ -2849,7 +2847,6 @@ def recover_recordings_on_startup(): mkv_path = cp.get("file_path") if hls_dir_path and os.path.isdir(hls_dir_path) and mkv_path: - import shutil as _shutil # Parse m3u8 for ordered segment list; fall back to sorted filenames _m3u8 = os.path.join(hls_dir_path, "index.m3u8") _segs = [] @@ -2881,7 +2878,8 @@ def recover_recordings_on_startup(): try: with open(_concat_txt, "w") as _cl: for _s in _segs: - _cl.write(f"file '{_s}'\n") + _escaped = _s.replace("'", "'\\''") + _cl.write(f"file '{_escaped}'\n") _res = subprocess.run( [ "ffmpeg", "-y", @@ -2896,7 +2894,7 @@ def recover_recordings_on_startup(): cp["interrupted_reason"] = "server_restarted_after_end" cp["remux_success"] = True try: - _shutil.rmtree(hls_dir_path) + shutil.rmtree(hls_dir_path) except OSError: pass logger.info( From c83e2be866d36eef772d4a42c4cb9515cd72acca Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Sun, 26 Apr 2026 13:28:13 -0500 Subject: [PATCH 46/63] Enhancement: Refactor FloatingVideo component to improve JWT handling and ensure playback starts from the beginning of recordings --- CHANGELOG.md | 1 + frontend/src/components/FloatingVideo.jsx | 39 +++++++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec8cad3..8e6e9b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Empty show/season folders left behind after deleting a recording**. Deleting a recording removes the MKV (or HLS working directory if still in progress) but previously left the parent show / season directories on disk even when they no longer contained any other recordings. After file cleanup, `RecordingViewSet.destroy` now walks up from the deleted file's parent directory and removes any now-empty directories, stopping at the `/data/recordings` library root so the root itself is never touched. - **Premature DVR concat on graceful Celery worker shutdown**. A `worker_shutting_down` signal handler in `apps/channels/tasks.py` now sets a module-level `_DVR_SHUTTING_DOWN` flag. The `run_recording` loop checks this flag after FFmpeg exits and, if the recording's `end_time` has not yet passed, skips concatenation and persists `status="interrupted"` with `interrupted_reason="server_shutdown"`. The HLS working directory is preserved across the restart so the recovery path on the next worker startup can resume segment numbering from where it left off rather than truncating the show. - **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name. - **Channel form reset on group creation**: creating a new channel group from within the channel create/edit form no longer wipes all filled-in form fields. The newly created group is also automatically selected in the channel group field after it is saved. (Fixes #545) diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 239e394b..507e0caf 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -119,7 +119,6 @@ export default function FloatingVideo() { const contentType = useVideoStore((s) => s.contentType); const metadata = useVideoStore((s) => s.metadata); const hideVideo = useVideoStore((s) => s.hideVideo); - const accessToken = useAuthStore((s) => s.accessToken); const videoRef = useRef(null); const playerRef = useRef(null); @@ -271,10 +270,16 @@ export default function FloatingVideo() { }; const handleLoadedData = () => { setIsLoading(false); + // hls.js applies its `startPosition` after MEDIA_ATTACHED, which can + // run later than `loadedmetadata`. Re-seek here as a safety net so a + // hls.js live playlist doesn't snap to the live edge after our first + // seek attempt happened against an empty seekable range. seekToStart(); }; const handleCanPlay = () => { setIsLoading(false); + // Final fallback for the Safari native-HLS path where seekable.start(0) + // is sometimes only valid by the time `canplay` fires. seekToStart(); // Auto-play for VOD content video.play().catch((e) => { @@ -329,9 +334,13 @@ export default function FloatingVideo() { if (isHls && Hls.isSupported()) { hls = new Hls({ - // Start playback at the very beginning of the recording rather than - // the live edge. Without this, an in-progress recording would - // open at "now" and hide all already-recorded content. + // Open at the very beginning of the recording rather than the live + // edge. Without this, an in-progress recording would start at "now" + // and hide everything already recorded. hls.js applies this AFTER + // MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is + // also kept as a safety net for the Safari native-HLS path and for + // edge cases where this initial-position logic loses to the user's + // first interaction. startPosition: 0, // Allow seeking back to the start of the recording, regardless of // current playhead position. Recordings can be hours long and the @@ -347,10 +356,14 @@ export default function FloatingVideo() { liveMaxLatencyDurationCount: 10, enableWorker: true, lowLatencyMode: false, - // Inject the JWT into every playlist + segment XHR. + // Inject the JWT into every playlist + segment XHR. Read the token + // from the auth store at request time rather than capturing the + // closure value at hls.js init, so a refreshed access token mid- + // playback is picked up on the next segment fetch. xhrSetup: (xhr) => { - if (accessToken) { - xhr.setRequestHeader('Authorization', `Bearer ${accessToken}`); + const token = useAuthStore.getState().accessToken; + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); } }, }); @@ -435,6 +448,14 @@ export default function FloatingVideo() { ? `${window.location.origin}${streamUrl}` : streamUrl; + // Read the JWT from the auth store at player-creation time rather than + // relying on the closure-captured `accessToken` value. mpegts.js has + // no per-request setup hook (unlike hls.js's xhrSetup), so this header + // is baked into the IO loader for the life of the player; we just want + // to be sure we use the freshest token available at the moment of + // connection rather than whatever React-render snapshot we closed over. + const liveAccessToken = useAuthStore.getState().accessToken; + const player = mpegts.createPlayer( { type: 'mpegts', @@ -451,8 +472,8 @@ export default function FloatingVideo() { autoCleanupMaxBackwardDuration: 120, autoCleanupMinBackwardDuration: 60, reuseRedirectedURL: true, - headers: accessToken - ? { Authorization: `Bearer ${accessToken}` } + headers: liveAccessToken + ? { Authorization: `Bearer ${liveAccessToken}` } : undefined, } ); From 1e5298a8d36a88e512ee670cf28cc458727ae8e0 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 10:15:03 -0500 Subject: [PATCH 47/63] Bug Fix: Implement Redis locks in run_recording and worker_ready to prevent duplicate ffmpeg processes and ensure single execution across workers --- apps/channels/tasks.py | 89 +++++++++++++++++++++++++++++++++++++++--- dispatcharr/celery.py | 47 +++++++++++++++++++--- 2 files changed, 125 insertions(+), 11 deletions(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index ee38c1f3..08c7737b 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1763,6 +1763,36 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): ) return + # --- Per-recording active lock (prevents concurrent ffmpeg processes + # writing to the same HLS dir when duplicate recovery dispatches occur). --- + # The lock is refreshed inside the main loop below; if a worker crashes + # the TTL expires within ~45s and recovery can re-acquire it. + _active_lock_key = f"dvr:recording-active:{recording_id}" + _active_lock_ttl = 45 + _active_lock_redis = None + try: + from core.utils import RedisClient + _active_lock_redis = RedisClient.get_client() + except Exception: + _active_lock_redis = None + + if _active_lock_redis is not None: + try: + if not _active_lock_redis.set( + _active_lock_key, "1", ex=_active_lock_ttl, nx=True + ): + logger.warning( + f"run_recording {recording_id}: another worker holds the " + f"active-recording lock, aborting duplicate dispatch." + ) + return + except Exception as _le: + logger.warning( + f"run_recording {recording_id}: active-lock acquisition error " + f"({type(_le).__name__}: {_le}), proceeding without lock." + ) + _active_lock_redis = None + # --- Clean up the one-off PeriodicTask that dispatched this task --- try: from apps.channels.signals import revoke_task, _dvr_task_name @@ -2095,11 +2125,25 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): _stream_confirmed = False _last_seg_count = hls_start_number _last_new_seg_time = time.time() + _last_active_lock_refresh = 0.0 + _active_lock_refresh_interval = 15.0 while ffmpeg_proc.poll() is None: time.sleep(0.5) now = time.time() + # Refresh the per-recording active lock so a concurrent worker + # cannot acquire it and start a duplicate ffmpeg. + if ( + _active_lock_redis is not None + and now - _last_active_lock_refresh >= _active_lock_refresh_interval + ): + try: + _active_lock_redis.expire(_active_lock_key, _active_lock_ttl) + except Exception: + pass + _last_active_lock_refresh = now + segs_now = [ f for f in os.listdir(hls_dir) if f.startswith("seg_") and f.endswith(".ts") @@ -2736,6 +2780,14 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str): except Exception: pass + # Release the per-recording active lock so a follow-up dispatch (e.g. + # manual re-record) does not have to wait for TTL expiry. + if _active_lock_redis is not None: + try: + _active_lock_redis.delete(_active_lock_key) + except Exception: + pass + @shared_task def recover_recordings_on_startup(): @@ -2752,12 +2804,21 @@ def recover_recordings_on_startup(): from .signals import schedule_recording_task, revoke_task redis = RedisClient.get_client() - if redis: - lock_key = "dvr:recover_lock" - # Set lock with 10-minute TTL; must be long enough for Phase 2 - # ffmpeg remux operations on large files. - if not redis.set(lock_key, "1", ex=600, nx=True): - return "Recovery already in progress" + if redis is None: + # Fail closed: without Redis we cannot guarantee single-execution + # across workers, and running unguarded has been shown to start + # duplicate ffmpeg processes against the same HLS output dir. + logger.warning( + "recover_recordings_on_startup: Redis unavailable, " + "skipping recovery to avoid duplicate recording dispatch." + ) + return "Redis unavailable" + + lock_key = "dvr:recover_lock" + # Set lock with 10-minute TTL; must be long enough for Phase 2 + # ffmpeg remux operations on large files. + if not redis.set(lock_key, "1", ex=600, nx=True): + return "Recovery already in progress" now = timezone.now() @@ -2789,6 +2850,22 @@ def recover_recordings_on_startup(): ) continue + # If a live worker still holds the per-recording active lock, + # skip recovery, that worker is currently writing to the HLS + # output dir and re-dispatching `run_recording` would spawn a + # second ffmpeg racing on the same files. The lock auto-expires + # within ~45s if the worker died, so this only blocks recovery + # while the recording is genuinely live. + try: + if redis.exists(f"dvr:recording-active:{rec.id}"): + logger.info( + f"recover_recordings_on_startup: skipping recording {rec.id} " + f"(active-recording lock held by live worker)." + ) + continue + except Exception: + pass + # Mark interrupted due to restart; will flip to 'recording' when task starts cp["status"] = "interrupted" cp["interrupted_reason"] = "server_restarted" diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 0d63f063..51a77052 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -4,6 +4,8 @@ from celery import Celery import logging from celery.signals import task_postrun, worker_ready +logger = logging.getLogger(__name__) + # Initialize with defaults before Django settings are loaded DEFAULT_LOG_LEVEL = 'DEBUG' @@ -158,9 +160,44 @@ def setup_celery_logging(**kwargs): @worker_ready.connect def on_worker_ready(**kwargs): - """Tasks to run once the worker is fully connected and ready.""" - from apps.channels.tasks import recover_recordings_on_startup - recover_recordings_on_startup.delay() + """Tasks to run once the worker is fully connected and ready. - from core.tasks import check_for_version_update - check_for_version_update.delay() + NOTE: when multiple Celery worker processes share a container (e.g. the + `dvr` and `default` workers in the AIO image), this signal fires once per + worker. We must guard the one-shot startup tasks with a short-lived + Redis NX lock so they are dispatched exactly once per cluster startup, + otherwise `recover_recordings_on_startup` runs twice and re-dispatches + `run_recording` for any in-flight recording, producing duplicate ffmpeg + processes that race on the same HLS output directory. + """ + try: + from core.utils import RedisClient + redis_client = RedisClient.get_client() + except Exception: + redis_client = None + + def _claim(lock_key, ttl_seconds=300): + """Return True if this worker should run the one-shot dispatch.""" + if redis_client is None: + # Redis unavailable: best-effort, allow dispatch (the in-task + # lock inside the recovery task itself is the second line of + # defense if Redis comes back online before the task runs). + return True + try: + claimed = bool(redis_client.set(lock_key, "1", ex=ttl_seconds, nx=True)) + if not claimed: + logger.debug( + f"on_worker_ready: dispatch lock {lock_key!r} held by " + f"another worker, skipping one-shot dispatch." + ) + return claimed + except Exception: + return True + + if _claim("dvr:recover_dispatch_lock"): + from apps.channels.tasks import recover_recordings_on_startup + recover_recordings_on_startup.delay() + + if _claim("core:version_check_dispatch_lock"): + from core.tasks import check_for_version_update + check_for_version_update.delay() From ddb03289b4a0a253a65d1145a4a64bcdc3edb5f1 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 10:33:41 -0500 Subject: [PATCH 48/63] Enhancement: Improve plugin discovery process by adding a completion flag and refining in-memory registry handling --- apps/plugins/apps.py | 11 +++++++++-- apps/plugins/loader.py | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/plugins/apps.py b/apps/plugins/apps.py index c2c0bce6..72b0b068 100644 --- a/apps/plugins/apps.py +++ b/apps/plugins/apps.py @@ -13,7 +13,11 @@ class PluginsConfig(AppConfig): - Skip during common management commands that don't need discovery. - Register post_migrate handler to sync plugin registry to DB after migrations. - - Do an in-memory discovery (no DB) so registry is available early. + - Do an in-memory discovery (no DB) so registry is available early + but only in the main uwsgi/daphne process. Celery workers and + management commands skip the eager pass: the post_migrate handler + covers the DB sync, and `connect/utils.py` lazy-discovers on first + event using the loader's cache. """ try: # Allow explicit opt-out via env var @@ -43,8 +47,11 @@ class PluginsConfig(AppConfig): _post_migrate_discover, dispatch_uid="apps.plugins.post_migrate_discover", ) + # + from dispatcharr.app_initialization import should_skip_initialization + if should_skip_initialization(): + return - # Perform non-DB discovery now to populate in-memory registry. from .loader import PluginManager PluginManager.get().discover_plugins(sync_db=False) except Exception: diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py index 6084b28e..a7ecb255 100644 --- a/apps/plugins/loader.py +++ b/apps/plugins/loader.py @@ -54,6 +54,7 @@ class PluginManager: self._alias_names: Dict[str, str] = {} self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token") self._last_reload_token = 0.0 + self._discovery_completed = False self._lock = threading.RLock() # Ensure plugins directory exists @@ -71,7 +72,7 @@ class PluginManager: token = self._get_reload_token() if use_cache and not force_reload: with self._lock: - if self._registry and token <= self._last_reload_token: + if self._discovery_completed and token <= self._last_reload_token: return self._registry if token > self._last_reload_token: force_reload = True @@ -231,6 +232,7 @@ class PluginManager: self._alias_names = new_aliases if token > self._last_reload_token: self._last_reload_token = token + self._discovery_completed = True logger.info(f"Discovered {len(new_registry)} plugin(s)") except FileNotFoundError: From dd911cc711c35979e4cd1c369bf004962ffc2268 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 12:23:37 -0500 Subject: [PATCH 49/63] Refactor: Remove SSDP advertisement implementation and related code from HDHomeRun app --- CHANGELOG.md | 6 +++++ apps/hdhr/apps.py | 5 +--- apps/hdhr/ssdp.py | 69 ----------------------------------------------- 3 files changed, 7 insertions(+), 73 deletions(-) delete mode 100644 apps/hdhr/ssdp.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6e9b51..40545381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Plugin discovery re-running on every connect event**. The `PluginManager` cache hit check used `if self._registry and ...`, which always evaluated false when zero plugins were installed because an empty dict is falsy. Every Connect event (`recording_start`, `client_connect`, `channel_start`, etc.) was therefore triggering a full filesystem walk of `/data/plugins` and emitting `Discovering plugins (no DB sync) in /data/plugins` / `Discovered 0 plugin(s)` log lines. Tracked separately via a new `_discovery_completed` flag so the cache short-circuits subsequent calls regardless of registry contents, dropping discovery to once per worker process lifetime. - **Empty show/season folders left behind after deleting a recording**. Deleting a recording removes the MKV (or HLS working directory if still in progress) but previously left the parent show / season directories on disk even when they no longer contained any other recordings. After file cleanup, `RecordingViewSet.destroy` now walks up from the deleted file's parent directory and removes any now-empty directories, stopping at the `/data/recordings` library root so the root itself is never touched. - **Premature DVR concat on graceful Celery worker shutdown**. A `worker_shutting_down` signal handler in `apps/channels/tasks.py` now sets a module-level `_DVR_SHUTTING_DOWN` flag. The `run_recording` loop checks this flag after FFmpeg exits and, if the recording's `end_time` has not yet passed, skips concatenation and persists `status="interrupted"` with `interrupted_reason="server_shutdown"`. The HLS working directory is preserved across the restart so the recovery path on the next worker startup can resume segment numbering from where it left off rather than truncating the show. - **Channel start/stop notifications missing name**: the "channel started" toast never showed up and the "channel stopped" toast showed the raw UUID instead of the channel name. `channel_name` is now written into the Redis metadata hash at init time and included in every stats payload pushed over WebSocket; the frontend notification functions read `ch.channel_name` from the stats payload directly, falling back to the store lookup and then a formatted placeholder. Both notifications now display the correct channel name. @@ -63,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Plugin discovery startup gated to the main process**. `apps/plugins/apps.py` now consults `should_skip_initialization()` before its eager in-memory `discover_plugins()` pass in `AppConfig.ready()`. The pass previously ran in every uwsgi worker fork, every Celery worker, and every `manage.py` invocation. Plugin discovery now happens lazily on first Connect event in worker processes (cached thereafter); the existing `post_migrate` handler still keeps the database side in sync. - DVR recording pipeline rewritten around FFmpeg + HLS. The previous implementation wrote a single growing `.ts` file via the proxy and remuxed it to `.mkv` at the end; the new implementation runs FFmpeg with `-f hls`, regenerates monotonic PTS to handle erratic IPTV source timestamps, shifts output timestamps so they start at 0 (fixing negative-PTS streams that prevented HLS segment boundary detection), and concatenates the resulting segments into the final MKV at the end of the recording. Server-restart resume now continues segment numbering from the previous session rather than overwriting earlier segments. - Stall detection added to the recording loop. Once the first segment confirms the stream is flowing, the loop watches both segment count and segment file mtime. If neither advances for the configured stall window (e.g. when an upstream proxy ghost-kills the client), FFmpeg is signaled and the recording ends cleanly rather than hanging until `end_time`. Recording duration is honored via SIGINT so FFmpeg writes `#EXT-X-ENDLIST` cleanly into the final playlist. - `end_time` extension is now picked up by the running recording without restarting FFmpeg. The DB poll loop simply updates the in-memory deadline. @@ -77,6 +79,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Client timing fields consolidated to `connected_at`**: `get_basic_channel_info` was sending only a pre-computed `connected_since` elapsed-seconds value (no raw timestamp), while `get_detailed_channel_info` was sending `connected_at` alongside a redundant `connection_duration`. Both paths now emit only `connected_at` (Unix timestamp). The frontend derives connection display time and duration from that single value at render time, which also fixes the "timestamp jumping" seen on the Stats page, the connected-at wall-clock time is now stable across polls instead of being recomputed each response. - **Duration tooltip on Stats page connection table**: the hover tooltip on the Duration column now shows a human-readable breakdown instead of a raw seconds count. Seconds only under a minute, minutes and seconds under an hour, hours and minutes under a day, and days and hours beyond that (e.g. `2 hours, 15 minutes`). +### Removed + +- **HDHomeRun SSDP advertiser**. The `apps/hdhr/ssdp.py` module and the `start_ssdp()` call in `apps.hdhr.apps.HdhrConfig.ready()` have been removed. The implementation advertised a `urn:schemas-upnp-org:device:MediaServer:1` UPnP MediaServer device on `udp/1900`, but Dispatcharr does not implement the UPnP MediaServer Content Directory Service that such an advertisement promises, and the `LOCATION`-targeted `/hdhr/device.xml` returns HDHomeRun-flavored XML rather than the UPnP descriptor a generic UPnP browser expects. None of the major HDHomeRun clients (Plex, Emby, Jellyfin, Channels DVR, Kodi) use SSDP for tuner discovery, they use SiliconDust's native UDP broadcast on `udp/65001`, which Dispatcharr does not currently implement. The advertiser ran a listener thread, a broadcaster thread, a bound multicast socket, and emitted a `NOTIFY` broadcast every 30 seconds for no consumer. Tuners can still be added in any HDHomeRun-aware client by entering the Dispatcharr URL manually (e.g. `http://<host>:9191/`). + ## [0.23.0] - 2026-04-17 ### Security diff --git a/apps/hdhr/apps.py b/apps/hdhr/apps.py index 18434738..582a3ce4 100644 --- a/apps/hdhr/apps.py +++ b/apps/hdhr/apps.py @@ -1,10 +1,7 @@ from django.apps import AppConfig -from . import ssdp + class HdhrConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.hdhr' verbose_name = "HDHomeRun Emulation" - def ready(self): - # Start SSDP services when the app is ready - ssdp.start_ssdp() diff --git a/apps/hdhr/ssdp.py b/apps/hdhr/ssdp.py deleted file mode 100644 index d794799a..00000000 --- a/apps/hdhr/ssdp.py +++ /dev/null @@ -1,69 +0,0 @@ -import os -import socket -import threading -import time -import gevent # Add this import -from django.conf import settings - -# SSDP Multicast Address and Port -SSDP_MULTICAST = "239.255.255.250" -SSDP_PORT = 1900 - -DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaServer:1" -SERVER_PORT = 8000 - -def get_host_ip(): - try: - # This relies on "host.docker.internal" being mapped to the host’s gateway IP. - return socket.gethostbyname("host.docker.internal") - except Exception: - return "127.0.0.1" - -def ssdp_response(addr, host_ip): - response = ( - f"HTTP/1.1 200 OK\r\n" - f"CACHE-CONTROL: max-age=1800\r\n" - f"EXT:\r\n" - f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n" - f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n" - f"ST: {DEVICE_TYPE}\r\n" - f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n" - f"\r\n" - ) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.sendto(response.encode("utf-8"), addr) - sock.close() - -def ssdp_listener(host_ip): - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((SSDP_MULTICAST, SSDP_PORT)) - while True: - data, addr = sock.recvfrom(1024) - if b"M-SEARCH" in data and DEVICE_TYPE.encode("utf-8") in data: - print(f"Received M-SEARCH from {addr}") - ssdp_response(addr, host_ip) - -def ssdp_broadcaster(host_ip): - notify = ( - f"NOTIFY * HTTP/1.1\r\n" - f"HOST: {SSDP_MULTICAST}:{SSDP_PORT}\r\n" - f"CACHE-CONTROL: max-age=1800\r\n" - f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n" - f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n" - f"NT: {DEVICE_TYPE}\r\n" - f"NTS: ssdp:alive\r\n" - f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n" - f"\r\n" - ) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) - while True: - sock.sendto(notify.encode("utf-8"), (SSDP_MULTICAST, SSDP_PORT)) - gevent.sleep(30) # Replace time.sleep with gevent.sleep - -def start_ssdp(): - host_ip = get_host_ip() - threading.Thread(target=ssdp_listener, args=(host_ip,), daemon=True).start() - threading.Thread(target=ssdp_broadcaster, args=(host_ip,), daemon=True).start() - print(f"SSDP services started on {host_ip}.") From 3801dd7aab4c2722809a1bb133e19e6004de69bf Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 14:03:30 -0500 Subject: [PATCH 50/63] Security: Implement network access checks for HDHomeRun discovery endpoints and remove dangerouslySetInnerHTML from M3U Profile regex preview to prevent XSS vulnerabilities --- CHANGELOG.md | 2 ++ apps/hdhr/api_views.py | 27 ++++++++++++++ frontend/src/components/forms/M3UProfile.jsx | 37 +++++++++++++++----- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40545381..e98d8e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **HDHomeRun discovery endpoints now respect the `M3U_EPG` network access policy**. `DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, and `HDHRDeviceXMLAPIView` were marked `AllowAny` so HDHR clients (Plex, Emby, Jellyfin, Channels DVR, etc.) can discover the tuner without authenticating, but they were not gated by any network allowlist. The lineup enumerates every channel name and per-channel UUID stream URL, so any client that could reach the server could full-enumerate the lineup. All four views now call `network_access_allowed(request, "M3U_EPG")` and return `403 Forbidden` for clients outside the allowlist, matching the gating already applied to the M3U and EPG endpoints (and matching what the Network Access settings UI already advertised: "Limit access to M3U, EPG, and HDHR URLs"). Operators with a restrictive `M3U_EPG` policy will see HDHR discovery start being blocked for off-LAN clients on upgrade; loosen the policy if remote HDHR access is required. +- **Removed `dangerouslySetInnerHTML` from the M3U Profile regex preview**. The "Matched Text" preview in the M3U Profile editor built an HTML string by interpolating the user's sample input into a `<mark>` wrapper and rendering it via `dangerouslySetInnerHTML`. A crafted sample input (admin-only, so self-XSS only) could inject arbitrary HTML into the preview pane. The preview now returns an array of plain strings and `<mark>` React elements, so user input is always treated as text by React. - **Authorization on DVR recording playback endpoints**. `RecordingViewSet.file` and `RecordingViewSet.hls` now require an authenticated session and enforce a per-user channel-access check before serving any bytes. Admins (`user_level >= 10`) are always allowed; standard users are allowed only when the recording's source channel is visible under their channel-profile assignments and within their `user_level`, mirroring the same logic used by `stream_xc` for live channels. Unauthenticated requests now receive `403 Forbidden` instead of being served. The pre-existing `network_access_allowed(request, "STREAMS")` perimeter check is retained as a separate, prior gate so external IPs can be blocked from streaming entirely even with a valid token. ### Fixed diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 539adffe..b1d0e852 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -18,11 +18,22 @@ from django.views import View from django.utils.decorators import method_decorator from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import csrf_exempt +from dispatcharr.utils import network_access_allowed # Configure logger logger = logging.getLogger(__name__) +def _hdhr_network_check(request): + """Return a 403 JsonResponse if the client IP is not allowed by the + M3U_EPG network access policy. HDHR discovery endpoints expose channel + inventory and stream URLs, so they share the same allowlist as M3U/EPG. + """ + if not network_access_allowed(request, "M3U_EPG"): + return JsonResponse({"error": "Forbidden"}, status=403) + return None + + @login_required def hdhr_dashboard_view(request): """Render the HDHR management page.""" @@ -53,6 +64,10 @@ class DiscoverAPIView(APIView): description="Retrieve HDHomeRun device discovery information", ) def get(self, request, profile=None): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + uri_parts = ["hdhr"] if profile is not None: uri_parts.append(profile) @@ -106,6 +121,10 @@ class LineupAPIView(APIView): description="Retrieve the available channel lineup", ) def get(self, request, profile=None): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + if profile is not None: channel_profile = ChannelProfile.objects.get(name=profile) channels = Channel.objects.filter( @@ -147,6 +166,10 @@ class LineupStatusAPIView(APIView): description="Retrieve the HDHomeRun lineup status", ) def get(self, request, profile=None): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + data = { "ScanInProgress": 0, "ScanPossible": 0, @@ -165,6 +188,10 @@ class HDHRDeviceXMLAPIView(APIView): description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): + blocked = _hdhr_network_check(request) + if blocked is not None: + return blocked + base_url = request.build_absolute_uri("/hdhr/").rstrip("/") xml_response = f"""<?xml version="1.0" encoding="utf-8"?> diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index f92039d0..1243a5ca 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -294,15 +294,35 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setXcMode(mode); }; - // Local regex for the live demo preview + // Local regex for the live demo preview. Returns an array of strings and + // <mark> React nodes so user-supplied text is never interpolated into raw + // HTML (avoids self-XSS via dangerouslySetInnerHTML). const getHighlightedSearchText = () => { if (!searchPattern || !sampleInput) return sampleInput; try { const regex = new RegExp(searchPattern, 'g'); - return sampleInput.replace( - regex, - (match) => `<mark style="background-color: #ffee58;">${match}</mark>` - ); + const parts = []; + let lastIndex = 0; + let m; + while ((m = regex.exec(sampleInput)) !== null) { + if (m.index > lastIndex) { + parts.push(sampleInput.slice(lastIndex, m.index)); + } + parts.push( + <mark + key={`${m.index}-${parts.length}`} + style={{ backgroundColor: '#ffee58' }} + > + {m[0]} + </mark> + ); + lastIndex = m.index + m[0].length; + if (m[0].length === 0) regex.lastIndex++; + } + if (lastIndex < sampleInput.length) { + parts.push(sampleInput.slice(lastIndex)); + } + return parts; } catch { return sampleInput; } @@ -529,11 +549,10 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { </Text> <Text size="sm" - dangerouslySetInnerHTML={{ - __html: getHighlightedSearchText(), - }} sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }} - /> + > + {getHighlightedSearchText()} + </Text> </Paper> </Grid.Col> From db6755a009e587c21c0f368ac9d2701a2ec677ff Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 14:28:26 -0500 Subject: [PATCH 51/63] Enhancement: Add database index on Stream.name to improve query performance and reduce disk spill during sorting (Fixes #1209) --- CHANGELOG.md | 2 +- .../migrations/0036_alter_stream_name.py | 18 ++++++++++++++++++ apps/channels/models.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 apps/channels/migrations/0036_alter_stream_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e98d8e20..3df32e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,8 +61,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Eliminated a redundant `Stream` DB lookup on stream switches. `get_stream_info_for_switch()` already fetches the `Stream` object to build the URL; it now includes `stream_name` in its return dict. `change_stream_url()` captures that value and passes it through to `_update_channel_metadata()`, which skips its own `Stream.objects.filter()` when the name is already known. - **Dedicated thread-pool Celery worker for DVR recordings**. `run_recording` is long-running and almost entirely I/O-bound: it loops in short ticks polling FFmpeg, the DB, and Redis for the full duration of the recording. Running it on the default prefork worker pool (`--autoscale=6,1`) meant at most 6 concurrent recordings, and only if no other background work was running. M3U refreshes, EPG parsing, channel matching, comskip, etc. all competed for the same 6 slots, so if every worker was busy when a recording's start time arrived, the task would queue in Redis and FFmpeg would not start until a worker freed up, causing the user to miss the beginning of the show. - A second Celery worker is now started alongside the default one, configured with `--pool=threads --concurrency=20` and bound to a dedicated `dvr` queue. Threads fit this workload because every blocking call in `run_recording` releases the GIL (sleep, subprocess wait, file / DB / Redis I/O) and FFmpeg itself runs in a separate OS process. A `task_routes` entry in `dispatcharr/celery.py` routes `apps.channels.tasks.run_recording` to the `dvr` queue regardless of how it is dispatched (Celery Beat `PeriodicTask`, `.delay()`, etc.). The default prefork worker now subscribes only to the `celery` queue (`-Q celery -n default@%h --autoscale=6,1`), so recordings can no longer starve background tasks and background tasks can no longer delay recordings. - - Net result: up to 20 concurrent recordings, zero startup delay regardless of EPG / M3U refresh activity, and a memory cost of roughly 80 to 120 MB for the second always-on worker process. Each additional concurrent recording costs nearly nothing on top because all 20 threads share that single process. Applied to AIO (`docker/uwsgi.ini`), dev (`docker/uwsgi.dev.ini`), debug (`docker/uwsgi.debug.ini`), and the modular celery container (`docker/entrypoint.celery.sh`). +- **Added database index on `Stream.name`**. The stream list default sort is `ORDER BY name`, but the column had no index, causing full table scans that spilled to disk (observed at ~38 MB temp file / ~800 ms) on large stream libraries. `db_index=True` is now set on the field, letting PostgreSQL satisfy the sort via an index scan with no disk spill. (Fixes #1209) ### Changed diff --git a/apps/channels/migrations/0036_alter_stream_name.py b/apps/channels/migrations/0036_alter_stream_name.py new file mode 100644 index 00000000..a008cb60 --- /dev/null +++ b/apps/channels/migrations/0036_alter_stream_name.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-30 19:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0035_alter_channel_name_alter_stream_name'), + ] + + operations = [ + migrations.AlterField( + model_name='stream', + name='name', + field=models.CharField(db_index=True, default='Default Stream', max_length=512), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 1c989855..c0de0fe1 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -56,7 +56,7 @@ class Stream(models.Model): Represents a single stream (e.g. from an M3U source or custom URL). """ - name = models.CharField(max_length=512, default="Default Stream") + name = models.CharField(max_length=512, default="Default Stream", db_index=True) url = models.URLField(max_length=4096, blank=True, null=True) m3u_account = models.ForeignKey( M3UAccount, From 44b1b3b5725bd096182f37ac89382cffc8b99ff1 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 15:29:00 -0500 Subject: [PATCH 52/63] Enhancement: Optimize StreamsTable data fetching by reducing unnecessary requests and improving filter handling --- CHANGELOG.md | 1 + .../src/components/tables/StreamsTable.jsx | 137 +++++++++++------- 2 files changed, 86 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df32e44..9afda4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A second Celery worker is now started alongside the default one, configured with `--pool=threads --concurrency=20` and bound to a dedicated `dvr` queue. Threads fit this workload because every blocking call in `run_recording` releases the GIL (sleep, subprocess wait, file / DB / Redis I/O) and FFmpeg itself runs in a separate OS process. A `task_routes` entry in `dispatcharr/celery.py` routes `apps.channels.tasks.run_recording` to the `dvr` queue regardless of how it is dispatched (Celery Beat `PeriodicTask`, `.delay()`, etc.). The default prefork worker now subscribes only to the `celery` queue (`-Q celery -n default@%h --autoscale=6,1`), so recordings can no longer starve background tasks and background tasks can no longer delay recordings. - Net result: up to 20 concurrent recordings, zero startup delay regardless of EPG / M3U refresh activity, and a memory cost of roughly 80 to 120 MB for the second always-on worker process. Each additional concurrent recording costs nearly nothing on top because all 20 threads share that single process. Applied to AIO (`docker/uwsgi.ini`), dev (`docker/uwsgi.dev.ini`), debug (`docker/uwsgi.debug.ini`), and the modular celery container (`docker/entrypoint.celery.sh`). - **Added database index on `Stream.name`**. The stream list default sort is `ORDER BY name`, but the column had no index, causing full table scans that spilled to disk (observed at ~38 MB temp file / ~800 ms) on large stream libraries. `db_index=True` is now set on the field, letting PostgreSQL satisfy the sort via an index scan with no disk spill. (Fixes #1209) +- **Streams table now only refetches what changed**. Loading the Channels page fired `/streams/`, `/streams/ids/`, and `/streams/filter-options/` together via `Promise.all`, and the trio refired on every pagination, sort, or filter change. The IDs list and filter options don't depend on page or sort order, so they were re-pulled needlessly (~440 KB per pagination round trip on a 70k-stream library). `fetchData` was split into a `fetchPageData` for the visible rows (depends on page + sort + filters) plus dedicated effects for the IDs and filter-options endpoints (filters only). Initial-load timing is unchanged (still parallelized), but every subsequent paginate or sort toggle now hits only `/streams/`. ### Changed diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 0197631b..626e4530 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -202,6 +202,9 @@ const StreamsTable = ({ onReady }) => { const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress + const initialDataCountRef = useRef(null); // First page count, kept in a ref so the page fetcher doesn't recreate when set + const lastIdsParamsRef = useRef(null); // De-dupe StrictMode double-fire of the IDs fetch + const lastFilterOptionsParamsRef = useRef(null); // De-dupe StrictMode double-fire of the filter-options fetch // Channel creation modal state (bulk) const [channelNumberingModalOpen, setChannelNumberingModalOpen] = @@ -586,16 +589,30 @@ const StreamsTable = ({ onReady }) => { })); }; - const fetchData = useCallback( + // Build a URLSearchParams object containing only the filter portion of the + // query. Page-rows fetches add page/page_size/ordering on top of this. + const buildFilterParams = useCallback(() => { + const params = new URLSearchParams(); + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (typeof value === 'boolean') { + if (value) params.append(key, 'true'); + } else if (value !== null && value !== undefined && value !== '') { + params.append(key, String(value)); + } + }); + return params; + }, [debouncedFilters]); + + // Fetch the visible page of stream rows. Depends on pagination, sorting, + // and filters. + const fetchPageData = useCallback( async ({ showLoader = true } = {}) => { - const params = new URLSearchParams(); + const params = buildFilterParams(); params.append('page', pagination.pageIndex + 1); params.append('page_size', pagination.pageSize); - // Apply sorting if (sorting.length > 0) { const columnId = sorting[0].id; - // Map frontend column IDs to backend field names const fieldMapping = { name: 'name', group: 'channel_group__name', @@ -607,15 +624,6 @@ const StreamsTable = ({ onReady }) => { params.append('ordering', `${sortDirection}${sortField}`); } - // Apply debounced filters; send boolean filters as 'true' when set - Object.entries(debouncedFilters).forEach(([key, value]) => { - if (typeof value === 'boolean') { - if (value) params.append(key, 'true'); - } else if (value !== null && value !== undefined && value !== '') { - params.append(key, String(value)); - } - }); - const paramsString = params.toString(); // Skip if same fetch is already in progress (prevents StrictMode double-fetch) @@ -626,7 +634,6 @@ const StreamsTable = ({ onReady }) => { return; } - // Increment fetch version to track this specific fetch request const currentFetchVersion = ++fetchVersionRef.current; lastFetchParamsRef.current = paramsString; fetchInProgressRef.current = true; @@ -636,45 +643,19 @@ const StreamsTable = ({ onReady }) => { } try { - const [result, ids, filterOptions] = await Promise.all([ - API.queryStreamsTable(params), - API.getAllStreamIds(params), - API.getStreamFilterOptions(params), - ]); + const result = await API.queryStreamsTable(params); fetchInProgressRef.current = false; - // Skip state updates if a newer fetch has been initiated if (currentFetchVersion !== fetchVersionRef.current) { return; } - setAllRowIds(ids); - - // Set filtered options based on current filters - // Ensure groupOptions is always an array of valid strings - if (filterOptions && typeof filterOptions === 'object') { - setGroupOptions( - (filterOptions.groups || []) - .filter((group) => group != null && group !== '') - .map((group) => String(group)) - ); - // Ensure m3uOptions is always an array of valid objects - setM3uOptions( - (filterOptions.m3u_accounts || []) - .filter((m3u) => m3u && m3u.id != null && m3u.name) - .map((m3u) => ({ - label: String(m3u.name), - value: String(m3u.id), - })) - ); - } - - if (initialDataCount === null) { + if (initialDataCountRef.current === null) { + initialDataCountRef.current = result.count; setInitialDataCount(result.count); } - // Signal that initial data load is complete if (!hasSignaledReady.current && onReady) { hasSignaledReady.current = true; onReady(); @@ -682,14 +663,12 @@ const StreamsTable = ({ onReady }) => { } catch (error) { fetchInProgressRef.current = false; - // Skip logging if a newer fetch has been initiated if (currentFetchVersion !== fetchVersionRef.current) { return; } console.error('Error fetching data:', error); } - // Skip state updates if a newer fetch has been initiated if (currentFetchVersion !== fetchVersionRef.current) { return; } @@ -699,7 +678,7 @@ const StreamsTable = ({ onReady }) => { setIsLoading(false); } }, - [pagination, sorting, debouncedFilters, onReady] + [pagination, sorting, buildFilterParams, onReady] ); // Bulk creation: create channels from selected streams asynchronously @@ -1320,19 +1299,73 @@ const StreamsTable = ({ onReady }) => { * useEffects */ useEffect(() => { - // Load data independently, don't wait for logos or other data - fetchData(); - }, [fetchData]); + // Load page rows independently, don't wait for logos or other data + fetchPageData(); + }, [fetchPageData]); - // Refetch data when video player closes to update stream stats + // The full ID list and filter options only depend on filters, not pagination + // or sort order, so they get their own effects to avoid refetching on every + // page change or sort toggle. + useEffect(() => { + const params = buildFilterParams(); + const paramsString = params.toString(); + if (lastIdsParamsRef.current === paramsString) { + return; + } + lastIdsParamsRef.current = paramsString; + let cancelled = false; + (async () => { + const ids = await API.getAllStreamIds(params); + if (!cancelled && ids) { + setAllRowIds(ids); + } + })(); + return () => { + cancelled = true; + }; + }, [buildFilterParams, setAllRowIds]); + + useEffect(() => { + const params = buildFilterParams(); + const paramsString = params.toString(); + if (lastFilterOptionsParamsRef.current === paramsString) { + return; + } + lastFilterOptionsParamsRef.current = paramsString; + let cancelled = false; + (async () => { + const filterOptions = await API.getStreamFilterOptions(params); + if (cancelled || !filterOptions || typeof filterOptions !== 'object') { + return; + } + setGroupOptions( + (filterOptions.groups || []) + .filter((group) => group != null && group !== '') + .map((group) => String(group)) + ); + setM3uOptions( + (filterOptions.m3u_accounts || []) + .filter((m3u) => m3u && m3u.id != null && m3u.name) + .map((m3u) => ({ + label: String(m3u.name), + value: String(m3u.id), + })) + ); + })(); + return () => { + cancelled = true; + }; + }, [buildFilterParams]); + + // Refetch page rows when video player closes to update stream stats const prevVideoVisible = useRef(false); useEffect(() => { if (prevVideoVisible.current && !videoIsVisible) { // Video was closed, refetch to get updated stream stats - fetchData({ showLoader: false }); + fetchPageData({ showLoader: false }); } prevVideoVisible.current = videoIsVisible; - }, [videoIsVisible, fetchData]); + }, [videoIsVisible, fetchPageData]); useEffect(() => { if ( From 2ef9ab0dcffdc3e8e93e1794991192f4df496f32 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 16:01:55 -0500 Subject: [PATCH 53/63] Refactor: Remove unused stream deactivation and filtering methods from M3UAccount and M3UFilter classes --- apps/m3u/models.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/apps/m3u/models.py b/apps/m3u/models.py index fbe22d8c..c1cb5a99 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -111,18 +111,6 @@ class M3UAccount(models.Model): def display_action(self): return "Exclude" if self.exclude else "Include" - def deactivate_streams(self): - """Deactivate all streams linked to this account.""" - for stream in self.streams.all(): - stream.is_active = False - stream.save() - - def reactivate_streams(self): - """Reactivate all streams linked to this account.""" - for stream in self.streams.all(): - stream.is_active = True - stream.save() - @classmethod def get_custom_account(cls): return cls.objects.get(name=CUSTOM_M3U_ACCOUNT_NAME, locked=True) @@ -205,25 +193,6 @@ class M3UFilter(models.Model): exclude_status = "Exclude" if self.exclude else "Include" return f"[{self.m3u_account.name}] {filter_type_display}: {self.regex_pattern} ({exclude_status})" - @staticmethod - def filter_streams(streams, filters): - included_streams = set() - excluded_streams = set() - - for f in filters: - for stream in streams: - if f.applies_to(stream.name, stream.group_name): - if f.exclude: - excluded_streams.add(stream) - else: - included_streams.add(stream) - - # If no include filters exist, assume all non-excluded streams are valid - if not any(not f.exclude for f in filters): - return streams.exclude(id__in=[s.id for s in excluded_streams]) - - return streams.filter(id__in=[s.id for s in included_streams]) - class ServerGroup(models.Model): """Represents a logical grouping of servers or channels.""" From f2069caa623322eaa434b1f230fdb6f0389b3298 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 16:11:37 -0500 Subject: [PATCH 54/63] Refactor: Simplify orphaned channel deletion and optimize M3UAccount fetching in send_m3u_update --- apps/m3u/tasks.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index e724e655..0a441e18 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -2354,12 +2354,11 @@ def sync_auto_channels(account_id, scan_start_time=None): ).values_list('channel_id', flat=True) ) - orphaned_count = orphaned_channels.count() - if orphaned_count > 0: - orphaned_channels.delete() - channels_deleted += orphaned_count + deleted_total, _ = orphaned_channels.delete() + if deleted_total: + channels_deleted += deleted_total logger.info( - f"Deleted {orphaned_count} orphaned auto channels with no valid streams" + f"Deleted {deleted_total} orphaned auto channels with no valid streams" ) logger.info( @@ -3188,16 +3187,17 @@ def send_m3u_update(account_id, action, progress, **kwargs): "action": action, } - # Add the status and message if not already in kwargs - try: - account = M3UAccount.objects.get(id=account_id) - if account: + # Only fetch the account when we actually need to fill in missing fields. + # Many callers in tight loops already pass status/message; skip the DB hit then. + if "status" not in kwargs or "message" not in kwargs: + try: + account = M3UAccount.objects.only("status", "last_message").get(id=account_id) if "status" not in kwargs: data["status"] = account.status if "message" not in kwargs and account.last_message: data["message"] = account.last_message - except: - pass # If account can't be retrieved, continue without these fields + except Exception: + pass # Add the additional key-value pairs from kwargs data.update(kwargs) From a5ac0e78fcb7725dbf2166fe03a6f36fa433833b Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 16:22:36 -0500 Subject: [PATCH 55/63] Enhancement: Optimize Redis writes in StreamBuffer by using pipelining for chunk storage and timestamp management --- apps/proxy/ts_proxy/stream_buffer.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/proxy/ts_proxy/stream_buffer.py b/apps/proxy/ts_proxy/stream_buffer.py index b61e7794..0ab2b1a7 100644 --- a/apps/proxy/ts_proxy/stream_buffer.py +++ b/apps/proxy/ts_proxy/stream_buffer.py @@ -103,19 +103,23 @@ class StreamBuffer: chunk_data = self._write_buffer[:self.target_chunk_size] self._write_buffer = self._write_buffer[self.target_chunk_size:] - # Write optimized chunk to Redis + # Write optimized chunk to Redis. We need the new index from + # incr() to build the chunk key, so issue that first; the + # remaining writes are pipelined into one round trip. if self.redis_client: chunk_index = self.redis_client.incr(self.buffer_index_key) chunk_key = RedisKeys.buffer_chunk(self.channel_id, chunk_index) - self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(chunk_data)) - # Record receive timestamp for time-based client positioning + pipe = self.redis_client.pipeline(transaction=False) + pipe.setex(chunk_key, self.chunk_ttl, bytes(chunk_data)) + if self.chunk_timestamps_key: now = time.time() - self.redis_client.zadd(self.chunk_timestamps_key, {str(chunk_index): now}) - # Prune entries whose chunks have expired from Redis - self.redis_client.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl) - self.redis_client.expire(self.chunk_timestamps_key, self.chunk_ttl) + pipe.zadd(self.chunk_timestamps_key, {str(chunk_index): now}) + pipe.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl) + pipe.expire(self.chunk_timestamps_key, self.chunk_ttl) + + pipe.execute() # Update local tracking self.index = chunk_index From 8e858045d6debbcd0e1d4bcdb9328be09fe72266 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 16:23:51 -0500 Subject: [PATCH 56/63] Enhancement: Throttle Redis stats writes in StreamGenerator to reduce traffic during high viewer load --- apps/proxy/ts_proxy/stream_generator.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py index df74b8d1..3e73c43e 100644 --- a/apps/proxy/ts_proxy/stream_generator.py +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -66,6 +66,11 @@ class StreamGenerator: self.last_ttl_refresh = time.time() self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming + # Throttle per-client stats writes to Redis. + # channels with many viewers. + self.last_stats_write = 0.0 + self.stats_write_interval = 1.0 + # Cached proxy server reference self.proxy_server = None @@ -448,9 +453,12 @@ class StreamGenerator: logger.debug(f"[{self.client_id}] Stats: {self.chunks_sent} chunks, {self.bytes_sent/1024:.1f} KB, " f"avg: {avg_rate:.1f} KB/s, current: {self.current_rate:.1f} KB/s") - # Store stats in Redis client metadata - if proxy_server.redis_client: + # Store stats in Redis client metadata, throttled to avoid an + # hset on every chunk. Frontend stats panels poll on the order + # of seconds, so 1s resolution is sufficient. + if proxy_server.redis_client and (current_time - self.last_stats_write) >= self.stats_write_interval: try: + self.last_stats_write = current_time client_key = RedisKeys.client_metadata(self.channel_id, self.client_id) stats = { ChannelMetadataField.CHUNKS_SENT: str(self.chunks_sent), From 022270efa0197146876cedf60be21b068a71b31a Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 16:34:55 -0500 Subject: [PATCH 57/63] changelog: Update changelog for recent changes. --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9afda4fb..19462cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Performance +- **TS proxy buffer writes pipelined**: `StreamBuffer.add_chunk` previously issued five separate Redis commands per buffered chunk (`incr`, `setex`, `zadd`, `zremrangebyscore`, `expire`), each a full round trip. The `incr` is still issued first because its return value is needed to build the chunk key, but the remaining four commands are now queued on a non-transactional pipeline and flushed in a single `execute()` call. Drops per-chunk Redis round trips from 5 to 2 on busy channels. +- **TS proxy per-client stats writes throttled**: `ClientStreamer._process_chunks` was issuing a Redis `hset` of client stats (chunks sent, bytes sent, transfer rates, last_active) on every chunk delivered to every client. The `hset` is now gated to once per second per client via a new `stats_write_interval` throttle, matching the actual polling cadence of the frontend stats panel. The TTL refresh logic and in-memory rate calculations are unchanged. Dramatically reduces Redis traffic on channels with many concurrent viewers. +- **`send_m3u_update` skips redundant `M3UAccount` lookup**: the helper used to re-fetch the `M3UAccount` row on every progress tick just to populate `status` and `last_message`. It now skips the query entirely when the caller has already supplied both fields in `kwargs`, and uses `.only("status", "last_message")` when the lookup is needed. Removes thousands of pointless `SELECT` queries from M3U download and processing progress streams. +- **M3U auto-channel orphan cleanup**: replaced a redundant `orphaned_channels.count()` followed by `.delete()` with a single `qs.delete()` call that uses the count returned by Django's delete. Saves one COUNT query per auto-sync run. - **Channel table performance**: - Removed unused Zustand store subscriptions (`channels`, `selectedProfileChannels`, `selectedProfileChannelIds`) and an unused `channelIds` array subscription from `ChannelsTable` to reduce unnecessary re-renders on unrelated store updates. Cleaned up associated dead code and unused imports. - Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization. @@ -84,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **Dead M3U helper methods**: deleted `M3UAccount.deactivate_streams`, `M3UAccount.reactivate_streams`, and `M3UFilter.filter_streams`. None had any callers anywhere in the codebase. New code that needs to flip `is_active` on every stream of an account should use `self.streams.update(is_active=...)` rather than reintroducing a per-row `.save()` loop. - **HDHomeRun SSDP advertiser**. The `apps/hdhr/ssdp.py` module and the `start_ssdp()` call in `apps.hdhr.apps.HdhrConfig.ready()` have been removed. The implementation advertised a `urn:schemas-upnp-org:device:MediaServer:1` UPnP MediaServer device on `udp/1900`, but Dispatcharr does not implement the UPnP MediaServer Content Directory Service that such an advertisement promises, and the `LOCATION`-targeted `/hdhr/device.xml` returns HDHomeRun-flavored XML rather than the UPnP descriptor a generic UPnP browser expects. None of the major HDHomeRun clients (Plex, Emby, Jellyfin, Channels DVR, Kodi) use SSDP for tuner discovery, they use SiliconDust's native UDP broadcast on `udp/65001`, which Dispatcharr does not currently implement. The advertiser ran a listener thread, a broadcaster thread, a bound multicast socket, and emitted a `NOTIFY` broadcast every 30 seconds for no consumer. Tuners can still be added in any HDHomeRun-aware client by entering the Dispatcharr URL manually (e.g. `http://<host>:9191/`). ## [0.23.0] - 2026-04-17 From a80af16e6ae5c006f53ba6746c07fea103bb55b9 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 17:46:49 -0500 Subject: [PATCH 58/63] Enhancement: Implement targeted stream-stats refresh for channel table using a new lightweight delta endpoint --- CHANGELOG.md | 1 + apps/channels/api_urls.py | 2 + apps/channels/api_views.py | 93 +++++++++++++++++++ frontend/src/api.js | 21 +++++ .../components/tables/ChannelTableStreams.jsx | 65 ++++++++++++- frontend/src/store/channelsTable.jsx | 39 ++++++++ 6 files changed, 218 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19462cd7..7f1e54d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) - **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) +- **Targeted stream-stats refresh in the channel table**: expanding a channel row now refreshes `stream_stats` for that channel's streams via a new lightweight delta endpoint (`GET /api/channels/channels/{channel_id}/streams/stats/`) instead of relying on a full channel-list re-fetch. The endpoint accepts a `since` (ISO 8601) cursor and an optional `ids` filter and returns only streams whose `stream_stats_updated_at` is strictly newer than the cursor, so the response is empty when nothing has changed. The frontend computes the cursor on demand from the streams already in the store, fires the request once on row expand, and fires it again scoped to a single stream ID when the in-app preview player closes. A new `patchChannelStreamStats` Zustand mutator merges the response into the store while preserving object identity for unchanged channels and streams, so memoized rows do not re-render. This restores the live-stats refresh behaviour that the channel-table performance work removed (`requeryChannels()`) without re-introducing the page-wide re-fetch. - **Editable default M3U profile patterns**: the default profile in the M3U profiles editor now exposes Search Pattern and Replace Pattern fields, allowing users to apply a URL transformation to every stream in the playlist (useful for replacing a local IP address, for example). A warning alert explains the fallback behaviour. A "Reset to Defaults" button restores the pass-through patterns (`^(.*)$` / `$1`). The live regex demonstration panel is also shown for the default profile. The backend serializer and `transform_url` were updated accordingly: `search_pattern` and `replace_pattern` are now permitted fields when updating a default profile, and `transform_url` uses `regex.subn()` to detect a genuine non-matches, logging a `WARNING` when the pattern does not match any part of the URL. ### Performance diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index b2af3167..756e2341 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -15,6 +15,7 @@ from .api_views import ( RecordingViewSet, RecurringRecordingRuleViewSet, GetChannelStreamsAPIView, + GetChannelStreamStatsAPIView, SeriesRulesAPIView, DeleteSeriesRuleAPIView, EvaluateSeriesRulesAPIView, @@ -41,6 +42,7 @@ urlpatterns = [ path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'), path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'), path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'), + path('channels/<int:channel_id>/streams/stats/', GetChannelStreamStatsAPIView.as_view(), name='get_channel_stream_stats'), path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), # DVR series rules (order matters: specific routes before catch-all slug) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index d3c76f88..5ef575a0 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2237,6 +2237,99 @@ class GetChannelStreamsAPIView(APIView): return Response(serializer.data) +class GetChannelStreamStatsAPIView(APIView): + """Returns a stats delta for a channel's streams (id, stream_stats, + stream_stats_updated_at). Supports `since` (ISO 8601) and `ids` + (comma-separated) query params.""" + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description=( + "Return a minimal stats delta for the streams attached to a " + "channel. Used by the channel table to refresh `stream_stats` " + "on row expand and after a preview closes without re-pulling " + "full stream rows." + ), + parameters=[ + OpenApiParameter( + name="since", + type=OpenApiTypes.DATETIME, + location=OpenApiParameter.QUERY, + required=False, + description=( + "ISO 8601 timestamp. Returns only streams whose " + "`stream_stats_updated_at` is strictly newer than this " + "value. Omit to return all streams for the channel." + ), + ), + OpenApiParameter( + name="ids", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description=( + "Comma-separated stream IDs to restrict the response " + "to. Combined with `since` via AND." + ), + ), + ], + responses={ + 200: inline_serializer( + name="ChannelStreamStatsDelta", + fields={ + "id": serializers.IntegerField(), + "stream_stats": serializers.JSONField(allow_null=True), + "stream_stats_updated_at": serializers.DateTimeField(allow_null=True), + }, + many=True, + ), + 400: inline_serializer( + name="ChannelStreamStatsErrorResponse", + fields={"detail": serializers.CharField()}, + ), + }, + ) + def get(self, request, channel_id): + from django.utils.dateparse import parse_datetime + + get_object_or_404(Channel, id=channel_id) + + qs = Stream.objects.filter(channels=channel_id) + + since_raw = request.query_params.get("since") + if since_raw: + since_dt = parse_datetime(since_raw) + if since_dt is None: + return Response( + {"detail": "Invalid 'since' value. Expected ISO 8601."}, + status=status.HTTP_400_BAD_REQUEST, + ) + qs = qs.filter(stream_stats_updated_at__gt=since_dt) + + ids_raw = request.query_params.get("ids") + if ids_raw: + try: + ids = [int(x) for x in ids_raw.split(",") if x.strip()] + except ValueError: + return Response( + {"detail": "Invalid 'ids' value. Expected comma-separated integers."}, + status=status.HTTP_400_BAD_REQUEST, + ) + qs = qs.filter(id__in=ids) + + data = list( + qs.values("id", "stream_stats", "stream_stats_updated_at") + ) + return Response(data) + + class UpdateChannelMembershipAPIView(APIView): permission_classes = [IsOwnerOfObject] diff --git a/frontend/src/api.js b/frontend/src/api.js index 761cb259..ff080c88 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1007,6 +1007,27 @@ export default class API { } } + /** + * Fetches a stats delta for a channel's streams. Errors are swallowed + * since this is a background refresh. + */ + static async getChannelStreamStats(channelId, since, ids) { + try { + const params = new URLSearchParams(); + if (since) params.set('since', since); + if (Array.isArray(ids) && ids.length > 0) { + params.set('ids', ids.join(',')); + } + const qs = params.toString(); + const response = await request( + `${host}/api/channels/channels/${channelId}/streams/stats/${qs ? `?${qs}` : ''}` + ); + return Array.isArray(response) ? response : []; + } catch (e) { + return []; + } + } + static async queryStreams(params) { try { const response = await request( diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 2e3ddb96..237f0c83 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -340,7 +340,8 @@ const StreamInfoCell = React.memo( onClick={() => handleWatchStream( stream.stream_hash || stream.id, - stream.name + stream.name, + stream.id ) } style={{ marginLeft: 2 }} @@ -496,18 +497,25 @@ const ChannelStreams = ({ channel }) => { (state) => state.getChannelStreams(channel.id), shallow ); + const patchChannelStreamStats = useChannelsTableStore( + (s) => s.patchChannelStreamStats + ); const playlists = usePlaylistsStore((s) => s.playlists); const authUser = useAuthStore((s) => s.user); const showVideo = useVideoStore((s) => s.showVideo); + const isVideoVisible = useVideoStore((s) => s.isVisible); const env_mode = useSettingsStore((s) => s.environment.env_mode); const handleWatchStream = useCallback( - (streamHash, streamName) => { + (streamHash, streamName, streamId) => { let vidUrl = `/proxy/ts/stream/${streamHash}`; if (env_mode === 'dev') { vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; } - showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); + const meta = {}; + if (streamName) meta.name = streamName; + if (streamId != null) meta.streamId = streamId; + showVideo(vidUrl, 'live', Object.keys(meta).length ? meta : null); }, [env_mode, showVideo] ); @@ -526,6 +534,57 @@ const ChannelStreams = ({ channel }) => { const dataIds = useMemo(() => data?.map(({ id }) => id), [data]); + // Fire-and-forget refresh of stream stats. Cursor is the newest + // stream_stats_updated_at already in the store; server returns only + // entries strictly newer than that (empty array when nothing changed). + const refreshStats = useCallback( + (opts) => { + const channelId = channelRef.current?.id; + if (!channelId) return; + const streams = dataRef.current || []; + let since = null; + for (const s of streams) { + const t = s.stream_stats_updated_at; + if (t && (since === null || t > since)) since = t; + } + const ids = opts && opts.ids; + API.getChannelStreamStats(channelId, since, ids).then((updates) => { + if (!updates || updates.length === 0) return; + patchChannelStreamStats(channelId, updates); + }); + }, + [patchChannelStreamStats] + ); + + // Refresh once when the row is expanded. + useEffect(() => { + refreshStats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Refresh just the previewed stream when the floating player closes. + // Metadata is captured while visible because hideVideo clears it. + const prevVisibleRef = useRef(isVideoVisible); + const lastPreviewMetaRef = useRef(null); + useEffect(() => { + if (isVideoVisible) { + lastPreviewMetaRef.current = useVideoStore.getState().metadata; + } + const wasVisible = prevVisibleRef.current; + prevVisibleRef.current = isVideoVisible; + if (wasVisible && !isVideoVisible) { + const meta = lastPreviewMetaRef.current; + lastPreviewMetaRef.current = null; + const streamId = meta && meta.streamId; + if ( + streamId != null && + (dataRef.current || []).some((s) => s.id === streamId) + ) { + refreshStats({ ids: [streamId] }); + } + } + }, [isVideoVisible, refreshStats]); + const removeStream = useCallback(async (stream) => { const newStreamList = dataRef.current.filter((s) => s.id !== stream.id); setData(newStreamList); diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx index baa20925..e8d9445f 100644 --- a/frontend/src/store/channelsTable.jsx +++ b/frontend/src/store/channelsTable.jsx @@ -76,6 +76,45 @@ const useChannelsTableStore = create((set, get) => ({ ), })); }, + + /** + * Merges stream-stats deltas into the target channel's streams. Preserves + * object identity for unchanged streams and channels so memoized rows + * don't re-render. + */ + patchChannelStreamStats: (channelId, updates) => { + if (!Array.isArray(updates) || updates.length === 0) return; + set((state) => { + const updateMap = new Map(updates.map((u) => [u.id, u])); + let channelChanged = false; + const nextChannels = state.channels.map((channel) => { + if (channel.id !== channelId) return channel; + const streams = channel.streams || []; + let streamsChanged = false; + const nextStreams = streams.map((stream) => { + const u = updateMap.get(stream.id); + if (!u) return stream; + if ( + stream.stream_stats_updated_at === u.stream_stats_updated_at && + stream.stream_stats === u.stream_stats + ) { + return stream; + } + streamsChanged = true; + return { + ...stream, + stream_stats: u.stream_stats, + stream_stats_updated_at: u.stream_stats_updated_at, + }; + }); + if (!streamsChanged) return channel; + channelChanged = true; + return { ...channel, streams: nextStreams }; + }); + if (!channelChanged) return state; + return { channels: nextChannels }; + }); + }, })); export default useChannelsTableStore; From 01e356c156e67eeb7068e5f22075acac158df414 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 18:02:40 -0500 Subject: [PATCH 59/63] Enhancement: Include channelId in video playback parameters for better tracking --- .../src/components/tables/ChannelTableStreams.jsx | 15 +++++++++++---- frontend/src/components/tables/ChannelsTable.jsx | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 237f0c83..ad183e98 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -575,12 +575,19 @@ const ChannelStreams = ({ channel }) => { if (wasVisible && !isVideoVisible) { const meta = lastPreviewMetaRef.current; lastPreviewMetaRef.current = null; - const streamId = meta && meta.streamId; + const channelId = channelRef.current?.id; + const previewedStreamId = meta && meta.streamId; + const previewedChannelId = meta && meta.channelId; if ( - streamId != null && - (dataRef.current || []).some((s) => s.id === streamId) + previewedStreamId != null && + (dataRef.current || []).some((s) => s.id === previewedStreamId) ) { - refreshStats({ ids: [streamId] }); + refreshStats({ ids: [previewedStreamId] }); + } else if ( + previewedChannelId != null && + previewedChannelId === channelId + ) { + refreshStats(); } } }, [isVideoVisible, refreshStats]); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 05c548fd..48b84531 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -689,7 +689,7 @@ const ChannelsTable = ({ onReady }) => { const handleWatchStream = useCallback( (channel) => { const url = getChannelURL(channel); - showVideo(url, 'live', { name: channel.name }); + showVideo(url, 'live', { name: channel.name, channelId: channel.id }); }, [getChannelURL, showVideo] ); From 37922a44aba919c04aeb5ef4d8cb714619947ecb Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Thu, 30 Apr 2026 20:33:30 -0500 Subject: [PATCH 60/63] Enhancement: Update user connection reporting in xc_get_info to reflect active connections and max connections based on user stream limits. (Fixes #990) --- CHANGELOG.md | 1 + apps/output/views.py | 11 ++++++++++- apps/proxy/utils.py | 8 ++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f1e54d0..2c94de6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Xtream Codes `player_api.php` missing `active_cons` and reporting wrong `max_connections`**. The `user_info` block returned by the XC API did not include the `active_cons` field, which Enigma2 clients (XStreamity, XKlass) read unconditionally and crash with `KeyError: 'active_cons'` when it is absent. `max_connections` was also hardcoded to the system-wide tuner count for every user, ignoring per-user `stream_limit` configuration. `xc_get_info` now reports `max_connections` as the user's `stream_limit` when set, falling back to the system tuner count for unlimited users; `active_cons` is the user's own active connection count when they have a per-user limit, or the system-wide active connection count when they do not (so unlimited clients can still see how much of the global tuner pool is in use). The existing `get_user_active_connections` helper was generalized to accept `user_id=None` for the system-wide query rather than duplicating its Redis scan logic. (Fixes #990) - **Plugin discovery re-running on every connect event**. The `PluginManager` cache hit check used `if self._registry and ...`, which always evaluated false when zero plugins were installed because an empty dict is falsy. Every Connect event (`recording_start`, `client_connect`, `channel_start`, etc.) was therefore triggering a full filesystem walk of `/data/plugins` and emitting `Discovering plugins (no DB sync) in /data/plugins` / `Discovered 0 plugin(s)` log lines. Tracked separately via a new `_discovery_completed` flag so the cache short-circuits subsequent calls regardless of registry contents, dropping discovery to once per worker process lifetime. - **Empty show/season folders left behind after deleting a recording**. Deleting a recording removes the MKV (or HLS working directory if still in progress) but previously left the parent show / season directories on disk even when they no longer contained any other recordings. After file cleanup, `RecordingViewSet.destroy` now walks up from the deleted file's parent directory and removes any now-empty directories, stopping at the `/data/recordings` library root so the root itself is never touched. - **Premature DVR concat on graceful Celery worker shutdown**. A `worker_shutting_down` signal handler in `apps/channels/tasks.py` now sets a module-level `_DVR_SHUTTING_DOWN` flag. The `run_recording` loop checks this flag after FFmpeg exits and, if the recording's `end_time` has not yet passed, skips concatenation and persists `status="interrupted"` with `interrupted_reason="server_shutdown"`. The HLS working directory is preserved across the restart so the recovery path on the next worker startup can resume segment numbering from where it left off rather than truncating the show. diff --git a/apps/output/views.py b/apps/output/views.py index 5b061f1f..01c7ee60 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -20,6 +20,7 @@ import logging from django.db.models.functions import Lower import os from apps.m3u.utils import calculate_tuner_count +from apps.proxy.utils import get_user_active_connections import regex from core.utils import log_system_event import hashlib @@ -1945,6 +1946,13 @@ def xc_get_info(request, full=False): hostname = raw_host port = "443" if request.is_secure() else "80" + if user.stream_limit and user.stream_limit > 0: + active_cons = len(get_user_active_connections(user.id)) + max_connections = user.stream_limit + else: + active_cons = len(get_user_active_connections(None)) + max_connections = calculate_tuner_count(minimum=1, unlimited_default=50) + info = { "user_info": { "username": request.GET.get("username"), @@ -1953,7 +1961,8 @@ def xc_get_info(request, full=False): "auth": 1, "status": "Active", "exp_date": str(int(time.time()) + (90 * 24 * 60 * 60)), - "max_connections": str(calculate_tuner_count(minimum=1, unlimited_default=50)), + "active_cons": str(active_cons), + "max_connections": str(max_connections), "allowed_output_formats": [ "ts", ], diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index 6503d1df..3e571cc1 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -83,6 +83,10 @@ def attempt_stream_termination(user_id, requesting_client_id, active_connections return False def get_user_active_connections(user_id): + """Return active stream connections for a single user. + + Pass `user_id=None` to return all active connections across the system. + """ redis_client = RedisClient.get_client() connections = [] @@ -101,7 +105,7 @@ def get_user_active_connections(user_id): logger.debug(f"[stream limits] channel_id = {channel_id}") logger.debug(f"[stream limits] client_id = {client_id}") - if client_user_id and int(client_user_id) == user_id: + if user_id is None or (client_user_id and int(client_user_id) == user_id): try: logger.debug(f"[stream limits] Found LIVE connection for user {user_id} on channel {channel_id} with client ID {client_id}") connected_at = float(connected_at) if connected_at else 0 @@ -127,7 +131,7 @@ def get_user_active_connections(user_id): logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] client_id = {client_id}") - if client_user_id and int(client_user_id) == user_id: + if user_id is None or (client_user_id and int(client_user_id) == user_id): try: logger.debug(f"[stream limits] Found VOD connection for user {user_id} on content {content_uuid} with client ID {client_id}") connected_at = float(connected_at) if connected_at else 0 From 1da3ca26de3a71bce6dcc62fa7ac146926865111 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Fri, 1 May 2026 17:54:25 -0500 Subject: [PATCH 61/63] Bug Fix: EPG program times shifted by host offset when `/etc/localtime` is mounted. (Fixes #651) --- CHANGELOG.md | 1 + core/apps.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c94de6f..d475ffe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **EPG program times shifted by the host system's UTC offset when `/etc/localtime` is bind-mounted into the container**. Mounting `/etc/localtime` from a non-UTC host causes PostgreSQL to silently resolve the `'UTC'` timezone name to the host's local timezone (e.g. CDT, CET) rather than actual UTC - even though `SHOW timezone` returns `UTC` and the zoneinfo file exists. This made PostgreSQL format all stored `timestamptz` values with the host's UTC offset, and psycopg2 returned datetimes shifted by that offset, causing every EPG program time to be read back N hours wrong and written to the XML output incorrectly. The fix registers a Django `connection_created` signal that issues `SET TIME ZONE 'UTC0'` on every new database connection. `UTC0` is a POSIX timezone string that bypasses the broken zoneinfo name-lookup path entirely; it resolves unconditionally to UTC+00 regardless of the host timezone or what files are mounted. (Fixes #651) - **Xtream Codes `player_api.php` missing `active_cons` and reporting wrong `max_connections`**. The `user_info` block returned by the XC API did not include the `active_cons` field, which Enigma2 clients (XStreamity, XKlass) read unconditionally and crash with `KeyError: 'active_cons'` when it is absent. `max_connections` was also hardcoded to the system-wide tuner count for every user, ignoring per-user `stream_limit` configuration. `xc_get_info` now reports `max_connections` as the user's `stream_limit` when set, falling back to the system tuner count for unlimited users; `active_cons` is the user's own active connection count when they have a per-user limit, or the system-wide active connection count when they do not (so unlimited clients can still see how much of the global tuner pool is in use). The existing `get_user_active_connections` helper was generalized to accept `user_id=None` for the system-wide query rather than duplicating its Redis scan logic. (Fixes #990) - **Plugin discovery re-running on every connect event**. The `PluginManager` cache hit check used `if self._registry and ...`, which always evaluated false when zero plugins were installed because an empty dict is falsy. Every Connect event (`recording_start`, `client_connect`, `channel_start`, etc.) was therefore triggering a full filesystem walk of `/data/plugins` and emitting `Discovering plugins (no DB sync) in /data/plugins` / `Discovered 0 plugin(s)` log lines. Tracked separately via a new `_discovery_completed` flag so the cache short-circuits subsequent calls regardless of registry contents, dropping discovery to once per worker process lifetime. - **Empty show/season folders left behind after deleting a recording**. Deleting a recording removes the MKV (or HLS working directory if still in progress) but previously left the parent show / season directories on disk even when they no longer contained any other recordings. After file cleanup, `RecordingViewSet.destroy` now walks up from the deleted file's parent directory and removes any now-empty directories, stopping at the `/data/recordings` library root so the root itself is never touched. diff --git a/core/apps.py b/core/apps.py index ee182e89..4b7fed5c 100644 --- a/core/apps.py +++ b/core/apps.py @@ -25,6 +25,14 @@ class CoreConfig(AppConfig): import core.signals from dispatcharr.app_initialization import should_skip_initialization + # Force UTC0 on every new DB connection. + from django.db.backends.signals import connection_created + + def _force_utc0(sender, connection, **kwargs): + connection.cursor().execute("SET TIME ZONE 'UTC0'") + + connection_created.connect(_force_utc0, dispatch_uid='force_db_utc0') + # Sync developer notifications and check for version updates on startup # Only run in the main process (not in management commands, migrations, or workers) if should_skip_initialization(): From 9e4bdf0a09b0a01255e192a93d8c1a4d0c13d967 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Fri, 1 May 2026 21:08:56 -0500 Subject: [PATCH 62/63] Update @xmldom/xmldom to version 0.8.13 and postcss to version 8.5.13 in package-lock.json --- frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5350fab..e239b3db 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2518,9 +2518,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", - "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4387,9 +4387,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { From 013fde4c552023923e9d29bda850ffb671005593 Mon Sep 17 00:00:00 2001 From: SergeantPanda <chester.cullen@gmail.com> Date: Fri, 1 May 2026 21:09:13 -0500 Subject: [PATCH 63/63] changelog: Update changelog for dependency updates. --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d475ffe1..66bb7297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **HDHomeRun discovery endpoints now respect the `M3U_EPG` network access policy**. `DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, and `HDHRDeviceXMLAPIView` were marked `AllowAny` so HDHR clients (Plex, Emby, Jellyfin, Channels DVR, etc.) can discover the tuner without authenticating, but they were not gated by any network allowlist. The lineup enumerates every channel name and per-channel UUID stream URL, so any client that could reach the server could full-enumerate the lineup. All four views now call `network_access_allowed(request, "M3U_EPG")` and return `403 Forbidden` for clients outside the allowlist, matching the gating already applied to the M3U and EPG endpoints (and matching what the Network Access settings UI already advertised: "Limit access to M3U, EPG, and HDHR URLs"). Operators with a restrictive `M3U_EPG` policy will see HDHR discovery start being blocked for off-LAN clients on upgrade; loosen the policy if remote HDHR access is required. - **Removed `dangerouslySetInnerHTML` from the M3U Profile regex preview**. The "Matched Text" preview in the M3U Profile editor built an HTML string by interpolating the user's sample input into a `<mark>` wrapper and rendering it via `dangerouslySetInnerHTML`. A crafted sample input (admin-only, so self-XSS only) could inject arbitrary HTML into the preview pane. The preview now returns an array of plain strings and `<mark>` React elements, so user input is always treated as text by React. - **Authorization on DVR recording playback endpoints**. `RecordingViewSet.file` and `RecordingViewSet.hls` now require an authenticated session and enforce a per-user channel-access check before serving any bytes. Admins (`user_level >= 10`) are always allowed; standard users are allowed only when the recording's source channel is visible under their channel-profile assignments and within their `user_level`, mirroring the same logic used by `stream_xc` for live channels. Unauthenticated requests now receive `403 Forbidden` instead of being served. The pre-existing `network_access_allowed(request, "STREAMS")` perimeter check is retained as a separate, prior gate so external IPs can be blocked from streaming entirely even with a valid token. +- Updated `lxml` 6.0.3 → 6.1.0, resolving the following CVE: + - **CVE-2026-41066**: External entity injection (XXE) in `iterparse()` and `ETCompatXMLParser`. +- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high): + - Updated `@xmldom/xmldom` 0.8.12 → 0.8.13, resolving **high** uncontrolled recursion in XML serialization causing DoS ([GHSA-2v35-w6hq-6mfw](https://github.com/advisories/GHSA-2v35-w6hq-6mfw)), **high** XML injection via unvalidated `DocumentType` serialization ([GHSA-f6ww-3ggp-fr8h](https://github.com/advisories/GHSA-f6ww-3ggp-fr8h)), **high** XML node injection via unvalidated processing instruction serialization ([GHSA-x6wf-f3px-wcqx](https://github.com/advisories/GHSA-x6wf-f3px-wcqx)), and **high** XML node injection via unvalidated comment serialization ([GHSA-j759-j44w-7fr8](https://github.com/advisories/GHSA-j759-j44w-7fr8)) + - Updated `postcss` 8.5.6 → 8.5.13, resolving **moderate** XSS via unescaped `</style>` in CSS stringify output ([GHSA-qx2v-qp2m-jg93](https://github.com/advisories/GHSA-qx2v-qp2m-jg93)) ### Fixed @@ -88,6 +93,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Improved channel start/client connect notifications**: when a channel starts streaming, the redundant separate "new channel" and "new client" toasts are now combined into a single **"Channel started streaming"** notification. All connect/disconnect notifications display both the channel name and the user identity (username + IP, or IP alone) on separate lines. A module-level `pageLoadTime` timestamp compared against each client's `connected_at` field from Redis filters out pre-existing connections on page load, while connections that genuinely start after the page loads (even if they arrive in the very first stats poll) correctly fire notifications. - **Client timing fields consolidated to `connected_at`**: `get_basic_channel_info` was sending only a pre-computed `connected_since` elapsed-seconds value (no raw timestamp), while `get_detailed_channel_info` was sending `connected_at` alongside a redundant `connection_duration`. Both paths now emit only `connected_at` (Unix timestamp). The frontend derives connection display time and duration from that single value at render time, which also fixes the "timestamp jumping" seen on the Stats page, the connected-at wall-clock time is now stable across polls instead of being recomputed each response. - **Duration tooltip on Stats page connection table**: the hover tooltip on the Duration column now shows a human-readable breakdown instead of a raw seconds count. Seconds only under a minute, minutes and seconds under an hour, hours and minutes under a day, and days and hours beyond that (e.g. `2 hours, 15 minutes`). +- Dependency updates: + - `psycopg2-binary` 2.9.11 → 2.9.12 + - `lxml` 6.0.3 → 6.1.0 (security patch; see Security section) + - `sentence-transformers` 5.4.0 → 5.4.1 + - `@xmldom/xmldom` 0.8.12 → 0.8.13 (security patch; see Security section) ### Removed