fix: Fix removed code from bad merge.

This commit is contained in:
SergeantPanda 2026-06-04 15:37:27 -05:00
parent 30843c0c4c
commit ac3a83a1aa
6 changed files with 439 additions and 6246 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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 &eacute; 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

View file

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

View file

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

3594
tasks.py

File diff suppressed because it is too large Load diff