,
@@ -58,7 +60,9 @@ vi.mock('@mantine/core', () => ({
>
{(data || []).map((opt) => (
-
+
))}
),
@@ -542,7 +546,8 @@ describe('DVRPage', () => {
expect(mockShowVideo).toHaveBeenCalledWith(
expect.stringContaining('stream.url'),
- 'live'
+ 'live',
+ { name: 'Channel 1' }
);
});
From 78aa8cf35376131ed7243247040e4ed417b48cc5 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 5 Mar 2026 15:36:12 -0600
Subject: [PATCH 32/55] Enhancement: Change default new client behind seconds
from 2 to 5 for stability.
---
CHANGELOG.md | 2 +-
apps/proxy/config.py | 4 ++--
apps/proxy/ts_proxy/config_helper.py | 2 +-
core/api_views.py | 2 +-
core/models.py | 2 +-
core/serializers.py | 2 +-
frontend/src/utils/forms/settings/ProxySettingsFormUtils.js | 2 +-
.../forms/settings/__tests__/ProxySettingsFormUtils.test.js | 2 +-
8 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15420a07..18195305 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.
-- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 2 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings β Proxy as "New Client Buffer (seconds)".
+- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings β Proxy as "New Client Buffer (seconds)".
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
- DVR enhancements β Thanks [@CodeBormen](https://github.com/CodeBormen)
- **Stop Recording**: A new Stop button (distinct from Cancel) cleanly ends an in-progress recording early and keeps the partial file available for playback. The API returns immediately; stream teardown and task revocation happen in a background thread to prevent 504 timeouts. When multiple recordings run simultaneously, stopping one only terminates that recording's proxy client by ID, leaving all others unaffected. (Closes #454)
diff --git a/apps/proxy/config.py b/apps/proxy/config.py
index dbdd8278..68e27a6f 100644
--- a/apps/proxy/config.py
+++ b/apps/proxy/config.py
@@ -43,7 +43,7 @@ class BaseConfig:
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
- "new_client_behind_seconds": 2,
+ "new_client_behind_seconds": 5,
}
finally:
@@ -82,7 +82,7 @@ class TSConfig(BaseConfig):
# Buffer settings
INITIAL_BEHIND_CHUNKS = 4 # How many chunks behind to start a client (4 chunks = ~1MB)
CHUNK_BATCH_SIZE = 5 # How many chunks to fetch in one batch
- NEW_CLIENT_BEHIND_SECONDS = 2 # Start new clients this many seconds behind live (0 = start at live)
+ NEW_CLIENT_BEHIND_SECONDS = 5 # Start new clients this many seconds behind live (0 = start at live)
KEEPALIVE_INTERVAL = 0.5 # Seconds between keepalive packets when at buffer head
# Chunk read timeout
CHUNK_TIMEOUT = 5 # Seconds to wait for each chunk read
diff --git a/apps/proxy/ts_proxy/config_helper.py b/apps/proxy/ts_proxy/config_helper.py
index f0f7a5b7..f9a0418e 100644
--- a/apps/proxy/ts_proxy/config_helper.py
+++ b/apps/proxy/ts_proxy/config_helper.py
@@ -48,7 +48,7 @@ class ConfigHelper:
Loaded from DB proxy_settings so users can change it at runtime."""
from apps.proxy.config import TSConfig
settings = TSConfig.get_proxy_settings()
- return settings.get('new_client_behind_seconds', 2)
+ return settings.get('new_client_behind_seconds', 5)
@staticmethod
def keepalive_interval():
diff --git a/core/api_views.py b/core/api_views.py
index 43e55d02..dd15886c 100644
--- a/core/api_views.py
+++ b/core/api_views.py
@@ -183,7 +183,7 @@ class ProxySettingsViewSet(viewsets.ViewSet):
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
- "new_client_behind_seconds": 2,
+ "new_client_behind_seconds": 5,
}
settings_obj, created = CoreSettings.objects.get_or_create(
key=PROXY_SETTINGS_KEY,
diff --git a/core/models.py b/core/models.py
index 7ce7713d..4e6322e7 100644
--- a/core/models.py
+++ b/core/models.py
@@ -329,7 +329,7 @@ class CoreSettings(models.Model):
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
- "new_client_behind_seconds": 2,
+ "new_client_behind_seconds": 5,
})
# System Settings
diff --git a/core/serializers.py b/core/serializers.py
index b0b730ee..bff5f701 100644
--- a/core/serializers.py
+++ b/core/serializers.py
@@ -78,7 +78,7 @@ class ProxySettingsSerializer(serializers.Serializer):
redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600)
channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300)
channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60)
- new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=2)
+ new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5)
def validate_buffering_timeout(self, value):
if value < 0 or value > 300:
diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
index e6e0464b..eddbba91 100644
--- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
+++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js
@@ -14,6 +14,6 @@ export const getProxySettingDefaults = () => {
redis_chunk_ttl: 60,
channel_shutdown_delay: 0,
channel_init_grace_period: 5,
- new_client_behind_seconds: 2,
+ new_client_behind_seconds: 5,
};
};
diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
index a48e5a37..21c48269 100644
--- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
@@ -61,7 +61,7 @@ describe('ProxySettingsFormUtils', () => {
redis_chunk_ttl: 60,
channel_shutdown_delay: 0,
channel_init_grace_period: 5,
- new_client_behind_seconds: 2,
+ new_client_behind_seconds: 5,
});
});
From 2b44bf1f8eff61be8d2bf7c0961b8363fe11f4f5 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Thu, 5 Mar 2026 16:55:24 -0600
Subject: [PATCH 33/55] changelog: Update changelog for M3U refresh memory
improvements.
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18195305..a8bd0551 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Celery worker memory leak during M3U/XC refresh causing 20β80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen)
+ - Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return)
+ - Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out)
+ - Added `CELERY_WORKER_MAX_MEMORY_PER_CHILD = 512 MB` as a safety net against pymalloc arena fragmentation
- EPG output was filtering programs using `start_time__gte=now` when the `days` parameter was specified, which caused currently-airing programs (started before the request time but not yet ended) to be omitted from the XML output. This produced a gap in clients' guides immediately after an EPG refresh, lasting until the next program started. Fixed by changing the filter to `end_time__gte=now` so any program that has not yet finished is included.
- TS proxy connection slot leaks and TOCTOU races in stream initialization (Fixes #947) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`βcheckβ`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity.
From a09915438cc56955a9436994634fd8087075db54 Mon Sep 17 00:00:00 2001
From: Marcin Olek
Date: Fri, 6 Mar 2026 00:48:22 +0100
Subject: [PATCH 34/55] fix(frontend): prevent infinite re-renders in tables
- disable autoResetPageIndex, autoResetExpanded, and autoResetRowSelection in CustomTable
- memoize M3U table data to prevent re-renders on playlist progress updates
- fix default table state initialization from array to object
Made-with: Cursor
---
.../components/tables/CustomTable/index.jsx | 7 ++++-
frontend/src/components/tables/M3UsTable.jsx | 30 +++++++++++--------
2 files changed, 23 insertions(+), 14 deletions(-)
diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx
index 883e01d7..7050ff1f 100644
--- a/frontend/src/components/tables/CustomTable/index.jsx
+++ b/frontend/src/components/tables/CustomTable/index.jsx
@@ -18,7 +18,7 @@ const useTable = ({
expandedRowRenderer = () => <>>,
onRowSelectionChange = null,
getExpandedRowHeight = null,
- state = [],
+ state = {},
columnSizing,
setColumnSizing,
onColumnVisibilityChange,
@@ -103,6 +103,11 @@ const useTable = ({
selectedTableIds,
...(columnSizing && { columnSizing }),
},
+ // Add these lines to prevent infinite loops during data updates
+ autoResetPageIndex: false,
+ autoResetExpanded: false,
+ autoResetRowSelection: false,
+
onStateChange: options.onStateChange,
...(setColumnSizing && { onColumnSizingChange: setColumnSizing }),
...(onColumnVisibilityChange && { onColumnVisibilityChange }),
diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx
index 6ae42e51..e2e5e844 100644
--- a/frontend/src/components/tables/M3UsTable.jsx
+++ b/frontend/src/components/tables/M3UsTable.jsx
@@ -139,6 +139,21 @@ const M3UTable = () => {
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
const editPlaylistId = usePlaylistsStore((s) => s.editPlaylistId);
const setEditPlaylistId = usePlaylistsStore((s) => s.setEditPlaylistId);
+
+ // Memoize data to prevent unnecessary re-renders during progress updates
+ const processedData = useMemo(() => {
+ return playlists
+ .filter((playlist) => playlist.locked === false)
+ .sort((a, b) => {
+ // First sort by active status (active items first)
+ if (a.is_active !== b.is_active) {
+ return a.is_active ? -1 : 1;
+ }
+ // Then sort by name (case-insensitive)
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
+ });
+ }, [playlists]);
+
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@@ -640,18 +655,7 @@ const M3UTable = () => {
// Listen for edit playlist requests from notifications
useEffect(() => {
- setData(
- playlists
- .filter((playlist) => playlist.locked === false)
- .sort((a, b) => {
- // First sort by active status (active items first)
- if (a.is_active !== b.is_active) {
- return a.is_active ? -1 : 1;
- }
- // Then sort by name (case-insensitive)
- return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
- })
- );
+ setData(processedData);
if (editPlaylistId) {
const playlistToEdit = playlists.find((p) => p.id === editPlaylistId);
@@ -661,7 +665,7 @@ const M3UTable = () => {
setEditPlaylistId(null);
}
}
- }, [editPlaylistId, playlists]);
+ }, [editPlaylistId, processedData, playlists, setEditPlaylistId]);
const onSortingChange = (column) => {
console.log(column);
From b43618dbe9253c237781947adbb906519b71f4f3 Mon Sep 17 00:00:00 2001
From: Marcin Olek
Date: Fri, 6 Mar 2026 00:49:23 +0100
Subject: [PATCH 35/55] fix(install): harden debian_install.sh for non-UTF8
environments
- ensure en_US.UTF-8 locales are generated and set as default
- force UTF8 encoding during PostgreSQL database creation
- add standard system paths to systemd services to ensure tools like ffmpeg are found
Made-with: Cursor
---
debian_install.sh | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/debian_install.sh b/debian_install.sh
index 3630161d..305424f8 100755
--- a/debian_install.sh
+++ b/debian_install.sh
@@ -12,9 +12,20 @@ fi
trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR
##############################################################################
-# 0) Warning / Disclaimer
+# 0) Locales & Warning / Disclaimer
##############################################################################
+setup_locales() {
+ echo ">>> Setting up locales..."
+ apt-get update
+ apt-get install -y locales
+ sed -i '/en_US.UTF-8 UTF-8/s/^# //g' /etc/locale.gen
+ locale-gen
+ update-locale LANG=en_US.UTF-8
+ export LANG=en_US.UTF-8
+ export LC_ALL=en_US.UTF-8
+}
+
show_disclaimer() {
echo "**************************************************************"
echo "WARNING: While we do not anticipate any problems, we disclaim all"
@@ -106,8 +117,8 @@ setup_postgresql() {
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
if [[ "$db_exists" != "1" ]]; then
- echo ">>> Creating database '${POSTGRES_DB}'..."
- sudo -u postgres createdb "$POSTGRES_DB"
+ echo ">>> Creating database '${POSTGRES_DB}' with UTF8 encoding..."
+ sudo -u postgres createdb -E UTF8 "$POSTGRES_DB"
else
echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation."
fi
@@ -326,7 +337,7 @@ User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
-Environment="PATH=${APP_DIR}/env/bin"
+Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
@@ -354,7 +365,7 @@ User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
-Environment="PATH=${APP_DIR}/env/bin"
+Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
@@ -382,7 +393,7 @@ User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=/opt/dispatcharr/.env
-Environment="PATH=${APP_DIR}/env/bin"
+Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
@@ -474,6 +485,7 @@ EOF
##############################################################################
main() {
+ setup_locales
show_disclaimer
configure_variables
install_packages
From a51eb37075cb80163e08c8d795fd2a5b1c343ca8 Mon Sep 17 00:00:00 2001
From: Jonathan Caicedo
Date: Fri, 6 Mar 2026 08:10:30 -0500
Subject: [PATCH 36/55] fix: M3U EXTINF parsing for attributes ending in double
equals
fixes: #1055
---
apps/m3u/tasks.py | 6 ++--
apps/m3u/tests/test_extinf_parsing.py | 41 +++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 2 deletions(-)
create mode 100644 apps/m3u/tests/test_extinf_parsing.py
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index c0c90c57..162359e9 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -482,8 +482,10 @@ def parse_extinf_line(line: str) -> dict:
attrs = {}
last_attr_end = 0
- # Use a single regex that handles both quote types
- for match in re.finditer(r'([^\s]+)=(["\'])([^\2]*?)\2', content):
+ # Use a single regex that handles both quote types.
+ # Keys must stop at '=' so values like base64-padded URLs ending with '=='
+ # don't get folded into the preceding attribute name.
+ for match in re.finditer(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2', content):
key = match.group(1)
value = match.group(3)
attrs[key] = value
diff --git a/apps/m3u/tests/test_extinf_parsing.py b/apps/m3u/tests/test_extinf_parsing.py
new file mode 100644
index 00000000..367f569b
--- /dev/null
+++ b/apps/m3u/tests/test_extinf_parsing.py
@@ -0,0 +1,41 @@
+from django.test import SimpleTestCase
+
+from apps.m3u.tasks import parse_extinf_line
+
+
+class ParseExtinfLineTests(SimpleTestCase):
+ def test_preserves_equals_padding_in_tvg_logo(self):
+ line = (
+ '#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" '
+ 'tvg-chno="1001" '
+ 'tvg-name="UK Sky Sports Premier League" '
+ 'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?'
+ 'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" '
+ 'group-title="Team Games",UK Sky Sports Premier League'
+ )
+
+ parsed = parse_extinf_line(line)
+
+ self.assertIsNotNone(parsed)
+ self.assertEqual(
+ parsed["attributes"]["tvg-logo"],
+ "https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==",
+ )
+ self.assertEqual(parsed["attributes"]["group-title"], "Team Games")
+ self.assertEqual(parsed["name"], "UK Sky Sports Premier League")
+
+ def test_supports_single_quoted_attributes(self):
+ line = (
+ "#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' "
+ "group-title='Sports',Channel One"
+ )
+
+ parsed = parse_extinf_line(line)
+
+ self.assertIsNotNone(parsed)
+ self.assertEqual(
+ parsed["attributes"]["tvg-logo"],
+ "https://example.com/logo==.png",
+ )
+ self.assertEqual(parsed["attributes"]["group-title"], "Sports")
+ self.assertEqual(parsed["display_name"], "Channel One")
From 4209794cfafa7fee7e53a6be79ab051488c6e105 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 09:05:30 -0600
Subject: [PATCH 37/55] changelog: Update changelog for extinf attribute
parsing.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8bd0551..7826d7e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012)
- Celery worker memory leak during M3U/XC refresh causing 20β80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return)
- Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out)
From 03ed8c0a1c862a5a3be1175540e52da13b0a6691 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 11:38:12 -0600
Subject: [PATCH 38/55] changelog: Update changelog for debian_install script
pr.
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7826d7e9..1e7776f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek)
+ - Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding.
+ - PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`.
+ - `PATH` in the Celery worker, Celery Beat, and Daphne systemd service files extended to include `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`, fixing failures where background tasks could not locate `ffmpeg` or `ffprobe`.
- M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012)
- Celery worker memory leak during M3U/XC refresh causing 20β80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return)
From 758a79a4f6e05761f991fe10ebe036fb7b0010a0 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 13:16:49 -0600
Subject: [PATCH 39/55] fix(frontend): remove non-existent
autoResetRowSelection option
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
autoResetRowSelection does not exist in TanStack Table v8 β it was a v7
option. The remaining autoResetPageIndex and autoResetExpanded options
are valid v8 API and retained.
---
frontend/src/components/tables/CustomTable/index.jsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx
index 7050ff1f..c87b57bb 100644
--- a/frontend/src/components/tables/CustomTable/index.jsx
+++ b/frontend/src/components/tables/CustomTable/index.jsx
@@ -103,10 +103,8 @@ const useTable = ({
selectedTableIds,
...(columnSizing && { columnSizing }),
},
- // Add these lines to prevent infinite loops during data updates
autoResetPageIndex: false,
autoResetExpanded: false,
- autoResetRowSelection: false,
onStateChange: options.onStateChange,
...(setColumnSizing && { onColumnSizingChange: setColumnSizing }),
From 375dd57ae5a8ae268276c8649a30e3caa7dbc695 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 13:21:18 -0600
Subject: [PATCH 40/55] changelog: Update changelog for m3u import pr.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e7776f9..04ca4ead 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` β `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek)
- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek)
- Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding.
- PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`.
From d39b0777155a086ced02964efe4dd199f4534733 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 14:51:34 -0600
Subject: [PATCH 41/55] Rename fetchChannels to fetchChannelIds in
M3URefreshNotification component and tests
---
.../src/components/M3URefreshNotification.jsx | 19 +++++------
.../__tests__/M3URefreshNotification.test.jsx | 32 +++++++++++--------
2 files changed, 28 insertions(+), 23 deletions(-)
diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx
index 8fd461b4..263cb46c 100644
--- a/frontend/src/components/M3URefreshNotification.jsx
+++ b/frontend/src/components/M3URefreshNotification.jsx
@@ -16,7 +16,7 @@ const M3uSetupSuccess = (data) => {
const onClickRefresh = () => {
API.refreshPlaylist(data.account);
- }
+ };
const onClickConfigure = () => {
// Store the ID we want to edit in the store first
@@ -25,7 +25,7 @@ const M3uSetupSuccess = (data) => {
// Then navigate to the content sources page
// Using the exact path that matches your app's routing structure
navigate('/sources');
- }
+ };
return (
@@ -41,14 +41,14 @@ const M3uSetupSuccess = (data) => {
);
-}
+};
export default function M3URefreshNotification() {
const playlists = usePlaylistsStore((s) => s.playlists);
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
const fetchStreams = useStreamsStore((s) => s.fetchStreams);
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
- const fetchChannels = useChannelsStore((s) => s.fetchChannels);
+ const fetchChannelIds = useChannelsStore((s) => s.fetchChannelIds);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
const fetchCategories = useVODStore((s) => s.fetchCategories);
@@ -69,7 +69,7 @@ export default function M3URefreshNotification() {
}
// Update notification status
- setNotificationStatus(prev => ({
+ setNotificationStatus((prev) => ({
...prev,
[data.account]: data,
}));
@@ -134,7 +134,7 @@ export default function M3URefreshNotification() {
if (action == 'parsing') {
fetchStreams();
API.requeryChannels();
- fetchChannels();
+ fetchChannelIds();
} else if (action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();
@@ -148,9 +148,10 @@ export default function M3URefreshNotification() {
const handleProgressNotification = (playlist, data) => {
const baseMessage = getActionMessage(data.action);
- const message = data.progress == 0
- ? `${baseMessage} starting...`
- : `${baseMessage} complete!`;
+ const message =
+ data.progress == 0
+ ? `${baseMessage} starting...`
+ : `${baseMessage} complete!`;
if (data.progress == 100) {
triggerPostCompletionFetches(data.action);
diff --git a/frontend/src/components/__tests__/M3URefreshNotification.test.jsx b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx
index 14be3a7f..2c5a5572 100644
--- a/frontend/src/components/__tests__/M3URefreshNotification.test.jsx
+++ b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx
@@ -61,9 +61,7 @@ vi.mock('lucide-react', () => ({
}));
const renderWithProviders = (component) => {
- return render(
- {component}
- );
+ return render({component});
};
describe('M3URefreshNotification', () => {
@@ -96,7 +94,7 @@ describe('M3URefreshNotification', () => {
mockChannelsStore = {
fetchChannelGroups: vi.fn(),
- fetchChannels: vi.fn(),
+ fetchChannelIds: vi.fn(),
};
mockEPGsStore = {
@@ -107,9 +105,15 @@ describe('M3URefreshNotification', () => {
fetchCategories: vi.fn(),
};
- usePlaylistsStore.mockImplementation((selector) => selector(mockPlaylistsStore));
- useStreamsStore.mockImplementation((selector) => selector(mockStreamsStore));
- useChannelsStore.mockImplementation((selector) => selector(mockChannelsStore));
+ usePlaylistsStore.mockImplementation((selector) =>
+ selector(mockPlaylistsStore)
+ );
+ useStreamsStore.mockImplementation((selector) =>
+ selector(mockStreamsStore)
+ );
+ useChannelsStore.mockImplementation((selector) =>
+ selector(mockChannelsStore)
+ );
useEPGsStore.mockImplementation((selector) => selector(mockEPGsStore));
useVODStore.mockImplementation((selector) => selector(mockVODStore));
});
@@ -231,7 +235,7 @@ describe('M3URefreshNotification', () => {
expect(showNotification).toHaveBeenCalled();
expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
expect(API.requeryChannels).toHaveBeenCalled();
- expect(mockChannelsStore.fetchChannels).toHaveBeenCalled();
+ expect(mockChannelsStore.fetchChannelIds).toHaveBeenCalled();
});
});
});
@@ -483,7 +487,7 @@ describe('M3URefreshNotification', () => {
rerender(
-
+
);
@@ -569,7 +573,7 @@ describe('M3URefreshNotification', () => {
// Re-render with same data
rerender(
-
+
);
@@ -606,7 +610,7 @@ describe('M3URefreshNotification', () => {
rerender(
-
+
);
@@ -648,7 +652,7 @@ describe('M3URefreshNotification', () => {
rerender(
-
+
);
@@ -687,7 +691,7 @@ describe('M3URefreshNotification', () => {
rerender(
-
+
);
@@ -704,7 +708,7 @@ describe('M3URefreshNotification', () => {
rerender(
-
+
);
});
From 8b332c426b0020dbdde85f5cad4e44fa0806e7ec Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 15:08:53 -0600
Subject: [PATCH 42/55] changelog: Update changelog for refactor PR.
---
CHANGELOG.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04ca4ead..6e956bd3 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
+- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. β Thanks [@nick4810](https://github.com/nick4810)
- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.
- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings β Proxy as "New Client Buffer (seconds)".
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
@@ -25,6 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Frontend component refactoring and cleanup β Thanks [@nick4810](https://github.com/nick4810)
+ - `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`).
+ - `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component.
+ - `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`).
+ - `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`).
+ - `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs.
+ - `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting.
+ - Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element.
+
- EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data.
### Fixed
From ba38dc1cd4f7e7ace933498e5f5fbcb75671b0a1 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 15:12:28 -0600
Subject: [PATCH 43/55] changelog: Removed accidental line break.
---
CHANGELOG.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e956bd3..42f2f89b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,7 +34,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs.
- `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting.
- Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element.
-
- EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data.
### Fixed
From 4f41c287aca6f401ff508f37e873a0222e964d72 Mon Sep 17 00:00:00 2001
From: None
Date: Fri, 6 Mar 2026 15:17:32 -0600
Subject: [PATCH 44/55] Fix Redis flush and wait_for_redis in modular mode
- Move modular Redis wait from uWSGI exec-pre to entrypoint (exec-pre runs under 'su -' which strips Docker env vars, so DISPATCHARR_ENV and REDIS_HOST were never available)
- Selective flush in modular mode: clears stale app state (stream locks, proxy metadata) while preserving Celery broker/result keys
- AIO mode unchanged: full flushdb via uWSGI exec-pre
- Update unit tests for both flush paths
---
docker/entrypoint.sh | 24 +++++++++---------------
docker/uwsgi.modular.ini | 4 ++--
scripts/wait_for_redis.py | 31 +++++++++++++++++++++++++++++++
tests/test_wait_for_redis.py | 31 ++++++++++++++++++++++++-------
4 files changed, 66 insertions(+), 24 deletions(-)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index b79af952..693c25d0 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -175,23 +175,17 @@ else
check_external_postgres_version || exit 1
fi
-# Wait for Redis to be ready (modular mode uses external Redis)
+# Wait for Redis to be ready and flush stale state.
+# In modular mode Redis is external β call wait_for_redis.py here
+# because uWSGI's exec-pre runs under 'su -' which strips env vars
+# (DISPATCHARR_ENV, REDIS_HOST, etc.).
+# In AIO mode Redis is started by uWSGI (attach-daemon), so the
+# exec-pre in uwsgi.ini handles the wait + flush there instead.
if [[ "$DISPATCHARR_ENV" == "modular" ]]; then
echo "π Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}"
- echo_with_timestamp "Waiting for external Redis to be ready..."
- until python3 -c "
-import socket, sys
-try:
- s = socket.create_connection(('${REDIS_HOST}', ${REDIS_PORT}), timeout=2)
- s.close()
- sys.exit(0)
-except Exception:
- sys.exit(1)
-" 2>/dev/null; do
- echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
- sleep 1
- done
- echo "β External Redis is ready"
+ echo_with_timestamp "Waiting for Redis to be ready..."
+ python3 /app/scripts/wait_for_redis.py
+ echo "β Redis is ready"
fi
# Ensure database encoding is UTF8 (handles both internal and external databases)
diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini
index 3220a8d8..57ecaea8 100644
--- a/docker/uwsgi.modular.ini
+++ b/docker/uwsgi.modular.ini
@@ -5,8 +5,8 @@
; exec-pre = touch /data/logs/uwsgi.log
; exec-pre = chmod 666 /data/logs/uwsgi.log
-; First run Redis availability check script once
-exec-pre = python /app/scripts/wait_for_redis.py
+; Redis wait + flush is handled by the entrypoint in modular mode
+; (uWSGI exec-pre runs under 'su -' which strips Docker env vars)
; Start Daphne for WebSocket support (required for real-time features)
; Redis and Celery run in separate containers in modular mode
diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py
index 41b66757..e1814411 100644
--- a/scripts/wait_for_redis.py
+++ b/scripts/wait_for_redis.py
@@ -12,6 +12,28 @@ import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
+# Key prefixes used by Celery's broker (Kombu) and result backend.
+# These must be preserved in modular mode where Celery runs independently.
+_CELERY_KEY_PREFIXES = ('celery', '_kombu', 'unacked')
+
+
+def _flush_non_celery_keys(client):
+ """Delete all Redis keys except those belonging to Celery."""
+ cursor = '0'
+ deleted = 0
+ while True:
+ cursor, keys = client.scan(cursor=cursor, count=500)
+ to_delete = [
+ k for k in keys
+ if not k.decode('utf-8', errors='replace').startswith(_CELERY_KEY_PREFIXES)
+ ]
+ if to_delete:
+ deleted += client.delete(*to_delete)
+ if cursor == 0:
+ break
+ logger.info(f"Modular mode: selectively cleared {deleted} non-Celery Redis key(s)")
+
+
def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2):
"""Wait for Redis to become available"""
redis_client = None
@@ -31,6 +53,15 @@ def wait_for_redis(host='localhost', port=6379, db=0, password='', username='',
socket_connect_timeout=2
)
redis_client.ping()
+ # Clear stale state on startup. In AIO mode, every service restarts
+ # together so a full flush is safe. In modular mode, Celery has its
+ # own lifecycle β preserve its broker/result keys and only wipe
+ # application state (stream locks, proxy metadata, etc.).
+ if os.environ.get('DISPATCHARR_ENV') == 'modular':
+ _flush_non_celery_keys(redis_client)
+ else:
+ redis_client.flushdb()
+ logger.info(f"Flushed Redis database")
logger.info(f"β Redis at {host}:{port}/{db} is now available!")
return True
except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e:
diff --git a/tests/test_wait_for_redis.py b/tests/test_wait_for_redis.py
index 55e623eb..0dd02ba1 100644
--- a/tests/test_wait_for_redis.py
+++ b/tests/test_wait_for_redis.py
@@ -21,23 +21,40 @@ class WaitForRedisTests(SimpleTestCase):
"""
Tests for scripts/wait_for_redis.py.
- The critical regression test is test_successful_connection_does_not_call_flushdb
- which ensures the removed flushdb() call is never re-added.
+ Verifies flush behaviour: full flushdb in AIO mode, selective
+ (non-Celery) key deletion in modular mode.
"""
@patch('wait_for_redis.redis.Redis')
- def test_successful_connection_does_not_call_flushdb(self, mock_redis_cls):
- """After connecting successfully, flushdb must NOT be called."""
+ def test_aio_mode_calls_flushdb(self, mock_redis_cls):
+ """In AIO mode (default), flushdb is called after successful ping."""
mock_client = MagicMock()
mock_client.ping.return_value = True
mock_redis_cls.return_value = mock_client
- wait_for_redis = _import_wait_for_redis()
- result = wait_for_redis(max_retries=1, retry_interval=0)
+ with patch.dict(os.environ, {}, clear=False):
+ os.environ.pop('DISPATCHARR_ENV', None)
+ wait_for_redis = _import_wait_for_redis()
+ result = wait_for_redis(max_retries=1, retry_interval=0)
+
+ self.assertTrue(result)
+ mock_client.flushdb.assert_called_once()
+
+ @patch('wait_for_redis._flush_non_celery_keys')
+ @patch('wait_for_redis.redis.Redis')
+ def test_modular_mode_does_not_call_flushdb(self, mock_redis_cls, mock_selective):
+ """In modular mode, flushdb must NOT be called β selective flush instead."""
+ mock_client = MagicMock()
+ mock_client.ping.return_value = True
+ mock_redis_cls.return_value = mock_client
+
+ with patch.dict(os.environ, {'DISPATCHARR_ENV': 'modular'}):
+ wait_for_redis = _import_wait_for_redis()
+ result = wait_for_redis(max_retries=1, retry_interval=0)
self.assertTrue(result)
- mock_client.ping.assert_called_once()
mock_client.flushdb.assert_not_called()
+ mock_selective.assert_called_once_with(mock_client)
@patch('wait_for_redis.redis.Redis')
def test_retries_on_connection_error(self, mock_redis_cls):
From 846ae598fc675b8246f61aaffa6852093574a7de Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 15:21:23 -0600
Subject: [PATCH 45/55] Tests: Fix errorboundary and floatingvideo tests.
---
.../__tests__/ErrorBoundary.test.jsx | 8 +-
.../__tests__/FloatingVideo.test.jsx | 259 ++++++++++--------
2 files changed, 146 insertions(+), 121 deletions(-)
diff --git a/frontend/src/components/__tests__/ErrorBoundary.test.jsx b/frontend/src/components/__tests__/ErrorBoundary.test.jsx
index bab0778d..76c51110 100644
--- a/frontend/src/components/__tests__/ErrorBoundary.test.jsx
+++ b/frontend/src/components/__tests__/ErrorBoundary.test.jsx
@@ -34,7 +34,7 @@ describe('ErrorBoundary', () => {
);
- expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
expect(screen.queryByText('Child component')).not.toBeInTheDocument();
});
@@ -72,7 +72,7 @@ describe('ErrorBoundary', () => {
);
- expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
it('should have hasError state set to true after catching error', () => {
@@ -83,7 +83,9 @@ describe('ErrorBoundary', () => {
);
// Verify error boundary rendered fallback UI
- expect(container.querySelector('div')).toHaveTextContent('Something went wrong');
+ expect(container.querySelector('div')).toHaveTextContent(
+ 'Something went wrong'
+ );
});
it('should have hasError state set to false initially', () => {
diff --git a/frontend/src/components/__tests__/FloatingVideo.test.jsx b/frontend/src/components/__tests__/FloatingVideo.test.jsx
index 9c537d2f..63f3ee3d 100644
--- a/frontend/src/components/__tests__/FloatingVideo.test.jsx
+++ b/frontend/src/components/__tests__/FloatingVideo.test.jsx
@@ -90,32 +90,36 @@ describe('FloatingVideo', () => {
});
it('should not render when streamUrl is null', () => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: null,
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: null,
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
const { container } = render();
expect(container.firstChild).toBeNull();
});
it('should render when isVisible is true and streamUrl is provided', () => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/stream',
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
render();
expect(screen.getByTestId('close-button')).toBeInTheDocument();
@@ -124,16 +128,18 @@ describe('FloatingVideo', () => {
describe('Live Stream Player', () => {
beforeEach(() => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/stream.ts',
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
});
it('should initialize mpegts player for live streams', () => {
@@ -198,20 +204,22 @@ describe('FloatingVideo', () => {
describe('VOD Player', () => {
beforeEach(() => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/video.mp4',
- contentType: 'vod',
- metadata: {
- name: 'Test Movie',
- year: '2024',
- logo: { url: 'http://example.com/poster.jpg' },
- },
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/video.mp4',
+ contentType: 'vod',
+ metadata: {
+ name: 'Test Movie',
+ year: '2024',
+ logo: { url: 'http://example.com/poster.jpg' },
+ },
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
});
it('should use native video player for VOD', () => {
@@ -234,7 +242,9 @@ describe('FloatingVideo', () => {
// Simulate video loaded event to clear loading state
fireEvent.loadedData(video);
- expect(screen.getByText('Test Movie')).toBeInTheDocument();
+ expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
+ 1
+ );
expect(screen.getByText('2024')).toBeInTheDocument();
});
@@ -247,13 +257,15 @@ describe('FloatingVideo', () => {
fireEvent.loadedData(video);
fireEvent.canPlay(video);
- expect(screen.getByText('Test Movie')).toBeInTheDocument();
-
+ expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
+ 1
+ );
vi.advanceTimersByTime(4000);
waitFor(() => {
- expect(screen.queryByText('Test Movie')).not.toBeInTheDocument();
+ // After overlay hides, only the header title remains
+ expect(screen.getAllByText('Test Movie').length).toBe(1);
});
vi.useRealTimers();
@@ -266,11 +278,13 @@ describe('FloatingVideo', () => {
fireEvent.loadedData(video);
fireEvent.canPlay(video);
- const videoContainer = screen.getByText('Test Movie').closest('div');
+ const videoContainer = video.parentElement;
fireEvent.mouseEnter(videoContainer);
- expect(screen.getByText('Test Movie')).toBeInTheDocument();
+ expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
+ 1
+ );
});
it('should hide overlay on mouse leave', () => {
@@ -282,7 +296,7 @@ describe('FloatingVideo', () => {
fireEvent.loadedData(video);
fireEvent.canPlay(video);
- const videoContainer = screen.getByText('Test Movie').closest('div');
+ const videoContainer = video.parentElement;
fireEvent.mouseEnter(videoContainer);
fireEvent.mouseLeave(videoContainer);
@@ -290,7 +304,8 @@ describe('FloatingVideo', () => {
vi.advanceTimersByTime(4000);
waitFor(() => {
- expect(screen.queryByText('Test Movie')).not.toBeInTheDocument();
+ // After overlay hides, only the header title remains
+ expect(screen.getAllByText('Test Movie').length).toBe(1);
});
vi.useRealTimers();
@@ -299,16 +314,18 @@ describe('FloatingVideo', () => {
describe('Close functionality', () => {
beforeEach(() => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/stream.ts',
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
});
it('should call hideVideo when close button is clicked', () => {
@@ -331,16 +348,18 @@ describe('FloatingVideo', () => {
describe('Error handling', () => {
beforeEach(() => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/video.mp4',
- contentType: 'vod',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/video.mp4',
+ contentType: 'vod',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
});
it('should display video error messages', () => {
@@ -354,9 +373,7 @@ describe('FloatingVideo', () => {
fireEvent.error(video);
- expect(
- screen.getByText(/MEDIA_ERR_DECODE/i)
- ).toBeInTheDocument();
+ expect(screen.getByText(/MEDIA_ERR_DECODE/i)).toBeInTheDocument();
});
it('should handle network errors', () => {
@@ -370,24 +387,24 @@ describe('FloatingVideo', () => {
fireEvent.error(video);
- expect(
- screen.getByText(/MEDIA_ERR_NETWORK/i)
- ).toBeInTheDocument();
+ expect(screen.getByText(/MEDIA_ERR_NETWORK/i)).toBeInTheDocument();
});
});
describe('Player cleanup', () => {
it('should cleanup player on unmount', () => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/stream.ts',
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
const { unmount } = render();
@@ -397,29 +414,33 @@ describe('FloatingVideo', () => {
});
it('should cleanup player when streamUrl changes', () => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/stream1.ts',
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream1.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
const { rerender } = render();
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/stream2.ts',
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream2.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
rerender();
@@ -429,16 +450,18 @@ describe('FloatingVideo', () => {
describe('Resize functionality', () => {
beforeEach(() => {
- useVideoStore.mockImplementation((selector) => { {
- const state = {
- isVisible: true,
- streamUrl: 'http://example.com/stream.ts',
- contentType: 'live',
- metadata: null,
- hideVideo: mockHideVideo,
- };
- return selector ? selector(state) : state;
- }});
+ useVideoStore.mockImplementation((selector) => {
+ {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }
+ });
});
it('should render resize handles', () => {
From bb2c3c228d0b14924f26a8bbe14bddafa6b81a8f Mon Sep 17 00:00:00 2001
From: None
Date: Fri, 6 Mar 2026 15:27:19 -0600
Subject: [PATCH 46/55] Fix stale env vars in profile.d on container restart
- Rewrite /etc/profile.d/dispatcharr.sh on every startup instead of only on first run, so container restarts with changed env vars pick up new values
- Quote exported values to prevent breakage from special characters in POSTGRES_PASSWORD or other vars
- Update /etc/environment entries instead of skipping if already present
---
docker/entrypoint.sh | 52 ++++++++++++++++++++++++--------------------
1 file changed, 28 insertions(+), 24 deletions(-)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 693c25d0..7d2a1bf6 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -99,31 +99,35 @@ echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'"
# READ-ONLY - don't let users change these
export POSTGRES_DIR=/data/db
-# Global variables, stored so other users inherit them
-if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
- # Define all variables to process
- variables=(
- PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
- POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
- DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
- REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
- DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
- CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
- )
+# Global variables, stored so other users inherit them.
+# Rewritten every startup so that container restarts with changed env vars
+# pick up the new values (not stale ones from a previous run).
+# Define all variables to process
+variables=(
+ PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
+ POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
+ DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
+ REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
+ DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
+ CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
+)
- # Process each variable for both profile.d and environment
- for var in "${variables[@]}"; do
- # Check if the variable is set in the environment
- if [ -n "${!var+x}" ]; then
- # Add to profile.d
- echo "export ${var}=${!var}" >> /etc/profile.d/dispatcharr.sh
- # Add to /etc/environment if not already there
- grep -q "^${var}=" /etc/environment || echo "${var}=${!var}" >> /etc/environment
- else
- echo "Warning: Environment variable $var is not set"
- fi
- done
-fi
+# Truncate files before rewriting
+> /etc/profile.d/dispatcharr.sh
+
+# Process each variable for both profile.d and environment
+for var in "${variables[@]}"; do
+ # Check if the variable is set in the environment
+ if [ -n "${!var+x}" ]; then
+ # Add to profile.d (quoted to handle special characters in values)
+ echo "export ${var}='${!var}'" >> /etc/profile.d/dispatcharr.sh
+ # Add/update in /etc/environment
+ sed -i "/^${var}=/d" /etc/environment
+ echo "${var}='${!var}'" >> /etc/environment
+ else
+ echo "Warning: Environment variable $var is not set"
+ fi
+done
chmod +x /etc/profile.d/dispatcharr.sh
From cf3f562cabc6b0c0bb8504b9329f197fb52a2c73 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 15:47:37 -0600
Subject: [PATCH 47/55] fix: destructure props in M3uSetupSuccess component
---
frontend/src/components/M3URefreshNotification.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx
index 263cb46c..bd6319bf 100644
--- a/frontend/src/components/M3URefreshNotification.jsx
+++ b/frontend/src/components/M3URefreshNotification.jsx
@@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom';
import { CircleCheck } from 'lucide-react';
import { showNotification } from '../utils/notificationUtils.js';
-const M3uSetupSuccess = (data) => {
+const M3uSetupSuccess = ({ data }) => {
const navigate = useNavigate();
const onClickRefresh = () => {
From 658c7744f7148cc88a017fcaab338a39831fd6bb Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 16:21:24 -0600
Subject: [PATCH 48/55] changelog: Update changelog for modular hardening PR.
---
CHANGELOG.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42f2f89b..d6d432d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. β Thanks [@nick4810](https://github.com/nick4810)
+- Unit tests for DVR port resolution (`build_dvr_candidates`) and selective Redis flush behavior in modular mode. β Thanks [@CodeBormen](https://github.com/CodeBormen)
- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.
- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings β Proxy as "New Client Buffer (seconds)".
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
@@ -88,6 +89,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Duplicate series rule evaluation race**: Creating a series rule fired `evaluate_series_rules.delay()` in the API view while the frontend immediately called the synchronous evaluate endpoint, racing to create duplicate recordings for the same program. Removed the redundant async call from the API; the frontend's explicit evaluate call is now the sole evaluation path.
- **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card.
- **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently.
+- Modular mode deployment hardening β Thanks [@CodeBormen](https://github.com/CodeBormen)
+ - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users.
+ - **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`.
+ - **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode β clearing stale stream locks, proxy metadata, and server-state keys β while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed.
+ - **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present.
+ - **Stale environment variables after container restart**: `/etc/profile.d/dispatcharr.sh` was only written on the first container run; restarts with changed env vars (e.g. a rotated `POSTGRES_PASSWORD`) retained stale values. The file is now truncated and rewritten on every startup. `/etc/environment` entries are likewise updated rather than skipped when a key already exists. All exported values are now quoted to prevent breakage from special characters.
+ - **Celery entrypoint startup timeouts**: The JWT key wait and migration wait loops had no timeout, leaving the Celery worker hanging indefinitely if the web container was stuck. Each loop now times out (120 s for JWT, 300 s for migrations) and exits with a clear diagnostic message. The migration readiness check is also replaced from a fragile `showmigrations | grep` pattern to `migrate --check`, which exits cleanly on both unapplied migrations and connection errors.
+ - **Service startup ordering**: `depends_on` entries for `db` and `redis` in `docker-compose.yml` upgraded from plain name-link ordering to `condition: service_healthy`, ensuring containers wait for actual readiness signals before starting.
+ - **`host.docker.internal` resolution on Linux**: Added `extra_hosts: host.docker.internal:host-gateway` to the web service in `docker-compose.yml` so Linux hosts resolve `host.docker.internal` the same way Docker Desktop does on macOS/Windows.
## [0.20.2] - 2026-03-03
From ebd112eeb2ac583f4b825d6e56b82c1e3120f057 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 16:29:55 -0600
Subject: [PATCH 49/55] changelog: Added reference to issue for PG version
check.
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6d432d3..17836b5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -90,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card.
- **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently.
- Modular mode deployment hardening β Thanks [@CodeBormen](https://github.com/CodeBormen)
- - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users.
+ - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. (Fixes #1045)
- **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`.
- **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode β clearing stale stream locks, proxy metadata, and server-state keys β while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed.
- **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present.
From f53dd7e01655ee0963ad9518399e266a90ce20f1 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 17:21:07 -0600
Subject: [PATCH 50/55] Revert "fix: add configurable uwsgi_read_timeout to fix
504 timeout on large M3U imports (fixes #745)"
This reverts commit 6f5491098697d0fb94de948c873da41b1507bd54.
---
docker/entrypoint.sh | 1 -
docker/init/03-init-dispatcharr.sh | 1 -
docker/nginx.conf | 1 -
3 files changed, 3 deletions(-)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index dc36421a..b79af952 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -41,7 +41,6 @@ export REDIS_DB=${REDIS_DB:-0}
export REDIS_PASSWORD=${REDIS_PASSWORD:-}
export REDIS_USER=${REDIS_USER:-}
export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191}
-export NGINX_UWSGI_TIMEOUT=${NGINX_UWSGI_TIMEOUT:-300}
export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri'
export LD_LIBRARY_PATH='/usr/local/lib'
export SECRET_FILE="/data/jwt"
diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh
index 186831ea..0c317017 100644
--- a/docker/init/03-init-dispatcharr.sh
+++ b/docker/init/03-init-dispatcharr.sh
@@ -36,7 +36,6 @@ if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then
DISPATCHARR_PORT=9191
fi
sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default
-sed -i "s/NGINX_UWSGI_TIMEOUT/${NGINX_UWSGI_TIMEOUT}/g" /etc/nginx/sites-enabled/default
# Configure nginx based on IPv6 availability
if ip -6 addr show | grep -q "inet6"; then
diff --git a/docker/nginx.conf b/docker/nginx.conf
index c8ee3bc1..e08d08f2 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -21,7 +21,6 @@ server {
location / {
include uwsgi_params;
uwsgi_pass unix:/app/uwsgi.sock;
- uwsgi_read_timeout NGINX_UWSGI_TIMEOUT;
}
location /assets/ {
From 7e5e539aac612f5e5e4af5fbed067f988970ce92 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Fri, 6 Mar 2026 17:24:54 -0600
Subject: [PATCH 51/55] changelog: add entry for bulk M3U group settings upsert
fix
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17836b5d..4d78f782 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) β Thanks [@nickgerrer](https://github.com/nickgerrer)
- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` β `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek)
- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek)
- Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding.
From 9876a25e628453cd88195a0feb280ac4e99b84bc Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 7 Mar 2026 18:33:17 -0600
Subject: [PATCH 52/55] Bug Fix: XC stream refresh crashing with a `null value
in column "name"` database error when a provider returns streams with a null
or empty name. Affected streams are now assigned a generated fallback name in
the format ` - ` so the refresh completes
successfully and the stream remains accessible. A warning is logged for each
affected stream.
---
CHANGELOG.md | 1 +
apps/m3u/tasks.py | 17 +++++++++++++++--
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4d78f782..ed606809 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream.
- 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) β Thanks [@nickgerrer](https://github.com/nickgerrer)
- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` β `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek)
- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek)
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index 162359e9..fc8dc756 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -751,6 +751,14 @@ def collect_xc_streams(account_id, enabled_groups):
# Filter streams based on enabled categories
filtered_count = 0
for stream in all_xc_streams:
+ # Fall back to a generated name if the provider returns null/empty
+ stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}"
+ if not stream.get("name"):
+ logger.warning(
+ f"XC stream has null/empty name; using generated name '{stream_name}' "
+ f"(stream_id={stream.get('stream_id', 'unknown')})"
+ )
+
# Get the category_id for this stream
category_id = str(stream.get("category_id", ""))
@@ -760,7 +768,7 @@ def collect_xc_streams(account_id, enabled_groups):
# Convert XC stream to our standard format with all properties preserved
stream_data = {
- "name": stream["name"],
+ "name": stream_name,
"url": xc_client.get_stream_url(stream["stream_id"]),
"attributes": {
"tvg-id": stream.get("epg_channel_id", ""),
@@ -844,7 +852,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
)
for stream in streams:
- name = stream["name"]
+ name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}"
+ if not stream.get("name"):
+ logger.warning(
+ f"XC stream has null/empty name in category {group_name}; "
+ f"using generated name '{name}' (stream_id={stream.get('stream_id', 'unknown')})"
+ )
raw_stream_id = stream.get("stream_id", "")
provider_stream_id = None
if raw_stream_id:
From c0d70767e67b13b7db5077487b80b74770684ffa Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sat, 7 Mar 2026 18:39:41 -0600
Subject: [PATCH 53/55] changelog: Add credit to @patchy8736 for contributions
that came from his closed PR.
---
CHANGELOG.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed606809..7e46e6e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,12 +53,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `CELERY_WORKER_MAX_MEMORY_PER_CHILD = 512 MB` as a safety net against pymalloc arena fragmentation
- EPG output was filtering programs using `start_time__gte=now` when the `days` parameter was specified, which caused currently-airing programs (started before the request time but not yet ended) to be omitted from the XML output. This produced a gap in clients' guides immediately after an EPG refresh, lasting until the next program started. Fixed by changing the filter to `end_time__gte=now` so any program that has not yet finished is included.
- TS proxy connection slot leaks and TOCTOU races in stream initialization (Fixes #947) - Thanks [@CodeBormen](https://github.com/CodeBormen)
- - **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`βcheckβ`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity.
+ - **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`βcheckβ`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity. β Thanks [@patchy8736](https://github.com/patchy8736)
- **Leak on URL generation failure**: `generate_stream_url()` called `get_stream()` (which `INCR`s the counter) but had no cleanup path if subsequent DB lookups or URL construction failed. The post-`get_stream()` block is now wrapped in a `try/except` that calls `release_stream()` on any error.
- **Leak on retry-loop timeout**: the retry loop in `stream_ts()` called a bare `get_stream()` on the first failure to classify the error reason. If a slot was available, this `INCR`'d the counter and set Redis keys that were never released when the loop timed out. A `release_stream()` call is now issued before returning 503.
- **Leak on `initialize_channel()` failure**: when `initialize_channel()` returned `False`, the connection slot allocated by the preceding `get_stream()` was never released. A `connection_allocated` flag now tracks whether this request performed the `INCR` (fresh initialization vs. joining an existing channel), and `release_stream()` is called guarded by that flag to prevent incorrect decrements when attaching to an already-running channel.
- **Safety net for unexpected exceptions**: the outer `except` in `stream_ts()` now checks `connection_allocated` and calls `release_stream()` as a last-resort cleanup for any unhandled exception that escapes before the channel is handed off to the stream lifecycle.
- - **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls.
+ - **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls. β Thanks [@patchy8736](https://github.com/patchy8736)
- **`update_stream_profile()` uses a Redis pipeline** for the old-profile DECR + key update + new-profile INCR sequence, preventing counter drift if the process crashes between operations.
- **`stream_generator._cleanup()`** now falls back to `Stream.objects.get()` when the channel UUID resolves to a preview flow rather than a normal channel, rather than silently skipping the slot release.
- **VOD `cleanup_persistent_connection()`** fallback DECR is now conditional: it only decrements the profile counter when the connection tracking key had already expired by TTL (i.e., `remove_connection()` would have skipped the DECR), preventing double-decrements when the key is still present.
From 753bc1a8934737b295fcca6ce5a1412929ee00c8 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 8 Mar 2026 11:46:31 -0500
Subject: [PATCH 54/55] fix(stream-preview): restore stream_name and
stream_stats for stream previews
Stream.get_stream() was never called in the preview path of
generate_stream_url(), so the channel_stream and stream_profile
Redis keys were never written. This caused the stream_id to be
missing from channel metadata, which silently broke two things:
- Stats page showed no stream name or logo
- stream_stats were never saved to the database
Fix: replace the manual profile-selection loop in the preview path
with a direct call to Stream.get_stream(), matching the channel path
exactly. This also fixes a pre-existing non-atomic slot reservation
(read-then-check vs INCR-first) that could over-allocate connections
on concurrent previews.
Regression introduced in 49b7b9e2.
---
apps/proxy/ts_proxy/url_utils.py | 86 +++++++++-----------------------
1 file changed, 23 insertions(+), 63 deletions(-)
diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py
index fe06ad48..14a714ea 100644
--- a/apps/proxy/ts_proxy/url_utils.py
+++ b/apps/proxy/ts_proxy/url_utils.py
@@ -39,84 +39,44 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
# Handle direct stream preview (custom streams)
if isinstance(channel_or_stream, Stream):
- from core.utils import RedisClient
-
stream = channel_or_stream
logger.info(f"Previewing stream directly: {stream.id} ({stream.name})")
- # For custom streams, we need to get the M3U account and profile
- m3u_account = stream.m3u_account
- if not m3u_account:
+ if not stream.m3u_account:
logger.error(f"Stream {stream.id} has no M3U account")
return None, None, False, None
- # Get active profiles for this M3U account
- m3u_profiles = m3u_account.profiles.filter(is_active=True)
- default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
-
- if not default_profile:
- logger.error(f"No default active profile found for M3U account {m3u_account.id}")
+ # Use get_stream() to atomically reserve a slot and write the
+ # channel_stream / stream_profile Redis keys, matching the channel
+ # path so stream_name and stream_stats work correctly.
+ stream_id, profile_id, error_reason = stream.get_stream()
+ if not stream_id or not profile_id:
+ logger.error(f"No profile available for stream {stream.id}: {error_reason}")
return None, None, False, None
- # Check profiles in order: default first, then others
- profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
+ try:
+ profile = M3UAccountProfile.objects.get(id=profile_id)
+ m3u_account = stream.m3u_account
- # Try to find an available profile with connection capacity
- redis_client = RedisClient.get_client()
- selected_profile = None
+ stream_user_agent = m3u_account.get_user_agent().user_agent
+ if stream_user_agent is None:
+ stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
+ logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
- for profile in profiles:
- logger.info(profile)
+ stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
- # Check connection availability
- if redis_client:
- profile_connections_key = f"profile_connections:{profile.id}"
- current_connections = int(redis_client.get(profile_connections_key) or 0)
+ stream_profile = stream.get_stream_profile()
+ logger.debug(f"Using stream profile: {stream_profile.name}")
- # Check if profile has available slots (or unlimited connections)
- if profile.max_streams == 0 or current_connections < profile.max_streams:
- selected_profile = profile
- logger.debug(f"Selected profile {profile.id} with {current_connections}/{profile.max_streams} connections for stream preview")
- break
- else:
- logger.debug(f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}")
- else:
- # No Redis available, use first active profile
- selected_profile = profile
- break
+ transcode = not stream_profile.is_proxy()
+ stream_profile_id = stream_profile.id
- if not selected_profile:
- logger.error(f"No profiles available with connection capacity for M3U account {m3u_account.id}")
+ return stream_url, stream_user_agent, transcode, stream_profile_id
+ except Exception as e:
+ logger.error(f"Error generating stream URL for stream {stream.id}: {e}")
+ stream.release_stream()
return None, None, False, None
- # Get the appropriate user agent
- stream_user_agent = m3u_account.get_user_agent().user_agent
- if stream_user_agent is None:
- stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
- logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
-
- # Get stream URL with the selected profile's URL transformation
- stream_url = transform_url(stream.url, selected_profile.search_pattern, selected_profile.replace_pattern)
-
- # Check if the stream has its own stream_profile set, otherwise use default
- if stream.stream_profile:
- stream_profile = stream.stream_profile
- logger.debug(f"Using stream's own stream profile: {stream_profile.name}")
- else:
- stream_profile = StreamProfile.objects.get(
- id=CoreSettings.get_default_stream_profile_id()
- )
- logger.debug(f"Using default stream profile: {stream_profile.name}")
-
- # Check if transcoding is needed
- if stream_profile.is_proxy() or stream_profile is None:
- transcode = False
- else:
- transcode = True
-
- stream_profile_id = stream_profile.id
-
- return stream_url, stream_user_agent, transcode, stream_profile_id
# Handle channel preview (existing logic)
channel = channel_or_stream
From dfb91db98753fc225f9fa663ecb25d47bb23e041 Mon Sep 17 00:00:00 2001
From: SergeantPanda
Date: Sun, 8 Mar 2026 11:56:52 -0500
Subject: [PATCH 55/55] Bug Fix: VOD orphan cleanup crashing with a
`ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task
created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series
between the orphan-detection query and the `DELETE` SQL. Both
`orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in
`try/except IntegrityError`; affected records are skipped with a warning and
will be cleaned up on the next scheduled run.
---
CHANGELOG.md | 1 +
apps/vod/tasks.py | 20 ++++++++++++++++++--
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e46e6e1..842da260 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run.
- XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream.
- 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) β Thanks [@nickgerrer](https://github.com/nickgerrer)
- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` β `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek)
diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py
index 1b12c7b7..ff195fc9 100644
--- a/apps/vod/tasks.py
+++ b/apps/vod/tasks.py
@@ -1645,14 +1645,30 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=
orphaned_movie_count = orphaned_movies.count()
if orphaned_movie_count > 0:
logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations")
- orphaned_movies.delete()
+ try:
+ orphaned_movies.delete()
+ except IntegrityError:
+ # A concurrent refresh task created a new relation for one of these movies
+ # between our query and the DELETE. Skip and let the next cleanup run handle it.
+ logger.warning(
+ "Skipped some orphaned movie deletions due to concurrent modifications; "
+ "they will be retried on the next cleanup run."
+ )
+ orphaned_movie_count = 0
# Clean up series with no relations (orphaned)
orphaned_series = Series.objects.filter(m3u_relations__isnull=True)
orphaned_series_count = orphaned_series.count()
if orphaned_series_count > 0:
logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations")
- orphaned_series.delete()
+ try:
+ orphaned_series.delete()
+ except IntegrityError:
+ logger.warning(
+ "Skipped some orphaned series deletions due to concurrent modifications; "
+ "they will be retried on the next cleanup run."
+ )
+ orphaned_series_count = 0
result = (f"Cleaned up {stale_movie_count} stale movie relations, "
f"{stale_series_count} stale series relations, "