From ed364a26c9884fbabb7885f7eebd22ef4c92c66c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 11 Jun 2026 09:00:35 -0500 Subject: [PATCH] refactor(EPGSourceViewSet, frontend): streamline SD countries fetching and remove proxy endpoint - Introduced a new method `_fetch_sd_countries` in `EPGSourceViewSet` to fetch SD countries directly from the backend, eliminating the need for a separate proxy endpoint. - Updated the `sd_lineups` action to include the fetched countries in the response. - Removed the `getSDCountries` method from the API and adjusted the frontend to fetch countries alongside lineups, improving efficiency - Updated related components to handle the new data structure for countries. --- apps/epg/api_views.py | 48 +++++++++---------- frontend/src/api.js | 8 ---- frontend/src/components/forms/EPG.jsx | 24 +++++----- .../components/forms/__tests__/EPG.test.jsx | 22 +++++++-- 4 files changed, 52 insertions(+), 50 deletions(-) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 58ab0334..f9810a04 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -53,7 +53,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: - if self.action in ('sd_lineups', 'sd_lineups_search', 'sd_countries'): + if self.action in ('sd_lineups', 'sd_lineups_search'): if self.request.method == 'GET': return [IsStandardUser()] return [IsAdmin()] @@ -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 version import __version__ as dispatcharr_version + + try: + resp = http_requests.get( + f"{SD_BASE_URL}/available/countries", + headers={'User-Agent': f'Dispatcharr/{dispatcharr_version}'}, + 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): """ @@ -256,6 +274,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet): } if request.method == "GET": + countries = self._fetch_sd_countries() try: resp = http_requests.get( f"{SD_BASE_URL}/lineups", @@ -272,6 +291,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 +301,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( @@ -390,31 +411,6 @@ class EPGSourceViewSet(viewsets.ModelViewSet): status=status.HTTP_502_BAD_GATEWAY ) - @action(detail=True, methods=["get"], url_path="sd-countries") - def sd_countries(self, request, pk=None): - """Proxy /available/countries from the SD API to avoid browser CORS restrictions.""" - import requests as http_requests - from apps.epg.tasks import SD_BASE_URL - - source = self.get_object() - if source.source_type != 'schedules_direct': - return Response( - {"error": "This action is only available for Schedules Direct sources."}, - status=status.HTTP_400_BAD_REQUEST, - ) - try: - resp = http_requests.get( - f"{SD_BASE_URL}/available/countries", - timeout=15, - ) - resp.raise_for_status() - return Response(resp.json()) - except http_requests.exceptions.RequestException as e: - return Response( - {"error": f"Failed to fetch countries: {str(e)}"}, - status=status.HTTP_502_BAD_GATEWAY, - ) - @action(detail=True, methods=["post"], url_path="sd-lineups/search") def sd_lineups_search(self, request, pk=None): """ diff --git a/frontend/src/api.js b/frontend/src/api.js index 445e7e82..1c826465 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -3889,14 +3889,6 @@ export default class API { } } - static async getSDCountries(sourceId) { - try { - return await request(`${host}/api/epg/sources/${sourceId}/sd-countries/`); - } catch (e) { - return null; - } - } - static async getSDLineups(sourceId) { try { const response = await request( diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index ebfea46b..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,16 +337,6 @@ const SDLineupManager = ({ sourceId }) => { useEffect(() => { if (sourceId) { fetchActiveLineups(); - // Fetch country list via backend proxy to avoid browser CORS restrictions - API.getSDCountries(sourceId).then((data) => { - if (!data) return; - 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); - }); } }, [sourceId, fetchActiveLineups]); diff --git a/frontend/src/components/forms/__tests__/EPG.test.jsx b/frontend/src/components/forms/__tests__/EPG.test.jsx index 66ead589..0597f0a0 100644 --- a/frontend/src/components/forms/__tests__/EPG.test.jsx +++ b/frontend/src/components/forms/__tests__/EPG.test.jsx @@ -8,7 +8,6 @@ global.fetch = vi.fn().mockResolvedValue({ vi.mock('../../../api.js', () => ({ default: { - getSDCountries: vi.fn().mockResolvedValue({}), getSDLineups: vi.fn().mockResolvedValue([]), addSDLineup: vi.fn().mockResolvedValue({ success: true }), deleteSDLineup: vi.fn().mockResolvedValue({ success: true }), @@ -216,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}
, @@ -238,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},