mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
fix: Fix removed code from bad merge.
This commit is contained in:
parent
30843c0c4c
commit
ac3a83a1aa
6 changed files with 439 additions and 6246 deletions
|
|
@ -1,458 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { Calendar, Video } from 'lucide-react';
|
||||
import API from '../api';
|
||||
import useVideoStore from '../store/useVideoStore';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils';
|
||||
import { formatSeasonEpisode } from '../utils/guideUtils';
|
||||
import {
|
||||
format,
|
||||
initializeTime,
|
||||
diff,
|
||||
useDateTimeFormat,
|
||||
} from '../utils/dateTimeUtils';
|
||||
import { imdbUrl, tmdbUrl } from '../utils/externalUrls';
|
||||
|
||||
const overlayProps = { color: '#000', backgroundOpacity: 0.55, blur: 0 };
|
||||
|
||||
function formatDurationMinutes(startTime, endTime) {
|
||||
if (!startTime || !endTime) return null;
|
||||
const start = initializeTime(startTime);
|
||||
const end = initializeTime(endTime);
|
||||
const minutes = diff(end, start, 'minute');
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function resolveImageUrl(detail) {
|
||||
if (detail?.tmdb_poster_url) return detail.tmdb_poster_url;
|
||||
if (detail?.poster_url) return detail.poster_url;
|
||||
if (detail?.images?.length > 0) return detail.images[0].url;
|
||||
if (detail?.icon) return detail.icon;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCredits(actors) {
|
||||
if (!actors?.length) return null;
|
||||
return actors
|
||||
.map((a) => (a.role ? `${a.name} (${a.role})` : a.name))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export default function ProgramDetailModal({
|
||||
program,
|
||||
channel,
|
||||
opened,
|
||||
onClose,
|
||||
onRecord,
|
||||
}) {
|
||||
const [detailData, setDetailData] = useState(null);
|
||||
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const { timeFormat } = useDateTimeFormat();
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || !program) {
|
||||
setDetailData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dummy programs may use UUID-style IDs that aren't real DB PKs
|
||||
const programId = program.id;
|
||||
if (!programId || typeof programId === 'string') {
|
||||
setDetailData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
API.getProgramDetail(programId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setDetailData(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDetailData(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [opened, program?.id]);
|
||||
|
||||
const handleWatchLive = useCallback(() => {
|
||||
if (!channel) return;
|
||||
showVideo(getShowVideoUrl(channel, env_mode), 'live', {
|
||||
name: channel.name,
|
||||
});
|
||||
onClose();
|
||||
}, [channel, env_mode, showVideo, onClose]);
|
||||
|
||||
const handleRecord = useCallback(() => {
|
||||
if (onRecord) onRecord(program);
|
||||
}, [onRecord, program]);
|
||||
|
||||
if (!program) return null;
|
||||
|
||||
// Merge detail data with grid data (detail enriches, grid is baseline)
|
||||
const d = detailData || {};
|
||||
const seasonEpisodeLabel = formatSeasonEpisode(
|
||||
d.season ?? program.season,
|
||||
d.episode ?? program.episode
|
||||
);
|
||||
const hasBadges =
|
||||
seasonEpisodeLabel ||
|
||||
program.is_live ||
|
||||
program.is_new ||
|
||||
d.is_previously_shown ||
|
||||
program.is_premiere ||
|
||||
program.is_finale ||
|
||||
d.video_quality ||
|
||||
d.rating;
|
||||
|
||||
const categories = d.categories || [];
|
||||
const credits = d.credits || {};
|
||||
const hasCredits =
|
||||
credits.actors?.length > 0 ||
|
||||
credits.directors?.length > 0 ||
|
||||
credits.writers?.length > 0;
|
||||
const starRatings = d.star_ratings || [];
|
||||
const description = d.description || program.description;
|
||||
const subtitle = d.sub_title ?? program.sub_title;
|
||||
const posterUrl =
|
||||
resolveImageUrl(d) || program?.custom_properties?.icon || null;
|
||||
const duration = formatDurationMinutes(program.start_time, program.end_time);
|
||||
const programStart = initializeTime(program.start_time || program.startMs);
|
||||
const programEnd = initializeTime(program.end_time || program.endMs);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
channel ? (
|
||||
<Text size="sm" fw={600} c="white">
|
||||
{channel.channel_number ? `${channel.channel_number} - ` : ''}
|
||||
{channel.name}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
size="lg"
|
||||
centered
|
||||
overlayProps={overlayProps}
|
||||
zIndex={9999}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Flex gap="md" align="stretch">
|
||||
{posterUrl && (
|
||||
<Image
|
||||
src={posterUrl}
|
||||
w={140}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
style={{ flexShrink: 0 }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
gap="xs"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
justify={posterUrl ? 'space-between' : 'flex-start'}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Title order={3} c="white">
|
||||
{program.title}
|
||||
</Title>
|
||||
|
||||
{subtitle && (
|
||||
<Text size="sm" fs="italic" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasBadges && (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{program.is_live && (
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
LIVE
|
||||
</Badge>
|
||||
)}
|
||||
{program.is_new && (
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
NEW
|
||||
</Badge>
|
||||
)}
|
||||
{d.is_previously_shown && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
RERUN
|
||||
</Badge>
|
||||
)}
|
||||
{program.is_premiere && (
|
||||
<Badge size="sm" variant="light" color="violet">
|
||||
PREMIERE
|
||||
</Badge>
|
||||
)}
|
||||
{program.is_finale && (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
FINALE
|
||||
</Badge>
|
||||
)}
|
||||
{seasonEpisodeLabel && (
|
||||
<Badge size="sm" variant="light" color="cyan">
|
||||
{seasonEpisodeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{d.rating && (
|
||||
<Badge size="sm" variant="light" color="yellow">
|
||||
{d.rating}
|
||||
</Badge>
|
||||
)}
|
||||
{d.video_quality && (
|
||||
<Badge size="sm" variant="light" color="indigo">
|
||||
{d.video_quality}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{format(programStart, timeFormat)} –{' '}
|
||||
{format(programEnd, timeFormat)}
|
||||
</Text>
|
||||
{duration && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{duration}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{categories.join(', ')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
{program.isLive && channel && (
|
||||
<Button
|
||||
leftSection={<Video size={14} />}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={handleWatchLive}
|
||||
>
|
||||
Watch Live
|
||||
</Button>
|
||||
)}
|
||||
{!program.isPast && (
|
||||
<Button
|
||||
leftSection={<Calendar size={14} />}
|
||||
variant="filled"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={handleRecord}
|
||||
>
|
||||
Record
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Flex>
|
||||
|
||||
{description && (
|
||||
<>
|
||||
<Divider color="#333" />
|
||||
<Text size="sm" c="#cbd5e0" style={{ whiteSpace: 'pre-line' }}>
|
||||
{description}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{d.content_advisory?.length > 0 && (
|
||||
<Text size="xs" c="orange" fs="italic">
|
||||
{d.content_advisory.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{d.event_details && (
|
||||
<>
|
||||
<Divider color="#333" />
|
||||
<Stack gap={4}>
|
||||
{d.event_details.venue100 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>Venue: </Text>
|
||||
{d.event_details.venue100}
|
||||
</Text>
|
||||
)}
|
||||
{d.event_details.teams?.length > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>Teams: </Text>
|
||||
{d.event_details.teams.map((t, i) => (
|
||||
<Text span key={i}>
|
||||
{i > 0 ? ' vs ' : ''}
|
||||
{t.name}{t.isHome ? ' (Home)' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasCredits && (
|
||||
<>
|
||||
<Divider color="#333" />
|
||||
<Stack gap={4}>
|
||||
{credits.actors?.length > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Cast:{' '}
|
||||
</Text>
|
||||
{formatCredits(credits.actors)}
|
||||
</Text>
|
||||
)}
|
||||
{credits.directors?.length > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Director:{' '}
|
||||
</Text>
|
||||
{credits.directors.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
{credits.writers?.length > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Writer:{' '}
|
||||
</Text>
|
||||
{credits.writers.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(d.country ||
|
||||
d.language ||
|
||||
d.original_air_date ||
|
||||
d.runtime ||
|
||||
(d.production_date && d.is_previously_shown) ||
|
||||
starRatings.length > 0) && (
|
||||
<>
|
||||
<Divider color="#333" />
|
||||
<Group gap="md" wrap="wrap">
|
||||
{d.country && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Country:{' '}
|
||||
</Text>
|
||||
{d.country}
|
||||
</Text>
|
||||
)}
|
||||
{d.language && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Language:{' '}
|
||||
</Text>
|
||||
{d.language}
|
||||
</Text>
|
||||
)}
|
||||
{d.production_date && d.is_previously_shown && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
First aired:{' '}
|
||||
</Text>
|
||||
{d.production_date}
|
||||
</Text>
|
||||
)}
|
||||
{d.runtime && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Runtime:{' '}
|
||||
</Text>
|
||||
{d.runtime} {d.runtime_units || 'min'}
|
||||
</Text>
|
||||
)}
|
||||
{d.original_air_date && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Original Air:{' '}
|
||||
</Text>
|
||||
{d.original_air_date}
|
||||
</Text>
|
||||
)}
|
||||
{starRatings.map((sr, i) => (
|
||||
<Text size="sm" c="dimmed" key={i}>
|
||||
★ {sr.value}
|
||||
{sr.system ? ` (${sr.system})` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(d.imdb_id || d.tmdb_id) && (
|
||||
<Group gap="xs">
|
||||
{d.imdb_id && (
|
||||
<Badge
|
||||
component="a"
|
||||
href={imdbUrl(d.imdb_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
IMDb ↗
|
||||
</Badge>
|
||||
)}
|
||||
{d.tmdb_id && (
|
||||
<Badge
|
||||
component="a"
|
||||
href={tmdbUrl(d.tmdb_id, d.tmdb_media_type)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="cyan"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
TMDB ↗
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
1285
api_views.py
1285
api_views.py
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ import time # Add import for tracking download progress
|
|||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
import gc # Add garbage collection module
|
||||
import json
|
||||
import re
|
||||
from lxml import etree # Using lxml exclusively
|
||||
import psutil # Add import for memory tracking
|
||||
import zipfile
|
||||
|
|
@ -56,6 +57,13 @@ def _build_html_entity_doctype() -> bytes:
|
|||
_HTML_ENTITY_DOCTYPE = _build_html_entity_doctype()
|
||||
|
||||
|
||||
def _parse_programme_element(element_bytes):
|
||||
"""Parse a single <programme> element, prepending the HTML-entity DOCTYPE
|
||||
so references like é in the text resolve instead of failing."""
|
||||
parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=True)
|
||||
return etree.fromstring(_HTML_ENTITY_DOCTYPE + element_bytes, parser)
|
||||
|
||||
|
||||
class _PrependStream:
|
||||
"""Wraps an open binary file and prepends a bytes prefix to its content.
|
||||
|
||||
|
|
@ -358,6 +366,9 @@ def refresh_epg_data(source_id, force=False):
|
|||
# Continue with the normal processing...
|
||||
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
|
||||
if source.source_type == 'xmltv':
|
||||
# Invalidate the byte-offset index before downloading the new file
|
||||
# so stale offsets are never used during the refresh window.
|
||||
EPGSource.objects.filter(id=source.id).update(programme_index=None)
|
||||
fetch_success = fetch_xmltv(source)
|
||||
if not fetch_success:
|
||||
logger.error(f"Failed to fetch XMLTV for source {source.name}")
|
||||
|
|
@ -376,6 +387,8 @@ def refresh_epg_data(source_id, force=False):
|
|||
gc.collect()
|
||||
return
|
||||
|
||||
# Build byte-offset index for preview lookups in the background so refresh isn't blocked by it
|
||||
build_programme_index_task.delay(source.id)
|
||||
parse_programs_for_source(source)
|
||||
|
||||
elif source.source_type == 'schedules_direct':
|
||||
|
|
@ -3561,3 +3574,429 @@ def generate_dummy_epg(source):
|
|||
logger.warning(f"generate_dummy_epg() called for {source.name} but this function is deprecated. "
|
||||
f"Dummy EPG programs are now generated on-demand.")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Byte-offset programme index (ported from dev branch)
|
||||
# These functions support fast current-program lookup for the CurrentPrograms
|
||||
# API without doing a full DB query for every channel on every poll.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_source_file(epg_source):
|
||||
"""Resolve the XML file path for an EPG source."""
|
||||
file_path = epg_source.extracted_file_path or epg_source.file_path
|
||||
if not file_path:
|
||||
file_path = epg_source.get_cache_file()
|
||||
return file_path
|
||||
|
||||
|
||||
_CHANNEL_ATTR_RE = re.compile(rb"""channel\s*=\s*(?:"([^"]+)"|'([^']+)')""")
|
||||
_PROGRAMME_TAG = b'<programme'
|
||||
_PROGRAMME_TAG_LEN = len(_PROGRAMME_TAG)
|
||||
_TAG_FOLLOW = b' \t\n\r>/'
|
||||
_MAX_START_TAG = 4096 # generous upper bound for a start tag with namespaces/extra attrs
|
||||
_OFFSET_CAP = 10 # max block-starts recorded per channel; exceeding this flags the channel as interleaved
|
||||
|
||||
|
||||
def _decode_channel_id(raw):
|
||||
"""Match how EPGData.tvg_id is stored: resolve XML entities and strip, so byte-level index keys equal the lxml-parsed channel ids."""
|
||||
s = raw.decode('utf-8', errors='replace')
|
||||
if '&' in s:
|
||||
s = html.unescape(s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _find_programme_tag(buf, start):
|
||||
"""
|
||||
Find the next <programme element in *buf* starting from *start*.
|
||||
Returns (tag_pos, tag_end) or (-1, -1) if not found.
|
||||
"""
|
||||
pos = start
|
||||
while True:
|
||||
idx = buf.find(_PROGRAMME_TAG, pos)
|
||||
if idx == -1:
|
||||
return -1, -1
|
||||
# Validate next byte is whitespace or '>'
|
||||
follow = idx + _PROGRAMME_TAG_LEN
|
||||
if follow >= len(buf):
|
||||
return idx, -1 # need more data
|
||||
if buf[follow: follow + 1] not in _TAG_FOLLOW:
|
||||
pos = follow # false match (e.g. <programmeXYZ), skip
|
||||
continue
|
||||
# Find the '>' that closes the opening tag (scan up to _MAX_START_TAG bytes)
|
||||
tag_end = buf.find(b'>', follow, idx + _MAX_START_TAG)
|
||||
if tag_end == -1:
|
||||
if len(buf) >= idx + _MAX_START_TAG:
|
||||
logger.warning(
|
||||
f'[_find_programme_tag] <programme> start tag exceeds {_MAX_START_TAG} bytes at offset {idx}, skipping'
|
||||
)
|
||||
return -1, -1
|
||||
return idx, -1 # need more data
|
||||
return idx, tag_end
|
||||
|
||||
|
||||
def _programme_to_dict(elem, start_time, end_time):
|
||||
"""Convert a <programme> lxml element to a serializable dict."""
|
||||
title_el = elem.find('title')
|
||||
desc_el = elem.find('desc')
|
||||
sub_el = elem.find('sub-title')
|
||||
return {
|
||||
'title': title_el.text if title_el is not None and title_el.text else '',
|
||||
'description': desc_el.text if desc_el is not None and desc_el.text else '',
|
||||
'sub_title': sub_el.text if sub_el is not None and sub_el.text else '',
|
||||
'start_time': start_time.isoformat(),
|
||||
'end_time': end_time.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def build_programme_index(source_id):
|
||||
"""
|
||||
Scan the XML file with raw binary I/O to build a {tvg_id: [byte_offset, ...]} map.
|
||||
Persists the result to EPGSource.programme_index. Most XMLTV files group programmes
|
||||
by channel, but some split a channel across multiple non-contiguous blocks, so we
|
||||
record block starts up to _OFFSET_CAP and mark only channels that exceed the cap
|
||||
as interleaved.
|
||||
"""
|
||||
try:
|
||||
source = EPGSource.objects.get(id=source_id)
|
||||
except EPGSource.DoesNotExist:
|
||||
logger.error(f'[build_programme_index] EPGSource {source_id} not found')
|
||||
return
|
||||
|
||||
file_path = _resolve_source_file(source)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
logger.warning(
|
||||
f'[build_programme_index] File not found for source {source_id}: {file_path}'
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f'[build_programme_index] Building byte-offset index for source {source_id} from {file_path}'
|
||||
)
|
||||
start = time.monotonic()
|
||||
index = {}
|
||||
prev_channel = None
|
||||
interleaved_channels = set()
|
||||
|
||||
CHUNK = 8 * 1024 * 1024 # 8MB
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
buf = bytearray()
|
||||
buf_offset = 0 # absolute file offset of buf[0]
|
||||
|
||||
while True:
|
||||
chunk = f.read(CHUNK)
|
||||
if not chunk and not buf:
|
||||
break
|
||||
buf.extend(chunk)
|
||||
search_from = 0
|
||||
|
||||
while True:
|
||||
idx, tag_end = _find_programme_tag(buf, search_from)
|
||||
if idx == -1:
|
||||
break
|
||||
if tag_end == -1 and chunk:
|
||||
break # incomplete tag at buffer edge, need more data
|
||||
|
||||
abs_pos = buf_offset + idx
|
||||
m = _CHANNEL_ATTR_RE.search(
|
||||
buf, idx, tag_end + 1 if tag_end != -1 else idx + _MAX_START_TAG
|
||||
)
|
||||
if m:
|
||||
channel_id = _decode_channel_id(m.group(1) or m.group(2))
|
||||
if channel_id not in index:
|
||||
index[channel_id] = [abs_pos]
|
||||
elif channel_id != prev_channel:
|
||||
if len(index[channel_id]) < _OFFSET_CAP:
|
||||
index[channel_id].append(abs_pos)
|
||||
else:
|
||||
interleaved_channels.add(channel_id)
|
||||
prev_channel = channel_id
|
||||
|
||||
search_from = (
|
||||
(tag_end + 1) if tag_end != -1 else (idx + _PROGRAMME_TAG_LEN)
|
||||
)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
# Keep unprocessed tail for next iteration
|
||||
keep_from = (
|
||||
max(search_from, len(buf) - _MAX_START_TAG) if chunk else len(buf)
|
||||
)
|
||||
del buf[:keep_from]
|
||||
buf_offset += keep_from
|
||||
|
||||
elapsed = time.monotonic() - start
|
||||
logger.info(
|
||||
f'[build_programme_index] Indexed {len(index)} channels in {elapsed:.1f}s for source {source_id}'
|
||||
+ (
|
||||
f' ({len(interleaved_channels)} interleaved)'
|
||||
if interleaved_channels
|
||||
else ''
|
||||
)
|
||||
)
|
||||
|
||||
result = {
|
||||
'channels': index,
|
||||
'interleaved_channels': sorted(interleaved_channels),
|
||||
}
|
||||
EPGSource.objects.filter(id=source_id).update(programme_index=result)
|
||||
|
||||
|
||||
@shared_task
|
||||
def build_programme_index_task(source_id):
|
||||
"""Celery wrapper. Locks so refresh and preview don't both build the same source. Releases on finish rather than waiting out the TTL."""
|
||||
from core.utils import RedisClient
|
||||
|
||||
redis_client = RedisClient.get_client()
|
||||
lock_key = f'building_programme_index_{source_id}'
|
||||
if not redis_client.set(lock_key, '1', nx=True, ex=300):
|
||||
return
|
||||
try:
|
||||
build_programme_index(source_id)
|
||||
finally:
|
||||
redis_client.delete(lock_key)
|
||||
|
||||
|
||||
def find_current_program_for_tvg_id(epg_or_id):
|
||||
"""
|
||||
Look up the currently-airing program for an EPGData instance (or id) using
|
||||
the byte-offset index. If no index exists yet, queue an async build and let
|
||||
the caller retry rather than doing a blocking scan.
|
||||
|
||||
Returns dict, None, or "timeout".
|
||||
"""
|
||||
if isinstance(epg_or_id, EPGData):
|
||||
epg = epg_or_id
|
||||
else:
|
||||
try:
|
||||
epg = EPGData.objects.select_related('epg_source').get(id=epg_or_id)
|
||||
except EPGData.DoesNotExist:
|
||||
return None
|
||||
|
||||
source = epg.epg_source
|
||||
if not source or source.source_type in ('dummy', 'schedules_direct'):
|
||||
return None
|
||||
|
||||
tvg_id = epg.tvg_id
|
||||
if not tvg_id:
|
||||
return None
|
||||
|
||||
file_path = _resolve_source_file(source)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
now = timezone.now()
|
||||
# Force a fresh read of the DB-backed index to avoid using stale related-object
|
||||
# state when an EPG refresh invalidates/rebuilds the index concurrently.
|
||||
source.refresh_from_db(fields=['programme_index'])
|
||||
index = source.programme_index
|
||||
|
||||
if index is not None:
|
||||
channels = index.get('channels', {})
|
||||
if tvg_id not in channels:
|
||||
# Channel has no programmes in the file
|
||||
return None
|
||||
offsets = channels[tvg_id]
|
||||
if tvg_id in (index.get('interleaved_channels') or ()):
|
||||
# Check all stored offsets first (cheap: one seek + one element parse each)
|
||||
result = _read_programs_at_offsets(file_path, tvg_id, offsets, now)
|
||||
if result is not None:
|
||||
return result
|
||||
# Current programme is beyond the stored offsets; scan forward from the
|
||||
# last known position to avoid re-reading the already-checked portion
|
||||
result = _scan_from_offset_for_tvg_id(file_path, tvg_id, offsets[-1], now)
|
||||
if result == 'timeout':
|
||||
logger.warning(
|
||||
f'[find_current_program_for_tvg_id] Interleaved scan timed out for '
|
||||
f'tvg_id={tvg_id} source={source.id}; index has {len(offsets)} offsets'
|
||||
)
|
||||
return None
|
||||
return result
|
||||
return _read_programs_at_offsets(file_path, tvg_id, offsets, now)
|
||||
|
||||
# No index yet: dispatch a background build and let the frontend retry.
|
||||
# A sync scan can block a worker for ~10s on SMB-hosted EPGs.
|
||||
build_programme_index_task.delay(source.id)
|
||||
return 'timeout'
|
||||
|
||||
|
||||
def _read_programs_at_offsets(file_path, tvg_id, offsets, now):
|
||||
"""
|
||||
Seek to each offset, extract <programme> elements for *tvg_id*, return the
|
||||
first one currently airing. Chunk-based so it works on minified XML.
|
||||
"""
|
||||
PROG_CLOSE = b'</programme>'
|
||||
CLOSE_LEN = len(PROG_CLOSE)
|
||||
READ_SIZE = 2 * 1024 * 1024 # 2MB per read
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
for offset in offsets:
|
||||
f.seek(offset)
|
||||
buf = bytearray()
|
||||
done = False
|
||||
|
||||
while not done:
|
||||
chunk = f.read(READ_SIZE)
|
||||
if not chunk and not buf:
|
||||
break
|
||||
buf.extend(chunk)
|
||||
search_from = 0
|
||||
|
||||
while True:
|
||||
tag_start, tag_end = _find_programme_tag(buf, search_from)
|
||||
if tag_start == -1:
|
||||
break
|
||||
if tag_end == -1 and chunk:
|
||||
break # incomplete tag, need more data
|
||||
|
||||
# Check channel before searching for close tag
|
||||
m = _CHANNEL_ATTR_RE.search(
|
||||
buf,
|
||||
tag_start,
|
||||
tag_end + 1 if tag_end != -1 else tag_start + _MAX_START_TAG,
|
||||
)
|
||||
if not m:
|
||||
search_from = (
|
||||
(tag_end + 1)
|
||||
if tag_end != -1
|
||||
else (tag_start + _PROGRAMME_TAG_LEN)
|
||||
)
|
||||
continue
|
||||
|
||||
ch = _decode_channel_id(m.group(1) or m.group(2))
|
||||
if ch != tvg_id:
|
||||
done = True # different channel, end of block
|
||||
break
|
||||
|
||||
# Find the closing </programme> tag
|
||||
close_pos = buf.find(
|
||||
PROG_CLOSE, tag_end + 1 if tag_end != -1 else m.end()
|
||||
)
|
||||
if close_pos == -1:
|
||||
if not chunk:
|
||||
done = True # EOF with no close tag
|
||||
break # need more data
|
||||
close_end = close_pos + CLOSE_LEN
|
||||
|
||||
element_bytes = bytes(buf[tag_start:close_end])
|
||||
search_from = close_end
|
||||
|
||||
try:
|
||||
prog = _parse_programme_element(element_bytes)
|
||||
except etree.XMLSyntaxError:
|
||||
continue
|
||||
|
||||
start_str = prog.get('start')
|
||||
stop_str = prog.get('stop')
|
||||
if not start_str or not stop_str:
|
||||
continue
|
||||
start_time = parse_xmltv_time(start_str)
|
||||
end_time = parse_xmltv_time(stop_str)
|
||||
if start_time is None or end_time is None:
|
||||
continue
|
||||
if start_time <= now < end_time:
|
||||
return _programme_to_dict(prog, start_time, end_time)
|
||||
|
||||
# Trim processed bytes
|
||||
if search_from > 0:
|
||||
del buf[:search_from]
|
||||
search_from = 0
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _scan_from_offset_for_tvg_id(file_path, tvg_id, start_offset, now, timeout_sec=10):
|
||||
"""
|
||||
Scan forward from start_offset for tvg_id, skipping other channels rather than
|
||||
stopping at a channel boundary. Used for interleaved/time-sorted XMLTV files where
|
||||
a channel exceeded the stored offset cap.
|
||||
Returns dict, None, or 'timeout'.
|
||||
"""
|
||||
PROG_CLOSE = b'</programme>'
|
||||
CLOSE_LEN = len(PROG_CLOSE)
|
||||
READ_SIZE = 2 * 1024 * 1024
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
f.seek(start_offset)
|
||||
buf = bytearray()
|
||||
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
return 'timeout'
|
||||
|
||||
chunk = f.read(READ_SIZE)
|
||||
if not chunk and not buf:
|
||||
break
|
||||
buf.extend(chunk)
|
||||
search_from = 0
|
||||
|
||||
trim_to = 0
|
||||
|
||||
while True:
|
||||
tag_start, tag_end = _find_programme_tag(buf, search_from)
|
||||
if tag_start == -1:
|
||||
trim_to = search_from
|
||||
break
|
||||
if tag_end == -1 and chunk:
|
||||
trim_to = tag_start # keep incomplete tag for next read
|
||||
break
|
||||
|
||||
m = _CHANNEL_ATTR_RE.search(
|
||||
buf,
|
||||
tag_start,
|
||||
tag_end + 1 if tag_end != -1 else tag_start + _MAX_START_TAG,
|
||||
)
|
||||
if not m:
|
||||
search_from = (
|
||||
tag_end + 1 if tag_end != -1 else tag_start + _PROGRAMME_TAG_LEN
|
||||
)
|
||||
continue
|
||||
|
||||
ch = _decode_channel_id(m.group(1) or m.group(2))
|
||||
if ch != tvg_id:
|
||||
search_from = (
|
||||
tag_end + 1 if tag_end != -1 else tag_start + _PROGRAMME_TAG_LEN
|
||||
)
|
||||
continue
|
||||
|
||||
close_pos = buf.find(
|
||||
PROG_CLOSE, tag_end + 1 if tag_end != -1 else m.end()
|
||||
)
|
||||
if close_pos == -1:
|
||||
trim_to = tag_start # keep incomplete element for next read
|
||||
break
|
||||
close_end = close_pos + CLOSE_LEN
|
||||
|
||||
element_bytes = bytes(buf[tag_start:close_end])
|
||||
search_from = close_end
|
||||
|
||||
try:
|
||||
prog = _parse_programme_element(element_bytes)
|
||||
except etree.XMLSyntaxError:
|
||||
continue
|
||||
|
||||
start_str = prog.get('start')
|
||||
stop_str = prog.get('stop')
|
||||
if not start_str or not stop_str:
|
||||
continue
|
||||
start_time = parse_xmltv_time(start_str)
|
||||
end_time = parse_xmltv_time(stop_str)
|
||||
if start_time is None or end_time is None:
|
||||
continue
|
||||
if start_time <= now < end_time:
|
||||
return _programme_to_dict(prog, start_time, end_time)
|
||||
|
||||
if trim_to > 0:
|
||||
del buf[:trim_to]
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
services:
|
||||
dispatcharr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: dispatcharr_dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 9191:9191
|
||||
- 8001:8001
|
||||
volumes:
|
||||
- dispatcharr_data:/data
|
||||
environment:
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_DB=dispatcharr
|
||||
- POSTGRES_USER=dispatch
|
||||
- POSTGRES_PASSWORD=secret
|
||||
- DJANGO_SECRET_KEY=dev-secret-key-not-for-production
|
||||
- DISPATCHARR_LOG_LEVEL=info
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
container_name: dispatcharr_dev_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=dispatcharr
|
||||
- POSTGRES_USER=dispatch
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatcharr"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
dispatcharr_data:
|
||||
postgres_data:
|
||||
866
serializers.py
866
serializers.py
|
|
@ -1,866 +0,0 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from rest_framework import serializers
|
||||
from .models import (
|
||||
Stream,
|
||||
Channel,
|
||||
ChannelGroup,
|
||||
ChannelOverride,
|
||||
ChannelStream,
|
||||
ChannelGroupM3UAccount,
|
||||
Logo,
|
||||
ChannelProfile,
|
||||
ChannelProfileMembership,
|
||||
Recording,
|
||||
RecurringRecordingRule,
|
||||
)
|
||||
from apps.epg.serializers import EPGDataSerializer
|
||||
from core.models import StreamProfile
|
||||
from apps.epg.models import EPGData
|
||||
from django.db import connection, transaction
|
||||
from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
from core.utils import validate_flexible_url
|
||||
|
||||
|
||||
class LogoSerializer(serializers.ModelSerializer):
|
||||
cache_url = serializers.SerializerMethodField()
|
||||
channel_count = serializers.SerializerMethodField()
|
||||
is_used = serializers.SerializerMethodField()
|
||||
channel_names = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Logo
|
||||
fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"]
|
||||
|
||||
def validate_url(self, value):
|
||||
"""Validate that the URL is unique for creation or update"""
|
||||
if self.instance and self.instance.url == value:
|
||||
return value
|
||||
|
||||
if Logo.objects.filter(url=value).exists():
|
||||
raise serializers.ValidationError("A logo with this URL already exists.")
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Handle logo creation with proper URL validation"""
|
||||
return Logo.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Handle logo updates"""
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def get_cache_url(self, obj):
|
||||
# Cache-busting: append a short hash of the logo's source URL so the browser
|
||||
# fetches fresh when the logo changes (e.g., M3U logo replaced by SD logo).
|
||||
# The backend ignores the 'v' parameter — it's purely for browser cache invalidation.
|
||||
# See SD integration PR notes for context on why this was added.
|
||||
import hashlib
|
||||
url_hash = hashlib.md5((obj.url or '').encode()).hexdigest()[:8]
|
||||
base_path = reverse("api:channels:logo-cache", args=[obj.id])
|
||||
cache_url = f"{base_path}?v={url_hash}"
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return request.build_absolute_uri(cache_url)
|
||||
return cache_url
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get the number of channels using this logo"""
|
||||
# `channel_count` is provided as an annotation in LogoViewSet.get_queryset().
|
||||
# Fall back to a query only when serializing a single un-annotated Logo
|
||||
# (e.g. nested inside ChannelSerializer.get_logo()).
|
||||
annotated = getattr(obj, "channel_count", None)
|
||||
if annotated is not None:
|
||||
return annotated
|
||||
return obj.channels.count()
|
||||
|
||||
def get_is_used(self, obj):
|
||||
"""Check if this logo is used by any channels"""
|
||||
return self.get_channel_count(obj) > 0
|
||||
|
||||
def get_channel_names(self, obj):
|
||||
"""Get the names of channels using this logo (limited to first 5)"""
|
||||
names = []
|
||||
|
||||
# When LogoViewSet.get_queryset() prefetches `channels`, iterating
|
||||
# obj.channels.all() reuses the cached set; slicing happens in Python.
|
||||
channels = list(obj.channels.all()[:5])
|
||||
for channel in channels:
|
||||
names.append(f"Channel: {channel.name}")
|
||||
|
||||
total_count = self.get_channel_count(obj)
|
||||
if total_count > 5:
|
||||
names.append(f"...and {total_count - 5} more")
|
||||
|
||||
return names
|
||||
|
||||
|
||||
#
|
||||
# Stream
|
||||
#
|
||||
class StreamSerializer(serializers.ModelSerializer):
|
||||
url = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
validators=[validate_flexible_url]
|
||||
)
|
||||
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StreamProfile.objects.all(),
|
||||
source="stream_profile",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
read_only_fields = ["is_custom", "m3u_account", "stream_hash", "stream_id", "stream_chno"]
|
||||
|
||||
class Meta:
|
||||
model = Stream
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"url",
|
||||
"m3u_account", # Uncomment if using M3U fields
|
||||
"logo_url",
|
||||
"tvg_id",
|
||||
"local_file",
|
||||
"current_viewers",
|
||||
"updated_at",
|
||||
"last_seen",
|
||||
"is_stale",
|
||||
"is_adult",
|
||||
"stream_profile_id",
|
||||
"is_custom",
|
||||
"channel_group",
|
||||
"stream_hash",
|
||||
"stream_stats",
|
||||
"stream_stats_updated_at",
|
||||
"stream_id",
|
||||
"stream_chno",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
fields = super().get_fields()
|
||||
|
||||
# Unable to edit specific properties if this stream was created from an M3U account
|
||||
if (
|
||||
self.instance
|
||||
and getattr(self.instance, "m3u_account", None)
|
||||
and not self.instance.is_custom
|
||||
):
|
||||
fields["id"].read_only = True
|
||||
fields["name"].read_only = True
|
||||
fields["url"].read_only = True
|
||||
fields["m3u_account"].read_only = True
|
||||
fields["tvg_id"].read_only = True
|
||||
fields["channel_group"].read_only = True
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
||||
m3u_accounts = serializers.IntegerField(source="m3u_accounts.id", read_only=True)
|
||||
enabled = serializers.BooleanField()
|
||||
auto_channel_sync = serializers.BooleanField(default=False)
|
||||
auto_sync_channel_start = serializers.FloatField(
|
||||
allow_null=True, required=False, min_value=1
|
||||
)
|
||||
auto_sync_channel_end = serializers.FloatField(
|
||||
allow_null=True, required=False, min_value=1
|
||||
)
|
||||
custom_properties = serializers.JSONField(required=False)
|
||||
# Provider stream count for this group+account. Lets users size an
|
||||
# optional end-range without first running a blind sync.
|
||||
stream_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ChannelGroupM3UAccount
|
||||
fields = [
|
||||
"m3u_accounts",
|
||||
"channel_group",
|
||||
"enabled",
|
||||
"auto_channel_sync",
|
||||
"auto_sync_channel_start",
|
||||
"auto_sync_channel_end",
|
||||
"custom_properties",
|
||||
"is_stale",
|
||||
"last_seen",
|
||||
"stream_count",
|
||||
]
|
||||
|
||||
def get_stream_count(self, obj):
|
||||
"""
|
||||
Return the number of streams for this (m3u_account, channel_group)
|
||||
pair. A parent serializer (e.g. M3UAccountSerializer) may seed
|
||||
``context["stream_counts"]`` with a pre-aggregated dict keyed by
|
||||
``(m3u_account_id, channel_group_id)`` to avoid one COUNT per row;
|
||||
when present, it is used as the source of truth. The per-row
|
||||
COUNT fallback is correct for stand-alone serialization (rare,
|
||||
low-volume) and exists so direct ChannelGroupM3UAccount queries
|
||||
do not require callers to know the seeding pattern.
|
||||
"""
|
||||
counts = self.context.get("stream_counts")
|
||||
if counts is not None:
|
||||
return counts.get((obj.m3u_account_id, obj.channel_group_id), 0)
|
||||
from apps.channels.models import Stream
|
||||
|
||||
return Stream.objects.filter(
|
||||
m3u_account_id=obj.m3u_account_id,
|
||||
channel_group_id=obj.channel_group_id,
|
||||
).count()
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
|
||||
custom_props = instance.custom_properties or {}
|
||||
|
||||
return data
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# Accept both dict and JSON string for custom_properties (for backward compatibility)
|
||||
val = data.get("custom_properties")
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
data["custom_properties"] = json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate(self, attrs):
|
||||
# Partial PATCHes only carry submitted fields; fill missing
|
||||
# start/end from the instance so the validator catches a PATCH
|
||||
# that lowers end past the existing start.
|
||||
start = attrs.get("auto_sync_channel_start")
|
||||
end = attrs.get("auto_sync_channel_end")
|
||||
if start is None and self.instance is not None:
|
||||
start = self.instance.auto_sync_channel_start
|
||||
if end is None and self.instance is not None:
|
||||
end = self.instance.auto_sync_channel_end
|
||||
if start is not None and end is not None and end < start:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"auto_sync_channel_end": (
|
||||
"End must be greater than or equal to start."
|
||||
)
|
||||
}
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
#
|
||||
# Channel Group
|
||||
#
|
||||
class ChannelGroupSerializer(serializers.ModelSerializer):
|
||||
channel_count = serializers.SerializerMethodField()
|
||||
m3u_account_count = serializers.SerializerMethodField()
|
||||
m3u_accounts = ChannelGroupM3UAccountSerializer(
|
||||
many=True,
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ChannelGroup
|
||||
fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"]
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
# Use the queryset annotation when available (list path); fall back
|
||||
# to a live query for retrieve/create/update where it isn't set.
|
||||
v = getattr(obj, 'channel_count', None)
|
||||
return v if v is not None else obj.channels.count()
|
||||
|
||||
def get_m3u_account_count(self, obj):
|
||||
v = getattr(obj, 'm3u_account_count', None)
|
||||
return v if v is not None else obj.m3u_accounts.count()
|
||||
|
||||
|
||||
class ChannelProfileSerializer(serializers.ModelSerializer):
|
||||
channels = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ChannelProfile
|
||||
fields = ["id", "name", "channels"]
|
||||
|
||||
def get_channels(self, obj):
|
||||
# Use prefetched attr when available, fall back to a direct query.
|
||||
memberships = getattr(obj, 'enabled_memberships', None)
|
||||
if memberships is not None:
|
||||
return [m.channel_id for m in memberships]
|
||||
return list(
|
||||
ChannelProfileMembership.objects.filter(
|
||||
channel_profile=obj, enabled=True
|
||||
).values_list('channel_id', flat=True)
|
||||
)
|
||||
|
||||
|
||||
class ChannelProfileMembershipSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ChannelProfileMembership
|
||||
fields = ["channel", "enabled"]
|
||||
|
||||
|
||||
class ChanneProfilelMembershipUpdateSerializer(serializers.Serializer):
|
||||
channel_id = serializers.IntegerField() # Ensure channel_id is an integer
|
||||
enabled = serializers.BooleanField()
|
||||
|
||||
|
||||
class BulkChannelProfileMembershipSerializer(serializers.Serializer):
|
||||
channels = serializers.ListField(
|
||||
child=ChanneProfilelMembershipUpdateSerializer(), # Use the nested serializer
|
||||
allow_empty=False,
|
||||
)
|
||||
|
||||
def validate_channels(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("At least one channel must be provided.")
|
||||
return value
|
||||
|
||||
|
||||
#
|
||||
# Channel override
|
||||
#
|
||||
# Nullable per-field overrides resolved over the parent Channel in read
|
||||
# paths. Embedded in ChannelSerializer so clients can upsert/clear in the
|
||||
# same PATCH that targets direct channel fields.
|
||||
class ChannelOverrideSerializer(serializers.ModelSerializer):
|
||||
# HDHR clients reject negative GuideNumber and zero is not a real
|
||||
# provider value, so reject both at the API boundary.
|
||||
channel_number = serializers.FloatField(
|
||||
allow_null=True, required=False, min_value=0.0001
|
||||
)
|
||||
channel_group_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChannelGroup.objects.all(),
|
||||
source="channel_group",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
logo_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Logo.objects.all(),
|
||||
source="logo",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
epg_data_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=EPGData.objects.all(),
|
||||
source="epg_data",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StreamProfile.objects.all(),
|
||||
source="stream_profile",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ChannelOverride
|
||||
fields = [
|
||||
"name",
|
||||
"channel_number",
|
||||
"channel_group_id",
|
||||
"logo_id",
|
||||
"tvg_id",
|
||||
"tvc_guide_stationid",
|
||||
"epg_data_id",
|
||||
"stream_profile_id",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"allow_null": True, "required": False},
|
||||
"tvg_id": {"allow_null": True, "required": False},
|
||||
"tvc_guide_stationid": {"allow_null": True, "required": False},
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Channel
|
||||
#
|
||||
class ChannelSerializer(serializers.ModelSerializer):
|
||||
# Show nested group data, or ID
|
||||
# Ensure channel_number is explicitly typed as FloatField and properly validated
|
||||
channel_number = serializers.FloatField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
error_messages={"invalid": "Channel number must be a valid decimal number."},
|
||||
)
|
||||
channel_group_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChannelGroup.objects.all(), source="channel_group", required=False
|
||||
)
|
||||
epg_data_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=EPGData.objects.all(),
|
||||
source="epg_data",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StreamProfile.objects.all(),
|
||||
source="stream_profile",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
streams = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Stream.objects.all(), many=True, required=False
|
||||
)
|
||||
|
||||
logo_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Logo.objects.all(),
|
||||
source="logo",
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
auto_created_by_name = serializers.SerializerMethodField()
|
||||
override = ChannelOverrideSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text=(
|
||||
"Per-field overrides for an auto-created channel. "
|
||||
'Send {"override": {"name": "ESPN"}} to upsert the listed '
|
||||
'fields, {"override": {"name": null}} to clear specific fields '
|
||||
'while leaving others, or {"override": null} to delete the '
|
||||
"override row entirely. Omitting the key leaves any existing "
|
||||
"override unchanged. Only valid for auto_created=True channels. "
|
||||
"Duplicate channel_number values across channels are permitted; "
|
||||
"downstream client behavior on duplicates varies by client."
|
||||
),
|
||||
)
|
||||
source_stream = serializers.SerializerMethodField()
|
||||
# Effective fields coalesce override over channel column. Consumers
|
||||
# display these; raw fields remain in the response so the edit form
|
||||
# can show them as "Provider: X" subtext.
|
||||
effective_name = serializers.SerializerMethodField()
|
||||
effective_channel_number = serializers.SerializerMethodField()
|
||||
effective_channel_group_id = serializers.SerializerMethodField()
|
||||
effective_logo_id = serializers.SerializerMethodField()
|
||||
effective_tvg_id = serializers.SerializerMethodField()
|
||||
effective_tvc_guide_stationid = serializers.SerializerMethodField()
|
||||
effective_epg_data_id = serializers.SerializerMethodField()
|
||||
effective_stream_profile_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Channel
|
||||
fields = [
|
||||
"id",
|
||||
"channel_number",
|
||||
"name",
|
||||
"channel_group_id",
|
||||
"tvg_id",
|
||||
"tvc_guide_stationid",
|
||||
"epg_data_id",
|
||||
"streams",
|
||||
"stream_profile_id",
|
||||
"uuid",
|
||||
"logo_id",
|
||||
"user_level",
|
||||
"is_adult",
|
||||
"hidden_from_output",
|
||||
"auto_created",
|
||||
"auto_created_by",
|
||||
"auto_created_by_name",
|
||||
"override",
|
||||
"source_stream",
|
||||
"effective_name",
|
||||
"effective_channel_number",
|
||||
"effective_channel_group_id",
|
||||
"effective_logo_id",
|
||||
"effective_tvg_id",
|
||||
"effective_tvc_guide_stationid",
|
||||
"effective_epg_data_id",
|
||||
"effective_stream_profile_id",
|
||||
]
|
||||
|
||||
def _effective_value(self, obj, field_name):
|
||||
override = getattr(obj, "_channel_override_cache", None)
|
||||
if override is None:
|
||||
try:
|
||||
override = obj.override
|
||||
except ChannelOverride.DoesNotExist:
|
||||
override = None
|
||||
obj._channel_override_cache = override
|
||||
if override is not None:
|
||||
value = getattr(override, field_name, None)
|
||||
if value is not None:
|
||||
return value
|
||||
return getattr(obj, field_name, None)
|
||||
|
||||
def get_effective_name(self, obj):
|
||||
return self._effective_value(obj, "name")
|
||||
|
||||
def get_effective_channel_number(self, obj):
|
||||
return self._effective_value(obj, "channel_number")
|
||||
|
||||
def get_effective_channel_group_id(self, obj):
|
||||
return self._effective_value(obj, "channel_group_id")
|
||||
|
||||
def get_effective_logo_id(self, obj):
|
||||
return self._effective_value(obj, "logo_id")
|
||||
|
||||
def get_effective_tvg_id(self, obj):
|
||||
return self._effective_value(obj, "tvg_id")
|
||||
|
||||
def get_effective_tvc_guide_stationid(self, obj):
|
||||
return self._effective_value(obj, "tvc_guide_stationid")
|
||||
|
||||
def get_effective_epg_data_id(self, obj):
|
||||
return self._effective_value(obj, "epg_data_id")
|
||||
|
||||
def get_effective_stream_profile_id(self, obj):
|
||||
return self._effective_value(obj, "stream_profile_id")
|
||||
|
||||
def get_source_stream(self, obj):
|
||||
"""
|
||||
Return the originating provider stream for an auto-created channel.
|
||||
|
||||
Surfaces the provider stream's name and owning M3U account so the
|
||||
frontend can render "Auto-created from: <provider> / <stream name>"
|
||||
in the channel edit form. Returns None for manual channels.
|
||||
"""
|
||||
if not self.context.get("include_source_stream", False):
|
||||
return None
|
||||
if not obj.auto_created:
|
||||
return None
|
||||
# Viewset prefetches `channelstream_set` ordered by `order`, so
|
||||
# `.all()[0]` reuses the cache and returns the lowest-order entry.
|
||||
prefetched_list = list(obj.channelstream_set.all())
|
||||
if not prefetched_list:
|
||||
return None
|
||||
cs = prefetched_list[0]
|
||||
if not cs.stream:
|
||||
return None
|
||||
stream = cs.stream
|
||||
return {
|
||||
"id": stream.id,
|
||||
"name": stream.name,
|
||||
"account_id": stream.m3u_account_id,
|
||||
"account_name": getattr(stream.m3u_account, "name", None),
|
||||
}
|
||||
|
||||
def to_representation(self, instance):
|
||||
include_streams = self.context.get("include_streams", False)
|
||||
|
||||
if include_streams:
|
||||
self.fields["streams"] = serializers.SerializerMethodField()
|
||||
return super().to_representation(instance)
|
||||
else:
|
||||
# Read from the prefetched channelstream_set (ordered by the
|
||||
# viewset's Prefetch); chaining .order_by() rebuilds the
|
||||
# queryset and fires one SELECT per row in list responses.
|
||||
representation = super().to_representation(instance)
|
||||
if "streams" in representation:
|
||||
representation["streams"] = [
|
||||
cs.stream_id for cs in instance.channelstream_set.all()
|
||||
]
|
||||
return representation
|
||||
|
||||
def get_logo(self, obj):
|
||||
return LogoSerializer(obj.logo).data
|
||||
|
||||
def get_streams(self, obj):
|
||||
"""Retrieve ordered stream IDs for GET requests."""
|
||||
return StreamSerializer(
|
||||
obj.streams.all().order_by("channelstream__order"), many=True
|
||||
).data
|
||||
|
||||
def create(self, validated_data):
|
||||
streams = validated_data.pop("streams", [])
|
||||
override_data = validated_data.pop("override", None)
|
||||
channel_number = validated_data.pop(
|
||||
"channel_number", Channel.get_next_available_channel_number()
|
||||
)
|
||||
validated_data["channel_number"] = channel_number
|
||||
|
||||
# Auto-assign Default Group if no channel_group is specified
|
||||
if "channel_group" not in validated_data or validated_data.get("channel_group") is None:
|
||||
from apps.channels.models import ChannelGroup
|
||||
default_group, _ = ChannelGroup.objects.get_or_create(name="Default Group")
|
||||
validated_data["channel_group"] = default_group
|
||||
|
||||
# Atomic wrapper keeps the channel insert and its override row
|
||||
# in the same transaction so a failure on either rolls both back.
|
||||
with transaction.atomic():
|
||||
channel = Channel.objects.create(**validated_data)
|
||||
|
||||
# Add streams in the specified order
|
||||
for index, stream in enumerate(streams):
|
||||
ChannelStream.objects.create(
|
||||
channel=channel, stream_id=stream.id, order=index
|
||||
)
|
||||
|
||||
if override_data:
|
||||
# Manual channels (auto_created=False) have no provider
|
||||
# value to override; reject the override payload here so a
|
||||
# programmatic client can't write a semantically meaningless
|
||||
# row that the frontend would then surface as "Overrides
|
||||
# active".
|
||||
if not channel.auto_created:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"override": (
|
||||
"Cannot set override on a manual channel; "
|
||||
"overrides only apply to auto-created channels."
|
||||
)
|
||||
}
|
||||
)
|
||||
obj = ChannelOverride.objects.create(channel=channel, **override_data)
|
||||
# Drop an all-null override row; an empty override would
|
||||
# falsely surface as active in the UI.
|
||||
if not obj.has_any_override():
|
||||
obj.delete()
|
||||
|
||||
return channel
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
PATCH handler for Channel rows. The ``override`` key carries
|
||||
per-field user overrides for auto-created channels and follows
|
||||
these rules:
|
||||
|
||||
* key absent from payload: no change to existing overrides
|
||||
* ``{"override": {"field": value}}``: upsert those fields
|
||||
* ``{"override": {"field": null}}``: clear those specific fields
|
||||
* ``{"override": null}``: delete the override row entirely
|
||||
|
||||
Key presence is what distinguishes "no change" from "delete";
|
||||
an explicit null means delete. Override mutations are rejected
|
||||
on manual channels (auto_created=False) since there is no
|
||||
provider value to override.
|
||||
"""
|
||||
streams = validated_data.pop("streams", None)
|
||||
has_override_key = "override" in self.initial_data
|
||||
override_data = validated_data.pop("override", None)
|
||||
|
||||
# Block override mutations on manual channels (no provider
|
||||
# value to override). Clearing is a tolerated no-op.
|
||||
if (
|
||||
has_override_key
|
||||
and override_data is not None
|
||||
and override_data != {}
|
||||
and not instance.auto_created
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"override": (
|
||||
"Cannot set override on a manual channel; "
|
||||
"overrides only apply to auto-created channels."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# Atomic so a failure on the override row rolls back the
|
||||
# channel update too.
|
||||
with transaction.atomic():
|
||||
# Skip save() when only override keys were submitted; a
|
||||
# no-op UPDATE would bump updated_at and bust caches.
|
||||
if validated_data:
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
if has_override_key:
|
||||
if override_data is None:
|
||||
# Explicit null: remove the override row.
|
||||
ChannelOverride.objects.filter(channel=instance).delete()
|
||||
elif override_data == {}:
|
||||
# Empty dict has no field intent; no-op.
|
||||
pass
|
||||
else:
|
||||
obj, _ = ChannelOverride.objects.update_or_create(
|
||||
channel=instance, defaults=override_data
|
||||
)
|
||||
# Drop an all-null override; would falsely surface
|
||||
# as active in the UI.
|
||||
if not obj.has_any_override():
|
||||
obj.delete()
|
||||
# Queryset writes leave the reverse-OneToOne cache stale;
|
||||
# clear it so to_representation reads the new state.
|
||||
try:
|
||||
instance._state.fields_cache.pop("override", None)
|
||||
except AttributeError:
|
||||
pass
|
||||
if hasattr(instance, "_channel_override_cache"):
|
||||
delattr(instance, "_channel_override_cache")
|
||||
|
||||
if streams is not None:
|
||||
# Normalize stream IDs
|
||||
normalized_ids = [
|
||||
stream.id if hasattr(stream, "id") else stream for stream in streams
|
||||
]
|
||||
|
||||
# Get current mapping of stream_id -> ChannelStream
|
||||
current_links = {
|
||||
cs.stream_id: cs for cs in instance.channelstream_set.all()
|
||||
}
|
||||
|
||||
# Track existing stream IDs
|
||||
existing_ids = set(current_links.keys())
|
||||
new_ids = set(normalized_ids)
|
||||
|
||||
# Delete any links not in the new list
|
||||
to_remove = existing_ids - new_ids
|
||||
if to_remove:
|
||||
instance.channelstream_set.filter(stream_id__in=to_remove).delete()
|
||||
|
||||
# Update or create with new order
|
||||
to_update = []
|
||||
for order, stream_id in enumerate(normalized_ids):
|
||||
if stream_id in current_links:
|
||||
cs = current_links[stream_id]
|
||||
if cs.order != order:
|
||||
cs.order = order
|
||||
to_update.append(cs)
|
||||
else:
|
||||
ChannelStream.objects.create(
|
||||
channel=instance, stream_id=stream_id, order=order
|
||||
)
|
||||
|
||||
if to_update:
|
||||
ChannelStream.objects.bulk_update(to_update, ["order"])
|
||||
|
||||
return instance
|
||||
|
||||
def validate_channel_number(self, value):
|
||||
"""Ensure channel_number is properly processed as a float"""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
try:
|
||||
# Ensure it's processed as a float
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
raise serializers.ValidationError(
|
||||
"Channel number must be a valid decimal number."
|
||||
)
|
||||
|
||||
def validate_stream_profile(self, value):
|
||||
"""Handle special case where empty/0 values mean 'use default' (null)"""
|
||||
if value == "0" or value == 0 or value == "" or value is None:
|
||||
return None
|
||||
return value # PrimaryKeyRelatedField will handle the conversion to object
|
||||
|
||||
def get_auto_created_by_name(self, obj):
|
||||
"""Get the name of the M3U account that auto-created this channel."""
|
||||
if obj.auto_created_by:
|
||||
return obj.auto_created_by.name
|
||||
return None
|
||||
|
||||
|
||||
class RecordingSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Recording
|
||||
fields = "__all__"
|
||||
read_only_fields = ["task_id"]
|
||||
|
||||
def validate(self, data):
|
||||
from core.models import CoreSettings
|
||||
start_time = data.get("start_time")
|
||||
end_time = data.get("end_time")
|
||||
|
||||
if start_time and timezone.is_naive(start_time):
|
||||
start_time = timezone.make_aware(start_time, timezone.get_current_timezone())
|
||||
data["start_time"] = start_time
|
||||
if end_time and timezone.is_naive(end_time):
|
||||
end_time = timezone.make_aware(end_time, timezone.get_current_timezone())
|
||||
data["end_time"] = end_time
|
||||
|
||||
# If this is an EPG-based recording (program provided), apply global pre/post offsets
|
||||
try:
|
||||
cp = data.get("custom_properties") or {}
|
||||
is_epg_based = isinstance(cp, dict) and isinstance(cp.get("program"), (dict,))
|
||||
except Exception:
|
||||
is_epg_based = False
|
||||
|
||||
if is_epg_based and start_time and end_time:
|
||||
try:
|
||||
pre_min = int(CoreSettings.get_dvr_pre_offset_minutes())
|
||||
except Exception:
|
||||
pre_min = 0
|
||||
try:
|
||||
post_min = int(CoreSettings.get_dvr_post_offset_minutes())
|
||||
except Exception:
|
||||
post_min = 0
|
||||
from datetime import timedelta
|
||||
try:
|
||||
if pre_min and pre_min > 0:
|
||||
start_time = start_time - timedelta(minutes=pre_min)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if post_min and post_min > 0:
|
||||
end_time = end_time + timedelta(minutes=post_min)
|
||||
except Exception:
|
||||
pass
|
||||
# write back adjusted times so scheduling uses them
|
||||
data["start_time"] = start_time
|
||||
data["end_time"] = end_time
|
||||
|
||||
now = timezone.now() # timezone-aware current time
|
||||
|
||||
if end_time < now:
|
||||
raise serializers.ValidationError("End time must be in the future.")
|
||||
|
||||
if start_time < now:
|
||||
# Optional: Adjust start_time if it's in the past but end_time is in the future
|
||||
data["start_time"] = now # or: timezone.now() + timedelta(seconds=1)
|
||||
if end_time <= data["start_time"]:
|
||||
raise serializers.ValidationError("End time must be after start time.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RecurringRecordingRuleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RecurringRecordingRule
|
||||
fields = "__all__"
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
|
||||
def validate_days_of_week(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("Select at least one day of the week")
|
||||
cleaned = []
|
||||
for entry in value:
|
||||
try:
|
||||
iv = int(entry)
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError("Days of week must be integers 0-6")
|
||||
if iv < 0 or iv > 6:
|
||||
raise serializers.ValidationError("Days of week must be between 0 (Monday) and 6 (Sunday)")
|
||||
cleaned.append(iv)
|
||||
return sorted(set(cleaned))
|
||||
|
||||
def validate(self, attrs):
|
||||
start = attrs.get("start_time") or getattr(self.instance, "start_time", None)
|
||||
end = attrs.get("end_time") or getattr(self.instance, "end_time", None)
|
||||
start_date = attrs.get("start_date") if "start_date" in attrs else getattr(self.instance, "start_date", None)
|
||||
end_date = attrs.get("end_date") if "end_date" in attrs else getattr(self.instance, "end_date", None)
|
||||
if start_date is None:
|
||||
existing_start = getattr(self.instance, "start_date", None)
|
||||
if existing_start is None:
|
||||
raise serializers.ValidationError("Start date is required")
|
||||
if start_date and end_date and end_date < start_date:
|
||||
raise serializers.ValidationError("End date must be on or after start date")
|
||||
if end_date is None:
|
||||
existing_end = getattr(self.instance, "end_date", None)
|
||||
if existing_end is None:
|
||||
raise serializers.ValidationError("End date is required")
|
||||
if start and end and start_date and end_date:
|
||||
start_dt = datetime.combine(start_date, start)
|
||||
end_dt = datetime.combine(end_date, end)
|
||||
if end_dt <= start_dt:
|
||||
raise serializers.ValidationError("End datetime must be after start datetime")
|
||||
elif start and end and end == start:
|
||||
raise serializers.ValidationError("End time must be different from start time")
|
||||
# Normalize empty strings to None for dates
|
||||
if attrs.get("end_date") == "":
|
||||
attrs["end_date"] = None
|
||||
if attrs.get("start_date") == "":
|
||||
attrs["start_date"] = None
|
||||
return super().validate(attrs)
|
||||
|
||||
def create(self, validated_data):
|
||||
return super().create(validated_data)
|
||||
Loading…
Add table
Add a link
Reference in a new issue