mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
@@ -7,8 +7,9 @@ import tempfile
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import time
|
||||||
|
|
||||||
from ytdl import DownloadQueue
|
from ytdl import DownloadInfo, DownloadQueue
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -386,3 +387,243 @@ async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
|||||||
download = dq.pending.get("https://example.com/clip")
|
download = dq.pending.get("https://example.com/clip")
|
||||||
assert download.info.clip_start == 10.0
|
assert download.info.clip_start == 10.0
|
||||||
assert download.info.clip_end == 99.5
|
assert download.info.clip_end == 99.5
|
||||||
|
|
||||||
|
|
||||||
|
def _upcoming_entry(url: str, *, release_timestamp: float | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "live1",
|
||||||
|
"title": "Upcoming Stream",
|
||||||
|
"url": url,
|
||||||
|
"webpage_url": url,
|
||||||
|
"live_status": "is_upcoming",
|
||||||
|
"release_timestamp": release_timestamp if release_timestamp is not None else time.time() + 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_upcoming_stream_scheduled_without_starting(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-upcoming"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
result = await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video",
|
||||||
|
"auto",
|
||||||
|
"any",
|
||||||
|
"best",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert dq.queue.exists(url)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
assert download.info.status == "scheduled"
|
||||||
|
assert download.info.live_status == "is_upcoming"
|
||||||
|
assert download.info.live_release_timestamp is not None
|
||||||
|
start_mock.assert_not_called()
|
||||||
|
assert url in dq._scheduled_probe_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_scheduled_starts_when_live(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-upcoming"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video",
|
||||||
|
"auto",
|
||||||
|
"any",
|
||||||
|
"best",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
|
||||||
|
def fake_probe_extract(self, probe_url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||||
|
assert probe_url == url
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "live1",
|
||||||
|
"title": "Live Now",
|
||||||
|
"url": url,
|
||||||
|
"webpage_url": url,
|
||||||
|
"live_status": "is_live",
|
||||||
|
"formats": [{"format_id": "22"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_probe_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
assert url not in dq._scheduled_probe_at
|
||||||
|
assert download.info.live_status == "is_live"
|
||||||
|
assert download.info.status == "pending"
|
||||||
|
start_mock.assert_called_once_with(download)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_scheduled_re_registers_monitor(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-restart"
|
||||||
|
release = time.time() + 7200
|
||||||
|
|
||||||
|
info = DownloadInfo(
|
||||||
|
id="live1",
|
||||||
|
title="Upcoming Stream",
|
||||||
|
url=url,
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error=None,
|
||||||
|
entry=None,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
live_status="is_upcoming",
|
||||||
|
live_release_timestamp=release,
|
||||||
|
)
|
||||||
|
info.status = "scheduled"
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq._DownloadQueue__add_download(info, True)
|
||||||
|
|
||||||
|
assert dq.queue.exists(url)
|
||||||
|
assert dq.queue.get(url).info.status == "scheduled"
|
||||||
|
assert url in dq._scheduled_probe_at
|
||||||
|
start_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_transient_error_retries_without_failing(dq_env):
|
||||||
|
"""A single probe failure must not abandon the scheduled stream."""
|
||||||
|
import ytdl
|
||||||
|
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-transient"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video", "auto", "any", "best", "", "", 0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
|
||||||
|
def boom(self, *args, **kwargs):
|
||||||
|
raise ytdl.yt_dlp.utils.YoutubeDLError("temporary network glitch")
|
||||||
|
|
||||||
|
before = time.time()
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
# Still scheduled, still monitored, probe rescheduled into the future.
|
||||||
|
assert download.info.status == "scheduled"
|
||||||
|
assert url in dq._scheduled_probe_at
|
||||||
|
assert dq._scheduled_probe_at[url] >= before
|
||||||
|
assert dq._scheduled_probe_failures[url] == 1
|
||||||
|
notifier.completed.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_gives_up_after_max_failures(dq_env):
|
||||||
|
import ytdl
|
||||||
|
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-dead"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video", "auto", "any", "best", "", "", 0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
|
||||||
|
def boom(self, *args, **kwargs):
|
||||||
|
raise ytdl.yt_dlp.utils.YoutubeDLError("stream was deleted")
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||||
|
for _ in range(ytdl._LIVE_PROBE_MAX_FAILURES):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
assert url not in dq._scheduled_probe_at
|
||||||
|
assert not dq.queue.exists(url)
|
||||||
|
assert dq.done.exists(url)
|
||||||
|
assert download.info.status == "error"
|
||||||
|
notifier.completed.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_recovers_after_transient_then_starts(dq_env):
|
||||||
|
"""A transient failure followed by a successful live probe should start the download."""
|
||||||
|
import ytdl
|
||||||
|
|
||||||
|
notifier = AsyncMock()
|
||||||
|
url = "https://example.com/live-recover"
|
||||||
|
start_mock = AsyncMock()
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq.add_entry(
|
||||||
|
_upcoming_entry(url),
|
||||||
|
"video", "auto", "any", "best", "", "", 0,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
download = dq.queue.get(url)
|
||||||
|
# The scheduling placeholder error is set on add.
|
||||||
|
assert download.info.error
|
||||||
|
|
||||||
|
def boom(self, *args, **kwargs):
|
||||||
|
raise ytdl.yt_dlp.utils.YoutubeDLError("temporary glitch")
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", boom):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
assert dq._scheduled_probe_failures[url] == 1
|
||||||
|
|
||||||
|
def live_now(self, *args, **kwargs):
|
||||||
|
return {
|
||||||
|
"_type": "video", "id": "live1", "title": "Live Now",
|
||||||
|
"url": url, "webpage_url": url, "live_status": "is_live",
|
||||||
|
"formats": [{"format_id": "22"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", live_now), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
|
||||||
|
await dq._probe_scheduled_download(download)
|
||||||
|
|
||||||
|
assert url not in dq._scheduled_probe_at
|
||||||
|
assert url not in dq._scheduled_probe_failures
|
||||||
|
assert download.info.status == "pending"
|
||||||
|
# Placeholder error/msg cleared now that a real download is starting.
|
||||||
|
assert download.info.error is None
|
||||||
|
assert download.info.msg is None
|
||||||
|
start_mock.assert_called_once_with(download)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seconds_until_next_probe_none_when_empty(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
assert dq._seconds_until_next_probe() is None
|
||||||
|
|||||||
+203
-8
@@ -24,6 +24,12 @@ from subscriptions import _entry_id
|
|||||||
|
|
||||||
log = logging.getLogger('ytdl')
|
log = logging.getLogger('ytdl')
|
||||||
|
|
||||||
|
_LIVE_CHECK_INTERVAL = 60
|
||||||
|
_LIVE_MAX_CHECK_INTERVAL = 3600
|
||||||
|
# Consecutive probe failures (network blips, rate limits, transient extractor
|
||||||
|
# errors) tolerated before a scheduled live download is abandoned as errored.
|
||||||
|
_LIVE_PROBE_MAX_FAILURES = 5
|
||||||
|
|
||||||
|
|
||||||
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
||||||
# sanitised when substituting playlist/channel titles into output templates so
|
# sanitised when substituting playlist/channel titles into output templates so
|
||||||
@@ -194,6 +200,8 @@ class DownloadInfo:
|
|||||||
ytdl_options_overrides=None,
|
ytdl_options_overrides=None,
|
||||||
clip_start=None,
|
clip_start=None,
|
||||||
clip_end=None,
|
clip_end=None,
|
||||||
|
live_status=None,
|
||||||
|
live_release_timestamp=None,
|
||||||
):
|
):
|
||||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||||
@@ -220,6 +228,8 @@ class DownloadInfo:
|
|||||||
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
||||||
self.clip_start = clip_start
|
self.clip_start = clip_start
|
||||||
self.clip_end = clip_end
|
self.clip_end = clip_end
|
||||||
|
self.live_status = live_status
|
||||||
|
self.live_release_timestamp = live_release_timestamp
|
||||||
self.subtitle_files = []
|
self.subtitle_files = []
|
||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
@@ -292,6 +302,10 @@ class DownloadInfo:
|
|||||||
self.clip_start = None
|
self.clip_start = None
|
||||||
if not hasattr(self, "clip_end"):
|
if not hasattr(self, "clip_end"):
|
||||||
self.clip_end = None
|
self.clip_end = None
|
||||||
|
if not hasattr(self, "live_status"):
|
||||||
|
self.live_status = None
|
||||||
|
if not hasattr(self, "live_release_timestamp"):
|
||||||
|
self.live_release_timestamp = None
|
||||||
|
|
||||||
|
|
||||||
_PERSISTED_DOWNLOAD_FIELDS = (
|
_PERSISTED_DOWNLOAD_FIELDS = (
|
||||||
@@ -313,6 +327,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
|||||||
"ytdl_options_overrides",
|
"ytdl_options_overrides",
|
||||||
"clip_start",
|
"clip_start",
|
||||||
"clip_end",
|
"clip_end",
|
||||||
|
"live_status",
|
||||||
|
"live_release_timestamp",
|
||||||
"status",
|
"status",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"error",
|
"error",
|
||||||
@@ -757,6 +773,10 @@ class DownloadQueue:
|
|||||||
self.done.load()
|
self.done.load()
|
||||||
self._add_generation = 0
|
self._add_generation = 0
|
||||||
self._canceled_urls = set() # URLs canceled during current playlist add
|
self._canceled_urls = set() # URLs canceled during current playlist add
|
||||||
|
self._scheduled_probe_at: dict[str, float] = {}
|
||||||
|
self._scheduled_probe_failures: dict[str, int] = {}
|
||||||
|
self._live_monitor_task: Optional[asyncio.Task] = None
|
||||||
|
self._live_monitor_wakeup = asyncio.Event()
|
||||||
|
|
||||||
def cancel_add(self):
|
def cancel_add(self):
|
||||||
self._add_generation += 1
|
self._add_generation += 1
|
||||||
@@ -772,9 +792,165 @@ class DownloadQueue:
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
log.info("Initializing DownloadQueue")
|
log.info("Initializing DownloadQueue")
|
||||||
|
self._start_live_monitor()
|
||||||
asyncio.create_task(self.__import_queue())
|
asyncio.create_task(self.__import_queue())
|
||||||
asyncio.create_task(self.__import_pending())
|
asyncio.create_task(self.__import_pending())
|
||||||
|
|
||||||
|
def _start_live_monitor(self) -> None:
|
||||||
|
if self._live_monitor_task is not None and not self._live_monitor_task.done():
|
||||||
|
return
|
||||||
|
self._live_monitor_task = asyncio.create_task(self._live_monitor_loop())
|
||||||
|
self._live_monitor_task.add_done_callback(
|
||||||
|
lambda t: log.error("Live monitor loop failed: %s", t.exception())
|
||||||
|
if not t.cancelled() and t.exception()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register_scheduled(self, download: Download) -> None:
|
||||||
|
self._scheduled_probe_at[download.info.url] = 0
|
||||||
|
self._scheduled_probe_failures.pop(download.info.url, None)
|
||||||
|
self._start_live_monitor()
|
||||||
|
self._wake_live_monitor()
|
||||||
|
|
||||||
|
def _unregister_scheduled(self, url: str) -> None:
|
||||||
|
self._scheduled_probe_at.pop(url, None)
|
||||||
|
self._scheduled_probe_failures.pop(url, None)
|
||||||
|
|
||||||
|
def _wake_live_monitor(self) -> None:
|
||||||
|
try:
|
||||||
|
self._live_monitor_wakeup.set()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _probe_interval_seconds(self, release_timestamp: Any) -> float:
|
||||||
|
if release_timestamp is not None:
|
||||||
|
try:
|
||||||
|
diff = float(release_timestamp) - time.time()
|
||||||
|
if diff > 0:
|
||||||
|
return max(_LIVE_CHECK_INTERVAL, min(diff, _LIVE_MAX_CHECK_INTERVAL))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return float(_LIVE_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
def _seconds_until_next_probe(self) -> Optional[float]:
|
||||||
|
"""Time until the earliest scheduled probe, or None when nothing is scheduled."""
|
||||||
|
if not self._scheduled_probe_at:
|
||||||
|
return None
|
||||||
|
return max(0.0, min(self._scheduled_probe_at.values()) - time.time())
|
||||||
|
|
||||||
|
async def _live_monitor_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
timeout = self._seconds_until_next_probe()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._live_monitor_wakeup.wait(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
self._live_monitor_wakeup.clear()
|
||||||
|
now = time.time()
|
||||||
|
due: list[Download] = []
|
||||||
|
for url, probe_at in list(self._scheduled_probe_at.items()):
|
||||||
|
if now < probe_at:
|
||||||
|
continue
|
||||||
|
if not self.queue.exists(url):
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
continue
|
||||||
|
download = self.queue.get(url)
|
||||||
|
if download.info.status != 'scheduled' or download.canceled:
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
continue
|
||||||
|
due.append(download)
|
||||||
|
for download in due:
|
||||||
|
try:
|
||||||
|
await self._probe_scheduled_download(download)
|
||||||
|
except Exception as exc:
|
||||||
|
# Defensive: _probe_scheduled_download handles its own errors,
|
||||||
|
# but never let an unexpected failure leave probe_at in the past
|
||||||
|
# (which would spin this loop) or kill the monitor task.
|
||||||
|
log.exception("Scheduled live probe crashed for %s: %s", download.info.url, exc)
|
||||||
|
if download.info.url in self._scheduled_probe_at:
|
||||||
|
self._scheduled_probe_at[download.info.url] = time.time() + _LIVE_CHECK_INTERVAL
|
||||||
|
|
||||||
|
async def _probe_scheduled_download(self, download: Download) -> None:
|
||||||
|
url = download.info.url
|
||||||
|
info = download.info
|
||||||
|
if info.status != 'scheduled' or download.canceled:
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry = await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
partial(
|
||||||
|
self.__extract_info,
|
||||||
|
url,
|
||||||
|
getattr(info, 'ytdl_options_presets', None),
|
||||||
|
getattr(info, 'ytdl_options_overrides', {}) or {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Treat all probe failures (transient network blips, rate limits,
|
||||||
|
# extractor errors) as recoverable up to a point: retry on the next
|
||||||
|
# interval and only give up after repeated consecutive failures so a
|
||||||
|
# momentary glitch doesn't abandon a stream the user is waiting for.
|
||||||
|
fails = self._scheduled_probe_failures.get(url, 0) + 1
|
||||||
|
self._scheduled_probe_failures[url] = fails
|
||||||
|
if fails >= _LIVE_PROBE_MAX_FAILURES:
|
||||||
|
log.warning(
|
||||||
|
"Giving up on scheduled live probe for %s after %d consecutive failures: %s",
|
||||||
|
info.title, fails, exc,
|
||||||
|
)
|
||||||
|
info.status = 'error'
|
||||||
|
info.msg = str(exc)
|
||||||
|
if not info.error:
|
||||||
|
info.error = str(exc)
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
self.queue.delete(url)
|
||||||
|
self.done.put(download)
|
||||||
|
await self.notifier.completed(info)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"Scheduled live probe failed for %s (attempt %d/%d), will retry: %s",
|
||||||
|
info.title, fails, _LIVE_PROBE_MAX_FAILURES, exc,
|
||||||
|
)
|
||||||
|
self._scheduled_probe_at[url] = time.time() + _LIVE_CHECK_INTERVAL
|
||||||
|
return
|
||||||
|
|
||||||
|
# Successful probe resets the transient-failure streak.
|
||||||
|
self._scheduled_probe_failures.pop(url, None)
|
||||||
|
|
||||||
|
release_ts = entry.get('release_timestamp')
|
||||||
|
live_status = entry.get('live_status')
|
||||||
|
if release_ts is not None:
|
||||||
|
info.live_release_timestamp = release_ts
|
||||||
|
if live_status is not None:
|
||||||
|
info.live_status = live_status
|
||||||
|
|
||||||
|
if live_status == 'is_upcoming':
|
||||||
|
self._scheduled_probe_at[url] = time.time() + self._probe_interval_seconds(release_ts)
|
||||||
|
await self.notifier.updated(info)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._unregister_scheduled(url)
|
||||||
|
info.status = 'pending'
|
||||||
|
# Clear the "scheduled to start at ..." placeholder now that the stream
|
||||||
|
# is live and a real download is about to begin.
|
||||||
|
info.error = None
|
||||||
|
info.msg = None
|
||||||
|
await self.notifier.updated(info)
|
||||||
|
asyncio.create_task(self.__start_download(download))
|
||||||
|
|
||||||
|
def _schedule_upcoming_download(self, download: Download) -> None:
|
||||||
|
download.info.status = 'scheduled'
|
||||||
|
self.queue.put(download)
|
||||||
|
self._register_scheduled(download)
|
||||||
|
|
||||||
|
def _force_start_scheduled(self, download: Download) -> None:
|
||||||
|
self._unregister_scheduled(download.info.url)
|
||||||
|
download.info.status = 'pending'
|
||||||
|
download.info.error = None
|
||||||
|
download.info.msg = None
|
||||||
|
asyncio.create_task(self.__start_download(download))
|
||||||
|
|
||||||
async def __start_download(self, download):
|
async def __start_download(self, download):
|
||||||
if download.canceled:
|
if download.canceled:
|
||||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||||
@@ -886,9 +1062,16 @@ class DownloadQueue:
|
|||||||
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
||||||
ytdl_options['playlistend'] = playlist_item_limit
|
ytdl_options['playlistend'] = playlist_item_limit
|
||||||
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
||||||
|
is_upcoming = (
|
||||||
|
getattr(dl, 'live_status', None) == 'is_upcoming'
|
||||||
|
or getattr(dl, 'status', None) == 'scheduled'
|
||||||
|
)
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
self.queue.put(download)
|
if is_upcoming:
|
||||||
asyncio.create_task(self.__start_download(download))
|
self._schedule_upcoming_download(download)
|
||||||
|
else:
|
||||||
|
self.queue.put(download)
|
||||||
|
asyncio.create_task(self.__start_download(download))
|
||||||
else:
|
else:
|
||||||
self.pending.put(download)
|
self.pending.put(download)
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
@@ -1036,6 +1219,8 @@ class DownloadQueue:
|
|||||||
ytdl_options_overrides=ytdl_options_overrides,
|
ytdl_options_overrides=ytdl_options_overrides,
|
||||||
clip_start=clip_start,
|
clip_start=clip_start,
|
||||||
clip_end=clip_end,
|
clip_end=clip_end,
|
||||||
|
live_status=entry.get('live_status'),
|
||||||
|
live_release_timestamp=entry.get('release_timestamp'),
|
||||||
)
|
)
|
||||||
await self.__add_download(dl, auto_start)
|
await self.__add_download(dl, auto_start)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
@@ -1156,13 +1341,21 @@ class DownloadQueue:
|
|||||||
|
|
||||||
async def start_pending(self, ids):
|
async def start_pending(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.pending.exists(id):
|
if self.pending.exists(id):
|
||||||
log.warning(f'requested start for non-existent download {id}')
|
dl = self.pending.get(id)
|
||||||
|
self.pending.delete(id)
|
||||||
|
if getattr(dl.info, 'live_status', None) == 'is_upcoming':
|
||||||
|
self._schedule_upcoming_download(dl)
|
||||||
|
else:
|
||||||
|
self.queue.put(dl)
|
||||||
|
asyncio.create_task(self.__start_download(dl))
|
||||||
continue
|
continue
|
||||||
dl = self.pending.get(id)
|
if self.queue.exists(id):
|
||||||
self.queue.put(dl)
|
dl = self.queue.get(id)
|
||||||
self.pending.delete(id)
|
if dl.info.status == 'scheduled':
|
||||||
asyncio.create_task(self.__start_download(dl))
|
self._force_start_scheduled(dl)
|
||||||
|
continue
|
||||||
|
log.warning(f'requested start for non-existent download {id}')
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
@@ -1177,6 +1370,8 @@ class DownloadQueue:
|
|||||||
log.warning(f'requested cancel for non-existent download {id}')
|
log.warning(f'requested cancel for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
dl = self.queue.get(id)
|
dl = self.queue.get(id)
|
||||||
|
if dl.info.status == 'scheduled':
|
||||||
|
self._unregister_scheduled(id)
|
||||||
if dl.started():
|
if dl.started():
|
||||||
dl.cancel()
|
dl.cancel()
|
||||||
else:
|
else:
|
||||||
|
|||||||
+19
-4
@@ -706,16 +706,31 @@
|
|||||||
</td>
|
</td>
|
||||||
<td title="{{ download.value.filename }}">
|
<td title="{{ download.value.filename }}">
|
||||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||||
<div>{{ download.value.title }} </div>
|
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||||
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
<span>{{ download.value.title }}</span>
|
||||||
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
||||||
|
<span class="badge bg-danger">LIVE</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (download.value.status === 'scheduled') {
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<fa-icon [icon]="faClock" />
|
||||||
|
Waiting for stream
|
||||||
|
@if (liveCountdownSeconds(download.value); as secs) {
|
||||||
|
- starts in {{ secs | eta }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
||||||
|
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ download.value.speed | speed }}</td>
|
<td>{{ download.value.speed | speed }}</td>
|
||||||
<td>{{ download.value.eta | eta }}</td>
|
<td>{{ download.value.eta | eta }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
@if (download.value.status === 'pending') {
|
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||||
|
|||||||
@@ -182,6 +182,37 @@ describe('App', () => {
|
|||||||
expect(payload.ytdlOptionsOverrides).toBe('');
|
expect(payload.ytdlOptionsOverrides).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows waiting badge for scheduled live stream', () => {
|
||||||
|
downloads.queue.set('https://example.com/live', {
|
||||||
|
id: 'live1',
|
||||||
|
title: 'Upcoming Stream',
|
||||||
|
url: 'https://example.com/live',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'scheduled',
|
||||||
|
live_status: 'is_upcoming',
|
||||||
|
live_release_timestamp: Date.now() / 1000 + 3600,
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
downloads.queueChanged.next();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.textContent).toContain('Waiting for stream');
|
||||||
|
expect(root.textContent).toContain('starts in');
|
||||||
|
});
|
||||||
|
|
||||||
it('includes titleRegex in subscribe payload', () => {
|
it('includes titleRegex in subscribe payload', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
|
|||||||
+27
-1
@@ -132,6 +132,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
lastCopiedErrorId: string | null = null;
|
lastCopiedErrorId: string | null = null;
|
||||||
private previousDownloadType = 'video';
|
private previousDownloadType = 'video';
|
||||||
private addRequestSub?: Subscription;
|
private addRequestSub?: Subscription;
|
||||||
|
private liveCountdownTimer?: ReturnType<typeof setInterval>;
|
||||||
private selectionsByType: Record<string, {
|
private selectionsByType: Record<string, {
|
||||||
codec: string;
|
codec: string;
|
||||||
format: string;
|
format: string;
|
||||||
@@ -285,6 +286,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// Subscribe to download updates
|
// Subscribe to download updates
|
||||||
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
|
this.syncLiveCountdownTimer();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
@@ -295,6 +297,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
// Subscribe to real-time updates
|
// Subscribe to real-time updates
|
||||||
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
|
this.syncLiveCountdownTimer();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,6 +340,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.addRequestSub?.unsubscribe();
|
this.addRequestSub?.unsubscribe();
|
||||||
|
if (this.liveCountdownTimer) {
|
||||||
|
clearInterval(this.liveCountdownTimer);
|
||||||
|
}
|
||||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,6 +1112,26 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.downloads.startById([id]).subscribe();
|
this.downloads.startById([id]).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveCountdownSeconds(download: Download): number | null {
|
||||||
|
const ts = download.live_release_timestamp;
|
||||||
|
if (ts == null || download.status !== 'scheduled') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.max(0, ts - Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncLiveCountdownTimer() {
|
||||||
|
const hasScheduled = Array.from(this.downloads.queue.values()).some(
|
||||||
|
(download) => download.status === 'scheduled',
|
||||||
|
);
|
||||||
|
if (hasScheduled && !this.liveCountdownTimer) {
|
||||||
|
this.liveCountdownTimer = setInterval(() => this.cdr.markForCheck(), 1000);
|
||||||
|
} else if (!hasScheduled && this.liveCountdownTimer) {
|
||||||
|
clearInterval(this.liveCountdownTimer);
|
||||||
|
this.liveCountdownTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
retryDownload(key: string, download: Download) {
|
retryDownload(key: string, download: Download) {
|
||||||
this.addDownload({
|
this.addDownload({
|
||||||
url: download.url,
|
url: download.url,
|
||||||
@@ -1631,7 +1657,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
speed += download.speed || 0;
|
speed += download.speed || 0;
|
||||||
} else if (download.status === 'preparing') {
|
} else if (download.status === 'preparing') {
|
||||||
active++;
|
active++;
|
||||||
} else if (download.status === 'pending') {
|
} else if (download.status === 'pending' || download.status === 'scheduled') {
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface Download {
|
|||||||
ytdl_options_overrides?: Record<string, unknown>;
|
ytdl_options_overrides?: Record<string, unknown>;
|
||||||
clip_start?: number;
|
clip_start?: number;
|
||||||
clip_end?: number;
|
clip_end?: number;
|
||||||
|
live_status?: string;
|
||||||
|
live_release_timestamp?: number;
|
||||||
status: string;
|
status: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user