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

141 lines
5.2 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoSlide;
use App\Services\NasSyncService;
use Illuminate\Http\Response;
/**
* Serves image assets (thumbnails, slides, avatars, banners) with a transparent
* NAS fallback: if the file is missing locally it is fetched from the NAS and
* cached in the standard public storage directory before being served.
*
* All routes are public (no auth) so images render in emails and for guests.
*/
class MediaController extends Controller
{
public function thumbnail(string $filename, NasSyncService $nas): Response
{
// New NAS-mirrored path format: "users/{username}/videos/{slug}/…"
if (str_starts_with($filename, 'users/')) {
$local = storage_path('app/' . $filename);
if (! file_exists($local)) {
@mkdir(dirname($local), 0755, true);
// The thumbnail field stores the local relative path.
// On NAS, thumbnails are always "thumb.webp"; try that first, then match extension.
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
$dir = $nas->resolveVideoDir($video);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
foreach (["thumb.webp", "thumb.{$ext}"] as $nasFile) {
if ($nas->ensureLocalAsset($local, "{$dir}/{$nasFile}")) break;
}
}
// Might be a slide
if (! file_exists($local)) {
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video) {
$dir = $nas->resolveVideoDir($slide->video);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
$nas->ensureLocalAsset($local, "{$dir}/slides/{$slide->position}.{$ext}");
}
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// Legacy flat filename format
$local = storage_path('app/public/thumbnails/' . $filename);
if (! file_exists($local)) {
// Find the video that owns this thumbnail and pull from NAS
$video = Video::where('thumbnail', $filename)->first();
if ($video) {
$dir = $nas->resolveVideoDir($video);
$nasPath = "{$dir}/thumb.webp";
$nas->ensureLocalAsset($local, $nasPath);
}
// Not found via video — might be a slide
if (! file_exists($local)) {
$slide = VideoSlide::where('filename', $filename)->with('video')->first();
if ($slide && $slide->video) {
$dir = $nas->resolveVideoDir($slide->video);
$ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'jpg';
$nasPath = "{$dir}/slides/{$slide->position}.{$ext}";
$nas->ensureLocalAsset($local, $nasPath);
}
}
if (! file_exists($local)) {
abort(404);
}
}
return $this->fileResponse($local);
}
public function avatar(string $filename, NasSyncService $nas): Response
{
$local = storage_path('app/public/avatars/' . $filename);
if (! file_exists($local)) {
$user = User::where('avatar', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$nasPath = "{$dir}/avatar.webp";
$nas->ensureLocalAsset($local, $nasPath);
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
public function banner(string $filename, NasSyncService $nas): Response
{
$local = storage_path('app/public/banners/' . $filename);
if (! file_exists($local)) {
$user = User::where('banner', $filename)->first();
if ($user) {
$dir = "users/{$nas->userSlug($user)}/profile";
$nasPath = "{$dir}/cover.webp";
$nas->ensureLocalAsset($local, $nasPath);
}
if (! file_exists($local)) abort(404);
}
return $this->fileResponse($local);
}
// ── Helper ────────────────────────────────────────────────────────────────
private function fileResponse(string $path): Response
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = match ($ext) {
'webp' => 'image/webp',
'png' => 'image/png',
'gif' => 'image/gif',
default => 'image/jpeg',
};
return response(file_get_contents($path), 200, [
'Content-Type' => $mime,
'Cache-Control' => 'public, max-age=86400',
'Last-Modified' => gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT',
]);
}
}