Previously only UnicodeDecodeError/UnicodeEncodeError were caught. Unexpected errors like disk full or permission denied would propagate
and crash the EPG refresh, which would have succeeded before the preprocessing step existed. Now all exceptions are caught and logged, leaving the original file untouched for lxml to handle as before.
lxml 6.0.2 with recover=True silently drops HTML named entities (e.g. é, î) that are not valid XML, causing channel names and program data to lose characters during EPG ingestion.
- Add preprocessing step in fetch_xmltv() that converts HTML named entities to Unicode while preserving XML-predefined entities
- Detect encoding from XML declaration, fall back to UTF-8 for unknown codecs, skip files that fail to decode cleanly
- Guard against entities resolving to XML-special characters and html.unescape partial matching edge cases
- Add 28 unit tests covering entity resolution, encoding detection, and file-level processing
- Write ERROR via ownership check OR state guard fallback when ownership TTL expired during retries
- Add INITIALIZING to cleanup task grace period monitoring
- Validate client SET entries against metadata hashes in orphaned channel cleanup; remove ghosts and clean up when no real clients remain
- Stats page self-heals by removing ghost SET entries on read
- Extract remove_ghost_clients() helper to ClientManager
- Add ChannelState.PRE_ACTIVE constant
- Fix 6 pre-existing NonOwnerWorkerKeepalive test failures
Replace DRF ModelSerializer with .values() + plain dict construction in
EPGGridAPIView, eliminating per-row serializer instantiation, field
object creation, and OrderedDict overhead across ~12k programs.
Pre-compute season/episode at import time instead of on every API request:
- Extract S/E from onscreen episode-num inline in extract_custom_properties()
immediately after storing the raw value, rather than in a separate pass
- Add missing description-based S/E fallback to parse_programs_for_tvg_id(),
bringing it in line with parse_programs_for_source()
Remove runtime extract_season_episode() call from ProgramDataSerializer and
EPGGridAPIView — values are now guaranteed to be in custom_properties after
import, so season/episode resolution is a plain dict lookup at request time.
Also remove unnecessary select_related("epg") JOIN and .count() query from
the grid endpoint.
Replace expanded inline cards with a ProgramDetailModal for viewing full program
details. Add real-time progress bars to currently-airing programs using direct DOM manipulation. Extract and display season/episode info from EPG data with a 3-tier fallback (custom_properties → onscreen_episode → description text).
Backend:
- Add ProgramDetailSerializer for rich EPG data (categories, credits, ratings, images, external IDs) served on the detail endpoint
- Add infer_is_live() for status badges (live, new, premiere, finale)
- Add shared S/E extraction utilities in apps/epg/utils.py
- Optimize querysets with select_related("epg")
Frontend:
- ProgramDetailModal with conditional field rendering and image fallback chain
- Progress bars via scaleX transform updated every second
- Parallel data fetching with Promise.all for channels + programs
- Browser tab visibility recovery for progress bars
- Now-line with triangle marker and sub-millisecond precision
- Card layout: title, season/episode + subtitle, time + badges
- Shared formatSeasonEpisode() and external URL helpers
P1 (Critical):
- Replace undefined @swagger_auto_schema with @extend_schema
- Add custom_properties validation with size limits
- Add merge semantics in serializer update() to prevent data loss
P2 (Important):
- Add debouncing (800ms) for drag operations in NavOrderForm
- Add error handling with notifications for visibility toggle
- Use store getters in Sidebar instead of direct property access
- Cache hiddenNav before render loop
P3 (Nice-to-have):
- Add canHide: false to settings nav item
- Fix admin check to use >= instead of ==
- Fix ESLint unused variable warnings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Allow users to update their own profile via PATCH /api/accounts/users/me/
- Supports partial updates to custom_properties (including navOrder)
- Add comprehensive test coverage for the endpoint
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Stream.get_stream() was never called in the preview path of
generate_stream_url(), so the channel_stream and stream_profile
Redis keys were never written. This caused the stream_id to be
missing from channel metadata, which silently broke two things:
- Stats page showed no stream name or logo
- stream_stats were never saved to the database
Fix: replace the manual profile-selection loop in the preview path
with a direct call to Stream.get_stream(), matching the channel path
exactly. This also fixes a pre-existing non-atomic slot reservation
(read-then-check vs INCR-first) that could over-allocate connections
on concurrent previews.
Regression introduced in 49b7b9e2.
Add is_new, is_live, is_premiere, and is_finale fields to the EPG program API and render them as colored badges in the TV guide.
- Compact card: S/E, LIVE, and NEW badges on the title row
- Expanded card: all badges (S/E, LIVE, NEW, PREMIERE, FINALE) on the subtitle row, now moved directly after title for consistency
- PREMIERE/FINALE detected from premiere_text (case-insensitive)
- PREMIERE/FINALE only shown in expanded view to conserve space
- Badge order: S/E → LIVE → NEW → PREMIERE → FINALE
- Consolidate serializer season/episode parsing into a single to_representation
pass: one custom_properties read, regex runs at most once per program
- Compact card: S/E badge inline with title; secondary row shows sub_title with
description fallback
- Expanded card: sub_title and S/E badge share a row above the description
- Fix secondary text color for legibility on dark backgrounds (#a0aec0 → #e2e8f0)
- Fix expanded card overflow (height:100% → flex:1 1 0) so action buttons stay visible
- Bump expanded card height 180 → 200px, description max-height 80 → 100px
Display sub_title (episode name) and season/episode badge on guide program cards. Extract season/episode from custom_properties with fallback parsing of onscreen_episode format (e.g. "S12 E6").
- Add season/episode SerializerMethodFields to ProgramDataSerializer
- Parse onscreen_episode string as fallback when xmltv_ns data absent
- Display sub_title with S/E badge on guide cards
- Show description for programs without sub_title (movies)
- Add formatSeasonEpisode utility with full test coverage
- Add 15 backend serializer tests and 12 frontend utility tests
Fulfills #1065
Restructure cleanup in refresh_single_m3u_account so del statements
run before gc.collect(), enabling cyclic garbage collection. Add worker_max_memory_per_child as a safety net against memory fragmentation.
Closes#1012, Closes#1053
- Add search, channel, and status filter controls to DVR page (matching Guide/VODs pattern)
- Fix post_save signal to schedule recordings for currently-playing programs immediately instead of skipping them
- Remove duplicate evaluate_series_rules.delay() call from series rule creation endpoint to prevent race condition that created two recordings for the same program
Frontend:
- RecordingDetailsModal: fix ReferenceError crash (editing state used before declaration in useMemo — TDZ in production bundles broke all tile clicks)
- RecordingDetailsModal: fix focus-stealing on description edit by calling Movie()/Series() as plain functions instead of <Movie />/<Series />(inline component refs change every render, causing unmount/remount)
- RecordingDetailsModal: suppress sub_title in modal title after custom save
- ErrorBoundary: log caught errors and show message for easier debugging
- guideUtils: filter terminal recordings from mapRecordingsByProgramId so Guide red dots clear after recording finishes or rule is deleted
- RecordingDetailsModalUtils.test: add missing end_time to test fixtures (filterByUpcoming now uses end_time instead of start_time)
Backend:
- tasks.py: output file collision avoidance (check .mkv/.ts before writing)
- tasks.py: pre-restart TS segment concatenation (direct concat→MKV remux)
- tasks.py: recovery pipeline — recover crashed "recording" status, remux expired recordings, revoke stale tasks, deterministic task IDs, early lock release, 10-min lock TTL for large remux operations
- tasks.py: poster resolver skips external API when title matches channel name
- celery.py: trigger recover_recordings_on_startup via worker_ready signal
Tests:
- Renamed test_dvr_fixes.py → test_recording_pipeline.py (clarity)
- Renamed test_dvr_retry.py → test_db_retry.py (clarity), fixed mock paths for close_old_connections (resolved 3 test failures), replaced synthetic test
- test_recording_stop_cancel: replaced brittle time.sleep(0.5) with deterministic thread-capture pattern
- Idempotency guard: fail closed on DB errors instead of proceeding, preventing duplicate tasks from overwriting valid recordings (#641)
- Remux sanity checks: reject MKV output that is <50% of a previous MKV (duplicate-task overwrite) or <10% of the source TS (corrupt first attempt); preserve .ts for manual recovery on all failure paths (#619)
- Recurring rule timezone fix: use user's configured timezone for date window calculation instead of UTC, fixing silent recording loss for users in UTC-negative timezones after 4pm local time (#1042)
- Include .ts path in final remux failure log for easier recovery
Backend:
- Add terminal status guard to stop() endpoint -prevents overwriting completed/interrupted/failed with "stopped" (returns 409 Conflict)
- Include currently-airing programs in series rule evaluation by filtering on end_time instead of start_time
- Clean up future recordings when deleting a series rule while preserving previously recorded episodes
- Add logo proxy negative cache to prevent repeated requests to unreachable hosts from blocking Daphne workers
- Add URL validation with HEAD-then-GET fallback and per-worker cache
- Add EPG time-slot matching (80% overlap threshold) to enrich manual recordings with program metadata
- Add multi-source poster resolution pipeline (EPG, VOD, TMDB, OMDb, TVMaze, iTunes) with URL validation at each stage
- Add editable recording metadata endpoint with user_edited flag to prevent EPG auto-overwrite
- Add refresh-artwork endpoint to re-run poster resolution on demand
- Batch-fetch PeriodicTask names in schedule_upcoming to avoid N+1
Frontend:
- Fix series modal showing only one episode — preserve _group_count from categorized prop when merging with store version so the episode list renders correctly
- Fix recording card button positioning — use flex column layout with marginTop auto so buttons align at the bottom regardless of content
- Add delete fallback in RecurringRuleModal for orphaned recordings from deleted rules
- Use end_time filter in upcoming episodes list to include currently-airing episodes
- Add inline metadata editing (title/description) in details modal
- Add refresh artwork button in details modal
- Replace hardcoded /logo.png fallbacks with imported default logo
- Add React.memo with custom comparator to RecordingCard
Tests:
- Add 21 EPG time-slot matching tests (overlap threshold, edge cases)
- Add 16 recording metadata tests (validation, artwork, logo cache)
- Add 17 URL validation tests (HEAD/GET fallback, caching, eviction)
Add exponential backoff retry (_db_retry helper) for transient DB errors during final metadata save, startup recovery, and initial TS proxy
connection. Add FFmpeg remux retry for transient I/O errors. Retry same proxy base before falling back to next candidate on initial connection failure.
Fix DVR card logos not displaying: channel summary API returns logo_id (integer) but frontend expected a nested logo object with cache_url.
Add getChannelLogoUrl() helper that handles both data shapes. Backend artwork prefetch and run_recording now fall back to the channel's own logo when no show-specific artwork is found, ensuring poster_logo_id is always populated.
Prevent incorrect artwork by skipping external API searches (TVMaze, iTunes) when no program title is available - channel names like
"USA A&E SD*" returned unrelated results from fuzzy matching.
Add 13 tests for retry logic (test_dvr_retry.py)
- Add reconnection loop to DVR streaming: on transient connectivity loss (ReadTimeout, ConnectionError, ChunkedEncodingError), the recording task retries the same TS proxy base up to 5 times with a 2-second delay, appending to the existing file. Counter resets on successful data receipt. TS container format tolerates brief discontinuities; MKV remux normalises timestamps.
- Extract _check_recording_cancelled() helper to consolidate repeated stop/delete DB checks across the streaming and reconnection paths.
- Fix comskip treating exit code 1 (no commercials detected) as a fatal error.
Use check=False and handle return codes explicitly: 0 = commercials found,
1 = no commercials (skipped), negative = signal, other = real error.
- Add human-readable reason messages for comskip 'skipped' notifications in
WebSocket handler.
- Remove redundant conditional Channel import in stream_generator._cleanup() that shadowed the module-level import, causing UnboundLocalError when the conditional branch did not execute.
File changes:
Backend
apps/channels/api_views.py — Added extend() action that uses queryset .update() to bypass signals, letting the running task pick up the new end_time via polling. Moved WS recording_stopped notification to synchronous (before HTTP response). Refactored destroy() to delete DB row first, send WS event immediately, defer cleanup to background thread.
apps/channels/signals.py — Added pre_save guard to skip task revocation when status is "recording". Added post_save reentrancy guard to skip artwork prefetch for internal field-only saves (custom_properties, task_id, end_time).
apps/channels/tasks.py — Added 2-second polling loop in streaming that checks for stop, deletion, and extended end_time. Added refresh_from_db() + merge before metadata save to prevent overwriting concurrent artwork prefetch. Replaced inline PeriodicTask cleanup with revoke_task()/_dvr_task_name(). Consolidated redundant DB queries in error path. Fixed TOCTOU os.path.exists + os.remove. Preserved "stopped" status in final-status logic instead of overwriting with "completed".
core/utils.py — Made send_websocket_update gevent-safe by detecting monkey-patching and offloading async_to_sync to gevent's native threadpool, fixing the event loop deadlock that caused cross-recording interference.
apps/proxy/ts_proxy/client_manager.py — Moved _trigger_stats_update to a background thread so remove_client() returns immediately.
apps/proxy/ts_proxy/stream_generator.py — Added Redis-based health check for non-owner workers in _should_send_keepalive(), so keepalives fire correctly even when stream_manager is None.
Frontend
frontend/src/WebSocket.jsx — Added 400ms debounced scheduleRecordingFetch() replacing all inline fetchRecordings() calls. Added recording_extended and recording_updated event handlers. recording_cancelled now does surgical removeRecording() by ID when available.
frontend/src/api.js — Added extendRecording(id, extraMinutes).
frontend/src/components/cards/RecordingCard.jsx — Added Extend menu (+15m/+30m/+1h) for in-progress recordings. Widened Comskip button to stopped/interrupted statuses.
frontend/src/components/forms/ProgramRecordingModal.jsx — Removed manual fetchRecordings() calls, relying on WS-driven debounced refresh.
frontend/src/components/forms/RecordingDetailsModal.jsx — Added stopped to playable statuses. Widened Comskip button. Removed manual fetchRecordings().
frontend/src/components/forms/RecurringRuleModal.jsx — Removed all manual fetchRecordings() calls, relying on WS events.
frontend/src/pages/Guide.jsx — Removed isLoading subscription that caused loading flash on debounced refresh. Removed manual fetchRecordings() after saving a series rule.
frontend/src/store/channels.jsx — Stripped isLoading/error state from fetchRecordings to prevent full-page loading indicators. Added no-op guards in removeRecording.
frontend/src/utils/cards/RecordingCardUtils.js — Added extendRecordingById wrapper.
Tests
apps/channels/tests/test_recording_extend.py (new) — 14 tests: extend endpoint validation, stacked extensions, signal bypass, pre_save guard behavior.
apps/channels/tests/test_recording_stop_cancel.py (new) — 18 tests: stop endpoint, DVR client teardown, cancel was_in_progress flag, run_recording race guards, signal reentrancy guards.
apps/channels/tests/test_ts_proxy_keepalive.py (new) — 21 tests: keepalive owner/non-owner logic, stats update error handling, client removal non-blocking, timing invariants.
apps/channels/tests/test_recording_scheduling.py — Updated mock assertion to match new _stop_dvr_clients call signature.
Summary:
Adds an Extend action for in-progress recordings that bypasses Django signals and lets the running task dynamically pick up the new end_time via its 2-second DB polling loop. Fixes cross-recording interference caused by send_websocket_update deadlocking the gevent event loop. Fixes an artwork race condition where run_recording overwrote concurrent prefetch data. Adds Redis-based keepalive health checks for non-owner workers to prevent DVR read timeouts during source transitions. Replaces manual fetchRecordings() calls throughout the frontend with a debounced WS-driven refresh. 53 new tests.
Fixes#940: Duplicate DVR task executions were caused by ClockedSchedule objects being shared across PeriodicTasks, triggering run_recording multiple
times for the same recording. Added an idempotency guard in run_recording that exits early if status is already 'recording', 'completed', or 'stopped'.
revoke_task() now deletes the PeriodicTask and orphaned ClockedSchedule on execution rather than relying on Celery revocation alone.
Fixes#454: Added POST /api/channels/recordings/{id}/stop/ action that marks the recording as stopped and terminates only the specific DVR proxy client (identified via User-Agent: Dispatcharr-DVR/recording-{id}) without disrupting
simultaneous recordings on the same or other channels. The API returns
immediately; DVR client teardown and task revocation happen in a background thread to avoid 504 timeouts on the recordings list endpoint.
Additional changes:
- destroy() now only calls _stop_dvr_clients() for in-progress recordings, preventing accidental stream termination when deleting completed recordings
- WebSocket events differentiated: recording_stopped, recording_cancelled (in-progress cancel), and recording_cancelled (delete) with was_in_progress flag so the frontend shows distinct "Recording deleted" vs "Recording cancelled" notifications
- Frontend: Stop and Cancel split into separate buttons with confirmation
modals; stopped recordings move immediately to Previously Recorded via
optimistic status bucketing; removed dead 'Stopped' badge state
- 27 new unit tests covering scheduling, idempotency, revocation, and DVR client isolation