Dispatcharr/apps/epg/models.py
Curt LeCaptain 408c3c6bea feat(epg): add native xz decompression support for EPG sources
Adds .xz to the set of compressed formats detect_file_format(),
extract_compressed_file(), fetch_xmltv(), and EPGSource.get_cache_file()
recognize, mirroring the existing gzip handling. Uses stdlib lzma, no new
dependency. Detection works via LZMA magic bytes (fd 37 7a 58 5a 00),
the .xz extension (including compound extensions like .xml.xz), and the
application/x-xz mimetype fallback.

Widened the magic-byte read in get_cache_file() from 4 to 6 bytes since
the xz signature is 6 bytes (the previous 4-byte read was sufficient
only for the 2-byte gzip/zip signatures it originally supported). That
widening silently broke the bare-<tv> raw-XML detection: the
fixed-length slice comparison header[:5] == b'<tv>' can never match a
5-byte slice of a 6-byte header against a 4-byte literal. Replaced the
fixed-length slice comparisons with header.startswith(...), which is
correct regardless of how many bytes are read - this also fixes the
adjacent <?xml declaration check, which was silently dead before this
change (a 4-byte read could never equal the 5-byte b'<?xml' literal
either).

Fixes Dispatcharr/Dispatcharr#1414

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:48:44 -05:00

254 lines
9.2 KiB
Python

from django.db import models
from django.utils import timezone
from django_celery_beat.models import PeriodicTask
from django.conf import settings
import os
class EPGSource(models.Model):
SOURCE_TYPE_CHOICES = [
('xmltv', 'XMLTV URL'),
('schedules_direct', 'Schedules Direct API'),
('dummy', 'Custom Dummy EPG'),
]
STATUS_IDLE = 'idle'
STATUS_FETCHING = 'fetching'
STATUS_PARSING = 'parsing'
STATUS_ERROR = 'error'
STATUS_SUCCESS = 'success'
STATUS_DISABLED = 'disabled'
STATUS_CHOICES = [
(STATUS_IDLE, 'Idle'),
(STATUS_FETCHING, 'Fetching'),
(STATUS_PARSING, 'Parsing'),
(STATUS_ERROR, 'Error'),
(STATUS_SUCCESS, 'Success'),
(STATUS_DISABLED, 'Disabled'),
]
name = models.CharField(max_length=255, unique=True)
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV
username = models.CharField(max_length=255, blank=True, null=True,
help_text='Username for credential-based EPG sources (e.g. Schedules Direct)')
password = models.CharField(max_length=255, blank=True, null=True,
help_text='Password for credential-based EPG sources (e.g. Schedules Direct)')
is_active = models.BooleanField(default=True)
file_path = models.CharField(max_length=1024, blank=True, null=True)
extracted_file_path = models.CharField(max_length=1024, blank=True, null=True,
help_text="Path to extracted XML file after decompression")
refresh_interval = models.IntegerField(default=0)
refresh_task = models.ForeignKey(
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
help_text="Custom properties for source-specific configuration"
)
priority = models.PositiveIntegerField(
default=0,
help_text="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_IDLE
)
last_message = models.TextField(
null=True,
blank=True,
help_text="Last status message, including success results or error information"
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text="Time when this source was created"
)
updated_at = models.DateTimeField(
null=True, blank=True,
help_text="Time when this source was last successfully refreshed"
)
def __str__(self):
return self.name
def get_cache_file(self):
import mimetypes
file_ext = ".tmp"
if self.file_path and os.path.exists(self.file_path):
_, existing_ext = os.path.splitext(self.file_path)
if existing_ext:
file_ext = existing_ext
else:
mime_type, _ = mimetypes.guess_type(self.file_path)
if mime_type:
if mime_type == 'application/gzip' or mime_type == 'application/x-gzip':
file_ext = '.gz'
elif mime_type == 'application/zip':
file_ext = '.zip'
elif mime_type == 'application/x-xz':
file_ext = '.xz'
elif mime_type == 'application/xml' or mime_type == 'text/xml':
file_ext = '.xml'
else:
try:
with open(self.file_path, 'rb') as f:
header = f.read(6)
if header.startswith(b'\x1f\x8b'):
file_ext = '.gz'
elif header.startswith(b'PK'):
file_ext = '.zip'
elif header.startswith(b'\xfd7zXZ\x00'):
file_ext = '.xz'
elif header.startswith(b'<?xml') or header.startswith(b'<tv>'):
file_ext = '.xml'
except Exception:
pass
filename = f"{self.id}{file_ext}"
cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg")
os.makedirs(cache_dir, exist_ok=True)
cache = os.path.join(cache_dir, filename)
return cache
def save(self, *args, **kwargs):
if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']:
kwargs.setdefault('update_fields', [])
if 'updated_at' in kwargs['update_fields']:
kwargs['update_fields'].remove('updated_at')
super().save(*args, **kwargs)
@property
def programme_index(self):
"""Byte-offset index for this source, read on demand from the separate
EPGSourceIndex table so the multi-MB blob is never pulled into EPGSource
queries or select_related JOINs. Returns the stored dict or None."""
return (
EPGSourceIndex.objects.filter(source_id=self.pk)
.values_list('data', flat=True)
.first()
)
class EPGSourceIndex(models.Model):
"""Byte-offset programme index for an EPGSource, stored in its own table.
Kept out of EPGSource so the multi-MB JSON blob is only loaded when read
explicitly, never when querying or joining EPGSource rows.
"""
source = models.OneToOneField(
EPGSource,
on_delete=models.CASCADE,
related_name='index_record',
primary_key=True,
)
data = models.JSONField(null=True, blank=True, default=None)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Programme index for source {self.source_id}"
class EPGData(models.Model):
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=512)
icon_url = models.URLField(max_length=500, null=True, blank=True)
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="epgs",
)
class Meta:
unique_together = ('tvg_id', 'epg_source')
def __str__(self):
return f"EPG Data for {self.name}"
class ProgramData(models.Model):
epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs")
start_time = models.DateTimeField()
end_time = models.DateTimeField()
title = models.CharField(max_length=255)
sub_title = models.TextField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
tvg_id = models.CharField(max_length=255, null=True, blank=True)
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.')
custom_properties = models.JSONField(default=dict, blank=True, null=True)
class Meta:
indexes = [
models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
]
def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})"
class SDScheduleMD5(models.Model):
"""
Caches per-station per-date MD5 hashes from Schedules Direct.
Used to detect schedule changes and avoid unnecessary re-downloads,
minimizing API calls against SD's rate-limited endpoints.
"""
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,
related_name="sd_schedule_md5s",
)
station_id = models.CharField(
max_length=20,
help_text="Schedules Direct stationID"
)
date = models.DateField(
help_text="Schedule date (UTC)"
)
md5 = models.CharField(
max_length=22,
help_text="MD5 hash of the schedule for this station/date from Schedules Direct"
)
last_modified = models.DateTimeField(
help_text="Last modified timestamp from Schedules Direct"
)
class Meta:
unique_together = ('epg_source', 'station_id', 'date')
indexes = [
models.Index(fields=['epg_source', 'station_id']),
]
def __str__(self):
return f"SDScheduleMD5: {self.station_id} / {self.date} ({self.epg_source.name})"
class SDProgramMD5(models.Model):
"""
Caches per-program MD5 hashes from Schedules Direct.
Keyed by epg_source + program_id (SD's programID e.g. EP123456789).
Used for program-level delta detection to avoid re-downloading unchanged
program metadata, minimizing API calls against SD's rate-limited endpoints.
"""
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,
related_name="sd_program_md5s",
)
program_id = models.CharField(
max_length=64,
help_text="Schedules Direct programID (e.g. EP123456789)"
)
md5 = models.CharField(
max_length=22,
help_text="MD5 hash of the program metadata from Schedules Direct"
)
class Meta:
unique_together = ('epg_source', 'program_id')
def __str__(self):
return f"SDProgramMD5: {self.program_id} ({self.epg_source.name})"