Make webp default format for images

This commit is contained in:
DariusIII
2026-07-16 15:57:20 +02:00
parent 17be0e11d2
commit b27fb8d86b
60 changed files with 1154 additions and 1136 deletions
+13
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Enums;
/**
* Image bounds used by the existing cover and sample producers.
*/
enum ImageAssetProfile
{
case Original;
case MetadataCover;
case Backdrop;
case Preview;
case Sample;
public function maxWidth(): ?int
{
return match ($this) {
self::Original => 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,
};
}
}
+7 -4
View File
@@ -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]) {
@@ -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;
@@ -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']
@@ -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;
@@ -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'),
@@ -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');
+1 -1
View File
@@ -672,7 +672,7 @@ class XML_Response
$dcov = ($dir === 'movies' ? '-cover' : '');
$this->cdata .=
"\t<img style=\"margin-left:10px;margin-bottom:10px;float:right;\" ".
"src=\"{$this->server['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);
+55 -62
View File
@@ -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<int, string>
*/
/** @var list<string> */
private const array VALID_TYPES = [
'anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies',
'music', 'preview', 'sample', 'tvrage', 'video', 'tvshows',
];
/** @var list<string> */
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);
}
+5 -3
View File
@@ -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;
+4 -3
View File
@@ -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;
+2 -2
View File
@@ -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');
}
}
+10 -3
View File
@@ -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;
+8 -3
View File
@@ -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');
}
}
+16 -4
View File
@@ -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;
}
}
@@ -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
);
@@ -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
@@ -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);
@@ -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');
+7 -1
View File
@@ -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'].
+13 -2
View File
@@ -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(
+13 -2
View File
@@ -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: ').
-327
View File
@@ -1,327 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Facades\Search;
use App\Models\Category;
use App\Models\Release;
use App\Services\Categorization\CategorizationService;
use FFMpeg\Coordinate\Dimension;
use FFMpeg\Coordinate\TimeCode;
use FFMpeg\FFMpeg;
use FFMpeg\FFProbe;
use FFMpeg\Filters\Video\ResizeFilter;
use FFMpeg\Format\Audio\Vorbis;
use FFMpeg\Format\Video\Ogg;
use FFMpeg\Media\Video;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Mhor\MediaInfo\MediaInfo;
class MediaProcessingService
{
public function __construct(
private readonly FFMpeg $ffmpeg,
private readonly FFProbe $ffprobe,
private readonly MediaInfo $mediaInfo,
private readonly ReleaseImageService $releaseImage,
private readonly ReleaseExtraService $releaseExtra,
private readonly CategorizationService $categorize,
) {}
public function getVideoTime(string $videoLocation): string
{
$time = null;
try {
if ($this->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<string, mixed>
*/
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];
}
}
+27 -14
View File
@@ -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;
}
}
+19 -3
View File
@@ -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 = '';
+17 -26
View File
@@ -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) {
+430 -152
View File
@@ -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<string> */
private Closure $hostResolver;
/**
* ReleaseImageService constructor.
* @param (Closure(string): list<string>)|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<string> */
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'] ?? '/');
}
}
@@ -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) {
@@ -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
@@ -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) {
@@ -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);
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\Data;
final readonly class ImageProcessingResult
{
private function __construct(
public bool $success,
public ?string $path,
public ?int $width,
public ?int $height,
public ?string $mimeType,
public ?string $failureReason,
) {}
public static function success(string $path, int $width, int $height, string $mimeType): self
{
return new self(true, $path, $width, $height, $mimeType, null);
}
public static function failure(string $reason): self
{
return new self(false, null, null, null, null, $reason);
}
}
-1
View File
@@ -68,7 +68,6 @@
"sentry/sentry-laravel": "^4.13",
"spatie/laravel-data": "^4",
"spatie/laravel-directory-cleanup": "^1.10",
"spatie/laravel-image-optimizer": "^1.8",
"spatie/laravel-passkeys": "^1.7",
"spatie/laravel-permission": "^7.0.0",
"spatie/laravel-typescript-transformer": "^2",
Generated
+1 -124
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8c7133089933c92e5fed429b9bc02822",
"content-hash": "41116ee8ce5edd0e34645adc6a218aec",
"packages": [
{
"name": "aharen/omdbapi",
@@ -6137,61 +6137,6 @@
],
"time": "2026-07-15T14:47:03+00:00"
},
{
"name": "spatie/image-optimizer",
"version": "1.10.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/image-optimizer.git",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"php": "^7.4|^8.0",
"psr/log": "^1.0 | ^2.0 | ^3.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ImageOptimizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily optimize images using PHP",
"homepage": "https://github.com/spatie/image-optimizer",
"keywords": [
"image-optimizer",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/image-optimizer/issues",
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
},
"time": "2026-06-29T08:28:30+00:00"
},
{
"name": "spatie/laravel-data",
"version": "4.23.0",
@@ -6349,74 +6294,6 @@
],
"time": "2026-02-22T18:46:38+00:00"
},
{
"name": "spatie/laravel-image-optimizer",
"version": "1.8.3",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-image-optimizer.git",
"reference": "abc476add8b41d10185a07377ce7f64657b3ed91"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-image-optimizer/zipball/abc476add8b41d10185a07377ce7f64657b3ed91",
"reference": "abc476add8b41d10185a07377ce7f64657b3ed91",
"shasum": ""
},
"require": {
"laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"spatie/image-optimizer": "^1.2.0"
},
"require-dev": {
"orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0|^11.0",
"phpunit/phpunit": "^9.4|^10.5|^11.5.3|^12.5.12"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"ImageOptimizer": "Spatie\\LaravelImageOptimizer\\Facades\\ImageOptimizer"
},
"providers": [
"Spatie\\LaravelImageOptimizer\\ImageOptimizerServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\LaravelImageOptimizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Optimize images in your Laravel app",
"homepage": "https://github.com/spatie/laravel-image-optimizer",
"keywords": [
"laravel-image-optimizer",
"spatie"
],
"support": {
"source": "https://github.com/spatie/laravel-image-optimizer/tree/1.8.3"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
}
],
"time": "2026-02-21T21:35:45+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.1",
-66
View File
@@ -1,66 +0,0 @@
<?php
use Spatie\ImageOptimizer\Optimizers\Cwebp;
use Spatie\ImageOptimizer\Optimizers\Gifsicle;
use Spatie\ImageOptimizer\Optimizers\Jpegoptim;
use Spatie\ImageOptimizer\Optimizers\Optipng;
use Spatie\ImageOptimizer\Optimizers\Pngquant;
use Spatie\ImageOptimizer\Optimizers\Svgo;
return [
/*
* When calling `optimize` the package will automatically determine which optimizers
* should run for the given image.
*/
'optimizers' => [
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,
];
+19
View File
@@ -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),
];
@@ -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) {
+2 -3
View File
@@ -125,10 +125,10 @@
</label>
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-900">
@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)
<img src="{{ url('/covers/anime/' . $anime['anidbid'] . '-cover.jpg') }}"
<img src="{{ url('/covers/anime/' . $anime['anidbid'] . '-cover.webp') }}"
alt="{{ $anime['title'] }}"
class="max-w-full h-auto mx-auto rounded shadow-lg"
style="max-height: 400px;">
@@ -260,4 +260,3 @@
</div>
</div>
@endsection
+2 -3
View File
@@ -61,10 +61,10 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap">
@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)
<img src="{{ url('/covers/anime/' . $anime->anidbid . '-cover.jpg') }}"
<img src="{{ url('/covers/anime/' . $anime->anidbid . '-cover.webp') }}"
alt="{{ $anime->title }}"
class="h-16 w-12 object-cover rounded shadow"
loading="lazy">
@@ -162,4 +162,3 @@
{{-- Styles moved to resources/css/csp-safe.css --}}
@endsection
+1 -2
View File
@@ -113,7 +113,7 @@
</label>
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-900">
@if(isset($book['cover']) && $book['cover'] == 1)
<img src="{{ asset('storage/covers/book/' . $book['id'] . '.jpg') }}"
<img src="{{ asset('storage/covers/book/' . $book['id'] . (file_exists(storage_path('covers/book/' . $book['id'] . '.webp')) ? '.webp' : '.jpg')) }}"
alt="{{ $book['title'] }}"
class="max-w-full h-auto mx-auto rounded shadow-lg img-max-h-400"
data-fallback-src="{{ asset('images/no-cover.png') }}">
@@ -190,4 +190,3 @@
{{-- Scripts moved to resources/js/csp-safe.js --}}
@endsection
+1 -2
View File
@@ -34,7 +34,7 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap">
@if($book->cover == 1)
<img src="{{ asset('storage/covers/book/' . $book->id . '.jpg') }}"
<img src="{{ asset('storage/covers/book/' . $book->id . (file_exists(storage_path('covers/book/' . $book->id . '.webp')) ? '.webp' : '.jpg')) }}"
alt="{{ $book->title }}"
class="h-16 w-12 object-cover rounded shadow"
data-fallback-src="{{ asset('images/no-cover.png') }}">
@@ -104,4 +104,3 @@
</div>
</div>
@endsection
+4 -4
View File
@@ -160,11 +160,12 @@
</label>
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-900">
@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)
<img src="{{ asset('covers/console/' . $con['id'] . '.jpg') }}"
<img src="{{ asset('covers/console/' . $con['id'] . (file_exists($coverPath) ? '.webp' : '.jpg')) }}"
alt="{{ $con['title'] }}"
class="max-w-full h-auto mx-auto rounded shadow-lg img-max-h-400">
@else
@@ -240,4 +241,3 @@
{{-- Scripts moved to resources/js/csp-safe.js --}}
@endsection
@@ -34,11 +34,12 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap">
@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)
<img src="{{ asset('covers/console/' . $console->id . '.jpg') }}"
<img src="{{ asset('covers/console/' . $console->id . (file_exists($coverPath) ? '.webp' : '.jpg')) }}"
alt="{{ $console->title }}"
class="h-16 w-12 object-cover rounded shadow"
loading="lazy">
@@ -130,4 +131,3 @@
</div>
</div>
@endsection
+1 -2
View File
@@ -166,7 +166,7 @@
</label>
@if(!empty($game['cover']) && $game['cover'] == 1)
<div class="mb-3">
<img src="{{ asset('storage/covers/games/' . $game['id'] . '.jpg') }}"
<img src="{{ asset('storage/covers/games/' . $game['id'] . (file_exists(storage_path('covers/games/' . $game['id'] . '.webp')) ? '.webp' : '.jpg')) }}"
alt="Game Cover"
class="max-w-xs rounded-lg shadow-md border border-gray-300 dark:border-gray-600">
</div>
@@ -212,4 +212,3 @@
</div>
</div>
@endsection
+8 -7
View File
@@ -52,11 +52,12 @@
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
@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)
<img src="{{ asset('covers/movies/' . $imdbid . '-cover.jpg') }}"
<img src="{{ asset('covers/movies/' . $imdbid . (file_exists($coverPath) ? '-cover.webp' : '-cover.jpg')) }}"
alt="Movie Cover"
class="w-full h-auto rounded-lg mb-3">
@else
@@ -80,11 +81,12 @@
</label>
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
@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)
<img src="{{ asset('covers/movies/' . $imdbid . '-backdrop.jpg') }}"
<img src="{{ asset('covers/movies/' . $imdbid . (file_exists($backdropPath) ? '-backdrop.webp' : '-backdrop.jpg')) }}"
alt="Movie Backdrop"
class="w-full h-auto rounded-lg mb-3">
@else
@@ -389,4 +391,3 @@
</div>
</div>
@endsection
+1 -2
View File
@@ -187,7 +187,7 @@
</label>
@if(!empty($mus['cover']) && $mus['cover'] == 1)
<div class="mb-3">
<img src="{{ asset('storage/covers/music/' . $mus['id'] . '.jpg') }}"
<img src="{{ asset('storage/covers/music/' . $mus['id'] . (file_exists(storage_path('covers/music/' . $mus['id'] . '.webp')) ? '.webp' : '.jpg')) }}"
alt="Album Cover"
class="max-w-xs rounded-lg shadow-md border border-gray-300 dark:border-gray-600">
</div>
@@ -233,4 +233,3 @@
</div>
</div>
@endsection
+4 -4
View File
@@ -67,11 +67,12 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap">
@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)
<img src="{{ asset('covers/tvshows/' . $show->id . '.jpg') }}"
<img src="{{ asset('covers/tvshows/' . $show->id . (file_exists($coverPath) ? '.webp' : '.jpg')) }}"
alt="{{ $show->title }}"
class="h-16 w-12 object-cover rounded shadow"
loading="lazy">
@@ -199,4 +200,3 @@
{{-- Styles moved to resources/css/csp-safe.css --}}
@endsection
+1 -1
View File
@@ -96,7 +96,7 @@
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition-shadow duration-200">
<a href="{{ $guid ? url('/details/' . $guid) : '#' }}" class="block relative">
@if(!empty($result->cover))
<img src="{{ url('/covers/book/' . $result->id . '.jpg') }}"
<img src="{{ url('/covers/book/' . $result->id . '.webp') }}"
alt="{{ $result->title }}"
class="w-full h-64 object-cover"
data-fallback-src="{{ url('/images/no-cover.png') }}">
+4 -4
View File
@@ -145,8 +145,8 @@
@if($hasPreviewImage)
<!-- Preview image -->
<div>
<div class="block cursor-pointer image-modal-trigger" data-image-url="{{ url('/covers/preview/' . $release->guid . '_thumb.jpg') }}" data-image-title="Preview Image">
<img src="{{ url('/covers/preview/' . $release->guid . '_thumb.jpg') }}"
<div class="block cursor-pointer image-modal-trigger" data-image-url="{{ url('/covers/preview/' . $release->guid . '_thumb.webp') }}" data-image-title="Preview Image">
<img src="{{ url('/covers/preview/' . $release->guid . '_thumb.webp') }}"
alt="Preview"
class="detail-gallery-image w-full h-auto rounded-lg"
loading="lazy">
@@ -158,8 +158,8 @@
@if($hasSampleImage)
<!-- Sample image -->
<div>
<div class="block cursor-pointer image-modal-trigger" data-image-url="{{ url('/covers/sample/' . $release->guid . '_thumb.jpg') }}" data-image-title="Sample Image">
<img src="{{ url('/covers/sample/' . $release->guid . '_thumb.jpg') }}"
<div class="block cursor-pointer image-modal-trigger" data-image-url="{{ url('/covers/sample/' . $release->guid . '_thumb.webp') }}" data-image-title="Sample Image">
<img src="{{ url('/covers/sample/' . $release->guid . '_thumb.webp') }}"
alt="Sample"
class="detail-gallery-image w-full h-auto rounded-lg"
loading="lazy">
+3 -4
View File
@@ -22,7 +22,7 @@
<div class="mb-6">
<div class="flex items-center gap-4 mb-4">
<img class="rounded-lg shadow-md w-24 h-auto"
src="{{ url("/covers/movies/{$imdbid}-cover.jpg") }}"
src="{{ url("/covers/movies/{$imdbid}-cover.webp") }}"
data-fallback-src="{{ url('/covers/movies/no-cover.jpg') }}"
alt="{{ e($movie['title'] ?? '') }}" />
@@ -146,7 +146,7 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<td class="px-4 py-3">
<img class="rounded-lg shadow-sm max-w-[120px]"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.jpg' : 'no-cover.jpg')) }}"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}"
alt="{{ e($movie['title'] ?? '') }}"/>
</td>
<td class="px-4 py-3">
@@ -212,7 +212,7 @@
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex gap-3">
<img class="rounded-lg shadow-sm w-20 h-auto shrink-0"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.jpg' : 'no-cover.jpg')) }}"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}"
alt="{{ e($movie['title'] ?? '') }}"/>
<div class="min-w-0 flex-1">
<a href="{{ url("/Movies?imdb={$movie['imdbid']}") }}" class="text-gray-900 dark:text-gray-100 font-semibold hover:text-blue-600 dark:hover:text-blue-400 transition text-sm">
@@ -262,4 +262,3 @@
</div>
</div>
</div>
+2 -3
View File
@@ -79,7 +79,7 @@
<!-- Movie Poster -->
<div class="shrink-0">
<img class="rounded-lg movie-poster-shadow w-32 h-48 object-cover"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.jpg' : 'no-cover.jpg')) }}"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}"
alt="{{ e($movie['title'] ?? '') }}"/>
</div>
@@ -176,7 +176,7 @@
<div class="flex gap-4 mb-4">
<div class="shrink-0">
<img class="rounded-lg movie-poster-shadow w-24 h-36 object-cover"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.jpg' : 'no-cover.jpg')) }}"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}"
alt="{{ e($movie['title'] ?? '') }}"/>
</div>
<div class="flex-1 min-w-0">
@@ -256,4 +256,3 @@
</div>
@endif
</div>
+1 -2
View File
@@ -22,7 +22,7 @@
<div class="mb-6">
<div class="flex items-center gap-4 mb-4">
<img class="rounded-lg shadow-md w-24 h-auto"
src="{{ url("/covers/tvshows/{$video}_thumb.jpg") }}"
src="{{ url("/covers/tvshows/{$video}_thumb.webp") }}"
data-fallback-src="{{ url('/covers/tvshows/no-cover.jpg') }}"
alt="{{ e($show['title'] ?? '') }}" />
@@ -88,4 +88,3 @@
</div>
</div>
+1 -2
View File
@@ -49,7 +49,7 @@
<div class="shrink-0 relative">
<a href="{{ route('series', ['id' => $show->id]) }}" class="block">
@if($show->image)
<img src="{{ url('/covers/tvshows/' . $show->id . '.jpg') }}" alt="{{ $show->title }}" class="w-full md:w-64 h-96 object-cover" data-fallback-src="{{ url('/covers/tvshows/no-cover.jpg') }}">
<img src="{{ url('/covers/tvshows/' . $show->id . '.webp') }}" alt="{{ $show->title }}" class="w-full md:w-64 h-96 object-cover" data-fallback-src="{{ url('/covers/tvshows/no-cover.jpg') }}">
@else
<div class="w-full md:w-64 h-96 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<i class="fas fa-tv text-gray-400 text-5xl"></i>
@@ -161,4 +161,3 @@
</div>
</div>
@endsection
+1 -1
View File
@@ -93,7 +93,7 @@
<div class="lg:col-span-1">
<img class="series-detail-poster w-full h-auto rounded-lg"
alt="{{ $seriestitles ?? '' }} Poster"
src="{{ url('/covers/tvshows/' . $show['id'] . '.jpg') }}"/>
src="{{ url('/covers/tvshows/' . $show['id'] . '.webp') }}"/>
</div>
<div class="lg:col-span-3">
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">{{ $seriessummary }}</p>
+1 -1
View File
@@ -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');
@@ -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);
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Http\Controllers\CoverController;
use GdImage;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
class CoverControllerTest extends TestCase
{
/** @var list<string> */
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;
}
}
-149
View File
@@ -1,149 +0,0 @@
<?php
namespace Tests\Unit;
use App\Models\Release as ReleaseModel;
use App\Services\Categorization\CategorizationService;
use App\Services\MediaProcessingService;
use App\Services\ReleaseExtraService;
use App\Services\ReleaseImageService;
use FFMpeg\FFMpeg;
use FFMpeg\FFProbe;
use Illuminate\Container\Container;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Facades\File;
use Mhor\MediaInfo\MediaInfo;
use Mockery;
use PHPUnit\Framework\Attributes\WithoutErrorHandler;
use PHPUnit\Framework\TestCase;
class MediaProcessingServiceTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
parent::setUp();
// Minimal Facade container for File facade
$container = new Container;
$container->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']);
}
}
+142 -57
View File
@@ -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);