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
|
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.
|
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
|
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
|
refined, but I’m happy with it as it is. You can, however, create and load your own UI for complete customization.
|
||||||
plan to refactor the UI/UX in the future using [Nuxt/ui](https://ui.nuxt.com/).
|
|
||||||
|
|
||||||
Contributions are welcome, but I may decline changes that don’t align with my vision for the project. Unsolicited pull
|
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.
|
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",
|
"quiet": "-q, --quiet",
|
||||||
"no_warnings": "--no-warnings",
|
"no_warnings": "--no-warnings",
|
||||||
"skip_download": "--skip-download",
|
|
||||||
"forceprint": "-O, --print",
|
"forceprint": "-O, --print",
|
||||||
"simulate": "--simulate",
|
|
||||||
"noprogress": "--no-progress",
|
"noprogress": "--no-progress",
|
||||||
"wait_for_video": "--wait-for-video",
|
"wait_for_video": "--wait-for-video",
|
||||||
"progress_delta": " --progress-delta",
|
"progress_delta": " --progress-delta",
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,8 @@ class ItemDTO:
|
||||||
""" The archive ID of the item. """
|
""" The archive ID of the item. """
|
||||||
sidecar: dict = field(default_factory=dict)
|
sidecar: dict = field(default_factory=dict)
|
||||||
""" Sidecar data associated with the item. """
|
""" Sidecar data associated with the item. """
|
||||||
|
download_skipped: bool = False
|
||||||
|
""" True when yt-dlp intentionally skips the primary media download. """
|
||||||
|
|
||||||
# yt-dlp injected fields.
|
# yt-dlp injected fields.
|
||||||
tmpfilename: str | None = None
|
tmpfilename: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -92,9 +92,11 @@ class Download:
|
||||||
download using yt-dlp.
|
download using yt-dlp.
|
||||||
"""
|
"""
|
||||||
cookie_file: Path | None = None
|
cookie_file: Path | None = None
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
download_skipped = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
params: dict = (
|
params = (
|
||||||
self.info.get_ytdlp_opts()
|
self.info.get_ytdlp_opts()
|
||||||
.add(
|
.add(
|
||||||
config={
|
config={
|
||||||
|
|
@ -116,6 +118,7 @@ class Download:
|
||||||
)
|
)
|
||||||
.get_all()
|
.get_all()
|
||||||
)
|
)
|
||||||
|
download_skipped = bool(params.get("skip_download") or params.get("simulate"))
|
||||||
|
|
||||||
params.update(
|
params.update(
|
||||||
{
|
{
|
||||||
|
|
@ -212,7 +215,7 @@ class Download:
|
||||||
|
|
||||||
signal.signal(signal.SIGUSR1, mark_cancelled)
|
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:
|
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
|
||||||
self.logger.debug(f"Downloading '{self.info.url}' using pre-info.")
|
self.logger.debug(f"Downloading '{self.info.url}' using pre-info.")
|
||||||
|
|
@ -222,7 +225,18 @@ class Download:
|
||||||
{
|
{
|
||||||
k: v
|
k: v
|
||||||
for k, v in self.info.extras.items()
|
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}")
|
self.logger.debug(f"Downloading using url: {self.info.url}")
|
||||||
ret = cls.download(url_list=[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:
|
except yt_dlp.utils.ExistingVideoReached as exc:
|
||||||
self.logger.error(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:
|
except Exception as exc:
|
||||||
self.logger.exception(exc)
|
self.logger.exception(exc)
|
||||||
self.logger.error(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:
|
finally:
|
||||||
self.status_queue.put(Terminator())
|
self.status_queue.put(Terminator())
|
||||||
if cookie_file and cookie_file.exists():
|
if cookie_file and cookie_file.exists():
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ class StatusTracker:
|
||||||
self.tmpfilename = status.get("tmpfilename")
|
self.tmpfilename = status.get("tmpfilename")
|
||||||
|
|
||||||
self.info.status = status.get("status", self.info.status)
|
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.msg = status.get("msg")
|
||||||
self.info.postprocessor = status.get("postprocessor", None)
|
self.info.postprocessor = status.get("postprocessor", None)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ class TestDownloadHooks:
|
||||||
max_workers = 1
|
max_workers = 1
|
||||||
temp_keep = False
|
temp_keep = False
|
||||||
temp_disabled = True
|
temp_disabled = True
|
||||||
download_info_expires = 3600
|
download_info_expires = 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_instance():
|
def get_instance():
|
||||||
|
|
@ -148,7 +148,7 @@ class TestDownloadStale:
|
||||||
max_workers = 1
|
max_workers = 1
|
||||||
temp_keep = False
|
temp_keep = False
|
||||||
temp_disabled = True
|
temp_disabled = True
|
||||||
download_info_expires = 3600
|
download_info_expires = 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_instance():
|
def get_instance():
|
||||||
|
|
@ -272,6 +272,130 @@ class TestDownloadStale:
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadFlow:
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_download_flow_inline_process(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
async def test_download_flow_inline_process(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
class Cfg:
|
class Cfg:
|
||||||
|
|
@ -724,6 +848,14 @@ class TestStatusTracker:
|
||||||
await st.process_status_update(status)
|
await st.process_status_update(status)
|
||||||
assert st.info.status == "downloading", "Should update info 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
|
@pytest.mark.asyncio
|
||||||
async def test_process_status_update_sets_tmpfilename(self, mock_config: dict) -> None:
|
async def test_process_status_update_sets_tmpfilename(self, mock_config: dict) -> None:
|
||||||
st = StatusTracker(**mock_config)
|
st = StatusTracker(**mock_config)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,23 @@ class TestItemDTO:
|
||||||
assert dto.is_archivable is True
|
assert dto.is_archivable is True
|
||||||
assert dto.is_archived 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")
|
@patch("app.library.ItemDTO.archive_read")
|
||||||
def test_serialize_triggers_archive_status_when_finished(self, mock_read):
|
def test_serialize_triggers_archive_status_when_finished(self, mock_read):
|
||||||
# Given a finished item with archive info
|
# Given a finished item with archive info
|
||||||
|
|
@ -125,12 +142,32 @@ class TestItemDTO:
|
||||||
for key in ItemDTO.removed_fields():
|
for key in ItemDTO.removed_fields():
|
||||||
assert key not in data
|
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")
|
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||||
def test_get_ytdlp_opts_uses_preset_and_cli(self, mock_opts):
|
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.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.add_cli.return_value = mock_opts.get_instance.return_value
|
||||||
|
|
||||||
dto = ItemDTO(id="id", title="t", url="u", folder="f", preset="p", cli="--x")
|
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()
|
opts = dto.get_ytdlp_opts()
|
||||||
|
|
||||||
mock_opts.get_instance.assert_called_once()
|
mock_opts.get_instance.assert_called_once()
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,15 @@ async def make_store() -> SqliteStore:
|
||||||
return store
|
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(
|
return ItemDTO(
|
||||||
id=f"vid{idx}",
|
id=f"vid{idx}",
|
||||||
title=f"Video {idx}",
|
title=f"Video {idx}",
|
||||||
url=f"https://example.com/{idx}",
|
url=f"https://example.com/{idx}",
|
||||||
folder="/downloads",
|
folder="/downloads",
|
||||||
status=status,
|
status=status,
|
||||||
|
cli=cli,
|
||||||
|
download_skipped=download_skipped,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -90,6 +92,22 @@ async def test_enqueue_upsert_and_fetch_saved():
|
||||||
await store.close()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_delete_and_bulk_delete():
|
async def test_delete_and_bulk_delete():
|
||||||
store = await make_store()
|
store = await make_store()
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@
|
||||||
<td class="w-44 px-3 py-3 align-top whitespace-nowrap">
|
<td class="w-44 px-3 py-3 align-top whitespace-nowrap">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="!item.filename"
|
v-if="showRetryAction(item)"
|
||||||
color="warning"
|
color="warning"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|
@ -442,7 +442,7 @@
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="!item.filename"
|
v-if="showRetryAction(item)"
|
||||||
color="warning"
|
color="warning"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="i-lucide-rotate-cw"
|
icon="i-lucide-rotate-cw"
|
||||||
|
|
@ -616,7 +616,7 @@ import { useStorage, useIntersectionObserver } from '@vueuse/core';
|
||||||
import { useDialog } from '~/composables/useDialog';
|
import { useDialog } from '~/composables/useDialog';
|
||||||
import type { StoreItem } from '~/types/store';
|
import type { StoreItem } from '~/types/store';
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
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';
|
import type { item_request } from '~/types/item';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
|
|
@ -816,6 +816,8 @@ const getListImage = (item: StoreItem): string =>
|
||||||
|
|
||||||
const getGridImage = (item: StoreItem): string => getImage(config.app.download_path, item) || '';
|
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 => {
|
const historyCardClass = (item: StoreItem): string => {
|
||||||
if (item.status === 'error') {
|
if (item.status === 'error') {
|
||||||
return 'border-error';
|
return 'border-error';
|
||||||
|
|
@ -1038,6 +1040,9 @@ const clearIncomplete = async () => {
|
||||||
|
|
||||||
const setIcon = (item: StoreItem) => {
|
const setIcon = (item: StoreItem) => {
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
|
if (isDownloadSkipped(item)) {
|
||||||
|
return 'i-lucide-ban';
|
||||||
|
}
|
||||||
if (!item.filename) {
|
if (!item.filename) {
|
||||||
return 'i-lucide-triangle-alert';
|
return 'i-lucide-triangle-alert';
|
||||||
}
|
}
|
||||||
|
|
@ -1063,6 +1068,9 @@ const setIcon = (item: StoreItem) => {
|
||||||
|
|
||||||
const setIconColor = (item: StoreItem) => {
|
const setIconColor = (item: StoreItem) => {
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
|
if (isDownloadSkipped(item)) {
|
||||||
|
return 'text-info';
|
||||||
|
}
|
||||||
if (!item.filename) {
|
if (!item.filename) {
|
||||||
return 'text-warning';
|
return 'text-warning';
|
||||||
}
|
}
|
||||||
|
|
@ -1084,6 +1092,9 @@ const setIconColor = (item: StoreItem) => {
|
||||||
|
|
||||||
const setStatus = (item: StoreItem) => {
|
const setStatus = (item: StoreItem) => {
|
||||||
if ('finished' === item.status) {
|
if ('finished' === item.status) {
|
||||||
|
if (isDownloadSkipped(item)) {
|
||||||
|
return 'Download skipped';
|
||||||
|
}
|
||||||
if (item.extras?.is_premiere) {
|
if (item.extras?.is_premiere) {
|
||||||
return 'Premiered';
|
return 'Premiered';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
v-model="bgOpacityModel"
|
v-model="bgOpacityModel"
|
||||||
:min="0.5"
|
:min="0.5"
|
||||||
:max="1"
|
:max="1"
|
||||||
:step="0.05"
|
:step="0.01"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,7 @@ import {
|
||||||
ag,
|
ag,
|
||||||
encodePath,
|
encodePath,
|
||||||
formatTime,
|
formatTime,
|
||||||
|
isDownloadSkipped,
|
||||||
makeDownload,
|
makeDownload,
|
||||||
request,
|
request,
|
||||||
stripPath,
|
stripPath,
|
||||||
|
|
@ -812,6 +813,10 @@ const getStatusLabel = (item: StoreItem): string => {
|
||||||
return 'Queued';
|
return 'Queued';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDownloadSkipped(item)) {
|
||||||
|
return 'Download skipped';
|
||||||
|
}
|
||||||
|
|
||||||
if (item.status === 'error' && item.filename) {
|
if (item.status === 'error' && item.filename) {
|
||||||
return 'Partial Error';
|
return 'Partial Error';
|
||||||
}
|
}
|
||||||
|
|
@ -824,6 +829,10 @@ const getStatusColor = (item: StoreItem): 'neutral' | 'info' | 'success' | 'erro
|
||||||
return 'neutral';
|
return 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDownloadSkipped(item)) {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
if (item.status === 'error' && item.filename) {
|
if (item.status === 'error' && item.filename) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,37 +74,44 @@
|
||||||
:ui="dashboardSidebarUi"
|
:ui="dashboardSidebarUi"
|
||||||
>
|
>
|
||||||
<template #header="{ collapsed }">
|
<template #header="{ collapsed }">
|
||||||
<div
|
<UTooltip :text="connectionStatusLabel">
|
||||||
class="flex w-full min-w-0 items-center gap-2"
|
<NuxtLink
|
||||||
:class="collapsed ? 'justify-center' : ''"
|
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"
|
||||||
<span
|
:class="collapsed ? 'justify-center p-1' : 'px-1.5 py-1'"
|
||||||
class="inline-flex shrink-0 items-center justify-center transition-all duration-200"
|
aria-label="Go to home"
|
||||||
:class="
|
|
||||||
collapsed
|
|
||||||
? 'size-10 rounded-xl bg-elevated/80 ring ring-default shadow-xs'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<UIcon
|
<span
|
||||||
v-if="'connecting' === socket.connectionStatus"
|
class="relative inline-flex shrink-0 items-center justify-center transition-all duration-200"
|
||||||
name="i-lucide-loader-circle"
|
:class="
|
||||||
:class="[collapsed ? 'size-6' : 'size-4', 'animate-spin']"
|
collapsed
|
||||||
/>
|
? 'size-10 rounded-xl bg-elevated/80 ring ring-default shadow-xs'
|
||||||
<UIcon v-else name="i-lucide-house" :class="collapsed ? 'size-6' : 'size-4'" />
|
: 'size-9 rounded-lg'
|
||||||
</span>
|
"
|
||||||
|
>
|
||||||
|
<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">
|
<div v-if="false === collapsed" class="min-w-0">
|
||||||
<UTooltip :text="connectionStatusLabel">
|
|
||||||
<p class="truncate text-sm font-semibold" :class="connectionStatusColor">
|
<p class="truncate text-sm font-semibold" :class="connectionStatusColor">
|
||||||
YTPTube
|
YTPTube
|
||||||
</p>
|
</p>
|
||||||
</UTooltip>
|
<p v-if="config?.app?.instance_title" class="truncate text-xs text-toned">
|
||||||
<p v-if="config?.app?.instance_title" class="truncate text-xs text-toned">
|
{{ config.app.instance_title }}
|
||||||
{{ config.app.instance_title }}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</UTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ collapsed }">
|
<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(() => {
|
const connectionStatusLabel = computed(() => {
|
||||||
switch (socket.connectionStatus) {
|
switch (socket.connectionStatus) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@
|
||||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
<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="min-w-0 space-y-1">
|
||||||
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
|
<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>
|
<span>Logs</span>
|
||||||
|
|
||||||
<UBadge :color="loading ? 'info' : 'success'" variant="soft" size="sm">
|
<UBadge v-if="loading" color="info" variant="soft" size="sm">Loading history</UBadge>
|
||||||
{{ loading ? 'Loading history' : 'Live stream' }}
|
|
||||||
</UBadge>
|
|
||||||
|
|
||||||
<UBadge :color="autoScroll ? 'success' : 'neutral'" variant="soft" size="sm">
|
|
||||||
{{ autoScroll ? 'Auto-follow' : 'Manual scroll' }}
|
|
||||||
</UBadge>
|
|
||||||
|
|
||||||
<UBadge color="neutral" variant="soft" size="sm">
|
<UBadge color="neutral" variant="soft" size="sm">
|
||||||
{{ filteredLogs.length }} shown
|
{{ filteredLogs.length }} shown
|
||||||
|
|
@ -77,19 +79,24 @@
|
||||||
Filter
|
Filter
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<USwitch
|
<UButton
|
||||||
v-model="textWrap"
|
color="neutral"
|
||||||
color="primary"
|
:variant="textWrap ? 'soft' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
:label="textWrap ? 'Wrap lines' : 'Horizontal scroll'"
|
icon="i-lucide-wrap-text"
|
||||||
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UPageCard variant="naked" :ui="pageCardUi">
|
<UPageCard variant="naked" :ui="pageCardUi">
|
||||||
<template #body>
|
<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">
|
<div class="flex flex-wrap items-center justify-end gap-2 text-xs text-toned">
|
||||||
<span v-if="searchTerm">
|
<span v-if="searchTerm">
|
||||||
Query: <code>{{ searchTerm }}</code>
|
Query: <code>{{ searchTerm }}</code>
|
||||||
|
|
@ -100,57 +107,69 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="logContainer" class="logbox overflow-auto" @scroll.passive="handleScroll">
|
<div class="w-full min-w-0 max-w-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="min-w-full space-y-2 font-mono text-[12px] leading-6 text-default"
|
ref="logContainer"
|
||||||
role="log"
|
class="logbox overflow-x-auto overflow-y-auto overscroll-x-contain"
|
||||||
aria-live="polite"
|
@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
|
<div
|
||||||
v-else
|
:class="[
|
||||||
class="rounded-xl border border-default bg-muted/20 px-4 py-6 text-center"
|
'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">
|
<div v-if="reachedEnd && !hasActiveFilter" class="flex justify-center">
|
||||||
<p class="text-sm font-semibold text-highlighted">
|
<div
|
||||||
{{
|
class="rounded-full border border-default bg-muted/40 px-3 py-1 text-[11px] text-toned"
|
||||||
hasActiveFilter
|
>
|
||||||
? 'No log lines match the current filter.'
|
No older lines remain in this file.
|
||||||
: 'No log lines are available yet.'
|
</div>
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="hasActiveFilter" class="text-xs text-toned">
|
|
||||||
Try a different term or clear <code>{{ query }}</code
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,10 +214,10 @@ const autoScroll = ref(true);
|
||||||
const reachedEnd = ref(false);
|
const reachedEnd = ref(false);
|
||||||
|
|
||||||
const pageCardUi = {
|
const pageCardUi = {
|
||||||
root: 'w-full bg-transparent',
|
root: 'w-full min-w-0 max-w-full bg-transparent',
|
||||||
container: 'w-full p-4 sm:p-5',
|
container: 'w-full min-w-0 max-w-full p-4 sm:p-5',
|
||||||
wrapper: 'w-full items-stretch',
|
wrapper: 'w-full min-w-0 items-stretch',
|
||||||
body: 'w-full',
|
body: 'w-full min-w-0 max-w-full overflow-hidden',
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = ref<string>(
|
const query = ref<string>(
|
||||||
|
|
@ -511,7 +530,9 @@ useHead({ title: 'Logs' });
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.logbox {
|
.logbox {
|
||||||
min-width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 55vh;
|
min-height: 55vh;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -520,10 +541,12 @@ useHead({ title: 'Logs' });
|
||||||
|
|
||||||
.log-row {
|
.log-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 0.625rem 0.75rem;
|
padding: 0.625rem 0.75rem;
|
||||||
background: var(--ui-bg);
|
background: var(--ui-bg);
|
||||||
transition:
|
transition:
|
||||||
|
|
@ -531,6 +554,15 @@ useHead({ title: 'Logs' });
|
||||||
border-color 0.15s ease;
|
border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-row--wrap-fit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--nowrap-fit {
|
||||||
|
min-width: 100%;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
.log-row--alt {
|
.log-row--alt {
|
||||||
background: var(--ui-bg-elevated);
|
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>;
|
subtitle?: Array<sideCarSubtitle>;
|
||||||
image?: Array<SideCar>;
|
image?: Array<SideCar>;
|
||||||
};
|
};
|
||||||
|
/** If the primary media download was intentionally skipped */
|
||||||
|
download_skipped?: boolean;
|
||||||
/** Extras for the item */
|
/** Extras for the item */
|
||||||
extras: {
|
extras: {
|
||||||
/** Which channel the item belongs to */
|
/** Which channel the item belongs to */
|
||||||
|
|
|
||||||
|
|
@ -793,6 +793,10 @@ const shortPath = (path: string, prefix: string = '...'): string => {
|
||||||
return `${prefix}/${parts.at(-1)}${hasTrailingSlash ? '/' : ''}`;
|
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.
|
* Recursively test if a value (including nested objects/arrays) contains a query string.
|
||||||
* - Plain queries match keys or values (case-insensitive).
|
* - Plain queries match keys or values (case-insensitive).
|
||||||
|
|
@ -1028,6 +1032,7 @@ export {
|
||||||
enableOpacity,
|
enableOpacity,
|
||||||
stripPath,
|
stripPath,
|
||||||
shortPath,
|
shortPath,
|
||||||
|
isDownloadSkipped,
|
||||||
deepIncludes,
|
deepIncludes,
|
||||||
getPath,
|
getPath,
|
||||||
getImage,
|
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');
|
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8');
|
||||||
expect(url).toBe('/base-path/api/player/m3u8/video/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', () => {
|
describe('dom and browser helpers', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue