mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 22:01:33 +00:00
Make webp default format for images
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'].
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: ').
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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') }}">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user