Merge pull request #586 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

minor bug fixes and overall ui improvements
This commit is contained in:
Abdulmohsen 2026-04-10 18:33:29 +03:00 committed by GitHub
commit 2139e20332
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 422 additions and 108 deletions

View file

@ -107,9 +107,11 @@ This project is not affiliated with yt-dlp or any other service.
Its a personal project designed to make downloading videos from the internet more convenient. Its not intended for
piracy or any unlawful use.
AI-based tools may have been used to assist with parts of this project. Regardless of how a change is produced, every
change is reviewed and approved by the human maintainer before it is included.
This project was built primarily for my own needs and preferences. The UI might not be the most polished or visually
refined, but Im happy with it as it is. You can, however, create and load your own UI for complete customization. I
plan to refactor the UI/UX in the future using [Nuxt/ui](https://ui.nuxt.com/).
refined, but Im happy with it as it is. You can, however, create and load your own UI for complete customization.
Contributions are welcome, but I may decline changes that dont align with my vision for the project. Unsolicited pull
requests may be ignored. For suggestions or feature requests, please open a discussion or join the Discord server.

View file

@ -37,9 +37,7 @@ class _DATA:
{
"quiet": "-q, --quiet",
"no_warnings": "--no-warnings",
"skip_download": "--skip-download",
"forceprint": "-O, --print",
"simulate": "--simulate",
"noprogress": "--no-progress",
"wait_for_video": "--wait-for-video",
"progress_delta": " --progress-delta",

View file

@ -381,6 +381,8 @@ class ItemDTO:
""" The archive ID of the item. """
sidecar: dict = field(default_factory=dict)
""" Sidecar data associated with the item. """
download_skipped: bool = False
""" True when yt-dlp intentionally skips the primary media download. """
# yt-dlp injected fields.
tmpfilename: str | None = None

View file

@ -92,9 +92,11 @@ class Download:
download using yt-dlp.
"""
cookie_file: Path | None = None
params: dict[str, Any] = {}
download_skipped = False
try:
params: dict = (
params = (
self.info.get_ytdlp_opts()
.add(
config={
@ -116,6 +118,7 @@ class Download:
)
.get_all()
)
download_skipped = bool(params.get("skip_download") or params.get("simulate"))
params.update(
{
@ -212,7 +215,7 @@ class Download:
signal.signal(signal.SIGUSR1, mark_cancelled)
self.status_queue.put({"id": self.id, "status": "downloading"})
self.status_queue.put({"id": self.id, "status": "downloading", "download_skipped": download_skipped})
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
self.logger.debug(f"Downloading '{self.info.url}' using pre-info.")
@ -222,7 +225,18 @@ class Download:
{
k: v
for k, v in self.info.extras.items()
if k not in _dct and v is not None and k not in ("is_live",)
if v is not None
and k not in ("is_live",)
and (
k not in _dct
or (
(
k.startswith("playlist_")
or k in {"playlist", "n_entries", "__last_playlist_index"}
)
and _dct.get(k) in (None, "", "NA")
)
)
}
)
@ -232,14 +246,35 @@ class Download:
self.logger.debug(f"Downloading using url: {self.info.url}")
ret = cls.download(url_list=[self.info.url])
self.status_queue.put({"id": self.id, "status": "finished" if 0 == ret else "error"})
self.status_queue.put(
{
"id": self.id,
"status": "finished" if 0 == ret else "error",
"download_skipped": download_skipped,
}
)
except yt_dlp.utils.ExistingVideoReached as exc:
self.logger.error(exc)
self.status_queue.put({"id": self.id, "status": "skip", "msg": "Item has already been downloaded."})
self.status_queue.put(
{
"id": self.id,
"status": "skip",
"msg": "Item has already been downloaded.",
"download_skipped": download_skipped,
}
)
except Exception as exc:
self.logger.exception(exc)
self.logger.error(exc)
self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)})
self.status_queue.put(
{
"id": self.id,
"status": "error",
"msg": str(exc),
"error": str(exc),
"download_skipped": download_skipped,
}
)
finally:
self.status_queue.put(Terminator())
if cookie_file and cookie_file.exists():

View file

@ -128,6 +128,8 @@ class StatusTracker:
self.tmpfilename = status.get("tmpfilename")
self.info.status = status.get("status", self.info.status)
if "download_skipped" in status:
self.info.download_skipped = bool(status.get("download_skipped"))
self.info.msg = status.get("msg")
self.info.postprocessor = status.get("postprocessor", None)

View file

@ -75,7 +75,7 @@ class TestDownloadHooks:
max_workers = 1
temp_keep = False
temp_disabled = True
download_info_expires = 3600
download_info_expires = 0
@staticmethod
def get_instance():
@ -148,7 +148,7 @@ class TestDownloadStale:
max_workers = 1
temp_keep = False
temp_disabled = True
download_info_expires = 3600
download_info_expires = 0
@staticmethod
def get_instance():
@ -272,6 +272,130 @@ class TestDownloadStale:
class TestDownloadFlow:
def test_download_pushes_download_skipped_flag(self, monkeypatch: pytest.MonkeyPatch) -> None:
class Cfg:
debug = False
ytdlp_debug = False
max_workers = 1
temp_keep = False
temp_disabled = True
download_info_expires = 3600
@staticmethod
def get_instance():
return Cfg
monkeypatch.setattr("app.library.downloads.core.Config", Cfg)
download = Download(
info=make_item(),
info_dict={"id": "test-id", "url": "http://u", "formats": [{"format_id": "18"}]},
)
download.status_queue = cast(Any, DummyQueue())
download._hook_handlers = Mock(
progress_hook=Mock(),
postprocessor_hook=Mock(),
post_hook=Mock(),
)
download.info.get_ytdlp_opts = Mock(
return_value=Mock(
add=Mock(
return_value=Mock(
get_all=Mock(return_value={"skip_download": True}),
)
)
)
)
class FakeYTDLP:
def __init__(self, params):
self.params = params
self._download_retcode = 0
self._interrupted = False
def process_ie_result(self, ie_result, download):
return ie_result, download
monkeypatch.setattr("app.library.downloads.core.YTDLP", FakeYTDLP)
download._download()
queue = cast(DummyQueue, download.status_queue)
assert queue.items[0]["download_skipped"] is True
assert queue.items[1]["download_skipped"] is True
def test_download_prefers_real_playlist_extras_over_placeholder_preinfo(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
class Cfg:
debug = False
ytdlp_debug = False
max_workers = 1
temp_keep = False
temp_disabled = True
download_info_expires = 0
@staticmethod
def get_instance():
return Cfg
monkeypatch.setattr("app.library.downloads.core.Config", Cfg)
item = make_item()
item.extras = {
"playlist": "Internet Dating Slang",
"playlist_title": "Internet Dating Slang",
"playlist_index": 1,
"playlist_autonumber": 1,
"n_entries": 2,
}
download = Download(
info=item,
info_dict={
"id": "test-id",
"url": "http://u",
"title": "Video Title",
"formats": [{"format_id": "18"}],
"playlist": "NA",
"playlist_title": None,
"playlist_index": "NA",
"playlist_autonumber": "",
"n_entries": None,
},
)
download.status_queue = cast(Any, DummyQueue())
download._hook_handlers = Mock(
progress_hook=Mock(),
postprocessor_hook=Mock(),
post_hook=Mock(),
)
download.info.get_ytdlp_opts = Mock(
return_value=Mock(add=Mock(return_value=Mock(get_all=Mock(return_value={}))))
)
captured: dict[str, Any] = {}
class FakeYTDLP:
def __init__(self, params):
self.params = params
self._download_retcode = 0
self._interrupted = False
def process_ie_result(self, ie_result, download):
captured["ie_result"] = ie_result
return ie_result, download
monkeypatch.setattr("app.library.downloads.core.YTDLP", FakeYTDLP)
download._download()
ie_result = captured["ie_result"]
assert ie_result["playlist"] == "Internet Dating Slang"
assert ie_result["playlist_title"] == "Internet Dating Slang"
assert ie_result["playlist_index"] == 1
assert ie_result["playlist_autonumber"] == 1
assert ie_result["n_entries"] == 2
@pytest.mark.asyncio
async def test_download_flow_inline_process(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
class Cfg:
@ -724,6 +848,14 @@ class TestStatusTracker:
await st.process_status_update(status)
assert st.info.status == "downloading", "Should update info status"
@pytest.mark.asyncio
async def test_process_status_update_sets_download_skipped(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config)
status = {"id": "test-id", "status": "downloading", "download_skipped": True}
await st.process_status_update(status)
assert st.info.download_skipped is True, "Should update download_skipped from status queue"
@pytest.mark.asyncio
async def test_process_status_update_sets_tmpfilename(self, mock_config: dict) -> None:
st = StatusTracker(**mock_config)

View file

@ -110,6 +110,23 @@ class TestItemDTO:
assert dto.is_archivable is True
assert dto.is_archived is True
@patch("app.library.ItemDTO.get_archive_id")
@patch("app.library.ItemDTO.YTDLPOpts")
@patch("app.library.ItemDTO.archive_read")
def test_post_init_does_not_infer_download_skipped_flag(self, mock_read, mock_opts, mock_get_id):
mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"}
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.get_all.return_value = {
"download_archive": "/tmp/a.txt",
"skip_download": True,
}
mock_read.return_value = ["arch"]
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
assert dto.download_skipped is False
@patch("app.library.ItemDTO.archive_read")
def test_serialize_triggers_archive_status_when_finished(self, mock_read):
# Given a finished item with archive info
@ -125,12 +142,32 @@ class TestItemDTO:
for key in ItemDTO.removed_fields():
assert key not in data
@patch("app.library.ItemDTO.YTDLPOpts")
def test_serialize_does_not_recompute_download_skipped(self, mock_opts):
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.get_all.return_value = {}
dto = ItemDTO(id="vid", title="t", url="u", folder="f", download_skipped=True)
mock_opts.get_instance.reset_mock()
data = dto.serialize()
assert data["download_skipped"] is True
mock_opts.get_instance.assert_not_called()
@patch("app.library.ItemDTO.YTDLPOpts")
def test_get_ytdlp_opts_uses_preset_and_cli(self, mock_opts):
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
dto = ItemDTO(id="id", title="t", url="u", folder="f", preset="p", cli="--x")
mock_opts.get_instance.reset_mock()
mock_opts.get_instance.return_value.preset.reset_mock()
mock_opts.get_instance.return_value.add_cli.reset_mock()
opts = dto.get_ytdlp_opts()
mock_opts.get_instance.assert_called_once()

View file

@ -17,13 +17,15 @@ async def make_store() -> SqliteStore:
return store
def make_item(idx: int, *, status: str = "finished") -> ItemDTO:
def make_item(idx: int, *, status: str = "finished", cli: str = "", download_skipped: bool = False) -> ItemDTO:
return ItemDTO(
id=f"vid{idx}",
title=f"Video {idx}",
url=f"https://example.com/{idx}",
folder="/downloads",
status=status,
cli=cli,
download_skipped=download_skipped,
)
@ -90,6 +92,22 @@ async def test_enqueue_upsert_and_fetch_saved():
await store.close()
@pytest.mark.asyncio
async def test_enqueue_upsert_persists_download_skipped_flag():
store = await make_store()
item = make_item(2, download_skipped=True)
await store.enqueue_upsert("history", item)
await store.flush()
saved = await store.fetch_saved("history")
assert len(saved) == 1
_, loaded = saved[0]
assert item.download_skipped is True
assert loaded.download_skipped is True
await store.close()
@pytest.mark.asyncio
async def test_delete_and_bulk_delete():
store = await make_store()

View file

@ -214,7 +214,7 @@
<td class="w-44 px-3 py-3 align-top whitespace-nowrap">
<div class="flex items-center justify-end gap-1">
<UButton
v-if="!item.filename"
v-if="showRetryAction(item)"
color="warning"
variant="outline"
size="xs"
@ -442,7 +442,7 @@
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
v-if="!item.filename"
v-if="showRetryAction(item)"
color="warning"
variant="outline"
icon="i-lucide-rotate-cw"
@ -616,7 +616,7 @@ import { useStorage, useIntersectionObserver } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import type { StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm';
import { deepIncludes, getPath, getImage } from '~/utils';
import { deepIncludes, getPath, getImage, isDownloadSkipped } from '~/utils';
import type { item_request } from '~/types/item';
const emitter = defineEmits<{
@ -816,6 +816,8 @@ const getListImage = (item: StoreItem): string =>
const getGridImage = (item: StoreItem): string => getImage(config.app.download_path, item) || '';
const showRetryAction = (item: StoreItem): boolean => !item.filename && !isDownloadSkipped(item);
const historyCardClass = (item: StoreItem): string => {
if (item.status === 'error') {
return 'border-error';
@ -1038,6 +1040,9 @@ const clearIncomplete = async () => {
const setIcon = (item: StoreItem) => {
if ('finished' === item.status) {
if (isDownloadSkipped(item)) {
return 'i-lucide-ban';
}
if (!item.filename) {
return 'i-lucide-triangle-alert';
}
@ -1063,6 +1068,9 @@ const setIcon = (item: StoreItem) => {
const setIconColor = (item: StoreItem) => {
if ('finished' === item.status) {
if (isDownloadSkipped(item)) {
return 'text-info';
}
if (!item.filename) {
return 'text-warning';
}
@ -1084,6 +1092,9 @@ const setIconColor = (item: StoreItem) => {
const setStatus = (item: StoreItem) => {
if ('finished' === item.status) {
if (isDownloadSkipped(item)) {
return 'Download skipped';
}
if (item.extras?.is_premiere) {
return 'Premiered';
}

View file

@ -88,7 +88,7 @@
v-model="bgOpacityModel"
:min="0.5"
:max="1"
:step="0.05"
:step="0.01"
size="lg"
class="w-full"
/>

View file

@ -445,6 +445,7 @@ import {
ag,
encodePath,
formatTime,
isDownloadSkipped,
makeDownload,
request,
stripPath,
@ -812,6 +813,10 @@ const getStatusLabel = (item: StoreItem): string => {
return 'Queued';
}
if (isDownloadSkipped(item)) {
return 'Download skipped';
}
if (item.status === 'error' && item.filename) {
return 'Partial Error';
}
@ -824,6 +829,10 @@ const getStatusColor = (item: StoreItem): 'neutral' | 'info' | 'success' | 'erro
return 'neutral';
}
if (isDownloadSkipped(item)) {
return 'info';
}
if (item.status === 'error' && item.filename) {
return 'warning';
}

View file

@ -74,37 +74,44 @@
:ui="dashboardSidebarUi"
>
<template #header="{ collapsed }">
<div
class="flex w-full min-w-0 items-center gap-2"
:class="collapsed ? 'justify-center' : ''"
>
<span
class="inline-flex shrink-0 items-center justify-center transition-all duration-200"
:class="
collapsed
? 'size-10 rounded-xl bg-elevated/80 ring ring-default shadow-xs'
: ''
"
<UTooltip :text="connectionStatusLabel">
<NuxtLink
to="/"
class="flex w-full min-w-0 items-center gap-2 rounded-xl transition-colors hover:bg-elevated/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="collapsed ? 'justify-center p-1' : 'px-1.5 py-1'"
aria-label="Go to home"
>
<UIcon
v-if="'connecting' === socket.connectionStatus"
name="i-lucide-loader-circle"
:class="[collapsed ? 'size-6' : 'size-4', 'animate-spin']"
/>
<UIcon v-else name="i-lucide-house" :class="collapsed ? 'size-6' : 'size-4'" />
</span>
<span
class="relative inline-flex shrink-0 items-center justify-center transition-all duration-200"
:class="
collapsed
? 'size-10 rounded-xl bg-elevated/80 ring ring-default shadow-xs'
: 'size-9 rounded-lg'
"
>
<img
:src="uri('/images/favicon.png')"
alt="YTPTube"
class="rounded-lg object-contain"
:class="collapsed ? 'size-6' : 'size-5'"
/>
<span
aria-hidden="true"
class="absolute right-0 bottom-0 size-2.5 rounded-full ring-2 ring-default"
:class="connectionStatusDotClass"
/>
</span>
<div v-if="false === collapsed" class="min-w-0">
<UTooltip :text="connectionStatusLabel">
<div v-if="false === collapsed" class="min-w-0">
<p class="truncate text-sm font-semibold" :class="connectionStatusColor">
YTPTube
</p>
</UTooltip>
<p v-if="config?.app?.instance_title" class="truncate text-xs text-toned">
{{ config.app.instance_title }}
</p>
</div>
</div>
<p v-if="config?.app?.instance_title" class="truncate text-xs text-toned">
{{ config.app.instance_title }}
</p>
</div>
</NuxtLink>
</UTooltip>
</template>
<template #default="{ collapsed }">
@ -1072,6 +1079,18 @@ const connectionStatusColor = computed(() => {
}
});
const connectionStatusDotClass = computed(() => {
switch (socket.connectionStatus) {
case 'connected':
return 'bg-success';
case 'connecting':
return 'bg-warning';
case 'disconnected':
default:
return 'bg-error';
}
});
const connectionStatusLabel = computed(() => {
switch (socket.connectionStatus) {
case 'connected':

View file

@ -3,16 +3,18 @@
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-file-text" class="size-5 text-toned" />
<UIcon
name="i-lucide-file-text"
:class="[
'size-5 transition-colors',
loading ? 'text-info' : 'animate-pulse text-success',
]"
:title="loading ? 'Loading history' : 'Live stream active'"
:aria-label="loading ? 'Loading history' : 'Live stream active'"
/>
<span>Logs</span>
<UBadge :color="loading ? 'info' : 'success'" variant="soft" size="sm">
{{ loading ? 'Loading history' : 'Live stream' }}
</UBadge>
<UBadge :color="autoScroll ? 'success' : 'neutral'" variant="soft" size="sm">
{{ autoScroll ? 'Auto-follow' : 'Manual scroll' }}
</UBadge>
<UBadge v-if="loading" color="info" variant="soft" size="sm">Loading history</UBadge>
<UBadge color="neutral" variant="soft" size="sm">
{{ filteredLogs.length }} shown
@ -77,19 +79,24 @@
Filter
</UButton>
<USwitch
v-model="textWrap"
color="primary"
<UButton
color="neutral"
:variant="textWrap ? 'soft' : 'outline'"
size="sm"
:label="textWrap ? 'Wrap lines' : 'Horizontal scroll'"
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
/>
icon="i-lucide-wrap-text"
:aria-pressed="textWrap"
:title="textWrap ? 'Wrap lines enabled' : 'Wrap lines disabled'"
:class="['transition-all', textWrap ? '-translate-y-px ring ring-default shadow-xs' : '']"
@click="textWrap = !textWrap"
>
Wrap lines
</UButton>
</div>
</div>
<UPageCard variant="naked" :ui="pageCardUi">
<template #body>
<div class="space-y-3">
<div class="w-full min-w-0 max-w-full space-y-3">
<div class="flex flex-wrap items-center justify-end gap-2 text-xs text-toned">
<span v-if="searchTerm">
Query: <code>{{ searchTerm }}</code>
@ -100,57 +107,69 @@
</span>
</div>
<div ref="logContainer" class="logbox overflow-auto" @scroll.passive="handleScroll">
<div class="w-full min-w-0 max-w-full overflow-hidden">
<div
class="min-w-full space-y-2 font-mono text-[12px] leading-6 text-default"
role="log"
aria-live="polite"
ref="logContainer"
class="logbox overflow-x-auto overflow-y-auto overscroll-x-contain"
@scroll.passive="handleScroll"
>
<div v-if="reachedEnd && !hasActiveFilter" class="flex justify-center">
<div
class="rounded-full border border-default bg-muted/40 px-3 py-1 text-[11px] text-toned"
>
No older lines remain in this file.
</div>
</div>
<div v-if="filteredLogs.length > 0" class="space-y-1.5">
<article
v-for="(entry, index) in filteredLogs"
:key="entry.log.id"
:class="logRowClass(entry, index)"
>
<span class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
{{ logTimeLabel(entry.log.datetime) }}
</span>
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
{{ entry.log.line }}
</p>
</article>
</div>
<div
v-else
class="rounded-xl border border-default bg-muted/20 px-4 py-6 text-center"
:class="[
'space-y-2 font-mono text-[12px] leading-6 text-default',
textWrap ? 'w-full min-w-0' : 'w-max min-w-full',
]"
role="log"
aria-live="polite"
>
<div class="space-y-2">
<p class="text-sm font-semibold text-highlighted">
{{
hasActiveFilter
? 'No log lines match the current filter.'
: 'No log lines are available yet.'
}}
</p>
<p v-if="hasActiveFilter" class="text-xs text-toned">
Try a different term or clear <code>{{ query }}</code
>.
</p>
<div v-if="reachedEnd && !hasActiveFilter" class="flex justify-center">
<div
class="rounded-full border border-default bg-muted/40 px-3 py-1 text-[11px] text-toned"
>
No older lines remain in this file.
</div>
</div>
</div>
<div ref="bottomMarker" />
<div v-if="filteredLogs.length > 0" class="space-y-1.5">
<article
v-for="(entry, index) in filteredLogs"
:key="entry.log.id"
:class="[
logRowClass(entry, index),
textWrap ? 'log-row--wrap-fit' : 'log-row--nowrap-fit',
]"
>
<span class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
{{ logTimeLabel(entry.log.datetime) }}
</span>
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
{{ entry.log.line }}
</p>
</article>
</div>
<div
v-else
class="rounded-xl border border-default bg-muted/20 px-4 py-6 text-center"
>
<div class="space-y-2">
<p class="text-sm font-semibold text-highlighted">
{{
hasActiveFilter
? 'No log lines match the current filter.'
: 'No log lines are available yet.'
}}
</p>
<p v-if="hasActiveFilter" class="text-xs text-toned">
Try a different term or clear <code>{{ query }}</code
>.
</p>
</div>
</div>
<div ref="bottomMarker" />
</div>
</div>
</div>
</div>
@ -195,10 +214,10 @@ const autoScroll = ref(true);
const reachedEnd = ref(false);
const pageCardUi = {
root: 'w-full bg-transparent',
container: 'w-full p-4 sm:p-5',
wrapper: 'w-full items-stretch',
body: 'w-full',
root: 'w-full min-w-0 max-w-full bg-transparent',
container: 'w-full min-w-0 max-w-full p-4 sm:p-5',
wrapper: 'w-full min-w-0 items-stretch',
body: 'w-full min-w-0 max-w-full overflow-hidden',
};
const query = ref<string>(
@ -511,7 +530,9 @@ useHead({ title: 'Logs' });
<style scoped>
.logbox {
min-width: 100%;
width: 100%;
min-width: 0;
max-width: 100%;
min-height: 55vh;
max-height: 60vh;
background: transparent;
@ -520,10 +541,12 @@ useHead({ title: 'Logs' });
.log-row {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 0.75rem;
border: 1px solid transparent;
border-radius: 0.75rem;
box-sizing: border-box;
padding: 0.625rem 0.75rem;
background: var(--ui-bg);
transition:
@ -531,6 +554,15 @@ useHead({ title: 'Logs' });
border-color 0.15s ease;
}
.log-row--wrap-fit {
width: 100%;
}
.log-row--nowrap-fit {
min-width: 100%;
width: max-content;
}
.log-row--alt {
background: var(--ui-bg-elevated);
}

View file

@ -70,6 +70,8 @@ type StoreItem = {
subtitle?: Array<sideCarSubtitle>;
image?: Array<SideCar>;
};
/** If the primary media download was intentionally skipped */
download_skipped?: boolean;
/** Extras for the item */
extras: {
/** Which channel the item belongs to */

View file

@ -793,6 +793,10 @@ const shortPath = (path: string, prefix: string = '...'): string => {
return `${prefix}/${parts.at(-1)}${hasTrailingSlash ? '/' : ''}`;
};
const isDownloadSkipped = (
item: Pick<StoreItem, 'status' | 'download_skipped'> | null | undefined,
): boolean => Boolean(item && item.status === 'finished' && item.download_skipped);
/**
* Recursively test if a value (including nested objects/arrays) contains a query string.
* - Plain queries match keys or values (case-insensitive).
@ -1028,6 +1032,7 @@ export {
enableOpacity,
stripPath,
shortPath,
isDownloadSkipped,
deepIncludes,
getPath,
getImage,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -388,6 +388,16 @@ describe('data conversion helpers', () => {
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8');
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8');
});
it('isDownloadSkipped detects finished skipped-download items', () => {
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: true } as any)).toBe(true);
});
it('isDownloadSkipped ignores unfinished or unflagged items', () => {
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: false } as any)).toBe(false);
expect(utils.isDownloadSkipped({ status: 'downloading', download_skipped: true } as any)).toBe(false);
expect(utils.isDownloadSkipped(undefined as any)).toBe(false);
});
});
describe('dom and browser helpers', () => {