diff --git a/app/Http/Controllers/LiveStreamController.php b/app/Http/Controllers/LiveStreamController.php new file mode 100644 index 00000000..a25cc3f1 --- /dev/null +++ b/app/Http/Controllers/LiveStreamController.php @@ -0,0 +1,220 @@ +user(), 403); + + if(config('livestreaming.broadcast.limits.enabled')) { + if($request->user()->is_admin) { + + } else { + $limits = config('livestreaming.broadcast.limits'); + $user = $request->user(); + abort_if($limits['admins_only'] && $user->is_admin == false, 401, 'LSE:003'); + if($limits['min_account_age']) { + abort_if($user->created_at->gt(now()->subDays($limits['min_account_age'])), 403, 'LSE:005'); + } + + if($limits['min_follower_count']) { + $account = AccountService::get($user->profile_id); + abort_if($account['followers_count'] < $limits['min_follower_count'], 403, 'LSE:008'); + } + } + } + + $this->validate($request, [ + 'name' => 'nullable|string|max:80', + 'description' => 'nullable|string|max:240', + 'visibility' => 'required|in:public,private' + ]); + + $stream = new LiveStream; + $stream->name = $request->input('name'); + $stream->description = $request->input('description'); + $stream->visibility = $request->input('visibility'); + $stream->profile_id = $request->user()->profile_id; + $stream->stream_id = Str::random(40); + $stream->stream_key = Str::random(64); + $stream->save(); + + return [ + 'url' => $stream->getStreamKeyUrl(), + 'id' => $stream->stream_id + ]; + } + + public function getUserStream(Request $request) + { + abort_if(!config('livestreaming.enabled'), 400); + abort_if(!$request->user(), 403); + + $stream = LiveStream::whereProfileId($request->input('profile_id'))->first(); + + if(!$stream) { + return []; + } + + $res = []; + $owner = $stream->profile_id == $request->user()->profile_id; + + if($stream->visibility === 'private') { + abort_if(!$owner && !FollowerService::follows($request->user()->profile_id, $stream->profile_id), 403, 'LSE:011'); + } + + if($owner) { + $res['stream_key'] = $stream->stream_key; + $res['stream_id'] = $stream->stream_id; + $res['stream_url'] = $stream->getStreamKeyUrl(); + } + + if($stream->live_at == null) { + $res['hls_url'] = null; + $res['name'] = $stream->name; + $res['description'] = $stream->description; + return $res; + } + + $res = [ + 'hls_url' => $stream->getHlsUrl(), + 'name' => $stream->name, + 'description' => $stream->description + ]; + + return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES); + } + + public function deleteStream(Request $request) + { + abort_if(!config('livestreaming.enabled'), 400); + abort_if(!$request->user(), 403); + + LiveStream::whereProfileId($request->user()->profile_id) + ->get() + ->each(function($stream) { + Storage::deleteDirectory("public/live-hls/{$stream->stream_id}"); + $stream->delete(); + }); + + return [200]; + } + + public function getActiveStreams(Request $request) + { + abort_if(!config('livestreaming.enabled'), 400); + abort_if(!$request->user(), 403); + + return LiveStream::whereVisibility('local')->whereNotNull('live_at')->get()->map(function($stream) { + return [ + 'account' => AccountService::get($stream->profile_id), + 'stream_id' => $stream->stream_id + ]; + }); + } + + public function getLatestChat(Request $request) + { + abort_if(!config('livestreaming.enabled'), 400); + abort_if(!$request->user(), 403); + + $stream = LiveStream::whereProfileId($request->input('profile_id')) + ->whereNotNull('live_at') + ->first(); + + if(!$stream) { + return []; + } + + $owner = $stream->profile_id == $request->user()->profile_id; + if($stream->visibility === 'private') { + abort_if(!$owner && !FollowerService::follows($request->user()->profile_id, $stream->profile_id), 403, 'LSE:021'); + } + + $res = collect(LiveStreamService::getComments($stream->profile_id)) + ->map(function($r) { + return json_decode($r); + }); + + return $res; + } + + public function addChatComment(Request $request) + { + abort_if(!config('livestreaming.enabled'), 400); + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'profile_id' => 'required|exists:profiles,id', + 'message' => 'required|max:140' + ]); + + $stream = LiveStream::whereProfileId($request->input('profile_id'))->firstOrFail(); + + $owner = $stream->profile_id == $request->user()->profile_id; + if($stream->visibility === 'private') { + abort_if(!$owner && !FollowerService::follows($request->user()->profile_id, $stream->profile_id), 403, 'LSE:022'); + } + + $res = [ + 'pid' => (string) $request->user()->profile_id, + 'username' => $request->user()->username, + 'text' => $request->input('message'), + 'ts' => now()->timestamp + ]; + + LiveStreamService::addComment($stream->profile_id, json_encode($res, JSON_UNESCAPED_SLASHES)); + + return $res; + } + + public function editStream(Request $request) + { + abort_if(!config('livestreaming.enabled'), 400); + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'name' => 'nullable|string|max:80', + 'description' => 'nullable|string|max:240' + ]); + + $stream = LiveStream::whereProfileId($request->user()->profile_id)->firstOrFail(); + $stream->name = $request->input('name'); + $stream->description = $request->input('description'); + $stream->save(); + + return; + } + + public function deleteChatComment(Request $request) + { + abort_if(!config('livestreaming.enabled'), 400); + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'profile_id' => 'required|exists:profiles,id', + 'message' => 'required' + ]); + + abort_if($request->user()->profile_id != $request->input('profile_id'), 403); + + $stream = LiveStream::whereProfileId($request->user()->profile_id)->firstOrFail(); + + $payload = $request->input('message'); + $payload = json_encode($payload, JSON_UNESCAPED_SLASHES); + LiveStreamService::deleteComment($stream->profile_id, $payload); + + return; + } +} diff --git a/app/Models/LiveStream.php b/app/Models/LiveStream.php new file mode 100644 index 00000000..3336f3ce --- /dev/null +++ b/app/Models/LiveStream.php @@ -0,0 +1,32 @@ +stream_id}/index.m3u8"); + return url($path); + } + + public function getStreamKeyUrl() + { + $proto = 'rtmp://'; + $host = config('livestreaming.server.host'); + $port = ':' . config('livestreaming.server.port'); + $path = '/' . config('livestreaming.server.path') . '?'; + $query = http_build_query([ + 'key' => $this->stream_key, + 'ts' => time() + ]); + + return $proto . $host . $port . $path . $query; + } +} diff --git a/app/Services/LiveStreamService.php b/app/Services/LiveStreamService.php new file mode 100644 index 00000000..01d44c85 --- /dev/null +++ b/app/Services/LiveStreamService.php @@ -0,0 +1,47 @@ += config('livestreaming.comments.max_falloff')) { + Redis::rpop($key); + } + } + + return Redis::lpush($key, $val); + } + + public static function commentsCount($id) + { + $key = self::CACHE_KEY . 'chat:' . $id; + return Redis::llen($key); + } + + public static function deleteComment($id, $val) + { + $key = self::CACHE_KEY . 'chat:' . $id; + return Redis::lrem($key, 0, $val); + } + + public static function clearChat($id, $val) + { + $key = self::CACHE_KEY . 'chat:' . $id; + return Redis::del($key); + } +} diff --git a/config/livestreaming.php b/config/livestreaming.php new file mode 100644 index 00000000..00ad13fd --- /dev/null +++ b/config/livestreaming.php @@ -0,0 +1,27 @@ + env('HLS_LIVE', false), + + 'server' => [ + 'host' => env('HLS_LIVE_HOST', env('APP_DOMAIN', 'localhost')), + 'port' => env('HLS_LIVE_PORT', 1935), + 'path' => env('HLS_LIVE_PATH', 'live') + ], + + 'broadcast' => [ + 'max_duration' => env('HLS_LIVE_BROADCAST_MAX_DURATION', 60), + 'max_active' => env('HLS_LIVE_BROADCAST_MAX_ACTIVE', 10), + + 'limits' => [ + 'enabled' => env('HLS_LIVE_BROADCAST_LIMITS', true), + 'min_follower_count' => env('HLS_LIVE_BROADCAST_LIMITS_MIN_FOLLOWERS', 100), + 'min_account_age' => env('HLS_LIVE_BROADCAST_LIMITS_MIN_ACCOUNT_AGE', 14), + 'admins_only' => env('HLS_LIVE_BROADCAST_LIMITS_ADMINS_ONLY', true) + ] + ], + + 'comments' => [ + 'max_falloff' => env('HLS_LIVE_COMMENTS_MAX_FALLOFF', 50) + ], +]; diff --git a/database/migrations/2022_05_26_034550_create_live_streams_table.php b/database/migrations/2022_05_26_034550_create_live_streams_table.php new file mode 100644 index 00000000..0d8e9024 --- /dev/null +++ b/database/migrations/2022_05_26_034550_create_live_streams_table.php @@ -0,0 +1,43 @@ +id(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('stream_id')->nullable()->unique()->index(); + $table->string('stream_key')->nullable(); + $table->string('visibility')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->string('thumbnail_path')->nullable(); + $table->json('settings')->nullable(); + $table->boolean('live_chat')->default(true); + $table->json('mod_ids')->nullable(); + $table->boolean('discoverable')->nullable(); + $table->timestamp('live_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('live_streams'); + } +} diff --git a/routes/api.php b/routes/api.php index 0c8da2bd..982e6f81 100644 --- a/routes/api.php +++ b/routes/api.php @@ -96,4 +96,14 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('media', 'Api\ApiV1Controller@mediaUploadV2')->middleware($middleware); }); + Route::group(['prefix' => 'live'], function() use($middleware) { + Route::post('create_stream', 'LiveStreamController@createStream')->middleware($middleware); + Route::post('stream/edit', 'LiveStreamController@editStream')->middleware($middleware); + Route::get('active/list', 'LiveStreamController@getActiveStreams')->middleware($middleware); + Route::get('accounts/stream', 'LiveStreamController@getUserStream')->middleware($middleware); + Route::delete('accounts/stream', 'LiveStreamController@deleteStream')->middleware($middleware); + Route::get('chat/latest', 'LiveStreamController@getLatestChat')->middleware($middleware); + Route::post('chat/message', 'LiveStreamController@addChatComment')->middleware($middleware); + Route::post('chat/delete', 'LiveStreamController@deleteChatComment')->middleware($middleware); + }); });