refactor: removed thumbnail proxying
This commit is contained in:
parent
854f5f4b42
commit
88db357b7f
7 changed files with 194 additions and 401 deletions
12
API.md
12
API.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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') : '';
|
||||
|
|
|
|||
Loading…
Reference in a new issue