diff --git a/.env.example b/.env.example index 685b28dfd..c513317f9 100644 --- a/.env.example +++ b/.env.example @@ -220,6 +220,19 @@ ITEMS_PER_PAGE=50 ITEMS_PER_COVER_PAGE=25 # Image processing driver. Use gd when the Imagick PHP extension is unavailable. IMAGE_DRIVER=imagick +# Format used for newly processed covers and samples. Supported: webp, jpg. +IMAGE_OUTPUT_FORMAT=webp +# Encoder quality for newly processed covers and samples (1-100). +IMAGE_OUTPUT_QUALITY=82 +# Maximum compressed source size accepted by image processing (20 MiB). +IMAGE_MAX_SOURCE_BYTES=20971520 +# Maximum decoded source area accepted by image processing (40 megapixels). +IMAGE_MAX_SOURCE_PIXELS=40000000 +# Remote image connection and total request timeouts, in seconds. +IMAGE_FETCH_CONNECT_TIMEOUT=5 +IMAGE_FETCH_TIMEOUT=30 +# Maximum number of redirects followed while downloading a remote image. +IMAGE_FETCH_MAX_REDIRECTS=5 MAX_PAGER_RESULTS=125000 ECHOCLI=true RENAME_PAR2=false diff --git a/app/Enums/ImageAssetProfile.php b/app/Enums/ImageAssetProfile.php new file mode 100644 index 000000000..0d8cdeaa4 --- /dev/null +++ b/app/Enums/ImageAssetProfile.php @@ -0,0 +1,39 @@ + null, + self::MetadataCover => 250, + self::Backdrop => 1920, + self::Preview => 800, + self::Sample => 650, + }; + } + + public function maxHeight(): ?int + { + return match ($this) { + self::Original => null, + self::MetadataCover => 250, + self::Backdrop => 1024, + self::Preview => 600, + self::Sample => 650, + }; + } +} diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index 076f52373..567df8604 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -568,10 +568,10 @@ if (! function_exists('getReleaseCover')) { if ($coverType && $coverId) { if (in_array($coverType, ['movies', 'anime'], true)) { - return url("/covers/{$coverType}/{$coverId}-cover.jpg"); + return url("/covers/{$coverType}/{$coverId}-cover.webp"); } - return url("/covers/{$coverType}/{$coverId}.jpg"); + return url("/covers/{$coverType}/{$coverId}.webp"); } // Return placeholder image if no cover type/ID found @@ -843,7 +843,7 @@ if (! function_exists('getCoverURL')) { $defaults = [ 'id' => null, - 'suffix' => '-cover.jpg', + 'suffix' => '-cover.webp', 'type' => '', ]; $options += $defaults; @@ -860,7 +860,10 @@ if (! function_exists('getCoverURL')) { $cacheKey = $options['type'].':'.$options['id']; if (! isset($coverCache[$cacheKey])) { - $coverCache[$cacheKey] = file_exists(storage_path('covers/').$fileSpec); + $canonicalPath = storage_path('covers/').$fileSpec; + $legacyPath = preg_replace('/\.webp$/i', '.jpg', $canonicalPath); + $coverCache[$cacheKey] = file_exists($canonicalPath) + || (is_string($legacyPath) && file_exists($legacyPath)); } if (! $coverCache[$cacheKey]) { diff --git a/app/Http/Controllers/Admin/AdminBookController.php b/app/Http/Controllers/Admin/AdminBookController.php index 39820d253..5d7b5eb54 100644 --- a/app/Http/Controllers/Admin/AdminBookController.php +++ b/app/Http/Controllers/Admin/AdminBookController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Models\BookInfo; use App\Services\BookService; +use App\Services\ReleaseImageService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -51,17 +52,14 @@ class AdminBookController extends BasePageController switch ($action) { case 'submit': - $coverLoc = storage_path('covers/book/'.$id.'.jpg'); + $coverDirectory = storage_path('covers/book/'); + $imageService = app(ReleaseImageService::class); if ($request->hasFile('cover') && $request->file('cover')->isValid()) { - $uploadedFile = $request->file('cover'); - $file_info = getimagesize($uploadedFile->getRealPath()); - if (! empty($file_info)) { - $uploadedFile->move(storage_path('covers/book'), $id.'.jpg'); - } + $imageService->saveUploadedImage((string) $id, $request->file('cover'), $coverDirectory); } - $hasCover = file_exists($coverLoc) ? 1 : 0; + $hasCover = (int) $imageService->imageExists($coverDirectory, (string) $id); $publishdate = (empty($request->input('publishdate')) || ! strtotime($request->input('publishdate'))) ? ($b['publishdate'] ?? null) : Carbon::parse($request->input('publishdate'))->timestamp; diff --git a/app/Http/Controllers/Admin/AdminConsoleController.php b/app/Http/Controllers/Admin/AdminConsoleController.php index 300e5f7a5..994f945df 100644 --- a/app/Http/Controllers/Admin/AdminConsoleController.php +++ b/app/Http/Controllers/Admin/AdminConsoleController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Services\ConsoleService; use App\Services\GenreService; +use App\Services\ReleaseImageService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -16,10 +17,13 @@ class AdminConsoleController extends BasePageController { protected ConsoleService $consoleService; - public function __construct(ConsoleService $consoleService) + protected ReleaseImageService $imageService; + + public function __construct(ConsoleService $consoleService, ReleaseImageService $imageService) { parent::__construct(); $this->consoleService = $consoleService; + $this->imageService = $imageService; } /** @@ -58,17 +62,13 @@ class AdminConsoleController extends BasePageController switch ($action) { case 'submit': - $coverLoc = storage_path('covers/console/'.$id.'.jpg'); + $coverDirectory = storage_path('covers/console/'); if ($request->hasFile('cover') && $request->file('cover')->isValid()) { - $uploadedFile = $request->file('cover'); - $file_info = getimagesize($uploadedFile->getRealPath()); - if (! empty($file_info)) { - $uploadedFile->move(storage_path('covers/console'), $id.'.jpg'); - } + $this->imageService->saveUploadedImage((string) $id, $request->file('cover'), $coverDirectory); } - $hasCover = file_exists($coverLoc) ? 1 : 0; + $hasCover = (int) $this->imageService->imageExists($coverDirectory, (string) $id); $salesrank = (empty($request->input('salesrank')) || ! ctype_digit($request->input('salesrank'))) ? null : $request->input('salesrank'); $releasedate = (empty($request->input('releasedate')) || ! strtotime($request->input('releasedate'))) ? $con['releasedate'] diff --git a/app/Http/Controllers/Admin/AdminGameController.php b/app/Http/Controllers/Admin/AdminGameController.php index 36ba1ba7e..f692d811d 100644 --- a/app/Http/Controllers/Admin/AdminGameController.php +++ b/app/Http/Controllers/Admin/AdminGameController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Services\GamesService; use App\Services\GenreService; +use App\Services\ReleaseImageService; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -57,17 +58,14 @@ class AdminGameController extends BasePageController switch ($action) { case 'submit': - $coverLoc = storage_path('covers/games/').$id.'.jpg'; + $coverDirectory = storage_path('covers/games/'); + $imageService = app(ReleaseImageService::class); if ($request->hasFile('cover') && $request->file('cover')->isValid()) { - $file = $request->file('cover'); - $file_info = getimagesize($file->getPathname()); - if (! empty($file_info)) { - $file->move(storage_path('covers/games/'), $id.'.jpg'); - } + $imageService->saveUploadedImage((string) $id, $request->file('cover'), $coverDirectory); } - $cover = file_exists($coverLoc) ? 1 : 0; + $cover = (int) $imageService->imageExists($coverDirectory, (string) $id); $releasedate = (empty($request->input('releasedate')) || ! strtotime($request->input('releasedate'))) ? $game['releasedate'] : Carbon::parse($request->input('releasedate'))->timestamp; diff --git a/app/Http/Controllers/Admin/AdminMovieController.php b/app/Http/Controllers/Admin/AdminMovieController.php index 89e82b452..a532a9315 100644 --- a/app/Http/Controllers/Admin/AdminMovieController.php +++ b/app/Http/Controllers/Admin/AdminMovieController.php @@ -9,6 +9,7 @@ use App\Http\Controllers\BasePageController; use App\Models\MovieInfo; use App\Models\Release; use App\Services\MovieService; +use App\Services\ReleaseImageService; use App\Support\ReleaseSearchIndexSync; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -18,10 +19,13 @@ class AdminMovieController extends BasePageController { protected MovieService $movieService; - public function __construct(MovieService $movieService) + protected ReleaseImageService $imageService; + + public function __construct(MovieService $movieService, ReleaseImageService $imageService) { parent::__construct(); $this->movieService = $movieService; + $this->imageService = $imageService; } /** @@ -158,8 +162,7 @@ class AdminMovieController extends BasePageController if ($action === 'submit') { try { - $coverLoc = public_path('covers/movies/'.$id.'-cover.jpg'); - $backdropLoc = public_path('covers/movies/'.$id.'-backdrop.jpg'); + $imageDirectory = public_path('covers/movies/'); // Ensure directory exists if (! file_exists(public_path('covers/movies'))) { @@ -168,18 +171,16 @@ class AdminMovieController extends BasePageController // Handle cover upload if ($request->hasFile('cover') && $request->file('cover')->isValid()) { - $coverFile = $request->file('cover'); - $coverFile->move(public_path('covers/movies'), $id.'-cover.jpg'); + $this->imageService->saveUploadedImage($id.'-cover', $request->file('cover'), $imageDirectory); } // Handle backdrop upload if ($request->hasFile('backdrop') && $request->file('backdrop')->isValid()) { - $backdropFile = $request->file('backdrop'); - $backdropFile->move(public_path('covers/movies'), $id.'-backdrop.jpg'); + $this->imageService->saveUploadedImage($id.'-backdrop', $request->file('backdrop'), $imageDirectory); } - $request->merge(['cover' => file_exists($coverLoc) ? 1 : 0]); - $request->merge(['backdrop' => file_exists($backdropLoc) ? 1 : 0]); + $request->merge(['cover' => (int) $this->imageService->imageExists($imageDirectory, $id.'-cover')]); + $request->merge(['backdrop' => (int) $this->imageService->imageExists($imageDirectory, $id.'-backdrop')]); $this->movieService->update([ 'actors' => $request->input('actors'), diff --git a/app/Http/Controllers/Admin/AdminMusicController.php b/app/Http/Controllers/Admin/AdminMusicController.php index f577105c6..81ab58835 100644 --- a/app/Http/Controllers/Admin/AdminMusicController.php +++ b/app/Http/Controllers/Admin/AdminMusicController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Services\GenreService; use App\Services\MusicService; +use App\Services\ReleaseImageService; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -56,17 +57,14 @@ class AdminMusicController extends BasePageController switch ($action) { case 'submit': - $coverLoc = storage_path('covers/music/'.$id.'.jpg'); + $coverDirectory = storage_path('covers/music/'); + $imageService = app(ReleaseImageService::class); if ($request->hasFile('cover') && $request->file('cover')->isValid()) { - $file = $request->file('cover'); - $file_info = getimagesize($file->getPathname()); - if (! empty($file_info)) { - $file->move(storage_path('covers/music/'), $id.'.jpg'); - } + $imageService->saveUploadedImage((string) $id, $request->file('cover'), $coverDirectory); } - $cover = file_exists($coverLoc) ? 1 : 0; + $cover = (int) $imageService->imageExists($coverDirectory, (string) $id); $salesrankInput = $request->input('salesrank'); $salesrank = (empty($salesrankInput) || ! ctype_digit((string) $salesrankInput)) ? null : (int) $salesrankInput; $releasedateInput = $request->input('releasedate'); diff --git a/app/Http/Controllers/Api/XML_Response.php b/app/Http/Controllers/Api/XML_Response.php index 75f9a9723..e8f33e807 100644 --- a/app/Http/Controllers/Api/XML_Response.php +++ b/app/Http/Controllers/Api/XML_Response.php @@ -672,7 +672,7 @@ class XML_Response $dcov = ($dir === 'movies' ? '-cover' : ''); $this->cdata .= "\tserver['server']['url']}/covers/{$dir}/{$this->release->$column}{$dcov}.jpg\" ". + "src=\"{$this->server['server']['url']}/covers/{$dir}/{$this->release->$column}{$dcov}.webp\" ". "width=\"120\" alt=\"{$this->release->searchname}\" />\n"; } $size = human_filesize($this->release->size); diff --git a/app/Http/Controllers/CoverController.php b/app/Http/Controllers/CoverController.php index c329729f7..1d6fc1738 100644 --- a/app/Http/Controllers/CoverController.php +++ b/app/Http/Controllers/CoverController.php @@ -5,29 +5,24 @@ declare(strict_types=1); namespace App\Http\Controllers; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\File; use Symfony\Component\HttpFoundation\BinaryFileResponse; class CoverController extends Controller { - /** - * @var array - */ + /** @var list */ + private const array VALID_TYPES = [ + 'anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies', + 'music', 'preview', 'sample', 'tvrage', 'video', 'tvshows', + ]; + + /** @var list */ private const array NUMERIC_ID_TYPES = ['anime', 'book', 'console', 'games', 'music', 'tvshows']; - /** - * Serve cover images from storage - * - * @param string $type The type of cover (movies, console, music, etc.) - * @param string $filename The filename of the cover image - * @return BinaryFileResponse|Response - */ + /** @return BinaryFileResponse|Response */ public function show(string $type, string $filename) { - // Validate cover type - $validTypes = ['anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies', 'music', 'preview', 'sample', 'tvrage', 'video', 'tvshows']; - - if (! in_array($type, $validTypes)) { + if (! in_array($type, self::VALID_TYPES, true) || ! $this->isValidFilename($filename)) { abort(404); } @@ -35,64 +30,62 @@ class CoverController extends Controller return $this->respondWithPlaceholder(); } - // Build the file path - // For preview and sample images, try with _thumb suffix first - if (in_array($type, ['preview', 'sample'], true)) { - $pathInfo = pathinfo($filename); - $thumbFilename = $pathInfo['filename'].'_thumb.'.($pathInfo['extension'] ?? 'jpg'); - $thumbPath = storage_path("covers/{$type}/{$thumbFilename}"); - - // Use thumb path if it exists, otherwise fall back to original filename - if (file_exists($thumbPath)) { - $filePath = $thumbPath; - } else { - $filePath = storage_path("covers/{$type}/{$filename}"); - } - } elseif ($type === 'anime') { - // For anime, try the requested filename first, then fall back to old format (without -cover) - $filePath = storage_path("covers/{$type}/{$filename}"); - - // If file doesn't exist and filename ends with -cover.jpg, try old format - if (! file_exists($filePath) && preg_match('/^(\d+)-cover\.jpg$/', $filename, $matches)) { - $oldFormatPath = storage_path("covers/{$type}/{$matches[1]}.jpg"); - if (file_exists($oldFormatPath)) { - $filePath = $oldFormatPath; - } - } - } else { - $filePath = storage_path("covers/{$type}/{$filename}"); + $filePath = $this->resolveImagePath($type, $filename); + if ($filePath === null) { + return $this->respondWithPlaceholder(); } - // Check if file exists - if (! file_exists($filePath)) { - // Return placeholder image - $placeholderPath = public_path('assets/images/no-cover.png'); - if (file_exists($placeholderPath)) { - return response()->file($placeholderPath); - } + $contentType = File::mimeType($filePath); + if (! is_string($contentType) || ! in_array($contentType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) { abort(404); } - // Determine content type - $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); - $contentType = match ($extension) { - 'jpg', 'jpeg' => 'image/jpeg', - 'png' => 'image/png', - 'gif' => 'image/gif', - 'webp' => 'image/webp', - default => 'image/jpeg', - }; - - // Serve the file with proper headers return response()->file($filePath, [ 'Content-Type' => $contentType, - 'Cache-Control' => 'public, max-age=31536000', // Cache for 1 year + 'Cache-Control' => 'public, max-age=31536000', ]); } + private function resolveImagePath(string $type, string $filename): ?string + { + $pathInfo = pathinfo($filename); + $basename = $pathInfo['filename']; + $requestedExtension = strtolower($pathInfo['extension']); + $basenames = [$basename]; + + if (in_array($type, ['preview', 'sample'], true) && ! str_ends_with($basename, '_thumb')) { + array_unshift($basenames, $basename.'_thumb'); + } + + if ($type === 'anime' && preg_match('/^(\d+)-cover$/', $basename, $matches) === 1) { + $basenames[] = $matches[1]; + } + + $extensions = array_values(array_unique([$requestedExtension, 'webp', 'jpg', 'jpeg'])); + $roots = [storage_path('covers'), public_path('covers')]; + + foreach ($basenames as $candidateBasename) { + foreach ($extensions as $extension) { + foreach ($roots as $root) { + $candidate = $root.DIRECTORY_SEPARATOR.$type.DIRECTORY_SEPARATOR.$candidateBasename.'.'.$extension; + if (File::isFile($candidate) && File::isReadable($candidate)) { + return $candidate; + } + } + } + } + + return null; + } + + private function isValidFilename(string $filename): bool + { + return preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\.(?:webp|jpe?g|png|gif)\z/iD', $filename) === 1; + } + private function isInvalidNumericCoverFilename(string $filename): bool { - if (preg_match('/^(-?\d+)(?:-cover)?\.jpg$/', $filename, $matches) !== 1) { + if (preg_match('/^(-?\d+)(?:-cover)?\.(?:webp|jpe?g)$/i', $filename, $matches) !== 1) { return false; } @@ -102,7 +95,7 @@ class CoverController extends Controller private function respondWithPlaceholder(): Response|BinaryFileResponse { $placeholderPath = public_path('assets/images/no-cover.png'); - if (file_exists($placeholderPath)) { + if (File::isFile($placeholderPath)) { return response()->file($placeholderPath); } diff --git a/app/Http/Resources/AnidbResource.php b/app/Http/Resources/AnidbResource.php index 56b59c5a0..7779b8903 100644 --- a/app/Http/Resources/AnidbResource.php +++ b/app/Http/Resources/AnidbResource.php @@ -68,9 +68,11 @@ class AnidbResource extends JsonResource } // Otherwise construct the local path - $picturePath = storage_path('covers/anime/'.$this->anidbid.'.jpg'); - if (file_exists($picturePath)) { - return url('/covers/anime/'.$this->anidbid.'.jpg'); + $picturePath = storage_path('covers/anime/'.$this->anidbid.'-cover.webp'); + $legacyPath = storage_path('covers/anime/'.$this->anidbid.'-cover.jpg'); + $oldLegacyPath = storage_path('covers/anime/'.$this->anidbid.'.jpg'); + if (file_exists($picturePath) || file_exists($legacyPath) || file_exists($oldLegacyPath)) { + return url('/covers/anime/'.$this->anidbid.'-cover.webp'); } return null; diff --git a/app/Http/Resources/BookResource.php b/app/Http/Resources/BookResource.php index ac490f45e..17c57b258 100644 --- a/app/Http/Resources/BookResource.php +++ b/app/Http/Resources/BookResource.php @@ -52,9 +52,10 @@ class BookResource extends JsonResource return null; } - $coverPath = storage_path('covers/book/'.$this->id.'.jpg'); - if (file_exists($coverPath)) { - return url('/covers/book/'.$this->id.'.jpg'); + $coverPath = storage_path('covers/book/'.$this->id.'.webp'); + $legacyPath = storage_path('covers/book/'.$this->id.'.jpg'); + if (file_exists($coverPath) || file_exists($legacyPath)) { + return url('/covers/book/'.$this->id.'.webp'); } return null; diff --git a/app/Http/Resources/MovieResource.php b/app/Http/Resources/MovieResource.php index 99d3e7c48..60ef60be1 100644 --- a/app/Http/Resources/MovieResource.php +++ b/app/Http/Resources/MovieResource.php @@ -58,7 +58,7 @@ class MovieResource extends JsonResource return null; } - return url('/covers/movies/'.$this->imdbid.'-cover.jpg'); + return url('/covers/movies/'.$this->imdbid.'-cover.webp'); } /** @@ -70,6 +70,6 @@ class MovieResource extends JsonResource return null; } - return url('/covers/movies/'.$this->imdbid.'-backdrop.jpg'); + return url('/covers/movies/'.$this->imdbid.'-backdrop.webp'); } } diff --git a/app/Models/AnidbInfo.php b/app/Models/AnidbInfo.php index 321cfc712..ccd709110 100644 --- a/app/Models/AnidbInfo.php +++ b/app/Models/AnidbInfo.php @@ -109,7 +109,7 @@ class AnidbInfo extends Model */ public function getPicturePath(): string { - return storage_path('covers/anime/'.$this->anidbid.'.jpg'); + return storage_path('covers/anime/'.$this->anidbid.'-cover.webp'); } /** @@ -117,7 +117,14 @@ class AnidbInfo extends Model */ public function hasPictureImage(): bool { - return file_exists($this->getPicturePath()); + $path = $this->getPicturePath(); + + $legacyPath = preg_replace('/\.webp$/', '.jpg', $path); + $oldLegacyPath = storage_path('covers/anime/'.$this->anidbid.'.jpg'); + + return file_exists($path) + || (is_string($legacyPath) && file_exists($legacyPath)) + || file_exists($oldLegacyPath); } /** @@ -136,7 +143,7 @@ class AnidbInfo extends Model // Otherwise construct the local path if ($this->hasPictureImage()) { - return url('/covers/anime/'.$this->anidbid.'.jpg'); + return url('/covers/anime/'.$this->anidbid.'-cover.webp'); } return null; diff --git a/app/Models/BookInfo.php b/app/Models/BookInfo.php index 306270281..6f88c5534 100644 --- a/app/Models/BookInfo.php +++ b/app/Models/BookInfo.php @@ -84,7 +84,7 @@ class BookInfo extends Model return ''; } - return storage_path('covers/book/'.$this->id.'.jpg'); + return storage_path('covers/book/'.$this->id.'.webp'); } /** @@ -93,8 +93,13 @@ class BookInfo extends Model public function hasCoverImage(): bool { $coverPath = $this->getCoverPath(); + if ($coverPath === '') { + return false; + } - return $coverPath !== '' && file_exists($coverPath); + $legacyPath = preg_replace('/\.webp$/', '.jpg', $coverPath); + + return file_exists($coverPath) || (is_string($legacyPath) && file_exists($legacyPath)); } /** @@ -106,6 +111,6 @@ class BookInfo extends Model return null; } - return url('/covers/book/'.$this->id.'.jpg'); + return url('/covers/book/'.$this->id.'.webp'); } } diff --git a/app/Models/GamesInfo.php b/app/Models/GamesInfo.php index e35e4dd50..cecb99ac6 100644 --- a/app/Models/GamesInfo.php +++ b/app/Models/GamesInfo.php @@ -106,9 +106,15 @@ class GamesInfo extends Model return null; } - $path = config('nntmux_settings.covers_path').'/games/'.$this->id.'.jpg'; + $path = config('nntmux_settings.covers_path').'/games/'.$this->id.'.webp'; - return file_exists($path) ? $path : null; + if (file_exists($path)) { + return $path; + } + + $legacyPath = preg_replace('/\.webp$/', '.jpg', $path); + + return is_string($legacyPath) && file_exists($legacyPath) ? $legacyPath : null; } /** @@ -120,8 +126,14 @@ class GamesInfo extends Model return null; } - $path = config('nntmux_settings.covers_path').'/games/'.$this->id.'-backdrop.jpg'; + $path = config('nntmux_settings.covers_path').'/games/'.$this->id.'-backdrop.webp'; - return file_exists($path) ? $path : null; + if (file_exists($path)) { + return $path; + } + + $legacyPath = preg_replace('/\.webp$/', '.jpg', $path); + + return is_string($legacyPath) && file_exists($legacyPath) ? $legacyPath : null; } } diff --git a/app/Providers/AdditionalProcessingServiceProvider.php b/app/Providers/AdditionalProcessingServiceProvider.php index abdae6823..571dfd1d4 100644 --- a/app/Providers/AdditionalProcessingServiceProvider.php +++ b/app/Providers/AdditionalProcessingServiceProvider.php @@ -82,7 +82,7 @@ class AdditionalProcessingServiceProvider extends ServiceProvider $this->app->singleton(ReleaseFileManager::class, function ($app) { return new ReleaseFileManager( $app->make(ProcessingConfiguration::class), - new ReleaseImageService, + $app->make(ReleaseImageService::class), new NfoService, $app->make(NzbService::class), new NameFixingService @@ -93,7 +93,7 @@ class AdditionalProcessingServiceProvider extends ServiceProvider $this->app->singleton(MediaExtractionService::class, function ($app) { return new MediaExtractionService( $app->make(ProcessingConfiguration::class), - new ReleaseImageService, + $app->make(ReleaseImageService::class), $app->make(ReleaseExtraService::class), new CategorizationService ); diff --git a/app/Services/AdditionalProcessing/MediaExtractionService.php b/app/Services/AdditionalProcessing/MediaExtractionService.php index 9428d328f..76dddfa28 100644 --- a/app/Services/AdditionalProcessing/MediaExtractionService.php +++ b/app/Services/AdditionalProcessing/MediaExtractionService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\AdditionalProcessing; +use App\Enums\ImageAssetProfile; use App\Facades\Search; use App\Models\Category; use App\Models\Release; @@ -95,29 +96,25 @@ class MediaExtractionService $video->frame(TimeCode::fromString($time === '' ? '00:00:03:00' : $time)) ->save($fileName); } + if (! File::isFile($fileName)) { + return false; + } + + return $this->releaseImage->saveLocalImage( + $guid.'_thumb', + $fileName, + $this->releaseImage->imgSavePath, + ImageAssetProfile::Preview, + )->success; } catch (\Throwable $e) { if ($this->config->debugMode) { Log::error($e->getTraceAsString()); } return false; + } finally { + File::delete($fileName); } - - if (! File::isFile($fileName)) { - return false; - } - - $saved = $this->releaseImage->saveImage( - $guid.'_thumb', - $fileName, - $this->releaseImage->imgSavePath, - 800, - 600 - ); - - File::delete($fileName); - - return $saved === 1; } /** @@ -239,15 +236,14 @@ class MediaExtractionService */ public function getJPGSample(string $fileLocation, string $guid): bool { - $saved = $this->releaseImage->saveImage( + $saved = $this->releaseImage->saveLocalImage( $guid.'_thumb', $fileLocation, $this->releaseImage->jpgSavePath, - 650, - 650 + ImageAssetProfile::Sample, ); - if ($saved === 1) { + if ($saved->success) { Release::query()->where('guid', $guid)->update(['jpgstatus' => 1]); return true; @@ -459,7 +455,7 @@ class MediaExtractionService $type = @exif_imagetype($filePath); - return $type === IMAGETYPE_JPEG || $type === IMAGETYPE_PNG; + return in_array($type, [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true); } private function ffmpeg(): FFMpeg diff --git a/app/Services/AdditionalProcessing/ReleaseFileManager.php b/app/Services/AdditionalProcessing/ReleaseFileManager.php index 3bac73f9f..89e58a72e 100644 --- a/app/Services/AdditionalProcessing/ReleaseFileManager.php +++ b/app/Services/AdditionalProcessing/ReleaseFileManager.php @@ -207,7 +207,7 @@ class ReleaseFileManager $updateRows = ['haspreview' => 0]; // Check for existing samples - if (File::isFile($this->releaseImage->imgSavePath.$context->release->guid.'_thumb.jpg')) { + if ($this->releaseImage->imageExists($this->releaseImage->imgSavePath, $context->release->guid.'_thumb')) { $updateRows = ['haspreview' => 1]; } @@ -215,7 +215,7 @@ class ReleaseFileManager $updateRows['videostatus'] = 1; } - if (File::isFile($this->releaseImage->jpgSavePath.$context->release->guid.'_thumb.jpg')) { + if ($this->releaseImage->imageExists($this->releaseImage->jpgSavePath, $context->release->guid.'_thumb')) { $updateRows['jpgstatus'] = 1; } @@ -304,11 +304,11 @@ class ReleaseFileManager // Delete preview assets try { - $files = [ - $this->releaseImage->imgSavePath.$guid.'_thumb.jpg', - $this->releaseImage->jpgSavePath.$guid.'_thumb.jpg', - $this->releaseImage->vidSavePath.$guid.'.ogv', - ]; + $files = [$this->releaseImage->vidSavePath.$guid.'.ogv']; + foreach (['webp', 'jpg', 'jpeg'] as $extension) { + $files[] = $this->releaseImage->imgSavePath.$guid.'_thumb.'.$extension; + $files[] = $this->releaseImage->jpgSavePath.$guid.'_thumb.'.$extension; + } foreach ($files as $file) { if (File::exists($file)) { File::delete($file); diff --git a/app/Services/AdditionalProcessing/ReleaseProcessor.php b/app/Services/AdditionalProcessing/ReleaseProcessor.php index cd66d20b2..69d334f5c 100644 --- a/app/Services/AdditionalProcessing/ReleaseProcessor.php +++ b/app/Services/AdditionalProcessing/ReleaseProcessor.php @@ -292,7 +292,7 @@ class ReleaseProcessor $fileLocation = $context->tmpPath.'samplepicture.jpg'; File::put($fileLocation, $result['data']); - if ($this->mediaService->isJpegData($fileLocation) + if ($this->mediaService->isValidImage($fileLocation) && $this->mediaService->getJPGSample($fileLocation, $context->release->guid) ) { $context->markFound('jpgSample'); diff --git a/app/Services/BookService.php b/app/Services/BookService.php index 31d84cf9e..c980f9bd7 100644 --- a/app/Services/BookService.php +++ b/app/Services/BookService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\ImageAssetProfile; use App\Enums\SecondarySearchIndex; use App\Facades\Search; use App\Models\BookInfo; @@ -910,7 +911,12 @@ class BookService } } - $book['cover'] = $ri->saveImage((string) $bookId, $book['coverurl'], $this->imgSavePath, 250, 250); + $book['cover'] = (int) $ri->saveRemoteImage( + (string) $bookId, + $book['coverurl'], + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; } elseif ($this->echooutput) { cli()->header('Nothing to update: '). cli()->header($book['author']. diff --git a/app/Services/ConsoleService.php b/app/Services/ConsoleService.php index 22fac0d1e..00bbcdfee 100644 --- a/app/Services/ConsoleService.php +++ b/app/Services/ConsoleService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\ImageAssetProfile; use App\Enums\SecondarySearchIndex; use App\Facades\Search; use App\Models\Category; @@ -698,13 +699,23 @@ class ConsoleService ]); if ($con['cover'] === 1) { - $con['cover'] = $this->imageService->saveImage((string) $consoleId, $con['coverurl'], $this->imgSavePath, 250, 250); + $con['cover'] = (int) $this->imageService->saveRemoteImage( + (string) $consoleId, + $con['coverurl'], + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; } } else { $consoleId = $check['id']; if ($con['cover'] === 1) { - $con['cover'] = $this->imageService->saveImage((string) $consoleId, $con['coverurl'], $this->imgSavePath, 250, 250); + $con['cover'] = (int) $this->imageService->saveRemoteImage( + (string) $consoleId, + $con['coverurl'], + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; } $this->update( diff --git a/app/Services/GamesService.php b/app/Services/GamesService.php index 055c716f8..d152bc5df 100644 --- a/app/Services/GamesService.php +++ b/app/Services/GamesService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\ImageAssetProfile; use App\Enums\SecondarySearchIndex; use App\Facades\Search; use App\Models\Category; @@ -677,12 +678,22 @@ class GamesService // Save cover image if ($game['cover'] === 1 && isset($game['coverurl'])) { - $game['cover'] = $this->imageService->saveImage((string) $gamesId, $game['coverurl'], $this->imgSavePath, 250, 250); + $game['cover'] = (int) $this->imageService->saveRemoteImage( + (string) $gamesId, + $game['coverurl'], + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; } // Save backdrop image if ($game['backdrop'] === 1 && isset($game['backdropurl'])) { - $game['backdrop'] = $this->imageService->saveImage($gamesId.'-backdrop', $game['backdropurl'], $this->imgSavePath, 1920, 1024); + $game['backdrop'] = (int) $this->imageService->saveRemoteImage( + $gamesId.'-backdrop', + $game['backdropurl'], + $this->imgSavePath, + ImageAssetProfile::Backdrop, + )->success; } } elseif ($this->echoOutput) { cli()->headerOver('Nothing to update: '). diff --git a/app/Services/MediaProcessingService.php b/app/Services/MediaProcessingService.php deleted file mode 100644 index 7deab2e05..000000000 --- a/app/Services/MediaProcessingService.php +++ /dev/null @@ -1,327 +0,0 @@ -ffprobe->isValid($videoLocation)) { - $val = $this->ffprobe->format($videoLocation)->get('duration'); - if (is_string($val) || is_numeric($val)) { - $time = (string) $val; - } - } - } catch (\Throwable $e) { - if (config('app.debug') === true) { - Log::debug($e->getMessage()); - } - } - - if (empty($time)) { - return ''; - } - - // Case 1: matches ffmpeg log style `time=.. bitrate=` (optionally with hours) - if (preg_match('/time=(\d{1,2}:\d{1,2}:)?(\d{1,2})\.(\d{1,2})\s*bitrate=/i', $time, $numbers)) { - if ($numbers[3] > 0) { - $numbers[3]--; - } elseif (! empty($numbers[1])) { - $numbers[2]--; - $numbers[3] = '99'; - } - - return '00:00:'.str_pad((string) $numbers[2], 2, '0', STR_PAD_LEFT).'.'.str_pad((string) $numbers[3], 2, '0', STR_PAD_LEFT); - } - - // Case 1b: matches `time=MM:SS.xx` (without trailing bitrate) - if (preg_match('/time=(\d{2}):(\d{2})\.(\d{2})/i', $time, $m)) { - $sec = (int) $m[2]; - $hund = (int) $m[3]; - if ($hund > 0) { - $hund--; - } else { - if ($sec > 0) { - $sec--; - $hund = 99; - } - } - - return '00:00:'.str_pad((string) $sec, 2, '0', STR_PAD_LEFT).'.'.str_pad((string) $hund, 2, '0', STR_PAD_LEFT); - } - - // Case 2: numeric seconds - if (is_numeric($time)) { - $seconds = (float) $time; - if ($seconds <= 0) { - return ''; - } - $seconds = max(0.0, $seconds - 0.01); - $whole = (int) floor($seconds); - $hund = (int) round(($seconds - $whole) * 100); - $hund = min($hund, 99); - - return '00:00:'.str_pad((string) $whole, 2, '0', STR_PAD_LEFT).'.'.str_pad((string) $hund, 2, '0', STR_PAD_LEFT); - } - - return ''; - } - - public function saveJPGSample(string $guid, string $fileLocation): bool - { - $saved = $this->releaseImage->saveImage( - $guid.'_thumb', - $fileLocation, - $this->releaseImage->jpgSavePath, - 650, - 650 - ) === 1; - if ($saved) { - Release::query()->where('guid', $guid)->update(['jpgstatus' => 1]); - } - - return $saved; - } - - public function createSampleImage(string $guid, string $fileLocation, string $tmpPath, bool $enabled, int $width = 800, int $height = 600): bool - { - if (! $enabled) { - return false; - } - if (! File::isFile($fileLocation)) { - return false; - } - $fileName = ($tmpPath.'zzzz'.random_int(5, 12).random_int(5, 12).'.jpg'); - $time = $this->getVideoTime($fileLocation); - if ($this->ffprobe->isValid($fileLocation)) { - try { - /** @var Video $video */ - $video = $this->ffmpeg->open($fileLocation); - $video->frame(TimeCode::fromString($time === '' ? '00:00:03:00' : $time)) - ->save($fileName); - } catch (\Throwable $e) { - if (config('app.debug') === true) { - Log::error($e->getMessage()); - } - } - } - if (! File::isFile($fileName)) { - return false; - } - $saved = $this->releaseImage->saveImage( - $guid.'_thumb', - $fileName, - $this->releaseImage->imgSavePath, - $width, - $height - ); - File::delete($fileName); - if ($saved === 1) { - return true; - } - - return false; - } - - public function createVideoSample(string $guid, string $fileLocation, string $tmpPath, bool $enabled, int $durationSeconds): bool - { - if (! $enabled) { - return false; - } - if (! File::isFile($fileLocation)) { - return false; - } - $fileName = ($tmpPath.'zzzz'.$guid.'.ogv'); - $newMethod = false; - if ($durationSeconds < 60) { - $time = $this->getVideoTime($fileLocation); - if ($time !== '' && preg_match('/(\d{2}).(\d{2})/', $time, $numbers)) { - $newMethod = true; - if ($numbers[1] <= $durationSeconds) { - $lowestLength = '00:00:00.00'; - } else { - $lowestLength = ($numbers[1] - $durationSeconds); - $end = '.'.$numbers[2]; - $lowestLength = match (strlen((string) $lowestLength)) { - 1 => ('00:00:0'.$lowestLength.$end), - 2 => ('00:00:'.$lowestLength.$end), - default => '00:00:60.00', - }; - } - if ($this->ffprobe->isValid($fileLocation)) { - try { - /** @var Video $video */ - $video = $this->ffmpeg->open($fileLocation); - $videoSample = $video->clip(TimeCode::fromString($lowestLength), TimeCode::fromSeconds($durationSeconds)); - $format = new Ogg; - $format->setAudioCodec('libvorbis'); - $videoSample->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT); - $videoSample->save($format, $fileName); - } catch (\Throwable $e) { - if (config('app.debug') === true) { - Log::error($e->getMessage()); - } - } - } - } - } - if (! $newMethod && $this->ffprobe->isValid($fileLocation)) { - try { - /** @var Video $video */ - $video = $this->ffmpeg->open($fileLocation); - $videoSample = $video->clip(TimeCode::fromSeconds(0), TimeCode::fromSeconds($durationSeconds)); - $format = new Ogg; - $format->setAudioCodec('libvorbis'); - $videoSample->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT); - $videoSample->save($format, $fileName); - } catch (\Throwable $e) { - if (config('app.debug') === true) { - Log::error($e->getMessage()); - } - } - } - if (! File::isFile($fileName)) { - return false; - } - $newFile = ($this->releaseImage->vidSavePath.$guid.'.ogv'); - if (! @File::move($fileName, $newFile)) { - $copied = @File::copy($fileName, $newFile); - File::delete($fileName); - if (! $copied) { - return false; - } - } - @chmod($newFile, 0764); - Release::query()->where('guid', $guid)->update(['videostatus' => 1]); - - return true; - } - - public function addVideoMediaInfo(int $releaseId, string $fileLocation): bool - { - if (! File::isFile($fileLocation)) { - return false; - } - try { - $xmlArray = $this->mediaInfo->getInfo($fileLocation, true); - \App\Models\MediaInfo::addData($releaseId, $xmlArray); - $this->releaseExtra->addFromXml($releaseId, $xmlArray); - - return true; - } catch (\Throwable $e) { - Log::debug($e->getMessage()); - - return false; - } - } - - /** - * @return array - */ - public function addAudioInfoAndSample( - Release $release, - string $fileLocation, - string $fileExtension, - bool $processAudioInfo, - bool $processAudioSample, - string $audioSavePath - ): array { - // Mirror original behavior: defaults depend on flags, not file presence - $retVal = ! $processAudioInfo ? true : false; - $audVal = ! $processAudioSample ? true : false; - - // Only proceed with file-dependent operations if file exists - if (File::isFile($fileLocation)) { - if ($processAudioInfo) { - try { - $xmlArray = $this->mediaInfo->getInfo($fileLocation, false); - foreach ($xmlArray->getAudios() as $track) { - if ($track->get('album') !== null && $track->get('performer') !== null) { - if ((int) $release->predb_id === 0 && config('nntmux.rename_music_mediainfo')) { - $ext = strtoupper($fileExtension); - if (! empty($track->get('recorded_date')) && preg_match('/(?:19|20)\d\d/', $track->get('recorded_date')->getFullname(), $Year)) { - $newName = $track->get('performer')->getFullName().' - '.$track->get('album')->getFullName().' ('.$Year[0].') '.$ext; - } else { - $newName = $track->get('performer')->getFullName().' - '.$track->get('album')->getFullName().' '.$ext; - } - if ($ext === 'MP3') { - $newCat = Category::MUSIC_MP3; - } elseif ($ext === 'FLAC') { - $newCat = Category::MUSIC_LOSSLESS; - } else { - $newCat = $this->categorize->determineCategory($release->groups_id, $newName, $release->fromname); - } - $newTitle = escapeString(substr($newName, 0, 255)); - Release::whereId($release->id)->update([ - 'searchname' => $newTitle, - 'categories_id' => $newCat['categories_id'] ?? $release->categories_id, - 'iscategorized' => 1, - 'isrenamed' => 1, - 'proc_pp' => 1, - ]); - Search::updateRelease($release->id); - } - $this->releaseExtra->addFromXml($release->id, $xmlArray); - $retVal = true; - break; - } - } - } catch (\Throwable $e) { - Log::debug($e->getMessage()); - } - } - - if ($processAudioSample) { - $audioFileName = ($release->guid.'.ogg'); - if ($this->ffprobe->isValid($fileLocation)) { - try { - $audioSample = $this->ffmpeg->open($fileLocation); - $format = new Vorbis; - $audioSample->clip(TimeCode::fromSeconds(30), TimeCode::fromSeconds(30)); // @phpstan-ignore method.notFound - $audioSample->save($format, $audioSavePath.$audioFileName); - } catch (\Throwable $e) { - if (config('app.debug') === true) { - Log::error($e->getMessage()); - } - } - } - if (File::isFile($audioSavePath.$audioFileName)) { - @chmod($audioSavePath.$audioFileName, 0764); - Release::query()->where('id', $release->id)->update(['audiostatus' => 1]); - $audVal = true; - } - } - } - - return ['info' => $retVal, 'sample' => $audVal]; - } -} diff --git a/app/Services/MovieService.php b/app/Services/MovieService.php index c57b0c664..53f2bceb7 100644 --- a/app/Services/MovieService.php +++ b/app/Services/MovieService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services; use aharen\OMDbAPI; +use App\Enums\ImageAssetProfile; use App\Facades\Search; use App\Models\Category; use App\Models\MovieInfo; @@ -194,7 +195,7 @@ class MovieService $genres = $data['genres'] ?? []; $genre = is_array($genres) ? implode(', ', $genres) : (string) $genres; $cover = 0; - if (File::isFile($this->imgSavePath.$imdbId.'-cover.jpg')) { + if ($this->releaseImage->imageExists($this->imgSavePath, $imdbId.'-cover')) { $cover = 1; } @@ -362,7 +363,7 @@ class MovieService // Prefer Fanart.tv cover over TMDB,TMDB over IMDB,IMDB over OMDB and OMDB over iTunes. if (! empty($fanart['cover'])) { try { - $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $fanart['cover'], $this->imgSavePath); + $mov['cover'] = $this->saveRemoteAsset($imdbId.'-cover', $fanart['cover']); if ($mov['cover'] === 0) { Log::warning('Failed to save FanartTV cover for '.$imdbId.' from URL: '.$fanart['cover']); } @@ -374,7 +375,7 @@ class MovieService if ($mov['cover'] === 0 && ! empty($tmdb['cover'])) { try { - $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $tmdb['cover'], $this->imgSavePath); + $mov['cover'] = $this->saveRemoteAsset($imdbId.'-cover', $tmdb['cover']); if ($mov['cover'] === 0) { Log::warning('Failed to save TMDB cover for '.$imdbId.' from URL: '.$tmdb['cover']); } @@ -386,7 +387,7 @@ class MovieService if ($mov['cover'] === 0 && ! empty($imdb['cover'])) { try { - $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $imdb['cover'], $this->imgSavePath); + $mov['cover'] = $this->saveRemoteAsset($imdbId.'-cover', $imdb['cover']); if ($mov['cover'] === 0) { Log::warning('Failed to save IMDB cover for '.$imdbId.' from URL: '.$imdb['cover']); } @@ -398,7 +399,7 @@ class MovieService if ($mov['cover'] === 0 && ! empty($omdb['cover'])) { try { - $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $omdb['cover'], $this->imgSavePath); + $mov['cover'] = $this->saveRemoteAsset($imdbId.'-cover', $omdb['cover']); if ($mov['cover'] === 0) { Log::warning('Failed to save OMDB cover for '.$imdbId.' from URL: '.$omdb['cover']); } @@ -411,7 +412,7 @@ class MovieService // Backdrops. if (! empty($fanart['backdrop'])) { try { - $mov['backdrop'] = $this->releaseImage->saveImage($imdbId.'-backdrop', $fanart['backdrop'], $this->imgSavePath, 1920, 1024); + $mov['backdrop'] = $this->saveRemoteAsset($imdbId.'-backdrop', $fanart['backdrop'], ImageAssetProfile::Backdrop); } catch (\Throwable $e) { Log::warning('Error saving FanartTV backdrop for '.$imdbId.': '.$e->getMessage()); $mov['backdrop'] = 0; @@ -420,7 +421,7 @@ class MovieService if ($mov['backdrop'] === 0 && ! empty($tmdb['backdrop'])) { try { - $mov['backdrop'] = $this->releaseImage->saveImage($imdbId.'-backdrop', $tmdb['backdrop'], $this->imgSavePath, 1920, 1024); + $mov['backdrop'] = $this->saveRemoteAsset($imdbId.'-backdrop', $tmdb['backdrop'], ImageAssetProfile::Backdrop); } catch (\Throwable $e) { Log::warning('Error saving TMDB backdrop for '.$imdbId.': '.$e->getMessage()); $mov['backdrop'] = 0; @@ -430,7 +431,7 @@ class MovieService // Banner if (! empty($fanart['banner'])) { try { - $mov['banner'] = $this->releaseImage->saveImage($imdbId.'-banner', $fanart['banner'], $this->imgSavePath); + $mov['banner'] = $this->saveRemoteAsset($imdbId.'-banner', $fanart['banner']); } catch (\Throwable $e) { Log::warning('Error saving FanartTV banner for '.$imdbId.': '.$e->getMessage()); $mov['banner'] = 0; @@ -1521,8 +1522,7 @@ class MovieService { $record = MovieInfo::query()->select('cover')->where('imdbid', $imdbId)->first(); $dbHas = $record !== null && (int) $record->cover === 1; - $filePath = $this->imgSavePath.$imdbId.'-cover.jpg'; - $fileHas = File::isFile($filePath); + $fileHas = $this->releaseImage->imageExists($this->imgSavePath, $imdbId.'-cover'); return $dbHas || $fileHas; } @@ -1532,7 +1532,7 @@ class MovieService try { $fanart = $this->fetchFanartTVProperties($imdbId); if (! empty($fanart['cover'])) { - if ($this->releaseImage->saveImage($imdbId.'-cover', $fanart['cover'], $this->imgSavePath)) { + if ($this->saveRemoteAsset($imdbId.'-cover', $fanart['cover']) === 1) { return true; } } @@ -1543,7 +1543,7 @@ class MovieService try { $tmdb = $this->fetchTMDBProperties($imdbId); if (! empty($tmdb['cover'])) { - if ($this->releaseImage->saveImage($imdbId.'-cover', $tmdb['cover'], $this->imgSavePath)) { + if ($this->saveRemoteAsset($imdbId.'-cover', $tmdb['cover']) === 1) { return true; } } @@ -1554,7 +1554,7 @@ class MovieService try { $imdb = $this->fetchIMDBProperties($imdbId); if (! empty($imdb['cover'])) { - if ($this->releaseImage->saveImage($imdbId.'-cover', $imdb['cover'], $this->imgSavePath)) { + if ($this->saveRemoteAsset($imdbId.'-cover', $imdb['cover']) === 1) { return true; } } @@ -1565,7 +1565,7 @@ class MovieService try { $omdb = $this->fetchOmdbAPIProperties($imdbId); if (! empty($omdb['cover'])) { - if ($this->releaseImage->saveImage($imdbId.'-cover', $omdb['cover'], $this->imgSavePath)) { + if ($this->saveRemoteAsset($imdbId.'-cover', $omdb['cover']) === 1) { return true; } } @@ -1575,4 +1575,17 @@ class MovieService return false; } + + private function saveRemoteAsset( + string $name, + string $url, + ImageAssetProfile $profile = ImageAssetProfile::Original, + ): int { + return (int) $this->releaseImage->saveRemoteImage( + $name, + $url, + $this->imgSavePath, + $profile, + )->success; + } } diff --git a/app/Services/MusicService.php b/app/Services/MusicService.php index 2dcae88b6..21adaab22 100644 --- a/app/Services/MusicService.php +++ b/app/Services/MusicService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\ImageAssetProfile; use App\Enums\SecondarySearchIndex; use App\Facades\Search; use App\Models\Category; @@ -409,11 +410,21 @@ class MusicService 'created_at' => now(), 'updated_at' => now(), ]); - $mus['cover'] = $ri->saveImage((string) $musicId, $mus['coverurl'], $this->imgSavePath, 250, 250); + $mus['cover'] = (int) $ri->saveRemoteImage( + (string) $musicId, + $mus['coverurl'], + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; MusicInfo::query()->where('id', $musicId)->update(['cover' => $mus['cover']]); } else { $musicId = $check['id']; - $mus['cover'] = $ri->saveImage((string) $musicId, $mus['coverurl'], $this->imgSavePath, 250, 250); + $mus['cover'] = (int) $ri->saveRemoteImage( + (string) $musicId, + $mus['coverurl'], + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; MusicInfo::query()->where('id', $musicId)->update([ 'title' => $mus['title'], 'asin' => $mus['asin'], @@ -439,7 +450,12 @@ class MusicService ' Year: '.$year ); } - $mus['cover'] = $ri->saveImage((string) $musicId, $mus['coverurl'], $this->imgSavePath, 250, 250); + $mus['cover'] = (int) $ri->saveRemoteImage( + (string) $musicId, + $mus['coverurl'], + $this->imgSavePath, + ImageAssetProfile::MetadataCover, + )->success; } elseif ($this->echooutput) { if ($mus['artist'] === '') { $artist = ''; diff --git a/app/Services/PopulateAniListService.php b/app/Services/PopulateAniListService.php index 55f89f361..577e872cc 100644 --- a/app/Services/PopulateAniListService.php +++ b/app/Services/PopulateAniListService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\ImageAssetProfile; use App\Models\AnidbInfo; use App\Models\AnidbTitle; use GuzzleHttp\Client; @@ -37,6 +38,8 @@ class PopulateAniListService */ protected Client $client; + private ReleaseImageService $imageService; + /** * Rate limiting: track requests and timestamps * @@ -55,12 +58,13 @@ class PopulateAniListService /** * @throws \Exception */ - public function __construct() + public function __construct(?ReleaseImageService $imageService = null) { $this->echooutput = config('nntmux.echocli'); // Use storage_path directly to match CoverController expectations $this->imgSavePath = storage_path('covers/anime/'); + $this->imageService = $imageService ?? new ReleaseImageService; $this->client = new Client([ 'base_uri' => self::API_URL, 'timeout' => 30, @@ -632,38 +636,25 @@ class PopulateAniListService */ private function downloadCoverImage(int $anidbid, string $imageUrl): void { - // Use the format expected by getReleaseCover: {id}-cover.jpg - // This matches the format used by movies: {id}-cover.jpg - $coverFilename = $anidbid.'-cover.jpg'; - $coverPath = $this->imgSavePath.$coverFilename; - - if (file_exists($coverPath)) { + $coverName = $anidbid.'-cover'; + if ($this->imageService->imageExists($this->imgSavePath, $coverName)) { return; // Already exists } try { - // Ensure directory exists with proper permissions - if (! is_dir($this->imgSavePath)) { - if (! mkdir($this->imgSavePath, 0755, true) && ! is_dir($this->imgSavePath)) { - throw new \RuntimeException(sprintf('Directory "%s" was not created', $this->imgSavePath)); - } + $result = $this->imageService->saveRemoteImage( + $coverName, + $imageUrl, + $this->imgSavePath, + ImageAssetProfile::Original, + ); + + if (! $result->success) { + throw new \RuntimeException($result->failureReason ?? 'Failed to process anime cover.'); } - $response = $this->client->get($imageUrl); - $imageData = $response->getBody()->getContents(); - - // Write the image file - $bytesWritten = file_put_contents($coverPath, $imageData); - - if ($bytesWritten === false) { - throw new \RuntimeException("Failed to write cover image to {$coverPath}"); - } - - // Set proper file permissions - chmod($coverPath, 0644); - if ($this->echooutput) { - cli()->info("Downloaded cover image for ID {$anidbid} from AniList to {$coverPath}"); + cli()->info("Downloaded cover image for ID {$anidbid} from AniList to {$result->path}"); } } catch (GuzzleException $e) { if ($this->echooutput) { diff --git a/app/Services/ReleaseImageService.php b/app/Services/ReleaseImageService.php index 04798000b..8480e4e8e 100644 --- a/app/Services/ReleaseImageService.php +++ b/app/Services/ReleaseImageService.php @@ -4,220 +4,498 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\ImageAssetProfile; +use App\Support\Data\ImageProcessingResult; +use Closure; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\UriResolver; +use Illuminate\Http\UploadedFile; use Illuminate\Image\Image as LaravelImage; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Log; -use Spatie\LaravelImageOptimizer\Facades\ImageOptimizer; use Throwable; /** - * Resize/save/delete images to disk. - * Service for handling release images (covers, backdrops, previews). - * Manages image storage and deletion for releases. + * Fetch, normalize, store, locate, and delete release images. + * + * Destination directories are deliberately supplied by callers. This keeps + * the existing storage_path/public_path/COVERS_PATH ownership unchanged. */ class ReleaseImageService { - /** - * Path to save ogg audio samples. - */ + /** Path to save ogg audio samples. */ public string $audSavePath; - /** - * Path to save video preview jpg pictures. - */ + /** Path to save video preview pictures. */ public string $imgSavePath; - /** - * Path to save large jpg pictures(xxx). - */ + /** Path to save downloaded sample pictures. */ public string $jpgSavePath; - /** - * Path to save movie jpg covers. - */ + /** Path to save movie covers. */ public string $movieImgSavePath; - /** - * Path to save video ogv files. - */ + /** Path to save video samples. */ public string $vidSavePath; + /** @var Closure(string): list */ + private Closure $hostResolver; + /** - * ReleaseImageService constructor. + * @param (Closure(string): list)|null $hostResolver */ - public function __construct() + public function __construct(?Closure $hostResolver = null) { $this->audSavePath = storage_path('covers/audiosample/'); $this->imgSavePath = storage_path('covers/preview/'); $this->jpgSavePath = storage_path('covers/sample/'); $this->movieImgSavePath = storage_path('covers/movies/'); $this->vidSavePath = storage_path('covers/video/'); + $this->hostResolver = $hostResolver ?? $this->defaultHostResolver(...); } - protected function fetchImage(string $imgLoc): bool|LaravelImage - { + public function saveRemoteImage( + string $imgName, + string $url, + string $destinationDirectory, + ImageAssetProfile $profile = ImageAssetProfile::Original, + ): ImageProcessingResult { + $fetched = $this->fetchRemoteBytes($url); + + if (! $fetched['success']) { + return ImageProcessingResult::failure($fetched['reason']); + } + + return $this->processBytes( + $imgName, + $fetched['contents'], + $destinationDirectory, + $profile->maxWidth(), + $profile->maxHeight(), + ); + } + + public function saveLocalImage( + string $imgName, + string $sourcePath, + string $destinationDirectory, + ImageAssetProfile $profile = ImageAssetProfile::Original, + ): ImageProcessingResult { + if (! File::isFile($sourcePath) || ! File::isReadable($sourcePath)) { + return ImageProcessingResult::failure('Image source is not a readable local file.'); + } + + $maxBytes = $this->maxSourceBytes(); + $sourceSize = File::size($sourcePath); + if ($sourceSize === false || $sourceSize <= 0 || $sourceSize > $maxBytes) { + return ImageProcessingResult::failure('Image source size is invalid or exceeds the configured limit.'); + } + try { - // Create context with timeout settings for file_get_contents - $context = stream_context_create([ - 'http' => [ - 'timeout' => 30, - 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'follow_location' => true, - 'max_redirects' => 5, - 'ignore_errors' => true, - ], - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - 'allow_self_signed' => false, - ], + return $this->processBytes( + $imgName, + File::get($sourcePath), + $destinationDirectory, + $profile->maxWidth(), + $profile->maxHeight(), + ); + } catch (Throwable $e) { + Log::debug('Unable to read local image source.', [ + 'path' => $sourcePath, + 'error' => $e->getMessage(), ]); - $imageData = @file_get_contents($imgLoc, false, $context); + return ImageProcessingResult::failure('Unable to read the local image source.'); + } + } - // Check HTTP response headers if available - if (! empty($http_response_header)) { - $statusLine = $http_response_header[0] ?? ''; - if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $statusLine, $matches)) { - $httpCode = (int) $matches[1]; - if ($httpCode >= 400) { - Log::debug('HTTP error fetching image from '.$imgLoc.': '.$statusLine); - cli()->notice('HTTP error fetching image: '.$statusLine); - - return false; - } - } - } - - if ($imageData === false) { - $error = error_get_last(); - $errorMsg = $error !== null ? $error['message'] : 'Unknown error fetching image'; - Log::debug('Failed to fetch image from '.$imgLoc.': '.$errorMsg); - cli()->notice('Failed to fetch image from '.$imgLoc.': '.$errorMsg); - - return false; - } - - if (empty($imageData)) { - Log::debug('Empty image data received from '.$imgLoc); - cli()->notice('Empty image data received from '.$imgLoc); - - return false; - } - - return Image::fromBytes($imageData); - } catch (Throwable $e) { - if ($e->getCode() === 404) { - cli()->notice('Data not available on server'); - } elseif ($e->getCode() === 503) { - cli()->notice('Service unavailable'); - } else { - Log::debug('Exception fetching image from '.$imgLoc.': '.$e->getMessage()); - cli()->notice('Unable to fetch image: '.$e->getMessage()); - } + public function saveUploadedImage( + string $imgName, + UploadedFile $upload, + string $destinationDirectory, + ImageAssetProfile $profile = ImageAssetProfile::Original, + ): ImageProcessingResult { + if (! $upload->isValid()) { + return ImageProcessingResult::failure('The uploaded image is invalid.'); } - return false; + $path = $upload->getRealPath(); + if ($path === false) { + return ImageProcessingResult::failure('The uploaded image has no readable temporary path.'); + } + + return $this->saveLocalImage($imgName, $path, $destinationDirectory, $profile); } /** - * Save an image to disk, optionally resizing it. + * Backwards-compatible adapter while callers move to explicit source APIs. * - * @param string $imgName What to name the new image. - * @param string $imgLoc URL or location on the disk the original image is in. - * @param string $imgSavePath Folder to save the new image in. - * @param int $imgMaxWidth Max width to resize image to. (OPTIONAL) - * @param int $imgMaxHeight Max height to resize image to. (OPTIONAL) - * @param bool $saveThumb Save a thumbnail of this image? (OPTIONAL) - * @return int 1 on success, 0 on failure Used on site to check if there is an image. + * @return int 1 on success, 0 on failure */ - public function saveImage(string $imgName, string $imgLoc, string $imgSavePath, int $imgMaxWidth = 0, int $imgMaxHeight = 0, bool $saveThumb = false): int - { - // Guard against empty image locations to avoid 'Path cannot be empty' - if (empty($imgLoc)) { + public function saveImage( + string $imgName, + string $imgLoc, + string $imgSavePath, + int $imgMaxWidth = 0, + int $imgMaxHeight = 0, + bool $saveThumb = false, + ): int { + if ($imgLoc === '') { return 0; } - $cover = $this->fetchImage($imgLoc); + $scheme = strtolower((string) parse_url($imgLoc, PHP_URL_SCHEME)); + $source = in_array($scheme, ['http', 'https'], true) + ? $this->fetchRemoteBytes($imgLoc) + : $this->readLocalBytes($imgLoc); - if ($cover === false) { + if (! $source['success']) { return 0; } - $coverPath = $imgSavePath.$imgName.'.jpg'; + $result = $this->processBytes( + $imgName, + $source['contents'], + $imgSavePath, + $imgMaxWidth > 0 ? $imgMaxWidth : null, + $imgMaxHeight > 0 ? $imgMaxHeight : null, + ); - try { - $shouldSaveThumb = false; - - // Check if we need to resize it. - if ($imgMaxWidth !== 0 && $imgMaxHeight !== 0) { - $width = $cover->width(); - $height = $cover->height(); - if ($width !== 0 || $height !== 0) { - $ratio = min($imgMaxHeight / $height, $imgMaxWidth / $width); - // New dimensions - $new_width = (int) ($ratio * $width); - $new_height = (int) ($ratio * $height); - if ($new_width < $width && $new_width > 10 && $new_height > 10) { - $cover = $cover->resize($new_width, $new_height); - $shouldSaveThumb = $saveThumb; - } - } - } - - $jpeg = $cover->toJpeg()->quality(100)->toBytes(); - - if ($shouldSaveThumb && ! $this->writeOptimizedImage($imgSavePath.$imgName.'_thumb.jpg', $jpeg)) { + if ($result->success && $saveThumb && $result->path !== null) { + $thumbPath = $imgSavePath.$imgName.'_thumb.'.$this->outputExtension(); + if (! File::copy($result->path, $thumbPath)) { return 0; } - - if (! $this->writeOptimizedImage($coverPath, $jpeg)) { - return 0; - } - } catch (Throwable $e) { - Log::debug('Unable to process image '.$imgLoc.' for '.$coverPath.': '.$e->getMessage()); - - return 0; - } - // Check if it's on the drive. - if (! File::isReadable($coverPath)) { - Log::debug('Image was not readable after save: '.$coverPath); - - return 0; } - return 1; + return $result->success ? 1 : 0; } - private function writeOptimizedImage(string $path, string $contents): bool + public function imagePath(string $directory, string $basename): ?string { - if (File::put($path, $contents) === false) { - Log::debug('Unable to write image to '.$path); - - return false; + foreach (array_unique([$this->outputExtension(), 'webp', 'jpg', 'jpeg']) as $extension) { + $path = rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$basename.'.'.$extension; + if (File::isReadable($path)) { + return $path; + } } - ImageOptimizer::optimize($path); - - if (! File::isReadable($path)) { - Log::debug('Image was not readable after save: '.$path); - - return false; - } - - return true; + return null; } - /** - * Delete images for the release. - * - * @param string $guid The GUID of the release. - */ + public function imageExists(string $directory, string $basename): bool + { + return $this->imagePath($directory, $basename) !== null; + } + + public function outputExtension(): string + { + return $this->outputFormat() === 'webp' ? 'webp' : 'jpg'; + } + + /** Delete all generated assets for a release, including legacy images. */ public function delete(string $guid): void { - $thumb = $guid.'_thumb.jpg'; + $files = [ + $this->audSavePath.$guid.'.ogg', + $this->vidSavePath.$guid.'.ogv', + ]; - File::delete([$this->audSavePath.$guid.'.ogg', $this->imgSavePath.$thumb, $this->jpgSavePath.$thumb, $this->vidSavePath.$guid.'.ogv']); + foreach (['webp', 'jpg', 'jpeg'] as $extension) { + $files[] = $this->imgSavePath.$guid.'_thumb.'.$extension; + $files[] = $this->jpgSavePath.$guid.'_thumb.'.$extension; + } + + File::delete($files); + } + + /** @return array{success: bool, contents: string, reason: string} */ + private function readLocalBytes(string $path): array + { + if (! File::isFile($path) || ! File::isReadable($path)) { + return ['success' => false, 'contents' => '', 'reason' => 'Image source is not a readable local file.']; + } + + $size = File::size($path); + if ($size === false || $size <= 0 || $size > $this->maxSourceBytes()) { + return ['success' => false, 'contents' => '', 'reason' => 'Image source size is invalid or exceeds the configured limit.']; + } + + try { + return ['success' => true, 'contents' => File::get($path), 'reason' => '']; + } catch (Throwable) { + return ['success' => false, 'contents' => '', 'reason' => 'Unable to read the local image source.']; + } + } + + /** @return array{success: bool, contents: string, reason: string} */ + private function fetchRemoteBytes(string $url): array + { + $currentUrl = $url; + $maxRedirects = max(0, (int) config('image.fetch_max_redirects', 5)); + + try { + for ($redirects = 0; $redirects <= $maxRedirects; $redirects++) { + $this->assertSafeRemoteUrl($currentUrl); + + $response = Http::withHeaders([ + 'Accept' => 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.5', + 'User-Agent' => 'NNTmux Image Fetcher', + ])->withOptions([ + 'allow_redirects' => false, + 'stream' => true, + ])->connectTimeout(max(1, (int) config('image.fetch_connect_timeout', 5))) + ->timeout(max(1, (int) config('image.fetch_timeout', 30))) + ->get($currentUrl); + + if ($response->status() >= 300 && $response->status() < 400) { + $location = $response->header('Location'); + if ($location === null || $location === '' || $redirects === $maxRedirects) { + return ['success' => false, 'contents' => '', 'reason' => 'Remote image redirect limit was exceeded.']; + } + + $currentUrl = (string) UriResolver::resolve(new Uri($currentUrl), new Uri($location)); + + continue; + } + + if (! $response->successful()) { + Log::debug('Remote image request failed.', [ + 'url' => $this->redactedUrl($currentUrl), + 'status' => $response->status(), + ]); + + return ['success' => false, 'contents' => '', 'reason' => 'Remote image request failed.']; + } + + $contentLength = $response->header('Content-Length'); + if (is_numeric($contentLength) && (int) $contentLength > $this->maxSourceBytes()) { + return ['success' => false, 'contents' => '', 'reason' => 'Remote image exceeds the configured size limit.']; + } + + $stream = $response->toPsrResponse()->getBody(); + $contents = ''; + while (! $stream->eof()) { + $remaining = $this->maxSourceBytes() - strlen($contents); + if ($remaining <= 0) { + return ['success' => false, 'contents' => '', 'reason' => 'Remote image exceeds the configured size limit.']; + } + + $contents .= $stream->read(min(8192, $remaining + 1)); + if (strlen($contents) > $this->maxSourceBytes()) { + return ['success' => false, 'contents' => '', 'reason' => 'Remote image exceeds the configured size limit.']; + } + } + + if ($contents === '') { + return ['success' => false, 'contents' => '', 'reason' => 'Remote image response was empty.']; + } + + return ['success' => true, 'contents' => $contents, 'reason' => '']; + } + } catch (Throwable $e) { + Log::debug('Unable to fetch remote image.', [ + 'url' => $this->redactedUrl($currentUrl), + 'error' => $e->getMessage(), + ]); + } + + return ['success' => false, 'contents' => '', 'reason' => 'Unable to fetch the remote image.']; + } + + private function processBytes( + string $imgName, + string $contents, + string $destinationDirectory, + ?int $maxWidth, + ?int $maxHeight, + ): ImageProcessingResult { + if (! $this->isValidBasename($imgName)) { + return ImageProcessingResult::failure('Image basename is invalid.'); + } + + if ($contents === '' || strlen($contents) > $this->maxSourceBytes()) { + return ImageProcessingResult::failure('Image source size is invalid or exceeds the configured limit.'); + } + + $temporaryPath = null; + + try { + $image = Image::fromBytes($contents); + $width = $image->width(); + $height = $image->height(); + + if ($width <= 0 || $height <= 0 || ($width * $height) > $this->maxSourcePixels()) { + return ImageProcessingResult::failure('Decoded image dimensions exceed the configured limit.'); + } + + $image = $image->orient(); + $image = $this->resizeDown($image, $width, $height, $maxWidth, $maxHeight); + $encoded = $this->encode($image); + + File::ensureDirectoryExists($destinationDirectory, 0775, true); + $directory = rtrim($destinationDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + $extension = $this->outputExtension(); + $destinationPath = $directory.$imgName.'.'.$extension; + $temporaryPath = $directory.'.'.$imgName.'.'.bin2hex(random_bytes(8)).'.tmp.'.$extension; + + if (File::put($temporaryPath, $encoded) === false) { + return ImageProcessingResult::failure('Unable to write the processed image.'); + } + + $dimensions = getimagesize($temporaryPath); + $mimeType = File::mimeType($temporaryPath); + $expectedMime = $extension === 'webp' ? 'image/webp' : 'image/jpeg'; + + if (! is_array($dimensions) || $mimeType !== $expectedMime || ! File::isReadable($temporaryPath)) { + return ImageProcessingResult::failure('Processed image validation failed.'); + } + + if (! rename($temporaryPath, $destinationPath)) { + return ImageProcessingResult::failure('Unable to atomically publish the processed image.'); + } + $temporaryPath = null; + File::chmod($destinationPath, 0644); + + return ImageProcessingResult::success( + $destinationPath, + (int) $dimensions[0], + (int) $dimensions[1], + $expectedMime, + ); + } catch (Throwable $e) { + Log::debug('Unable to process image.', [ + 'destination' => $destinationDirectory, + 'name' => $imgName, + 'error' => $e->getMessage(), + ]); + + return ImageProcessingResult::failure('Unable to decode or process the image.'); + } finally { + if ($temporaryPath !== null) { + File::delete($temporaryPath); + } + } + } + + private function resizeDown( + LaravelImage $image, + int $width, + int $height, + ?int $maxWidth, + ?int $maxHeight, + ): LaravelImage { + if ($maxWidth === null || $maxHeight === null) { + return $image; + } + + $ratio = min($maxHeight / $height, $maxWidth / $width, 1); + $newWidth = (int) floor($ratio * $width); + $newHeight = (int) floor($ratio * $height); + + if ($ratio < 1 && $newWidth > 10 && $newHeight > 10) { + return $image->resize($newWidth, $newHeight); + } + + return $image; + } + + private function encode(LaravelImage $image): string + { + $quality = max(1, min(100, (int) config('image.output_quality', 82))); + + return match ($this->outputFormat()) { + 'webp' => $image->toWebp()->quality($quality)->toBytes(), + default => $image->toJpeg()->quality($quality)->toBytes(), + }; + } + + private function outputFormat(): string + { + $format = strtolower((string) config('image.output_format', 'webp')); + + return in_array($format, ['jpg', 'jpeg'], true) ? 'jpg' : 'webp'; + } + + private function maxSourceBytes(): int + { + return max(1, (int) config('image.max_source_bytes', 20 * 1024 * 1024)); + } + + private function maxSourcePixels(): int + { + return max(1, (int) config('image.max_source_pixels', 40_000_000)); + } + + private function isValidBasename(string $basename): bool + { + return preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $basename) === 1; + } + + private function assertSafeRemoteUrl(string $url): void + { + $parts = parse_url($url); + if (! is_array($parts) + || ! isset($parts['scheme'], $parts['host']) + || ! in_array(strtolower($parts['scheme']), ['http', 'https'], true) + || isset($parts['user']) + || isset($parts['pass']) + ) { + throw new \InvalidArgumentException('Remote image URL is invalid.'); + } + + $addresses = ($this->hostResolver)($parts['host']); + if ($addresses === []) { + throw new \InvalidArgumentException('Remote image host did not resolve.'); + } + + foreach ($addresses as $address) { + if (filter_var( + $address, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, + ) === false) { + throw new \InvalidArgumentException('Remote image host resolves to a non-public address.'); + } + } + } + + /** @return list */ + private function defaultHostResolver(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return [$host]; + } + + $records = dns_get_record($host, DNS_A | DNS_AAAA); + if ($records === false) { + return []; + } + + $addresses = []; + foreach ($records as $record) { + if (isset($record['ip'])) { + $addresses[] = $record['ip']; + } + if (isset($record['ipv6'])) { + $addresses[] = $record['ipv6']; + } + } + + return array_values(array_unique($addresses)); + } + + private function redactedUrl(string $url): string + { + $parts = parse_url($url); + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + return '[invalid-url]'; + } + + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + + return strtolower($parts['scheme']).'://'.$parts['host'].$port.($parts['path'] ?? '/'); } } diff --git a/app/Services/TvProcessing/Providers/TmdbProvider.php b/app/Services/TvProcessing/Providers/TmdbProvider.php index 806e826c2..f19528bd7 100644 --- a/app/Services/TvProcessing/Providers/TmdbProvider.php +++ b/app/Services/TvProcessing/Providers/TmdbProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\TvProcessing\Providers; +use App\Enums\ImageAssetProfile; use App\Models\Video; use App\Services\ReleaseImageService; use App\Services\TmdbClient; @@ -360,7 +361,12 @@ class TmdbProvider extends AbstractTvProvider // Try to get the Poster if (! empty($this->posterUrl)) { - $hascover = $ri->saveImage((string) $videoId, $this->posterUrl, $this->imgSavePath); + $hascover = (int) $ri->saveRemoteImage( + (string) $videoId, + $this->posterUrl, + $this->imgSavePath, + ImageAssetProfile::Original, + )->success; // Mark it retrieved if we saved an image if ($hascover === 1) { diff --git a/app/Services/TvProcessing/Providers/TraktProvider.php b/app/Services/TvProcessing/Providers/TraktProvider.php index a7e7c51a9..622ae5363 100644 --- a/app/Services/TvProcessing/Providers/TraktProvider.php +++ b/app/Services/TvProcessing/Providers/TraktProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\TvProcessing\Providers; +use App\Enums\ImageAssetProfile; use App\Models\Video; use App\Services\ReleaseImageService; use App\Services\TraktService; @@ -322,12 +323,22 @@ class TraktProvider extends AbstractTvProvider if ($this->posterUrl !== '') { // Try to get the Poster - $hasCover = $ri->saveImage((string) $videoId, $this->posterUrl, $this->imgSavePath); + $hasCover = (int) $ri->saveRemoteImage( + (string) $videoId, + $this->posterUrl, + $this->imgSavePath, + ImageAssetProfile::Original, + )->success; } // Couldn't get poster, try fan art instead if ($hasCover !== 1 && $this->fanartUrl !== '') { - $hasCover = $ri->saveImage((string) $videoId, $this->fanartUrl, $this->imgSavePath); + $hasCover = (int) $ri->saveRemoteImage( + (string) $videoId, + $this->fanartUrl, + $this->imgSavePath, + ImageAssetProfile::Original, + )->success; } // Mark it retrieved if we saved an image diff --git a/app/Services/TvProcessing/Providers/TvMazeProvider.php b/app/Services/TvProcessing/Providers/TvMazeProvider.php index c4d5dcf21..058a1c57e 100644 --- a/app/Services/TvProcessing/Providers/TvMazeProvider.php +++ b/app/Services/TvProcessing/Providers/TvMazeProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\TvProcessing\Providers; +use App\Enums\ImageAssetProfile; use App\Services\ReleaseImageService; use App\Services\TmdbClient; use App\Services\TraktService; @@ -382,7 +383,12 @@ class TvMazeProvider extends AbstractTvProvider // Try to get the Poster if (! empty($this->posterUrl)) { - $hasCover = $ri->saveImage((string) $videoId, $this->posterUrl, $this->imgSavePath); + $hasCover = (int) $ri->saveRemoteImage( + (string) $videoId, + $this->posterUrl, + $this->imgSavePath, + ImageAssetProfile::Original, + )->success; // Mark it retrieved if we saved an image if ($hasCover === 1) { diff --git a/app/Services/TvProcessing/Providers/TvdbProvider.php b/app/Services/TvProcessing/Providers/TvdbProvider.php index c161f445f..6b429c2b3 100644 --- a/app/Services/TvProcessing/Providers/TvdbProvider.php +++ b/app/Services/TvProcessing/Providers/TvdbProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\TvProcessing\Providers; +use App\Enums\ImageAssetProfile; use App\Services\FanartTvService; use App\Services\ReleaseImageService; use App\Services\TmdbClient; @@ -318,7 +319,12 @@ class TvdbProvider extends AbstractTvProvider $hasCover = 0; if (! empty($this->posterUrl)) { - $hasCover = $ri->saveImage((string) $videoId, $this->posterUrl, $this->imgSavePath); + $hasCover = (int) $ri->saveRemoteImage( + (string) $videoId, + $this->posterUrl, + $this->imgSavePath, + ImageAssetProfile::Original, + )->success; if ($hasCover === 1) { $this->setCoverFound($videoId); } diff --git a/app/Support/Data/ImageProcessingResult.php b/app/Support/Data/ImageProcessingResult.php new file mode 100644 index 000000000..07c55e44c --- /dev/null +++ b/app/Support/Data/ImageProcessingResult.php @@ -0,0 +1,27 @@ + [ - - Jpegoptim::class => [ - '-m85', // set maximum quality to 85% - '--strip-all', // this strips out all text information such as comments and EXIF data - '--all-progressive', // this will make sure the resulting image is a progressive one - ], - - Pngquant::class => [ - '--force', // required parameter for this package - ], - - Optipng::class => [ - '-i0', // this will result in a non-interlaced, progressive scanned image - '-o2', // this set the optimization level to two (multiple IDAT compression trials) - '-quiet', // required parameter for this package - ], - - Svgo::class => [ - '--disable=cleanupIDs', // disabling because it is know to cause troubles - ], - - Gifsicle::class => [ - '-b', // required parameter for this package - '-O3', // this produces the slowest but best results - ], - - Cwebp::class => [ - '-m 6', // for the slowest compression method in order to get the best compression. - '-pass 10', // for maximizing the amount of analysis pass. - '-mt', // multithreading for some speed improvements. - '-q 90', // quality factor that brings the least noticeable changes. - ], - ], - - /* - * The directory where your binaries are stored. - * Only use this when you binaries are not accessible in the global environment. - */ - 'binary_path' => '', - - /* - * The maximum time in seconds each optimizer is allowed to run separately. - */ - 'timeout' => 60, - - /* - * If set to `true` all output of the optimizer binaries will be appended to the default log. - * You can also set this to a class that implements `Psr\Log\LoggerInterface`. - */ - 'log_optimizer_activity' => false, -]; diff --git a/config/image.php b/config/image.php index 6b1d300b8..b2f5bafcc 100644 --- a/config/image.php +++ b/config/image.php @@ -17,4 +17,23 @@ return [ 'default' => env('IMAGE_DRIVER', 'imagick'), + /* + |-------------------------------------------------------------------------- + | Stored Image Processing + |-------------------------------------------------------------------------- + | + | These limits apply to covers, previews, and image samples. Destination + | directories remain owned by their callers; this configuration controls + | only decoding, remote fetching, and the encoded output. + | + */ + + 'output_format' => env('IMAGE_OUTPUT_FORMAT', 'webp'), + 'output_quality' => (int) env('IMAGE_OUTPUT_QUALITY', 82), + 'max_source_bytes' => (int) env('IMAGE_MAX_SOURCE_BYTES', 20 * 1024 * 1024), + 'max_source_pixels' => (int) env('IMAGE_MAX_SOURCE_PIXELS', 40_000_000), + 'fetch_connect_timeout' => (int) env('IMAGE_FETCH_CONNECT_TIMEOUT', 5), + 'fetch_timeout' => (int) env('IMAGE_FETCH_TIMEOUT', 30), + 'fetch_max_redirects' => (int) env('IMAGE_FETCH_MAX_REDIRECTS', 5), + ]; diff --git a/resources/js/alpine/components/preview-modal.js b/resources/js/alpine/components/preview-modal.js index ab052379a..ef9bbb684 100644 --- a/resources/js/alpine/components/preview-modal.js +++ b/resources/js/alpine/components/preview-modal.js @@ -6,7 +6,7 @@ import Alpine from '@alpinejs/csp'; const prefetchedUrls = new Set(); function buildImageUrl(guid, type) { - return '/covers/' + (type || 'preview') + '/' + guid + '_thumb.jpg'; + return '/covers/' + (type || 'preview') + '/' + guid + '_thumb.webp'; } function prefetchImage(guid, type) { diff --git a/resources/views/admin/anidb/edit.blade.php b/resources/views/admin/anidb/edit.blade.php index 7e9cc3c13..379667b5f 100644 --- a/resources/views/admin/anidb/edit.blade.php +++ b/resources/views/admin/anidb/edit.blade.php @@ -125,10 +125,10 @@
@php - $hasCover = $anime['anidbid'] > 0 && file_exists(storage_path('covers/anime/' . $anime['anidbid'] . '-cover.jpg')); + $hasCover = $anime['anidbid'] > 0 && (file_exists(storage_path('covers/anime/' . $anime['anidbid'] . '-cover.webp')) || file_exists(storage_path('covers/anime/' . $anime['anidbid'] . '-cover.jpg'))); @endphp @if($hasCover) - {{ $anime['title'] }} @@ -260,4 +260,3 @@
@endsection - diff --git a/resources/views/admin/anidb/index.blade.php b/resources/views/admin/anidb/index.blade.php index bf0069434..d0bb21b44 100644 --- a/resources/views/admin/anidb/index.blade.php +++ b/resources/views/admin/anidb/index.blade.php @@ -61,10 +61,10 @@ @php - $hasCover = $anime->anidbid > 0 && file_exists(storage_path('covers/anime/' . $anime->anidbid . '-cover.jpg')); + $hasCover = $anime->anidbid > 0 && (file_exists(storage_path('covers/anime/' . $anime->anidbid . '-cover.webp')) || file_exists(storage_path('covers/anime/' . $anime->anidbid . '-cover.jpg'))); @endphp @if($hasCover) - {{ $anime->title }} @@ -162,4 +162,3 @@ {{-- Styles moved to resources/css/csp-safe.css --}} @endsection - diff --git a/resources/views/admin/books/edit.blade.php b/resources/views/admin/books/edit.blade.php index e7587d429..4c8626384 100644 --- a/resources/views/admin/books/edit.blade.php +++ b/resources/views/admin/books/edit.blade.php @@ -113,7 +113,7 @@
@if(isset($book['cover']) && $book['cover'] == 1) - {{ $book['title'] }} @@ -190,4 +190,3 @@ {{-- Scripts moved to resources/js/csp-safe.js --}} @endsection - diff --git a/resources/views/admin/books/index.blade.php b/resources/views/admin/books/index.blade.php index 1054ef638..9eae83d41 100644 --- a/resources/views/admin/books/index.blade.php +++ b/resources/views/admin/books/index.blade.php @@ -34,7 +34,7 @@ @if($book->cover == 1) - {{ $book->title }} @@ -104,4 +104,3 @@
@endsection - diff --git a/resources/views/admin/console/edit.blade.php b/resources/views/admin/console/edit.blade.php index 2a5cf1223..3d3f2d462 100644 --- a/resources/views/admin/console/edit.blade.php +++ b/resources/views/admin/console/edit.blade.php @@ -160,11 +160,12 @@
@php - $coverPath = public_path('covers/console/' . $con['id'] . '.jpg'); - $hasCover = file_exists($coverPath); + $coverPath = public_path('covers/console/' . $con['id'] . '.webp'); + $legacyCoverPath = public_path('covers/console/' . $con['id'] . '.jpg'); + $hasCover = file_exists($coverPath) || file_exists($legacyCoverPath); @endphp @if($hasCover) - {{ $con['title'] }} @else @@ -240,4 +241,3 @@ {{-- Scripts moved to resources/js/csp-safe.js --}} @endsection - diff --git a/resources/views/admin/console/index.blade.php b/resources/views/admin/console/index.blade.php index 949fb2427..6e6f4eadc 100644 --- a/resources/views/admin/console/index.blade.php +++ b/resources/views/admin/console/index.blade.php @@ -34,11 +34,12 @@ @php - $coverPath = public_path('covers/console/' . $console->id . '.jpg'); - $hasCover = file_exists($coverPath); + $coverPath = public_path('covers/console/' . $console->id . '.webp'); + $legacyCoverPath = public_path('covers/console/' . $console->id . '.jpg'); + $hasCover = file_exists($coverPath) || file_exists($legacyCoverPath); @endphp @if($hasCover) - {{ $console->title }} @@ -130,4 +131,3 @@
@endsection - diff --git a/resources/views/admin/games/edit.blade.php b/resources/views/admin/games/edit.blade.php index f13832862..e4a6b62d4 100644 --- a/resources/views/admin/games/edit.blade.php +++ b/resources/views/admin/games/edit.blade.php @@ -166,7 +166,7 @@ @if(!empty($game['cover']) && $game['cover'] == 1)
- Game Cover
@@ -212,4 +212,3 @@ @endsection - diff --git a/resources/views/admin/movies/edit.blade.php b/resources/views/admin/movies/edit.blade.php index 500a258c0..61eba8c7c 100644 --- a/resources/views/admin/movies/edit.blade.php +++ b/resources/views/admin/movies/edit.blade.php @@ -52,11 +52,12 @@
@php $imdbid = $movie['imdbid'] ?? $movie->imdbid ?? ''; - $coverPath = public_path('covers/movies/' . $imdbid . '-cover.jpg'); - $hasCover = file_exists($coverPath); + $coverPath = public_path('covers/movies/' . $imdbid . '-cover.webp'); + $legacyCoverPath = public_path('covers/movies/' . $imdbid . '-cover.jpg'); + $hasCover = file_exists($coverPath) || file_exists($legacyCoverPath); @endphp @if($hasCover) - Movie Cover @else @@ -80,11 +81,12 @@
@php - $backdropPath = public_path('covers/movies/' . $imdbid . '-backdrop.jpg'); - $hasBackdrop = file_exists($backdropPath); + $backdropPath = public_path('covers/movies/' . $imdbid . '-backdrop.webp'); + $legacyBackdropPath = public_path('covers/movies/' . $imdbid . '-backdrop.jpg'); + $hasBackdrop = file_exists($backdropPath) || file_exists($legacyBackdropPath); @endphp @if($hasBackdrop) - Movie Backdrop @else @@ -389,4 +391,3 @@
@endsection - diff --git a/resources/views/admin/music/edit.blade.php b/resources/views/admin/music/edit.blade.php index eda7330d0..5d3d5e70c 100644 --- a/resources/views/admin/music/edit.blade.php +++ b/resources/views/admin/music/edit.blade.php @@ -187,7 +187,7 @@ @if(!empty($mus['cover']) && $mus['cover'] == 1)
- Album Cover
@@ -233,4 +233,3 @@ @endsection - diff --git a/resources/views/admin/shows/index.blade.php b/resources/views/admin/shows/index.blade.php index 5c2ee26fd..7502ce1bb 100644 --- a/resources/views/admin/shows/index.blade.php +++ b/resources/views/admin/shows/index.blade.php @@ -67,11 +67,12 @@ @php - $coverPath = public_path('covers/tvshows/' . $show->id . '.jpg'); - $hasCover = file_exists($coverPath); + $coverPath = public_path('covers/tvshows/' . $show->id . '.webp'); + $legacyCoverPath = public_path('covers/tvshows/' . $show->id . '.jpg'); + $hasCover = file_exists($coverPath) || file_exists($legacyCoverPath); @endphp @if($hasCover) - {{ $show->title }} @@ -199,4 +200,3 @@ {{-- Styles moved to resources/css/csp-safe.css --}} @endsection - diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 84e9d08a4..47147b9aa 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -96,7 +96,7 @@
@if(!empty($result->cover)) - {{ $result->title }} diff --git a/resources/views/details/index.blade.php b/resources/views/details/index.blade.php index 553076ed0..e89315177 100644 --- a/resources/views/details/index.blade.php +++ b/resources/views/details/index.blade.php @@ -145,8 +145,8 @@ @if($hasPreviewImage)
-
- + Preview @@ -158,8 +158,8 @@ @if($hasSampleImage)
-
- + Sample diff --git a/resources/views/mymovies/add.blade.php b/resources/views/mymovies/add.blade.php index 6173bc821..75f5ed036 100644 --- a/resources/views/mymovies/add.blade.php +++ b/resources/views/mymovies/add.blade.php @@ -22,7 +22,7 @@
{{ e($movie['title'] ?? '') }} @@ -146,7 +146,7 @@ {{ e($movie['title'] ?? '') }} @@ -212,7 +212,7 @@ - diff --git a/resources/views/mymovies/index.blade.php b/resources/views/mymovies/index.blade.php index de472dde6..39c96ce2e 100644 --- a/resources/views/mymovies/index.blade.php +++ b/resources/views/mymovies/index.blade.php @@ -79,7 +79,7 @@
{{ e($movie['title'] ?? '') }}
@@ -176,7 +176,7 @@
{{ e($movie['title'] ?? '') }}
@@ -256,4 +256,3 @@
@endif
- diff --git a/resources/views/myshows/add.blade.php b/resources/views/myshows/add.blade.php index b1a78473e..72f8143c7 100644 --- a/resources/views/myshows/add.blade.php +++ b/resources/views/myshows/add.blade.php @@ -22,7 +22,7 @@
{{ e($show['title'] ?? '') }} @@ -88,4 +88,3 @@
- diff --git a/resources/views/series/trending.blade.php b/resources/views/series/trending.blade.php index a326f343a..f25a10b56 100644 --- a/resources/views/series/trending.blade.php +++ b/resources/views/series/trending.blade.php @@ -49,7 +49,7 @@
@endsection - diff --git a/resources/views/series/viewseries.blade.php b/resources/views/series/viewseries.blade.php index e6570df48..bdfddc5c4 100644 --- a/resources/views/series/viewseries.blade.php +++ b/resources/views/series/viewseries.blade.php @@ -93,7 +93,7 @@
{{ $seriestitles ?? '' }} Poster + src="{{ url('/covers/tvshows/' . $show['id'] . '.webp') }}"/>

