mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Enhancement: **EPG historical data window**: the EPG XML output and XC EPG API now support a prev_days URL parameter (e.g. &prev_days=3) to include past programs in the EPG response. This allows third-party players that request historical program schedules to receive the data they need. The EPG URL builder in the Channels page exposes "Days forward" and "Days back" controls. Per-user defaults for both values (epg_days / epg_prev_days) can be configured in the User settings modal and are applied automatically when no URL parameter is present. (Closes #1154)
This commit is contained in:
parent
a83b9fdf62
commit
ba967f47d9
4 changed files with 75 additions and 12 deletions
|
|
@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- **EPG historical data window**: the EPG XML output and XC EPG API now support a `prev_days` URL parameter (e.g. `&prev_days=3`) to include past programs in the EPG response. This allows third-party players that request historical program schedules to receive the data they need. The EPG URL builder in the Channels page exposes "Days forward" and "Days back" controls. Per-user defaults for both values (`epg_days` / `epg_prev_days`) can be configured in the User settings modal and are applied automatically when no URL parameter is present. (Closes #1154)
|
||||
- **Plugin Hub**: administrators can now browse, install, and update plugins directly from remote repositories via a new Plugin Hub page in Settings. (Closes #393) — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- Install plugins directly from the hub: the release zip is downloaded, SHA256 integrity is verified, and the plugin is installed atomically.
|
||||
- Update managed plugins when a newer version is available from their source repo. Version compatibility constraints (`min_dispatcharr_version` / `max_dispatcharr_version`) are enforced at install time.
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint'))
|
||||
|
||||
# Optionally preserve certain query parameters
|
||||
preserved_params = ['tvg_id_source', 'cachedlogos', 'days']
|
||||
preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days']
|
||||
query_params = {k: v for k, v in request.GET.items() if k in preserved_params}
|
||||
if query_params:
|
||||
from urllib.parse import urlencode
|
||||
|
|
@ -1339,21 +1339,35 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
|
||||
|
||||
# Get the number of days for EPG data
|
||||
# The user account default (custom_properties['epg_days']) is used when the
|
||||
# URL parameter is absent, so XC clients that cannot pass query params still benefit.
|
||||
user_custom = (user.custom_properties or {}) if user else {}
|
||||
try:
|
||||
# Default to 0 days (everything) for real EPG if not specified
|
||||
days_param = request.GET.get('days', '0')
|
||||
default_days = int(user_custom.get('epg_days', 0))
|
||||
days_param = request.GET.get('days', str(default_days))
|
||||
num_days = int(days_param)
|
||||
# Set reasonable limits
|
||||
num_days = max(0, min(num_days, 365)) # Between 0 and 365 days
|
||||
except ValueError:
|
||||
num_days = 0 # Default to all data if invalid value
|
||||
|
||||
# Get number of previous/historical days to include (for catch-up/timeshift support).
|
||||
# The user account default (custom_properties['epg_prev_days']) is used when the
|
||||
# URL parameter is absent, so XC clients that cannot pass query params still benefit.
|
||||
try:
|
||||
default_prev_days = int(user_custom.get('epg_prev_days', 0))
|
||||
prev_days = int(request.GET.get('prev_days', default_prev_days))
|
||||
prev_days = max(0, min(prev_days, 30)) # Hard cap at 30 days lookback
|
||||
except (ValueError, TypeError):
|
||||
prev_days = 0
|
||||
|
||||
# For dummy EPG, use either the specified value or default to 3 days
|
||||
dummy_days = num_days if num_days > 0 else 3
|
||||
|
||||
# Calculate cutoff date for EPG data filtering (only if days > 0)
|
||||
# Calculate cutoff dates for EPG data filtering
|
||||
now = django_timezone.now()
|
||||
cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None
|
||||
lookback_cutoff = now - timedelta(days=prev_days)
|
||||
|
||||
# Build collision-free channel number mapping for XC clients (if user is authenticated)
|
||||
# XC clients require integer channel numbers, so we need to ensure no conflicts
|
||||
|
|
@ -1643,13 +1657,14 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
# For real EPG data - filter only if days parameter was specified
|
||||
if num_days > 0:
|
||||
programs_qs = channel.epg_data.programs.filter(
|
||||
end_time__gte=now,
|
||||
end_time__gte=lookback_cutoff,
|
||||
start_time__lt=cutoff_date
|
||||
).order_by('id') # Explicit ordering for consistent chunking
|
||||
else:
|
||||
# Return all non-expired programs if days=0 or not specified
|
||||
# Return programs from lookback_cutoff onward (includes recent past
|
||||
# for catch-up when prev_days > 0, otherwise current/future only)
|
||||
programs_qs = channel.epg_data.programs.filter(
|
||||
end_time__gte=now
|
||||
end_time__gte=lookback_cutoff
|
||||
).order_by('id')
|
||||
|
||||
# Process programs in chunks to avoid cursor timeout issues
|
||||
|
|
@ -2332,6 +2347,9 @@ def xc_get_epg(request, user, short=False):
|
|||
channel_num_int = channel_num_map.get(channel.id, int(channel.channel_number))
|
||||
|
||||
limit = int(request.GET.get('limit', 4))
|
||||
user_custom = user.custom_properties or {}
|
||||
prev_days = max(0, min(int(user_custom.get('epg_prev_days', 0)), 30))
|
||||
lookback_cutoff = django_timezone.now() - timedelta(days=prev_days)
|
||||
if channel.epg_data:
|
||||
# Check if this is a dummy EPG that generates on-demand
|
||||
if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy':
|
||||
|
|
@ -2346,21 +2364,21 @@ def xc_get_epg(request, user, short=False):
|
|||
# Has stored programs, use them
|
||||
if short == False:
|
||||
programs = channel.epg_data.programs.filter(
|
||||
end_time__gt=django_timezone.now()
|
||||
end_time__gt=lookback_cutoff
|
||||
).order_by('start_time')
|
||||
else:
|
||||
programs = channel.epg_data.programs.filter(
|
||||
end_time__gt=django_timezone.now()
|
||||
end_time__gt=lookback_cutoff
|
||||
).order_by('start_time')[:limit]
|
||||
else:
|
||||
# Regular EPG with stored programs
|
||||
if short == False:
|
||||
programs = channel.epg_data.programs.filter(
|
||||
end_time__gt=django_timezone.now()
|
||||
end_time__gt=lookback_cutoff
|
||||
).order_by('start_time')
|
||||
else:
|
||||
programs = channel.epg_data.programs.filter(
|
||||
end_time__gt=django_timezone.now()
|
||||
end_time__gt=lookback_cutoff
|
||||
).order_by('start_time')[:limit]
|
||||
else:
|
||||
# No EPG data assigned, generate default dummy
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
xc_password: '',
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
epg_days: 0,
|
||||
epg_prev_days: 0,
|
||||
},
|
||||
|
||||
validate: (values) => ({
|
||||
|
|
@ -100,6 +102,12 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
customProps.hide_adult_content = values.hide_adult_content || false;
|
||||
delete values.hide_adult_content;
|
||||
|
||||
// Save EPG defaults in custom_properties
|
||||
customProps.epg_days = values.epg_days || 0;
|
||||
delete values.epg_days;
|
||||
customProps.epg_prev_days = values.epg_prev_days || 0;
|
||||
delete values.epg_prev_days;
|
||||
|
||||
values.custom_properties = customProps;
|
||||
|
||||
// If 'All' is included, clear this and we assume access to all channels
|
||||
|
|
@ -152,6 +160,8 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
: ['0'],
|
||||
xc_password: customProps.xc_password || '',
|
||||
hide_adult_content: customProps.hide_adult_content || false,
|
||||
epg_days: customProps.epg_days || 0,
|
||||
epg_prev_days: customProps.epg_prev_days || 0,
|
||||
});
|
||||
|
||||
if (customProps.xc_password) {
|
||||
|
|
@ -353,6 +363,24 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label="EPG: Default days forward (0 = all)"
|
||||
description="How many future days of EPG data to include by default"
|
||||
min={0}
|
||||
max={365}
|
||||
{...form.getInputProps('epg_days')}
|
||||
key={form.key('epg_days')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="EPG: Default days back (0 = none)"
|
||||
description="How many past days of EPG data to include by default (max 30)"
|
||||
min={0}
|
||||
max={30}
|
||||
{...form.getInputProps('epg_prev_days')}
|
||||
key={form.key('epg_prev_days')}
|
||||
/>
|
||||
|
||||
{canGenerateKey && (
|
||||
<Stack>
|
||||
{userAPIKey && (
|
||||
|
|
|
|||
|
|
@ -388,6 +388,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
cachedlogos: true,
|
||||
tvg_id_source: 'channel_number',
|
||||
days: 0,
|
||||
prev_days: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -758,6 +759,8 @@ const ChannelsTable = ({ onReady }) => {
|
|||
if (epgParams.tvg_id_source !== 'channel_number')
|
||||
params.append('tvg_id_source', epgParams.tvg_id_source);
|
||||
if (epgParams.days > 0) params.append('days', epgParams.days.toString());
|
||||
if (epgParams.prev_days > 0)
|
||||
params.append('prev_days', epgParams.prev_days.toString());
|
||||
|
||||
const baseUrl = epgUrl;
|
||||
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
|
||||
|
|
@ -1442,7 +1445,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
]}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Days (0 = all data)"
|
||||
label="Days forward (0 = all)"
|
||||
size="xs"
|
||||
min={0}
|
||||
max={365}
|
||||
|
|
@ -1454,6 +1457,19 @@ const ChannelsTable = ({ onReady }) => {
|
|||
}))
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Days back (0 = none)"
|
||||
size="xs"
|
||||
min={0}
|
||||
max={30}
|
||||
value={epgParams.prev_days}
|
||||
onChange={(value) =>
|
||||
setEpgParams((prev) => ({
|
||||
...prev,
|
||||
prev_days: value || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue