From 5d2bc2606c74d866790bdf51988641478c9ee8a7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 31 May 2026 11:22:04 -0500 Subject: [PATCH] fix(epg): add force refresh option for EPG data import and update related API methods --- apps/epg/api_views.py | 3 +- apps/epg/tasks.py | 10 +-- frontend/src/api.js | 41 ++++++----- frontend/src/components/tables/EPGsTable.jsx | 71 +++++++++++++++----- 4 files changed, 89 insertions(+), 36 deletions(-) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index f368714f..265532b0 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -999,6 +999,7 @@ class EPGImportAPIView(APIView): def post(self, request, format=None): logger.info("EPGImportAPIView: Received request to import EPG data.") epg_id = request.data.get("id", None) + force = bool(request.data.get("force", False)) # Check if this is a dummy EPG source try: @@ -1013,7 +1014,7 @@ class EPGImportAPIView(APIView): except EPGSource.DoesNotExist: pass # Let the task handle the missing source - refresh_epg_data.delay(epg_id) # Trigger Celery task + refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.") return Response( {"success": True, "message": "EPG data refresh initiated."}, diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 2974cf74..d9f2315b 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -307,7 +307,7 @@ def refresh_all_epg_data(): @shared_task(time_limit=14400) -def refresh_epg_data(source_id): +def refresh_epg_data(source_id, force=False): if not acquire_task_lock('refresh_epg_data', source_id): logger.debug(f"EPG refresh for {source_id} already running") return @@ -378,7 +378,7 @@ def refresh_epg_data(source_id): parse_programs_for_source(source) elif source.source_type == 'schedules_direct': - fetch_schedules_direct(source) + fetch_schedules_direct(source, force=force) source.save(update_fields=['updated_at']) # After successful EPG refresh, evaluate DVR series rules to schedule new episodes @@ -1973,7 +1973,7 @@ def fetch_schedules_direct_stations(self, source_id): fetch_schedules_direct(source, stations_only=True) -def fetch_schedules_direct(source, stations_only=False): +def fetch_schedules_direct(source, stations_only=False, force=False): """ Fetch EPG data from the Schedules Direct JSON API and persist it to the EPGData / ProgramData models. @@ -2032,7 +2032,7 @@ def fetch_schedules_direct(source, stations_only=False): # updated_at, which would otherwise incorrectly trigger this guard). Always # allow the first full refresh through so guide data is immediately available. # ------------------------------------------------------------------------- - if not stations_only and source.updated_at: + if not stations_only and not force and source.updated_at: from apps.epg.models import SDScheduleMD5 as _SDScheduleMD5 has_prior_full_refresh = _SDScheduleMD5.objects.filter(epg_source=source).exists() if has_prior_full_refresh: @@ -2053,6 +2053,8 @@ def fetch_schedules_direct(source, stations_only=False): return else: logger.info(f"SD source {source.id}: No prior full refresh detected — skipping 2-hour guard for first full fetch.") + elif force and not stations_only: + logger.info(f"SD source {source.id}: Force flag set — bypassing 2-hour refresh guard.") # ------------------------------------------------------------------------- # Build SD-specific headers diff --git a/frontend/src/api.js b/frontend/src/api.js index 42f010c6..e45fe1a9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1632,11 +1632,11 @@ export default class API { } } - static async refreshEPG(id) { + static async refreshEPG(id, force = false) { try { const response = await request(`${host}/api/epg/import/`, { method: 'POST', - body: { id }, + body: { id, force }, }); return response; @@ -3820,7 +3820,9 @@ export default class API { static async getSDLineups(sourceId) { try { - const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/`); + const response = await request( + `${host}/api/epg/sources/${sourceId}/sd-lineups/` + ); return response; } catch (e) { errorNotification('Failed to retrieve Schedules Direct lineups', e); @@ -3829,10 +3831,13 @@ export default class API { static async addSDLineup(sourceId, lineup) { try { - const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/`, { - method: 'POST', - body: { lineup }, - }); + const response = await request( + `${host}/api/epg/sources/${sourceId}/sd-lineups/`, + { + method: 'POST', + body: { lineup }, + } + ); return response; } catch (e) { errorNotification(`Failed to add lineup ${lineup}`, e); @@ -3841,10 +3846,13 @@ export default class API { static async deleteSDLineup(sourceId, lineup) { try { - const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/`, { - method: 'DELETE', - body: { lineup }, - }); + const response = await request( + `${host}/api/epg/sources/${sourceId}/sd-lineups/`, + { + method: 'DELETE', + body: { lineup }, + } + ); return response; } catch (e) { errorNotification(`Failed to remove lineup ${lineup}`, e); @@ -3853,10 +3861,13 @@ export default class API { static async searchSDLineups(sourceId, country, postalcode) { try { - const response = await request(`${host}/api/epg/sources/${sourceId}/sd-lineups/search/`, { - method: 'POST', - body: { country, postalcode }, - }); + const response = await request( + `${host}/api/epg/sources/${sourceId}/sd-lineups/search/`, + { + method: 'POST', + body: { country, postalcode }, + } + ); return response; } catch (e) { errorNotification('Failed to search Schedules Direct lineups', e); diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index a18dcc7b..8b464ee2 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -99,7 +99,6 @@ const RowActions = ({ tableSize, row, editEPG, deleteEPG, refreshEPG }) => { ); }; - const EPGStatusCell = ({ epg }) => { // Direct Zustand subscription scoped to this source only. // This component re-renders whenever its source's progress changes, @@ -138,9 +137,15 @@ const EPGStatusCell = ({ epg }) => { let additionalInfo = ''; if (progress.message) { additionalInfo = progress.message; - } else if (progress.processed !== undefined && progress.channels !== undefined) { + } else if ( + progress.processed !== undefined && + progress.channels !== undefined + ) { additionalInfo = `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`; - } else if (progress.processed !== undefined && progress.total !== undefined) { + } else if ( + progress.processed !== undefined && + progress.total !== undefined + ) { additionalInfo = `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`; } @@ -186,7 +191,8 @@ const EPGStatusCell = ({ epg }) => { // Show success message if (epg.status === 'success') { - const successMessage = epg.last_message || 'EPG data refreshed successfully'; + const successMessage = + epg.last_message || 'EPG data refreshed successfully'; return ( { if (epg.status === 'idle' && epg.last_message) { return ( - + {epg.last_message} @@ -230,6 +231,8 @@ const EPGsTable = () => { const [epgToDelete, setEpgToDelete] = useState(null); const [data, setData] = useState([]); const [deleting, setDeleting] = useState(false); + const [confirmSDRefreshOpen, setConfirmSDRefreshOpen] = useState(false); + const [sdRefreshTarget, setSDRefreshTarget] = useState(null); const epgs = useEPGsStore((s) => s.epgs); @@ -274,9 +277,9 @@ const EPGsTable = () => { size: 130, cell: ({ cell }) => { const typeMap = { - 'xmltv': 'XMLTV', - 'schedules_direct': 'Schedules Direct', - 'dummy': 'Custom Dummy', + xmltv: 'XMLTV', + schedules_direct: 'Schedules Direct', + dummy: 'Custom Dummy', }; return typeMap[cell.getValue()] || cell.getValue(); }, @@ -439,13 +442,27 @@ const EPGsTable = () => { } }; - const refreshEPG = async (id) => { - await API.refreshEPG(id); + const refreshEPG = async (id, force = false) => { + await API.refreshEPG(id, force); notifications.show({ title: 'EPG refresh initiated', }); }; + const handleRefreshEPG = (id) => { + const epgObj = epgs[id]; + if ( + epgObj?.source_type === 'schedules_direct' && + epgObj?.updated_at && + Date.now() - new Date(epgObj.updated_at).getTime() < 2 * 60 * 60 * 1000 + ) { + setSDRefreshTarget(id); + setConfirmSDRefreshOpen(true); + return; + } + refreshEPG(id); + }; + const closeEPGForm = () => { setEPG(null); setEPGModalOpen(false); @@ -478,7 +495,7 @@ const EPGsTable = () => { row={row} editEPG={editEPG} deleteEPG={deleteEPG} - refreshEPG={refreshEPG} + refreshEPG={handleRefreshEPG} /> ); } @@ -686,6 +703,28 @@ const EPGsTable = () => { onClose={closeDummyEPGForm} /> + setConfirmSDRefreshOpen(false)} + onConfirm={() => { + setConfirmSDRefreshOpen(false); + refreshEPG(sdRefreshTarget, true); + }} + title="Refresh Schedules Direct Early?" + message={ +
+

This source was refreshed less than 2 hours ago.

+

+ Schedules Direct rate-limits requests per account. Refreshing too + frequently may cause your account to be temporarily blocked. +

+

Are you sure you want to force a refresh now?

+
+ } + confirmLabel="Refresh Anyway" + cancelLabel="Cancel" + /> + setConfirmDeleteOpen(false)}