diff --git a/CHANGELOG.md b/CHANGELOG.md
index aad0afec..1b2b8333 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/apps/output/views.py b/apps/output/views.py
index 84c5f29e..063ca71b 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -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
diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx
index f62975ff..dd015511 100644
--- a/frontend/src/components/forms/User.jsx
+++ b/frontend/src/components/forms/User.jsx
@@ -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 }) => {
)}
+
+
+
+
{canGenerateKey && (
{userAPIKey && (
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index dde9fda0..b931a551 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -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 }) => {
]}
/>
{
}))
}
/>
+
+ setEpgParams((prev) => ({
+ ...prev,
+ prev_days: value || 0,
+ }))
+ }
+ />