Skip to content

Commit a074358

Browse files
committed
recording processing
1 parent de3863d commit a074358

22 files changed

+2254
-152
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,6 @@ DNS_KEY_NAME=stream-ddns
104104
DNS_KEY_ALGORITHM=hmac-sha256
105105
DNS_KEY_SECRET=
106106
DNS_TTL=60
107+
108+
# Recording API Authentication
109+
RECORDING_API_KEY=your-secure-api-key-here
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Jobs\ProcessRecordingJob;
6+
use App\Models\Recording;
7+
use App\Services\RecordingService;
8+
use Illuminate\Console\Command;
9+
10+
class ProcessRecordings extends Command
11+
{
12+
/**
13+
* The name and signature of the console command.
14+
*
15+
* @var string
16+
*/
17+
protected $signature = 'recordings:process
18+
{--id= : Process a specific recording by ID}
19+
{--all : Process all recordings without duration or thumbnail}
20+
{--queue : Use queue for processing}';
21+
22+
/**
23+
* The console command description.
24+
*
25+
* @var string
26+
*/
27+
protected $description = 'Process recordings to extract duration and generate thumbnails';
28+
29+
/**
30+
* Execute the console command.
31+
*/
32+
public function handle(RecordingService $recordingService)
33+
{
34+
// Check if ffmpeg is available
35+
if (! $recordingService->isFFmpegAvailable()) {
36+
$this->error('FFmpeg and FFprobe are required but not found in system PATH.');
37+
38+
return 1;
39+
}
40+
41+
// Process specific recording by ID
42+
if ($recordingId = $this->option('id')) {
43+
$recording = Recording::find($recordingId);
44+
if (! $recording) {
45+
$this->error("Recording with ID {$recordingId} not found.");
46+
47+
return 1;
48+
}
49+
50+
$this->info("Processing recording: {$recording->title}");
51+
52+
if ($this->option('queue')) {
53+
ProcessRecordingJob::dispatch($recording);
54+
$this->info("Job dispatched for recording {$recordingId}");
55+
} else {
56+
$recordingService->processRecording($recording);
57+
$this->info("Recording {$recordingId} processed successfully.");
58+
}
59+
60+
return 0;
61+
}
62+
63+
// Process all recordings without duration or thumbnail
64+
if ($this->option('all')) {
65+
$recordings = Recording::where(function ($query) {
66+
$query->whereNull('duration')
67+
->orWhereNull('thumbnail_path');
68+
})->get();
69+
70+
if ($recordings->isEmpty()) {
71+
$this->info('No recordings need processing.');
72+
73+
return 0;
74+
}
75+
76+
$this->info("Found {$recordings->count()} recordings to process.");
77+
$bar = $this->output->createProgressBar($recordings->count());
78+
$bar->start();
79+
80+
foreach ($recordings as $recording) {
81+
if ($this->option('queue')) {
82+
ProcessRecordingJob::dispatch($recording);
83+
} else {
84+
$recordingService->processRecording($recording);
85+
}
86+
$bar->advance();
87+
}
88+
89+
$bar->finish();
90+
$this->newLine();
91+
92+
if ($this->option('queue')) {
93+
$this->info("Jobs dispatched for {$recordings->count()} recordings.");
94+
} else {
95+
$this->info("{$recordings->count()} recordings processed successfully.");
96+
}
97+
98+
return 0;
99+
}
100+
101+
// Show usage if no options provided
102+
$this->info('Usage:');
103+
$this->line(' Process specific recording: php artisan recordings:process --id=1');
104+
$this->line(' Process all unprocessed: php artisan recordings:process --all');
105+
$this->line(' Process using queue: php artisan recordings:process --all --queue');
106+
107+
return 0;
108+
}
109+
}

app/Filament/Resources/RecordingResource.php

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,30 @@ public static function form(Form $form): Form
3636
Forms\Components\TextInput::make('duration')
3737
->numeric()
3838
->suffix('seconds')
39-
->helperText('Duration in seconds'),
39+
->helperText('Duration in seconds (will be auto-filled via ffmpeg if left empty)')
40+
->nullable(),
4041
Forms\Components\TextInput::make('m3u8_url')
4142
->label('M3U8 URL')
4243
->required()
4344
->url()
4445
->columnSpanFull()
4546
->helperText('URL to the HLS playlist file'),
46-
Forms\Components\TextInput::make('thumbnail_url')
47-
->label('Thumbnail URL')
48-
->url()
49-
->columnSpanFull(),
47+
Forms\Components\FileUpload::make('thumbnail_path')
48+
->label('Thumbnail')
49+
->image()
50+
->imageResizeMode('cover')
51+
->imageResizeTargetWidth(1280)
52+
->imageResizeTargetHeight(720)
53+
->disk('s3')
54+
->directory('recordings/thumbnails')
55+
->visibility('private')
56+
->columnSpanFull()
57+
->helperText('Upload a thumbnail or leave empty to auto-generate from first frame')
58+
->loadStateFromRelationshipsUsing(static function (Forms\Components\FileUpload $component, ?Recording $record): void {
59+
if ($record && $record->thumbnail_path) {
60+
$component->state($record->thumbnail_path);
61+
}
62+
}),
5063
Forms\Components\Toggle::make('is_published')
5164
->label('Published')
5265
->default(true)
@@ -58,6 +71,10 @@ public static function table(Table $table): Table
5871
{
5972
return $table
6073
->columns([
74+
Tables\Columns\ImageColumn::make('thumbnail_url')
75+
->label('Thumbnail')
76+
->size(80)
77+
->height(45),
6178
Tables\Columns\TextColumn::make('title')
6279
->searchable()
6380
->sortable(),
@@ -66,13 +83,16 @@ public static function table(Table $table): Table
6683
->sortable(),
6784
Tables\Columns\TextColumn::make('duration')
6885
->formatStateUsing(function ($state) {
69-
if (!$state) return '-';
86+
if (! $state) {
87+
return '-';
88+
}
7089
$hours = floor($state / 3600);
7190
$minutes = floor(($state % 3600) / 60);
7291
$seconds = $state % 60;
7392
if ($hours > 0) {
7493
return sprintf('%d:%02d:%02d', $hours, $minutes, $seconds);
7594
}
95+
7696
return sprintf('%d:%02d', $minutes, $seconds);
7797
})
7898
->label('Duration'),
@@ -118,4 +138,4 @@ public static function getPages(): array
118138
'edit' => Pages\EditRecording::route('/{record}/edit'),
119139
];
120140
}
121-
}
141+
}

app/Filament/Resources/ShowResource.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,12 @@ public static function form(Form $form): Form
9696
->label('Actual Start')
9797
->seconds(false)
9898
->timezone('Europe/Berlin')
99-
->disabled()
100-
->dehydrated(),
99+
->helperText('Set the actual start time when the show went live'),
101100
DateTimePicker::make('actual_end')
102101
->label('Actual End')
103102
->seconds(false)
104103
->timezone('Europe/Berlin')
105-
->disabled()
106-
->dehydrated(),
104+
->helperText('Set the actual end time when the show ended'),
107105
])
108106
->columns(2),
109107

