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