From 3bd2a7bee7ac64223f5d30575508d708cd3a2d55 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 17 May 2026 20:21:58 +0300 Subject: [PATCH 1/7] refactor: minor ui cleanup --- API.md | 1 - app/routes/api/images.py | 49 ++++-- app/routes/api/system.py | 1 - app/tests/test_images_routes.py | 46 ++++++ app/tests/test_system_routes.py | 1 - ui/app/components/LimitsPage.vue | 48 +++--- ui/app/layouts/default.vue | 4 +- ui/app/pages/browser/[...slug].vue | 2 +- ui/app/pages/changelog.vue | 2 +- ui/app/pages/conditions.vue | 2 +- ui/app/pages/console.vue | 2 +- ui/app/pages/dl_fields.vue | 2 +- ui/app/pages/docs/[...slug].vue | 2 +- ui/app/pages/history.vue | 2 +- ui/app/pages/index.vue | 2 +- ui/app/pages/logs.vue | 248 ++++++++++------------------- ui/app/pages/notifications.vue | 4 +- ui/app/pages/presets.vue | 2 +- ui/app/pages/task_definitions.vue | 4 +- ui/app/pages/tasks.vue | 2 +- ui/app/types/limits.ts | 1 - ui/app/utils/media.ts | 16 +- ui/tests/utils/media.test.ts | 12 +- 23 files changed, 225 insertions(+), 230 deletions(-) diff --git a/API.md b/API.md index c1a5d5a5..b30b3d12 100644 --- a/API.md +++ b/API.md @@ -2602,7 +2602,6 @@ or an error: { "downloads": { "paused": false, - "live_bypasses_limits": true, "global": { "limit": 20, "active": 3, diff --git a/app/routes/api/images.py b/app/routes/api/images.py index 164e9479..6abeca72 100644 --- a/app/routes/api/images.py +++ b/app/routes/api/images.py @@ -4,7 +4,7 @@ import random import time from datetime import UTC, datetime from typing import Any -from urllib.parse import urlparse +from urllib.parse import urlparse, urlsplit, urlunsplit from aiohttp import web from aiohttp.web import Request, Response @@ -22,6 +22,27 @@ LOG: logging.Logger = logging.getLogger(__name__) IS_REQUESTING_BACKGROUND: bool = False +def _safe_url(url: str | None) -> str: + if not url: + return "" + + try: + parsed = urlsplit(url) + except Exception: + return str(url) + + netloc: str = parsed.netloc + if parsed.username or parsed.password: + host: str = parsed.hostname or "" + if parsed.port: + host: str = f"{host}:{parsed.port}" + netloc: str = f"redacted:redacted@{host}" if host else "redacted:redacted" + + query: str = "redacted" if parsed.query else "" + fragment: str = "redacted" if parsed.fragment else "" + return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment)) + + @route("GET", "api/thumbnail/", "get_thumbnail") async def get_thumbnail(request: Request, config: Config) -> Response: """ @@ -52,9 +73,10 @@ async def get_thumbnail(request: Request, config: Config) -> Response: 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 '{url}'.") + LOG.debug(f"Fetching thumbnail from '{safe_url}'.") response = await client.request( method="GET", url=url, @@ -64,7 +86,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response: ) if response.status_code != web.HTTPOk.status_code: - LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.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( @@ -81,7 +103,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response: }, ) except Exception as e: - LOG.error(f"Error fetching thumbnail from '{url}'. '{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 ) @@ -110,7 +132,8 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp try: IS_REQUESTING_BACKGROUND = True - backend = random.choice(config.pictures_backends) + backend: str = random.choice(config.pictures_backends) + safe_backend: str = _safe_url(backend) CACHE_KEY_BING = "random_background_bing" CACHE_KEY = "random_background" @@ -135,12 +158,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp ) ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() - use_curl = resolve_curl_transport() - request_headers = build_request_headers( + use_curl: bool = resolve_curl_transport() + request_headers: dict = 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") + proxy: str | None = ytdlp_args.get("proxy") client = get_async_client(proxy=proxy, use_curl=use_curl) if backend.startswith("https://www.bing.com/HPImageArchive.aspx"): @@ -159,10 +182,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp status=web.HTTPInternalServerError.status_code, ) - backend = f"https://www.bing.com{img_url}" + backend: str = f"https://www.bing.com{img_url}" + safe_backend: str = _safe_url(backend) await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24) else: backend = await cache.aget(CACHE_KEY_BING) + safe_backend = _safe_url(backend if isinstance(backend, str) else None) if not isinstance(backend, str) or not backend: return web.json_response( @@ -170,7 +195,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp status=web.HTTPInternalServerError.status_code, ) - LOG.debug(f"Requesting random picture from '{backend!s}'.") + LOG.debug(f"Requesting random picture from '{safe_backend}'.") response = await client.request( method="GET", @@ -198,7 +223,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp await cache.aset(key=CACHE_KEY, value=data, ttl=3600) - LOG.debug(f"Random background image from '{backend!s}' cached.") + LOG.debug(f"Random background image from '{safe_backend}' cached.") return web.Response( body=data.get("content"), @@ -210,7 +235,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp }, ) except Exception as e: - LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.") + LOG.error(f"Failed to request random background image from '{safe_backend}'. '{e!s}'.") return web.json_response( data={"error": "failed to retrieve the random background image."}, status=web.HTTPInternalServerError.status_code, diff --git a/app/routes/api/system.py b/app/routes/api/system.py index f45f5f3f..d96506db 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -464,7 +464,6 @@ async def system_limits(queue: DownloadQueue, config: Config, encoder: Encoder) data={ "downloads": { "paused": queue.is_paused(), - "live_bypasses_limits": True, "global": { "limit": config.max_workers, "active": len(active_non_live), diff --git a/app/tests/test_images_routes.py b/app/tests/test_images_routes.py index 97bce698..417b5209 100644 --- a/app/tests/test_images_routes.py +++ b/app/tests/test_images_routes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Generator from unittest.mock import AsyncMock @@ -95,3 +96,48 @@ async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None: 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() + config.pictures_backends = ["https://user:pass@example.com/bg.jpg?apitoken=secret#frag"] + req = make_mocked_request("GET", "/api/random/background") + + class DummyCache: + def has(self, _key: str) -> bool: + return False + + async def aset(self, **_kwargs) -> None: + return None + + client = AsyncMock() + client.request.side_effect = RuntimeError("boom") + + 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: {}, + }, + )() + ), + ) + + with caplog.at_level(logging.ERROR): + response = await images.get_background(req, config, DummyCache()) + + assert response.status == web.HTTPInternalServerError.status_code + logs = caplog.text + assert "apitoken=secret" not in logs + assert "user:pass@" not in logs + assert "https://redacted:redacted@example.com/bg.jpg?redacted#redacted" in logs diff --git a/app/tests/test_system_routes.py b/app/tests/test_system_routes.py index eefbfda5..ce616045 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -151,7 +151,6 @@ class TestSystemLimitsEndpoint: body = json.loads(response.body.decode("utf-8")) assert body["downloads"]["paused"] is False - assert body["downloads"]["live_bypasses_limits"] is True assert body["downloads"]["global"] == { "limit": 10, "active": 2, diff --git a/ui/app/components/LimitsPage.vue b/ui/app/components/LimitsPage.vue index a424342d..e186e476 100644 --- a/ui/app/components/LimitsPage.vue +++ b/ui/app/components/LimitsPage.vue @@ -1,17 +1,14 @@ @@ -164,7 +390,7 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source'; import moment from 'moment'; import { useStorage } from '@vueuse/core'; import type { log_line } from '~/types/logs'; -import { parse_api_error, request, uri } from '~/utils'; +import { copyText, parse_api_error, request, uri } from '~/utils'; import { requirePageShell } from '~/utils/topLevelNavigation'; type FilteredLogEntry = { @@ -175,23 +401,32 @@ type FilteredLogEntry = { }; type LogLevel = 'debug' | 'info' | 'warning' | 'error'; +type LogLevelColor = 'neutral' | 'info' | 'warning' | 'error'; +type DetailRow = { + label: string; + value: string; + icon: string; +}; +type LevelFilterItem = { + label: string; + value: LogLevel; +}; const FILTER_CONTEXT_REGEX = /context:(\d+)/; -const LOG_LEVEL_PREFIX = - /^\s*(?:\[(debug|info|warning|warn|error|critical|fatal)(?:[.\]])|(debug|info|warning|warn|error|critical|fatal)\b(?::|\s|-))/i; +const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warning', 'error']; const LOG_ROW_CLASS = 'flex min-w-0 border-b border-default/40 bg-transparent transition-colors duration-150 last:border-b-0 hover:bg-elevated/70'; -const LOG_LEVEL_DOT_CLASS: Record = { - debug: 'bg-muted', - info: 'bg-info', - warning: 'bg-warning', - error: 'bg-error', +const LOG_LEVEL_COLOR: Record = { + debug: 'neutral', + info: 'info', + warning: 'warning', + error: 'error', }; -const LOG_LEVEL_TEXT_CLASS: Record = { - debug: 'text-toned', - info: 'text-info', - warning: 'text-warning', - error: 'text-error', +const LOG_LEVEL_ICON: Record = { + debug: 'i-lucide-terminal', + info: 'i-lucide-info', + warning: 'i-lucide-triangle-alert', + error: 'i-lucide-circle-x', }; let scrollTimeout: NodeJS.Timeout | null = null; @@ -203,13 +438,20 @@ const pageShell = requirePageShell('logs'); const logContainer = useTemplateRef('logContainer'); const textWrap = useStorage('logs_wrap', true); +const exceptionOpen = useStorage('logs_exception_open', false); +const fieldsOpen = useStorage('logs_fields_open', true); +const rawJsonOpen = useStorage('logs_raw_json_open', false); +const sourceOpen = useStorage('logs_source_open', true); +const selectedLevels = useStorage('logs_level_filter', [...LOG_LEVELS]); const sseController = ref(null); const logs = ref>([]); +const selectedLog = ref(null); const offset = ref(0); const loading = ref(false); const autoScroll = ref(true); const reachedEnd = ref(false); +const detailsOpen = ref(false); const pageCardUi = { root: 'w-full min-w-0 max-w-full bg-transparent', @@ -217,6 +459,10 @@ const pageCardUi = { wrapper: 'w-full min-w-0 items-stretch', body: 'w-full min-w-0 max-w-full overflow-hidden', }; +const detailsModalUi = { + content: 'max-w-5xl', + body: 'max-h-[75vh] overflow-y-auto', +}; const query = ref( (() => { @@ -240,12 +486,75 @@ const query = ref( const toggleFilter = ref(Boolean(query.value)); const normalizedQuery = computed(() => query.value.trim().toLowerCase()); +const selectedLevelSet = computed( + () => new Set(LOG_LEVELS.filter((level) => selectedLevels.value.includes(level))), +); +const hasLevelFilter = computed(() => selectedLevelSet.value.size !== LOG_LEVELS.length); const filterContext = computed(() => { const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX); return match ? parseInt(match[1] ?? '0', 10) : 0; }); const searchTerm = computed(() => normalizedQuery.value.replace(FILTER_CONTEXT_REGEX, '').trim()); -const hasActiveFilter = computed(() => Boolean(searchTerm.value)); +const hasTextFilter = computed(() => Boolean(searchTerm.value)); +const hasActiveFilter = computed(() => hasTextFilter.value || hasLevelFilter.value); +const canLoadFilteredHistory = computed( + () => hasActiveFilter.value && !reachedEnd.value && logs.value.length > 0, +); +const levelCounts = computed>(() => { + const counts: Record = { + debug: 0, + info: 0, + warning: 0, + error: 0, + }; + + logs.value.forEach((log) => { + const level = getLogLevel(log.level); + counts[level] += 1; + }); + + return counts; +}); +const levelFilterItems = computed(() => [ + ...LOG_LEVELS.map((level) => ({ + label: `${level.charAt(0).toUpperCase()}${level.slice(1)} (${levelCounts.value[level]})`, + value: level, + })), +]); +const levelFilterLabel = computed(() => { + if (selectedLevelSet.value.size === LOG_LEVELS.length) { + return `All levels (${logs.value.length})`; + } + + if (selectedLevelSet.value.size === 0) { + return 'No levels selected'; + } + + return LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level)).join(', '); +}); +const activeFilterLabel = computed(() => { + const parts: string[] = []; + if (hasTextFilter.value) { + parts.push(`query "${searchTerm.value}"`); + } + + if (hasLevelFilter.value) { + const levels = LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level)); + parts.push(levels.length ? `levels ${levels.join(', ')}` : 'no selected levels'); + } + + return parts.join(' and '); +}); +const emptyStateTitle = computed(() => + hasActiveFilter.value ? 'No logs match these filters' : 'No log lines available', +); +const emptyStateDescription = computed(() => { + if (!hasActiveFilter.value) { + return 'No log lines are available yet.'; + } + + return `No loaded log lines match ${activeFilterLabel.value}. Adjust filters or load older lines.`; +}); watch(toggleFilter, () => { if (!toggleFilter.value) { query.value = ''; @@ -264,11 +573,17 @@ watch( }, ); +watch(detailsOpen, (open) => { + if (!open) { + selectedLog.value = null; + } +}); + const filteredLogs = computed(() => { if (!hasActiveFilter.value) { return logs.value.map((log) => ({ log, - level: detectLogLevel(log.line), + level: getLogLevel(log.level), isMatch: false, isContext: false, })); @@ -279,7 +594,16 @@ const filteredLogs = computed(() => { const matchedIndexes = new Set(); logs.value.forEach((log, index) => { - if (log.line.toLowerCase().includes(searchTerm.value)) { + if (!selectedLevelSet.value.has(getLogLevel(log.level))) { + return; + } + + if (!hasTextFilter.value) { + visibleIndexes.add(index); + return; + } + + if (searchableLog(log).includes(searchTerm.value)) { matchedIndexes.add(index); for ( @@ -296,10 +620,13 @@ const filteredLogs = computed(() => { .sort((a, b) => a - b) .forEach((index) => { const log = logs.value[index] as log_line; + if (!selectedLevelSet.value.has(getLogLevel(log.level))) { + return; + } result.push({ log, - level: detectLogLevel(log.line), + level: getLogLevel(log.level), isMatch: matchedIndexes.has(index), isContext: !matchedIndexes.has(index), }); @@ -321,10 +648,10 @@ const scrollLogContainerToBottom = async (behavior: ScrollBehavior = 'auto'): Pr }); }; -const fetchLogs = async (): Promise => { +const fetchLogs = async (force = false): Promise => { loading.value = true; - if (reachedEnd.value || (hasActiveFilter.value && logs.value.length > 0)) { + if (reachedEnd.value || (!force && hasActiveFilter.value && logs.value.length > 0)) { loading.value = false; return; } @@ -477,6 +804,24 @@ const logTimeLabel = (value?: string): string => const logTimeTitle = (value?: string): string => value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp'; +const logRaw = (log: log_line): string => JSON.stringify(log, null, 2); + +const searchableLog = (log: log_line): string => + [ + log.message, + log.level, + log.logger, + log.exception, + log.stack, + log.source ? JSON.stringify(log.source) : '', + log.process ? JSON.stringify(log.process) : '', + log.thread ? JSON.stringify(log.thread) : '', + log.fields ? JSON.stringify(log.fields) : '', + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + const getLogLevel = (level: string): LogLevel => { switch (level.toLowerCase()) { case 'info': @@ -493,21 +838,20 @@ const getLogLevel = (level: string): LogLevel => { } }; -const detectLogLevel = (line: string): LogLevel => { - const match = line.match(LOG_LEVEL_PREFIX); - const level = match?.[1] ?? match?.[2]; +const logLevelBadgeClass = (level: LogLevel): string[] => [ + 'inline-flex items-center gap-1.5 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide cursor-pointer', + level === 'debug' ? 'bg-muted/40 text-muted' : '', + level === 'info' ? 'bg-info/10 text-info' : '', + level === 'warning' ? 'bg-warning/10 text-warning' : '', + level === 'error' ? 'bg-error/10 text-error' : '', +]; - return level ? getLogLevel(level) : 'debug'; -}; - -const logLevelDotClass = (level: LogLevel): string => LOG_LEVEL_DOT_CLASS[level]; - -const logLineClass = (level: LogLevel): string[] => [ +const logLineClass = (): string[] => [ 'flex-1', textWrap.value ? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]' : 'min-w-max whitespace-pre', - LOG_LEVEL_TEXT_CLASS[level], + 'text-default', ]; const logRowClass = (entry: FilteredLogEntry, index: number): string[] => { @@ -530,6 +874,82 @@ const logRowClass = (entry: FilteredLogEntry, index: number): string[] => { return classes; }; +const openLogDetails = (log: log_line): void => { + selectedLog.value = log; + detailsOpen.value = true; +}; + +const formatDetailValue = (value: unknown): string => { + if (value === undefined || value === null || value === '') { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +}; + +const compactRows = (rows: Array<{ label: string; value: unknown; icon: string }>): DetailRow[] => + rows + .map((row) => ({ + label: row.label, + value: formatDetailValue(row.value), + icon: row.icon, + })) + .filter((row) => Boolean(row.value)); + +const formatNameId = (name: unknown, id: unknown): string => { + const nameValue = formatDetailValue(name); + const idValue = formatDetailValue(id); + if (nameValue && idValue) { + return `${nameValue} / ${idValue}`; + } + + return nameValue || idValue; +}; + +const detailRows = (log: log_line): DetailRow[] => + compactRows([ + { label: 'File', value: log.source?.file, icon: 'i-lucide-file' }, + { label: 'Line', value: log.source?.line, icon: 'i-lucide-hash' }, + { label: 'Function', value: log.source?.function, icon: 'i-lucide-code-2' }, + { label: 'Module', value: log.source?.module, icon: 'i-lucide-box' }, + { label: 'Path', value: log.source?.path, icon: 'i-lucide-folder-tree' }, + { + label: 'Process / ID', + value: formatNameId(log.process?.name, log.process?.id), + icon: 'i-lucide-cpu', + }, + { + label: 'Thread / ID', + value: formatNameId(log.thread?.name, log.thread?.id), + icon: 'i-lucide-git-branch', + }, + ]); + +const fieldRows = (log: log_line): DetailRow[] => + compactRows( + Object.entries(log.fields ?? {}).map(([label, value]) => ({ + label, + value, + icon: 'i-lucide-tag', + })), + ); + +const copyLogMessage = (log: log_line): void => { + copyText(log.message); +}; + +const copyLogRaw = (log: log_line): void => { + copyText(logRaw(log)); +}; + onMounted(async () => { if (!config.app.file_logging) { await navigateTo('/'); diff --git a/ui/app/types/logs.ts b/ui/app/types/logs.ts index d918adf0..48cccbda 100644 --- a/ui/app/types/logs.ts +++ b/ui/app/types/logs.ts @@ -1,7 +1,35 @@ -type log_line = { - id: number; - line: string; - datetime?: string; +type log_source = { + path?: string; + file?: string; + module?: string; + function?: string; + line?: number; }; -export type { log_line }; +type log_process = { + id?: number | string; + name?: string; +}; + +type log_thread = { + id?: number | string; + name?: string; +}; + +type log_line = { + id: string; + message: string; + datetime: string; + level: string; + levelno?: number; + logger: string; + source?: log_source; + process?: log_process; + thread?: log_thread; + fields?: Record; + exception?: string | null; + exception_message?: string | null; + stack?: string | null; +}; + +export type { log_line, log_process, log_source, log_thread }; diff --git a/ui/app/utils/topLevelNavigation.ts b/ui/app/utils/topLevelNavigation.ts index 03c1b212..21074d4b 100644 --- a/ui/app/utils/topLevelNavigation.ts +++ b/ui/app/utils/topLevelNavigation.ts @@ -168,7 +168,7 @@ const NavItems: Array = [ group: 'tools', label: 'Logs', pageLabel: 'Logs', - description: 'Scroll near the top to load older logs.', + description: 'Scroll up to load older logs.', icon: 'i-lucide-file-text', to: '/logs', matchPath: '/logs', From 88db357b7fed5b2097ef893b8bc368ba48d07170 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 18 May 2026 12:28:06 +0300 Subject: [PATCH 4/7] refactor: removed thumbnail proxying --- API.md | 12 - app/routes/api/images.py | 70 ------ app/tests/test_images_routes.py | 72 ------ ui/app/components/GetInfo.vue | 358 ++++++++++++----------------- ui/app/components/ImageView.vue | 64 +++--- ui/app/pages/browser/[...slug].vue | 17 +- ui/app/utils/index.ts | 2 +- 7 files changed, 194 insertions(+), 401 deletions(-) diff --git a/API.md b/API.md index 9ed301c7..57d30de0 100644 --- a/API.md +++ b/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=` - -**Response**: -Binary image data with the appropriate `Content-Type`. - ---- - ### GET /api/file/ffprobe/{file:.*} **Purpose**: Return the `ffprobe` data for a local file. diff --git a/app/routes/api/images.py b/app/routes/api/images.py index 6abeca72..ebb55af8 100644 --- a/app/routes/api/images.py +++ b/app/routes/api/images.py @@ -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: """ diff --git a/app/tests/test_images_routes.py b/app/tests/test_images_routes.py index 417b5209..c8d4ea1d 100644 --- a/app/tests/test_images_routes.py +++ b/app/tests/test_images_routes.py @@ -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() diff --git a/ui/app/components/GetInfo.vue b/ui/app/components/GetInfo.vue index 8b2b6898..e0f1238e 100644 --- a/ui/app/components/GetInfo.vue +++ b/ui/app/components/GetInfo.vue @@ -1,201 +1,124 @@ diff --git a/ui/app/pages/browser/[...slug].vue b/ui/app/pages/browser/[...slug].vue index 4a129808..3fca59c5 100644 --- a/ui/app/pages/browser/[...slug].vue +++ b/ui/app/pages/browser/[...slug].vue @@ -491,7 +491,7 @@ - - + + diff --git a/ui/app/utils/index.ts b/ui/app/utils/index.ts index 8b846f47..49655989 100644 --- a/ui/app/utils/index.ts +++ b/ui/app/utils/index.ts @@ -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') : ''; From 3b3c8120b89a09fdce951c233b1703840622a395 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 18 May 2026 22:12:13 +0300 Subject: [PATCH 5/7] fix: recent change made fetch_info unpicklable --- app/features/ytdlp/extractor.py | 33 +++++++++++++------ .../ytdlp/tests/test_ytdlp_extractor.py | 18 ++++++++++ ui/app/pages/logs.vue | 18 +++++++--- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/app/features/ytdlp/extractor.py b/app/features/ytdlp/extractor.py index b8071879..a9e9e674 100644 --- a/app/features/ytdlp/extractor.py +++ b/app/features/ytdlp/extractor.py @@ -19,19 +19,32 @@ LOG: logging.Logger = logging.getLogger("downloads.extractor") def _ytdlp_logger(target: logging.Logger): - def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None: + return _YTDLPLogger(target) + + +class _YTDLPLogger: + def __init__(self, target: logging.Logger) -> None: + self.target = target + + def __call__(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: kwargs.setdefault("stacklevel", 4) if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "): - target.debug(msg.removeprefix("[debug] "), *args, **kwargs) + self.target.debug(msg.removeprefix("[debug] "), *args, **kwargs) return if level <= logging.DEBUG: - target.info(msg, *args, **kwargs) + self.target.info(msg, *args, **kwargs) return - target.log(level, msg, *args, **kwargs) + self.target.log(level, msg, *args, **kwargs) - return _log + +class _LogCapture: + def __init__(self, logs: list[str]) -> None: + self.logs = logs + + def __call__(self, _: int, msg: str, *args: Any, **__: Any) -> None: + self.logs.append(msg % args if args else msg) def _get_process_pool_kwargs() -> dict[str, Any]: @@ -243,7 +256,7 @@ def extract_info_sync( sanitize_info: bool = False, capture_logs: int | None = None, **kwargs, -) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: +) -> tuple[dict[str, Any] | None, list[str]]: """ Extract video information from a URL. @@ -258,7 +271,7 @@ def extract_info_sync( **kwargs: Additional arguments Returns: - tuple[dict | None, list[dict]]: Extracted information and captured logs. + tuple[dict | None, list[str]]: Extracted information and captured logs. """ params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True} @@ -287,7 +300,7 @@ def extract_info_sync( captured_logs: list[str] = kwargs.get("captured_logs", []) if capture_logs is not None: log_wrapper.add_target( - target=lambda _, msg: captured_logs.append(msg), + target=_LogCapture(captured_logs), level=capture_logs, name="log-capture", ) @@ -351,7 +364,7 @@ async def fetch_info( extractor_config: ExtractorConfig | None = None, budget_sleep: bool = False, **kwargs, -) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: +) -> tuple[dict[str, Any] | None, list[str]]: """ Extract video information from a URL. @@ -371,7 +384,7 @@ async def fetch_info( **kwargs: Additional arguments Returns: - tuple[dict | None, list[dict]]: Extracted information and captured logs. + tuple[dict | None, list[str]]: Extracted information and captured logs. """ if extractor_config is None: diff --git a/app/features/ytdlp/tests/test_ytdlp_extractor.py b/app/features/ytdlp/tests/test_ytdlp_extractor.py index a2a7d955..33972198 100644 --- a/app/features/ytdlp/tests/test_ytdlp_extractor.py +++ b/app/features/ytdlp/tests/test_ytdlp_extractor.py @@ -1,13 +1,16 @@ import logging +import pickle from unittest.mock import MagicMock, patch from app.features.ytdlp.extractor import ( ExtractorConfig, ExtractorPool, + _LogCapture, _get_process_pool_kwargs, _ytdlp_logger, extract_info_sync, ) +from app.features.ytdlp.utils import LogWrapper class TestProcessPoolConfiguration: @@ -170,3 +173,18 @@ class TestYtdlpLogger: _ytdlp_logger(logger)(logging.DEBUG, "screen line") logger.info.assert_called_once_with("screen line", stacklevel=4) + + def test_targets_are_picklable(self) -> None: + logs: list[str] = [] + wrapper = LogWrapper() + wrapper.add_target(_ytdlp_logger(logging.getLogger("yt-dlp.test")), level=logging.DEBUG, name="yt-dlp.test") + wrapper.add_target(_LogCapture(logs), level=logging.WARNING, name="log-capture") + + pickle.dumps(wrapper) + + def test_capture_formats_args(self) -> None: + logs: list[str] = [] + + _LogCapture(logs)(logging.WARNING, "hello %s", "world") + + assert logs == ["hello world"] diff --git a/ui/app/pages/logs.vue b/ui/app/pages/logs.vue index 3702f4e9..5e9fed60 100644 --- a/ui/app/pages/logs.vue +++ b/ui/app/pages/logs.vue @@ -142,7 +142,9 @@ ]" >

- + {{ logTimeLabel(entry.log.datetime) }} @@ -169,7 +171,8 @@ [{{ entry.log.logger }}] @@ -223,8 +226,15 @@ /> {{ getLogLevel(selectedLog.level) }} - - {{ selectedLog.logger }} + + {{ selectedLog.logger }} {{ logTimeTitle(selectedLog.datetime) }} From ca7227ffc5160847dde5bead60a443feb3f6775b Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 18 May 2026 22:20:31 +0300 Subject: [PATCH 6/7] refactor: adjust width and shrink behavior of filter input --- ui/app/pages/logs.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/pages/logs.vue b/ui/app/pages/logs.vue index 5e9fed60..2b205cc3 100644 --- a/ui/app/pages/logs.vue +++ b/ui/app/pages/logs.vue @@ -72,7 +72,7 @@ multiple size="sm" icon="i-lucide-list-filter" - class="order-last w-full sm:order-0 sm:w-48" + class="w-44 shrink-0 sm:w-48" :ui="{ content: 'min-w-48' }" >