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/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
- [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/info/{file:.\*}](#get-apifileinfofile)
- [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:.*}
**Purpose**: Return the `ffprobe` data for a local file.

View file

@ -1,8 +1,5 @@
import asyncio
import logging
import random
import time
from datetime import UTC, datetime
from typing import Any
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.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.router import route
from app.library.Utils import validate_url
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))
@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")
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}
@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
async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
config = Config.get_instance()

View file

@ -1,201 +1,124 @@
<template>
<UModal
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')"
>
<UModal v-model:open="modalOpen" :title="resolvedTitle" :ui="modalUi">
<template #description>
<span class="sr-only">{{ modalDescription }}</span>
</template>
<template #body>
<div 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>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !hasDisplayText"
@click="copyData"
>
Copy
</UButton>
</div>
<div class="space-y-4">
<div v-if="isLoading" class="flex min-h-80 items-center justify-center">
<UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-primary" />
</div>
<div class="flex-1 min-h-0 p-4 sm:p-5">
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
<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>
<UAlert
v-else-if="errorMessage"
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Unable to load information"
:description="errorMessage"
/>
<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 v-else class="space-y-3">
<UInput
v-model="query"
type="search"
placeholder="Filter text"
icon="i-lucide-filter"
size="sm"
class="w-full"
/>
<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>
<UAlert
v-if="query && 0 === filteredLineCount"
color="warning"
variant="soft"
icon="i-lucide-filter"
title="No matching lines"
>
<template #description>
<p class="text-sm text-default">
No lines match this filter: <u>{{ query }}</u>
</p>
</template>
</UAlert>
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
<UAlert
color="neutral"
variant="soft"
icon="i-lucide-info"
title="No content returned"
description="The request completed successfully but did not return any visible content."
class="w-full max-w-2xl"
<UAlert
v-else-if="!hasDisplayText"
color="neutral"
variant="soft"
icon="i-lucide-info"
title="No content returned"
description="The request completed successfully but did not return any visible content."
/>
<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>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
</div>
</div>
</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
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !hasDisplayText"
:disabled="isLoading || !hasVisibleText"
@click="copyData"
>
Copy
</UButton>
</div>
</div>
<div class="flex-1 min-h-0 p-4 sm:p-5">
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
<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
<UButton
type="button"
color="neutral"
variant="soft"
icon="i-lucide-info"
title="No content returned"
description="The request completed successfully but did not return any visible content."
class="w-full max-w-2xl"
/>
</div>
variant="outline"
size="sm"
icon="i-lucide-arrow-down"
:disabled="isLoading || !hasVisibleText"
@click="scrollContent('end')"
>
Go down
</UButton>
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
</div>
</div>
<UButton
type="button"
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>
<script setup lang="ts">
import { useStorage } from '@vueuse/core';
const toast = useNotification();
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
@ -205,25 +128,37 @@ const props = withDefaults(
preset?: string;
cli?: string;
useUrl?: boolean;
externalModel?: boolean;
code_classes?: string;
}>(),
{
link: '',
preset: '',
cli: '',
useUrl: false,
externalModel: false,
code_classes: '',
},
);
const isLoading = ref(true);
const data = ref<unknown>(null);
const errorMessage = ref('');
const wrapText = useStorage<boolean>('get_info_wrap_text', false);
const query = ref('');
const contentView = ref<HTMLElement | null>(null);
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(() => {
if (props.useUrl && props.link.startsWith('/api/history/')) {
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 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 hasVisibleText = computed(() => displayedText.value.length > 0);
const buildRequestUrl = (): string => {
if (props.useUrl) {
@ -348,6 +268,7 @@ const loadInfo = async (): Promise<void> => {
latestRequestId += 1;
const requestId = latestRequestId;
query.value = '';
isLoading.value = true;
errorMessage.value = '';
data.value = null;
@ -388,13 +309,32 @@ const loadInfo = async (): Promise<void> => {
};
const copyData = (): void => {
if (!displayText.value) {
if (!displayedText.value) {
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(
() => [props.link, props.preset, props.cli, props.useUrl],
() => {

View file

@ -8,24 +8,40 @@ img {
</style>
<template>
<div>
<div v-if="isLoading" class="flex min-h-[50vh] items-center justify-center">
<div class="relative min-h-[50vh]">
<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" />
</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>
<img
v-if="link"
:src="link"
alt="Image preview"
:class="{ invisible: isLoading || hasError }"
@load="handle_load"
@error="handle_error"
/>
</div>
</template>
<script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils';
const toast = useNotification();
const emitter = defineEmits(['closeModel']);
const isLoading = ref<boolean>(false);
const image = ref<string>('');
const isLoading = ref<boolean>(true);
const hasError = ref<boolean>(false);
const props = defineProps({
link: {
@ -41,36 +57,28 @@ const handle_event = (e: KeyboardEvent) => {
emitter('closeModel');
};
onMounted(async () => {
const handle_load = () => {
isLoading.value = false;
hasError.value = false;
};
const handle_error = () => {
isLoading.value = false;
hasError.value = true;
};
onMounted(() => {
disableOpacity();
document.addEventListener('keydown', handle_event);
const url = props.link.startsWith('/')
? props.link
: '/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 {
if (!props.link) {
hasError.value = true;
isLoading.value = false;
}
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event);
if (image.value) {
URL.revokeObjectURL(image.value);
}
enableOpacity();
});
</script>

View file

@ -491,7 +491,7 @@
</div>
<UModal
v-if="model_item"
v-if="model_item && model_item.type !== 'text'"
:open="previewOpen"
:title="previewTitle"
:dismissible="true"
@ -511,14 +511,6 @@
@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
v-else-if="model_item?.type === 'image'"
:link="model_item.filename"
@ -526,6 +518,13 @@
/>
</template>
</UModal>
<GetInfo
v-if="model_item?.type === 'text'"
:link="model_item.filename"
:useUrl="true"
@closeModel="closeModel"
/>
</div>
</template>

View file

@ -873,7 +873,7 @@ const getPath = (basePath: string, item: StoreItem): string => {
const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => {
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') : '';