DVR Developer Guide
Overview
The Phlix DVR system provides scheduled and series-based recording capabilities built on top of the existing Recorder.php framework. This guide covers the architecture, configuration, and integration points.
Architecture
Core Components
SeriesRuleManager (
src/LiveTv/Recording/SeriesRuleManager.php)- CRUD operations for series recording rules
matchAndSchedule()- queries upcoming EPG data and schedules recordings
RecordingDeduplicator (
src/LiveTv/Recording/RecordingDeduplicator.php)- Prevents duplicate recordings within a 2-hour time window
isDuplicate()- checks if program already scheduled/recordedresolveDuplicates()- cancels lower-priority duplicates
RecordingScheduler (
src/LiveTv/Recording/RecordingScheduler.php)- Priority-based conflict resolution for tuner allocation
processDueRecordings()- runs every minute via Workerman timergetNextRecording()- returns upcoming recording for display
RecordingHooksRunner (
src/LiveTv/Recording/RecordingHooksRunner.php)- Async post-recording hook execution (Comskip, etc.)
Database Schema
livetv_series_rules table
CREATE TABLE livetv_series_rules (
rule_id CHAR(36) PRIMARY KEY,
series_id VARCHAR(255) NOT NULL,
channel_id CHAR(36) NULL, -- NULL = any channel
title VARCHAR(255) NOT NULL,
priority INT NOT NULL DEFAULT 5, -- 1=low, 5=normal, 10=high
pre_padding_seconds INT NOT NULL DEFAULT 60,
post_padding_seconds INT NOT NULL DEFAULT 60,
max_recordings INT NULL, -- NULL = unlimited
days_ahead INT NOT NULL DEFAULT 14,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_series_id (series_id),
INDEX idx_is_active (is_active),
INDEX idx_channel_id (channel_id)
);livetv_recordings additions
ALTER TABLE livetv_recordings ADD COLUMN (
series_rule_id CHAR(36) NULL,
duplicate_group CHAR(36) NULL,
pre_padding_seconds INT NOT NULL DEFAULT 60,
post_padding_seconds INT NOT NULL DEFAULT 60,
scheduled_by_rule CHAR(36) NULL
);Configuration
config/livetv.php - DVR section
'dvr' => [
'enabled' => true,
'storage_path' => '/var/recordings',
'max_storage_bytes' => 0, // 0 = unlimited
'default_pre_padding_seconds' => 60, // Start 60s early
'default_post_padding_seconds' => 60, // End 60s late
'auto_resolution' => true, // Auto-start when tuner free
],Series Rule Management
Creating a Series Rule
$ruleManager->createRule('series_tms_id', 'channel_id', [
'title' => 'My Favorite Show',
'priority' => Recorder::PRIORITY_NORMAL,
'pre_padding_seconds' => 120, // 2 minutes pre-padding
'post_padding_seconds' => 60, // 1 minute post-padding
'max_recordings' => 10, // Keep last 10 episodes
'days_ahead' => 14, // Schedule 14 days ahead
]);Scheduling Recordings from Rules
// Called periodically (e.g., every hour via Workerman timer)
$guideManager = $container->get(GuideManager::class);
$stats = $ruleManager->matchAndSchedule($guideManager);
echo "Scheduled: {$stats['scheduled']}, "
. "Skipped: {$stats['skipped']}, "
. "Errors: {$stats['errors']}";Conflict Resolution
When multiple recordings are due simultaneously:
- Priority sorting - Higher priority rules record first
- Start time - Earlier start_time wins tiebreaker
- Tuner availability - If no tuner free, recording is skipped
Tuner Conflict Example
Rule A (priority=10): "News at 6" - Channel 4, 6:00 PM
Rule B (priority=5): "Movie" - Channel 4, 6:00 PM
If only 1 tuner:
-> Rule A wins, Movie is skipped (or rescheduled)
If 2 tuners:
-> Both record simultaneouslyPre/Post Padding
Recordings automatically start and end with configurable padding:
Actual Program: 6:00 PM - 7:00 PM
Pre-padding: 2 minutes -> Recording starts at 5:58 PM
Post-padding: 1 minute -> Recording ends at 7:01 PMThe startRecording() method applies pre-padding:
$effectiveStart = $recording['start_time'] - $recording['pre_padding_seconds'];Deduplication
The RecordingDeduplicator prevents recording the same episode twice:
- Uses 2-hour time window by default
- Groups recordings via
duplicate_grouphash (MD5 of program_id + channel_id) isDuplicate()called before scheduling new recordingsresolveDuplicates()cancels lower-priority recordings in same group
Manual Deduplication
$deduplicator->resolveDuplicates('prefer_rule_id');Scheduler Integration
The RecordingScheduler should be registered with a Workerman timer:
// In your Application bootstrap
use Workerman\Timer;
$scheduler = $container->get(RecordingScheduler::class);
// Run every 60 seconds
Timer::add(60, function () use ($scheduler) {
$stats = $scheduler->processDueRecordings();
if ($stats['started'] > 0 || $stats['skipped'] > 0) {
echo "DVR: {$stats['started']} started, {$stats['skipped']} skipped\n";
}
});Comskip Integration
Post-recording hooks are already wired via RecordingHooks:
// In your bootstrap
$comskipProcessor = $container->get(ComskipPostProcessor::class);
RecordingHooks::register($recorder, $comskipProcessor);When a recording completes:
Recorder::stopRecording()firesonCompletecallbacksComskipPostProcessor::processRecording()is called- Comskip runs on the .ts file, generates .edl
- Commercial chapters stored via
MarkerService
API Endpoints (Future)
Planned REST endpoints for series rules:
POST /api/v1/dvr/rules - Create series rule
GET /api/v1/dvr/rules - List all rules
GET /api/v1/dvr/rules/{id} - Get specific rule
PUT /api/v1/dvr/rules/{id} - Update rule
DELETE /api/v1/dvr/rules/{id} - Delete rule
POST /api/v1/dvr/rules/{id}/match - Trigger manual match
GET /api/v1/dvr/recordings - List recordings
GET /api/v1/dvr/recordings/{id} - Get recording details
DELETE /api/v1/dvr/recordings/{id} - Cancel/delete recordingTesting
Run the DVR unit tests:
./vendor/bin/phpunit tests/unit/LiveTv/Recording/Coverage targets:
SeriesRuleManager≥ 85%RecordingDeduplicator≥ 85%RecordingScheduler≥ 80%
Migration
After updating the codebase, run migrations:
php scripts/run-migrations.phpThis applies migrations/013_livetv_dvr.sql which:
- Adds series_rule_id, duplicate_group, pre/post_padding to livetv_recordings
- Creates livetv_series_rules table
Error Handling
All components log errors via PSR-3 logger:
$logger->error('Failed to schedule recording', [
'rule_id' => $ruleId,
'program_id' => $programId,
'error' => $e->getMessage(),
]);Common failure scenarios:
- No tuner available (tuner conflict)
- Insufficient storage space
- Program no longer exists in guide
- Database connection failure