From 820c61caa0b560abaffed682346a05db9287e0ab Mon Sep 17 00:00:00 2001 From: recurst Date: Tue, 23 Jun 2026 14:04:01 +0200 Subject: [PATCH 01/18] Allow underscores in non-FQDN hostnames This PR updates the non_fqdn_pattern regex to allow underscores in non-FQDN hostnames. Such hostnames are commonly used in Docker networks, but Dispatcharr currently rejects them due to the overly strict regex. --- core/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/utils.py b/core/utils.py index 188d9cb2..539c2cf8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -613,7 +613,7 @@ def validate_flexible_url(value): # Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234 # Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value # Also supports authentication: rtsp://user:pass@hostname/path - non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' + non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9_\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' non_fqdn_match = re.match(non_fqdn_pattern, value) if non_fqdn_match: From e5d33861e6a81db368167110dde25a9bd0535ba7 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Mon, 13 Jul 2026 22:13:42 +0200 Subject: [PATCH 02/18] Accept QUERY-style incoming catch-up requests (streaming/timeshift.php) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients that build catch-up requests in QUERY layout (e.g. Open-TV / Fred TV: /streaming/timeshift.php?username=...&stream=...&start=...) had no matching urlpattern, so the request silently fell through to the frontend's catch-all and got served index.html (200 OK, wrong content) instead of reaching the proxy — no error, no log line, the client just fails to play. The PATH-style layout (timeshift/////) already worked; QUERY-style autodetection already existed for outgoing provider requests (helpers.build_timeshift_url_format_a/_b) but was never mirrored to the incoming route. Split timeshift_proxy into a shared _timeshift_proxy_impl plus two thin entry points (PATH-style timeshift_proxy, new QUERY-style timeshift_proxy_query) so both incoming layouts are recognized. Reported against the predecessor plugin as dispatcharr_timeshift#10; reproduced identically against dispatcharr:dev (confirmed via nginx access log: a QUERY-style request returned 200 with a response body exactly matching the size of frontend/dist/index.html). --- apps/timeshift/tests/test_views.py | 52 ++++++++++++++++++++++++++++++ apps/timeshift/views.py | 29 ++++++++++++++++- dispatcharr/urls.py | 7 +++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 73e55bc4..183b5a7f 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import requests from django.http import HttpResponse from django.test import RequestFactory, TestCase, override_settings +from django.urls import resolve from rest_framework.test import APIRequestFactory, force_authenticate from apps.timeshift import views @@ -768,6 +769,57 @@ class TimeshiftProxyTimestampWiringTests(TestCase): stream_mock.assert_not_called() +class TimeshiftProxyQueryRoutingTests(TestCase): + """Regression test for dispatcharr_timeshift#10: some clients (e.g. + Open-TV / Fred TV) build catch-up requests in QUERY layout + (``/streaming/timeshift.php?...``) rather than PATH layout. Before this + fix, no urlpattern matched that path, so the request silently fell + through to the frontend's ```` catch-all and got served + ``index.html`` (200 OK, wrong content) instead of reaching the proxy.""" + + def test_query_style_path_resolves_to_timeshift_proxy_query(self): + match = resolve("/streaming/timeshift.php") + self.assertIs(match.func, views.timeshift_proxy_query) + + def test_path_style_still_resolves_to_timeshift_proxy(self): + match = resolve("/timeshift/u/p/8/2026-06-08:17-00/8.ts") + self.assertIs(match.func, views.timeshift_proxy) + + +class TimeshiftProxyQueryParamMappingTests(TestCase): + """`timeshift_proxy_query` must extract the same fields from the + querystring that `timeshift_proxy` receives as URL kwargs, and reject + the request before touching auth/DB when required params are absent.""" + + def setUp(self): + self.factory = RequestFactory() + + def test_delegates_with_mapped_params(self): + request = self.factory.get( + "/streaming/timeshift.php", + { + "username": "u", + "password": "p", + "stream": "8", + "start": "2026-06-08:17-00", + "duration": "40", + }, + ) + with patch.object(views, "_timeshift_proxy_impl", return_value=HttpResponse()) as impl: + views.timeshift_proxy_query(request) + impl.assert_called_once_with(request, "u", "p", "2026-06-08:17-00", "8") + + def test_missing_stream_param_returns_400_without_touching_impl(self): + request = self.factory.get( + "/streaming/timeshift.php", + {"username": "u", "password": "p", "start": "2026-06-08:17-00"}, + ) + with patch.object(views, "_timeshift_proxy_impl") as impl: + response = views.timeshift_proxy_query(request) + self.assertEqual(response.status_code, 400) + impl.assert_not_called() + + class TimeshiftProxyFailoverTests(TestCase): """When the first catch-up stream's provider cannot serve the archive, the proxy must fail over to the channel's next catch-up stream — each diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 71717e36..d39a9cf3 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -98,7 +98,7 @@ def _finalize_timeshift_response(response): def timeshift_proxy(request, username, password, stream_id, timestamp, duration): # noqa: ARG001 stream_id """Proxy an XC catch-up request to the provider with multi-stream failover. - URL shape (iPlayTV / TiviMate): + URL shape (iPlayTV / TiviMate — PATH layout): ``stream_id``: EPG channel number (ignored here). ``duration``: Dispatcharr ``Channel.id`` (XC API exposes channel.id as stream_id). ``timestamp``: UTC programme start (``YYYY-MM-DD:HH-MM`` or XC colon form @@ -110,6 +110,33 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) an in-flight or idle pool entry for the same viewer are served immediately (no redirect). Reuse ``session_id`` for all range/seek requests in a programme. """ + return _timeshift_proxy_impl(request, username, password, timestamp, duration) + + +def timeshift_proxy_query(request): + """Proxy an XC catch-up request submitted in QUERY layout. + + URL shape (Open-TV / Fred TV): ``/streaming/timeshift.php?username=... + &password=...&stream=&start=&duration=...`` + (``duration`` here is the EPG-minutes hint some clients send; unused, same + as ``stream_id`` in the PATH-layout ``timeshift_proxy`` above). + + Mirrors ``timeshift_proxy`` so clients that construct catch-up requests in + QUERY style are routed to the proxy instead of falling through to the + frontend catch-all route (see upstream issue dispatcharr_timeshift#10). + """ + username = request.GET.get("username", "") + password = request.GET.get("password", "") + timestamp = request.GET.get("start", "") + duration = request.GET.get("stream", "") + if not (username and password and timestamp and duration): + return _finalize_timeshift_response( + HttpResponseBadRequest("Missing required parameters") + ) + return _timeshift_proxy_impl(request, username, password, timestamp, duration) + + +def _timeshift_proxy_impl(request, username, password, timestamp, duration): raw_id = duration[:-3] if duration.endswith(".ts") else duration user = _authenticate_user(username, password) diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index cdedb0c4..8e8831cf 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -7,7 +7,7 @@ from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.live_proxy.views import stream_xc from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode -from apps.timeshift.views import timeshift_proxy +from apps.timeshift.views import timeshift_proxy, timeshift_proxy_query urlpatterns = [ # API Routes @@ -47,6 +47,11 @@ urlpatterns = [ timeshift_proxy, name="timeshift_proxy", ), + path( + "streaming/timeshift.php", + timeshift_proxy_query, + name="timeshift_proxy_query", + ), # XC VOD endpoints path( "movie///.", From d1344adb4ffe8162d9bfc29b6fb765970c3aeebf Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 14 Jul 2026 00:53:03 +0000 Subject: [PATCH 03/18] chore(uwsgi): update configuration for worker reload mechanism Added a new file entry to .gitignore for .uwsgi-reload and modified uwsgi.debug.ini to implement a touch-based worker reload mechanism, replacing the previous py-autoreload setting. This change enhances the management of API worker reloads during development. --- .gitignore | 1 + docker/uwsgi.debug.ini | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c75198a0..6d6b1613 100755 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ celerybeat-schedule* dump.rdb debugpy* uwsgi.sock +.uwsgi-reload package-lock.json models .idea diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini index 6753d1a6..80f45ae8 100644 --- a/docker/uwsgi.debug.ini +++ b/docker/uwsgi.debug.ini @@ -66,7 +66,9 @@ ignore-write-errors = true disable-write-exception = true # Debugging settings -py-autoreload = 1 +# Reload API workers: touch /app/.uwsgi-reload +; py-autoreload = 1 +touch-workers-reload = /app/.uwsgi-reload honour-stdin = true # Environment variables From 6d5a5a549ad41e834540d98c32686f276d7c89ec Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:17:11 -0700 Subject: [PATCH 04/18] Extracted utils --- .../src/utils/forms/OutputProfileUtils.js | 36 +++++++++++++++++++ frontend/src/utils/forms/ServerGroupUtils.js | 16 +++++++++ frontend/src/utils/pages/PluginsUtils.js | 9 +++++ 3 files changed, 61 insertions(+) create mode 100644 frontend/src/utils/forms/OutputProfileUtils.js create mode 100644 frontend/src/utils/forms/ServerGroupUtils.js diff --git a/frontend/src/utils/forms/OutputProfileUtils.js b/frontend/src/utils/forms/OutputProfileUtils.js new file mode 100644 index 00000000..b95e7664 --- /dev/null +++ b/frontend/src/utils/forms/OutputProfileUtils.js @@ -0,0 +1,36 @@ +import * as Yup from 'yup'; +import API from '../../api'; +import { yupResolver } from '@hookform/resolvers/yup'; + +export const BUILT_IN_COMMANDS = [ + { value: 'ffmpeg', label: 'FFmpeg' }, + { value: '__custom__', label: 'Custom…' }, +]; + +export const COMMAND_EXAMPLES = { + ffmpeg: + '-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1', +}; + +export const toCommandSelection = (command) => + BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__') + ? command + : '__custom__'; + +export const schema = Yup.object({ + name: Yup.string().required('Name is required'), + command: Yup.string().required('Command is required'), + parameters: Yup.string(), +}); + +export const addOutputProfile = (values) => { + return API.addOutputProfile(values); +}; + +export const updateOutputProfile = (values) => { + return API.updateOutputProfile(values); +}; + +export const getResolver = () => { + return yupResolver(schema); +}; diff --git a/frontend/src/utils/forms/ServerGroupUtils.js b/frontend/src/utils/forms/ServerGroupUtils.js new file mode 100644 index 00000000..55cbf5c4 --- /dev/null +++ b/frontend/src/utils/forms/ServerGroupUtils.js @@ -0,0 +1,16 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import * as Yup from 'yup'; +import API from '../../api'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), +}); +export const getResolver = () => { + return yupResolver(schema); +}; +export const updateServerGroup = (values) => { + return API.updateServerGroup(values); +}; +export const addServerGroup = (values) => { + return API.addServerGroup(values); +}; diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js index 3f11bfd2..54942385 100644 --- a/frontend/src/utils/pages/PluginsUtils.js +++ b/frontend/src/utils/pages/PluginsUtils.js @@ -25,3 +25,12 @@ export const deletePluginByKey = (key) => { export const getPluginDetailManifest = (repoId, manifestUrl) => { return API.getPluginDetailManifest(repoId, manifestUrl); }; +export const getPluginRepoSettings = () => { + return API.getPluginRepoSettings(); +}; +export const updatePluginRepoSettings = (values) => { + return API.updatePluginRepoSettings(values); +}; +export const previewPluginRepo = (url, publicKey) => { + return API.previewPluginRepo(url, publicKey); +}; From f6ad115a1fc34a3dc65fe6d9a104fd6356021e41 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:17:25 -0700 Subject: [PATCH 05/18] Added tests for utils --- .../__tests__/OutputProfileUtils.test.js | 210 ++++++++++++++++++ .../forms/__tests__/ServerGroupUtils.test.js | 114 ++++++++++ .../pages/__tests__/PluginsUtils.test.js | 85 +++++++ 3 files changed, 409 insertions(+) create mode 100644 frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js diff --git a/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js b/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js new file mode 100644 index 00000000..86eb2c00 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +vi.mock('../../../api', () => ({ + default: { + addOutputProfile: vi.fn(), + updateOutputProfile: vi.fn(), + }, +})); + +const mockResolver = vi.fn(); +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn(() => mockResolver), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── + +import API from '../../../api'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + toCommandSelection, + schema, + addOutputProfile, + updateOutputProfile, + getResolver, +} from '../OutputProfileUtils'; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('OutputProfileUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(yupResolver).mockReturnValue(mockResolver); + }); + + // ── Constants ────────────────────────────────────────────────────────────── + + describe('BUILT_IN_COMMANDS', () => { + it('includes the ffmpeg entry', () => { + expect(BUILT_IN_COMMANDS).toContainEqual({ + value: 'ffmpeg', + label: 'FFmpeg', + }); + }); + + it('includes the custom entry', () => { + expect(BUILT_IN_COMMANDS).toContainEqual({ + value: '__custom__', + label: 'Custom…', + }); + }); + }); + + describe('COMMAND_EXAMPLES', () => { + it('has a non-empty string example for ffmpeg', () => { + expect(typeof COMMAND_EXAMPLES.ffmpeg).toBe('string'); + expect(COMMAND_EXAMPLES.ffmpeg.length).toBeGreaterThan(0); + }); + }); + + // ── toCommandSelection ───────────────────────────────────────────────────── + + describe('toCommandSelection', () => { + it('returns the command value when it matches a non-custom built-in', () => { + expect(toCommandSelection('ffmpeg')).toBe('ffmpeg'); + }); + + it('returns "__custom__" when command is "__custom__"', () => { + // __custom__ is in BUILT_IN_COMMANDS but excluded by the o.value !== '__custom__' guard + expect(toCommandSelection('__custom__')).toBe('__custom__'); + }); + + it('returns "__custom__" for an unrecognized command string', () => { + expect(toCommandSelection('my-arbitrary-tool')).toBe('__custom__'); + }); + + it('returns "__custom__" for an empty string', () => { + expect(toCommandSelection('')).toBe('__custom__'); + }); + + it('returns "__custom__" for undefined', () => { + expect(toCommandSelection(undefined)).toBe('__custom__'); + }); + }); + + // ── schema ───────────────────────────────────────────────────────────────── + + describe('schema', () => { + it('validates a fully populated object', async () => { + await expect( + schema.validate({ + name: 'HD Profile', + command: 'ffmpeg', + parameters: '-c:v copy', + }) + ).resolves.toMatchObject({ + name: 'HD Profile', + command: 'ffmpeg', + parameters: '-c:v copy', + }); + }); + + it('validates when parameters is omitted (optional)', async () => { + await expect( + schema.validate({ name: 'HD Profile', command: 'ffmpeg' }) + ).resolves.toMatchObject({ name: 'HD Profile', command: 'ffmpeg' }); + }); + + it('rejects when name is missing', async () => { + await expect(schema.validate({ command: 'ffmpeg' })).rejects.toThrow( + 'Name is required' + ); + }); + + it('rejects when name is an empty string', async () => { + await expect( + schema.validate({ name: '', command: 'ffmpeg' }) + ).rejects.toThrow('Name is required'); + }); + + it('rejects when command is missing', async () => { + await expect(schema.validate({ name: 'HD Profile' })).rejects.toThrow( + 'Command is required' + ); + }); + + it('rejects when command is an empty string', async () => { + await expect( + schema.validate({ name: 'HD Profile', command: '' }) + ).rejects.toThrow('Command is required'); + }); + }); + + // ── addOutputProfile ─────────────────────────────────────────────────────── + + describe('addOutputProfile', () => { + it('calls API.addOutputProfile with the provided values', async () => { + const values = { name: 'New Profile', command: 'ffmpeg', parameters: '' }; + vi.mocked(API.addOutputProfile).mockResolvedValue({ id: 1, ...values }); + + await addOutputProfile(values); + + expect(API.addOutputProfile).toHaveBeenCalledWith(values); + expect(API.addOutputProfile).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { name: 'New Profile', command: 'ffmpeg' }; + const response = { id: 42, ...values }; + vi.mocked(API.addOutputProfile).mockResolvedValue(response); + + const result = await addOutputProfile(values); + + expect(result).toEqual(response); + }); + + it('propagates errors thrown by API.addOutputProfile', async () => { + vi.mocked(API.addOutputProfile).mockRejectedValue( + new Error('Network error') + ); + + await expect(addOutputProfile({})).rejects.toThrow('Network error'); + }); + }); + + // ── updateOutputProfile ──────────────────────────────────────────────────── + + describe('updateOutputProfile', () => { + it('calls API.updateOutputProfile with the provided values', async () => { + const values = { id: 1, name: 'Updated Profile', command: 'ffmpeg' }; + vi.mocked(API.updateOutputProfile).mockResolvedValue(values); + + await updateOutputProfile(values); + + expect(API.updateOutputProfile).toHaveBeenCalledWith(values); + expect(API.updateOutputProfile).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { id: 7, name: 'Updated Profile', command: 'ffmpeg' }; + vi.mocked(API.updateOutputProfile).mockResolvedValue(values); + + const result = await updateOutputProfile(values); + + expect(result).toEqual(values); + }); + + it('propagates errors thrown by API.updateOutputProfile', async () => { + vi.mocked(API.updateOutputProfile).mockRejectedValue( + new Error('Update failed') + ); + + await expect(updateOutputProfile({})).rejects.toThrow('Update failed'); + }); + }); + + // ── getResolver ──────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('returns the result of yupResolver called with schema', () => { + const resolver = getResolver(); + + expect(yupResolver).toHaveBeenCalledWith(schema); + expect(resolver).toBe(mockResolver); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js b/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js new file mode 100644 index 00000000..db94a8cd --- /dev/null +++ b/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +vi.mock('../../../api', () => ({ + default: { + addServerGroup: vi.fn(), + updateServerGroup: vi.fn(), + }, +})); + +const mockResolver = vi.fn(); +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn(() => mockResolver), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── + +import API from '../../../api'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + getResolver, + addServerGroup, + updateServerGroup, +} from '../ServerGroupUtils'; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('ServerGroupUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(yupResolver).mockReturnValue(mockResolver); + }); + + // ── getResolver ──────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver with a schema and returns the result', () => { + const resolver = getResolver(); + + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(yupResolver).toHaveBeenCalledWith(expect.any(Object)); + expect(resolver).toBe(mockResolver); + }); + + it('returns a new resolver on each call', () => { + const resolverA = getResolver(); + const resolverB = getResolver(); + + expect(yupResolver).toHaveBeenCalledTimes(2); + expect(resolverA).toBe(mockResolver); + expect(resolverB).toBe(mockResolver); + }); + }); + + // ── addServerGroup ───────────────────────────────────────────────────────── + + describe('addServerGroup', () => { + it('calls API.addServerGroup with the provided values', async () => { + const values = { name: 'US East' }; + vi.mocked(API.addServerGroup).mockResolvedValue({ id: 1, ...values }); + + await addServerGroup(values); + + expect(API.addServerGroup).toHaveBeenCalledWith(values); + expect(API.addServerGroup).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { name: 'US East' }; + const response = { id: 1, ...values }; + vi.mocked(API.addServerGroup).mockResolvedValue(response); + + const result = await addServerGroup(values); + + expect(result).toEqual(response); + }); + + it('propagates errors thrown by API.addServerGroup', async () => { + vi.mocked(API.addServerGroup).mockRejectedValue(new Error('Network error')); + + await expect(addServerGroup({ name: 'Test' })).rejects.toThrow('Network error'); + }); + }); + + // ── updateServerGroup ────────────────────────────────────────────────────── + + describe('updateServerGroup', () => { + it('calls API.updateServerGroup with the provided values', async () => { + const values = { id: 5, name: 'EU West' }; + vi.mocked(API.updateServerGroup).mockResolvedValue(values); + + await updateServerGroup(values); + + expect(API.updateServerGroup).toHaveBeenCalledWith(values); + expect(API.updateServerGroup).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { id: 5, name: 'EU West' }; + vi.mocked(API.updateServerGroup).mockResolvedValue(values); + + const result = await updateServerGroup(values); + + expect(result).toEqual(values); + }); + + it('propagates errors thrown by API.updateServerGroup', async () => { + vi.mocked(API.updateServerGroup).mockRejectedValue(new Error('Update failed')); + + await expect(updateServerGroup({ id: 1, name: 'Test' })).rejects.toThrow('Update failed'); + }); + }); +}); diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js index 7d175451..bc27e005 100644 --- a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js @@ -11,6 +11,9 @@ vi.mock('../../../api.js', () => ({ reloadPlugins: vi.fn(), deletePlugin: vi.fn(), getPluginDetailManifest: vi.fn(), + getPluginRepoSettings: vi.fn(), + updatePluginRepoSettings: vi.fn(), + previewPluginRepo: vi.fn(), }, })); @@ -344,4 +347,86 @@ describe('PluginsUtils', () => { expect(API.getPluginDetailManifest).toHaveBeenCalledWith(null, null); }); }); + + describe('getPluginRepoSettings', () => { + it('should call API getPluginRepoSettings', () => { + PluginsUtils.getPluginRepoSettings(); + + expect(API.getPluginRepoSettings).toHaveBeenCalledTimes(1); + }); + + it('should return API response', () => { + const mockResponse = { repos: [] }; + + API.getPluginRepoSettings.mockReturnValue(mockResponse); + + const result = PluginsUtils.getPluginRepoSettings(); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('updatePluginRepoSettings', () => { + it('should call API updatePluginRepoSettings with values', () => { + const values = { repos: ['https://example.com/repo.json'] }; + + PluginsUtils.updatePluginRepoSettings(values); + + expect(API.updatePluginRepoSettings).toHaveBeenCalledWith(values); + expect(API.updatePluginRepoSettings).toHaveBeenCalledTimes(1); + }); + + it('should return API response', () => { + const values = { repos: ['https://example.com/repo.json'] }; + const mockResponse = { success: true }; + + API.updatePluginRepoSettings.mockReturnValue(mockResponse); + + const result = PluginsUtils.updatePluginRepoSettings(values); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('previewPluginRepo', () => { + it('should call API previewPluginRepo with url and publicKey', () => { + const url = 'https://example.com/repo.json'; + const publicKey = 'public-key'; + + PluginsUtils.previewPluginRepo(url, publicKey); + + expect(API.previewPluginRepo).toHaveBeenCalledWith(url, publicKey); + expect(API.previewPluginRepo).toHaveBeenCalledTimes(1); + }); + + it('should return API response', () => { + const url = 'https://example.com/repo.json'; + const publicKey = 'public-key'; + const mockResponse = { name: 'Test Repo', plugins: [] }; + + API.previewPluginRepo.mockReturnValue(mockResponse); + + const result = PluginsUtils.previewPluginRepo(url, publicKey); + + expect(result).toEqual(mockResponse); + }); + + it('should handle empty string url and publicKey', () => { + const url = ''; + const publicKey = ''; + + PluginsUtils.previewPluginRepo(url, publicKey); + + expect(API.previewPluginRepo).toHaveBeenCalledWith('', ''); + }); + + it('should handle null url and publicKey', () => { + const url = null; + const publicKey = null; + + PluginsUtils.previewPluginRepo(url, publicKey); + + expect(API.previewPluginRepo).toHaveBeenCalledWith(null, null); + }); + }); }); From 99fc71de993f59368a164e5f2cf529f620a2a26f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:18:53 -0700 Subject: [PATCH 06/18] Slight refactoring of components --- .../src/components/forms/OutputProfile.jsx | 38 ++++++------------- frontend/src/components/forms/ServerGroup.jsx | 30 +++++---------- .../components/modals/CreateChannelModal.jsx | 9 ++--- .../src/components/modals/ProfileModal.jsx | 20 +++++++--- frontend/src/hooks/useEpgPreview.jsx | 6 ++- frontend/src/pages/Connect.jsx | 15 ++++++-- frontend/src/pages/ConnectLogs.jsx | 6 ++- 7 files changed, 59 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/forms/OutputProfile.jsx b/frontend/src/components/forms/OutputProfile.jsx index 541c911b..610c0ef4 100644 --- a/frontend/src/components/forms/OutputProfile.jsx +++ b/frontend/src/components/forms/OutputProfile.jsx @@ -1,8 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Modal, TextInput, @@ -13,27 +10,14 @@ import { Stack, Checkbox, } from '@mantine/core'; - -const BUILT_IN_COMMANDS = [ - { value: 'ffmpeg', label: 'FFmpeg' }, - { value: '__custom__', label: 'Custom…' }, -]; - -const COMMAND_EXAMPLES = { - ffmpeg: - '-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1', -}; - -const toCommandSelection = (command) => - BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__') - ? command - : '__custom__'; - -const schema = Yup.object({ - name: Yup.string().required('Name is required'), - command: Yup.string().required('Command is required'), - parameters: Yup.string(), -}); +import { + addOutputProfile, + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + getResolver, + toCommandSelection, + updateOutputProfile, +} from '../../utils/forms/OutputProfileUtils'; const OutputProfile = ({ profile = null, isOpen, onClose }) => { const [commandSelection, setCommandSelection] = useState('ffmpeg'); @@ -57,7 +41,7 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { watch, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); useEffect(() => { @@ -67,9 +51,9 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { const onSubmit = async (values) => { if (profile?.id) { - await API.updateOutputProfile({ id: profile.id, ...values }); + await updateOutputProfile({ id: profile.id, ...values }); } else { - await API.addOutputProfile(values); + await addOutputProfile(values); } reset(); onClose(); diff --git a/frontend/src/components/forms/ServerGroup.jsx b/frontend/src/components/forms/ServerGroup.jsx index 80c8c1a4..0d5ecd05 100644 --- a/frontend/src/components/forms/ServerGroup.jsx +++ b/frontend/src/components/forms/ServerGroup.jsx @@ -1,20 +1,13 @@ import React, { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Button, Flex, Modal, TextInput } from '@mantine/core'; +import { + getResolver, + updateServerGroup, + addServerGroup, +} from '../../utils/forms/ServerGroupUtils'; -const schema = Yup.object({ - name: Yup.string().required('Name is required'), -}); - -const ServerGroupForm = ({ - serverGroup = null, - isOpen, - onClose, - onSaved, -}) => { +const ServerGroupForm = ({ serverGroup = null, isOpen, onClose, onSaved }) => { const defaultValues = useMemo( () => ({ name: serverGroup?.name || '', @@ -29,16 +22,13 @@ const ServerGroupForm = ({ reset, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { - let response; - if (serverGroup?.id) { - response = await API.updateServerGroup({ id: serverGroup.id, ...values }); - } else { - response = await API.addServerGroup(values); - } + const response = serverGroup?.id + ? await updateServerGroup({ id: serverGroup.id, ...values }) + : await addServerGroup(values); if (response) { onSaved?.(response); diff --git a/frontend/src/components/modals/CreateChannelModal.jsx b/frontend/src/components/modals/CreateChannelModal.jsx index a1a42b29..4f4d9d4e 100644 --- a/frontend/src/components/modals/CreateChannelModal.jsx +++ b/frontend/src/components/modals/CreateChannelModal.jsx @@ -4,6 +4,7 @@ import { Stack, Text, Radio, + RadioGroup, NumberInput, Checkbox, Group, @@ -101,11 +102,7 @@ const CreateChannelModal = ({ - + - + {mode === customModeValue && ( { + return API.updateChannelProfile(values); +} + +const duplicateChannelProfile = (profileId, newName) => { + return API.duplicateChannelProfile(profileId, newName); +} + const ProfileModal = ({ opened, onClose, mode, profile }) => { const [profileNameInput, setProfileNameInput] = useState(''); const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId); @@ -40,7 +48,7 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => { if (!mode || !profile) return; if (!trimmedName) { - notifications.show({ + showNotification({ title: 'Profile name is required', color: 'red.5', }); @@ -53,13 +61,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => { return; } - const updatedProfile = await API.updateChannelProfile({ + const updatedProfile = await updateChannelProfile({ id: profile.id, name: trimmedName, }); if (updatedProfile) { - notifications.show({ + showNotification({ title: 'Profile renamed', message: `${profile.name} → ${trimmedName}`, color: 'green.5', @@ -69,13 +77,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => { } if (mode === 'duplicate') { - const duplicatedProfile = await API.duplicateChannelProfile( + const duplicatedProfile = await duplicateChannelProfile( profile.id, trimmedName ); if (duplicatedProfile) { - notifications.show({ + showNotification({ title: 'Profile duplicated', message: `${profile.name} copied to ${duplicatedProfile.name}`, color: 'green.5', diff --git a/frontend/src/hooks/useEpgPreview.jsx b/frontend/src/hooks/useEpgPreview.jsx index f2cab900..3c804ea3 100644 --- a/frontend/src/hooks/useEpgPreview.jsx +++ b/frontend/src/hooks/useEpgPreview.jsx @@ -1,6 +1,10 @@ import { useEffect, useState } from 'react'; import API from '../api'; +const getCurrentProgramForEpg = (epgId) => { + return API.getCurrentProgramForEpg(epgId); +}; + export const useEpgPreview = (epgDataId) => { const [currentProgram, setCurrentProgram] = useState(null); const [isLoadingProgram, setIsLoadingProgram] = useState(false); @@ -28,7 +32,7 @@ export const useEpgPreview = (epgDataId) => { if (cancelled || Date.now() - startTime > deadlineMs) break; try { - const program = await API.getCurrentProgramForEpg(epgDataId); + const program = await getCurrentProgramForEpg(epgDataId); if (cancelled) return; if (program && program.parsing && attempt < maxRetries) { diff --git a/frontend/src/pages/Connect.jsx b/frontend/src/pages/Connect.jsx index 099546af..293c2e57 100644 --- a/frontend/src/pages/Connect.jsx +++ b/frontend/src/pages/Connect.jsx @@ -18,6 +18,14 @@ import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react'; import ConnectionForm from '../components/forms/Connection'; import { SUBSCRIPTION_EVENTS } from '../constants'; +const deleteConnectIntegration = (id) => { + return API.deleteConnectIntegration(id); +}; + +const updateConnectIntegration = (id, values) => { + return API.updateConnectIntegration(id, values); +}; + export default function ConnectPage() { const { integrations, isLoading, fetchIntegrations } = useConnectStore(); const theme = useMantineTheme(); @@ -40,7 +48,7 @@ export default function ConnectPage() { const deleteConnection = async (id) => { console.log('Deleting connection', id); - await API.deleteConnectIntegration(id); + await deleteConnectIntegration(id); }; return ( @@ -99,15 +107,14 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) { const toggleIntegration = async () => { try { - await API.updateConnectIntegration(integration.id, { + await updateConnectIntegration(integration.id, { ...integration, enabled: !enabled, }); setEnabled(!enabled); } catch (error) { console.error('Failed to update integration', error); - } finally { - } + } }; return ( diff --git a/frontend/src/pages/ConnectLogs.jsx b/frontend/src/pages/ConnectLogs.jsx index 08705a13..eccc3672 100644 --- a/frontend/src/pages/ConnectLogs.jsx +++ b/frontend/src/pages/ConnectLogs.jsx @@ -18,6 +18,10 @@ import { SUBSCRIPTION_EVENTS } from '../constants'; import { CustomTable, useTable } from '../components/tables/CustomTable'; import { copyToClipboard } from '../utils'; +const getConnectLogs = (params) => { + return API.getConnectLogs(params); +}; + export default function ConnectLogsPage() { const { integrations, fetchIntegrations } = useConnectStore(); @@ -51,7 +55,7 @@ export default function ConnectLogsPage() { if (filters.type) params.type = filters.type; if (filters.integration) params.integration = filters.integration; - const data = await API.getConnectLogs(params); + const data = await getConnectLogs(params); const results = Array.isArray(data) ? data : data?.results || []; setLogs(results); setCount(data?.count || results.length || 0); From 07d17a5ffc1d3b10336ae5e4deac58bdf3602736 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:19:47 -0700 Subject: [PATCH 07/18] Extracted ManageReposModal from PluginBrowse --- .../components/modals/ManageReposModal.jsx | 567 ++++++++++++ frontend/src/pages/PluginBrowse.jsx | 815 +++--------------- 2 files changed, 702 insertions(+), 680 deletions(-) create mode 100644 frontend/src/components/modals/ManageReposModal.jsx diff --git a/frontend/src/components/modals/ManageReposModal.jsx b/frontend/src/components/modals/ManageReposModal.jsx new file mode 100644 index 00000000..2b823b32 --- /dev/null +++ b/frontend/src/components/modals/ManageReposModal.jsx @@ -0,0 +1,567 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActionIcon, + Badge, + Box, + Button, + Group, + Loader, + Modal, + NumberInput, + Stack, + Text, + Textarea, + TextInput, +} from '@mantine/core'; +import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; +import ConfirmationDialog from '../ConfirmationDialog.jsx'; +import { usePluginStore } from '../../store/plugins.jsx'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getPluginRepoSettings, + previewPluginRepo, + updatePluginRepoSettings, +} from '../../utils/pages/PluginsUtils.js'; + +export default function ManageReposModal({ opened, onClose }) { + const repos = usePluginStore((s) => s.repos); + const reposLoading = usePluginStore((s) => s.reposLoading); + const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins); + const refreshRepo = usePluginStore((s) => s.refreshRepo); + const addRepo = usePluginStore((s) => s.addRepo); + const removeRepo = usePluginStore((s) => s.removeRepo); + const updateRepo = usePluginStore((s) => s.updateRepo); + + const [refreshInterval, setRefreshInterval] = useState(6); + const [savingInterval, setSavingInterval] = useState(false); + const saveIntervalTimer = useRef(null); + + const [editingKeyRepoId, setEditingKeyRepoId] = useState(null); + const [editKeyValue, setEditKeyValue] = useState(''); + const [savingKey, setSavingKey] = useState(false); + + const [showAddRepo, setShowAddRepo] = useState(false); + const [newRepoUrl, setNewRepoUrl] = useState(''); + const [newRepoPublicKey, setNewRepoPublicKey] = useState(''); + const [addingRepo, setAddingRepo] = useState(false); + const [gpgKeyFocused, setGpgKeyFocused] = useState(false); + const [repoPreview, setRepoPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const previewTimer = useRef(null); + + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const loadRepoSettings = useCallback(async () => { + const data = await getPluginRepoSettings(); + if (data) setRefreshInterval(data.refresh_interval_hours ?? 6); + }, []); + + const handleSaveInterval = useCallback((val) => { + const hours = val ?? 0; + setRefreshInterval(hours); + if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current); + saveIntervalTimer.current = setTimeout(async () => { + setSavingInterval(true); + try { + await updatePluginRepoSettings({ refresh_interval_hours: hours }); + } catch { + // Error notification handled by API layer + } finally { + setSavingInterval(false); + } + }, 800); + }, []); + + // Debounced manifest preview + const fetchPreview = useCallback((url, publicKey) => { + if (previewTimer.current) clearTimeout(previewTimer.current); + if (!url.trim() || !url.match(/^https?:\/\/.+/i)) { + setRepoPreview(null); + setPreviewLoading(false); + return; + } + setPreviewLoading(true); + previewTimer.current = setTimeout(async () => { + const result = await previewPluginRepo(url.trim(), publicKey?.trim()); + setRepoPreview(result); + setPreviewLoading(false); + }, 600); + }, []); + + const handleAddRepo = useCallback(async () => { + if (!newRepoUrl.trim()) return; + setAddingRepo(true); + try { + await addRepo({ + url: newRepoUrl.trim(), + public_key: newRepoPublicKey.trim(), + }); + setNewRepoUrl(''); + setNewRepoPublicKey(''); + setRepoPreview(null); + setShowAddRepo(false); + await fetchAvailablePlugins(); + showNotification({ + title: 'Added', + message: 'Plugin repo added', + color: 'green', + }); + } catch { + // Error notification handled by API layer + } finally { + setAddingRepo(false); + } + }, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]); + + const handleDeleteRepo = useCallback( + async (id) => { + await removeRepo(id); + setDeleteConfirmId(null); + await fetchAvailablePlugins(); + showNotification({ + title: 'Removed', + message: 'Plugin repo removed', + color: 'green', + }); + }, + [removeRepo, fetchAvailablePlugins] + ); + + const handleEditKey = useCallback((repo) => { + setEditingKeyRepoId(repo.id); + setEditKeyValue(repo.public_key || ''); + }, []); + + const handleSaveKey = useCallback(async () => { + if (editingKeyRepoId == null) return; + setSavingKey(true); + try { + await updateRepo(editingKeyRepoId, { public_key: editKeyValue }); + await refreshRepo(editingKeyRepoId); + await fetchAvailablePlugins(); + showNotification({ + title: 'Updated', + message: 'Public key updated', + color: 'green', + }); + setEditingKeyRepoId(null); + setEditKeyValue(''); + } catch { + showNotification({ + title: 'Error', + message: 'Failed to update key', + color: 'red', + }); + } finally { + setSavingKey(false); + } + }, [ + editingKeyRepoId, + editKeyValue, + updateRepo, + refreshRepo, + fetchAvailablePlugins, + ]); + + // Load settings when modal opens + useEffect(() => { + if (opened) loadRepoSettings(); + }, [opened, loadRepoSettings]); + + // Cleanup any pending timers on unmount + useEffect(() => { + return () => { + if (previewTimer.current) clearTimeout(previewTimer.current); + if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current); + }; + }, []); + + return ( + <> + +
+ Plugin Repositories + + Add third-party plugin repositories or manage existing ones. + Manifests are fetched automatically at the configured interval. + +
+
+ + Refresh Interval + + + + Hours, 0 to disable + +
+ + } + centered + size="lg" + styles={{ + title: { width: '100%' }, + header: { alignItems: 'flex-start' }, + }} + > + + {reposLoading && repos.length === 0 && } + + {repos.map((repo) => ( + + + + + + {repo.name} + + {repo.is_official && ( + + Official Repo + + )} + {repo.signature_verified === true && ( + } + > + Verified Signature + + )} + {repo.signature_verified === false && ( + } + > + Invalid Signature + + )} + + {repo.registry_url ? ( + + + {repo.registry_url} + + + ) : null} + + {repo.url} + + {repo.last_fetched && ( + + Last fetched:{' '} + {new Date(repo.last_fetched).toLocaleString()} + {repo.last_fetch_status && + repo.last_fetch_status !== '200' + ? ` · ${repo.last_fetch_status}` + : repo.plugin_count != null + ? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available` + : ''} + + )} + + {!repo.is_official && ( + + handleEditKey(repo)} + > + + + setDeleteConfirmId(repo.id)} + > + + + + )} + + {editingKeyRepoId === repo.id && ( + +