diff --git a/app/Extensions/helper/helpers.php b/app/Extensions/helper/helpers.php index 567df8604..8550d9477 100644 --- a/app/Extensions/helper/helpers.php +++ b/app/Extensions/helper/helpers.php @@ -508,6 +508,60 @@ if (! function_exists('release_flag')) { } } +if (! function_exists('resolveImageAssetFilename')) { + /** + * Resolve the real on-disk extension before publishing an image URL. + * + * @param list $alternateBasenames + */ + function resolveImageAssetFilename(string $type, string $basename, array $alternateBasenames = []): ?string + { + if (preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $type) !== 1 + || preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $basename) !== 1) { + return null; + } + + $configuredRoot = config('nntmux_settings.covers_path'); + $roots = array_values(array_unique(array_filter([ + is_string($configuredRoot) && $configuredRoot !== '' ? rtrim($configuredRoot, '/\\') : null, + storage_path('covers'), + public_path('covers'), + ], static fn (mixed $root): bool => is_string($root) && $root !== ''))); + $basenames = array_values(array_unique([$basename, ...$alternateBasenames])); + + foreach (['webp', 'jpg', 'jpeg'] as $extension) { + foreach ($basenames as $candidateBasename) { + if (preg_match('/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/D', $candidateBasename) !== 1) { + continue; + } + + foreach ($roots as $root) { + $path = $root.DIRECTORY_SEPARATOR.$type.DIRECTORY_SEPARATOR.$candidateBasename.'.'.$extension; + if (is_file($path) && is_readable($path)) { + return $candidateBasename.'.'.$extension; + } + } + } + } + + return null; + } +} + +if (! function_exists('getImageAssetUrl')) { + /** + * Return a URL containing the extension of the image that actually exists. + * + * @param list $alternateBasenames + */ + function getImageAssetUrl(string $type, string $basename, ?string $fallbackUrl = null, array $alternateBasenames = []): ?string + { + $filename = resolveImageAssetFilename($type, $basename, $alternateBasenames); + + return $filename === null ? $fallbackUrl : url("/covers/{$type}/{$filename}"); + } +} + if (! function_exists('getReleaseCover')) { /** * Get the cover image URL for a release based on its type and ID @@ -567,11 +621,17 @@ if (! function_exists('getReleaseCover')) { } if ($coverType && $coverId) { - if (in_array($coverType, ['movies', 'anime'], true)) { - return url("/covers/{$coverType}/{$coverId}-cover.webp"); - } + $basename = in_array($coverType, ['movies', 'anime'], true) + ? $coverId.'-cover' + : (string) $coverId; + $alternateBasenames = $coverType === 'anime' ? [(string) $coverId] : []; - return url("/covers/{$coverType}/{$coverId}.webp"); + return getImageAssetUrl( + $coverType, + $basename, + asset('assets/images/no-cover.png'), + $alternateBasenames + ) ?? asset('assets/images/no-cover.png'); } // Return placeholder image if no cover type/ID found @@ -832,15 +892,12 @@ if (! function_exists('streamSslContextOptions')) { if (! function_exists('getCoverURL')) { /** - * Get cover URL for a release. Uses a short-lived in-memory cache to avoid - * repeated filesystem file_exists() calls for the same cover during a single request. + * Get the relative cover URL using the extension that exists on disk. * * @param array $options */ function getCoverURL(array $options = []): string { - static $coverCache = []; - $defaults = [ 'id' => null, 'suffix' => '-cover.webp', @@ -852,23 +909,16 @@ if (! function_exists('getCoverURL')) { if (! empty($options['id']) && \in_array( $options['type'], - ['anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies', 'music', 'preview', 'sample', 'tvrage', 'video', 'xxx'], + ['anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies', 'music', 'preview', 'sample', 'tvrage', 'tvshows', 'video', 'xxx'], false ) ) { - $fileSpec = sprintf($fileSpecTemplate, $options['type'], $options['id'], $options['suffix']); - $cacheKey = $options['type'].':'.$options['id']; - - if (! isset($coverCache[$cacheKey])) { - $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]) { - $fileSpec = sprintf($fileSpecTemplate, $options['type'], 'no', $options['suffix']); - } + $suffix = preg_replace('/\.(?:webp|jpe?g)$/i', '', (string) $options['suffix']); + $basename = (string) $options['id'].(is_string($suffix) ? $suffix : ''); + $filename = resolveImageAssetFilename((string) $options['type'], $basename); + $fileSpec = $filename === null + ? sprintf($fileSpecTemplate, $options['type'], 'no-cover', '.jpg') + : $options['type'].'/'.$filename; } return $fileSpec; diff --git a/app/Http/Controllers/Api/XML_Response.php b/app/Http/Controllers/Api/XML_Response.php index e8f33e807..c0f68bcc3 100644 --- a/app/Http/Controllers/Api/XML_Response.php +++ b/app/Http/Controllers/Api/XML_Response.php @@ -664,15 +664,16 @@ class XML_Response $column = 'consoleinfo_id'; break; case ! empty($this->release->bo_cover): - $dir = 'books'; + $dir = 'book'; $column = 'bookinfo_id'; break; } if (isset($dir, $column)) { $dcov = ($dir === 'movies' ? '-cover' : ''); + $filename = resolveImageAssetFilename($dir, $this->release->$column.$dcov) ?? 'no-cover.jpg'; $this->cdata .= "\tserver['server']['url']}/covers/{$dir}/{$this->release->$column}{$dcov}.webp\" ". + "src=\"{$this->server['server']['url']}/covers/{$dir}/{$filename}\" ". "width=\"120\" alt=\"{$this->release->searchname}\" />\n"; } $size = human_filesize($this->release->size); diff --git a/app/Http/Resources/AnidbResource.php b/app/Http/Resources/AnidbResource.php index 7779b8903..e6a2af430 100644 --- a/app/Http/Resources/AnidbResource.php +++ b/app/Http/Resources/AnidbResource.php @@ -68,13 +68,11 @@ class AnidbResource extends JsonResource } // Otherwise construct the local path - $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; + return getImageAssetUrl( + 'anime', + $this->anidbid.'-cover', + null, + [(string) $this->anidbid] + ); } } diff --git a/app/Http/Resources/BookResource.php b/app/Http/Resources/BookResource.php index 17c57b258..c3feef959 100644 --- a/app/Http/Resources/BookResource.php +++ b/app/Http/Resources/BookResource.php @@ -52,12 +52,6 @@ class BookResource extends JsonResource return null; } - $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; + return getImageAssetUrl('book', (string) $this->id); } } diff --git a/app/Http/Resources/MovieResource.php b/app/Http/Resources/MovieResource.php index 60ef60be1..a4688109e 100644 --- a/app/Http/Resources/MovieResource.php +++ b/app/Http/Resources/MovieResource.php @@ -58,7 +58,7 @@ class MovieResource extends JsonResource return null; } - return url('/covers/movies/'.$this->imdbid.'-cover.webp'); + return getImageAssetUrl('movies', (string) $this->imdbid.'-cover'); } /** @@ -70,6 +70,6 @@ class MovieResource extends JsonResource return null; } - return url('/covers/movies/'.$this->imdbid.'-backdrop.webp'); + return getImageAssetUrl('movies', (string) $this->imdbid.'-backdrop'); } } diff --git a/app/Models/AnidbInfo.php b/app/Models/AnidbInfo.php index ccd709110..5fb3ae504 100644 --- a/app/Models/AnidbInfo.php +++ b/app/Models/AnidbInfo.php @@ -143,7 +143,12 @@ class AnidbInfo extends Model // Otherwise construct the local path if ($this->hasPictureImage()) { - return url('/covers/anime/'.$this->anidbid.'-cover.webp'); + return getImageAssetUrl( + 'anime', + $this->anidbid.'-cover', + null, + [(string) $this->anidbid] + ); } return null; diff --git a/app/Models/BookInfo.php b/app/Models/BookInfo.php index 6f88c5534..2a20d1882 100644 --- a/app/Models/BookInfo.php +++ b/app/Models/BookInfo.php @@ -111,6 +111,6 @@ class BookInfo extends Model return null; } - return url('/covers/book/'.$this->id.'.webp'); + return getImageAssetUrl('book', (string) $this->id); } } diff --git a/resources/js/alpine/components/preview-modal.js b/resources/js/alpine/components/preview-modal.js index ef9bbb684..01d3072b3 100644 --- a/resources/js/alpine/components/preview-modal.js +++ b/resources/js/alpine/components/preview-modal.js @@ -9,8 +9,8 @@ function buildImageUrl(guid, type) { return '/covers/' + (type || 'preview') + '/' + guid + '_thumb.webp'; } -function prefetchImage(guid, type) { - const url = buildImageUrl(guid, type); +function prefetchImage(guid, type, resolvedUrl) { + const url = resolvedUrl || buildImageUrl(guid, type); if (!prefetchedUrls.has(url)) { const img = new Image(); img.src = url; @@ -25,10 +25,10 @@ Alpine.data('previewModal', () => ({ imageError: false, imageLoaded: false, - show(guid, type) { + show(guid, type, resolvedUrl) { type = type || 'preview'; this.title = type === 'sample' ? 'Sample Image' : 'Preview Image'; - const newUrl = buildImageUrl(guid, type); + const newUrl = resolvedUrl || buildImageUrl(guid, type); if (this.imageUrl === newUrl) { this.open = true; @@ -64,18 +64,18 @@ Alpine.data('previewModal', () => ({ document.addEventListener('click', function(e) { const preview = e.target.closest('.preview-badge'); - if (preview) { e.preventDefault(); self.show(preview.dataset.guid, 'preview'); return; } + if (preview) { e.preventDefault(); self.show(preview.dataset.guid, 'preview', preview.dataset.imageUrl); return; } const sample = e.target.closest('.sample-badge'); - if (sample) { e.preventDefault(); self.show(sample.dataset.guid, 'sample'); return; } + if (sample) { e.preventDefault(); self.show(sample.dataset.guid, 'sample', sample.dataset.imageUrl); return; } if (e.target.closest('[data-close-preview-modal]')) { e.preventDefault(); self.close(); } }); // Prefetch on hover so the image is cached before click document.addEventListener('mouseover', function(e) { const preview = e.target.closest('.preview-badge'); - if (preview) { prefetchImage(preview.dataset.guid, 'preview'); return; } + if (preview) { prefetchImage(preview.dataset.guid, 'preview', preview.dataset.imageUrl); return; } const sample = e.target.closest('.sample-badge'); - if (sample) { prefetchImage(sample.dataset.guid, 'sample'); } + if (sample) { prefetchImage(sample.dataset.guid, 'sample', sample.dataset.imageUrl); } }); document.addEventListener('keydown', function(e) { @@ -91,9 +91,9 @@ Alpine.data('previewModal', () => ({ const type = el.classList.contains('sample-badge') ? 'sample' : 'preview'; const guid = el.dataset.guid; if (typeof requestIdleCallback === 'function') { - requestIdleCallback(function() { prefetchImage(guid, type); }); + requestIdleCallback(function() { prefetchImage(guid, type, el.dataset.imageUrl); }); } else { - setTimeout(function() { prefetchImage(guid, type); }, 200); + setTimeout(function() { prefetchImage(guid, type, el.dataset.imageUrl); }, 200); } observer.unobserve(el); } diff --git a/resources/views/admin/anidb/edit.blade.php b/resources/views/admin/anidb/edit.blade.php index 379667b5f..64d8db1aa 100644 --- a/resources/views/admin/anidb/edit.blade.php +++ b/resources/views/admin/anidb/edit.blade.php @@ -128,7 +128,7 @@ $hasCover = $anime['anidbid'] > 0 && (file_exists(storage_path('covers/anime/' . $anime['anidbid'] . '-cover.webp')) || file_exists(storage_path('covers/anime/' . $anime['anidbid'] . '-cover.jpg'))); @endphp @if($hasCover) - {{ $anime['title'] }} diff --git a/resources/views/admin/anidb/index.blade.php b/resources/views/admin/anidb/index.blade.php index d0bb21b44..2e16a4296 100644 --- a/resources/views/admin/anidb/index.blade.php +++ b/resources/views/admin/anidb/index.blade.php @@ -64,7 +64,7 @@ $hasCover = $anime->anidbid > 0 && (file_exists(storage_path('covers/anime/' . $anime->anidbid . '-cover.webp')) || file_exists(storage_path('covers/anime/' . $anime->anidbid . '-cover.jpg'))); @endphp @if($hasCover) - {{ $anime->title }} diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 47147b9aa..6415e8b4b 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -96,7 +96,7 @@
@if(!empty($result->cover)) - {{ $result->title }} diff --git a/resources/views/components/release-results.blade.php b/resources/views/components/release-results.blade.php index d88d6023d..6feb093cf 100644 --- a/resources/views/components/release-results.blade.php +++ b/resources/views/components/release-results.blade.php @@ -76,6 +76,7 @@ @@ -84,6 +85,7 @@ diff --git a/resources/views/details/index.blade.php b/resources/views/details/index.blade.php index e89315177..3f321f527 100644 --- a/resources/views/details/index.blade.php +++ b/resources/views/details/index.blade.php @@ -127,6 +127,12 @@ @php $hasPreviewImage = isset($release->haspreview) && $release->haspreview == 1; $hasSampleImage = isset($release->jpgstatus) && $release->jpgstatus == 1; + $previewImageUrl = $hasPreviewImage + ? getImageAssetUrl('preview', $release->guid . '_thumb', asset('assets/images/no-cover.png')) + : null; + $sampleImageUrl = $hasSampleImage + ? getImageAssetUrl('sample', $release->guid . '_thumb', asset('assets/images/no-cover.png')) + : null; @endphp @if($hasPreviewImage || $hasSampleImage) @@ -145,8 +151,8 @@ @if($hasPreviewImage)
-
- + Preview @@ -158,8 +164,8 @@ @if($hasSampleImage)
-
- + Sample diff --git a/resources/views/movies/partials/movie-card.blade.php b/resources/views/movies/partials/movie-card.blade.php index b92a73f2e..fce5632b6 100644 --- a/resources/views/movies/partials/movie-card.blade.php +++ b/resources/views/movies/partials/movie-card.blade.php @@ -172,6 +172,7 @@ diff --git a/resources/views/movies/partials/release-item.blade.php b/resources/views/movies/partials/release-item.blade.php index 104b6f9af..5baab4dd2 100644 --- a/resources/views/movies/partials/release-item.blade.php +++ b/resources/views/movies/partials/release-item.blade.php @@ -42,6 +42,7 @@ diff --git a/resources/views/mymovies/add.blade.php b/resources/views/mymovies/add.blade.php index 75f5ed036..eac3dc952 100644 --- a/resources/views/mymovies/add.blade.php +++ b/resources/views/mymovies/add.blade.php @@ -22,7 +22,7 @@
{{ e($movie['title'] ?? '') }} @@ -146,7 +146,7 @@ {{ e($movie['title'] ?? '') }} @@ -212,7 +212,7 @@
{{ e($movie['title'] ?? '') }}
diff --git a/resources/views/mymovies/index.blade.php b/resources/views/mymovies/index.blade.php index 39c96ce2e..d309e44c9 100644 --- a/resources/views/mymovies/index.blade.php +++ b/resources/views/mymovies/index.blade.php @@ -79,7 +79,7 @@
{{ e($movie['title'] ?? '') }}
@@ -176,7 +176,7 @@
{{ e($movie['title'] ?? '') }}
diff --git a/resources/views/myshows/add.blade.php b/resources/views/myshows/add.blade.php index 72f8143c7..2a41b60cd 100644 --- a/resources/views/myshows/add.blade.php +++ b/resources/views/myshows/add.blade.php @@ -22,7 +22,7 @@
{{ e($show['title'] ?? '') }} @@ -87,4 +87,3 @@
- diff --git a/resources/views/series/trending.blade.php b/resources/views/series/trending.blade.php index f25a10b56..1df03c4a7 100644 --- a/resources/views/series/trending.blade.php +++ b/resources/views/series/trending.blade.php @@ -49,7 +49,7 @@
@if($show->image) - {{ $show->title }} + {{ $show->title }} @else
diff --git a/resources/views/series/viewseries.blade.php b/resources/views/series/viewseries.blade.php index bdfddc5c4..28a546f6c 100644 --- a/resources/views/series/viewseries.blade.php +++ b/resources/views/series/viewseries.blade.php @@ -93,7 +93,7 @@
{{ $seriestitles ?? '' }} Poster + src="{{ getImageAssetUrl('tvshows', (string) $show['id'], url('/covers/tvshows/no-cover.jpg')) }}"/>

{{ $seriessummary }}

diff --git a/tests/Unit/Extensions/HelperCoverUrlTest.php b/tests/Unit/Extensions/HelperCoverUrlTest.php index f2a693e8d..7b2fb7ab2 100644 --- a/tests/Unit/Extensions/HelperCoverUrlTest.php +++ b/tests/Unit/Extensions/HelperCoverUrlTest.php @@ -8,6 +8,35 @@ use Tests\TestCase; class HelperCoverUrlTest extends TestCase { + /** @var list */ + private array $temporaryCoverFiles = []; + + /** @var list */ + private array $temporaryCoverDirectories = []; + + private mixed $originalCoversPath; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalCoversPath = config('nntmux_settings.covers_path'); + } + + protected function tearDown(): void + { + foreach ($this->temporaryCoverFiles as $path) { + @unlink($path); + } + foreach (array_reverse($this->temporaryCoverDirectories) as $path) { + @rmdir($path); + } + + config(['nntmux_settings.covers_path' => $this->originalCoversPath]); + + parent::tearDown(); + } + public function test_unzip_gzip_file_returns_uncompressed_contents(): void { $path = tempnam(sys_get_temp_dir(), 'nntmux-gzip-'); @@ -35,4 +64,39 @@ class HelperCoverUrlTest extends TestCase $this->assertStringContainsString('assets/images/no-cover.png', $url); } + + public function test_get_release_cover_emits_legacy_jpeg_extension_when_webp_does_not_exist(): void + { + $id = 987654321; + $path = storage_path("covers/book/{$id}.jpg"); + $this->temporaryCoverFiles[] = $path; + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + file_put_contents($path, 'legacy jpeg fixture'); + + $url = getReleaseCover((object) ['bookinfo_id' => $id]); + + $this->assertStringEndsWith("/covers/book/{$id}.jpg", $url); + } + + public function test_get_release_cover_uses_the_real_extension_in_a_custom_covers_path(): void + { + $id = 987654322; + $root = sys_get_temp_dir().'/nntmux-cover-url-'.bin2hex(random_bytes(6)); + $directory = $root.'/book'; + $jpegPath = $directory."/{$id}.jpg"; + $webpPath = $directory."/{$id}.webp"; + $this->temporaryCoverDirectories = [$root, $directory]; + $this->temporaryCoverFiles = [$jpegPath, $webpPath]; + mkdir($directory, 0777, true); + config(['nntmux_settings.covers_path' => $root]); + file_put_contents($jpegPath, 'legacy jpeg fixture'); + + $this->assertStringEndsWith("/covers/book/{$id}.jpg", getReleaseCover((object) ['bookinfo_id' => $id])); + + file_put_contents($webpPath, 'webp fixture'); + + $this->assertStringEndsWith("/covers/book/{$id}.webp", getReleaseCover((object) ['bookinfo_id' => $id])); + } }