Привет, Хабр. В этой статье я хочу поделиться своим опытом создания приложения на фреймворке 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 как свободное программное обеспечение.
