diff --git a/CHANGELOG.md b/CHANGELOG.md index 19ff2f53..49637717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Live XC upstream URLs use current credentials.** `_resolve_live_stream_url()` builds `/live/{user}/{pass}/{stream_id}.ts` from transformed account credentials and the stream's provider `stream_id`, so playback stays aligned with the active login after credential or profile changes instead of reusing a stale `stream.url` from sync. - **Channel stream switches check pooled capacity.** `get_stream_info_for_switch()` uses `profile_available_for_channel_switch()` when targeting a specific stream, and releases a reserved slot if URL assembly fails after `get_stream()` allocated one. - **Live proxy init reads Redis assignment once.** After `generate_stream_url()` reserves a slot, the stream handler reads `channel_stream` / `stream_profile` from Redis instead of calling `get_stream()` again, avoiding double INCR under concurrent release. +- **Centralized Dispatcharr User-Agent construction in `core.utils`.** Outbound HTTP calls (Schedules Direct API, update checks, logo/VOD fallbacks, DVR recording clients) now use `dispatcharr_user_agent()`, `dispatcharr_dvr_user_agent()`, and `dispatcharr_http_headers()` instead of ad-hoc `Dispatcharr/{version}` strings and stale `Dispatcharr/1.0` fallbacks. - **EPG auto-match overhaul** — matching logic moved to `apps/channels/epg_matching.py`; Celery tasks in `tasks.py` are thin wrappers. - Single-channel auto-match is now asynchronous: the API returns `202 Accepted` and pushes the result over WebSocket (`single_channel_epg_match`), so large EPG libraries no longer hit the previous 30-second HTTP timeout. - Progress, bulk completion, and single-channel results use `send_websocket_update` instead of `async_to_sync(channel_layer.group_send)`, so notifications work reliably under gevent-patched uWSGI and Celery workers. @@ -46,6 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wrong channel assignments from global ML similarity; ML validation now checks the fuzzy best match (or top fuzzy candidates as a last resort) instead of scoring the entire catalog. - Channel form auto-match spinner could stick after errors or early task exits; all single-channel outcomes now push a WebSocket result, and the UI clears loading state after a 3-minute timeout. - Bulk auto-match completion no longer calls `batch-set-epg` from the WebSocket handler, which had been re-applying every match and queueing redundant `parse_programs_for_tvg_id` tasks even when assignments were unchanged. +- **Schedules Direct lineup search country dropdown.** The country list was fetched directly from the SD API in the browser, which failed due to CORS and silently fell back to a 14-country hardcoded list. Countries are now included in the `GET sd-lineups` response (server-side fetch with proper User-Agent) so the full SD country list populates correctly. — Thanks [@sethwv](https://github.com/sethwv) +- **Schedules Direct program poster proxy omitted User-Agent on image requests.** The poster endpoint authenticated with SD correctly but fetched images with only the `token` header, which violates SD's API requirements (error 1003). Image requests now include the standard `Dispatcharr/{version}` User-Agent via `dispatcharr_http_headers()`. ## [0.26.0] - 2026-06-07 diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 965da468..e9711026 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2800,8 +2800,9 @@ class LogoViewSet(viewsets.ModelViewSet): user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id)) user_agent = user_agent_obj.user_agent except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError): - # Fallback to hardcoded if default not found - user_agent = 'Dispatcharr/1.0' + # Fallback if default not found + from core.utils import dispatcharr_user_agent + user_agent = dispatcharr_user_agent() # Hard total timeout (connect + full download) prevents a slow # server dripping bytes from holding a greenlet indefinitely. diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 79058c3c..1250607f 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -1181,12 +1181,13 @@ def _dvr_ffmpeg_retry_backoff_seconds(retry_index): def _dvr_build_ffmpeg_cmd(stream_url, recording_id, hls_m3u8, hls_seg_pattern, hls_start_number): """Build the FFmpeg command for DVR HLS segment recording.""" + from core.utils import dispatcharr_dvr_user_agent return [ "ffmpeg", "-y", "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", - "-user_agent", f"Dispatcharr-DVR/recording-{recording_id}", + "-user_agent", dispatcharr_dvr_user_agent(recording_id), # Regenerate monotonic PTS to handle erratic/discontinuous timestamps # from IPTV sources. "-fflags", "+genpts", diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 547be152..b268ef65 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -128,7 +128,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): import hashlib import requests as http_requests from apps.epg.tasks import SD_BASE_URL - from version import __version__ as dispatcharr_version + from core.utils import dispatcharr_http_headers username = (source.username or '').strip() password = (source.password or '').strip() @@ -143,7 +143,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): auth_response = http_requests.post( f"{SD_BASE_URL}/token", json={'username': username, 'password': sha1_password}, - headers={'Content-Type': 'application/json', 'User-Agent': f'Dispatcharr/{dispatcharr_version}'}, + headers=dispatcharr_http_headers(), timeout=15, ) auth_response.raise_for_status() @@ -227,6 +227,24 @@ class EPGSourceViewSet(viewsets.ModelViewSet): f"Lockout set until {reset_at.isoformat()}." ) + def _fetch_sd_countries(self): + """Fetch the SD country list (token not required; User-Agent is).""" + import requests as http_requests + from apps.epg.tasks import SD_BASE_URL + from core.utils import dispatcharr_http_headers + + try: + resp = http_requests.get( + f"{SD_BASE_URL}/available/countries", + headers=dispatcharr_http_headers(content_type=None), + timeout=15, + ) + resp.raise_for_status() + return resp.json() + except http_requests.exceptions.RequestException as e: + logger.warning(f"Failed to fetch SD countries: {e}") + return None + @action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups") def sd_lineups(self, request, pk=None): """ @@ -236,7 +254,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): """ import requests as http_requests from apps.epg.tasks import SD_BASE_URL - from version import __version__ as dispatcharr_version + from core.utils import dispatcharr_http_headers source = self.get_object() if source.source_type != 'schedules_direct': @@ -249,13 +267,10 @@ class EPGSourceViewSet(viewsets.ModelViewSet): if error: return error - headers = { - 'Content-Type': 'application/json', - 'User-Agent': f'Dispatcharr/{dispatcharr_version}', - 'token': token, - } + headers = dispatcharr_http_headers(token=token) if request.method == "GET": + countries = self._fetch_sd_countries() try: resp = http_requests.get( f"{SD_BASE_URL}/lineups", @@ -272,6 +287,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): "changes_remaining": self._get_sd_changes_remaining(source), "changes_reset_at": self._get_sd_reset_at(source), "notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.", + "countries": countries, }) resp.raise_for_status() data = resp.json() @@ -281,6 +297,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): "max_lineups": 4, "changes_remaining": self._get_sd_changes_remaining(source), "changes_reset_at": self._get_sd_reset_at(source), + "countries": countries, }) except http_requests.exceptions.RequestException as e: return Response( @@ -399,7 +416,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): """ import requests as http_requests from apps.epg.tasks import SD_BASE_URL - from version import __version__ as dispatcharr_version + from core.utils import dispatcharr_http_headers source = self.get_object() if source.source_type != 'schedules_direct': @@ -420,11 +437,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): if error: return error - headers = { - 'Content-Type': 'application/json', - 'User-Agent': f'Dispatcharr/{dispatcharr_version}', - 'token': token, - } + headers = dispatcharr_http_headers(token=token) try: resp = http_requests.get( @@ -493,6 +506,7 @@ class ProgramViewSet(viewsets.ModelViewSet): """Proxy endpoint for SD program poster images. Nginx caches the response.""" import requests as http_requests from apps.epg.tasks import SD_BASE_URL + from core.utils import dispatcharr_http_headers program = self.get_object() poster_sd_url = (program.custom_properties or {}).get('sd_icon') @@ -516,14 +530,10 @@ class ProgramViewSet(viewsets.ModelViewSet): if not token: sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest() try: - from version import __version__ as dispatcharr_version auth_resp = http_requests.post( f"{SD_BASE_URL}/token", json={'username': source.username, 'password': sha1_password}, - headers={ - 'Content-Type': 'application/json', - 'User-Agent': f'Dispatcharr/{dispatcharr_version}', - }, + headers=dispatcharr_http_headers(), timeout=10, ) auth_data = auth_resp.json() @@ -549,7 +559,7 @@ class ProgramViewSet(viewsets.ModelViewSet): try: img_resp = http_requests.get( poster_sd_url, - headers={'token': token}, + headers=dispatcharr_http_headers(token=token, content_type=None), timeout=15, allow_redirects=True, ) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 9d244e79..ad2afb4c 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -2242,17 +2242,10 @@ def fetch_schedules_direct(source, stations_only=False, force=False): # SD API spec requires the User-Agent to identify the application and version. # SergeantPanda confirmed Dispatcharr should identify itself properly. # ------------------------------------------------------------------------- - from version import __version__ as dispatcharr_version - sd_user_agent = f"Dispatcharr/{dispatcharr_version}" + from core.utils import dispatcharr_http_headers def _sd_headers(token=None): - h = { - 'Content-Type': 'application/json', - 'User-Agent': sd_user_agent, - } - if token: - h['token'] = token - return h + return dispatcharr_http_headers(token=token) def _sd_post_refresh_tasks(mapped_epg_ids, program_metadata, today): """Poster fetch, logo auto-apply, and pruning — runs even when schedules are unchanged.""" diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 27208ec9..f9740aa1 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -23,6 +23,7 @@ from rest_framework_simplejwt.authentication import JWTAuthentication from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication from apps.proxy.utils import check_user_stream_limits from dispatcharr.utils import network_access_allowed +from core.utils import dispatcharr_user_agent logger = logging.getLogger(__name__) @@ -704,7 +705,7 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None # Use M3U account's user agent as primary, client user agent as fallback m3u_user_agent = m3u_account.get_user_agent().user_agent if m3u_account.get_user_agent() else None headers = { - 'User-Agent': m3u_user_agent or client_user_agent or 'Dispatcharr/1.0', + 'User-Agent': m3u_user_agent or client_user_agent or dispatcharr_user_agent(), 'Accept': '*/*', 'Range': 'bytes=0-1' # Request only first 2 bytes } diff --git a/core/tasks.py b/core/tasks.py index e9d79d37..b562f335 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -796,11 +796,11 @@ def check_for_version_update(): from packaging import version as pkg_version from version import __version__, __timestamp__ from core.models import SystemNotification - from core.utils import send_websocket_notification + from core.utils import dispatcharr_http_headers, send_websocket_notification try: is_dev_build = __timestamp__ is not None - DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'} + DISPATCHARR_HEADERS = dispatcharr_http_headers(content_type=None) if is_dev_build: # Check Docker Hub for newer dev builds diff --git a/core/tests.py b/core/tests.py index e7f45c5a..7b2f1986 100644 --- a/core/tests.py +++ b/core/tests.py @@ -6,6 +6,35 @@ from apps.epg.models import EPGSource from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY +class DispatcharrUserAgentTests(TestCase): + @patch('version.__version__', '1.2.3') + def test_dispatcharr_user_agent(self): + from core.utils import dispatcharr_user_agent + self.assertEqual(dispatcharr_user_agent(), 'Dispatcharr/1.2.3') + + def test_dispatcharr_dvr_user_agent(self): + from core.utils import dispatcharr_dvr_user_agent + self.assertEqual(dispatcharr_dvr_user_agent(42), 'Dispatcharr-DVR/recording-42') + + @patch('version.__version__', '1.2.3') + def test_dispatcharr_http_headers_with_token(self): + from core.utils import dispatcharr_http_headers + headers = dispatcharr_http_headers(token='tok123') + self.assertEqual(headers, { + 'User-Agent': 'Dispatcharr/1.2.3', + 'Content-Type': 'application/json', + 'token': 'tok123', + }) + + @patch('version.__version__', '1.2.3') + def test_dispatcharr_http_headers_without_content_type(self): + from core.utils import dispatcharr_http_headers + self.assertEqual( + dispatcharr_http_headers(content_type=None), + {'User-Agent': 'Dispatcharr/1.2.3'}, + ) + + class ProgrammeIndexRebuildTests(TestCase): def test_startup_rebuild_does_not_lock_out_queued_build_task(self): EPGSource.objects.update( diff --git a/core/utils.py b/core/utils.py index 6a13c472..c0a70987 100644 --- a/core/utils.py +++ b/core/utils.py @@ -21,6 +21,33 @@ logger = logging.getLogger(__name__) # Import the command detector from .command_utils import is_management_command + +def dispatcharr_user_agent(): + """Return the standard Dispatcharr User-Agent string (Dispatcharr/{version}).""" + from version import __version__ + return f'Dispatcharr/{__version__}' + + +def dispatcharr_dvr_user_agent(recording_id): + """Return the User-Agent string used by DVR FFmpeg clients for a recording.""" + return f'Dispatcharr-DVR/recording-{recording_id}' + + +def dispatcharr_http_headers(*, token=None, content_type='application/json'): + """ + Build HTTP headers for outbound Dispatcharr requests. + + content_type=None omits Content-Type (e.g. simple GET proxies). + token is included when authenticating with Schedules Direct. + """ + headers = {'User-Agent': dispatcharr_user_agent()} + if content_type: + headers['Content-Type'] = content_type + if token: + headers['token'] = token + return headers + + def natural_sort_key(text): """ Convert a string into a list of string and number chunks for natural sorting. diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index f7e6ec92..7b8118de 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -29,7 +29,7 @@ import { showNotification } from '../../utils/notificationUtils.js'; import API from '../../api.js'; import useEPGsStore from '../../store/epgs'; -// Countries are fetched dynamically from the SD API on component mount. +// Countries are fetched with lineups from the backend on component mount. // Fallback list used if the API call fails. const SD_COUNTRIES_FALLBACK = [ { value: 'USA', label: 'United States' }, @@ -48,6 +48,16 @@ const SD_COUNTRIES_FALLBACK = [ { value: 'NZL', label: 'New Zealand' }, ]; +const mapSDCountries = (data) => { + if (!data) return null; + const all = Object.values(data).flat(); + const mapped = all + .filter((c) => c.shortName && c.fullName) + .map((c) => ({ value: c.shortName, label: c.fullName })) + .sort((a, b) => a.label.localeCompare(b.label)); + return mapped.length > 0 ? mapped : null; +}; + // ESPN HD logo previews — packaged as static URLs so no API call is needed. // These are publicly accessible S3 URLs that don't require authentication. const SD_LOGO_PREVIEW_BASE = @@ -309,6 +319,8 @@ const SDLineupManager = ({ sourceId }) => { if (data) { setActiveLineups(data.lineups || []); setLineupNotice(data.notice || null); + const mappedCountries = mapSDCountries(data.countries); + if (mappedCountries) setCountries(mappedCountries); // Always update changesRemaining from server — includes null (unknown) and 0 (locked) if (data.changes_remaining !== undefined) { setChangesRemaining(data.changes_remaining); @@ -325,18 +337,6 @@ const SDLineupManager = ({ sourceId }) => { useEffect(() => { if (sourceId) { fetchActiveLineups(); - // Fetch country list from SD API per their recommendation to not hardcode - fetch('https://json.schedulesdirect.org/20141201/available/countries') - .then((r) => r.json()) - .then((data) => { - const all = Object.values(data).flat(); - const mapped = all - .filter((c) => c.shortName && c.fullName) - .map((c) => ({ value: c.shortName, label: c.fullName })) - .sort((a, b) => a.label.localeCompare(b.label)); - if (mapped.length > 0) setCountries(mapped); - }) - .catch(() => {}); // fallback list remains if fetch fails } }, [sourceId, fetchActiveLineups]); diff --git a/frontend/src/components/forms/__tests__/EPG.test.jsx b/frontend/src/components/forms/__tests__/EPG.test.jsx index 1e64a563..0597f0a0 100644 --- a/frontend/src/components/forms/__tests__/EPG.test.jsx +++ b/frontend/src/components/forms/__tests__/EPG.test.jsx @@ -215,12 +215,20 @@ vi.mock('@mantine/core', async () => ({ {(data ?? []).map((opt) => { const val = typeof opt === 'string' ? opt : opt.value; const lbl = typeof opt === 'string' ? opt : opt.label; - return ; + return ( + + ); })} ), Loader: ({ size }) =>
, - Badge: ({ children, color }) => {children}, + Badge: ({ children, color }) => ( + + {children} + + ), ScrollArea: ({ children }) =>
{children}
, Table: ({ children }) => {children}
, Tooltip: ({ children }) =>
{children}
, @@ -237,10 +245,15 @@ vi.mock('@mantine/core', async () => ({ ), UnstyledButton: ({ children, onClick, ...props }) => ( - + ), Alert: ({ children, title, color, icon }) => ( -
{title}{children}
+
+ {title} + {children} +
), Stack: ({ children, gap }) =>
{children}
, Text: ({ children, ...props }) => {children},