Fix image links

This commit is contained in:
DariusIII
2026-07-16 16:45:14 +02:00
parent b27fb8d86b
commit ba7cef9b3c
21 changed files with 190 additions and 69 deletions
+72 -22
View File
@@ -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<string> $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<string> $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')) { if (! function_exists('getReleaseCover')) {
/** /**
* Get the cover image URL for a release based on its type and ID * 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 ($coverType && $coverId) {
if (in_array($coverType, ['movies', 'anime'], true)) { $basename = in_array($coverType, ['movies', 'anime'], true)
return url("/covers/{$coverType}/{$coverId}-cover.webp"); ? $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 // Return placeholder image if no cover type/ID found
@@ -832,15 +892,12 @@ if (! function_exists('streamSslContextOptions')) {
if (! function_exists('getCoverURL')) { if (! function_exists('getCoverURL')) {
/** /**
* Get cover URL for a release. Uses a short-lived in-memory cache to avoid * Get the relative cover URL using the extension that exists on disk.
* repeated filesystem file_exists() calls for the same cover during a single request.
* *
* @param array<string, mixed> $options * @param array<string, mixed> $options
*/ */
function getCoverURL(array $options = []): string function getCoverURL(array $options = []): string
{ {
static $coverCache = [];
$defaults = [ $defaults = [
'id' => null, 'id' => null,
'suffix' => '-cover.webp', 'suffix' => '-cover.webp',
@@ -852,23 +909,16 @@ if (! function_exists('getCoverURL')) {
if (! empty($options['id']) && \in_array( if (! empty($options['id']) && \in_array(
$options['type'], $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 false
) )
) { ) {
$fileSpec = sprintf($fileSpecTemplate, $options['type'], $options['id'], $options['suffix']); $suffix = preg_replace('/\.(?:webp|jpe?g)$/i', '', (string) $options['suffix']);
$cacheKey = $options['type'].':'.$options['id']; $basename = (string) $options['id'].(is_string($suffix) ? $suffix : '');
$filename = resolveImageAssetFilename((string) $options['type'], $basename);
if (! isset($coverCache[$cacheKey])) { $fileSpec = $filename === null
$canonicalPath = storage_path('covers/').$fileSpec; ? sprintf($fileSpecTemplate, $options['type'], 'no-cover', '.jpg')
$legacyPath = preg_replace('/\.webp$/i', '.jpg', $canonicalPath); : $options['type'].'/'.$filename;
$coverCache[$cacheKey] = file_exists($canonicalPath)
|| (is_string($legacyPath) && file_exists($legacyPath));
}
if (! $coverCache[$cacheKey]) {
$fileSpec = sprintf($fileSpecTemplate, $options['type'], 'no', $options['suffix']);
}
} }
return $fileSpec; return $fileSpec;
+3 -2
View File
@@ -664,15 +664,16 @@ class XML_Response
$column = 'consoleinfo_id'; $column = 'consoleinfo_id';
break; break;
case ! empty($this->release->bo_cover): case ! empty($this->release->bo_cover):
$dir = 'books'; $dir = 'book';
$column = 'bookinfo_id'; $column = 'bookinfo_id';
break; break;
} }
if (isset($dir, $column)) { if (isset($dir, $column)) {
$dcov = ($dir === 'movies' ? '-cover' : ''); $dcov = ($dir === 'movies' ? '-cover' : '');
$filename = resolveImageAssetFilename($dir, $this->release->$column.$dcov) ?? 'no-cover.jpg';
$this->cdata .= $this->cdata .=
"\t<img style=\"margin-left:10px;margin-bottom:10px;float:right;\" ". "\t<img style=\"margin-left:10px;margin-bottom:10px;float:right;\" ".
"src=\"{$this->server['server']['url']}/covers/{$dir}/{$this->release->$column}{$dcov}.webp\" ". "src=\"{$this->server['server']['url']}/covers/{$dir}/{$filename}\" ".
"width=\"120\" alt=\"{$this->release->searchname}\" />\n"; "width=\"120\" alt=\"{$this->release->searchname}\" />\n";
} }
$size = human_filesize($this->release->size); $size = human_filesize($this->release->size);
+6 -8
View File
@@ -68,13 +68,11 @@ class AnidbResource extends JsonResource
} }
// Otherwise construct the local path // Otherwise construct the local path
$picturePath = storage_path('covers/anime/'.$this->anidbid.'-cover.webp'); return getImageAssetUrl(
$legacyPath = storage_path('covers/anime/'.$this->anidbid.'-cover.jpg'); 'anime',
$oldLegacyPath = storage_path('covers/anime/'.$this->anidbid.'.jpg'); $this->anidbid.'-cover',
if (file_exists($picturePath) || file_exists($legacyPath) || file_exists($oldLegacyPath)) { null,
return url('/covers/anime/'.$this->anidbid.'-cover.webp'); [(string) $this->anidbid]
} );
return null;
} }
} }
+1 -7
View File
@@ -52,12 +52,6 @@ class BookResource extends JsonResource
return null; return null;
} }
$coverPath = storage_path('covers/book/'.$this->id.'.webp'); return getImageAssetUrl('book', (string) $this->id);
$legacyPath = storage_path('covers/book/'.$this->id.'.jpg');
if (file_exists($coverPath) || file_exists($legacyPath)) {
return url('/covers/book/'.$this->id.'.webp');
}
return null;
} }
} }
+2 -2
View File
@@ -58,7 +58,7 @@ class MovieResource extends JsonResource
return null; return 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 null;
} }
return url('/covers/movies/'.$this->imdbid.'-backdrop.webp'); return getImageAssetUrl('movies', (string) $this->imdbid.'-backdrop');
} }
} }
+6 -1
View File
@@ -143,7 +143,12 @@ class AnidbInfo extends Model
// Otherwise construct the local path // Otherwise construct the local path
if ($this->hasPictureImage()) { if ($this->hasPictureImage()) {
return url('/covers/anime/'.$this->anidbid.'-cover.webp'); return getImageAssetUrl(
'anime',
$this->anidbid.'-cover',
null,
[(string) $this->anidbid]
);
} }
return null; return null;
+1 -1
View File
@@ -111,6 +111,6 @@ class BookInfo extends Model
return null; return null;
} }
return url('/covers/book/'.$this->id.'.webp'); return getImageAssetUrl('book', (string) $this->id);
} }
} }
+10 -10
View File
@@ -9,8 +9,8 @@ function buildImageUrl(guid, type) {
return '/covers/' + (type || 'preview') + '/' + guid + '_thumb.webp'; return '/covers/' + (type || 'preview') + '/' + guid + '_thumb.webp';
} }
function prefetchImage(guid, type) { function prefetchImage(guid, type, resolvedUrl) {
const url = buildImageUrl(guid, type); const url = resolvedUrl || buildImageUrl(guid, type);
if (!prefetchedUrls.has(url)) { if (!prefetchedUrls.has(url)) {
const img = new Image(); const img = new Image();
img.src = url; img.src = url;
@@ -25,10 +25,10 @@ Alpine.data('previewModal', () => ({
imageError: false, imageError: false,
imageLoaded: false, imageLoaded: false,
show(guid, type) { show(guid, type, resolvedUrl) {
type = type || 'preview'; type = type || 'preview';
this.title = type === 'sample' ? 'Sample Image' : 'Preview Image'; this.title = type === 'sample' ? 'Sample Image' : 'Preview Image';
const newUrl = buildImageUrl(guid, type); const newUrl = resolvedUrl || buildImageUrl(guid, type);
if (this.imageUrl === newUrl) { if (this.imageUrl === newUrl) {
this.open = true; this.open = true;
@@ -64,18 +64,18 @@ Alpine.data('previewModal', () => ({
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const preview = e.target.closest('.preview-badge'); 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'); 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(); } if (e.target.closest('[data-close-preview-modal]')) { e.preventDefault(); self.close(); }
}); });
// Prefetch on hover so the image is cached before click // Prefetch on hover so the image is cached before click
document.addEventListener('mouseover', function(e) { document.addEventListener('mouseover', function(e) {
const preview = e.target.closest('.preview-badge'); 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'); 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) { document.addEventListener('keydown', function(e) {
@@ -91,9 +91,9 @@ Alpine.data('previewModal', () => ({
const type = el.classList.contains('sample-badge') ? 'sample' : 'preview'; const type = el.classList.contains('sample-badge') ? 'sample' : 'preview';
const guid = el.dataset.guid; const guid = el.dataset.guid;
if (typeof requestIdleCallback === 'function') { if (typeof requestIdleCallback === 'function') {
requestIdleCallback(function() { prefetchImage(guid, type); }); requestIdleCallback(function() { prefetchImage(guid, type, el.dataset.imageUrl); });
} else { } else {
setTimeout(function() { prefetchImage(guid, type); }, 200); setTimeout(function() { prefetchImage(guid, type, el.dataset.imageUrl); }, 200);
} }
observer.unobserve(el); observer.unobserve(el);
} }
+1 -1
View File
@@ -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'))); $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 @endphp
@if($hasCover) @if($hasCover)
<img src="{{ url('/covers/anime/' . $anime['anidbid'] . '-cover.webp') }}" <img src="{{ getImageAssetUrl('anime', $anime['anidbid'] . '-cover', url('/covers/anime/no-cover.jpg'), [(string) $anime['anidbid']]) }}"
alt="{{ $anime['title'] }}" alt="{{ $anime['title'] }}"
class="max-w-full h-auto mx-auto rounded shadow-lg" class="max-w-full h-auto mx-auto rounded shadow-lg"
style="max-height: 400px;"> style="max-height: 400px;">
+1 -1
View File
@@ -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'))); $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 @endphp
@if($hasCover) @if($hasCover)
<img src="{{ url('/covers/anime/' . $anime->anidbid . '-cover.webp') }}" <img src="{{ getImageAssetUrl('anime', $anime->anidbid . '-cover', url('/covers/anime/no-cover.jpg'), [(string) $anime->anidbid]) }}"
alt="{{ $anime->title }}" alt="{{ $anime->title }}"
class="h-16 w-12 object-cover rounded shadow" class="h-16 w-12 object-cover rounded shadow"
loading="lazy"> loading="lazy">
+1 -1
View File
@@ -96,7 +96,7 @@
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition-shadow duration-200"> <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"> <a href="{{ $guid ? url('/details/' . $guid) : '#' }}" class="block relative">
@if(!empty($result->cover)) @if(!empty($result->cover))
<img src="{{ url('/covers/book/' . $result->id . '.webp') }}" <img src="{{ getImageAssetUrl('book', (string) $result->id, asset('assets/images/no-cover.png')) }}"
alt="{{ $result->title }}" alt="{{ $result->title }}"
class="w-full h-64 object-cover" class="w-full h-64 object-cover"
data-fallback-src="{{ url('/images/no-cover.png') }}"> data-fallback-src="{{ url('/images/no-cover.png') }}">
@@ -76,6 +76,7 @@
<button type="button" <button type="button"
class="preview-badge inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800 transition cursor-pointer" class="preview-badge inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800 transition cursor-pointer"
data-guid="{{ $result->guid }}" data-guid="{{ $result->guid }}"
data-image-url="{{ getImageAssetUrl('preview', $result->guid . '_thumb') }}"
title="View preview image"> title="View preview image">
<i class="fas fa-image mr-1"></i> Preview <i class="fas fa-image mr-1"></i> Preview
</button> </button>
@@ -84,6 +85,7 @@
<button type="button" <button type="button"
class="sample-badge inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800 transition cursor-pointer" class="sample-badge inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800 transition cursor-pointer"
data-guid="{{ $result->guid }}" data-guid="{{ $result->guid }}"
data-image-url="{{ getImageAssetUrl('sample', $result->guid . '_thumb') }}"
title="View sample image"> title="View sample image">
<i class="fas fa-images mr-1"></i> Sample <i class="fas fa-images mr-1"></i> Sample
</button> </button>
+10 -4
View File
@@ -127,6 +127,12 @@
@php @php
$hasPreviewImage = isset($release->haspreview) && $release->haspreview == 1; $hasPreviewImage = isset($release->haspreview) && $release->haspreview == 1;
$hasSampleImage = isset($release->jpgstatus) && $release->jpgstatus == 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 @endphp
@if($hasPreviewImage || $hasSampleImage) @if($hasPreviewImage || $hasSampleImage)
@@ -145,8 +151,8 @@
@if($hasPreviewImage) @if($hasPreviewImage)
<!-- Preview image --> <!-- Preview image -->
<div> <div>
<div class="block cursor-pointer image-modal-trigger" data-image-url="{{ url('/covers/preview/' . $release->guid . '_thumb.webp') }}" data-image-title="Preview Image"> <div class="block cursor-pointer image-modal-trigger" data-image-url="{{ $previewImageUrl }}" data-image-title="Preview Image">
<img src="{{ url('/covers/preview/' . $release->guid . '_thumb.webp') }}" <img src="{{ $previewImageUrl }}"
alt="Preview" alt="Preview"
class="detail-gallery-image w-full h-auto rounded-lg" class="detail-gallery-image w-full h-auto rounded-lg"
loading="lazy"> loading="lazy">
@@ -158,8 +164,8 @@
@if($hasSampleImage) @if($hasSampleImage)
<!-- Sample image --> <!-- Sample image -->
<div> <div>
<div class="block cursor-pointer image-modal-trigger" data-image-url="{{ url('/covers/sample/' . $release->guid . '_thumb.webp') }}" data-image-title="Sample Image"> <div class="block cursor-pointer image-modal-trigger" data-image-url="{{ $sampleImageUrl }}" data-image-title="Sample Image">
<img src="{{ url('/covers/sample/' . $release->guid . '_thumb.webp') }}" <img src="{{ $sampleImageUrl }}"
alt="Sample" alt="Sample"
class="detail-gallery-image w-full h-auto rounded-lg" class="detail-gallery-image w-full h-auto rounded-lg"
loading="lazy"> loading="lazy">
@@ -172,6 +172,7 @@
<button type="button" <button type="button"
class="preview-badge inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800 transition cursor-pointer" class="preview-badge inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800 transition cursor-pointer"
data-guid="{{ $release->guid }}" data-guid="{{ $release->guid }}"
data-image-url="{{ getImageAssetUrl('preview', $release->guid . '_thumb') }}"
title="View preview image"> title="View preview image">
<i class="fas fa-image mr-1"></i> Preview <i class="fas fa-image mr-1"></i> Preview
</button> </button>
@@ -42,6 +42,7 @@
<button type="button" <button type="button"
class="preview-badge inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800 transition cursor-pointer" class="preview-badge inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800 transition cursor-pointer"
data-guid="{{ $release->guid }}" data-guid="{{ $release->guid }}"
data-image-url="{{ getImageAssetUrl('preview', $release->guid . '_thumb') }}"
title="View preview image"> title="View preview image">
<i class="fas fa-image mr-1"></i> Preview <i class="fas fa-image mr-1"></i> Preview
</button> </button>
+3 -3
View File
@@ -22,7 +22,7 @@
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<img class="rounded-lg shadow-md w-24 h-auto" <img class="rounded-lg shadow-md w-24 h-auto"
src="{{ url("/covers/movies/{$imdbid}-cover.webp") }}" src="{{ getImageAssetUrl('movies', $imdbid . '-cover', url('/covers/movies/no-cover.jpg')) }}"
data-fallback-src="{{ url('/covers/movies/no-cover.jpg') }}" data-fallback-src="{{ url('/covers/movies/no-cover.jpg') }}"
alt="{{ e($movie['title'] ?? '') }}" /> alt="{{ e($movie['title'] ?? '') }}" />
@@ -146,7 +146,7 @@
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<td class="px-4 py-3"> <td class="px-4 py-3">
<img class="rounded-lg shadow-sm max-w-[120px]" <img class="rounded-lg shadow-sm max-w-[120px]"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}" src="{{ ($movie['cover'] ?? 0) == 1 ? getImageAssetUrl('movies', $movie['imdbid'] . '-cover', url('/covers/movies/no-cover.jpg')) : url('/covers/movies/no-cover.jpg') }}"
alt="{{ e($movie['title'] ?? '') }}"/> alt="{{ e($movie['title'] ?? '') }}"/>
</td> </td>
<td class="px-4 py-3"> <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="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex gap-3"> <div class="flex gap-3">
<img class="rounded-lg shadow-sm w-20 h-auto shrink-0" <img class="rounded-lg shadow-sm w-20 h-auto shrink-0"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}" src="{{ ($movie['cover'] ?? 0) == 1 ? getImageAssetUrl('movies', $movie['imdbid'] . '-cover', url('/covers/movies/no-cover.jpg')) : url('/covers/movies/no-cover.jpg') }}"
alt="{{ e($movie['title'] ?? '') }}"/> alt="{{ e($movie['title'] ?? '') }}"/>
<div class="min-w-0 flex-1"> <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"> <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">
+2 -2
View File
@@ -79,7 +79,7 @@
<!-- Movie Poster --> <!-- Movie Poster -->
<div class="shrink-0"> <div class="shrink-0">
<img class="rounded-lg movie-poster-shadow w-32 h-48 object-cover" <img class="rounded-lg movie-poster-shadow w-32 h-48 object-cover"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}" src="{{ ($movie['cover'] ?? 0) == 1 ? getImageAssetUrl('movies', $movie['imdbid'] . '-cover', url('/covers/movies/no-cover.jpg')) : url('/covers/movies/no-cover.jpg') }}"
alt="{{ e($movie['title'] ?? '') }}"/> alt="{{ e($movie['title'] ?? '') }}"/>
</div> </div>
@@ -176,7 +176,7 @@
<div class="flex gap-4 mb-4"> <div class="flex gap-4 mb-4">
<div class="shrink-0"> <div class="shrink-0">
<img class="rounded-lg movie-poster-shadow w-24 h-36 object-cover" <img class="rounded-lg movie-poster-shadow w-24 h-36 object-cover"
src="{{ url('/covers/movies/' . (($movie['cover'] ?? 0) == 1 ? $movie['imdbid'] . '-cover.webp' : 'no-cover.jpg')) }}" src="{{ ($movie['cover'] ?? 0) == 1 ? getImageAssetUrl('movies', $movie['imdbid'] . '-cover', url('/covers/movies/no-cover.jpg')) : url('/covers/movies/no-cover.jpg') }}"
alt="{{ e($movie['title'] ?? '') }}"/> alt="{{ e($movie['title'] ?? '') }}"/>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
+1 -2
View File
@@ -22,7 +22,7 @@
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<img class="rounded-lg shadow-md w-24 h-auto" <img class="rounded-lg shadow-md w-24 h-auto"
src="{{ url("/covers/tvshows/{$video}_thumb.webp") }}" src="{{ getImageAssetUrl('tvshows', $video . '_thumb', url('/covers/tvshows/no-cover.jpg')) }}"
data-fallback-src="{{ url('/covers/tvshows/no-cover.jpg') }}" data-fallback-src="{{ url('/covers/tvshows/no-cover.jpg') }}"
alt="{{ e($show['title'] ?? '') }}" /> alt="{{ e($show['title'] ?? '') }}" />
@@ -87,4 +87,3 @@
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -49,7 +49,7 @@
<div class="shrink-0 relative"> <div class="shrink-0 relative">
<a href="{{ route('series', ['id' => $show->id]) }}" class="block"> <a href="{{ route('series', ['id' => $show->id]) }}" class="block">
@if($show->image) @if($show->image)
<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') }}"> <img src="{{ getImageAssetUrl('tvshows', (string) $show->id, url('/covers/tvshows/no-cover.jpg')) }}" alt="{{ $show->title }}" class="w-full md:w-64 h-96 object-cover" data-fallback-src="{{ url('/covers/tvshows/no-cover.jpg') }}">
@else @else
<div class="w-full md:w-64 h-96 bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <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> <i class="fas fa-tv text-gray-400 text-5xl"></i>
+1 -1
View File
@@ -93,7 +93,7 @@
<div class="lg:col-span-1"> <div class="lg:col-span-1">
<img class="series-detail-poster w-full h-auto rounded-lg" <img class="series-detail-poster w-full h-auto rounded-lg"
alt="{{ $seriestitles ?? '' }} Poster" alt="{{ $seriestitles ?? '' }} Poster"
src="{{ url('/covers/tvshows/' . $show['id'] . '.webp') }}"/> src="{{ getImageAssetUrl('tvshows', (string) $show['id'], url('/covers/tvshows/no-cover.jpg')) }}"/>
</div> </div>
<div class="lg:col-span-3"> <div class="lg:col-span-3">
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">{{ $seriessummary }}</p> <p class="text-gray-700 dark:text-gray-300 leading-relaxed">{{ $seriessummary }}</p>
@@ -8,6 +8,35 @@ use Tests\TestCase;
class HelperCoverUrlTest extends TestCase class HelperCoverUrlTest extends TestCase
{ {
/** @var list<string> */
private array $temporaryCoverFiles = [];
/** @var list<string> */
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 public function test_unzip_gzip_file_returns_uncompressed_contents(): void
{ {
$path = tempnam(sys_get_temp_dir(), 'nntmux-gzip-'); $path = tempnam(sys_get_temp_dir(), 'nntmux-gzip-');
@@ -35,4 +64,39 @@ class HelperCoverUrlTest extends TestCase
$this->assertStringContainsString('assets/images/no-cover.png', $url); $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]));
}
} }