fix(epg): add force refresh option for EPG data import and update related API methods

This commit is contained in:
SergeantPanda 2026-05-31 11:22:04 -05:00
parent ae56407405
commit 5d2bc2606c
4 changed files with 89 additions and 36 deletions

View file

@ -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."},

View file

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

View file

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

View file

@ -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 (
<Tooltip label={successMessage} multiline width={300}>
<Text
@ -205,12 +211,7 @@ const EPGStatusCell = ({ epg }) => {
if (epg.status === 'idle' && epg.last_message) {
return (
<Tooltip label={epg.last_message} multiline width={300}>
<Text
c="dimmed"
size="xs"
lineClamp={2}
style={{ lineHeight: 1.3 }}
>
<Text c="dimmed" size="xs" lineClamp={2} style={{ lineHeight: 1.3 }}>
{epg.last_message}
</Text>
</Tooltip>
@ -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}
/>
<ConfirmationDialog
opened={confirmSDRefreshOpen}
onClose={() => setConfirmSDRefreshOpen(false)}
onConfirm={() => {
setConfirmSDRefreshOpen(false);
refreshEPG(sdRefreshTarget, true);
}}
title="Refresh Schedules Direct Early?"
message={
<div>
<p>This source was refreshed less than 2 hours ago.</p>
<p>
Schedules Direct rate-limits requests per account. Refreshing too
frequently may cause your account to be temporarily blocked.
</p>
<p>Are you sure you want to force a refresh now?</p>
</div>
}
confirmLabel="Refresh Anyway"
cancelLabel="Cancel"
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}