feat(BE): webstream

This commit is contained in:
Michael 2025-07-02 13:14:27 +02:00
parent 42dcde7fc9
commit ebe6b72efe
10 changed files with 387 additions and 75 deletions

View file

@ -5,7 +5,7 @@ namespace App\Enums;
enum PlaylistContentType: int
{
case audioclip = 0;
case stream = 1;
case webstream = 1;
case block = 2;
public static function fromName(string $name){

View file

@ -0,0 +1,33 @@
<?php
namespace App\Filters\Webstream;
use App\Filters\FiltersType\LikeFilter;
class WebstreamFilters
{
protected $filters = [
'name' => LikeFilter::class,
'description' => LikeFilter::class,
'url' => LikeFilter::class,
'mime' => LikeFilter::class,
'per_page' => null,
];
public function apply($query)
{
foreach ($this->receivedFilters() as $name => $value) {
if ($name != 'per_page') {
$filterInstance = new $this->filters[$name];
$query = $filterInstance($query, $name, $value);
}
}
return $query;
}
public function receivedFilters()
{
return request()->only(array_keys($this->filters));
}
}

View file

@ -8,6 +8,7 @@ use App\Models\File;
use App\Models\Playlist;
use App\Models\PlaylistContent;
use App\Models\SmartBlock;
use App\Models\Webstream;
use DateInterval;
use DateTime;
use Illuminate\Http\Request;
@ -41,60 +42,64 @@ class PlaylistController extends Controller
* @throws \DateMalformedIntervalStringException
*/
public function store(Request $request) {
$user = Auth::user();
$request->validate([
'name' => 'required',
'tracks' => 'required|array'
]);
$length = 0;
$dbPlaylist = Playlist::create([
'name' => $request->get('name'),
'creator_id' => $user->id,
'description' => $request->description,
]);
// dd($request->tracks);
foreach($request->tracks as $key => $file) {
if (!isset($file['id'])) {
$file = $file['db_element'];
}
switch ($file['type']) {
case 'audioclip':
$model = File::whereId($file['id'])->first();
break;
case 'block':
$model = SmartBlock::whereId($file['id'])->first();
break;
case 'stream':
//Todo: $model = Stream::whereId($file['id'])->first();
break;
}
$modelTime = new LengthFormatter($model->length);
$modelCuein = new LengthFormatter($model->cuein);
$modelCueout = new LengthFormatter($model->cueout);
$length = $length + $modelTime->toSeconds();
PlaylistContent::create([
'playlist_id' => $dbPlaylist->id,
'file_id' => ($file['type'] === 'audioclip') ? $file['id'] : null,
'block_id' => ($file['type'] === 'block') ? $file['id'] : null,
'stream_id' => ($file['type'] === 'stream') ? $file['id'] : null,
'type' => PlaylistContentType::fromName($file['type']),
'position' => $key,
'trackoffset' => 0, //ToDo: understand this field
'cliplength' => $modelTime->toSeconds(),
'cuein' => $modelCuein->toSeconds() ?? 0,
'cueout' => $modelCueout->toSeconds() ?? $modelTime->toSeconds(),
'fadein' => '00:00:00',
'fadeout' => '00:00:00',
try{
$user = Auth::user();
$request->validate([
'name' => 'required',
'tracks' => 'required|array'
]);
$length = 0;
$dbPlaylist = Playlist::create([
'name' => $request->get('name'),
'creator_id' => $user->id,
'description' => $request->description,
]);
// dd($request->tracks);
foreach($request->tracks as $key => $file) {
if (!isset($file['id'])) {
$file = $file['db_element'];
}
switch ($file['type']) {
case 'audioclip':
$model = File::whereId($file['id'])->first();
break;
case 'block':
$model = SmartBlock::whereId($file['id'])->first();
break;
case 'webstream':
$model = Webstream::whereId($file['id'])->first();
break;
}
$modelTime = new LengthFormatter($model->length);
$modelCuein = new LengthFormatter($model->cuein);
$modelCueout = new LengthFormatter($model->cueout);
$length = $length + $modelTime->toSeconds();
PlaylistContent::create([
'playlist_id' => $dbPlaylist->id,
'file_id' => ($file['type'] === 'audioclip') ? $file['id'] : null,
'block_id' => ($file['type'] === 'block') ? $file['id'] : null,
'stream_id' => ($file['type'] === 'webstream') ? $file['id'] : null,
'type' => PlaylistContentType::fromName($file['type']),
'position' => $key,
'trackoffset' => 0, //ToDo: understand this field
'cliplength' => $modelTime->toSeconds(),
'cuein' => $modelCuein->toSeconds() ?? 0,
'cueout' => $modelCueout->toSeconds() ?? $modelTime->toSeconds(),
'fadein' => '00:00:00',
'fadeout' => '00:00:00',
]);
}
$dbPlaylist->update(['length' => $length]);
return $dbPlaylist->with('tracks')->get()->toJson();
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
$dbPlaylist->update(['length' => $length]);
return $dbPlaylist->with('tracks')->get()->toJson();
}
/**
@ -141,7 +146,7 @@ class PlaylistController extends Controller
];
break;
case 'stream':
//Todo: $model = Stream::whereId($file['id'])->first();
$model = Webstream::whereId($file['id'])->first();
$data = [
'playlist_id' => $playlist->id,
'stream_id' => $file['id'],

View file

@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers;
use App\Filters\Webstream\WebstreamFilters;
use App\Models\Webstream;
use App\Services\WebstreamService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
use Throwable;
class WebstreamController extends Controller
{
public function __construct(WebstreamService $webstreamService)
{
$this->webstreamService = $webstreamService;
}
public function index(WebstreamFilters $filters)
{
try {
if ( ! isset($filters->per_page) || is_null($filters)) {
$pagination = 20;
} else {
$pagination = $filters->per_page;
}
return Webstream::searchFilter($filters)->cursorPaginate($pagination)->toJson();
} catch (Exception $e) {
Log::error($e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);
}
}
public function show(Request $request, $webstreamId)
{
try {
$webstream = Webstream::findOrFail($webstreamId);
return response()->json($webstream);
} catch (Exception $e) {
DB::rollBack();
Log::error($e->getMessage());
return response()->json(['message' => 'Webstream not found: ' . $e->getMessage()], 404);
}
}
public function store(Request $request)
{
try {
# Todo length field needs to be validated against a type
$data = $request->validate([
'name' => ['required'],
'description' => ['required'],
'url' => ['required', 'url'],
'length' => ['required'],
]);
$isAudioStream = $this->webstreamService->isAudioStream($data['url']);
# TODO Permissions
$data['mtime'] = now();
$data['utime'] = now();
$data['creator_id'] = auth()->id();
DB::beginTransaction();
$webstream = Webstream::create($data);
DB::commit();
return response()->json([
'status' => 'success',
'message' => 'Webstream saved successfully!',
'data' => $webstream,
'isAudioStream' => $isAudioStream
]);
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
return response()->json(['message' => $e->getMessage()], 500);
} catch (Throwable $e) {
DB::rollBack();
Log::error($e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);
}
}
public function update(Request $request, Webstream $webstream)
{
try {
$data = $request->validate([
'name' => ['required'],
'description' => ['required'],
'url' => ['required', 'url'],
]);
$isAudioStream = $this->webstreamService->isAudioStream($data['url']);
// Update utime to current time
$data['utime'] = now();
DB::beginTransaction();
$webstream->update($data);
DB::commit();
return response()->json([
'status' => 'success',
'message' => 'Webstream updated successfully!',
'data' => $webstream,
'isAudioStream' => $isAudioStream
]);
} catch (Exception $e) {
DB::rollBack();
Log::error($e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);
} catch (Throwable $e) {
DB::rollBack();
Log::error($e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);
}
}
public function destroy(Webstream $webstream)
{
try {
DB::beginTransaction();
$webstream->delete();
DB::commit();
return response()->json([
'status' => 'success',
'message' => 'Webstream deleted successfully!'
]);
} catch (Exception $e) {
DB::rollBack();
Log::error($e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);
} catch (Throwable $e) {
DB::rollBack();
Log::error($e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);
}
}
}

View file

@ -40,7 +40,7 @@ class PlaylistContent extends Model
return $this->belongsTo(SmartBlock::class, 'block_id');
}
public function stream() {
//ToDo create belongsTo relationship after create stream model
public function webstream() {
return $this->belongsTo(Webstream::class, 'stream_id');
}
}

View file

@ -25,6 +25,7 @@ class Schedule extends Model
'broadcasted',
'position',
'file_id',
'stream_id',
'instance_id'
];
@ -34,13 +35,18 @@ class Schedule extends Model
'media_item_played' => 'boolean',
];
public function ccFile(): BelongsTo
public function file(): BelongsTo
{
return $this->belongsTo(File::class, 'cc_file_id');
return $this->belongsTo(File::class, 'file_id');
}
public function ccShowInstance(): BelongsTo
public function webstream(): BelongsTo
{
return $this->belongsTo(ShowInstances::class, 'cc_show_instance_id');
return $this->belongsTo(Webstream::class, 'stream_id');
}
public function showInstance(): BelongsTo
{
return $this->belongsTo(ShowInstances::class, 'show_instance_id');
}
}

35
app/Models/Webstream.php Normal file
View file

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Webstream extends Model
{
public $timestamps = false;
protected $table = 'cc_webstream';
protected $fillable = [
'name',
'description',
'url',
'length',
'creator_id',
'mtime',
'utime',
'lptime',
'mime',
];
protected $casts = [
'mtime' => 'timestamp',
'utime' => 'timestamp',
'lptime' => 'timestamp',
];
public function scopeSearchFilter($query, $filters)
{
return $filters->apply($query);
}
}

View file

@ -50,15 +50,14 @@ class ScheduleService
->where('ends', '<=', $showInstance->ends)
->get()
->keyBy(function ($item) {
return $item->starts->timestamp . '_' . $item->ends->timestamp . '_' . $item->file_id;
return $item->starts->timestamp . '_' . $item->ends->timestamp;
});
// Compare and insert only new entries
$newItems = collect($this->prepareScheduleData($scheduleItems))
->filter(function ($item) use ($existingSchedule) {
$key = Carbon::parse($item['starts'])->timestamp . '_' .
Carbon::parse($item['ends'])->timestamp . '_' .
$item['file_id'];
Carbon::parse($item['ends'])->timestamp;
return !$existingSchedule->has($key);
})
->toArray();
@ -117,7 +116,7 @@ class ScheduleService
$nextContent = $playlistContent[$playlistIndex];
$contentType = $this->resolveContentType($nextContent->type);
$content = $nextContent->$contentType()->first();
$nextLength = LengthFormatter::contentLengthToSeconds($content->length);
//$nextLength = LengthFormatter::contentLengthToSeconds($content->length);
$result = $this->managePlaylistContentEntry(
$playlistContent[$playlistIndex],
@ -153,19 +152,29 @@ class ScheduleService
$contentLength = LengthFormatter::contentLengthToSeconds($content->length);
$contentFinalLength = $this->calculateFinalLength($currentTime, $contentLength, $endTime);
$fileId = null;
$webstreamId = null;
if ($contentType === 'file') {
$content->update(['is_scheduled' => true]);
$fileId = $content->id;
}
if ($contentType === 'webstream') {
$webstreamId = $content->id;
}
$schedule = $this->createScheduleItem(
$currentTime,
$contentLength,
$contentFinalLength,
$playlistContentEntry,
$content->id,
$fileId,
$webstreamId,
$showInstanceId
);
if ($contentType === 'file') {
$content->update(['is_scheduled' => true]);
}
return [
'currentTime' => $currentTime->addSeconds($contentLength),
'schedule' => $schedule
@ -176,7 +185,7 @@ class ScheduleService
{
return match (PlaylistContentType::from($type)) {
PlaylistContentType::audioclip => 'file',
PlaylistContentType::stream => 'stream',
PlaylistContentType::webstream => 'webstream',
PlaylistContentType::block => 'block',
default => throw new InvalidArgumentException('Invalid content type'),
};
@ -194,7 +203,8 @@ class ScheduleService
int $contentLength,
int $contentDuration,
PlaylistContent $playlistContentEntry,
int $contentId,
int | null $fileId,
int | null $webstreamId,
int $showInstanceId
): Schedule {
return new Schedule([
@ -209,7 +219,8 @@ class ScheduleService
'playout_status' => 0,
'broadcasted' => self::BROADCAST_STATUS_PENDING,
'position' => $playlistContentEntry->position,
'file_id' => $contentId,
'file_id' => $fileId,
'stream_id' => $webstreamId,
'instance_id' => $showInstanceId
]);
}
@ -219,7 +230,7 @@ class ScheduleService
return collect($scheduleItems)->map(fn (Schedule $schedule) => $schedule->only([
'starts', 'ends', 'clip_length', 'fade_in', 'fade_out',
'cue_in', 'cue_out', 'media_item_played', 'playout_status',
'broadcasted', 'position', 'instance_id', 'file_id'
'broadcasted', 'position', 'instance_id', 'file_id', 'stream_id'
]))->toArray();
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Services;
use Exception;
class WebstreamService
{
public function __construct()
{
}
/**
* @param $headers
* @param $headerAttribute
* @param $acceptedValues
*
* @return bool
*/
private function checkHttpHeaders($headers, $headerAttribute, $acceptedValues): bool
{
if (isset($headers[$headerAttribute])) {
$contentType = is_array($headers[$headerAttribute])
? $headers[$headerAttribute][0]
: $headers[$headerAttribute];
$contentType = strtolower($contentType);
foreach ($acceptedValues as $acceptedValue) {
if (str_contains($contentType, $acceptedValue)) {
return true;
}
}
}
return false;
}
/**
* Check if URL is a valid audio stream
*
* @param string $url
*
* @return array{server: bool, audio: bool, streamingServer: bool}
*/
public function isAudioStream(string $url): array
{
$acceptedContentTypes = ['mpeg', 'mp3', 'aac', 'ogg'];
$acceptedServers = ['icecast', 'shoutcast', 'rocket', 'rsas', 'azura', 'ampache'];
$audioCheckResults = ['server' => false, 'audio' => false, 'streamingServer' => false];
try {
$headers = get_headers($url, 1);
if ( ! $headers) {
return $audioCheckResults;
}
$audioCheckResults['server'] = true;
$audioCheckResults['audio'] = $this->checkHttpHeaders($headers,'Content-Type',$acceptedContentTypes);
$audioCheckResults['streamingServer'] = $this->checkHttpHeaders($headers,'Server',$acceptedServers);
return $audioCheckResults;
} catch (Exception $e) {
return $audioCheckResults;
}
}
}

View file

@ -77,8 +77,11 @@ trait ShowInstancesTrait
public function manageShowInstances(Show $show)
{
try {
$generationLimitDate = Preference::where('keystr', 'shows_populated_until')->value('valstr');
$generationLimitDate = Carbon::createFromFormat('Y-m-d H:i:s', $generationLimitDate);
$generationLimitDateSetting = Preference::where('keystr', 'shows_populated_until')->value('valstr');
if (empty($generationLimitDateSetting)) {
$generationLimitDateSetting = Carbon::now()->addYears(3)->format('Y-m-d H:i:s');
}
$generationLimitDate = Carbon::createFromFormat('Y-m-d H:i:s', $generationLimitDateSetting);
$showInstances = [];
foreach ($show->showDays as $showDay) {
try {