mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Merge PR #1041: preserve playlist folder when retrying failed downloads
Replaces the client-side retry (which rebuilt an /add payload from the public
download dict) with a server-side POST /retry that re-adds from the stored
DownloadInfo. The public dict deliberately excludes `entry`, so the UI could
never carry playlist context across a retry: retried playlist items lost
playlist_index and landed in the root directory instead of their playlist
folder. The completed queue now persists the compacted entry for status=error
records only, so the context also survives a restart; successful records still
drop it. Also adds track_number to _COMPACT_ENTRY_EXTRA_KEYS so the #1031 music
metadata survives a failure/retry cycle.
Merged with two review fixes (08dccd9): retry_entry is now carried through the
url/url_transparent recursion in __add_entry, and retry() re-applies the
ALLOW_YTDL_OPTIONS_OVERRIDES and configured-preset gates that /add enforces via
parse_download_options.
Co-authored-by: jahruz67 <jahruz67@users.noreply.github.com>
This commit is contained in:
+11
@@ -893,6 +893,16 @@ async def cancel_add(request):
|
|||||||
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post(config.URL_PREFIX + 'retry')
|
||||||
|
async def retry(request):
|
||||||
|
post = await _read_json_request(request)
|
||||||
|
ids = _require_id_list(post)
|
||||||
|
if len(ids) != 1:
|
||||||
|
raise web.HTTPBadRequest(reason="'ids' must contain exactly one download id")
|
||||||
|
status = await dqueue.retry(ids[0])
|
||||||
|
return web.Response(text=serializer.encode(status), content_type='application/json')
|
||||||
|
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||||
async def subscribe(request):
|
async def subscribe(request):
|
||||||
post = await _read_json_request(request)
|
post = await _read_json_request(request)
|
||||||
@@ -1227,6 +1237,7 @@ async def add_cors(request):
|
|||||||
|
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'retry', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors)
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def mock_dqueue(monkeypatch):
|
|||||||
d = MagicMock()
|
d = MagicMock()
|
||||||
d.initialize = AsyncMock(return_value=None)
|
d.initialize = AsyncMock(return_value=None)
|
||||||
d.add = AsyncMock(return_value={"status": "ok"})
|
d.add = AsyncMock(return_value={"status": "ok"})
|
||||||
|
d.retry = AsyncMock(return_value={"status": "ok"})
|
||||||
d.cancel = AsyncMock(return_value={"status": "ok"})
|
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||||
d.clear = AsyncMock(return_value={"status": "ok"})
|
d.clear = AsyncMock(return_value={"status": "ok"})
|
||||||
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||||
@@ -69,6 +70,14 @@ async def test_add_ok(mock_dqueue):
|
|||||||
mock_dqueue.add.assert_awaited_once()
|
mock_dqueue.add.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_passes_failed_download_id(mock_dqueue):
|
||||||
|
req = _json_request({"ids": ["https://example.com/watch?v=1"]})
|
||||||
|
resp = await main.retry(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
mock_dqueue.retry.assert_awaited_once_with("https://example.com/watch?v=1")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
|
async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
|
||||||
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}})
|
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}})
|
||||||
|
|||||||
@@ -302,6 +302,179 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
|||||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_restores_playlist_output_context(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
failed_info = DownloadInfo(
|
||||||
|
id="vid1",
|
||||||
|
title="Test Video",
|
||||||
|
url=url,
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error="temporary failure",
|
||||||
|
entry={
|
||||||
|
"playlist_index": "01",
|
||||||
|
"playlist_title": "My Playlist",
|
||||||
|
"playlist_count": 10,
|
||||||
|
},
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
)
|
||||||
|
failed_info.status = "error"
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, failed_info))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
queued = dq.queue.get(url)
|
||||||
|
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
||||||
|
assert queued.info.entry["playlist_index"] == "01"
|
||||||
|
assert queued.info.entry["playlist_title"] == "My Playlist"
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_playlist_item(url, **overrides):
|
||||||
|
"""A done-list entry for a playlist item that failed mid-download."""
|
||||||
|
info = DownloadInfo(
|
||||||
|
id="vid1",
|
||||||
|
title="Test Video",
|
||||||
|
url=url,
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error="temporary failure",
|
||||||
|
entry={
|
||||||
|
"playlist_index": "01",
|
||||||
|
"playlist_title": "My Playlist",
|
||||||
|
"playlist_count": 10,
|
||||||
|
},
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
info.status = "error"
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_keeps_playlist_context_through_url_indirection(dq_env):
|
||||||
|
# extract_flat=True makes yt-dlp hand back url/url_transparent results
|
||||||
|
# unprocessed, so __add_entry recurses into add() a second time. The retry
|
||||||
|
# context has to survive that hop or the item lands in the root directory.
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
resolved = "https://example.com/resolved?v=1"
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, _failed_playlist_item(url)))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||||
|
if extracted_url == url:
|
||||||
|
return {"_type": "url", "url": resolved, "id": "vid1"}
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
queued = dq.queue.get(resolved)
|
||||||
|
assert queued.output_template == "My Playlist/%(title)s.%(ext)s"
|
||||||
|
assert queued.info.entry["playlist_title"] == "My Playlist"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_reapplies_current_options_gates(dq_env):
|
||||||
|
# The stored options passed parse_download_options when first submitted, but
|
||||||
|
# the configuration can have changed since; retry must not resurrect
|
||||||
|
# overrides or presets the current configuration no longer allows.
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = False
|
||||||
|
dq_env.YTDL_OPTIONS_PRESETS = {"Still There": {"writesubtitles": True}}
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
info = _failed_playlist_item(
|
||||||
|
url,
|
||||||
|
ytdl_options_presets=["Still There", "Removed Preset"],
|
||||||
|
ytdl_options_overrides={"paths": {"home": "/etc"}},
|
||||||
|
)
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
queued = dq.queue.get(url)
|
||||||
|
assert queued.info.ytdl_options_overrides == {}
|
||||||
|
assert queued.info.ytdl_options_presets == ["Still There"]
|
||||||
|
assert queued.ytdl_opts.get("paths", {}).get("home") != "/etc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_keeps_overrides_while_still_allowed(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.ALLOW_YTDL_OPTIONS_OVERRIDES = True
|
||||||
|
dq_env.YTDL_OPTIONS_PRESETS = {}
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
url = "https://example.com/watch?v=1"
|
||||||
|
info = _failed_playlist_item(url, ytdl_options_overrides={"writesubtitles": True})
|
||||||
|
dq.done.put(Download(None, None, None, None, "best", "any", {}, info))
|
||||||
|
|
||||||
|
def fake_extract(self, extracted_url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": extracted_url,
|
||||||
|
"webpage_url": extracted_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||||
|
result = await dq.retry(url)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert dq.queue.get(url).info.ytdl_options_overrides == {"writesubtitles": True}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
|
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
|
|||||||
@@ -146,12 +146,12 @@ class PersistentQueueTests(unittest.TestCase):
|
|||||||
self.assertNotIn("formats", record["entry"])
|
self.assertNotIn("formats", record["entry"])
|
||||||
self.assertNotIn("description", record["entry"])
|
self.assertNotIn("description", record["entry"])
|
||||||
|
|
||||||
def test_completed_queue_does_not_persist_entry_or_transient_progress(self):
|
def test_completed_queue_persists_only_failed_retry_context(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = os.path.join(tmp, "completed")
|
path = os.path.join(tmp, "completed")
|
||||||
pq = PersistentQueue("completed", path)
|
pq = PersistentQueue("completed", path)
|
||||||
info = _make_info("http://done.example")
|
info = _make_info("http://done.example")
|
||||||
info.status = "finished"
|
info.status = "error"
|
||||||
info.percent = 88
|
info.percent = 88
|
||||||
info.speed = 123
|
info.speed = 123
|
||||||
info.eta = 9
|
info.eta = 9
|
||||||
@@ -167,12 +167,24 @@ class PersistentQueueTests(unittest.TestCase):
|
|||||||
payload = json.load(f)
|
payload = json.load(f)
|
||||||
|
|
||||||
record = payload["items"][0]["info"]
|
record = payload["items"][0]["info"]
|
||||||
self.assertNotIn("entry", record)
|
self.assertEqual(
|
||||||
|
record["entry"],
|
||||||
|
{
|
||||||
|
"playlist_index": "01",
|
||||||
|
"playlist_title": "Playlist",
|
||||||
|
},
|
||||||
|
)
|
||||||
self.assertNotIn("percent", record)
|
self.assertNotIn("percent", record)
|
||||||
self.assertNotIn("speed", record)
|
self.assertNotIn("speed", record)
|
||||||
self.assertNotIn("eta", record)
|
self.assertNotIn("eta", record)
|
||||||
self.assertEqual(record["filename"], "done.mp4")
|
self.assertEqual(record["filename"], "done.mp4")
|
||||||
|
|
||||||
|
info.status = "finished"
|
||||||
|
pq.put(_FakeDownload(info))
|
||||||
|
with open(path + ".json", encoding="utf-8") as f:
|
||||||
|
payload = json.load(f)
|
||||||
|
self.assertNotIn("entry", payload["items"][0]["info"])
|
||||||
|
|
||||||
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
|
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = os.path.join(tmp, "queue")
|
path = os.path.join(tmp, "queue")
|
||||||
|
|||||||
+61
-9
@@ -510,7 +510,7 @@ def _short_title_for_failed_url(url: str) -> str:
|
|||||||
return hostname or url
|
return hostname or url
|
||||||
|
|
||||||
|
|
||||||
_COMPACT_ENTRY_EXTRA_KEYS = frozenset(("n_entries", "__last_playlist_index"))
|
_COMPACT_ENTRY_EXTRA_KEYS = frozenset(("n_entries", "__last_playlist_index", "track_number"))
|
||||||
|
|
||||||
|
|
||||||
def _compact_persisted_entry(entry: Any) -> Optional[dict[str, Any]]:
|
def _compact_persisted_entry(entry: Any) -> Optional[dict[str, Any]]:
|
||||||
@@ -944,8 +944,12 @@ class PersistentQueue:
|
|||||||
]
|
]
|
||||||
return sorted(items, key=lambda item: item[1].timestamp)
|
return sorted(items, key=lambda item: item[1].timestamp)
|
||||||
|
|
||||||
def _should_persist_entry(self) -> bool:
|
def _should_persist_entry(self, info: DownloadInfo | dict[str, Any]) -> bool:
|
||||||
return self.identifier != "completed"
|
# Failed downloads need their compact playlist/channel context so a
|
||||||
|
# retry after a server restart still resolves the original outtmpl.
|
||||||
|
# Successful completed entries continue to omit extractor metadata.
|
||||||
|
status = info.get("status") if isinstance(info, dict) else info.status
|
||||||
|
return self.identifier != "completed" or status == "error"
|
||||||
|
|
||||||
def _serialize_items(self):
|
def _serialize_items(self):
|
||||||
return [
|
return [
|
||||||
@@ -953,7 +957,7 @@ class PersistentQueue:
|
|||||||
"key": key,
|
"key": key,
|
||||||
"info": _download_info_to_record(
|
"info": _download_info_to_record(
|
||||||
download.info,
|
download.info,
|
||||||
include_entry=self._should_persist_entry(),
|
include_entry=self._should_persist_entry(download.info),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for key, download in self.dict.items()
|
for key, download in self.dict.items()
|
||||||
@@ -972,7 +976,7 @@ class PersistentQueue:
|
|||||||
"key": item["key"],
|
"key": item["key"],
|
||||||
"info": _download_info_to_record(
|
"info": _download_info_to_record(
|
||||||
_download_info_from_record(item["info"]),
|
_download_info_from_record(item["info"]),
|
||||||
include_entry=self._should_persist_entry(),
|
include_entry=self._should_persist_entry(item["info"]),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for item in items
|
for item in items
|
||||||
@@ -993,7 +997,7 @@ class PersistentQueue:
|
|||||||
"key": key,
|
"key": key,
|
||||||
"info": _download_info_to_record(
|
"info": _download_info_to_record(
|
||||||
value,
|
value,
|
||||||
include_entry=self._should_persist_entry(),
|
include_entry=self._should_persist_entry(value),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for key, value in sorted(legacy_items, key=lambda item: item[1].timestamp)
|
for key, value in sorted(legacy_items, key=lambda item: item[1].timestamp)
|
||||||
@@ -1394,6 +1398,7 @@ class DownloadQueue:
|
|||||||
clip_end,
|
clip_end,
|
||||||
already,
|
already,
|
||||||
_add_gen=None,
|
_add_gen=None,
|
||||||
|
retry_entry=None,
|
||||||
):
|
):
|
||||||
if not entry:
|
if not entry:
|
||||||
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
||||||
@@ -1412,6 +1417,10 @@ class DownloadQueue:
|
|||||||
|
|
||||||
if etype.startswith('url'):
|
if etype.startswith('url'):
|
||||||
log.debug('Processing as a url')
|
log.debug('Processing as a url')
|
||||||
|
# retry_entry must ride along: extraction can hand back an
|
||||||
|
# unprocessed url/url_transparent result, and dropping the retry
|
||||||
|
# context here would send the retried item back to the root
|
||||||
|
# directory instead of its original playlist folder.
|
||||||
return await self.add(
|
return await self.add(
|
||||||
entry['url'],
|
entry['url'],
|
||||||
download_type,
|
download_type,
|
||||||
@@ -1432,6 +1441,7 @@ class DownloadQueue:
|
|||||||
clip_end,
|
clip_end,
|
||||||
already,
|
already,
|
||||||
_add_gen,
|
_add_gen,
|
||||||
|
retry_entry,
|
||||||
)
|
)
|
||||||
elif etype == 'playlist' or etype == 'channel':
|
elif etype == 'playlist' or etype == 'channel':
|
||||||
if etype == 'playlist' and self.__is_channel_extraction(entry):
|
if etype == 'playlist' and self.__is_channel_extraction(entry):
|
||||||
@@ -1559,6 +1569,7 @@ class DownloadQueue:
|
|||||||
ytdl_options_overrides,
|
ytdl_options_overrides,
|
||||||
clip_start,
|
clip_start,
|
||||||
clip_end,
|
clip_end,
|
||||||
|
entry=None,
|
||||||
):
|
):
|
||||||
"""Surface a URL that failed before a DownloadInfo could be created (unsupported
|
"""Surface a URL that failed before a DownloadInfo could be created (unsupported
|
||||||
URL, SSRF-rejected, extraction error) as a failed entry in the done list, so the
|
URL, SSRF-rejected, extraction error) as a failed entry in the done list, so the
|
||||||
@@ -1575,7 +1586,7 @@ class DownloadQueue:
|
|||||||
folder=folder,
|
folder=folder,
|
||||||
custom_name_prefix=custom_name_prefix,
|
custom_name_prefix=custom_name_prefix,
|
||||||
error=msg,
|
error=msg,
|
||||||
entry=None,
|
entry=entry,
|
||||||
playlist_item_limit=playlist_item_limit,
|
playlist_item_limit=playlist_item_limit,
|
||||||
split_by_chapters=split_by_chapters,
|
split_by_chapters=split_by_chapters,
|
||||||
chapter_template=chapter_template,
|
chapter_template=chapter_template,
|
||||||
@@ -1613,6 +1624,7 @@ class DownloadQueue:
|
|||||||
clip_end=None,
|
clip_end=None,
|
||||||
already=None,
|
already=None,
|
||||||
_add_gen=None,
|
_add_gen=None,
|
||||||
|
retry_entry=None,
|
||||||
):
|
):
|
||||||
if ytdl_options_presets is None:
|
if ytdl_options_presets is None:
|
||||||
ytdl_options_presets = []
|
ytdl_options_presets = []
|
||||||
@@ -1641,7 +1653,7 @@ class DownloadQueue:
|
|||||||
url, url_error, download_type, codec, format, quality, folder,
|
url, url_error, download_type, codec, format, quality, folder,
|
||||||
custom_name_prefix, playlist_item_limit, split_by_chapters, chapter_template,
|
custom_name_prefix, playlist_item_limit, split_by_chapters, chapter_template,
|
||||||
subtitle_language, subtitle_mode, ytdl_options_presets, ytdl_options_overrides,
|
subtitle_language, subtitle_mode, ytdl_options_presets, ytdl_options_overrides,
|
||||||
clip_start, clip_end,
|
clip_start, clip_end, retry_entry,
|
||||||
)
|
)
|
||||||
return {'status': 'error', 'msg': url_error}
|
return {'status': 'error', 'msg': url_error}
|
||||||
try:
|
try:
|
||||||
@@ -1655,9 +1667,12 @@ class DownloadQueue:
|
|||||||
url, msg, download_type, codec, format, quality, folder,
|
url, msg, download_type, codec, format, quality, folder,
|
||||||
custom_name_prefix, playlist_item_limit, split_by_chapters, chapter_template,
|
custom_name_prefix, playlist_item_limit, split_by_chapters, chapter_template,
|
||||||
subtitle_language, subtitle_mode, ytdl_options_presets, ytdl_options_overrides,
|
subtitle_language, subtitle_mode, ytdl_options_presets, ytdl_options_overrides,
|
||||||
clip_start, clip_end,
|
clip_start, clip_end, retry_entry,
|
||||||
)
|
)
|
||||||
return {'status': 'error', 'msg': msg}
|
return {'status': 'error', 'msg': msg}
|
||||||
|
retry_context = _compact_persisted_entry(retry_entry)
|
||||||
|
if isinstance(entry, dict) and retry_context is not None:
|
||||||
|
entry = {**entry, **copy.deepcopy(retry_context)}
|
||||||
return await self.__add_entry(
|
return await self.__add_entry(
|
||||||
entry,
|
entry,
|
||||||
download_type,
|
download_type,
|
||||||
@@ -1678,6 +1693,43 @@ class DownloadQueue:
|
|||||||
clip_end,
|
clip_end,
|
||||||
already,
|
already,
|
||||||
_add_gen,
|
_add_gen,
|
||||||
|
retry_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def retry(self, id):
|
||||||
|
if not self.done.exists(id):
|
||||||
|
return {'status': 'error', 'msg': 'Failed download no longer exists.'}
|
||||||
|
|
||||||
|
info = self.done.get(id).info
|
||||||
|
if info.status != 'error':
|
||||||
|
return {'status': 'error', 'msg': 'Only failed downloads can be retried.'}
|
||||||
|
|
||||||
|
# The stored options were validated by parse_download_options when the
|
||||||
|
# download was first submitted, but the configuration can have changed
|
||||||
|
# since. Re-apply the same gates here so a retry can't resurrect
|
||||||
|
# overrides or presets the current configuration no longer allows.
|
||||||
|
overrides = info.ytdl_options_overrides if self.config.ALLOW_YTDL_OPTIONS_OVERRIDES else {}
|
||||||
|
presets = [p for p in info.ytdl_options_presets if p in self.config.YTDL_OPTIONS_PRESETS]
|
||||||
|
|
||||||
|
return await self.add(
|
||||||
|
info.url,
|
||||||
|
info.download_type,
|
||||||
|
info.codec,
|
||||||
|
info.format,
|
||||||
|
info.quality,
|
||||||
|
info.folder,
|
||||||
|
info.custom_name_prefix,
|
||||||
|
info.playlist_item_limit,
|
||||||
|
True,
|
||||||
|
info.split_by_chapters,
|
||||||
|
info.chapter_template,
|
||||||
|
info.subtitle_language,
|
||||||
|
info.subtitle_mode,
|
||||||
|
presets,
|
||||||
|
overrides,
|
||||||
|
info.clip_start,
|
||||||
|
info.clip_end,
|
||||||
|
retry_entry=info.entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_entry(
|
async def add_entry(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class DownloadsServiceStub {
|
|||||||
customDirsChanged = new Subject<Record<string, string[]>>();
|
customDirsChanged = new Subject<Record<string, string[]>>();
|
||||||
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
||||||
updated = new Subject<void>();
|
updated = new Subject<void>();
|
||||||
|
retryCalls: string[] = [];
|
||||||
|
|
||||||
getCookieStatus() {
|
getCookieStatus() {
|
||||||
return of({ status: 'ok', has_cookies: false });
|
return of({ status: 'ok', has_cookies: false });
|
||||||
@@ -32,6 +33,11 @@ class DownloadsServiceStub {
|
|||||||
return of({ status: 'ok' as const });
|
return of({ status: 'ok' as const });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retry(id: string) {
|
||||||
|
this.retryCalls.push(id);
|
||||||
|
return of({ status: 'ok' as const });
|
||||||
|
}
|
||||||
|
|
||||||
cancelAdd() {
|
cancelAdd() {
|
||||||
return of({ status: 'ok' as const });
|
return of({ status: 'ok' as const });
|
||||||
}
|
}
|
||||||
@@ -269,6 +275,33 @@ describe('App', () => {
|
|||||||
expect(payload.clipEnd).toBe('1:20');
|
expect(payload.clipEnd).toBe('1:20');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries a failed download by its server-side queue id', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const download = {
|
||||||
|
id: 'vid1',
|
||||||
|
title: 'Test Video',
|
||||||
|
url: 'https://example.com/v',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'error',
|
||||||
|
msg: 'temporary failure',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.retryDownload(download.url, download);
|
||||||
|
|
||||||
|
expect(downloads.retryCalls).toEqual([download.url]);
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks subscribe with invalid title regex', () => {
|
it('blocks subscribe with invalid title regex', () => {
|
||||||
const toasts = TestBed.inject(ToastService);
|
const toasts = TestBed.inject(ToastService);
|
||||||
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
||||||
|
|||||||
+1
-22
@@ -1146,30 +1146,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retryDownload(key: string, download: Download) {
|
retryDownload(key: string, download: Download) {
|
||||||
const payload = this.buildAddPayload({
|
|
||||||
url: download.url,
|
|
||||||
downloadType: download.download_type,
|
|
||||||
codec: download.codec,
|
|
||||||
quality: download.quality,
|
|
||||||
format: download.format,
|
|
||||||
folder: download.folder,
|
|
||||||
customNamePrefix: download.custom_name_prefix,
|
|
||||||
playlistItemLimit: download.playlist_item_limit,
|
|
||||||
autoStart: true,
|
|
||||||
splitByChapters: download.split_by_chapters,
|
|
||||||
chapterTemplate: download.chapter_template,
|
|
||||||
subtitleLanguage: download.subtitle_language,
|
|
||||||
subtitleMode: download.subtitle_mode,
|
|
||||||
ytdlOptionsPresets: download.ytdl_options_presets?.length
|
|
||||||
? [...download.ytdl_options_presets]
|
|
||||||
: [],
|
|
||||||
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
|
|
||||||
clipStart: download.clip_start != null ? String(download.clip_start) : '',
|
|
||||||
clipEnd: download.clip_end != null ? String(download.clip_end) : '',
|
|
||||||
});
|
|
||||||
// Only remove the done-list record once the retry is confirmed queued —
|
// Only remove the done-list record once the retry is confirmed queued —
|
||||||
// deleting it eagerly would silently lose history if the re-add fails.
|
// deleting it eagerly would silently lose history if the re-add fails.
|
||||||
this.downloads.add(payload)
|
this.downloads.retry(key)
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe((status: Status) => {
|
.subscribe((status: Status) => {
|
||||||
if (status.status === 'error') {
|
if (status.status === 'error') {
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ describe('DownloadsService', () => {
|
|||||||
req.flush({ presets: ['Preset A'] });
|
req.flush({ presets: ['Preset A'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retry() posts the failed download id', () => {
|
||||||
|
service.retry('https://example.com/v').subscribe();
|
||||||
|
const req = httpMock.expectOne('retry');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual({ ids: ['https://example.com/v'] });
|
||||||
|
req.flush({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
it('cancelAdd posts to cancel-add', () => {
|
it('cancelAdd posts to cancel-add', () => {
|
||||||
service.cancelAdd().subscribe();
|
service.cancelAdd().subscribe();
|
||||||
const req = httpMock.expectOne('cancel-add');
|
const req = httpMock.expectOne('cancel-add');
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ export class DownloadsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public retry(id: string) {
|
||||||
|
return this.http.post<Status>('retry', { ids: [id] }).pipe(
|
||||||
|
catchError(this.handleHTTPError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public startById(ids: string[]) {
|
public startById(ids: string[]) {
|
||||||
return this.http.post<Status>('start', {ids: ids}).pipe(
|
return this.http.post<Status>('start', {ids: ids}).pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
|
|||||||
Reference in New Issue
Block a user