@@ -123,6 +121,10 @@ public static function form(Form $form): Form
123121
->label('Auto Mode')
124122
->helperText('When enabled, show will automatically start/end based on source status and scheduled times')
125123
->hint('Show starts when source goes online after scheduled start, ends when source goes offline after scheduled end'),
124+
Toggle::make('recordable')
125+
->label('Recordable')
126+
->helperText('Enable recording for this show')
127+
->hint('When enabled, this show will be available for recording processing'),
126128
FileUpload::make('thumbnail_path')
127129
->label('Thumbnail')
128130
->image()
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\Recording;
7+
use App\Models\Show;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Str;
10+
11+
class RecordingApiController extends Controller
12+
{
13+
/**
14+
* Get shows that need to be recorded.
15+
* Returns shows where recordable=true, actual_start and actual_end are set, and no recording exists.
16+
*/
17+
public function shows()
18+
{
19+
$shows = Show::with('source')
20+
->where('recordable', true)
21+
->whereNotNull('actual_start')
22+
->whereNotNull('actual_end')
23+
->whereDoesntHave('recording')
24+
->get()
25+
->map(function ($show) {
26+
return [
27+
'show_id' => $show->id,
28+
'source' => $show->source->slug ?? null,
29+
'show' => $show->slug,
30+
'start' => $show->actual_start->toIso8601String(),
31+
'end' => $show->actual_end->toIso8601String(),
32+
'title' => $show->title,
33+
'description' => $show->description,
34+
];
35+
});
36+
37+
return response()->json([
38+
'success' => true,
39+
'data' => $shows,
40+
'count' => $shows->count()
41+
]);
42+
}
43+
44+
/**
45+
* Create a new recording.
46+
*/
47+
public function create(Request $request)
48+
{
49+
$validated = $request->validate([
50+
'show_id' => 'nullable|exists:shows,id',
51+
'title' => 'required|string|max:255',
52+
'slug' => 'nullable|string|max:255|unique:recordings,slug',
53+
'description' => 'nullable|string',
54+
'm3u8_url' => 'required|url',
55+
'duration' => 'nullable|integer|min:0',
56+
'date' => 'nullable|date',
57+
'is_published' => 'nullable|boolean',
58+
]);
59+
60+
// If show_id is provided, get the show details
61+
if (!empty($validated['show_id'])) {
62+
$show = Show::find($validated['show_id']);
63+
64+
// Use show details if not provided
65+
if (empty($validated['date']) && $show->actual_start) {
66+
$validated['date'] = $show->actual_start;
67+
}
68+
69+
// Calculate duration if not provided
70+
if (empty($validated['duration']) && $show->actual_start && $show->actual_end) {
71+
$validated['duration'] = $show->actual_start->diffInSeconds($show->actual_end);
72+
}
73+
74+
// Use show description if not provided
75+
if (empty($validated['description']) && $show->description) {
76+
$validated['description'] = $show->description;
77+
}
78+
}
79+
80+
// Generate slug if not provided
81+
if (empty($validated['slug'])) {
82+
$baseSlug = Str::slug($validated['title']);
83+
$slug = $baseSlug;
84+
$count = 1;
85+
86+
while (Recording::where('slug', $slug)->exists()) {
87+
$slug = $baseSlug . '-' . $count;
88+
$count++;
89+
}
90+
91+
$validated['slug'] = $slug;
92+
}
93+
94+
// Default to published
95+
if (!isset($validated['is_published'])) {
96+
$validated['is_published'] = true;
97+
}
98+
99+
// Default date to now if not provided
100+
if (empty($validated['date'])) {
101+
$validated['date'] = now();
102+
}
103+
104+
// Create the recording
105+
$recording = Recording::create($validated);
106+
107+
return response()->json([
108+
'success' => true,
109+
'data' => $recording,
110+
'message' => 'Recording created successfully'
111+
], 201);
112+
}
113+
114+
/**
115+
* Get a recording by slug.
116+
*/
117+
public function getBySlug($slug)
118+
{
119+
$recording = Recording::where('slug', $slug)
120+
->where('is_published', true)
121+
->firstOrFail();
122+
123+
return response()->json([
124+
'success' => true,
125+
'data' => $recording
126+
]);
127+
}
128+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use Symfony\Component\HttpFoundation\Response;
8+
9+
class CheckRecordingApiKeyMiddleware
10+
{
11+
/**
12+
* Handle an incoming request.
13+
*
14+
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
15+
*/
16+
public function handle(Request $request, Closure $next): Response
17+
{
18+
// Check for API key in header first, then fall back to request parameter
19+
$apiKey = $request->header('X-Recording-Api-Key') ?: $request->get('api_key');
20+
21+
// Get the expected API key from environment
22+
$expectedApiKey = config('app.recording_api_key', env('RECORDING_API_KEY'));
23+
24+
// Check if API key matches
25+
if (empty($expectedApiKey) || $apiKey !== $expectedApiKey) {
26+
return response()->json([
27+
'error' => 'Unauthorized',
28+
'message' => 'Invalid or missing API key'
29+
], 401);
30+
}
31+
32+
return $next($request);
33+
}
34+
}

0 commit comments

Comments
 (0)