Compare commits
9 Commits
feature/lo
...
f624150bd8
| Author | SHA1 | Date | |
|---|---|---|---|
| f624150bd8 | |||
| 3229995dec | |||
| 4536ab77ee | |||
| f535530537 | |||
| b0417000e4 | |||
| 59e4ae60da | |||
| 295a408965 | |||
|
|
fc5a4a9b0b | ||
|
|
eb4d950905 |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Meghdad
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
45
app/Enums/ChannelCategory.php
Normal file
45
app/Enums/ChannelCategory.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ChannelCategory: string
|
||||||
|
{
|
||||||
|
case Gaming = 'gaming';
|
||||||
|
case Music = 'music';
|
||||||
|
case TalkShows = 'talk-shows';
|
||||||
|
case Education = 'education';
|
||||||
|
case Creative = 'creative';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Gaming => __('Gaming'),
|
||||||
|
self::Music => __('Music'),
|
||||||
|
self::TalkShows => __('Talk Shows'),
|
||||||
|
self::Education => __('Education'),
|
||||||
|
self::Creative => __('Creative'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{value: string, label: string}
|
||||||
|
*/
|
||||||
|
public function toOption(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'value' => $this->value,
|
||||||
|
'label' => $this->label(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{value: string, label: string}>
|
||||||
|
*/
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
fn (self $category): array => $category->toOption(),
|
||||||
|
self::cases(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ class ChannelController extends Controller
|
|||||||
): Response {
|
): Response {
|
||||||
abort_if($channel->isSuspended(), 404);
|
abort_if($channel->isSuspended(), 404);
|
||||||
|
|
||||||
$channel->load(['category', 'liveBroadcast', 'user']);
|
$channel->load(['liveBroadcast', 'user']);
|
||||||
$channel->loadCount('follows');
|
$channel->loadCount('follows');
|
||||||
|
|
||||||
$currentBroadcast = $channel->liveBroadcast;
|
$currentBroadcast = $channel->liveBroadcast;
|
||||||
@@ -39,11 +39,7 @@ class ChannelController extends Controller
|
|||||||
'is_live' => $channel->is_live,
|
'is_live' => $channel->is_live,
|
||||||
'viewer_count' => $channel->is_live ? $viewerCounts->current($channel) : 0,
|
'viewer_count' => $channel->is_live ? $viewerCounts->current($channel) : 0,
|
||||||
'followers_count' => $channel->follows_count,
|
'followers_count' => $channel->follows_count,
|
||||||
'category' => $channel->category ? [
|
'category' => $channel->category?->toOption(),
|
||||||
'id' => $channel->category->id,
|
|
||||||
'name' => $channel->category->name,
|
|
||||||
'slug' => $channel->category->slug,
|
|
||||||
] : null,
|
|
||||||
'owner' => [
|
'owner' => [
|
||||||
'id' => $channel->user->id,
|
'id' => $channel->user->id,
|
||||||
'name' => $channel->user->name,
|
'name' => $channel->user->name,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\ChannelCategory;
|
||||||
use App\Models\Broadcast;
|
use App\Models\Broadcast;
|
||||||
use App\Models\Category;
|
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Services\Streaming\StreamKeyManager;
|
use App\Services\Streaming\StreamKeyManager;
|
||||||
use App\Services\Streaming\ViewerCountStore;
|
use App\Services\Streaming\ViewerCountStore;
|
||||||
@@ -21,7 +21,7 @@ class CreatorDashboardController extends Controller
|
|||||||
{
|
{
|
||||||
$channel = $request->user()
|
$channel = $request->user()
|
||||||
->channel()
|
->channel()
|
||||||
->with(['category', 'liveBroadcast', 'streamKey'])
|
->with(['liveBroadcast', 'streamKey'])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return Inertia::render('dashboard', [
|
return Inertia::render('dashboard', [
|
||||||
@@ -34,14 +34,7 @@ class CreatorDashboardController extends Controller
|
|||||||
? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key'))
|
? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key'))
|
||||||
: null,
|
: null,
|
||||||
],
|
],
|
||||||
'categories' => Category::query()
|
'categories' => ChannelCategory::options(),
|
||||||
->orderBy('name')
|
|
||||||
->get(['id', 'name', 'slug'])
|
|
||||||
->map(fn (Category $category) => [
|
|
||||||
'id' => $category->id,
|
|
||||||
'name' => $category->name,
|
|
||||||
'slug' => $category->slug,
|
|
||||||
]),
|
|
||||||
'recentBroadcasts' => $channel
|
'recentBroadcasts' => $channel
|
||||||
? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [
|
? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [
|
||||||
'id' => $broadcast->id,
|
'id' => $broadcast->id,
|
||||||
@@ -65,7 +58,7 @@ class CreatorDashboardController extends Controller
|
|||||||
'display_name' => ['required', 'string', 'max:80'],
|
'display_name' => ['required', 'string', 'max:80'],
|
||||||
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'],
|
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'],
|
||||||
'description' => ['nullable', 'string', 'max:1000'],
|
'description' => ['nullable', 'string', 'max:1000'],
|
||||||
'category_id' => ['nullable', 'exists:categories,id'],
|
'category' => ['nullable', Rule::enum(ChannelCategory::class)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$channel = $request->user()->channel()->create([
|
$channel = $request->user()->channel()->create([
|
||||||
@@ -92,7 +85,7 @@ class CreatorDashboardController extends Controller
|
|||||||
'display_name' => ['required', 'string', 'max:80'],
|
'display_name' => ['required', 'string', 'max:80'],
|
||||||
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)],
|
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)],
|
||||||
'description' => ['nullable', 'string', 'max:1000'],
|
'description' => ['nullable', 'string', 'max:1000'],
|
||||||
'category_id' => ['nullable', 'exists:categories,id'],
|
'category' => ['nullable', Rule::enum(ChannelCategory::class)],
|
||||||
'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
|
'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -187,7 +180,7 @@ class CreatorDashboardController extends Controller
|
|||||||
'is_live' => $channel->is_live,
|
'is_live' => $channel->is_live,
|
||||||
'viewer_count' => $channel->viewer_count,
|
'viewer_count' => $channel->viewer_count,
|
||||||
'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(),
|
'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(),
|
||||||
'category_id' => $channel->category_id,
|
'category' => $channel->category?->value,
|
||||||
'live_broadcast' => $channel->liveBroadcast ? [
|
'live_broadcast' => $channel->liveBroadcast ? [
|
||||||
'id' => $channel->liveBroadcast->id,
|
'id' => $channel->liveBroadcast->id,
|
||||||
'title' => $channel->liveBroadcast->title,
|
'title' => $channel->liveBroadcast->title,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Enums\ChannelCategory;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -17,7 +17,7 @@ class HomeController extends Controller
|
|||||||
$liveChannels = Channel::query()
|
$liveChannels = Channel::query()
|
||||||
->where('is_live', true)
|
->where('is_live', true)
|
||||||
->whereNull('suspended_at')
|
->whereNull('suspended_at')
|
||||||
->with(['category', 'liveBroadcast', 'user'])
|
->with(['liveBroadcast', 'user'])
|
||||||
->withCount('follows')
|
->withCount('follows')
|
||||||
->orderByDesc('viewer_count')
|
->orderByDesc('viewer_count')
|
||||||
->latest()
|
->latest()
|
||||||
@@ -28,14 +28,7 @@ class HomeController extends Controller
|
|||||||
return Inertia::render('welcome', [
|
return Inertia::render('welcome', [
|
||||||
'canRegister' => Features::enabled(Features::registration()),
|
'canRegister' => Features::enabled(Features::registration()),
|
||||||
'liveChannels' => $liveChannels,
|
'liveChannels' => $liveChannels,
|
||||||
'categories' => Category::query()
|
'categories' => ChannelCategory::options(),
|
||||||
->orderBy('name')
|
|
||||||
->get(['id', 'name', 'slug'])
|
|
||||||
->map(fn (Category $category) => [
|
|
||||||
'id' => $category->id,
|
|
||||||
'name' => $category->name,
|
|
||||||
'slug' => $category->slug,
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +45,7 @@ class HomeController extends Controller
|
|||||||
'banner_url' => $this->mediaUrl($channel->banner_path),
|
'banner_url' => $this->mediaUrl($channel->banner_path),
|
||||||
'viewer_count' => $channel->viewer_count,
|
'viewer_count' => $channel->viewer_count,
|
||||||
'followers_count' => $channel->follows_count ?? 0,
|
'followers_count' => $channel->follows_count ?? 0,
|
||||||
'category' => $channel->category ? [
|
'category' => $channel->category?->toOption(),
|
||||||
'id' => $channel->category->id,
|
|
||||||
'name' => $channel->category->name,
|
|
||||||
'slug' => $channel->category->slug,
|
|
||||||
] : null,
|
|
||||||
'broadcast' => $channel->liveBroadcast ? [
|
'broadcast' => $channel->liveBroadcast ? [
|
||||||
'id' => $channel->liveBroadcast->id,
|
'id' => $channel->liveBroadcast->id,
|
||||||
'title' => $channel->liveBroadcast->title,
|
'title' => $channel->liveBroadcast->title,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Database\Factories\CategoryFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable(['name', 'slug', 'description'])]
|
|
||||||
class Category extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<CategoryFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public function channels(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Channel::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ChannelCategory;
|
||||||
use Database\Factories\ChannelFactory;
|
use Database\Factories\ChannelFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'user_id',
|
'user_id',
|
||||||
'category_id',
|
'category',
|
||||||
'live_broadcast_id',
|
'live_broadcast_id',
|
||||||
'slug',
|
'slug',
|
||||||
'display_name',
|
'display_name',
|
||||||
@@ -32,6 +33,7 @@ class Channel extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'category' => ChannelCategory::class,
|
||||||
'is_live' => 'boolean',
|
'is_live' => 'boolean',
|
||||||
'viewer_count' => 'integer',
|
'viewer_count' => 'integer',
|
||||||
'suspended_at' => 'datetime',
|
'suspended_at' => 'datetime',
|
||||||
@@ -48,11 +50,6 @@ class Channel extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function category(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Category::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function streamKey(): HasOne
|
public function streamKey(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(StreamKey::class);
|
return $this->hasOne(StreamKey::class);
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class CategoryFactory extends Factory
|
|
||||||
{
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
$name = fake()->unique()->words(2, true);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'name' => Str::headline($name),
|
|
||||||
'slug' => Str::slug($name),
|
|
||||||
'description' => fake()->optional()->sentence(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Enums\ChannelCategory;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -15,7 +15,7 @@ class ChannelFactory extends Factory
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
'category_id' => Category::factory(),
|
'category' => fake()->randomElement(ChannelCategory::cases())->value,
|
||||||
'slug' => Str::slug($name),
|
'slug' => Str::slug($name),
|
||||||
'display_name' => Str::headline($name),
|
'display_name' => Str::headline($name),
|
||||||
'description' => fake()->sentence(),
|
'description' => fake()->sentence(),
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('channels', function (Blueprint $table) {
|
||||||
|
$table->string('category')->nullable()->after('user_id')->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
$allowedCategories = ['gaming', 'music', 'talk-shows', 'education', 'creative'];
|
||||||
|
|
||||||
|
DB::table('categories')
|
||||||
|
->select(['id', 'slug'])
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function (object $category) use ($allowedCategories): void {
|
||||||
|
if (! in_array($category->slug, $allowedCategories, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('channels')
|
||||||
|
->where('category_id', $category->id)
|
||||||
|
->update(['category' => $category->slug]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('channels', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('category_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::dropIfExists('categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::create('categories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('categories')->insert([
|
||||||
|
['name' => 'Gaming', 'slug' => 'gaming', 'created_at' => now(), 'updated_at' => now()],
|
||||||
|
['name' => 'Music', 'slug' => 'music', 'created_at' => now(), 'updated_at' => now()],
|
||||||
|
['name' => 'Talk Shows', 'slug' => 'talk-shows', 'created_at' => now(), 'updated_at' => now()],
|
||||||
|
['name' => 'Education', 'slug' => 'education', 'created_at' => now(), 'updated_at' => now()],
|
||||||
|
['name' => 'Creative', 'slug' => 'creative', 'created_at' => now(), 'updated_at' => now()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::table('channels', function (Blueprint $table) {
|
||||||
|
$table->foreignId('category_id')->nullable()->after('user_id')->constrained()->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('categories')
|
||||||
|
->select(['id', 'slug'])
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(function (object $category): void {
|
||||||
|
DB::table('channels')
|
||||||
|
->where('category', $category->slug)
|
||||||
|
->update(['category_id' => $category->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('channels', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['category']);
|
||||||
|
$table->dropColumn('category');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Enums\ChannelCategory;
|
||||||
use App\Models\PlatformSetting;
|
use App\Models\PlatformSetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Streaming\StreamKeyManager;
|
use App\Services\Streaming\StreamKeyManager;
|
||||||
@@ -15,17 +15,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
collect([
|
|
||||||
['name' => 'Gaming', 'slug' => 'gaming'],
|
|
||||||
['name' => 'Music', 'slug' => 'music'],
|
|
||||||
['name' => 'Talk Shows', 'slug' => 'talk-shows'],
|
|
||||||
['name' => 'Education', 'slug' => 'education'],
|
|
||||||
['name' => 'Creative', 'slug' => 'creative'],
|
|
||||||
])->each(fn (array $category) => Category::query()->firstOrCreate(
|
|
||||||
['slug' => $category['slug']],
|
|
||||||
['name' => $category['name']],
|
|
||||||
));
|
|
||||||
|
|
||||||
PlatformSetting::current();
|
PlatformSetting::current();
|
||||||
|
|
||||||
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']);
|
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']);
|
||||||
@@ -47,7 +36,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$channel = $official->channel()->updateOrCreate([], [
|
$channel = $official->channel()->updateOrCreate([], [
|
||||||
'category_id' => Category::query()->where('slug', 'creative')->value('id'),
|
'category' => ChannelCategory::Creative,
|
||||||
'slug' => 'nyone',
|
'slug' => 'nyone',
|
||||||
'display_name' => 'Nyone Official',
|
'display_name' => 'Nyone Official',
|
||||||
'description' => 'Official updates and live sessions from Nyone.',
|
'description' => 'Official updates and live sessions from Nyone.',
|
||||||
|
|||||||
13
dockerized/.dockerignore
Normal file
13
dockerized/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.env
|
||||||
|
runtime/source/.env
|
||||||
|
runtime/source/.git
|
||||||
|
runtime/source/node_modules
|
||||||
|
runtime/source/vendor
|
||||||
|
runtime/source/public/build
|
||||||
|
runtime/source/storage/app/private/recordings
|
||||||
|
runtime/source/storage/framework/cache
|
||||||
|
runtime/source/storage/framework/sessions
|
||||||
|
runtime/source/storage/framework/testing
|
||||||
|
runtime/source/storage/framework/views
|
||||||
|
runtime/source/storage/logs
|
||||||
|
|
||||||
48
dockerized/.env.example
Normal file
48
dockerized/.env.example
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
COMPOSE_PROJECT_NAME=nyone
|
||||||
|
|
||||||
|
# Git source cloned by scripts/deploy.sh into runtime/source before Docker builds.
|
||||||
|
GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
|
||||||
|
GIT_REF=main
|
||||||
|
|
||||||
|
# Public hostnames. TLS should terminate at your external proxy/CDN/load balancer.
|
||||||
|
PUBLIC_SCHEME=https
|
||||||
|
APP_DOMAIN=nyone.example.com
|
||||||
|
HLS_DOMAIN=nyone-hls.example.com
|
||||||
|
APP_HTTP_PORT=80
|
||||||
|
RTMP_PUBLIC_PORT=1935
|
||||||
|
|
||||||
|
APP_NAME=Nyone
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_KEY=
|
||||||
|
APP_PREVIOUS_KEYS=
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_FROM_ADDRESS=hello@example.com
|
||||||
|
MAIL_FROM_NAME=Nyone
|
||||||
|
|
||||||
|
POSTGRES_DB=nyone
|
||||||
|
POSTGRES_USER=nyone
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
|
MEDIAMTX_SHARED_SECRET=
|
||||||
|
STREAMING_VIEWER_SYNC_INTERVAL=2
|
||||||
|
STREAMING_VIEWER_COUNT_TTL=10
|
||||||
|
STREAMING_VIEWER_IDENTITY_COOKIE=nyone_visitor_id
|
||||||
|
STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS=365
|
||||||
|
STREAMING_VIEWER_IDENTITY_TTL=86400
|
||||||
|
STREAMING_VIEWER_STATS_POLL_INTERVAL=30000
|
||||||
|
STREAMING_VOD_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# Optional deployment behavior.
|
||||||
|
SEED_DATABASE=false
|
||||||
|
APP_OPTIMIZE_ON_BOOT=true
|
||||||
|
QUEUE_NAMES=default
|
||||||
|
QUEUE_SLEEP=3
|
||||||
|
QUEUE_TRIES=3
|
||||||
|
QUEUE_MAX_TIME=3600
|
||||||
|
QUEUE_TIMEOUT=90
|
||||||
3
dockerized/.gitignore
vendored
Normal file
3
dockerized/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
runtime/
|
||||||
|
|
||||||
206
dockerized/README.md
Normal file
206
dockerized/README.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Nyone Dockerized Deployment
|
||||||
|
|
||||||
|
This folder is a self-contained deployment bundle. A server only needs the `dockerized/` directory; the application repository is cloned later into `runtime/source` by `scripts/deploy.sh`.
|
||||||
|
|
||||||
|
## What It Runs
|
||||||
|
|
||||||
|
- Laravel app served by Apache.
|
||||||
|
- PostgreSQL database.
|
||||||
|
- Redis cache for Laravel cache and streaming viewer counts.
|
||||||
|
- MediaMTX for RTMP ingest, HLS playback, hooks, recordings, and the private API.
|
||||||
|
- Laravel queue worker.
|
||||||
|
- Laravel scheduler worker.
|
||||||
|
- Long-running `streaming:sync-viewer-counts` worker.
|
||||||
|
|
||||||
|
Default ports use the services' normal defaults:
|
||||||
|
|
||||||
|
- HTTP app/HLS proxy: `80`
|
||||||
|
- RTMP ingest: `1935`
|
||||||
|
- MediaMTX HLS inside Docker: `8888`
|
||||||
|
- MediaMTX API inside Docker: `9997`
|
||||||
|
- PostgreSQL inside Docker: `5432`
|
||||||
|
- Redis inside Docker: `6379`
|
||||||
|
|
||||||
|
Only HTTP and RTMP are published by default. HLS is served through Apache on `HLS_DOMAIN`, not directly from MediaMTX.
|
||||||
|
|
||||||
|
## Server Requirements
|
||||||
|
|
||||||
|
- Docker with Compose v2.
|
||||||
|
- Git.
|
||||||
|
- OpenSSL.
|
||||||
|
- Network access from the server to the Git repository.
|
||||||
|
- DNS for both `APP_DOMAIN` and `HLS_DOMAIN`.
|
||||||
|
- External TLS termination through a reverse proxy, load balancer, or CDN.
|
||||||
|
|
||||||
|
If a host-level reverse proxy already uses port `80`, set `APP_HTTP_PORT` to another local port and forward both hostnames to that port.
|
||||||
|
|
||||||
|
## First Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dockerized
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
chmod +x scripts/deploy.sh
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
At minimum, change these values in `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GIT_REPOSITORY_URL=https://github.com/your-org/nyone.git
|
||||||
|
GIT_REF=main
|
||||||
|
APP_DOMAIN=nyone.example.com
|
||||||
|
HLS_DOMAIN=nyone-hls.example.com
|
||||||
|
PUBLIC_SCHEME=https
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `APP_KEY`, `POSTGRES_PASSWORD`, and `MEDIAMTX_SHARED_SECRET` empty on first deploy if you want `scripts/deploy.sh` to generate them.
|
||||||
|
|
||||||
|
The deploy script will:
|
||||||
|
|
||||||
|
1. Create `.env` from `.env.example` if needed.
|
||||||
|
2. Generate missing secrets.
|
||||||
|
3. Clone or update the app into `runtime/source`.
|
||||||
|
4. Validate the Compose config.
|
||||||
|
5. Build Docker images.
|
||||||
|
6. Start PostgreSQL and Redis.
|
||||||
|
7. Run Laravel migrations.
|
||||||
|
8. Start Apache, MediaMTX, queue, scheduler, and viewer-sync services.
|
||||||
|
|
||||||
|
## Runtime Configuration
|
||||||
|
|
||||||
|
This bundle does not use the cloned repository's `.env` file for production settings. Docker Compose passes configuration from `dockerized/.env` into the containers.
|
||||||
|
|
||||||
|
Important values:
|
||||||
|
|
||||||
|
- `APP_DOMAIN`: Laravel app hostname.
|
||||||
|
- `HLS_DOMAIN`: HLS playback hostname. Must differ from `APP_DOMAIN`.
|
||||||
|
- `APP_HTTP_PORT`: host port published by Apache, default `80`.
|
||||||
|
- `RTMP_PUBLIC_PORT`: host RTMP port, default `1935`.
|
||||||
|
- `PUBLIC_SCHEME`: usually `https` behind external TLS.
|
||||||
|
- `SEED_DATABASE`: defaults to `false`; set to `true` only if you intentionally want the app seeder to run.
|
||||||
|
- `QUEUE_NAMES`: queue list for the Laravel database queue worker, default `default`.
|
||||||
|
|
||||||
|
The MediaMTX config is Docker-owned at `docker/mediamtx/mediamtx.yml.template`; it does not mount or depend on `deploy/mediamtx.yml` from the cloned repository.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
Run commands from the `dockerized/` directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env -f docker-compose.yml ps
|
||||||
|
docker compose --env-file .env -f docker-compose.yml logs -f app
|
||||||
|
docker compose --env-file .env -f docker-compose.yml logs -f mediamtx
|
||||||
|
docker compose --env-file .env -f docker-compose.yml exec app php artisan migrate:status
|
||||||
|
docker compose --env-file .env -f docker-compose.yml exec app php artisan streaming:sync-viewer-counts --once -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Redeploy after new Git changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop the stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env -f docker-compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not remove volumes unless you intend to delete database, Redis, and stored app media data.
|
||||||
|
|
||||||
|
## External Proxy Notes
|
||||||
|
|
||||||
|
Forward both `APP_DOMAIN` and `HLS_DOMAIN` to the Apache container's published HTTP port. Preserve the `Host` header and set `X-Forwarded-Proto: https` when TLS is terminated outside Docker.
|
||||||
|
|
||||||
|
Expose TCP `1935` publicly for OBS/RTMP ingest. Do not expose MediaMTX API port `9997` publicly.
|
||||||
|
|
||||||
|
## Nginx TLS Proxy Example
|
||||||
|
|
||||||
|
Use this pattern when Nginx runs on the host and handles public HTTP/HTTPS. In that setup, let Nginx own ports `80` and `443`, and bind the Docker Apache service to a private local port:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_HTTP_PORT=127.0.0.1:8080
|
||||||
|
PUBLIC_SCHEME=https
|
||||||
|
APP_DOMAIN=app.example.com
|
||||||
|
HLS_DOMAIN=hls.example.com
|
||||||
|
RTMP_PUBLIC_PORT=1935
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `./scripts/deploy.sh` after changing `.env`, then point Nginx at `http://127.0.0.1:8080`. Replace the example domains and certificate paths before enabling the config.
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /etc/nginx/sites-available/nyone-dockerized.conf
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name app.example.com hls.example.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name app.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port 443;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name hls.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/hls.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/hls.example.com/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port 443;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and validate on the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/nyone-dockerized.conf /etc/nginx/sites-enabled/nyone-dockerized.conf
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep RTMP separate from this HTTP proxy. Open TCP `1935` to the Docker host so OBS can publish directly to MediaMTX.
|
||||||
70
dockerized/SKILL.md
Normal file
70
dockerized/SKILL.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: dockerized-deployment
|
||||||
|
description: Work on the Nyone Dockerized deployment bundle under dockerized/. Use when modifying Docker Compose, Apache, MediaMTX, PostgreSQL, Redis, Laravel queue/scheduler/viewer-sync services, deploy.sh, deployment README files, or runtime environment defaults for this self-contained Docker deployment.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nyone Dockerized Deployment
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
- Keep deployment changes inside `dockerized/` unless the user explicitly requests application code changes.
|
||||||
|
- Treat `dockerized/.env` and `dockerized/runtime/` as server-local generated state; do not commit or depend on them.
|
||||||
|
- Do not make this bundle depend on the cloned app repository's `.env` or `deploy/mediamtx.yml`.
|
||||||
|
- Keep MediaMTX config Docker-owned at `docker/mediamtx/mediamtx.yml.template`.
|
||||||
|
- Keep `APP_DOMAIN` and `HLS_DOMAIN` separate because Apache uses separate virtual hosts.
|
||||||
|
- Do not run `scripts/deploy.sh`, `docker compose up`, or image builds unless the user confirms this is a deployment server.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `scripts/deploy.sh` is the entrypoint for server deployment.
|
||||||
|
- `docker-compose.yml` defines `app`, `db`, `redis`, `mediamtx`, `queue`, `scheduler`, `viewer-sync`, and one-shot `migrate`.
|
||||||
|
- `docker/app/Dockerfile` builds the Laravel Apache image from `runtime/source`.
|
||||||
|
- `docker/app/entrypoint.sh` selects behavior by `CONTAINER_ROLE`.
|
||||||
|
- `docker/app/apache/nyone.conf.template` serves Laravel on `APP_DOMAIN` and proxies HLS for `HLS_DOMAIN`.
|
||||||
|
- `docker/mediamtx/entrypoint.sh` renders the MediaMTX template at container start.
|
||||||
|
|
||||||
|
## Port Defaults
|
||||||
|
|
||||||
|
- Apache published HTTP: `80`.
|
||||||
|
- MediaMTX RTMP: `1935`.
|
||||||
|
- MediaMTX HLS internal: `8888`.
|
||||||
|
- MediaMTX API internal: `9997`.
|
||||||
|
- PostgreSQL internal: `5432`.
|
||||||
|
- Redis internal: `6379`.
|
||||||
|
|
||||||
|
Only publish HTTP and RTMP by default. Keep MediaMTX HLS behind Apache and keep the API private on the Docker network.
|
||||||
|
|
||||||
|
## Editing Guidance
|
||||||
|
|
||||||
|
- When adding an environment option, update `.env.example`, `docker-compose.yml`, and any entrypoint/template that consumes it.
|
||||||
|
- When changing MediaMTX ports, update Compose environment, published ports, entrypoint defaults, and the template together.
|
||||||
|
- When changing Laravel runtime behavior, update the shared `x-app-environment` block unless the value belongs to one role only.
|
||||||
|
- When changing queue behavior, prefer env-driven options on the `queue` service and `docker/app/entrypoint.sh`.
|
||||||
|
- Keep deploy script behavior idempotent: repeat runs should update the Git checkout, rebuild, migrate, and restart services without deleting volumes.
|
||||||
|
- Keep generated secrets in `.env`; do not bake secrets into images or templates.
|
||||||
|
|
||||||
|
## Nginx TLS Proxy Guidance
|
||||||
|
|
||||||
|
- Document Nginx as a host-level TLS reverse proxy in `README.md`; do not add Nginx as another Compose service unless the user asks.
|
||||||
|
- Keep Docker Apache as the upstream for both app and HLS hostnames. Nginx should proxy both domains to the published Apache port and preserve the `Host` header so Apache can select the correct virtual host.
|
||||||
|
- Recommend `APP_HTTP_PORT=127.0.0.1:8080` when Nginx owns host ports `80` and `443`.
|
||||||
|
- Include `X-Forwarded-Proto https`, `X-Forwarded-Port 443`, `X-Forwarded-Host`, `X-Real-IP`, and `X-Forwarded-For` in proxy examples.
|
||||||
|
- Keep HLS proxy examples buffering off and cache headers set to no-store.
|
||||||
|
- Keep RTMP outside the HTTP proxy guidance unless explicitly using Nginx stream proxying; the default is direct TCP `1935` to MediaMTX through Docker.
|
||||||
|
|
||||||
|
## Static Verification
|
||||||
|
|
||||||
|
Use static checks when not on a deployment server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
rg "1993[5]|1998[8]|80[8]0|25[2]5|nyone[.]net|nyone-hls[.]net|change-this-secre[t]" dockerized -n
|
||||||
|
```
|
||||||
|
|
||||||
|
If Docker is available and the user allows non-deploy validation, validate only the Compose config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file dockerized/.env.example -f dockerized/docker-compose.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not start containers during local verification unless the user explicitly asks for runtime testing.
|
||||||
188
dockerized/docker-compose.yml
Normal file
188
dockerized/docker-compose.yml
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
name: ${COMPOSE_PROJECT_NAME:-nyone-service}
|
||||||
|
|
||||||
|
x-app-environment: &app-environment
|
||||||
|
APP_NAME: ${APP_NAME:-Nyone}
|
||||||
|
APP_ENV: ${APP_ENV:-production}
|
||||||
|
APP_DEBUG: ${APP_DEBUG:-false}
|
||||||
|
APP_KEY: ${APP_KEY:-}
|
||||||
|
APP_PREVIOUS_KEYS: ${APP_PREVIOUS_KEYS:-}
|
||||||
|
APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com}
|
||||||
|
APP_LOCALE: ${APP_LOCALE:-en}
|
||||||
|
APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-en}
|
||||||
|
APP_FAKER_LOCALE: ${APP_FAKER_LOCALE:-en_US}
|
||||||
|
APP_MAINTENANCE_DRIVER: file
|
||||||
|
BCRYPT_ROUNDS: 12
|
||||||
|
LOG_CHANNEL: stack
|
||||||
|
LOG_STACK: stderr
|
||||||
|
LOG_DEPRECATIONS_CHANNEL: "null"
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
DB_CONNECTION: pgsql
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_DATABASE: ${POSTGRES_DB:-nyone}
|
||||||
|
DB_USERNAME: ${POSTGRES_USER:-nyone}
|
||||||
|
DB_PASSWORD: ${POSTGRES_PASSWORD:-}
|
||||||
|
SESSION_DRIVER: database
|
||||||
|
SESSION_LIFETIME: 120
|
||||||
|
SESSION_ENCRYPT: "false"
|
||||||
|
SESSION_PATH: /
|
||||||
|
SESSION_DOMAIN: ${SESSION_DOMAIN:-}
|
||||||
|
SESSION_SECURE_COOKIE: ${SESSION_SECURE_COOKIE:-true}
|
||||||
|
BROADCAST_CONNECTION: log
|
||||||
|
FILESYSTEM_DISK: local
|
||||||
|
QUEUE_CONNECTION: database
|
||||||
|
CACHE_STORE: redis
|
||||||
|
REDIS_CLIENT: phpredis
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
MAIL_MAILER: ${MAIL_MAILER:-log}
|
||||||
|
MAIL_SCHEME: ${MAIL_SCHEME:-}
|
||||||
|
MAIL_HOST: ${MAIL_HOST:-127.0.0.1}
|
||||||
|
MAIL_PORT: ${MAIL_PORT:-25}
|
||||||
|
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||||
|
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-hello@example.com}
|
||||||
|
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Nyone}
|
||||||
|
VITE_APP_NAME: ${APP_NAME:-Nyone}
|
||||||
|
STREAMING_RTMP_INGEST_URL: rtmp://${APP_DOMAIN:-nyone.example.com}:${RTMP_PUBLIC_PORT:-1935}
|
||||||
|
STREAMING_HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com}
|
||||||
|
STREAMING_MEDIAMTX_API_URL: http://mediamtx:9997
|
||||||
|
STREAMING_VIEWER_COUNT_CACHE_STORE: redis
|
||||||
|
STREAMING_VIEWER_COUNT_TTL: ${STREAMING_VIEWER_COUNT_TTL:-10}
|
||||||
|
STREAMING_VIEWER_SYNC_INTERVAL: ${STREAMING_VIEWER_SYNC_INTERVAL:-2}
|
||||||
|
STREAMING_VIEWER_IDENTITY_COOKIE: ${STREAMING_VIEWER_IDENTITY_COOKIE:-nyone_visitor_id}
|
||||||
|
STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS: ${STREAMING_VIEWER_IDENTITY_COOKIE_LIFETIME_DAYS:-365}
|
||||||
|
STREAMING_VIEWER_IDENTITY_TTL: ${STREAMING_VIEWER_IDENTITY_TTL:-86400}
|
||||||
|
STREAMING_VIEWER_STATS_POLL_INTERVAL: ${STREAMING_VIEWER_STATS_POLL_INTERVAL:-30000}
|
||||||
|
STREAMING_VOD_RETENTION_DAYS: ${STREAMING_VOD_RETENTION_DAYS:-30}
|
||||||
|
STREAMING_RECORDINGS_PATH: /var/www/html/storage/app/private/recordings
|
||||||
|
MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-}
|
||||||
|
APP_DOMAIN: ${APP_DOMAIN:-nyone.example.com}
|
||||||
|
HLS_DOMAIN: ${HLS_DOMAIN:-nyone-hls.example.com}
|
||||||
|
HLS_INTERNAL_PORT: 8888
|
||||||
|
APP_OPTIMIZE_ON_BOOT: ${APP_OPTIMIZE_ON_BOOT:-true}
|
||||||
|
SEED_DATABASE: ${SEED_DATABASE:-false}
|
||||||
|
|
||||||
|
x-app-service: &app-service
|
||||||
|
image: ${APP_IMAGE:-nyone-app:latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/app/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
volumes:
|
||||||
|
- app-storage:/var/www/html/storage
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
<<: *app-service
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
CONTAINER_ROLE: app
|
||||||
|
ports:
|
||||||
|
- "${APP_HTTP_PORT:-80}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/up >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
<<: *app-service
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
CONTAINER_ROLE: migrate
|
||||||
|
profiles:
|
||||||
|
- tools
|
||||||
|
|
||||||
|
queue:
|
||||||
|
<<: *app-service
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
CONTAINER_ROLE: queue
|
||||||
|
QUEUE_NAMES: ${QUEUE_NAMES:-default}
|
||||||
|
QUEUE_SLEEP: ${QUEUE_SLEEP:-3}
|
||||||
|
QUEUE_TRIES: ${QUEUE_TRIES:-3}
|
||||||
|
QUEUE_MAX_TIME: ${QUEUE_MAX_TIME:-3600}
|
||||||
|
QUEUE_TIMEOUT: ${QUEUE_TIMEOUT:-90}
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
<<: *app-service
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
CONTAINER_ROLE: scheduler
|
||||||
|
|
||||||
|
viewer-sync:
|
||||||
|
<<: *app-service
|
||||||
|
environment:
|
||||||
|
<<: *app-environment
|
||||||
|
CONTAINER_ROLE: viewer-sync
|
||||||
|
MEDIAMTX_HOST: mediamtx
|
||||||
|
MEDIAMTX_API_PORT: 9997
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
mediamtx:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
mediamtx:
|
||||||
|
image: ${MEDIAMTX_IMAGE:-nyone-mediamtx:latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/mediamtx/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
APP_URL: ${PUBLIC_SCHEME:-https}://${APP_DOMAIN:-nyone.example.com}
|
||||||
|
HLS_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${HLS_DOMAIN:-nyone-hls.example.com}
|
||||||
|
MEDIAMTX_SHARED_SECRET: ${MEDIAMTX_SHARED_SECRET:-}
|
||||||
|
RTMP_INTERNAL_PORT: 1935
|
||||||
|
HLS_INTERNAL_PORT: 8888
|
||||||
|
ports:
|
||||||
|
- "${RTMP_PUBLIC_PORT:-1935}:1935"
|
||||||
|
volumes:
|
||||||
|
- app-storage:/var/www/html/storage
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-nyone}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-nyone}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-storage:
|
||||||
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
75
dockerized/docker/app/Dockerfile
Normal file
75
dockerized/docker/app/Dockerfile
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS assets
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY runtime/source/package.json runtime/source/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY runtime/source/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM php:8.4-apache-bookworm AS app
|
||||||
|
|
||||||
|
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gettext-base \
|
||||||
|
git \
|
||||||
|
gosu \
|
||||||
|
libicu-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libzip-dev \
|
||||||
|
netcat-openbsd \
|
||||||
|
unzip \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" \
|
||||||
|
bcmath \
|
||||||
|
curl \
|
||||||
|
intl \
|
||||||
|
mbstring \
|
||||||
|
opcache \
|
||||||
|
pcntl \
|
||||||
|
pdo_pgsql \
|
||||||
|
pgsql \
|
||||||
|
zip \
|
||||||
|
&& pecl install redis \
|
||||||
|
&& docker-php-ext-enable redis \
|
||||||
|
&& a2enmod headers proxy proxy_http rewrite setenvif \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
COPY runtime/source/composer.json runtime/source/composer.lock ./
|
||||||
|
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
|
||||||
|
|
||||||
|
COPY runtime/source/ ./
|
||||||
|
COPY --from=assets /build/public/build ./public/build
|
||||||
|
|
||||||
|
COPY docker/app/apache/nyone.conf.template /etc/apache2/sites-available/nyone.conf.template
|
||||||
|
COPY docker/app/php/production.ini /usr/local/etc/php/conf.d/production.ini
|
||||||
|
COPY docker/app/entrypoint.sh /usr/local/bin/nyone-entrypoint
|
||||||
|
|
||||||
|
RUN composer dump-autoload --optimize --no-dev \
|
||||||
|
&& php artisan package:discover --ansi \
|
||||||
|
&& mkdir -p \
|
||||||
|
storage/app/private/recordings \
|
||||||
|
storage/app/public \
|
||||||
|
storage/framework/cache/data \
|
||||||
|
storage/framework/sessions \
|
||||||
|
storage/framework/views \
|
||||||
|
storage/logs \
|
||||||
|
bootstrap/cache \
|
||||||
|
&& chown -R www-data:www-data storage bootstrap/cache \
|
||||||
|
&& chmod +x /usr/local/bin/nyone-entrypoint
|
||||||
|
|
||||||
|
ENTRYPOINT ["nyone-entrypoint"]
|
||||||
|
CMD ["app"]
|
||||||
43
dockerized/docker/app/apache/nyone.conf.template
Normal file
43
dockerized/docker/app/apache/nyone.conf.template
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
ServerName ${APP_DOMAIN}
|
||||||
|
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName ${APP_DOMAIN}
|
||||||
|
|
||||||
|
DocumentRoot /var/www/html/public
|
||||||
|
DirectoryIndex index.php
|
||||||
|
|
||||||
|
SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
|
||||||
|
SetEnvIf X-Forwarded-Ssl "^on$" HTTPS=on
|
||||||
|
|
||||||
|
<Directory /var/www/html/public>
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<FilesMatch "\.php$">
|
||||||
|
SetHandler application/x-httpd-php
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
ErrorLog /proc/self/fd/2
|
||||||
|
CustomLog /proc/self/fd/1 combined
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName ${HLS_DOMAIN}
|
||||||
|
|
||||||
|
ProxyRequests Off
|
||||||
|
ProxyPreserveHost On
|
||||||
|
|
||||||
|
ProxyPass "/" "http://mediamtx:${HLS_INTERNAL_PORT}/" retry=0
|
||||||
|
ProxyPassReverse "/" "http://mediamtx:${HLS_INTERNAL_PORT}/"
|
||||||
|
|
||||||
|
Header always set Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"
|
||||||
|
Header always set Access-Control-Allow-Origin "${APP_URL}"
|
||||||
|
Header always set Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
|
||||||
|
Header always set Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type"
|
||||||
|
|
||||||
|
ErrorLog /proc/self/fd/2
|
||||||
|
CustomLog /proc/self/fd/1 combined
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
134
dockerized/docker/app/entrypoint.sh
Normal file
134
dockerized/docker/app/entrypoint.sh
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd /var/www/html
|
||||||
|
|
||||||
|
role="${CONTAINER_ROLE:-${1:-app}}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "nyone-entrypoint: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local key="$1"
|
||||||
|
local value="${!key:-}"
|
||||||
|
|
||||||
|
if [[ -z "$value" || "$value" == "change-me"* || "$value" == "base64:change-me"* ]]; then
|
||||||
|
fail "$key is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for() {
|
||||||
|
local host="$1"
|
||||||
|
local port="$2"
|
||||||
|
local label="$3"
|
||||||
|
local attempts="${4:-60}"
|
||||||
|
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if nc -z "$host" "$port" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
fail "Timed out waiting for $label at $host:$port."
|
||||||
|
}
|
||||||
|
|
||||||
|
artisan() {
|
||||||
|
gosu www-data php artisan "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_apache_config() {
|
||||||
|
export APP_DOMAIN HLS_DOMAIN HLS_INTERNAL_PORT APP_URL
|
||||||
|
|
||||||
|
envsubst '${APP_DOMAIN} ${HLS_DOMAIN} ${HLS_INTERNAL_PORT} ${APP_URL}' \
|
||||||
|
< /etc/apache2/sites-available/nyone.conf.template \
|
||||||
|
> /etc/apache2/sites-available/000-default.conf
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_laravel_paths() {
|
||||||
|
mkdir -p \
|
||||||
|
storage/app/private/recordings \
|
||||||
|
storage/app/public \
|
||||||
|
storage/framework/cache/data \
|
||||||
|
storage/framework/sessions \
|
||||||
|
storage/framework/views \
|
||||||
|
storage/logs \
|
||||||
|
bootstrap/cache
|
||||||
|
|
||||||
|
chown -R www-data:www-data storage bootstrap/cache
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_laravel_bootstrap() {
|
||||||
|
if [[ "${APP_OPTIMIZE_ON_BOOT:-true}" != "true" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
artisan config:clear
|
||||||
|
artisan route:clear
|
||||||
|
artisan view:clear
|
||||||
|
artisan event:clear
|
||||||
|
artisan config:cache
|
||||||
|
artisan route:cache
|
||||||
|
artisan view:cache
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env APP_KEY
|
||||||
|
require_env APP_DOMAIN
|
||||||
|
require_env HLS_DOMAIN
|
||||||
|
require_env MEDIAMTX_SHARED_SECRET
|
||||||
|
require_env DB_DATABASE
|
||||||
|
require_env DB_USERNAME
|
||||||
|
require_env DB_PASSWORD
|
||||||
|
|
||||||
|
if [[ "${WAIT_FOR_SERVICES:-true}" == "true" ]]; then
|
||||||
|
wait_for "${DB_HOST:-db}" "${DB_PORT:-5432}" "PostgreSQL"
|
||||||
|
wait_for "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}" "Redis"
|
||||||
|
|
||||||
|
if [[ "$role" == "viewer-sync" ]]; then
|
||||||
|
wait_for "${MEDIAMTX_HOST:-mediamtx}" "${MEDIAMTX_API_PORT:-9997}" "MediaMTX API"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
prepare_laravel_paths
|
||||||
|
artisan storage:link --force >/dev/null || true
|
||||||
|
cache_laravel_bootstrap
|
||||||
|
|
||||||
|
case "$role" in
|
||||||
|
app)
|
||||||
|
render_apache_config
|
||||||
|
exec apache2-foreground
|
||||||
|
;;
|
||||||
|
migrate)
|
||||||
|
artisan migrate --force
|
||||||
|
|
||||||
|
if [[ "${SEED_DATABASE:-false}" == "true" ]]; then
|
||||||
|
artisan db:seed --force
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
queue)
|
||||||
|
exec gosu www-data php artisan queue:work "${QUEUE_CONNECTION:-database}" \
|
||||||
|
--queue="${QUEUE_NAMES:-default}" \
|
||||||
|
--sleep="${QUEUE_SLEEP:-3}" \
|
||||||
|
--tries="${QUEUE_TRIES:-3}" \
|
||||||
|
--max-time="${QUEUE_MAX_TIME:-3600}" \
|
||||||
|
--timeout="${QUEUE_TIMEOUT:-90}"
|
||||||
|
;;
|
||||||
|
scheduler)
|
||||||
|
exec gosu www-data php artisan schedule:work
|
||||||
|
;;
|
||||||
|
viewer-sync)
|
||||||
|
exec gosu www-data php artisan streaming:sync-viewer-counts \
|
||||||
|
--isolated \
|
||||||
|
--interval="${STREAMING_VIEWER_SYNC_INTERVAL:-2}"
|
||||||
|
;;
|
||||||
|
artisan)
|
||||||
|
shift || true
|
||||||
|
exec gosu www-data php artisan "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exec "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
17
dockerized/docker/app/php/production.ini
Normal file
17
dockerized/docker/app/php/production.ini
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
expose_php=Off
|
||||||
|
memory_limit=512M
|
||||||
|
upload_max_filesize=100M
|
||||||
|
post_max_size=100M
|
||||||
|
max_execution_time=120
|
||||||
|
max_input_vars=3000
|
||||||
|
variables_order=EGPCS
|
||||||
|
|
||||||
|
opcache.enable=1
|
||||||
|
opcache.enable_cli=1
|
||||||
|
opcache.memory_consumption=256
|
||||||
|
opcache.interned_strings_buffer=32
|
||||||
|
opcache.max_accelerated_files=20000
|
||||||
|
opcache.validate_timestamps=0
|
||||||
|
opcache.save_comments=1
|
||||||
|
opcache.jit=0
|
||||||
|
|
||||||
17
dockerized/docker/mediamtx/Dockerfile
Normal file
17
dockerized/docker/mediamtx/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM bluenviron/mediamtx:latest-ffmpeg
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN (apk add --no-cache curl gettext >/dev/null 2>&1) \
|
||||||
|
|| (apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl gettext-base \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*)
|
||||||
|
|
||||||
|
COPY docker/mediamtx/entrypoint.sh /usr/local/bin/nyone-mediamtx-entrypoint
|
||||||
|
COPY docker/mediamtx/mediamtx.yml.template /etc/mediamtx/mediamtx.yml.template
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/nyone-mediamtx-entrypoint \
|
||||||
|
&& mkdir -p /config /var/www/html/storage/app/private/recordings
|
||||||
|
|
||||||
|
ENTRYPOINT ["nyone-mediamtx-entrypoint"]
|
||||||
|
|
||||||
34
dockerized/docker/mediamtx/entrypoint.sh
Normal file
34
dockerized/docker/mediamtx/entrypoint.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "nyone-mediamtx-entrypoint: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "${MEDIAMTX_SHARED_SECRET:-}" ] || [ "${MEDIAMTX_SHARED_SECRET#change-me}" != "$MEDIAMTX_SHARED_SECRET" ]; then
|
||||||
|
fail "MEDIAMTX_SHARED_SECRET is required. Run dockerized/scripts/deploy.sh or set it in dockerized/.env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${APP_URL:?APP_URL is required.}"
|
||||||
|
: "${HLS_PUBLIC_URL:?HLS_PUBLIC_URL is required.}"
|
||||||
|
: "${RTMP_INTERNAL_PORT:=1935}"
|
||||||
|
: "${HLS_INTERNAL_PORT:=8888}"
|
||||||
|
|
||||||
|
mkdir -p /config /var/www/html/storage/app/private/recordings
|
||||||
|
|
||||||
|
export APP_URL HLS_PUBLIC_URL MEDIAMTX_SHARED_SECRET RTMP_INTERNAL_PORT HLS_INTERNAL_PORT
|
||||||
|
|
||||||
|
envsubst '${APP_URL} ${HLS_PUBLIC_URL} ${MEDIAMTX_SHARED_SECRET} ${RTMP_INTERNAL_PORT} ${HLS_INTERNAL_PORT}' \
|
||||||
|
< /etc/mediamtx/mediamtx.yml.template \
|
||||||
|
> /config/mediamtx.yml
|
||||||
|
|
||||||
|
if command -v mediamtx >/dev/null 2>&1; then
|
||||||
|
exec mediamtx /config/mediamtx.yml
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x /mediamtx ]; then
|
||||||
|
exec /mediamtx /config/mediamtx.yml
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "MediaMTX binary was not found in the base image."
|
||||||
35
dockerized/docker/mediamtx/mediamtx.yml.template
Normal file
35
dockerized/docker/mediamtx/mediamtx.yml.template
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
authMethod: http
|
||||||
|
authHTTPAddress: http://app/internal/mediamtx/auth?secret=${MEDIAMTX_SHARED_SECRET}
|
||||||
|
authHTTPExclude:
|
||||||
|
- action: read
|
||||||
|
- action: playback
|
||||||
|
- action: api
|
||||||
|
- action: metrics
|
||||||
|
- action: pprof
|
||||||
|
|
||||||
|
api: yes
|
||||||
|
apiAddress: :9997
|
||||||
|
|
||||||
|
rtmp: yes
|
||||||
|
rtmpAddress: :${RTMP_INTERNAL_PORT}
|
||||||
|
|
||||||
|
hls: yes
|
||||||
|
hlsAddress: :${HLS_INTERNAL_PORT}
|
||||||
|
hlsVariant: mpegts
|
||||||
|
hlsAllowOrigins:
|
||||||
|
- ${APP_URL}
|
||||||
|
- ${HLS_PUBLIC_URL}
|
||||||
|
|
||||||
|
pathDefaults:
|
||||||
|
record: yes
|
||||||
|
recordPath: /var/www/html/storage/app/private/recordings/%path/%Y-%m-%d_%H-%M-%S-%f
|
||||||
|
|
||||||
|
runOnReady: curl -fsS -X POST "http://app/internal/mediamtx/ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
|
||||||
|
runOnReadyRestart: no
|
||||||
|
runOnNotReady: curl -fsS -X POST "http://app/internal/mediamtx/not-ready?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "source_type=$MTX_SOURCE_TYPE" --data-urlencode "source_id=$MTX_SOURCE_ID"
|
||||||
|
runOnRecordSegmentComplete: curl -fsS -X POST "http://app/internal/mediamtx/recording-complete?secret=${MEDIAMTX_SHARED_SECRET}" --data-urlencode "path=$MTX_PATH" --data-urlencode "segment_path=$MTX_SEGMENT_PATH" --data-urlencode "duration=$MTX_SEGMENT_DURATION"
|
||||||
|
|
||||||
|
paths:
|
||||||
|
all_others:
|
||||||
|
source: publisher
|
||||||
|
|
||||||
184
dockerized/scripts/deploy.sh
Normal file
184
dockerized/scripts/deploy.sh
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
ENV_FILE="$ROOT_DIR/.env"
|
||||||
|
EXAMPLE_ENV_FILE="$ROOT_DIR/.env.example"
|
||||||
|
SOURCE_DIR="$ROOT_DIR/runtime/source"
|
||||||
|
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
info() {
|
||||||
|
printf '[nyone-deploy] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '[nyone-deploy] ERROR: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || fail "$1 is required."
|
||||||
|
}
|
||||||
|
|
||||||
|
env_value() {
|
||||||
|
local key="$1"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
awk -v key="$key" '
|
||||||
|
index($0, key "=") == 1 {
|
||||||
|
value = substr($0, length(key) + 2)
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
print value
|
||||||
|
}
|
||||||
|
' "$ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local escaped
|
||||||
|
|
||||||
|
escaped="$(printf '%s' "$value" | sed 's/[\/&]/\\&/g')"
|
||||||
|
|
||||||
|
if grep -qE "^${key}=" "$ENV_FILE"; then
|
||||||
|
sed -i.bak "s/^${key}=.*/${key}=${escaped}/" "$ENV_FILE"
|
||||||
|
rm -f "$ENV_FILE.bak"
|
||||||
|
else
|
||||||
|
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_missing_secret() {
|
||||||
|
local value="$1"
|
||||||
|
|
||||||
|
[ -z "$value" ] || [[ "$value" == "change-me"* ]] || [[ "$value" == "base64:change-me"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
random_secret() {
|
||||||
|
openssl rand -base64 48 | tr -d '\n' | tr '/+' '_-' | tr -d '='
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_env_file() {
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$EXAMPLE_ENV_FILE" ] || fail ".env.example was not found."
|
||||||
|
cp "$EXAMPLE_ENV_FILE" "$ENV_FILE"
|
||||||
|
info "Created .env from .env.example."
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_secret() {
|
||||||
|
local key="$1"
|
||||||
|
local value
|
||||||
|
|
||||||
|
value="$(env_value "$key")"
|
||||||
|
|
||||||
|
if is_missing_secret "$value"; then
|
||||||
|
set_env_value "$key" "$(random_secret)"
|
||||||
|
info "Generated $key."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_config_value() {
|
||||||
|
local key="$1"
|
||||||
|
local placeholder="${2:-}"
|
||||||
|
local value
|
||||||
|
|
||||||
|
value="$(env_value "$key")"
|
||||||
|
|
||||||
|
[ -n "$value" ] || fail "Set $key in $ENV_FILE."
|
||||||
|
|
||||||
|
if [ -n "$placeholder" ] && [ "$value" = "$placeholder" ]; then
|
||||||
|
fail "Replace the placeholder $key in $ENV_FILE."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_app_key() {
|
||||||
|
local value
|
||||||
|
|
||||||
|
value="$(env_value APP_KEY)"
|
||||||
|
|
||||||
|
if is_missing_secret "$value"; then
|
||||||
|
set_env_value APP_KEY "base64:$(openssl rand -base64 32 | tr -d '\n')"
|
||||||
|
info "Generated APP_KEY."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
clone_or_update_source() {
|
||||||
|
local repository_url="$1"
|
||||||
|
local git_ref="$2"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$SOURCE_DIR")"
|
||||||
|
|
||||||
|
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||||
|
info "Updating existing source checkout."
|
||||||
|
git -C "$SOURCE_DIR" fetch --prune --tags origin
|
||||||
|
|
||||||
|
if git -C "$SOURCE_DIR" rev-parse --verify --quiet "origin/$git_ref" >/dev/null; then
|
||||||
|
git -C "$SOURCE_DIR" checkout -B "$git_ref" "origin/$git_ref"
|
||||||
|
else
|
||||||
|
git -C "$SOURCE_DIR" checkout "$git_ref"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e "$SOURCE_DIR" ]; then
|
||||||
|
fail "$SOURCE_DIR exists but is not a Git checkout. Move it away or remove it before deploying."
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Cloning application repository."
|
||||||
|
git clone "$repository_url" "$SOURCE_DIR"
|
||||||
|
git -C "$SOURCE_DIR" checkout "$git_ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_compose() {
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command git
|
||||||
|
require_command docker
|
||||||
|
require_command openssl
|
||||||
|
|
||||||
|
ensure_env_file
|
||||||
|
ensure_app_key
|
||||||
|
ensure_secret POSTGRES_PASSWORD
|
||||||
|
ensure_secret MEDIAMTX_SHARED_SECRET
|
||||||
|
|
||||||
|
GIT_REPOSITORY_URL="$(env_value GIT_REPOSITORY_URL)"
|
||||||
|
GIT_REF="$(env_value GIT_REF)"
|
||||||
|
|
||||||
|
require_config_value GIT_REPOSITORY_URL "https://github.com/your-org/nyone.git"
|
||||||
|
require_config_value APP_DOMAIN "nyone.example.com"
|
||||||
|
require_config_value HLS_DOMAIN "nyone-hls.example.com"
|
||||||
|
|
||||||
|
if [ "$(env_value APP_DOMAIN)" = "$(env_value HLS_DOMAIN)" ]; then
|
||||||
|
fail "APP_DOMAIN and HLS_DOMAIN must be different because this deployment uses separate Apache virtual hosts."
|
||||||
|
fi
|
||||||
|
|
||||||
|
GIT_REF="${GIT_REF:-main}"
|
||||||
|
|
||||||
|
clone_or_update_source "$GIT_REPOSITORY_URL" "$GIT_REF"
|
||||||
|
|
||||||
|
info "Validating Compose configuration."
|
||||||
|
run_compose config >/dev/null
|
||||||
|
|
||||||
|
info "Building Docker images."
|
||||||
|
run_compose build
|
||||||
|
|
||||||
|
info "Starting database and Redis."
|
||||||
|
run_compose up -d db redis
|
||||||
|
|
||||||
|
info "Running migrations."
|
||||||
|
run_compose --profile tools run --rm migrate
|
||||||
|
|
||||||
|
info "Starting background workers."
|
||||||
|
run_compose up -d --remove-orphans app mediamtx queue scheduler viewer-sync
|
||||||
|
|
||||||
|
info "Current container status:"
|
||||||
|
run_compose ps
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"Create an account": "إنشاء حساب",
|
"Create an account": "إنشاء حساب",
|
||||||
"Create channel": "إنشاء قناة",
|
"Create channel": "إنشاء قناة",
|
||||||
"Create your channel": "أنشئ قناتك",
|
"Create your channel": "أنشئ قناتك",
|
||||||
|
"Creative": "الإبداع",
|
||||||
"Creator Studio": "استوديو المنشئ",
|
"Creator Studio": "استوديو المنشئ",
|
||||||
"Creator access grants stay in the moderation console.": "تبقى أذونات وصول المنشئ في وحدة الإشراف.",
|
"Creator access grants stay in the moderation console.": "تبقى أذونات وصول المنشئ في وحدة الإشراف.",
|
||||||
"Creator grants": "أذونات المنشئ",
|
"Creator grants": "أذونات المنشئ",
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"Don't have an account?": "ليس لديك حساب؟",
|
"Don't have an account?": "ليس لديك حساب؟",
|
||||||
"Each account can own one public channel in this version.": "يمكن لكل حساب امتلاك قناة عامة واحدة في هذا الإصدار.",
|
"Each account can own one public channel in this version.": "يمكن لكل حساب امتلاك قناة عامة واحدة في هذا الإصدار.",
|
||||||
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "يمكن استخدام كل رمز استرداد مرة واحدة للوصول إلى حسابك وسيتم حذفه بعد الاستخدام. إذا احتجت إلى المزيد، فانقر",
|
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "يمكن استخدام كل رمز استرداد مرة واحدة للوصول إلى حسابك وسيتم حذفه بعد الاستخدام. إذا احتجت إلى المزيد، فانقر",
|
||||||
|
"Education": "التعليم",
|
||||||
"Email": "البريد الإلكتروني",
|
"Email": "البريد الإلكتروني",
|
||||||
"Email address": "عنوان البريد الإلكتروني",
|
"Email address": "عنوان البريد الإلكتروني",
|
||||||
"Email password reset link": "إرسال رابط إعادة تعيين كلمة المرور",
|
"Email password reset link": "إرسال رابط إعادة تعيين كلمة المرور",
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
"Forgot password": "نسيت كلمة المرور",
|
"Forgot password": "نسيت كلمة المرور",
|
||||||
"Forgot password?": "هل نسيت كلمة المرور؟",
|
"Forgot password?": "هل نسيت كلمة المرور؟",
|
||||||
"Full name": "الاسم الكامل",
|
"Full name": "الاسم الكامل",
|
||||||
|
"Gaming": "الألعاب",
|
||||||
"Grant": "منح",
|
"Grant": "منح",
|
||||||
"Hide password": "إخفاء كلمة المرور",
|
"Hide password": "إخفاء كلمة المرور",
|
||||||
"Hide recovery codes": "إخفاء رموز الاسترداد",
|
"Hide recovery codes": "إخفاء رموز الاسترداد",
|
||||||
@@ -152,6 +155,7 @@
|
|||||||
"Message sent to admin.": "تم إرسال الرسالة إلى المسؤول.",
|
"Message sent to admin.": "تم إرسال الرسالة إلى المسؤول.",
|
||||||
"Moderation console": "وحدة الإشراف",
|
"Moderation console": "وحدة الإشراف",
|
||||||
"More": "المزيد",
|
"More": "المزيد",
|
||||||
|
"Music": "الموسيقى",
|
||||||
"NEEDS APPROVAL": "يتطلب موافقة",
|
"NEEDS APPROVAL": "يتطلب موافقة",
|
||||||
"NEEDS CHANNEL GRANT": "يتطلب إذن قناة",
|
"NEEDS CHANNEL GRANT": "يتطلب إذن قناة",
|
||||||
"Name": "الاسم",
|
"Name": "الاسم",
|
||||||
@@ -268,6 +272,7 @@
|
|||||||
"Suspend": "تعليق",
|
"Suspend": "تعليق",
|
||||||
"Suspend channel?": "تعليق القناة؟",
|
"Suspend channel?": "تعليق القناة؟",
|
||||||
"System": "النظام",
|
"System": "النظام",
|
||||||
|
"Talk Shows": "برامج الحوار",
|
||||||
"The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.",
|
"The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.",
|
||||||
"The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.",
|
"The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.",
|
||||||
"The live floor is quiet": "ساحة البث هادئة",
|
"The live floor is quiet": "ساحة البث هادئة",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"Create an account": "Create an account",
|
"Create an account": "Create an account",
|
||||||
"Create channel": "Create channel",
|
"Create channel": "Create channel",
|
||||||
"Create your channel": "Create your channel",
|
"Create your channel": "Create your channel",
|
||||||
|
"Creative": "Creative",
|
||||||
"Creator Studio": "Creator Studio",
|
"Creator Studio": "Creator Studio",
|
||||||
"Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.",
|
"Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.",
|
||||||
"Creator grants": "Creator grants",
|
"Creator grants": "Creator grants",
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"Don't have an account?": "Don't have an account?",
|
"Don't have an account?": "Don't have an account?",
|
||||||
"Each account can own one public channel in this version.": "Each account can own one public channel in this version.",
|
"Each account can own one public channel in this version.": "Each account can own one public channel in this version.",
|
||||||
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Each recovery code can be used once to access your account and will be removed after use. If you need more, click",
|
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Each recovery code can be used once to access your account and will be removed after use. If you need more, click",
|
||||||
|
"Education": "Education",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Email address": "Email address",
|
"Email address": "Email address",
|
||||||
"Email password reset link": "Email password reset link",
|
"Email password reset link": "Email password reset link",
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
"Forgot password": "Forgot password",
|
"Forgot password": "Forgot password",
|
||||||
"Forgot password?": "Forgot password?",
|
"Forgot password?": "Forgot password?",
|
||||||
"Full name": "Full name",
|
"Full name": "Full name",
|
||||||
|
"Gaming": "Gaming",
|
||||||
"Grant": "Grant",
|
"Grant": "Grant",
|
||||||
"Hide password": "Hide password",
|
"Hide password": "Hide password",
|
||||||
"Hide recovery codes": "Hide recovery codes",
|
"Hide recovery codes": "Hide recovery codes",
|
||||||
@@ -152,6 +155,7 @@
|
|||||||
"Message sent to admin.": "Message sent to admin.",
|
"Message sent to admin.": "Message sent to admin.",
|
||||||
"Moderation console": "Moderation console",
|
"Moderation console": "Moderation console",
|
||||||
"More": "More",
|
"More": "More",
|
||||||
|
"Music": "Music",
|
||||||
"NEEDS APPROVAL": "NEEDS APPROVAL",
|
"NEEDS APPROVAL": "NEEDS APPROVAL",
|
||||||
"NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT",
|
"NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT",
|
||||||
"Name": "Name",
|
"Name": "Name",
|
||||||
@@ -268,6 +272,7 @@
|
|||||||
"Suspend": "Suspend",
|
"Suspend": "Suspend",
|
||||||
"Suspend channel?": "Suspend channel?",
|
"Suspend channel?": "Suspend channel?",
|
||||||
"System": "System",
|
"System": "System",
|
||||||
|
"Talk Shows": "Talk Shows",
|
||||||
"The channel will be hidden from public browsing and watch pages.": "The channel will be hidden from public browsing and watch pages.",
|
"The channel will be hidden from public browsing and watch pages.": "The channel will be hidden from public browsing and watch pages.",
|
||||||
"The channel will be visible again if the owning account is active.": "The channel will be visible again if the owning account is active.",
|
"The channel will be visible again if the owning account is active.": "The channel will be visible again if the owning account is active.",
|
||||||
"The live floor is quiet": "The live floor is quiet",
|
"The live floor is quiet": "The live floor is quiet",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"Create an account": "Crear una cuenta",
|
"Create an account": "Crear una cuenta",
|
||||||
"Create channel": "Crear canal",
|
"Create channel": "Crear canal",
|
||||||
"Create your channel": "Crea tu canal",
|
"Create your channel": "Crea tu canal",
|
||||||
|
"Creative": "Creatividad",
|
||||||
"Creator Studio": "Estudio de creador",
|
"Creator Studio": "Estudio de creador",
|
||||||
"Creator access grants stay in the moderation console.": "Los permisos de acceso de creador permanecen en la consola de moderación.",
|
"Creator access grants stay in the moderation console.": "Los permisos de acceso de creador permanecen en la consola de moderación.",
|
||||||
"Creator grants": "Permisos de creador",
|
"Creator grants": "Permisos de creador",
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"Don't have an account?": "¿No tienes una cuenta?",
|
"Don't have an account?": "¿No tienes una cuenta?",
|
||||||
"Each account can own one public channel in this version.": "Cada cuenta puede tener un canal público en esta versión.",
|
"Each account can own one public channel in this version.": "Cada cuenta puede tener un canal público en esta versión.",
|
||||||
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación puede usarse una vez para acceder a tu cuenta y se eliminará después de usarlo. Si necesitas más, haz clic",
|
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación puede usarse una vez para acceder a tu cuenta y se eliminará después de usarlo. Si necesitas más, haz clic",
|
||||||
|
"Education": "Educación",
|
||||||
"Email": "Correo electrónico",
|
"Email": "Correo electrónico",
|
||||||
"Email address": "Dirección de correo electrónico",
|
"Email address": "Dirección de correo electrónico",
|
||||||
"Email password reset link": "Enviar enlace para restablecer contraseña",
|
"Email password reset link": "Enviar enlace para restablecer contraseña",
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
"Forgot password": "Olvidé mi contraseña",
|
"Forgot password": "Olvidé mi contraseña",
|
||||||
"Forgot password?": "¿Olvidaste tu contraseña?",
|
"Forgot password?": "¿Olvidaste tu contraseña?",
|
||||||
"Full name": "Nombre completo",
|
"Full name": "Nombre completo",
|
||||||
|
"Gaming": "Videojuegos",
|
||||||
"Grant": "Conceder",
|
"Grant": "Conceder",
|
||||||
"Hide password": "Ocultar contraseña",
|
"Hide password": "Ocultar contraseña",
|
||||||
"Hide recovery codes": "Ocultar códigos de recuperación",
|
"Hide recovery codes": "Ocultar códigos de recuperación",
|
||||||
@@ -152,6 +155,7 @@
|
|||||||
"Message sent to admin.": "Mensaje enviado al administrador.",
|
"Message sent to admin.": "Mensaje enviado al administrador.",
|
||||||
"Moderation console": "Consola de moderación",
|
"Moderation console": "Consola de moderación",
|
||||||
"More": "Más",
|
"More": "Más",
|
||||||
|
"Music": "Música",
|
||||||
"NEEDS APPROVAL": "REQUIERE APROBACIÓN",
|
"NEEDS APPROVAL": "REQUIERE APROBACIÓN",
|
||||||
"NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL",
|
"NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL",
|
||||||
"Name": "Nombre",
|
"Name": "Nombre",
|
||||||
@@ -268,6 +272,7 @@
|
|||||||
"Suspend": "Suspender",
|
"Suspend": "Suspender",
|
||||||
"Suspend channel?": "¿Suspender canal?",
|
"Suspend channel?": "¿Suspender canal?",
|
||||||
"System": "Sistema",
|
"System": "Sistema",
|
||||||
|
"Talk Shows": "Programas de conversación",
|
||||||
"The channel will be hidden from public browsing and watch pages.": "El canal se ocultará de la navegación pública y de las páginas de visualización.",
|
"The channel will be hidden from public browsing and watch pages.": "El canal se ocultará de la navegación pública y de las páginas de visualización.",
|
||||||
"The channel will be visible again if the owning account is active.": "El canal volverá a estar visible si la cuenta propietaria está activa.",
|
"The channel will be visible again if the owning account is active.": "El canal volverá a estar visible si la cuenta propietaria está activa.",
|
||||||
"The live floor is quiet": "No hay actividad en vivo",
|
"The live floor is quiet": "No hay actividad en vivo",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"Create an account": "ایجاد یک حساب",
|
"Create an account": "ایجاد یک حساب",
|
||||||
"Create channel": "ایجاد کانال",
|
"Create channel": "ایجاد کانال",
|
||||||
"Create your channel": "کانال خود را ایجاد کنید",
|
"Create your channel": "کانال خود را ایجاد کنید",
|
||||||
|
"Creative": "خلاقیت",
|
||||||
"Creator Studio": "استودیوی سازنده",
|
"Creator Studio": "استودیوی سازنده",
|
||||||
"Creator access grants stay in the moderation console.": "مجوزهای دسترسی سازنده در کنسول مدیریت باقی میمانند.",
|
"Creator access grants stay in the moderation console.": "مجوزهای دسترسی سازنده در کنسول مدیریت باقی میمانند.",
|
||||||
"Creator grants": "مجوزهای سازنده",
|
"Creator grants": "مجوزهای سازنده",
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"Don't have an account?": "حساب ندارید؟",
|
"Don't have an account?": "حساب ندارید؟",
|
||||||
"Each account can own one public channel in this version.": "در این نسخه هر حساب میتواند یک کانال عمومی داشته باشد.",
|
"Each account can own one public channel in this version.": "در این نسخه هر حساب میتواند یک کانال عمومی داشته باشد.",
|
||||||
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "هر کد بازیابی فقط یکبار برای دسترسی به حساب شما قابل استفاده است و پس از استفاده حذف میشود. اگر به کدهای بیشتری نیاز دارید، کلیک کنید",
|
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "هر کد بازیابی فقط یکبار برای دسترسی به حساب شما قابل استفاده است و پس از استفاده حذف میشود. اگر به کدهای بیشتری نیاز دارید، کلیک کنید",
|
||||||
|
"Education": "آموزش",
|
||||||
"Email": "ایمیل",
|
"Email": "ایمیل",
|
||||||
"Email address": "آدرس ایمیل",
|
"Email address": "آدرس ایمیل",
|
||||||
"Email password reset link": "ارسال لینک بازنشانی رمز عبور",
|
"Email password reset link": "ارسال لینک بازنشانی رمز عبور",
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
"Forgot password": "فراموشی رمز عبور",
|
"Forgot password": "فراموشی رمز عبور",
|
||||||
"Forgot password?": "رمز عبور را فراموش کردهاید؟",
|
"Forgot password?": "رمز عبور را فراموش کردهاید؟",
|
||||||
"Full name": "نام کامل",
|
"Full name": "نام کامل",
|
||||||
|
"Gaming": "بازی",
|
||||||
"Grant": "اعطا",
|
"Grant": "اعطا",
|
||||||
"Hide password": "پنهان کردن رمز عبور",
|
"Hide password": "پنهان کردن رمز عبور",
|
||||||
"Hide recovery codes": "پنهان کردن کدهای بازیابی",
|
"Hide recovery codes": "پنهان کردن کدهای بازیابی",
|
||||||
@@ -152,6 +155,7 @@
|
|||||||
"Message sent to admin.": "پیام برای مدیر ارسال شد.",
|
"Message sent to admin.": "پیام برای مدیر ارسال شد.",
|
||||||
"Moderation console": "کنسول مدیریت",
|
"Moderation console": "کنسول مدیریت",
|
||||||
"More": "بیشتر",
|
"More": "بیشتر",
|
||||||
|
"Music": "موسیقی",
|
||||||
"NEEDS APPROVAL": "نیازمند تأیید",
|
"NEEDS APPROVAL": "نیازمند تأیید",
|
||||||
"NEEDS CHANNEL GRANT": "نیازمند مجوز کانال",
|
"NEEDS CHANNEL GRANT": "نیازمند مجوز کانال",
|
||||||
"Name": "نام",
|
"Name": "نام",
|
||||||
@@ -268,6 +272,7 @@
|
|||||||
"Suspend": "تعلیق",
|
"Suspend": "تعلیق",
|
||||||
"Suspend channel?": "کانال تعلیق شود؟",
|
"Suspend channel?": "کانال تعلیق شود؟",
|
||||||
"System": "سیستم",
|
"System": "سیستم",
|
||||||
|
"Talk Shows": "گفتوگو",
|
||||||
"The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.",
|
"The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.",
|
||||||
"The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.",
|
"The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.",
|
||||||
"The live floor is quiet": "فضای زنده آرام است",
|
"The live floor is quiet": "فضای زنده آرام است",
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
@font-face {
|
|
||||||
font-family: 'IRANSansWeb';
|
|
||||||
src: url('IRANSansWeb.eot');
|
|
||||||
src: local('IRANSansWeb'),
|
|
||||||
url('IRANSansWeb.eot?#iefix') format('embedded-opentype'),
|
|
||||||
url('IRANSansWeb.woff2') format('woff2'),
|
|
||||||
url('IRANSansWeb.woff') format('woff'),
|
|
||||||
url('IRANSansWeb.ttf') format('truetype'),
|
|
||||||
url('IRANSansWeb.svg#IRANSansWeb') format('svg');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'IRANSansWeb';
|
font-family: 'IRANSansWeb';
|
||||||
src:
|
src:
|
||||||
url('/fonts/IRANSansWeb/IRANSansWeb.woff2') format('woff2'),
|
url('./fonts/IRANSansWeb/IRANSansWeb.woff2') format('woff2'),
|
||||||
url('/fonts/IRANSansWeb/IRANSansWeb.woff') format('woff'),
|
url('./fonts/IRANSansWeb/IRANSansWeb.woff') format('woff'),
|
||||||
url('/fonts/IRANSansWeb/IRANSansWeb.ttf') format('truetype');
|
url('./fonts/IRANSansWeb/IRANSansWeb.ttf') format('truetype');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 237 KiB |
14
resources/css/fonts/IRANSansWeb/stylesheet.css
Normal file
14
resources/css/fonts/IRANSansWeb/stylesheet.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'IRANSansWeb';
|
||||||
|
src: url('IRANSansWeb.eot');
|
||||||
|
src:
|
||||||
|
local('IRANSansWeb'),
|
||||||
|
url('IRANSansWeb.eot?#iefix') format('embedded-opentype'),
|
||||||
|
url('IRANSansWeb.woff2') format('woff2'),
|
||||||
|
url('IRANSansWeb.woff') format('woff'),
|
||||||
|
url('IRANSansWeb.ttf') format('truetype'),
|
||||||
|
url('IRANSansWeb.svg#IRANSansWeb') format('svg');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ export default function ChannelShow({
|
|||||||
<LiveState channel={channel} />
|
<LiveState channel={channel} />
|
||||||
{channel.category && (
|
{channel.category && (
|
||||||
<span className="rounded-md border px-2 py-1 text-xs">
|
<span className="rounded-md border px-2 py-1 text-xs">
|
||||||
{channel.category.name}
|
{channel.category.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
update as updateChannelRoute,
|
update as updateChannelRoute,
|
||||||
} from '@/routes/creator/channel';
|
} from '@/routes/creator/channel';
|
||||||
import { rotate as rotateStreamKeyRoute } from '@/routes/creator/stream-key';
|
import { rotate as rotateStreamKeyRoute } from '@/routes/creator/stream-key';
|
||||||
import type { BroadcastSummary, Category } from '@/types';
|
import type { BroadcastSummary, ChannelCategory } from '@/types';
|
||||||
|
|
||||||
type CreatorChannel = {
|
type CreatorChannel = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -51,7 +51,7 @@ type CreatorChannel = {
|
|||||||
is_live: boolean;
|
is_live: boolean;
|
||||||
viewer_count: number;
|
viewer_count: number;
|
||||||
stream_key_last_used_at: string | null;
|
stream_key_last_used_at: string | null;
|
||||||
category_id: number | null;
|
category: string | null;
|
||||||
live_broadcast: BroadcastSummary | null;
|
live_broadcast: BroadcastSummary | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ type Props = {
|
|||||||
ingestServer: string;
|
ingestServer: string;
|
||||||
tokenizedPath: string | null;
|
tokenizedPath: string | null;
|
||||||
};
|
};
|
||||||
categories: Category[];
|
categories: ChannelCategory[];
|
||||||
recentBroadcasts: BroadcastSummary[];
|
recentBroadcasts: BroadcastSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,14 +90,14 @@ export default function Dashboard({
|
|||||||
display_name: '',
|
display_name: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
description: '',
|
description: '',
|
||||||
category_id: '',
|
category: '',
|
||||||
});
|
});
|
||||||
const channelForm = useForm({
|
const channelForm = useForm({
|
||||||
_method: 'PATCH',
|
_method: 'PATCH',
|
||||||
display_name: channel?.display_name ?? '',
|
display_name: channel?.display_name ?? '',
|
||||||
slug: channel?.slug ?? '',
|
slug: channel?.slug ?? '',
|
||||||
description: channel?.description ?? '',
|
description: channel?.description ?? '',
|
||||||
category_id: channel?.category_id ? String(channel.category_id) : '',
|
category: channel?.category ?? '',
|
||||||
thumbnail: null as File | null,
|
thumbnail: null as File | null,
|
||||||
});
|
});
|
||||||
const broadcastForm = useForm({
|
const broadcastForm = useForm({
|
||||||
@@ -235,13 +235,16 @@ export default function Dashboard({
|
|||||||
placeholder={t('nyone-live')}
|
placeholder={t('nyone-live')}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Category">
|
<Field
|
||||||
|
label="Category"
|
||||||
|
error={createForm.errors.category}
|
||||||
|
>
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={createForm.data.category_id}
|
value={createForm.data.category}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
createForm.setData(
|
createForm.setData(
|
||||||
'category_id',
|
'category',
|
||||||
value,
|
value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -440,13 +443,16 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Category">
|
<Field
|
||||||
|
label="Category"
|
||||||
|
error={channelForm.errors.category}
|
||||||
|
>
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={channelForm.data.category_id}
|
value={channelForm.data.category}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
channelForm.setData(
|
channelForm.setData(
|
||||||
'category_id',
|
'category',
|
||||||
value,
|
value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -763,7 +769,7 @@ function CategorySelect({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
categories: Category[];
|
categories: ChannelCategory[];
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -777,8 +783,8 @@ function CategorySelect({
|
|||||||
>
|
>
|
||||||
<option value="">{t('No category')}</option>
|
<option value="">{t('No category')}</option>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.value} value={category.value}>
|
||||||
{category.name}
|
{category.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Head, Link, usePage } from '@inertiajs/react';
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Compass,
|
Compass,
|
||||||
LifeBuoy,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Radio,
|
Radio,
|
||||||
Search,
|
Search,
|
||||||
@@ -18,13 +17,12 @@ import { useTranslation } from '@/lib/translations';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { dashboard, home, login, register } from '@/routes';
|
import { dashboard, home, login, register } from '@/routes';
|
||||||
import { show as showChannel } from '@/routes/channels';
|
import { show as showChannel } from '@/routes/channels';
|
||||||
import { index as supportIndex } from '@/routes/support';
|
import type { ChannelCard, ChannelCategory } from '@/types';
|
||||||
import type { Category, ChannelCard } from '@/types';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
canRegister: boolean;
|
canRegister: boolean;
|
||||||
liveChannels: ChannelCard[];
|
liveChannels: ChannelCard[];
|
||||||
categories: Category[];
|
categories: ChannelCategory[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Welcome({
|
export default function Welcome({
|
||||||
@@ -43,7 +41,7 @@ export default function Welcome({
|
|||||||
|
|
||||||
return liveChannels.filter((channel) => {
|
return liveChannels.filter((channel) => {
|
||||||
const matchesCategory =
|
const matchesCategory =
|
||||||
category === 'all' || channel.category?.slug === category;
|
category === 'all' || channel.category?.value === category;
|
||||||
const matchesQuery =
|
const matchesQuery =
|
||||||
normalizedQuery === '' ||
|
normalizedQuery === '' ||
|
||||||
channel.display_name.toLowerCase().includes(normalizedQuery) ||
|
channel.display_name.toLowerCase().includes(normalizedQuery) ||
|
||||||
@@ -72,48 +70,15 @@ export default function Welcome({
|
|||||||
<span>Nyone</span>
|
<span>Nyone</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-1 text-sm text-muted-foreground md:flex">
|
|
||||||
<Link
|
|
||||||
href={home()}
|
|
||||||
className="rounded-md px-3 py-2 text-foreground"
|
|
||||||
>
|
|
||||||
{t('Live')}
|
|
||||||
</Link>
|
|
||||||
{auth.user && (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href={dashboard()}
|
|
||||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
|
||||||
>
|
|
||||||
{t('Studio')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={supportIndex()}
|
|
||||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
|
||||||
>
|
|
||||||
{t('Contact admin')}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="ms-auto flex items-center gap-2">
|
<div className="ms-auto flex items-center gap-2">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
{auth.user ? (
|
{auth.user ? (
|
||||||
<>
|
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
<Link href={dashboard()}>
|
<Link href={dashboard()}>
|
||||||
<LayoutDashboard className="size-4" />
|
<LayoutDashboard className="size-4" />
|
||||||
{t('Studio')}
|
{t('Studio')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href={supportIndex()}>
|
|
||||||
<LifeBuoy className="size-4" />
|
|
||||||
{t('Contact admin')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
@@ -175,7 +140,7 @@ export default function Welcome({
|
|||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 break-words">
|
<span className="min-w-0 break-words">
|
||||||
{featuredChannel.category
|
{featuredChannel.category
|
||||||
?.name ??
|
?.label ??
|
||||||
t('Uncategorized')}
|
t('Uncategorized')}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
@@ -292,8 +257,11 @@ export default function Welcome({
|
|||||||
{t('All categories')}
|
{t('All categories')}
|
||||||
</option>
|
</option>
|
||||||
{categories.map((item) => (
|
{categories.map((item) => (
|
||||||
<option key={item.id} value={item.slug}>
|
<option
|
||||||
{item.name}
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -356,7 +324,7 @@ function StreamCard({ channel }: { channel: ChannelCard }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{channel.category?.name ?? t('Uncategorized')}
|
{channel.category?.label ?? t('Uncategorized')}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{t(':count viewers', {
|
{t(':count viewers', {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export type Category = {
|
export type ChannelCategory = {
|
||||||
id: number;
|
value: string;
|
||||||
name: string;
|
label: string;
|
||||||
slug: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelCard = {
|
export type ChannelCard = {
|
||||||
@@ -15,7 +14,7 @@ export type ChannelCard = {
|
|||||||
banner_url: string | null;
|
banner_url: string | null;
|
||||||
viewer_count: number;
|
viewer_count: number;
|
||||||
followers_count: number;
|
followers_count: number;
|
||||||
category: Category | null;
|
category: ChannelCategory | null;
|
||||||
broadcast: {
|
broadcast: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -38,7 +37,7 @@ export type ChannelDetail = {
|
|||||||
is_live: boolean;
|
is_live: boolean;
|
||||||
viewer_count: number;
|
viewer_count: number;
|
||||||
followers_count: number;
|
followers_count: number;
|
||||||
category: Category | null;
|
category: ChannelCategory | null;
|
||||||
owner: {
|
owner: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Enums\ChannelCategory;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\PlatformSetting;
|
use App\Models\PlatformSetting;
|
||||||
use App\Models\StreamKey;
|
use App\Models\StreamKey;
|
||||||
@@ -17,13 +17,12 @@ class CreatorChannelTest extends TestCase
|
|||||||
public function test_user_with_creator_access_can_create_one_channel_and_receives_one_time_stream_key(): void
|
public function test_user_with_creator_access_can_create_one_channel_and_receives_one_time_stream_key(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create(['can_create_channel' => true]);
|
$user = User::factory()->create(['can_create_channel' => true]);
|
||||||
$category = Category::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
||||||
'display_name' => 'Nyone Live',
|
'display_name' => 'Nyone Live',
|
||||||
'slug' => 'nyone-live',
|
'slug' => 'nyone-live',
|
||||||
'description' => 'Live builds and demos.',
|
'description' => 'Live builds and demos.',
|
||||||
'category_id' => $category->id,
|
'category' => ChannelCategory::Creative->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response
|
$response
|
||||||
@@ -34,7 +33,7 @@ class CreatorChannelTest extends TestCase
|
|||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'slug' => 'nyone-live',
|
'slug' => 'nyone-live',
|
||||||
'display_name' => 'Nyone Live',
|
'display_name' => 'Nyone Live',
|
||||||
'category_id' => $category->id,
|
'category' => ChannelCategory::Creative->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseCount(StreamKey::class, 1);
|
$this->assertDatabaseCount(StreamKey::class, 1);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\ChannelCategory;
|
||||||
use App\Models\Broadcast;
|
use App\Models\Broadcast;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -64,6 +65,25 @@ class DashboardTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_home_page_exposes_translated_channel_category_labels(): void
|
||||||
|
{
|
||||||
|
config(['app.locale' => 'es']);
|
||||||
|
|
||||||
|
Channel::factory()->create([
|
||||||
|
'is_live' => true,
|
||||||
|
'category' => ChannelCategory::Music,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('welcome')
|
||||||
|
->where('liveChannels.0.category.value', ChannelCategory::Music->value)
|
||||||
|
->where('liveChannels.0.category.label', __('Music', [], 'es'))
|
||||||
|
->where('categories', fn ($categories) => $categories->firstWhere('value', ChannelCategory::Music->value)['label'] === __('Music', [], 'es')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_creator_dashboard_exposes_channel_display_media_props(): void
|
public function test_creator_dashboard_exposes_channel_display_media_props(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -101,7 +121,7 @@ class DashboardTest extends TestCase
|
|||||||
'display_name' => $channel->display_name,
|
'display_name' => $channel->display_name,
|
||||||
'slug' => $channel->slug,
|
'slug' => $channel->slug,
|
||||||
'description' => $channel->description,
|
'description' => $channel->description,
|
||||||
'category_id' => $channel->category_id,
|
'category' => $channel->category?->value,
|
||||||
'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720),
|
'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720),
|
||||||
])
|
])
|
||||||
->assertRedirect()
|
->assertRedirect()
|
||||||
@@ -131,7 +151,7 @@ class DashboardTest extends TestCase
|
|||||||
'display_name' => $channel->display_name,
|
'display_name' => $channel->display_name,
|
||||||
'slug' => $channel->slug,
|
'slug' => $channel->slug,
|
||||||
'description' => $channel->description,
|
'description' => $channel->description,
|
||||||
'category_id' => $channel->category_id,
|
'category' => $channel->category?->value,
|
||||||
'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'),
|
'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'),
|
||||||
])
|
])
|
||||||
->assertRedirect(route('dashboard'))
|
->assertRedirect(route('dashboard'))
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class SupportConversationTest extends TestCase
|
|||||||
'category',
|
'category',
|
||||||
'subject',
|
'subject',
|
||||||
'body',
|
'body',
|
||||||
'attachments.0' => 'Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.',
|
'attachments.0' => __('Unsupported attachment type. Upload media, PDF, text/data, Office, OpenDocument, or archive files.'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class SupportConversationTest extends TestCase
|
|||||||
'attachments' => [$attachment],
|
'attachments' => [$attachment],
|
||||||
])
|
])
|
||||||
->assertSessionHasErrors([
|
->assertSessionHasErrors([
|
||||||
'attachments.0' => 'Attachments must be 200 MB or smaller.',
|
'attachments.0' => __('Attachments must be 200 MB or smaller.'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user