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
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:
commit
2139e20332
17 changed files with 422 additions and 108 deletions
|
|
@ -107,9 +107,11 @@ This project is not affiliated with yt-dlp or any other service.
|
|||
It’s a personal project designed to make downloading videos from the internet more convenient. It’s 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 I’m 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 I’m 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 don’t 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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@
|
|||
v-model="bgOpacityModel"
|
||||
:min="0.5"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
:step="0.01"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
2
ui/app/types/store.d.ts
vendored
2
ui/app/types/store.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue