refactor: removed thumbnail proxying

This commit is contained in:
arabcoders 2026-05-18 12:28:06 +03:00
parent 854f5f4b42
commit 88db357b7f
7 changed files with 194 additions and 401 deletions

12
API.md
View file

@ -59,7 +59,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt) - [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile) - [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile) - [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
- [GET /api/thumbnail](#get-apithumbnail)
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile) - [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath) - [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
@ -1655,17 +1654,6 @@ Binary TS data (`Content-Type: video/mpegts`).
--- ---
### GET /api/thumbnail
**Purpose**: Proxy/fetch a remote thumbnail image.
**Query Parameter**:
- `?url=<remote-thumbnail-url>`
**Response**:
Binary image data with the appropriate `Content-Type`.
---
### GET /api/file/ffprobe/{file:.*} ### GET /api/file/ffprobe/{file:.*}
**Purpose**: Return the `ffprobe` data for a local file. **Purpose**: Return the `ffprobe` data for a local file.

View file

@ -1,8 +1,5 @@
import asyncio
import logging import logging
import random import random
import time
from datetime import UTC, datetime
from typing import Any from typing import Any
from urllib.parse import urlparse, urlsplit, urlunsplit from urllib.parse import urlparse, urlsplit, urlunsplit
@ -15,7 +12,6 @@ from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.router import route from app.library.router import route
from app.library.Utils import validate_url
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
@ -43,72 +39,6 @@ def _safe_url(url: str | None) -> str:
return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment)) return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment))
@route("GET", "api/thumbnail/", "get_thumbnail")
async def get_thumbnail(request: Request, config: Config) -> Response:
"""
Get the thumbnail.
Args:
request (Request): The request object.
config (Config): The configuration object.
Returns:
Response: The response object.
"""
url: str | None = request.query.get("url")
if not url:
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
try:
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
try:
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
use_curl = resolve_curl_transport()
request_headers = build_request_headers(
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
use_curl=use_curl,
)
proxy = ytdlp_args.get("proxy")
safe_url = _safe_url(url)
client = get_async_client(proxy=proxy, use_curl=use_curl)
LOG.debug(f"Fetching thumbnail from '{safe_url}'.")
response = await client.request(
method="GET",
url=url,
follow_redirects=True,
headers=request_headers,
timeout=10.0,
)
if response.status_code != web.HTTPOk.status_code:
LOG.error(f"Failed to fetch thumbnail from '{safe_url}'. Status code: {response.status_code}.")
return web.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code)
return web.Response(
body=response.content,
headers={
"Content-Type": response.headers.get("Content-Type"),
"Pragma": "public",
"Access-Control-Allow-Origin": "*",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(),
),
},
)
except Exception as e:
LOG.error(f"Error fetching thumbnail from '{safe_url}'. '{e}'.")
return web.json_response(
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
)
@route("GET", "api/random/background/", "get_background") @route("GET", "api/random/background/", "get_background")
async def get_background(request: Request, config: Config, cache: Cache) -> Response: async def get_background(request: Request, config: Config, cache: Cache) -> Response:
""" """

View file

