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 enum PlaylistContentType: int
{ {
case audioclip = 0; case audioclip = 0;
case stream = 1; case webstream = 1;
case block = 2; case block = 2;
public static function fromName(string $name){ 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\Playlist;
use App\Models\PlaylistContent; use App\Models\PlaylistContent;
use App\Models\SmartBlock; use App\Models\SmartBlock;
use App\Models\Webstream;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -41,6 +42,7 @@ class PlaylistController extends Controller
* @throws \DateMalformedIntervalStringException * @throws \DateMalformedIntervalStringException
*/ */
public function store(Request $request) { public function store(Request $request) {
try{
$user = Auth::user(); $user = Auth::user();
$request->validate([ $request->validate([
'name' => 'required', 'name' => 'required',
@ -66,8 +68,8 @@ class PlaylistController extends Controller
case 'block': case 'block':
$model = SmartBlock::whereId($file['id'])->first(); $model = SmartBlock::whereId($file['id'])->first();
break; break;
case 'stream': case 'webstream':
//Todo: $model = Stream::whereId($file['id'])->first(); $model = Webstream::whereId($file['id'])->first();
break; break;
} }
$modelTime = new LengthFormatter($model->length); $modelTime = new LengthFormatter($model->length);
@ -80,7 +82,7 @@ class PlaylistController extends Controller
'playlist_id' => $dbPlaylist->id, 'playlist_id' => $dbPlaylist->id,
'file_id' => ($file['type'] === 'audioclip') ? $file['id'] : null, 'file_id' => ($file['type'] === 'audioclip') ? $file['id'] : null,
'block_id' => ($file['type'] === 'block') ? $file['id'] : null, 'block_id' => ($file['type'] === 'block') ? $file['id'] : null,
'stream_id' => ($file['type'] === 'stream') ? $file['id'] : null, 'stream_id' => ($file['type'] === 'webstream') ? $file['id'] : null,
'type' => PlaylistContentType::fromName($file['type']), 'type' => PlaylistContentType::fromName($file['type']),
'position' => $key, 'position' => $key,
'trackoffset' => 0, //ToDo: understand this field 'trackoffset' => 0, //ToDo: understand this field
@ -95,6 +97,9 @@ class PlaylistController extends Controller
$dbPlaylist->update(['length' => $length]); $dbPlaylist->update(['length' => $length]);
return $dbPlaylist->with('tracks')->get()->toJson(); return $dbPlaylist->with('tracks')->get()->toJson();
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
} }
/** /**
@ -141,7 +146,7 @@ class PlaylistController extends Controller
]; ];
break; break;
case 'stream': case 'stream':
//Todo: $model = Stream::whereId($file['id'])->first(); $model = Webstream::whereId($file['id'])->first();
$data = [ $data = [
'playlist_id' => $playlist->id, 'playlist_id' => $playlist->id,
'stream_id' => $file['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'); return $this->belongsTo(SmartBlock::class, 'block_id');
} }
public function stream() { public function webstream() {
//ToDo create belongsTo relationship after create stream model return $this->belongsTo(Webstream::class, 'stream_id');
} }
} }

View file

@ -25,6 +25,7 @@ class Schedule extends Model
'broadcasted', 'broadcasted',
'position', 'position',
'file_id', 'file_id',
'stream_id',
'instance_id' 'instance_id'
]; ];
@ -34,13 +35,18 @@ class Schedule extends Model
'media_item_played' => 'boolean', '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) ->where('ends', '<=', $showInstance->ends)
->get() ->get()
->keyBy(function ($item) { ->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 // Compare and insert only new entries
$newItems = collect($this->prepareScheduleData($scheduleItems)) $newItems = collect($this->prepareScheduleData($scheduleItems))
->filter(function ($item) use ($existingSchedule) { ->filter(function ($item) use ($existingSchedule) {
$key = Carbon::parse($item['starts'])->timestamp . '_' . $key = Carbon::parse($item['starts'])->timestamp . '_' .
Carbon::parse($item['ends'])->timestamp . '_' . Carbon::parse($item['ends'])->timestamp;
$item['file_id'];
return !$existingSchedule->has($key); return !$existingSchedule->has($key);
}) })
->toArray(); ->toArray();
@ -117,7 +116,7 @@ class ScheduleService
$nextContent = $playlistContent[$playlistIndex]; $nextContent = $playlistContent[$playlistIndex];
$contentType = $this->resolveContentType($nextContent->type); $contentType = $this->resolveContentType($nextContent->type);
$content = $nextContent->$contentType()->first(); $content = $nextContent->$contentType()->first();
$nextLength = LengthFormatter::contentLengthToSeconds($content->length); //$nextLength = LengthFormatter::contentLengthToSeconds($content->length);
$result = $this->managePlaylistContentEntry( $result = $this->managePlaylistContentEntry(
$playlistContent[$playlistIndex], $playlistContent[$playlistIndex],
@ -153,19 +152,29 @@ class ScheduleService
$contentLength = LengthFormatter::contentLengthToSeconds($content->length); $contentLength = LengthFormatter::contentLengthToSeconds($content->length);
$contentFinalLength = $this->calculateFinalLength($currentTime, $contentLength, $endTime); $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( $schedule = $this->createScheduleItem(
$currentTime, $currentTime,
$contentLength, $contentLength,
$contentFinalLength, $contentFinalLength,
$playlistContentEntry, $playlistContentEntry,
$content->id, $fileId,
$webstreamId,
$showInstanceId $showInstanceId
); );
if ($contentType === 'file') {
$content->update(['is_scheduled' => true]);
}
return [ return [
'currentTime' => $currentTime->addSeconds($contentLength), 'currentTime' => $currentTime->addSeconds($contentLength),
'schedule' => $schedule 'schedule' => $schedule
@ -176,7 +185,7 @@ class ScheduleService
{ {
return match (PlaylistContentType::from($type)) { return match (PlaylistContentType::from($type)) {
PlaylistContentType::audioclip => 'file', PlaylistContentType::audioclip => 'file',
PlaylistContentType::stream => 'stream', PlaylistContentType::webstream => 'webstream',
PlaylistContentType::block => 'block', PlaylistContentType::block => 'block',
default => throw new InvalidArgumentException('Invalid content type'), default => throw new InvalidArgumentException('Invalid content type'),
}; };
@ -194,7 +203,8 @@ class ScheduleService
int $contentLength, int $contentLength,
int $contentDuration, int $contentDuration,
PlaylistContent $playlistContentEntry, PlaylistContent $playlistContentEntry,
int $contentId, int | null $fileId,
int | null $webstreamId,
int $showInstanceId int $showInstanceId
): Schedule { ): Schedule {
return new Schedule([ return new Schedule([
@ -209,7 +219,8 @@ class ScheduleService
'playout_status' => 0, 'playout_status' => 0,
'broadcasted' => self::BROADCAST_STATUS_PENDING, 'broadcasted' => self::BROADCAST_STATUS_PENDING,
'position' => $playlistContentEntry->position, 'position' => $playlistContentEntry->position,
'file_id' => $contentId, 'file_id' => $fileId,
'stream_id' => $webstreamId,
'instance_id' => $showInstanceId 'instance_id' => $showInstanceId
]); ]);
} }
@ -219,7 +230,7 @@ class ScheduleService
return collect($scheduleItems)->map(fn (Schedule $schedule) => $schedule->only([ return collect($scheduleItems)->map(fn (Schedule $schedule) => $schedule->only([
'starts', 'ends', 'clip_length', 'fade_in', 'fade_out', 'starts', 'ends', 'clip_length', 'fade_in', 'fade_out',
'cue_in', 'cue_out', 'media_item_played', 'playout_status', 'cue_in', 'cue_out', 'media_item_played', 'playout_status',
'broadcasted', 'position', 'instance_id', 'file_id' 'broadcasted', 'position', 'instance_id', 'file_id', 'stream_id'
]))->toArray(); ]))->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) public function manageShowInstances(Show $show)
{ {
try { try {
$generationLimitDate = Preference::where('keystr', 'shows_populated_until')->value('valstr'); $generationLimitDateSetting = Preference::where('keystr', 'shows_populated_until')->value('valstr');
$generationLimitDate = Carbon::createFromFormat('Y-m-d H:i:s', $generationLimitDate); 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 = []; $showInstances = [];
foreach ($show->showDays as $showDay) { foreach ($show->showDays as $showDay) {
try { try {