Merge pull request #4 from TonyBlur/auto-download-pause-resume

feat: support auto download, pause and resume and download phase label
This commit is contained in:
TonyBlu 2026-05-25 22:10:59 +08:00 committed by GitHub
commit 6e65441eba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 697 additions and 39 deletions

File diff suppressed because one or more lines are too long

15
.codex/hooks.json Normal file
View file

@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node .claude/setup.mjs"
}
]
}
]
}
}

View file

@ -31,7 +31,7 @@ jobs:
upstream_sync_repo: alexta69/metube
upstream_sync_branch: master
target_sync_branch: master
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
target_repo_token: ${{ secrets.UPSTREAM_TOKEN }}
test_mode: false
- name: Sync check

View file

@ -16,6 +16,7 @@ COPY pyproject.toml uv.lock docker-entrypoint.sh ./
# Install dependencies
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
chmod +x docker-entrypoint.sh && \
find /etc/apt -type f -name '*.sources' -exec sed -i 's|http://deb.debian.org|https://deb.debian.org|g' {} + && \
apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
@ -27,28 +28,28 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
curl \
tini \
build-essential && \
curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && \
curl --retry 5 --retry-all-errors --retry-delay 3 -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && \
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
uv cache clean && \
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
curl --retry 5 --retry-all-errors --retry-delay 3 -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
apt-get purge -y --auto-remove build-essential && \
rm -rf /var/lib/apt/lists/* && \
mkdir /.cache && chmod 777 /.cache
ARG TARGETARCH
RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
RUN BGUTIL_TAG="$(curl --retry 5 --retry-all-errors --retry-delay 3 -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
case "$TARGETARCH" in \
amd64) BGUTIL_ARCH="x86_64" ;; \
arm64) BGUTIL_ARCH="aarch64" ;; \
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
esac && \
curl -L -o /usr/local/bin/bgutil-pot \
curl --retry 5 --retry-all-errors --retry-delay 3 -L -o /usr/local/bin/bgutil-pot \
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-pot-linux-${BGUTIL_ARCH}" && \
chmod +x /usr/local/bin/bgutil-pot && \
PLUGIN_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" && \
curl -L -o /tmp/bgutil-ytdlp-pot-provider-rs.zip \
curl --retry 5 --retry-all-errors --retry-delay 3 -L -o /tmp/bgutil-ytdlp-pot-provider-rs.zip \
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-ytdlp-pot-provider-rs.zip" && \
unzip -q /tmp/bgutil-ytdlp-pot-provider-rs.zip -d "${PLUGIN_DIR}" && \
rm /tmp/bgutil-ytdlp-pot-provider-rs.zip

View file

@ -10,6 +10,74 @@ CODEC_FILTER_MAP = {
'vp9': "[vcodec~='^vp0?9']",
}
# Common language variant suffixes that yt-dlp may encounter on YouTube and
# other platforms. When a user requests "en" we also try "en-GB", "en-US",
# etc. so that a request isn't silently skipped when only a regional variant
# exists. yt-dlp processes the list in order and picks the first match.
_LANGUAGE_REGION_VARIANTS = {
"en": ("US", "GB", "AU", "CA", "IN", "NZ", "IE", "ZA"),
"zh": ("CN", "TW", "HK", "SG"),
"pt": ("BR", "PT"),
"es": ("ES", "MX", "AR", "CO", "CL", "PE", "VE"),
"fr": ("FR", "CA", "BE", "CH"),
"de": ("DE", "AT", "CH"),
"ar": ("SA", "AE", "EG", "MA", "DZ"),
"nl": ("NL", "BE"),
"ro": ("RO", "MD"),
"ms": ("MY", "BN", "SG"),
"it": ("IT", "SM"),
"ja": (),
"ko": (),
"hi": (),
"th": (),
"vi": (),
"id": (),
"pl": (),
"uk": (),
"ru": ("RU", "BY", "KZ", "UA"),
"cs": (),
"sv": ("SE", "FI"),
"da": ("DK",),
"no": ("NO", "NB", "NN"),
"fi": (),
"tr": (),
"el": ("GR", "CY"),
"he": (),
"hu": (),
"bn": ("BD", "IN"),
"ta": ("IN", "SG", "LK", "MY"),
}
def _subtitle_lang_list(language: str, include_orig: bool = False) -> list[str]:
"""Build a prioritised list of subtitle language codes for yt-dlp.
Starts with the exact language, then appends common regional variants
(e.g. ``en`` ``en-US, en-GB, en-AU, ``). ``include_orig`` adds the
``<lang>-orig`` tag that YouTube uses for original-language auto-captions.
Handles user input that already contains a region or script subtag (e.g.
``zh-Hans``, ``pt-BR``): extracts the base language (``zh``, ``pt``) so
regional variants for that base language are still included as fallbacks.
"""
langs = [language]
# Extract base language (e.g. "zh-Hans" → "zh", "en-GB" → "en")
base = language.split('-')[0]
# If user provided a qualified tag, also include the base form as fallback
if base != language:
langs.append(base)
variants = _LANGUAGE_REGION_VARIANTS.get(base)
if variants:
for region in variants:
variant = f"{base}-{region}"
if variant not in langs:
langs.append(variant)
if include_orig:
orig_tag = f"{base}-orig"
if orig_tag not in langs:
langs.append(orig_tag)
return langs
def _normalize_caption_mode(mode: str) -> str:
mode = (mode or "").strip()
@ -141,21 +209,20 @@ def get_opts(
if mode == "manual_only":
opts["writesubtitles"] = True
opts["writeautomaticsub"] = False
opts["subtitleslangs"] = [language]
opts["subtitleslangs"] = _subtitle_lang_list(language)
elif mode == "auto_only":
opts["writesubtitles"] = False
opts["writeautomaticsub"] = True
# `-orig` captures common YouTube auto-sub tags. The plain language
# fallback keeps behavior useful across other extractors.
opts["subtitleslangs"] = [f"{language}-orig", language]
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
elif mode == "prefer_auto":
opts["writesubtitles"] = True
opts["writeautomaticsub"] = True
opts["subtitleslangs"] = [f"{language}-orig", language]
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
else:
# prefer_manual (default)
opts["writesubtitles"] = True
opts["writeautomaticsub"] = True
opts["subtitleslangs"] = [language, f"{language}-orig"]
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
opts["postprocessors"] = postprocessors + (
opts["postprocessors"] if "postprocessors" in opts else []

View file

@ -988,6 +988,16 @@ async def start(request):
status = await dqueue.start_pending(ids)
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'pause')
async def pause(request):
post = await _read_json_request(request)
ids = post.get('ids')
if not ids or not isinstance(ids, list):
raise web.HTTPBadRequest(reason='missing ids list')
log.info(f"Received request to pause downloads for ids: {ids}")
status = await dqueue.pause(ids)
return web.Response(text=serializer.encode(status))
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
@ -1229,6 +1239,7 @@ app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'pause', add_cors)
async def on_prepare(request, response):
origin = request.headers.get('Origin')

View file

@ -18,6 +18,7 @@ def mock_dqueue(monkeypatch):
d.add = AsyncMock(return_value={"status": "ok"})
d.cancel = AsyncMock(return_value={"status": "ok"})
d.start_pending = AsyncMock(return_value={"status": "ok"})
d.pause = AsyncMock(return_value={"status": "ok"})
d.cancel_add = MagicMock()
d.queue = MagicMock()
d.done = MagicMock()
@ -212,6 +213,21 @@ async def test_start_pending(mock_dqueue):
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
@pytest.mark.asyncio
async def test_pause_download(mock_dqueue):
req = _json_request({"ids": ["a"]})
resp = await main.pause(req)
assert resp.status == 200
mock_dqueue.pause.assert_awaited_once_with(["a"])
@pytest.mark.asyncio
async def test_pause_missing_ids(mock_dqueue):
req = _json_request({})
with pytest.raises(web.HTTPBadRequest):
await main.pause(req)
@pytest.mark.asyncio
async def test_history_shape(mock_dqueue):
mock_dqueue.queue.saved_items.return_value = []

View file

@ -92,7 +92,7 @@ class DlFormatsTests(unittest.TestCase):
)
self.assertTrue(opts.get("writesubtitles"))
self.assertFalse(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["fr"])
self.assertEqual(opts["subtitleslangs"], ["fr", "fr-FR", "fr-CA", "fr-BE", "fr-CH"])
def test_get_opts_captions_auto_only(self):
opts = get_opts(
@ -100,7 +100,7 @@ class DlFormatsTests(unittest.TestCase):
)
self.assertFalse(opts.get("writesubtitles"))
self.assertTrue(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["de-orig", "de"])
self.assertEqual(opts["subtitleslangs"], ["de", "de-DE", "de-AT", "de-CH", "de-orig"])
def test_get_opts_captions_prefer_auto(self):
opts = get_opts(
@ -108,13 +108,13 @@ class DlFormatsTests(unittest.TestCase):
)
self.assertTrue(opts.get("writesubtitles"))
self.assertTrue(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["es-orig", "es"])
self.assertEqual(opts["subtitleslangs"], ["es", "es-ES", "es-MX", "es-AR", "es-CO", "es-CL", "es-PE", "es-VE", "es-orig"])
def test_get_opts_captions_prefer_manual_default_branch(self):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="it", subtitle_mode="prefer_manual"
)
self.assertEqual(opts["subtitleslangs"], ["it", "it-orig"])
self.assertEqual(opts["subtitleslangs"], ["it", "it-IT", "it-SM", "it-orig"])
def test_get_opts_captions_txt_maps_to_srt_format(self):
opts = get_opts("captions", "auto", "txt", "best", {})
@ -134,6 +134,23 @@ class DlFormatsTests(unittest.TestCase):
self.assertEqual(_normalize_subtitle_language(""), "en")
self.assertEqual(_normalize_subtitle_language(" "), "en")
def test_subtitle_lang_list_with_region_variant_input(self):
"""zh-Hans should expand to include zh base and zh-CN, zh-TW, etc."""
from app.dl_formats import _subtitle_lang_list
result = _subtitle_lang_list("zh-Hans", include_orig=True)
self.assertEqual(result[0], "zh-Hans") # original input first
self.assertIn("zh", result) # base language fallback
self.assertIn("zh-CN", result) # regional variant
self.assertIn("zh-orig", result) # orig suffix for auto-captions
def test_subtitle_lang_list_dedup_region_variant(self):
"""pt-BR should not duplicate pt-BR in the variant expansion."""
from app.dl_formats import _subtitle_lang_list
result = _subtitle_lang_list("pt-BR", include_orig=True)
self.assertEqual(result.count("pt-BR"), 1) # no duplicate
self.assertIn("pt", result) # base language fallback
self.assertIn("pt-PT", result) # other regional variant
if __name__ == "__main__":
unittest.main()

View file

@ -190,6 +190,92 @@ async def test_start_pending_moves_to_queue(dq_env):
assert not dq.pending.exists(url)
@pytest.mark.asyncio
async def test_pause_keeps_download_in_queue(dq_env):
notifier = AsyncMock()
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid-pause",
"title": "Pause Test",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/pause"
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
result = await dq.pause([url])
assert result["status"] == "ok"
assert dq.queue.exists(url)
download = dq.queue.get(url)
assert download.paused is True
assert download.info.status == "paused"
notifier.updated.assert_awaited()
@pytest.mark.asyncio
async def test_start_paused_download_reschedules_it(dq_env):
notifier = AsyncMock()
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid-resume",
"title": "Resume Test",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/resume"
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
await dq.pause([url])
start_mock = AsyncMock()
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
result = await dq.start_pending([url])
assert result["status"] == "ok"
download = dq.queue.get(url)
assert download.paused is False
assert download.info.status == "pending"
start_mock.assert_called_once_with(download, download.start_generation)
@pytest.mark.asyncio
async def test_cancel_paused_download_removes_from_queue(dq_env):
notifier = AsyncMock()
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid-cancel-paused",
"title": "Cancel Paused Test",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/cancel-paused"
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
await dq.pause([url])
result = await dq.cancel([url])
assert result["status"] == "ok"
assert not dq.queue.exists(url)
notifier.canceled.assert_awaited_with(url)
@pytest.mark.asyncio
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
notifier = AsyncMock()

View file

@ -206,9 +206,11 @@ class DownloadInfo:
self.custom_name_prefix = custom_name_prefix
self.msg = self.percent = self.speed = self.eta = None
self.status = "pending"
self.download_phase = None
self.size = None
self.timestamp = time.time_ns()
self.error = error
self.filename = None
# Strip non-pickleable values (generators, iterators, locks, etc.) for shelve
self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
self.playlist_item_limit = playlist_item_limit
@ -220,6 +222,7 @@ class DownloadInfo:
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
self.clip_start = clip_start
self.clip_end = clip_end
self.chapter_files = []
self.subtitle_files = []
def __setstate__(self, state):
@ -288,10 +291,14 @@ class DownloadInfo:
self.subtitle_files = []
if not hasattr(self, "chapter_files"):
self.chapter_files = []
if not hasattr(self, "filename"):
self.filename = None
if not hasattr(self, "clip_start"):
self.clip_start = None
if not hasattr(self, "clip_end"):
self.clip_end = None
if not hasattr(self, "download_phase"):
self.download_phase = None
_PERSISTED_DOWNLOAD_FIELDS = (
@ -314,12 +321,14 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"clip_start",
"clip_end",
"status",
"download_phase",
"timestamp",
"error",
"msg",
"filename",
"size",
"chapter_files",
"subtitle_files",
)
@ -368,6 +377,8 @@ def _download_info_from_record(record: dict[str, Any]) -> DownloadInfo:
info.eta = None
if not hasattr(info, "status"):
info.status = "pending"
if not hasattr(info, "download_phase"):
info.download_phase = None
if not hasattr(info, "size"):
info.size = None
if not hasattr(info, "error"):
@ -470,18 +481,56 @@ class Download:
if "impersonate" in self.ytdl_opts:
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
self.canceled = False
self.paused = getattr(self.info, 'status', None) == 'paused'
self.tmpfilename = None
self.status_queue = None
self.proc = None
self.loop = None
self.notifier = None
self.start_generation = 0
def _download_phase_from_status(self, st):
info_dict = st.get('info_dict') if isinstance(st, dict) else None
if not isinstance(info_dict, dict):
return None
vcodec = str(info_dict.get('vcodec') or '').lower()
acodec = str(info_dict.get('acodec') or '').lower()
# Check requested_formats when top-level codecs are both "none"
# (common when yt-dlp uses separate downloaders for video+audio streams)
if (not vcodec or vcodec == 'none') and (not acodec or acodec == 'none'):
requested = info_dict.get('requested_formats')
if isinstance(requested, list) and requested:
has_video = any(
str(f.get('vcodec') or '').lower() not in ('', 'none')
for f in requested if isinstance(f, dict)
)
has_audio = any(
str(f.get('acodec') or '').lower() not in ('', 'none')
for f in requested if isinstance(f, dict)
)
if has_video and has_audio:
return 'media'
if has_video:
return 'video'
if has_audio:
return 'audio'
if vcodec and vcodec != 'none' and (not acodec or acodec == 'none'):
return 'video'
if acodec and acodec != 'none' and (not vcodec or vcodec == 'none'):
return 'audio'
if vcodec and vcodec != 'none' and acodec and acodec != 'none':
return 'media'
return None
def _download(self):
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
try:
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
def put_status(st):
self.status_queue.put({k: v for k, v in st.items() if k in (
status = {k: v for k, v in st.items() if k in (
'tmpfilename',
'filename',
'status',
@ -491,9 +540,22 @@ class Download:
'downloaded_bytes',
'speed',
'eta',
)})
)}
phase = self._download_phase_from_status(st)
if phase:
status['download_phase'] = phase
log.debug(f"put_status: status={status.get('status')}, phase={phase}, "
f"vcodec={st.get('info_dict', {}).get('vcodec') if isinstance(st.get('info_dict'), dict) else 'N/A'}, "
f"acodec={st.get('info_dict', {}).get('acodec') if isinstance(st.get('info_dict'), dict) else 'N/A'}")
self.status_queue.put(status)
def put_status_postprocessor(d):
if d.get('status') == 'started':
self.status_queue.put({
'status': 'postprocessing',
'download_phase': 'postprocessing',
'msg': d.get('postprocessor'),
})
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
filepath = d['info_dict']['filepath']
if '__finaldir' in d['info_dict']:
@ -573,14 +635,15 @@ class Download:
)
ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
self.status_queue.put({'status': 'finished' if ret == 0 else 'error', 'download_phase': None})
log.info(f"Finished download for: {self.info.title}")
except yt_dlp.utils.YoutubeDLError as exc:
log.error(f"Download error for {self.info.title}: {str(exc)}")
self.status_queue.put({'status': 'error', 'msg': str(exc)})
self.status_queue.put({'status': 'error', 'msg': str(exc), 'download_phase': None})
async def start(self, notifier):
log.info(f"Preparing download for: {self.info.title}")
self.paused = False
if Download.manager is None:
Download.manager = multiprocessing.Manager()
self.status_queue = Download.manager.Queue()
@ -589,6 +652,7 @@ class Download:
self.loop = asyncio.get_running_loop()
self.notifier = notifier
self.info.status = 'preparing'
self.info.download_phase = None
await self.notifier.updated(self.info)
self.status_task = asyncio.create_task(self.update_status())
await self.loop.run_in_executor(None, self.proc.join)
@ -610,6 +674,22 @@ class Download:
if self.status_queue is not None:
self.status_queue.put(None)
def pause(self):
log.info(f"Pausing download: {self.info.title}")
self.paused = True
self.start_generation += 1
self.info.status = 'paused'
self.info.download_phase = None
self.info.speed = None
self.info.eta = None
if self.running():
try:
self.proc.kill()
except Exception as e:
log.error(f"Error killing process for {self.info.title}: {e}")
if self.status_queue is not None:
self.status_queue.put(None)
def close(self):
log.info(f"Closing download process for: {self.info.title}")
if self.started():
@ -629,6 +709,14 @@ class Download:
status = await self.loop.run_in_executor(None, self.status_queue.get)
if status is None:
log.info(f"Status update finished for: {self.info.title}")
return
if self.paused:
self.info.status = 'paused'
self.info.download_phase = None
self.info.speed = None
self.info.eta = None
await self.notifier.updated(self.info)
return
if self.canceled:
log.info(f"Download {self.info.title} is canceled; stopping status updates.")
@ -693,6 +781,7 @@ class Download:
str(getattr(self.info, 'format', '')).lower() == 'txt'
):
self.info.filename = rel_path
continue
if 'thumbnail_file' in status:
thumbnail_file = status.get('thumbnail_file')
@ -702,8 +791,15 @@ class Download:
self.info.size = os.path.getsize(thumbnail_file)
continue
# All remaining messages must have a 'status' key
if 'status' not in status:
log.warning(f"Skipping status update without 'status' key for {self.info.title}: {list(status.keys())}")
continue
self.info.status = status['status']
self.info.msg = status.get('msg')
if 'download_phase' in status:
self.info.download_phase = status['download_phase']
if 'downloaded_bytes' in status:
total = status.get('total_bytes') or status.get('total_bytes_estimate')
if total:
@ -892,18 +988,29 @@ class DownloadQueue:
asyncio.create_task(self.__import_queue())
asyncio.create_task(self.__import_pending())
async def __start_download(self, download):
if download.canceled:
log.info(f"Download {download.info.title} was canceled, skipping start.")
async def __start_download(self, download, generation):
if generation != download.start_generation or download.canceled or download.paused:
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
return
async with self.semaphore:
if download.canceled:
log.info(f"Download {download.info.title} was canceled, skipping start.")
if generation != download.start_generation or download.canceled or download.paused:
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
return
await download.start(self.notifier)
self._post_download_cleanup(download)
def _post_download_cleanup(self, download):
key = getattr(download.info, 'key', download.info.url)
if download.paused:
download.info.status = 'paused'
download.info.download_phase = None
download.info.speed = None
download.info.eta = None
download.close()
if self.queue.exists(key):
self.queue.put(download)
asyncio.create_task(self.notifier.updated(download.info))
return
if download.info.status != 'finished':
if download.tmpfilename and os.path.isfile(download.tmpfilename):
try:
@ -911,8 +1018,34 @@ class DownloadQueue:
except OSError:
pass
download.info.status = 'error'
else:
# Captions-only downloads that produced no subtitle files should be
# reported as an error rather than a silent "success". This can
# happen when the requested language (e.g. "en") is unavailable and
# yt-dlp silently skips subtitle extraction without raising an
# error.
if getattr(download.info, 'download_type', '') == 'captions' and not getattr(download.info, 'filename', None):
subtitle_files = getattr(download.info, 'subtitle_files', [])
if not subtitle_files:
log.warning(
f"Captions download for \"{download.info.title}\" produced no files. "
f"Requested language: {getattr(download.info, 'subtitle_language', 'en')}, "
f"mode: {getattr(download.info, 'subtitle_mode', 'prefer_manual')}"
)
download.info.status = 'error'
download.info.error = (
f"No subtitles found for language \"{getattr(download.info, 'subtitle_language', 'en')}\". "
f"The video may not have subtitles in the requested language."
)
# Thumbnail-only downloads that produced no image file should also
# be reported as an error for the same reason.
elif getattr(download.info, 'download_type', '') == 'thumbnail' and not getattr(download.info, 'filename', None):
log.warning(
f"Thumbnail download for \"{download.info.title}\" produced no file."
)
download.info.status = 'error'
download.info.error = "No thumbnail found for this video."
download.close()
key = getattr(download.info, 'key', download.info.url)
if self.queue.exists(key):
self.queue.delete(key)
if download.canceled:
@ -1028,7 +1161,8 @@ class DownloadQueue:
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
if auto_start is True:
self.queue.put(download)
asyncio.create_task(self.__start_download(download))
download.start_generation += 1
asyncio.create_task(self.__start_download(download, download.start_generation))
else:
self.pending.put(download)
await self.notifier.added(dl)
@ -1297,13 +1431,25 @@ class DownloadQueue:
async def start_pending(self, ids):
for id in ids:
if self.queue.exists(id):
dl = self.queue.get(id)
if getattr(dl.info, 'status', None) == 'paused' or dl.paused:
dl.paused = False
dl.info.status = 'pending'
dl.info.speed = None
dl.info.eta = None
self.queue.put(dl)
dl.start_generation += 1
asyncio.create_task(self.__start_download(dl, dl.start_generation))
continue
if not self.pending.exists(id):
log.warning(f'requested start for non-existent download {id}')
continue
dl = self.pending.get(id)
self.queue.put(dl)
self.pending.delete(id)
asyncio.create_task(self.__start_download(dl))
dl.start_generation += 1
asyncio.create_task(self.__start_download(dl, dl.start_generation))
return {'status': 'ok'}
async def cancel(self, ids):
@ -1318,6 +1464,11 @@ class DownloadQueue:
log.warning(f'requested cancel for non-existent download {id}')
continue
dl = self.queue.get(id)
if getattr(dl.info, 'status', None) == 'paused' or dl.paused:
dl.cancel()
self.queue.delete(id)
await self.notifier.canceled(id)
continue
if dl.started():
dl.cancel()
else:
@ -1326,6 +1477,17 @@ class DownloadQueue:
await self.notifier.canceled(id)
return {'status': 'ok'}
async def pause(self, ids):
for id in ids:
if not self.queue.exists(id):
log.warning(f'requested pause for non-existent download {id}')
continue
dl = self.queue.get(id)
dl.pause()
self.queue.put(dl)
await self.notifier.updated(dl.info)
return {'status': 'ok'}
async def clear(self, ids):
for id in ids:
if not self.done.exists(id):
@ -1333,11 +1495,23 @@ class DownloadQueue:
continue
if self.config.DELETE_FILE_ON_TRASHCAN:
dl = self.done.get(id)
try:
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
os.remove(os.path.join(dldirectory, dl.info.filename))
except Exception as e:
log.warning(f'deleting file for download {id} failed with error message {e!r}')
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
# Delete the primary downloaded file
files_to_delete = [dl.info.filename]
# Also delete chapter files and subtitle files
for cf in getattr(dl.info, 'chapter_files', []) or []:
if isinstance(cf, dict) and cf.get('filename'):
files_to_delete.append(cf['filename'])
for sf in getattr(dl.info, 'subtitle_files', []) or []:
if isinstance(sf, dict) and sf.get('filename'):
files_to_delete.append(sf['filename'])
for filename in files_to_delete:
if not filename:
continue
try:
os.remove(os.path.join(dldirectory, filename))
except Exception as e:
log.warning(f'deleting file {filename} for download {id} failed with error message {e!r}')
self.done.delete(id)
await self.notifier.cleared(id)
return {'status': 'ok'}

122
docker-compose.local.yml Normal file
View file

@ -0,0 +1,122 @@
services:
mytube:
build:
context: .
args:
TARGETARCH: amd64
image: mytube
container_name: mytube
restart: unless-stopped
ports:
- "8081:8081"
volumes:
# Required: download directory (matches DOWNLOAD_DIR, STATE_DIR, TEMP_DIR)
- /path/to/downloads:/downloads
# Optional: environment configuration file
# - /path/to/.env:/app/.env:ro
# Optional: yt-dlp global options file
# - /path/to/ytdl-options.json:/config/ytdl-options.json:ro
# Optional: yt-dlp presets file
# - /path/to/ytdl-presets.json:/config/ytdl-presets.json:ro
# Optional: HTTPS certificates (if HTTPS=true)
# - /path/to/ssl/crt.pem:/ssl/crt.pem:ro
# - /path/to/ssl/key.pem:/ssl/key.pem:ro
# Optional: custom robots.txt
# - /path/to/robots.txt:/etc/robots.txt:ro
environment:
ENV_FILE: ""
# === Container Setup ===
# PUID: "1000"
# PGID: "1000"
# UMASK: "022"
# DEFAULT_THEME: auto
# LOGLEVEL: INFO
# ENABLE_ACCESSLOG: "false"
# === Directory Configuration ===
# DOWNLOAD_DIR: /downloads
# STATE_DIR: /downloads/.metube
# TEMP_DIR: /downloads
# AUDIO_DOWNLOAD_DIR: /audio
# === Web Server ===
# HOST: 0.0.0.0
# PORT: "8081"
# PUBLIC_MODE: "false"
# URL_PREFIX: /mytube/
# PUBLIC_HOST_URL: download/
# PUBLIC_HOST_AUDIO_URL: audio_download/
# HTTPS: "false"
# CERTFILE: /ssl/crt.pem
# KEYFILE: /ssl/key.pem
# CORS_ALLOWED_ORIGINS: ""
# ROBOTS_TXT: ""
# === Download Behavior ===
# MAX_CONCURRENT_DOWNLOADS: "3"
# DELETE_FILE_ON_TRASHCAN: "false"
# DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT: "0"
# SUBSCRIPTION_DEFAULT_CHECK_INTERVAL: "60"
# SUBSCRIPTION_SCAN_PLAYLIST_END: "50"
# SUBSCRIPTION_MAX_SEEN_IDS: "50000"
# CLEAR_COMPLETED_AFTER: "0"
# === File Naming & Output ===
# OUTPUT_TEMPLATE: "%(title)s.%(ext)s"
# OUTPUT_TEMPLATE_CHAPTER: "%(title)s - %(section_number)02d - %(section_title)s.%(ext)s"
# OUTPUT_TEMPLATE_PLAYLIST: "%(playlist_title)s/%(title)s.%(ext)s"
# OUTPUT_TEMPLATE_CHANNEL: "%(channel)s/%(title)s.%(ext)s"
# === yt-dlp Options ===
# YTDL_OPTIONS: "{}"
# YTDL_OPTIONS_FILE: /config/ytdl-options.json
# YTDL_OPTIONS_PRESETS: "{}"
# YTDL_OPTIONS_PRESETS_FILE: /config/ytdl-presets.json
# ALLOW_YTDL_OPTIONS_OVERRIDES: "false"
# === Directory Features ===
# CUSTOM_DIRS: "true"
# CREATE_CUSTOM_DIRS: "true"
# DOWNLOAD_DIRS_INDEXABLE: "false"
# CUSTOM_DIRS_EXCLUDE_REGEX: "(^|/)[.@].*$"
# CHOWN_DIRS: "true"
# === Custom Environment File ===
# ENV_FILE: /path/to/custom.env
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:${PORT:-8081}/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
# Optional: set working user
# user: "${PUID:-1000}:${PGID:-1000}"
# Optional: network configuration
# networks:
# - default
# Optional: resource limits
# deploy:
# resources:
# limits:
# cpus: '1'
# memory: 1G
# reservations:
# cpus: '0.5'
# memory: 512M
# Optional: define custom network
# networks:
# default:
# name: mytube-network
# driver: bridge

View file

@ -480,6 +480,20 @@
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Download</span>
<select class="form-select"
name="autoDownloadCompleted"
[(ngModel)]="autoDownloadCompleted"
(change)="autoDownloadCompletedChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Automatically save completed files through the browser">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Subscription Check (min)</span>
@ -688,6 +702,7 @@
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" />&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" />&nbsp; Download selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queuePauseSelected (click)="pauseSelectedDownloads()"><fa-icon [icon]="faPause" />&nbsp; Pause selected</button>
</div>
<div class="overflow-auto">
<table class="table">
@ -710,7 +725,12 @@
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }} </div>
<div>
{{ download.value.title }}
@if (downloadPhaseLabel(download.value)) {
<span class="badge text-bg-secondary ms-2">{{ downloadPhaseLabel(download.value) }}</span>
}
</div>
<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>
@ -722,6 +742,12 @@
@if (download.value.status === 'pending') {
<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>
}
@if (download.value.status === 'paused') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Resume download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faPlay" /></button>
}
@if (download.value.status === 'downloading' || download.value.status === 'preparing') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Pause download for ' + download.value.title" (click)="pauseDownloadByKey(download.key)"><fa-icon [icon]="faPause" /></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>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div>
@ -878,7 +904,36 @@
</td>
</tr>
}
}
}
@if (entry[1].subtitle_files && entry[1].subtitle_files.length > 0) {
@for (subtitleFile of entry[1].subtitle_files; track subtitleFile.filename) {
<tr [class.disabled]='entry[1].deleting'>
<td></td>
<td>
<div style="padding-left: 2rem;">
<fa-icon [icon]="faClosedCaptioning" class="text-info me-2" />
<a href="{{buildChapterDownloadLink(entry[1], subtitleFile.filename)}}" target="_blank" [attr.aria-label]="'Open subtitle file ' + getChapterFileName(subtitleFile.filename)">{{
getChapterFileName(subtitleFile.filename) }}</a>
</div>
</td>
<td></td>
<td></td>
<td></td>
<td>
@if (subtitleFile.size) {
<span>{{ subtitleFile.size | fileSize }}</span>
}
</td>
<td></td>
<td>
<div class="d-flex">
<a href="{{buildChapterDownloadLink(entry[1], subtitleFile.filename)}}" download [attr.aria-label]="'Download subtitle file ' + getChapterFileName(subtitleFile.filename)"
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>

View file

@ -5,6 +5,7 @@ import { App } from './app';
import { DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { CookieService } from 'ngx-cookie-service';
import { Download } from './interfaces';
class DownloadsServiceStub {
loading = false;
@ -18,6 +19,7 @@ class DownloadsServiceStub {
customDirsChanged = new Subject<Record<string, string[]>>();
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
updated = new Subject<void>();
completedDownload = new Subject<Download>();
getCookieStatus() {
return of({ status: 'ok', has_cookies: false });

View file

@ -7,7 +7,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay, faClosedCaptioning } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
@ -78,6 +78,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
folder!: string;
customNamePrefix!: string;
autoStart: boolean;
autoDownloadCompleted: boolean;
playlistItemLimit!: number;
splitByChapters: boolean;
chapterTemplate: string;
@ -128,6 +129,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
cachedSortedDone: [string, Download][] = [];
lastCopiedErrorId: string | null = null;
private previousDownloadType = 'video';
private autoDownloadedResults = new Set<string>();
private addRequestSub?: Subscription;
private selectionsByType: Record<string, {
codec: string;
@ -158,6 +160,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
readonly queueMasterCheckbox = viewChild<SelectAllCheckboxComponent>('queueMasterCheckboxRef');
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
readonly queuePauseSelected = viewChild.required<ElementRef>('queuePauseSelected');
readonly doneMasterCheckbox = viewChild<SelectAllCheckboxComponent>('doneMasterCheckboxRef');
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
@ -185,6 +188,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
faUpload = faUpload;
faPause = faPause;
faPlay = faPlay;
faClosedCaptioning = faClosedCaptioning;
subtitleLanguages = [
{ id: 'en', text: 'English' },
{ id: 'ar', text: 'Arabic' },
@ -242,6 +246,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.format = this.cookieService.get('metube_format') || 'any';
this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.autoDownloadCompleted = this.cookieService.get('metube_auto_download_completed') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
// Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
@ -293,6 +298,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.updateMetrics();
this.cdr.markForCheck();
});
this.downloads.completedDownload.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((download) => {
this.autoDownloadResult(download);
});
this.subscriptionsSvc.subscriptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.rebuildCachedSubs();
@ -885,6 +893,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
queueSelectionChanged(checked: number) {
this.queueDelSelected().nativeElement.disabled = checked === 0;
this.queueDownloadSelected().nativeElement.disabled = checked === 0;
this.queuePauseSelected().nativeElement.disabled = checked === 0;
}
doneSelectionChanged(checked: number) {
@ -1128,6 +1137,16 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.downloads.startById([id]).subscribe();
}
pauseDownloadByKey(id: string) {
this.downloads.pauseById([id]).subscribe();
}
autoDownloadCompletedChanged() {
this.cookieService.set('metube_auto_download_completed', String(this.autoDownloadCompleted), {
expires: this.settingsCookieExpiryDays,
});
}
retryDownload(key: string, download: Download) {
this.addDownload({
url: download.url,
@ -1161,6 +1180,18 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
}
pauseSelectedDownloads() {
const ids: string[] = [];
this.downloads.queue.forEach((dl: Download, key: string) => {
if (dl.checked && (dl.status === 'downloading' || dl.status === 'preparing')) {
ids.push(key);
}
});
if (ids.length) {
this.downloads.pauseById(ids).subscribe();
}
}
delSelectedDownloads(where: State) {
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
}
@ -1196,6 +1227,28 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
});
}
private autoDownloadResult(download: Download) {
if (!this.autoDownloadCompleted || download.status !== 'finished' || !download.filename) {
return;
}
const key = `${download.url}|${download.filename}|${download.timestamp ?? ''}`;
if (this.autoDownloadedResults.has(key)) {
return;
}
this.autoDownloadedResults.add(key);
this.triggerBrowserDownload(download);
}
private triggerBrowserDownload(download: Download) {
const link = document.createElement('a');
link.href = this.buildDownloadLink(download);
link.setAttribute('download', download.filename);
link.setAttribute('target', '_self');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
buildDownloadLink(download: Download) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
if (download.download_type === 'audio' || download.filename.endsWith('.mp3')) {
@ -1209,6 +1262,24 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return baseDir + encodeURIComponent(download.filename);
}
downloadPhaseLabel(download: Download): string {
switch (download.download_phase) {
case 'video':
return 'Video';
case 'audio':
return 'Audio';
case 'media':
return 'Media';
case 'postprocessing':
return 'Post-processing';
default:
if (download.status === 'paused') return 'Paused';
if (download.status === 'pending') return 'Pending';
if (download.status === 'preparing') return 'Preparing';
return '';
}
}
buildResultItemTooltip(download: Download) {
const parts = [];
if (download.msg) {
@ -1581,7 +1652,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
speed += download.speed || 0;
} else if (download.status === 'preparing') {
active++;
} else if (download.status === 'pending') {
} else if (download.status === 'postprocessing') {
active++;
} else if (download.status === 'pending' || download.status === 'paused') {
queued++;
}
});

View file

@ -19,6 +19,7 @@ export interface Download {
clip_start?: number;
clip_end?: number;
status: string;
download_phase?: string;
msg: string;
percent: number;
speed: number;
@ -30,4 +31,5 @@ export interface Download {
error?: string;
deleting?: boolean;
chapter_files?: { filename: string, size: number }[];
subtitle_files?: { filename: string, size: number }[];
}

View file

@ -153,6 +153,13 @@ describe('DownloadsService', () => {
req.flush({});
});
it('pauseById posts ids', () => {
service.pauseById(['a', 'b']).subscribe();
const req = httpMock.expectOne('pause');
expect(req.request.body).toEqual({ ids: ['a', 'b'] });
req.flush({});
});
it('delById marks items deleting and posts delete', () => {
const dl = makeDownload({ deleting: false });
service.queue.set('u1', dl);
@ -271,7 +278,7 @@ describe('DownloadsService', () => {
expect(updated?.deleting).toBe(true);
});
it('socket completed moves entry to done', () => {
it('socket completed moves entry to done and emits completedDownload', () => {
service.queue.set('u1', {
id: '1',
title: 't',
@ -290,9 +297,14 @@ describe('DownloadsService', () => {
filename: '',
checked: false,
});
let completed: Download | undefined;
service.completedDownload.subscribe((download) => {
completed = download;
});
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
expect(service.queue.has('u1')).toBe(false);
expect(service.done.has('u1')).toBe(true);
expect(completed?.url).toBe('u1');
});
it('socket canceled removes from queue', () => {

View file

@ -40,6 +40,7 @@ export class DownloadsService {
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
configurationChanged = new Subject<Record<string, unknown>>();
updated = new Subject<void>();
completedDownload = new Subject<Download>();
configuration: Record<string, unknown> = {};
customDirs: Record<string, string[]> = {};
@ -90,6 +91,7 @@ export class DownloadsService {
this.done.set(key, data);
this.queueChanged.next();
this.doneChanged.next();
this.completedDownload.next(data);
});
this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed())
@ -177,6 +179,10 @@ export class DownloadsService {
return this.http.post('start', {ids: ids});
}
public pauseById(ids: string[]) {
return this.http.post('pause', {ids: ids});
}
public delById(where: State, ids: string[]) {
const map = this[where];
const touched: [string, Download, boolean | undefined][] = [];