@ -26,78 +26,6 @@ class _Resp:
self.headers = {"Content-Type": content_type} self.headers = {"Content-Type": content_type}
@pytest.mark.asyncio
async def test_thumb_thread(monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
req = make_mocked_request("GET", "/api/thumbnail?url=https://example.com/a.jpg")
req._rel_url = req._rel_url.with_query({"url": "https://example.com/a.jpg"})
seen = {"to_thread": False, "validate": False}
def fake_validate_url(url: str, allow_internal: bool = False) -> bool:
seen["validate"] = True
assert url == "https://example.com/a.jpg"
assert allow_internal is config.allow_internal_urls
return True
async def fake_to_thread(func, *args, **kwargs):
seen["to_thread"] = True
return func(*args, **kwargs)
client = AsyncMock()
client.request.return_value = _Resp()
monkeypatch.setattr(images, "validate_url", fake_validate_url)
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client)
monkeypatch.setattr(images, "resolve_curl_transport", lambda: False)
monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {})
monkeypatch.setattr(images.Globals, "get_random_agent", staticmethod(lambda: "agent"))
monkeypatch.setattr(
images.YTDLPOpts,
"get_instance",
staticmethod(
lambda: type(
"Opts",
(),
{
"preset": lambda self, name: self,
"get_all": lambda self: {},
},
)()
),
)
response = await images.get_thumbnail(req, config)
assert response.status == web.HTTPOk.status_code
assert seen["to_thread"] is True
assert seen["validate"] is True
client.request.assert_awaited_once()
@pytest.mark.asyncio
async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()
req = make_mocked_request("GET", "/api/thumbnail?url=https://bad.example/a.jpg")
req._rel_url = req._rel_url.with_query({"url": "https://bad.example/a.jpg"})
def fake_validate_url(_url: str, allow_internal: bool = False) -> bool:
assert allow_internal is config.allow_internal_urls
raise ValueError("Invalid hostname.")
async def fake_to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(images, "validate_url", fake_validate_url)
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
response = await images.get_thumbnail(req, config)
assert response.status == web.HTTPForbidden.status_code
assert response.text == '{"error": "Invalid hostname."}'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None: async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance() config = Config.get_instance()

View file

@ -1,201 +1,124 @@
<template> <template>
<UModal <UModal v-model:open="modalOpen" :title="resolvedTitle" :ui="modalUi">
v-if="!externalModel"
:open="true"
:title="resolvedTitle"
:dismissible="true"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'p-0 overflow-hidden' }"
@update:open="(open) => !open && emitter('closeModel')"
>
<template #description> <template #description>
<span class="sr-only">{{ modalDescription }}</span> <span class="sr-only">{{ modalDescription }}</span>
</template> </template>
<template #body> <template #body>
<div class="flex h-[75vh] min-h-96 min-w-0 flex-col"> <div class="space-y-4">
<div <div v-if="isLoading" class="flex min-h-80 items-center justify-center">
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3" <UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-primary" />
>
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon :name="contentIcon" class="size-4 text-toned" />
<span>{{ contentLabel }}</span>
<UBadge color="neutral" variant="soft" size="sm">
{{ isStructuredData ? 'JSON' : 'Text' }}
</UBadge>
</div>
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
</div>
<div class="flex items-center gap-2">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
:disabled="isLoading || !hasDisplayText"
@click="wrapText = !wrapText"
>
{{ wrapText ? 'No wrap' : 'Wrap text' }}
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !hasDisplayText"
@click="copyData"
>
Copy
</UButton>
</div>
</div> </div>
<div class="flex-1 min-h-0 p-4 sm:p-5"> <UAlert
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center"> v-else-if="errorMessage"
<div class="flex flex-col items-center gap-3 text-center text-toned"> color="error"
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" /> variant="soft"
<span class="text-sm">Loading information...</span> icon="i-lucide-triangle-alert"
</div> title="Unable to load information"
</div> :description="errorMessage"
/>
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center"> <div v-else class="space-y-3">
<div class="w-full max-w-2xl space-y-4"> <UInput
<UAlert v-model="query"
color="error" type="search"
variant="soft" placeholder="Filter text"
icon="i-lucide-triangle-alert" icon="i-lucide-filter"
title="Unable to load information" size="sm"
:description="errorMessage" class="w-full"
/> />
<div class="flex justify-end"> <UAlert
<UButton v-if="query && 0 === filteredLineCount"
type="button" color="warning"
color="neutral" variant="soft"
variant="outline" icon="i-lucide-filter"
size="sm" title="No matching lines"
icon="i-lucide-rotate-ccw" >
@click="() => void loadInfo()" <template #description>
> <p class="text-sm text-default">
Retry No lines match this filter: <u>{{ query }}</u>
</UButton> </p>
</div> </template>
</div> </UAlert>
</div>
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center"> <UAlert
<UAlert v-else-if="!hasDisplayText"
color="neutral" color="neutral"
variant="soft" variant="soft"
icon="i-lucide-info" icon="i-lucide-info"
title="No content returned" title="No content returned"
description="The request completed successfully but did not return any visible content." description="The request completed successfully but did not return any visible content."
class="w-full max-w-2xl" />
<div v-else class="overflow-hidden rounded-md border border-default bg-elevated/30">
<code
ref="contentView"
class="block max-h-[55vh] overflow-auto p-4 font-mono text-sm leading-6 text-default whitespace-pre-wrap wrap-break-word"
v-text="displayedText"
/> />
</div> </div>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
</div> </div>
</div> </div>
</template> </template>
</UModal>
<div v-else class="flex h-[75vh] min-h-96 min-w-0 flex-col">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3"
>
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon :name="contentIcon" class="size-4 text-toned" />
<span>{{ contentLabel }}</span>
<UBadge color="neutral" variant="soft" size="sm">
{{ isStructuredData ? 'JSON' : 'Text' }}
</UBadge>
</div>
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
</div>
<div class="flex items-center gap-2">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
:disabled="isLoading || !hasDisplayText"
@click="wrapText = !wrapText"
>
{{ wrapText ? 'No wrap' : 'Wrap text' }}
</UButton>
<template #footer>
<div class="flex w-full flex-wrap items-center justify-end gap-2">
<UButton <UButton
type="button" type="button"
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-copy" icon="i-lucide-copy"
:disabled="isLoading || !hasDisplayText" :disabled="isLoading || !hasVisibleText"
@click="copyData" @click="copyData"
> >
Copy Copy
</UButton> </UButton>
</div>
</div>
<div class="flex-1 min-h-0 p-4 sm:p-5"> <UButton
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center"> type="button"
<div class="flex flex-col items-center gap-3 text-center text-toned">
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
<span class="text-sm">Loading information...</span>
</div>
</div>
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center">
<div class="w-full max-w-2xl space-y-4">
<UAlert
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load information"
:description="errorMessage"
/>
<div class="flex justify-end">
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-rotate-ccw"
@click="() => void loadInfo()"
>
Retry
</UButton>
</div>
</div>
</div>
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
<UAlert
color="neutral" color="neutral"
variant="soft" variant="outline"
icon="i-lucide-info" size="sm"
title="No content returned" icon="i-lucide-arrow-down"
description="The request completed successfully but did not return any visible content." :disabled="isLoading || !hasVisibleText"
class="w-full max-w-2xl" @click="scrollContent('end')"
/> >
</div> Go down
</UButton>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre> <UButton
</div> type="button"
</div> color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:disabled="!link"
:loading="isLoading"
@click="() => void loadInfo()"
>
Reload
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-arrow-up"
:disabled="isLoading || !hasVisibleText"
@click="scrollContent('start')"
>
Go up
</UButton>
</div>
</template>
</UModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core';
const toast = useNotification(); const toast = useNotification();
const emitter = defineEmits<{ (e: 'closeModel'): void }>(); const emitter = defineEmits<{ (e: 'closeModel'): void }>();
@ -205,25 +128,37 @@ const props = withDefaults(
preset?: string; preset?: string;
cli?: string; cli?: string;
useUrl?: boolean; useUrl?: boolean;
externalModel?: boolean;
code_classes?: string;
}>(), }>(),
{ {
link: '', link: '',
preset: '', preset: '',
cli: '', cli: '',
useUrl: false, useUrl: false,
externalModel: false,
code_classes: '',
}, },
); );
const isLoading = ref(true); const isLoading = ref(true);
const data = ref<unknown>(null); const data = ref<unknown>(null);
const errorMessage = ref(''); const errorMessage = ref('');
const wrapText = useStorage<boolean>('get_info_wrap_text', false); const query = ref('');
const contentView = ref<HTMLElement | null>(null);
let latestRequestId = 0; let latestRequestId = 0;
const modalUi = {
content: 'w-full sm:max-w-5xl',
body: 'p-4 sm:p-5',
footer: 'px-4 pb-4 sm:px-5 sm:pb-5',
} as const;
const modalOpen = computed({
get: () => true,
set: (value: boolean) => {
if (!value) {
emitter('closeModel');
}
},
});
const resolvedTitle = computed(() => { const resolvedTitle = computed(() => {
if (props.useUrl && props.link.startsWith('/api/history/')) { if (props.useUrl && props.link.startsWith('/api/history/')) {
return 'Local Information'; return 'Local Information';
@ -264,44 +199,29 @@ const displayText = computed(() => {
} }
}); });
const lines = computed<Array<string>>(() => {
if (!displayText.value) {
return [];
}
return displayText.value.split('\n');
});
const filteredLines = computed<Array<string>>(() => {
if (!query.value) {
return lines.value;
}
const needle = query.value.toLowerCase();
return lines.value.filter((line) => line.toLowerCase().includes(needle));
});
const filteredLineCount = computed(() => filteredLines.value.length);
const displayedText = computed(() =>
query.value ? filteredLines.value.join('\n') : displayText.value,
);
const hasDisplayText = computed(() => displayText.value.length > 0); const hasDisplayText = computed(() => displayText.value.length > 0);
const hasVisibleText = computed(() => displayedText.value.length > 0);
const isStructuredData = computed(() => {
return data.value !== null && data.value !== undefined && typeof data.value !== 'string';
});
const contentIcon = computed(() => {
return isStructuredData.value ? 'i-lucide-braces' : 'i-lucide-file-text';
});
const contentLabel = computed(() => {
return isStructuredData.value ? 'Structured output' : 'Plain text output';
});
const sourceSummary = computed(() => {
if (props.useUrl) {
return props.link || 'Direct response source';
}
const details = [];
if (props.preset) {
details.push(`Preset: ${props.preset}`);
}
if (props.cli) {
details.push('Custom yt-dlp args applied');
}
return details.join(' | ') || 'yt-dlp response payload';
});
const preClasses = computed(() => [
'h-full max-h-full overflow-auto rounded-xl border border-default bg-elevated/40 p-4 text-sm text-default',
'font-mono leading-6',
wrapText.value ? 'whitespace-pre-wrap break-words' : 'whitespace-pre',
props.code_classes,
]);
const buildRequestUrl = (): string => { const buildRequestUrl = (): string => {
if (props.useUrl) { if (props.useUrl) {
@ -348,6 +268,7 @@ const loadInfo = async (): Promise<void> => {
latestRequestId += 1; latestRequestId += 1;
const requestId = latestRequestId; const requestId = latestRequestId;
query.value = '';
isLoading.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
data.value = null; data.value = null;
@ -388,13 +309,32 @@ const loadInfo = async (): Promise<void> => {
}; };
const copyData = (): void => { const copyData = (): void => {
if (!displayText.value) { if (!displayedText.value) {
return; return;
} }
copyText(displayText.value); copyText(displayedText.value, false);
}; };
const scrollContent = (dir: 'start' | 'end'): void => {
if (!contentView.value) {
return;
}
contentView.value.scrollTo({
top: 'start' === dir ? 0 : contentView.value.scrollHeight,
behavior: 'smooth',
});
};
watch(query, () => {
if (!contentView.value) {
return;
}
contentView.value.scrollTo({ top: 0 });
});
watch( watch(
() => [props.link, props.preset, props.cli, props.useUrl], () => [props.link, props.preset, props.cli, props.useUrl],
() => { () => {

View file

@ -8,24 +8,40 @@ img {
</style> </style>
<template> <template>
<div> <div class="relative min-h-[50vh]">
<div v-if="isLoading" class="flex min-h-[50vh] items-center justify-center"> <div v-if="isLoading" class="absolute inset-0 flex items-center justify-center">
<UIcon name="i-lucide-loader-circle" class="size-20 animate-spin text-toned sm:size-24" /> <UIcon name="i-lucide-loader-circle" class="size-20 animate-spin text-toned sm:size-24" />
</div> </div>
<div v-else>
<img :src="image" /> <div v-else-if="hasError" class="flex min-h-[50vh] items-center justify-center">
<UAlert
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load image"
description="The preview could not be loaded."
class="w-full max-w-2xl"
/>
</div> </div>
<img
v-if="link"
:src="link"
alt="Image preview"
:class="{ invisible: isLoading || hasError }"
@load="handle_load"
@error="handle_error"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils'; import { disableOpacity, enableOpacity } from '~/utils';
const toast = useNotification();
const emitter = defineEmits(['closeModel']); const emitter = defineEmits(['closeModel']);
const isLoading = ref<boolean>(false); const isLoading = ref<boolean>(true);
const image = ref<string>(''); const hasError = ref<boolean>(false);
const props = defineProps({ const props = defineProps({
link: { link: {
@ -41,36 +57,28 @@ const handle_event = (e: KeyboardEvent) => {
emitter('closeModel'); emitter('closeModel');
}; };
onMounted(async () => { const handle_load = () => {
isLoading.value = false;
hasError.value = false;
};
const handle_error = () => {
isLoading.value = false;
hasError.value = true;
};
onMounted(() => {
disableOpacity(); disableOpacity();
document.addEventListener('keydown', handle_event); document.addEventListener('keydown', handle_event);
const url = props.link.startsWith('/') if (!props.link) {
? props.link hasError.value = true;
: '/api/thumbnail?url=' + encodePath(props.link);
try {
isLoading.value = true;
const imgRequest = await request(url);
if (200 !== imgRequest.status) {
return;
}
image.value = URL.createObjectURL(await imgRequest.blob());
} catch (e: any) {
console.error(e);
toast.error(`Error: ${e.message}`);
} finally {
isLoading.value = false; isLoading.value = false;
} }
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event); document.removeEventListener('keydown', handle_event);
if (image.value) {
URL.revokeObjectURL(image.value);
}
enableOpacity(); enableOpacity();
}); });
</script> </script>

View file

@ -491,7 +491,7 @@
</div> </div>
<UModal <UModal
v-if="model_item" v-if="model_item && model_item.type !== 'text'"
:open="previewOpen" :open="previewOpen"
:title="previewTitle" :title="previewTitle"
:dismissible="true" :dismissible="true"
@ -511,14 +511,6 @@
@playback-state-change="(playing: boolean) => (playingNow = playing)" @playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
<GetInfo
v-else-if="model_item?.type === 'text'"
:link="model_item.filename"
:useUrl="true"
:externalModel="true"
@closeModel="closeModel"
/>
<ImageView <ImageView
v-else-if="model_item?.type === 'image'" v-else-if="model_item?.type === 'image'"
:link="model_item.filename" :link="model_item.filename"
@ -526,6 +518,13 @@
/> />
</template> </template>
</UModal> </UModal>
<GetInfo
v-if="model_item?.type === 'text'"
:link="model_item.filename"
:useUrl="true"
@closeModel="closeModel"
/>
</div> </div>
</template> </template>

View file

@ -873,7 +873,7 @@ const getPath = (basePath: string, item: StoreItem): string => {
const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => { const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => {
if (item?.extras?.thumbnail) { if (item?.extras?.thumbnail) {
return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail)); return uri(item.extras.thumbnail);
} }
return fallback ? uri('/images/placeholder.png') : ''; return fallback ? uri('/images/placeholder.png') : '';