{{ $seriessummary }}

diff --git a/routes/web.php b/routes/web.php index e7edc38d6..692a8b5d3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -91,7 +91,7 @@ use Spatie\LaravelPasskeys\Http\Controllers\GeneratePasskeyAuthenticationOptions // Serve cover images from storage - Must be public (no auth required) Route::get('/covers/{type}/{filename}', [CoverController::class, 'show']) - ->where('type', 'anime|audio|audiosample|book|console|games|movies|music|preview|sample|tvrage|video') + ->where('type', 'anime|audio|audiosample|book|console|games|movies|music|preview|sample|tvrage|video|tvshows') ->where('filename', '.*') ->name('covers.show'); diff --git a/tests/Feature/AdditionalProcessingReleaseFileManagerTest.php b/tests/Feature/AdditionalProcessingReleaseFileManagerTest.php index 38c927f63..3d743fb76 100644 --- a/tests/Feature/AdditionalProcessingReleaseFileManagerTest.php +++ b/tests/Feature/AdditionalProcessingReleaseFileManagerTest.php @@ -16,6 +16,7 @@ use App\Services\ReleaseImageService; use Illuminate\Contracts\Console\Kernel; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; use Mockery; use PDO; @@ -154,11 +155,44 @@ class AdditionalProcessingReleaseFileManagerTest extends TestCase $this->assertSame(1, DB::table('par_hashes')->count()); } + public function test_finalize_recognizes_webp_preview_and_sample_without_moving_them(): void + { + DB::table('releases')->insert($this->releaseRow()); + Search::shouldReceive('updateRelease')->once()->with(1); + + $imageService = new ReleaseImageService; + $preview = $imageService->imgSavePath.'guid-1_thumb.webp'; + $sample = $imageService->jpgSavePath.'guid-1_thumb.webp'; + File::ensureDirectoryExists(dirname($preview)); + File::ensureDirectoryExists(dirname($sample)); + File::put($preview, 'preview'); + File::put($sample, 'sample'); + + try { + $manager = $this->makeManagerWithImageService($imageService); + $context = new ReleaseProcessingContext(Release::query()->findOrFail(1)); + + $manager->finalizeRelease($context, false); + + $this->assertSame(1, DB::table('releases')->where('id', 1)->value('haspreview')); + $this->assertSame(1, DB::table('releases')->where('id', 1)->value('jpgstatus')); + } finally { + File::delete([$preview, $sample]); + } + } + private function makeManager(?NameFixingService $nameFixing = null): ReleaseFileManager { + return $this->makeManagerWithImageService(new ReleaseImageService, $nameFixing); + } + + private function makeManagerWithImageService( + ReleaseImageService $imageService, + ?NameFixingService $nameFixing = null, + ): ReleaseFileManager { return new ReleaseFileManager( $this->makeConfig(), - new ReleaseImageService, + $imageService, new NfoService, new TestNzbService, $nameFixing ?? new CountingNameFixingService @@ -232,6 +266,8 @@ class AdditionalProcessingReleaseFileManagerTest extends TestCase $table->integer('categories_id')->default(10); $table->integer('passwordstatus')->default(-1); $table->integer('haspreview')->default(-1); + $table->integer('jpgstatus')->default(0); + $table->integer('videostatus')->default(0); $table->integer('nzbstatus')->default(1); $table->integer('rarinnerfilecount')->default(0); $table->integer('pp_timeout_count')->default(0); diff --git a/tests/Feature/CoverControllerTest.php b/tests/Feature/CoverControllerTest.php new file mode 100644 index 000000000..2e7591b31 --- /dev/null +++ b/tests/Feature/CoverControllerTest.php @@ -0,0 +1,105 @@ + */ + private array $createdFiles = []; + + protected function setUp(): void + { + parent::setUp(); + + config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]); + } + + protected function tearDown(): void + { + File::delete($this->createdFiles); + + parent::tearDown(); + } + + public function test_webp_request_falls_back_to_storage_backed_jpeg(): void + { + $name = 'fallback-'.uniqid(); + $this->createImage(storage_path('covers/preview/'.$name.'.jpg'), 'jpg'); + + $response = (new CoverController)->show('preview', $name.'.webp'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('image/jpeg', $response->headers->get('Content-Type')); + } + + public function test_legacy_jpeg_request_falls_back_to_storage_backed_webp(): void + { + $name = 'fallback-'.uniqid(); + $this->createImage(storage_path('covers/sample/'.$name.'.webp'), 'webp'); + + $response = (new CoverController)->show('sample', $name.'.jpg'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('image/webp', $response->headers->get('Content-Type')); + } + + public function test_missing_public_extension_falls_back_without_moving_the_asset(): void + { + $name = 'public-'.uniqid(); + $path = public_path('covers/movies/'.$name.'-cover.jpg'); + $this->createImage($path, 'jpg'); + + $response = (new CoverController)->show('movies', $name.'-cover.webp'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('image/jpeg', $response->headers->get('Content-Type')); + $this->assertFileExists($path); + $this->assertFileDoesNotExist(public_path('covers/movies/'.$name.'-cover.webp')); + } + + public function test_tvshows_are_accepted_by_the_cover_route(): void + { + $id = (string) random_int(8000000, 8999999); + $this->createImage(storage_path('covers/tvshows/'.$id.'.webp'), 'webp'); + + $response = (new CoverController)->show('tvshows', $id.'.webp'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('image/webp', $response->headers->get('Content-Type')); + + $route = app('router')->getRoutes()->getByName('covers.show'); + $this->assertNotNull($route); + $this->assertStringContainsString('tvshows', $route->wheres['type']); + } + + public function test_traversal_filename_is_rejected(): void + { + $this->expectException(NotFoundHttpException::class); + + (new CoverController)->show('preview', '../.env.webp'); + } + + private function createImage(string $path, string $format): void + { + File::ensureDirectoryExists(dirname($path)); + $image = imagecreatetruecolor(20, 10); + $this->assertInstanceOf(GdImage::class, $image); + imagefill($image, 0, 0, imagecolorallocate($image, 20, 40, 60)); + + if ($format === 'webp') { + imagewebp($image, $path, 82); + } else { + imagejpeg($image, $path, 82); + } + + $this->createdFiles[] = $path; + } +} diff --git a/tests/Unit/MediaProcessingServiceTest.php b/tests/Unit/MediaProcessingServiceTest.php deleted file mode 100644 index fbddedbf5..000000000 --- a/tests/Unit/MediaProcessingServiceTest.php +++ /dev/null @@ -1,149 +0,0 @@ -instance('files', new Filesystem); - Facade::setFacadeApplication($container); - - $this->tmpDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'mps_'.uniqid().DIRECTORY_SEPARATOR; - File::makeDirectory($this->tmpDir, 0777, true, true); - } - - protected function tearDown(): void - { - if (File::exists($this->tmpDir)) { - File::deleteDirectory($this->tmpDir); - } - Mockery::close(); - parent::tearDown(); - } - - private function makeService( - ?FFMpeg $ffmpeg = null, - ?FFProbe $ffprobe = null, - ?MediaInfo $mediaInfo = null, - ?ReleaseImageService $releaseImage = null, - ?ReleaseExtraService $releaseExtra = null, - ?CategorizationService $categorize = null - ): MediaProcessingService { - $ffmpeg ??= Mockery::mock(FFMpeg::class); - $ffprobe ??= Mockery::mock(FFProbe::class); - $mediaInfo ??= Mockery::mock(MediaInfo::class); - $releaseImage ??= Mockery::mock(ReleaseImageService::class); - $releaseExtra ??= Mockery::mock(ReleaseExtraService::class); - $categorize ??= Mockery::mock(CategorizationService::class); - - return new MediaProcessingService($ffmpeg, $ffprobe, $mediaInfo, $releaseImage, $releaseExtra, $categorize); - } - - #[WithoutErrorHandler] - public function test_get_video_time_parses_duration_string(): void - { - $ffprobe = Mockery::mock(FFProbe::class); - $ffprobe->shouldReceive('isValid')->once()->andReturn(true); - $format = new class - { - public function get($key) - { - return 'time=00:05.10 bitrate=800k'; - } - }; - $ffprobe->shouldReceive('format')->once()->andReturn($format); - $svc = $this->makeService(null, $ffprobe); - $out = $svc->getVideoTime($this->tmpDir.'vid.avi'); - $this->assertSame('00:00:05.09', $out); - } - - #[WithoutErrorHandler] - public function test_create_sample_image_returns_true_when_saved(): void - { - $videoFile = $this->tmpDir.'video.avi'; - File::put($videoFile, 'fake'); - - $ffprobe = Mockery::mock(FFProbe::class); - $ffprobe->shouldReceive('isValid')->andReturn(true); - $format = new class - { - public function get($key) - { - return 'time=00:03.10 bitrate=800k'; - } - }; - $ffprobe->shouldReceive('format')->andReturn($format); - - $frameMock = new class - { - public function save($path) - { - \file_put_contents($path, 'x'); - } - }; - $openMock = new class($frameMock) - { - public function __construct(private $frame) {} - - public function frame($tc) - { - return $this->frame; - } - }; - $ffmpeg = Mockery::mock(FFMpeg::class); - $ffmpeg->shouldReceive('open')->andReturn($openMock); - - $releaseImage = Mockery::mock(ReleaseImageService::class); - $releaseImage->imgSavePath = $this->tmpDir; - $releaseImage->shouldReceive('saveImage')->andReturn(1); - - $svc = $this->makeService($ffmpeg, $ffprobe, null, $releaseImage); - $ok = $svc->createSampleImage('guid123', $videoFile, $this->tmpDir, true); - $this->assertTrue($ok); - } - - #[WithoutErrorHandler] - public function test_add_video_media_info_false_if_file_missing(): void - { - $svc = $this->makeService(); - $this->assertFalse($svc->addVideoMediaInfo(1, $this->tmpDir.'nope.avi')); - } - - #[WithoutErrorHandler] - public function test_add_audio_info_and_sample_returns_true_when_disabled(): void - { - $svc = $this->makeService(); - $release = new ReleaseModel; - $release->id = 1; - $release->guid = 'g'; - $release->predb_id = 0; - $release->categories_id = 0; - $release->groups_id = 0; - $release->fromname = ''; - $res = $svc->addAudioInfoAndSample($release, $this->tmpDir.'nofile.mp3', 'MP3', false, false, $this->tmpDir); - $this->assertTrue($res['info']); - $this->assertTrue($res['sample']); - } -} diff --git a/tests/Unit/ReleaseImageServiceTest.php b/tests/Unit/ReleaseImageServiceTest.php index 20b950b98..fd12a1507 100644 --- a/tests/Unit/ReleaseImageServiceTest.php +++ b/tests/Unit/ReleaseImageServiceTest.php @@ -4,10 +4,11 @@ declare(strict_types=1); namespace Tests\Unit; +use App\Enums\ImageAssetProfile; use App\Services\ReleaseImageService; use GdImage; use Illuminate\Support\Facades\File; -use Spatie\LaravelImageOptimizer\Facades\ImageOptimizer; +use Illuminate\Support\Facades\Http; use Tests\TestCase; class ReleaseImageServiceTest extends TestCase @@ -18,7 +19,13 @@ class ReleaseImageServiceTest extends TestCase { parent::setUp(); - config(['image.default' => 'gd']); + config([ + 'image.default' => 'gd', + 'image.output_format' => 'webp', + 'image.output_quality' => 82, + 'image.max_source_bytes' => 20 * 1024 * 1024, + 'image.max_source_pixels' => 40_000_000, + ]); $this->temporaryDirectory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'release-image-'.uniqid('', true).DIRECTORY_SEPARATOR; File::makeDirectory($this->temporaryDirectory, 0777, true); @@ -31,80 +38,157 @@ class ReleaseImageServiceTest extends TestCase parent::tearDown(); } - public function test_it_converts_and_proportionally_resizes_a_local_image_to_jpeg(): void + public function test_it_converts_and_proportionally_resizes_a_local_image_to_webp(): void { $source = $this->createPng('source.png', 400, 200); - $destination = $this->temporaryDirectory.'cover.jpg'; - ImageOptimizer::shouldReceive('optimize')->once()->with($destination); + $result = (new ReleaseImageService)->saveLocalImage( + 'cover', + $source, + $this->temporaryDirectory, + ImageAssetProfile::MetadataCover, + ); - $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 100, 100); - - $this->assertSame(1, $result); - $this->assertSame('image/jpeg', File::mimeType($destination)); - $this->assertImageDimensions($destination, 100, 50); + $this->assertTrue($result->success); + $this->assertSame($this->temporaryDirectory.'cover.webp', $result->path); + $this->assertSame('image/webp', File::mimeType($result->path)); + $this->assertImageDimensions($result->path, 250, 125); } - public function test_it_does_not_upscale_or_create_a_thumbnail_when_no_resize_occurs(): void + public function test_it_does_not_upscale_a_small_image(): void { $source = $this->createPng('small.png', 40, 20); - $destination = $this->temporaryDirectory.'cover.jpg'; - ImageOptimizer::shouldReceive('optimize')->once()->with($destination); + $result = (new ReleaseImageService)->saveLocalImage( + 'cover', + $source, + $this->temporaryDirectory, + ImageAssetProfile::MetadataCover, + ); - $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 250, 250, true); - - $this->assertSame(1, $result); - $this->assertImageDimensions($destination, 40, 20); - $this->assertFileDoesNotExist($this->temporaryDirectory.'cover_thumb.jpg'); + $this->assertTrue($result->success); + $this->assertImageDimensions($result->path, 40, 20); } - public function test_it_preserves_the_existing_small_dimension_resize_threshold(): void - { - $source = $this->createPng('short.png', 400, 20); - $destination = $this->temporaryDirectory.'cover.jpg'; - - ImageOptimizer::shouldReceive('optimize')->once()->with($destination); - - $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 100, 100); - - $this->assertSame(1, $result); - $this->assertImageDimensions($destination, 400, 20); - } - - public function test_it_writes_and_optimizes_the_main_image_and_thumbnail(): void + public function test_compatibility_wrapper_preserves_custom_bounds_and_thumbnail_name(): void { $source = $this->createPng('source.png', 200, 100); - $destination = $this->temporaryDirectory.'cover.jpg'; - $thumbnail = $this->temporaryDirectory.'cover_thumb.jpg'; - - ImageOptimizer::shouldReceive('optimize')->once()->with($thumbnail); - ImageOptimizer::shouldReceive('optimize')->once()->with($destination); - - $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 100, 100, true); - - $this->assertSame(1, $result); - $this->assertImageDimensions($destination, 100, 50); - $this->assertImageDimensions($thumbnail, 100, 50); - } - - public function test_it_returns_zero_for_empty_missing_invalid_and_unwritable_inputs(): void - { - ImageOptimizer::shouldReceive('optimize')->never(); - $service = new ReleaseImageService; - $this->assertSame(0, $service->saveImage('empty', '', $this->temporaryDirectory)); - $this->assertSame(0, $service->saveImage('missing', $this->temporaryDirectory.'missing.png', $this->temporaryDirectory)); + $result = $service->saveImage('cover', $source, $this->temporaryDirectory, 100, 100, true); + $this->assertSame(1, $result); + $this->assertImageDimensions($this->temporaryDirectory.'cover.webp', 100, 50); + $this->assertImageDimensions($this->temporaryDirectory.'cover_thumb.webp', 100, 50); + } + + public function test_it_rejects_invalid_basename_missing_invalid_and_oversized_inputs(): void + { + $service = new ReleaseImageService; + $source = $this->createPng('source.png', 100, 50); $invalid = $this->temporaryDirectory.'invalid.png'; File::put($invalid, 'not an image'); - $this->assertSame(0, $service->saveImage('invalid', $invalid, $this->temporaryDirectory)); - $source = $this->createPng('source.png', 100, 50); - $notDirectory = $this->temporaryDirectory.'not-a-directory'; - File::put($notDirectory, 'file'); - $this->assertSame(0, $service->saveImage('unwritable', $source, $notDirectory.DIRECTORY_SEPARATOR)); + $this->assertFalse($service->saveLocalImage('../cover', $source, $this->temporaryDirectory)->success); + $this->assertFalse($service->saveLocalImage('missing', $this->temporaryDirectory.'missing.png', $this->temporaryDirectory)->success); + $this->assertFalse($service->saveLocalImage('invalid', $invalid, $this->temporaryDirectory)->success); + + config(['image.max_source_bytes' => 2]); + $this->assertFalse($service->saveLocalImage('large', $source, $this->temporaryDirectory)->success); + } + + public function test_failed_processing_preserves_an_existing_asset(): void + { + $destination = $this->temporaryDirectory.'cover.webp'; + File::put($destination, 'existing-image'); + $invalid = $this->temporaryDirectory.'invalid.png'; + File::put($invalid, 'not an image'); + + $result = (new ReleaseImageService)->saveLocalImage('cover', $invalid, $this->temporaryDirectory); + + $this->assertFalse($result->success); + $this->assertSame('existing-image', File::get($destination)); + $this->assertSame([], File::glob($this->temporaryDirectory.'.cover.*.tmp.webp')); + } + + public function test_it_fetches_a_public_remote_image_with_bounded_http_client(): void + { + $source = $this->createPng('remote.png', 60, 30); + $bytes = File::get($source); + Http::fake([ + 'https://images.example.test/cover.png' => Http::response($bytes, 200, [ + 'Content-Length' => (string) strlen($bytes), + ]), + ]); + $service = new ReleaseImageService(static fn (string $host): array => ['93.184.216.34']); + + $result = $service->saveRemoteImage( + 'remote', + 'https://images.example.test/cover.png', + $this->temporaryDirectory, + ); + + $this->assertTrue($result->success); + $this->assertSame('image/webp', File::mimeType($result->path)); + Http::assertSentCount(1); + } + + public function test_it_rejects_remote_hosts_that_resolve_to_private_addresses(): void + { + Http::fake(); + $service = new ReleaseImageService(static fn (string $host): array => ['127.0.0.1']); + + $result = $service->saveRemoteImage( + 'private', + 'http://internal.example.test/image.jpg?token=secret', + $this->temporaryDirectory, + ); + + $this->assertFalse($result->success); + Http::assertNothingSent(); + } + + public function test_release_paths_keep_the_existing_storage_relationship(): void + { + $service = new ReleaseImageService; + + $this->assertSame(storage_path('covers/preview/'), $service->imgSavePath); + $this->assertSame(storage_path('covers/sample/'), $service->jpgSavePath); + $this->assertSame(storage_path('covers/movies/'), $service->movieImgSavePath); + } + + public function test_jpeg_output_remains_available_as_a_rollback_setting(): void + { + config(['image.output_format' => 'jpg']); + $source = $this->createPng('rollback.png', 30, 15); + + $result = (new ReleaseImageService)->saveLocalImage('rollback', $source, $this->temporaryDirectory); + + $this->assertTrue($result->success); + $this->assertSame($this->temporaryDirectory.'rollback.jpg', $result->path); + $this->assertSame('image/jpeg', File::mimeType($result->path)); + } + + public function test_delete_removes_webp_and_legacy_release_images(): void + { + $service = new ReleaseImageService; + $guid = 'delete-'.uniqid(); + $files = [ + $service->imgSavePath.$guid.'_thumb.webp', + $service->imgSavePath.$guid.'_thumb.jpg', + $service->jpgSavePath.$guid.'_thumb.webp', + $service->jpgSavePath.$guid.'_thumb.jpg', + ]; + foreach ($files as $file) { + File::ensureDirectoryExists(dirname($file)); + File::put($file, 'image'); + } + + $service->delete($guid); + + foreach ($files as $file) { + $this->assertFileDoesNotExist($file); + } } private function createPng(string $filename, int $width, int $height): string @@ -121,8 +205,9 @@ class ReleaseImageServiceTest extends TestCase return $path; } - private function assertImageDimensions(string $path, int $width, int $height): void + private function assertImageDimensions(?string $path, int $width, int $height): void { + $this->assertNotNull($path); $dimensions = getimagesize($path); $this->assertIsArray($dimensions);