Веб-приложение для видеотрансляций на Laravel

от автора

Привет, Хабр. В этой статье я хочу поделиться своим опытом создания приложения на фреймворке Laravel по трансляции видеоконтента. Итак начнём.

Проект опубликован как свободное ПО

Задача

Сделать сервис, совместимый с бизнес-моделью SaaS , принимающий данные по протоколу RTMP от разных поставщиков контента и раздающий этот контент по HLS конечным пользователям за плату или бесплатно, т.е. реализовать Live-трансляции.

Ингредиенты

Будем использовать свободное программное обеспечение. Для работы с RTMP и HLS мы будем использовать nginx с nginx-rtmp-module. Для выполнение веб-приложения мы будем использовать apache2, php, базу данных MariaDB. В качестве фреймворка Laravel с компонентами LiveWire для динамического обновления данных и для построения html страниц шаблоны Blade. Для обработки записанных трансляций будем использовать FFMPEG. Всё это на сервере Ubuntu 20.04 LTS.

Приступаем

Создали проект Laravel 8. Создаём миграции для баз данных. У нас будут пользователи, организации (поставщики контента), посты (то есть записи о будущих, текущих и прошедших трансляциях) и так далее.

Таблица Users:

<?php  use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema;  class CreateUsersTable extends Migration {     /**      * Run the migrations.      *      * @return void      */     public function up()     {         Schema::create('users', function (Blueprint $table) {             $table->id();             $table->string('name');             $table->string('email')->unique();             $table->unsignedTinyInteger('access_level')->default(0); // 0 - user, 1 - editor, 2 - finmanager, 3 - admin, 4 - root(global admin)             $table->timestamp('email_verified_at')->nullable();             $table->string('password')->nullable();             $table->rememberToken();             $table->string('google_id')->nullable();             $table->string('google_token')->nullable();             $table->string('google_refresh_token')->nullable();             $table->string('instagram_id')->nullable();             $table->string('instagram_token')->nullable();             $table->string('instagram_refresh_token')->nullable();             $table->string('yandex_id')->nullable();             $table->string('yandex_token')->nullable();             $table->string('yandex_refresh_token')->nullable();             $table->string('vk_id')->nullable();             $table->string('vk_token')->nullable();             $table->string('vk_refresh_token')->nullable();             $table->foreignId('org_id')                 ->nullable()                 ->constrained()                 ->onUpdate('cascade')                 ->onDelete('restrict');             $table->timestamps();         });     }      /**      * Reverse the migrations.      *      * @return void      */     public function down()     {         Schema::dropIfExists('users');     } }

Таблица Orgs:

<?php  use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema;  class CreateOrgsTable extends Migration {     /**      * Run the migrations.      *      * @return void      */     public function up()     {         Schema::create('orgs', function (Blueprint $table) {             $table->id();             $table->string('fulltitle', 512)->nullable();             $table->string('title', 128);             $table->string('brandtitle', 128);             $table->string('ogrn', 15);             $table->string('inn', 12);             $table->string('kpp', 9)->nullable();             $table->string('address', 255);             $table->string('drawer_status', 2)->nullable();             $table->string('fintitle', 255);             $table->string('personal_acc', 20);             $table->string('bank_name', 128);             $table->string('bic', 9);             $table->string('corresp_acc', 20);             $table->string('kbk', 20)->nullable();             $table->string('titlekbk', 128)->nullable();             $table->string('oktmo', 11)->nullable();             $table->string('purpose', 255)->nullable();             $table->string('email', 255);             $table->string('tel', 10);;             $table->timestamps();         });     }      /**      * Reverse the migrations.      *      * @return void      */     public function down()     {         Schema::dropIfExists('orgs');     } }

Таблица постов:

<?php ... Schema::create('posts', function (Blueprint $table) {             $table->id();             $table->foreignId('org_id')                 ->nullable()                 ->constrained()                 ->cascadeOnUpdate()                 ->nullOnDelete();             $table->boolean('record')->default(FALSE);             $table->boolean('autorecord')->default(FALSE);             $table->boolean('file_preparation')->default(FALSE);             $table->boolean('rtmp_status')->default(FALSE);             $table->ipAddress('rtmp_ip_sender')->nullable();             $table->boolean('allow_comment')->default(FALSE);             $table->string('title1', 64);             $table->string('title2', 64)->nullable();             $table->string('body', 2048)->nullable();             $table->uuid('stream_name')->unique();             $table->string('stream_token', 32);             $table->dateTime('dt_begin');             $table->dateTime('dt_end');             $table->unsignedDecimal('price', 14, 2)->nullable();             $table->unsignedBigInteger('timeleft')->nullable();             $table->unsignedBigInteger('timepass')->nullable();             $table->char('color', 4)->charset('binary')->nullable();             $table->foreignId('picture_id')->nullable()->constrained('mediafiles')->cascadeOnUpdate()->nullOnDelete();             $table->foreignId('videopreview_id')->nullable()->constrained('mediafiles')->cascadeOnUpdate()->nullOnDelete();             $table->foreignId('video_id')->nullable()->constrained('mediafiles')->cascadeOnUpdate()->nullOnDelete();             $table->foreignId('user_id') //author                 ->nullable()                 ->constrained()                 ->cascadeOnUpdate()                 ->nullOnDelete();             $table->unsignedBigInteger('cv_before')->default(0);             $table->unsignedBigInteger('cv_live')->default(0);             $table->unsignedBigInteger('cv_after')->default(0);             $table->timestamps();         });

Настраиваем модели Eloquent

Модель пользователя:

<?php  namespace App\Models;  use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; use Illuminate\Support\Facades\Auth;  class User extends Authenticatable {     use HasApiTokens, HasFactory, Notifiable;      const AAL = [         0 => 'Пользователь',         1 => 'Редактор',         2 => 'Финансовый менеджер',         3 => 'Администратор',         4 => 'root'     ];      /**      * The attributes that are mass assignable.      *      * @var array<int, string>      */     protected $fillable = [         'name',         'email',         'password',         'google_id',         'google_token',         'google_refresh_token',         'instagram_id',         'instagram_token',         'instagram_refresh_token',         'vk_id',         'vk_token',         'vk_refresh_token',         'yandex_id',         'yandex_token',         'yandex_refresh_token',     ];      protected $attributes = ['access_level' => 0];      /**      * The attributes that should be hidden for serialization.      *      * @var array<int, string>      */     protected $hidden = [         'password',         'remember_token',         'google_id',         'google_token',         'google_refresh_token',         'instagram_id',         'instagram_token',         'instagram_refresh_token',         'vk_id',         'vk_token',         'vk_refresh_token',         'yandex_id',         'yandex_token',         'yandex_refresh_token',     ];      /**      * The attributes that should be cast.      *      * @var array<string, string>      */     protected $casts = [         'email_verified_at' => 'datetime',     ];      public function getALAttribute()     {         return self::AAL[$this->access_level];     }      public function org() {         return $this->belongsTo(Org::class);     }      public function scopeLimitAL($query){         $ac = Auth::user()->access_level;         if ($ac == 0) {             return $query->where('id', Auth::id());         } else {             return $query->where('access_level', '<=', $ac);         }     } }

Модель поста:

<?php  namespace App\Models;  use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use App\Models\Mediafile; use App\Models\User; use App\Models\Org; use App\Models\Ticket; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str;  class Post extends Model {     use HasFactory;      public function picture() {         return $this->belongsTo(Mediafile::class);     }      public function video() {         return $this->belongsTo(Mediafile::class);     }      public function videopriview() {         return $this->belongsTo(Mediafile::class);     }      public function getStreamStringAttribute() {         return "{$this->stream_name}/{$this->stream_token}";     }      public function getCvAttribute() {         return $this->cv_before + $this->cv_live + $this->cv_after;     }      public function tickets()     {         return $this->hasMany(Ticket::class);     }      public function user() {         return $this->belongsTo(User::class);     }      public function org() {         return $this->belongsTo(Org::class);     }      protected static function booted()     {         static::creating(function (Post $post) {             $post->user_id = Auth::id();             $post->org_id = Auth::user()->org_id;             $post->stream_name = Str::uuid();             $post->stream_token = Str::random(32);         });     } }

Пишем контроллеры

По сути вся логика приложения пишется в контроллерах. С пользователями и другими моделями достаточно всё тривиально. Рассмотрим контроллер постов и контроллер взаиморасчетов между организациями и пользователями.

Контроллер постов:

<?php  namespace App\Http\Controllers;  use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Auth; use App\Models\Mediafile; use App\Models\Post; use App\Jobs\StartRec; use App\Jobs\StopRec; use App\Jobs\StopRTMP;  class PostController extends Controller {      public function show($id = 0)     {         if ($id > 0) {             return view('post', ['edit' => 0, 'posts' => [Post::findOrFail($id)]]);         } else {             return view('post', ['edit' => 0, 'posts' => Post::orderByDesc('id')->paginate(10)]);         }     }      public function index()     {         $lp = Post::select('id')->orderByDesc('id')->take(1)->get();         if (isset($lp[0])) { $lpid = $lp[0]['id']; } else { $lpid = 0; }         return view('home', [             'posts' => Post::orderByDesc('id')->paginate(32),             'postsfuture' => Post::where('dt_begin', '>', now())->orderByDesc('id')->paginate(32),             'postspast' => Post::where('dt_end', '<', now())->orderByDesc('id')->paginate(32),             'postsnow' => Post::where('dt_end', '>', now())->where('dt_begin', '<', now())->orderByDesc('id')->paginate(32),             'lpid' => $lpid         ]);     }      public function edit($id = 0)     {         if (($id > 0) && (in_array(Auth::user()->access_level, [1, 3, 4]))) {             return view('post', ['edit' => 1, 'post' => Post::findOrFail($id)]);         } elseif (in_array(Auth::user()->access_level, [1, 3, 4])) {             return view('post', ['edit' => 2]);         }     }      public function rtmp_on(Request $request) {         $ar = [             'stream_name' => $request->input('name'),             'stream_token' => $request->input('token')         ];         $post = Post::where($ar)->firstOr(function () { return false; });         if ($post) {             $post->rtmp_status = true;             $post->rtmp_ip_sender = $request->input('addr');             $post->save();             if (($post->autorecord == true) && ($post->record == false)) {                 StartRec::dispatch($post);             }             return response()->noContent(); // allow         } else {             return response(null, 403); // forbidden         }     }      public function rtmp_off(Request $request) {         $ar = [             'stream_name' => $request->input('name'),             'rtmp_ip_sender' => $request->input('addr')         ];         $post = Post::where($ar)->firstOr(function () { return false; });         if ($post) {             $post->rtmp_status = false;             $post->save();             if ($post->record == true) {                 StopRec::dispatch($post);             }         }         return response()->noContent();     }      public function rtmp_update(Request $request) {         $ar = [             'stream_name' => $request->input('name'),             'stream_token' => $request->input('token')         ];         $post = Post::where($ar)->firstOr(function () { return false; });         if ($post) {             $post->rtmp_status = true;             $post->rtmp_ip_sender = $request->input('addr');             $post->timepass = $request->input('time');             $post->save();             return response()->noContent(); // allow         } else {             return response(null, 403); // forbidden         }     } }

Этот контроллер у нас взаимодействует и конечным пользователем и сервером nginx. Маршруты к этому контроллеру для пользователя мы напишем в web.php:

<?php ... Route::prefix('posts')->middleware('auth')->group(function () {     Route::get('/{id?}', [PostController::class, 'show'])->where('id', '[0-9]+')->name('posts');     Route::get('/{id}/edit', [PostController::class, 'edit'])->where('id', '[0-9]+')->name('editpost');     Route::get('/add', [PostController::class, 'edit'])->name('addpost'); });

А для сервера в файле api.php:

<?php ...   Route::post('stream/on_publish', [PostController::class, 'rtmp_on'])->name('rtmp_on'); Route::post('stream/on_publish_done', [PostController::class, 'rtmp_off'])->name('rtmp_off'); Route::post('stream/on_update', [PostController::class, 'rtmp_update'])->name('rtmp_update');

Логика такая: пользователь создаёт пост: пишет название, дата и время начала и конца, прикладывает картинку, при сохранение модель создаёт уникальные stream_name и stream_token. stream_name видят все, а stream_token только администраторы и автор поста. Запись внесена в базу данных. Затем автор запускает приложение для трансляции контента на сервер, например OBS. Указывает rtmp адрес сервера и запускает.

Данные принимает сервер nginx и отправляет запрос на приложение, как это указано в его настройках:

rtmp {     server {         listen 1935; # Listen on standard RTMP port         chunk_size 8192;         max_streams 32;          application show {             on_publish "http://live.example.org:80/api/stream/on_publish";             live on;             recorder rec1 {                 record all manual;                 record_suffix _rec.flv;                 record_path /var/www/live.example.org/storage/app/public/rec;                 record_unique on;             }             hls on;             hls_path /var/www/live.example.org-hls/public_html/hls;             hls_fragment 5;             hls_cleanup on;             hls_playlist_length 30;             hls_nested on;             deny play all;             on_publish_done "http://live.example.org:80/api/stream/on_publish_done";             notify_update_timeout 2s;             on_update "http://live.example.org:80/api/stream/on_update";         }     } }

То есть событие on_publish в сервере nginx вызывает метод rtmp_on у контроллера поста. Веб-приложение проверяет stream_name и stream_token т.е. есть ли вообще такой пост и совпадает ли токен, если да, то ответ для сервера HTTP 204, и сервер продолжает принимать данные RTMP, а если нет, то HTTP 403, сервер отказывает в приёме данных, в программе OBS выйдет ошибка I/O error. rtmp_off — вносит меняет статус поста на «трансляция завершена». rtmp_update — обновляет сведения о трансляции. Также контроллер поста проверяет надо ли записывать трансляцию, причём пользователь может в реальном времени начинать и останавливать запись. Для таких действий мы будем использовать очередь Laravel для того, чтобы они выполнялись в одном потоке. Создадим службу для очередей Laravel:

[Unit] Description=The Deyen Live Video Platform Laravel Queue Worker Daemon  [Service] User=www-data Group=www-data Restart=on-failure ExecStart=/usr/bin/php /var/www/live.example.org/artisan queue:work ExecReload=/usr/bin/php /var/www/live.example.org/artisan queue:restart  [Install] WantedBy=multi-user.target

для приёма команд от службы очередей laravel на стороне nginx создадим виртуальный хост на 82 порту:

server { listen 127.0.1.2:82; root /var/www/live.example.org-hls/public_html; index index.html index.m3u8; server_name live.example.org;  location / { try_files $uri $uri/ =404; }  location /control {         rtmp_control all; add_header Access-Control-Allow-Origin "*";     } }

Теперь можно вовремя трансляции управлять поведением записи, а также кикать клиентов-вещателей.

Задание на старт записи:

<?php  namespace App\Jobs;  use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; use App\Models\Mediafile; use App\Models\Post;  class StartRec implements ShouldQueue {     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;      public $post;      public function __construct(Post $post)     {         $this->post = $post;     }      /**      * Execute the job.      *      * @return void      */     public function handle()     {         if ($this->post->record == false) {             $this->post->record = true;             $r = Http::get(env('APP_URL').":82/control/record/start?rec=rec1&app=show&name={$this->post->stream_name}");             $v = new Mediafile;             $v->org_id = $this->post->org_id;             $v->user_id = $this->post->user_id;             $m = [0 => ""];             preg_match('/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+_rec.flv/i', $r, $m);             $v->uri = 'public/rec/'.$m[0];             $v->sha256checksum = hash('sha256', $v->uri, true);             $v->save();             $this->post->video_id = $v->id;             Post::where('stream_name', $this->post->stream_name)->update(['video_id' => $v->id, 'record' => true]);         }     } }

Теперь про контроллер взаиморасчётов:

<?php  namespace App\Http\Controllers;  use Illuminate\Support\Facades\Auth; use Illuminate\Http\Request; use Barryvdh\DomPDF\Facade\Pdf; use App\Models\Inout; use App\Models\Org; use App\Models\User;  class InoutController extends Controller {      public function show($id = 0)     {         if ($id > 0) {             return view('inout', ['inouts' => [Inout::limitByUser()->findOrFail($id)]]);         } else {             return view('inout', ['inouts' => Inout::limitByUser()->orderByDesc('id')->paginate(10)]);         }     }      public function show_balance()     {         return view('inout-balance', ['inouts'=> Inout::getBalances()->limitByUser()->paginate(10)]);     }      public function getkvit(Request $request)     {         $validatedData = $request->validate([             'user_id' => ['required', 'numeric'],             'org_id' => ['required', 'numeric']         ]);         $org = Org::findOrFail($validatedData['org_id']);         $user = User::findOrFail($validatedData['user_id']);         $qs = ["ST00012", "Name={$org->fintitle}","PersonalAcc={$org->personal_acc}", "BankName={$org->bank_name}", "BIC={$org->bic}", "CorrespAcc={$org->corresp_acc}", "PayeeINN={$org->inn}", "KPP={$org->kpp}", "CBC={$org->kbk}", "OKTMO={$org->oktmo}", "Purpose=ID {$user->id} {$org->purpose}", "DrawerStatus={$org->drawer_status}", "PersAcc={$user->id}"];         $ms = implode('|',$qs);         $pdf = PDF::loadView('pdf/kvit', ['org' => $org, 'user' => $user, 'ms' => $ms]);         return $pdf->download('kvit.pdf');     }      public function edit()     {         if (in_array(Auth::user()->access_level, [2, 3, 4])) {             return view('inout-add');         }     }      public function store(Request $request)     {         if (in_array(Auth::user()->access_level, [2, 3, 4])) {             $inout = new Inout;             $validatedData = $request->validate([                 'title_doc' => ['required', 'string', 'max:64'],                 'number_doc' => ['required', 'string', 'max:64'],                 'date_doc' => ['required', 'date'],                 'user_id' => ['required', 'numeric'],                 'sum' => ['required', 'numeric']             ]);             $inout->fill($validatedData);             $inout->org_id = Auth::user()->org_id;             $inout->total = $inout->balance;             $inout->save();             return redirect()->route('inouts');         }     } }

У нас по каждому пользователю по каждой организации ведется отдельный лицевой счёт взаиморасчётов. Самого лицевого счёта как бы нет, это лишь состояние отношений между организацией и пользователем. Это полезно когда мы с юридической точки зрения не хотим быть платежным агентом и все платежи между клиентами и организациями проходят на прямую. Так же добавим метод оплаты в пользу организации по реквизитам с формированием квитанции в PDF с QR-кодом. За это отвечает функция getkvit. Не составит труда добавить и другие методы оплаты. Не буду вставлять коды Blade шаблонов, иначе статья станет слишком длинной.

Проект опубликован на Github https://github.com/deyen01/dlvp как свободное программное обеспечение.


ссылка на оригинал статьи https://habr.com/ru/post/672276/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *