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.
This commit is contained in:
SergeantPanda 2026-06-11 09:00:35 -05:00
parent 887a177581
commit ed364a26c9
4 changed files with 52 additions and 50 deletions

View file

@ -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):
"""

View file

@ -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(

View file

@ -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]);

View file

@ -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 <option key={val} value={val}>{lbl}</option>;
return (
<option key={val} value={val}>
{lbl}
</option>
);
})}
</select>
),
Loader: ({ size }) => <div data-testid="loader" data-size={size} />,
Badge: ({ children, color }) => <span data-testid="badge" data-color={color}>{children}</span>,
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
ScrollArea: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Table: ({ children }) => <table>{children}</table>,
Tooltip: ({ children }) => <div>{children}</div>,
@ -238,10 +245,15 @@ vi.mock('@mantine/core', async () => ({
</label>
),
UnstyledButton: ({ children, onClick, ...props }) => (
<button type="button" onClick={onClick} {...props}>{children}</button>
<button type="button" onClick={onClick} {...props}>
{children}
</button>
),
Alert: ({ children, title, color, icon }) => (
<div data-testid="alert" data-color={color}><strong>{title}</strong>{children}</div>
<div data-testid="alert" data-color={color}>
<strong>{title}</strong>
{children}
</div>
),
Stack: ({ children, gap }) => <div data-testid="stack">{children}</div>,
Text: ({ children, ...props }) => <span>{children}</span>,