takeone-youtube-clone/app/Jobs/GenerateHlsJob.php
ghassan 6b3ab5b65e Use NAS as primary storage — direct upload when enabled
When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.

- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
  organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
  deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
  file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
  thumbnailStorageKey(), localPath(), storageKey() helpers for
  format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:17:07 +03:00

186 lines
6.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Jobs;
use App\Models\Setting;
use App\Models\Video;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class GenerateHlsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $video;
public function __construct(Video $video)
{
$this->video = $video;
$this->onQueue('video-processing');
}
public function handle()
{
$video = $this->video->fresh();
if ($video->status !== 'ready') {
Log::warning('GenerateHlsJob: Video not ready', ['id' => $video->id]);
return;
}
$sourcePath = storage_path('app/' . $video->path);
$nasDownloaded = null; // track a NAS-fetched local copy so we can clean it up
if (! file_exists($sourcePath)) {
// NAS-primary mode: file lives on NAS, download a temporary local copy
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
$localCopy = $nas->ensureLocalCopy($video);
if ($localCopy) {
$sourcePath = $localCopy;
$nasDownloaded = $localCopy;
}
}
if (! file_exists($sourcePath)) {
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
return;
}
}
$hlsDir = 'public/hls/' . $video->id;
$hlsPath = storage_path('app/' . $hlsDir);
if (is_dir($hlsPath)) {
Storage::deleteDirectory($hlsDir);
}
Storage::makeDirectory($hlsDir);
$variants = [
['height' => 480, 'name' => '480p', 'bitrate' => '1000k'],
['height' => 720, 'name' => '720p', 'bitrate' => '2500k'],
['height' => 1080, 'name' => '1080p', 'bitrate' => '5000k'],
];
foreach ($variants as $v) {
@mkdir($hlsPath . '/' . $v['name'], 0755, true);
}
try {
$ffmpegBin = \App\Models\Setting::ffmpegBinary();
$gpuEnabled = Setting::gpuEnabled();
$encoder = Setting::gpuEncoder(); // h264_nvenc / hevc_nvenc / libx264
$preset = Setting::gpuPreset(); // p1p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); // GPU index (0, 1, …)
$hwaccel = Setting::gpuHwaccel(); // cuda / none
$cmd = [escapeshellcmd($ffmpegBin)];
// Hardware-accelerated decode when GPU is in use
if ($gpuEnabled && $hwaccel !== 'none') {
$cmd[] = "-hwaccel {$hwaccel}";
$cmd[] = "-hwaccel_device {$device}";
}
$cmd[] = '-i ' . escapeshellarg($sourcePath);
// One video+audio stream per variant
$n = count($variants);
for ($i = 0; $i < $n; $i++) {
$cmd[] = '-map 0:v:0';
$cmd[] = '-map 0:a:0?';
}
// Video codec
$cmd[] = "-c:v {$encoder}";
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
$cmd[] = "-preset {$preset}";
$cmd[] = '-rc vbr';
$cmd[] = '-cq 23';
$cmd[] = "-gpu {$device}";
} else {
$cmd[] = "-preset {$preset}";
$cmd[] = '-crf 23';
}
$cmd[] = '-pix_fmt yuv420p';
// Audio codec
$cmd[] = '-c:a aac';
$cmd[] = '-b:a 128k';
$cmd[] = '-ar 48000';
// Per-variant scale + bitrate
for ($i = 0; $i < $n; $i++) {
$cmd[] = "-filter:v:{$i} scale=-2:{$variants[$i]['height']}";
$cmd[] = "-b:v:{$i} {$variants[$i]['bitrate']}";
}
// HLS muxer options
$cmd[] = '-g 48';
$cmd[] = '-sc_threshold 0';
$cmd[] = '-f hls';
$cmd[] = '-hls_time 6';
$cmd[] = '-hls_list_size 0';
$cmd[] = '-hls_flags independent_segments';
$cmd[] = '-hls_segment_filename ' . escapeshellarg($hlsPath . '/%v/%03d.ts');
$cmd[] = '-master_pl_name playlist.m3u8';
$vsm = implode(' ', array_map(
fn ($i, $v) => "v:{$i},a:{$i},name:{$v['name']}",
array_keys($variants),
$variants
));
$cmd[] = '-var_stream_map ' . escapeshellarg($vsm);
$cmd[] = escapeshellarg($hlsPath . '/%v/index.m3u8');
$fullCmd = implode(' ', $cmd) . ' 2>&1';
Log::info('GenerateHlsJob: Starting', [
'video_id' => $video->id,
'gpu' => $gpuEnabled,
'encoder' => $encoder,
'device' => $gpuEnabled ? $device : 'cpu',
]);
exec($fullCmd, $output, $exitCode);
if ($exitCode !== 0) {
$tail = implode("\n", array_slice($output, -30));
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
}
$video->update(['has_hls' => true, 'hls_path' => $hlsDir]);
Log::info('GenerateHlsJob: HLS generated', [
'video_id' => $video->id,
'variants' => array_column($variants, 'name'),
'encoder' => $encoder,
]);
$nas = app(\App\Services\NasSyncService::class);
if ($nas->isEnabled()) {
if ($nasDownloaded) {
// NAS-primary mode: video was fetched from NAS for HLS generation.
// The original is already on NAS — just delete the local temp copy.
@unlink($nasDownloaded);
} else {
// Local-storage mode: push the (compressed) file to NAS and free local disk.
// HLS segments stay local — per-segment SMB latency would hurt playback.
$nas->syncVideo($video);
$nas->deleteLocalVideo($video);
$nas->deleteLocalAssets($video);
}
}
} catch (\Exception $e) {
Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]);
Storage::deleteDirectory($hlsDir);
}
}
}