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:
commit
6e65441eba
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
15
.codex/hooks.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node .claude/setup.mjs"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.github/workflows/sync-upstream.yml
vendored
2
.github/workflows/sync-upstream.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
upstream_sync_repo: alexta69/metube
|
upstream_sync_repo: alexta69/metube
|
||||||
upstream_sync_branch: master
|
upstream_sync_branch: master
|
||||||
target_sync_branch: master
|
target_sync_branch: master
|
||||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
|
target_repo_token: ${{ secrets.UPSTREAM_TOKEN }}
|
||||||
test_mode: false
|
test_mode: false
|
||||||
|
|
||||||
- name: Sync check
|
- name: Sync check
|
||||||
|
|
|
||||||
11
Dockerfile
11
Dockerfile
|
|
@ -16,6 +16,7 @@ COPY pyproject.toml uv.lock docker-entrypoint.sh ./
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
||||||
chmod +x 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 update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|
@ -27,28 +28,28 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
||||||
curl \
|
curl \
|
||||||
tini \
|
tini \
|
||||||
build-essential && \
|
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_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
||||||
uv cache clean && \
|
uv cache clean && \
|
||||||
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
|
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 && \
|
apt-get purge -y --auto-remove build-essential && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
mkdir /.cache && chmod 777 /.cache
|
mkdir /.cache && chmod 777 /.cache
|
||||||
|
|
||||||
ARG TARGETARCH
|
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 \
|
case "$TARGETARCH" in \
|
||||||
amd64) BGUTIL_ARCH="x86_64" ;; \
|
amd64) BGUTIL_ARCH="x86_64" ;; \
|
||||||
arm64) BGUTIL_ARCH="aarch64" ;; \
|
arm64) BGUTIL_ARCH="aarch64" ;; \
|
||||||
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
|
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
|
||||||
esac && \
|
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}" && \
|
"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 && \
|
chmod +x /usr/local/bin/bgutil-pot && \
|
||||||
PLUGIN_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" && \
|
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" && \
|
"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}" && \
|
unzip -q /tmp/bgutil-ytdlp-pot-provider-rs.zip -d "${PLUGIN_DIR}" && \
|
||||||
rm /tmp/bgutil-ytdlp-pot-provider-rs.zip
|
rm /tmp/bgutil-ytdlp-pot-provider-rs.zip
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,74 @@ CODEC_FILTER_MAP = {
|
||||||
'vp9': "[vcodec~='^vp0?9']",
|
'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:
|
def _normalize_caption_mode(mode: str) -> str:
|
||||||
mode = (mode or "").strip()
|
mode = (mode or "").strip()
|
||||||
|
|
@ -141,21 +209,20 @@ def get_opts(
|
||||||
if mode == "manual_only":
|
if mode == "manual_only":
|
||||||
opts["writesubtitles"] = True
|
opts["writesubtitles"] = True
|
||||||
opts["writeautomaticsub"] = False
|
opts["writeautomaticsub"] = False
|
||||||
opts["subtitleslangs"] = [language]
|
opts["subtitleslangs"] = _subtitle_lang_list(language)
|
||||||
elif mode == "auto_only":
|
elif mode == "auto_only":
|
||||||
opts["writesubtitles"] = False
|
opts["writesubtitles"] = False
|
||||||
opts["writeautomaticsub"] = True
|
opts["writeautomaticsub"] = True
|
||||||
# `-orig` captures common YouTube auto-sub tags. The plain language
|
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
|
||||||
# fallback keeps behavior useful across other extractors.
|
|
||||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
|
||||||
elif mode == "prefer_auto":
|
elif mode == "prefer_auto":
|
||||||
opts["writesubtitles"] = True
|
opts["writesubtitles"] = True
|
||||||
opts["writeautomaticsub"] = True
|
opts["writeautomaticsub"] = True
|
||||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
|
||||||
else:
|
else:
|
||||||
|
# prefer_manual (default)
|
||||||
opts["writesubtitles"] = True
|
opts["writesubtitles"] = True
|
||||||
opts["writeautomaticsub"] = True
|
opts["writeautomaticsub"] = True
|
||||||
opts["subtitleslangs"] = [language, f"{language}-orig"]
|
opts["subtitleslangs"] = _subtitle_lang_list(language, include_orig=True)
|
||||||
|
|
||||||
opts["postprocessors"] = postprocessors + (
|
opts["postprocessors"] = postprocessors + (
|
||||||
opts["postprocessors"] if "postprocessors" in opts else []
|
opts["postprocessors"] if "postprocessors" in opts else []
|
||||||
|
|
|
||||||
11
app/main.py
11
app/main.py
|
|
@ -988,6 +988,16 @@ async def start(request):
|
||||||
status = await dqueue.start_pending(ids)
|
status = await dqueue.start_pending(ids)
|
||||||
return web.Response(text=serializer.encode(status))
|
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')
|
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 + 'subscriptions/check', add_cors)
|
||||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', 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 + 'delete-cookies', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'pause', add_cors)
|
||||||
|
|
||||||
async def on_prepare(request, response):
|
async def on_prepare(request, response):
|
||||||
origin = request.headers.get('Origin')
|
origin = request.headers.get('Origin')
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ def mock_dqueue(monkeypatch):
|
||||||
d.add = AsyncMock(return_value={"status": "ok"})
|
d.add = AsyncMock(return_value={"status": "ok"})
|
||||||
d.cancel = AsyncMock(return_value={"status": "ok"})
|
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||||
d.start_pending = 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.cancel_add = MagicMock()
|
||||||
d.queue = MagicMock()
|
d.queue = MagicMock()
|
||||||
d.done = MagicMock()
|
d.done = MagicMock()
|
||||||
|
|
@ -212,6 +213,21 @@ async def test_start_pending(mock_dqueue):
|
||||||
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_history_shape(mock_dqueue):
|
async def test_history_shape(mock_dqueue):
|
||||||
mock_dqueue.queue.saved_items.return_value = []
|
mock_dqueue.queue.saved_items.return_value = []
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ class DlFormatsTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertTrue(opts.get("writesubtitles"))
|
self.assertTrue(opts.get("writesubtitles"))
|
||||||
self.assertFalse(opts.get("writeautomaticsub"))
|
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):
|
def test_get_opts_captions_auto_only(self):
|
||||||
opts = get_opts(
|
opts = get_opts(
|
||||||
|
|
@ -100,7 +100,7 @@ class DlFormatsTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertFalse(opts.get("writesubtitles"))
|
self.assertFalse(opts.get("writesubtitles"))
|
||||||
self.assertTrue(opts.get("writeautomaticsub"))
|
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):
|
def test_get_opts_captions_prefer_auto(self):
|
||||||
opts = get_opts(
|
opts = get_opts(
|
||||||
|
|
@ -108,13 +108,13 @@ class DlFormatsTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertTrue(opts.get("writesubtitles"))
|
self.assertTrue(opts.get("writesubtitles"))
|
||||||
self.assertTrue(opts.get("writeautomaticsub"))
|
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):
|
def test_get_opts_captions_prefer_manual_default_branch(self):
|
||||||
opts = get_opts(
|
opts = get_opts(
|
||||||
"captions", "auto", "srt", "best", {}, subtitle_language="it", subtitle_mode="prefer_manual"
|
"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):
|
def test_get_opts_captions_txt_maps_to_srt_format(self):
|
||||||
opts = get_opts("captions", "auto", "txt", "best", {})
|
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")
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,92 @@ async def test_start_pending_moves_to_queue(dq_env):
|
||||||
assert not dq.pending.exists(url)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
||||||
notifier = AsyncMock()
|
notifier = AsyncMock()
|
||||||
|
|
|
||||||
208
app/ytdl.py
208
app/ytdl.py
|
|
@ -206,9 +206,11 @@ class DownloadInfo:
|
||||||
self.custom_name_prefix = custom_name_prefix
|
self.custom_name_prefix = custom_name_prefix
|
||||||
self.msg = self.percent = self.speed = self.eta = None
|
self.msg = self.percent = self.speed = self.eta = None
|
||||||
self.status = "pending"
|
self.status = "pending"
|
||||||
|
self.download_phase = None
|
||||||
self.size = None
|
self.size = None
|
||||||
self.timestamp = time.time_ns()
|
self.timestamp = time.time_ns()
|
||||||
self.error = error
|
self.error = error
|
||||||
|
self.filename = None
|
||||||
# Strip non-pickleable values (generators, iterators, locks, etc.) for shelve
|
# 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.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
|
||||||
self.playlist_item_limit = playlist_item_limit
|
self.playlist_item_limit = playlist_item_limit
|
||||||
|
|
@ -220,6 +222,7 @@ 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.chapter_files = []
|
||||||
self.subtitle_files = []
|
self.subtitle_files = []
|
||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
|
|
@ -288,10 +291,14 @@ class DownloadInfo:
|
||||||
self.subtitle_files = []
|
self.subtitle_files = []
|
||||||
if not hasattr(self, "chapter_files"):
|
if not hasattr(self, "chapter_files"):
|
||||||
self.chapter_files = []
|
self.chapter_files = []
|
||||||
|
if not hasattr(self, "filename"):
|
||||||
|
self.filename = None
|
||||||
if not hasattr(self, "clip_start"):
|
if not hasattr(self, "clip_start"):
|
||||||
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, "download_phase"):
|
||||||
|
self.download_phase = None
|
||||||
|
|
||||||
|
|
||||||
_PERSISTED_DOWNLOAD_FIELDS = (
|
_PERSISTED_DOWNLOAD_FIELDS = (
|
||||||
|
|
@ -314,12 +321,14 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
||||||
"clip_start",
|
"clip_start",
|
||||||
"clip_end",
|
"clip_end",
|
||||||
"status",
|
"status",
|
||||||
|
"download_phase",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"error",
|
"error",
|
||||||
"msg",
|
"msg",
|
||||||
"filename",
|
"filename",
|
||||||
"size",
|
"size",
|
||||||
"chapter_files",
|
"chapter_files",
|
||||||
|
"subtitle_files",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -368,6 +377,8 @@ def _download_info_from_record(record: dict[str, Any]) -> DownloadInfo:
|
||||||
info.eta = None
|
info.eta = None
|
||||||
if not hasattr(info, "status"):
|
if not hasattr(info, "status"):
|
||||||
info.status = "pending"
|
info.status = "pending"
|
||||||
|
if not hasattr(info, "download_phase"):
|
||||||
|
info.download_phase = None
|
||||||
if not hasattr(info, "size"):
|
if not hasattr(info, "size"):
|
||||||
info.size = None
|
info.size = None
|
||||||
if not hasattr(info, "error"):
|
if not hasattr(info, "error"):
|
||||||
|
|
@ -470,18 +481,56 @@ class Download:
|
||||||
if "impersonate" in self.ytdl_opts:
|
if "impersonate" in self.ytdl_opts:
|
||||||
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
|
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
|
||||||
self.canceled = False
|
self.canceled = False
|
||||||
|
self.paused = getattr(self.info, 'status', None) == 'paused'
|
||||||
self.tmpfilename = None
|
self.tmpfilename = None
|
||||||
self.status_queue = None
|
self.status_queue = None
|
||||||
self.proc = None
|
self.proc = None
|
||||||
self.loop = None
|
self.loop = None
|
||||||
self.notifier = 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):
|
def _download(self):
|
||||||
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
||||||
try:
|
try:
|
||||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||||
def put_status(st):
|
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',
|
'tmpfilename',
|
||||||
'filename',
|
'filename',
|
||||||
'status',
|
'status',
|
||||||
|
|
@ -491,9 +540,22 @@ class Download:
|
||||||
'downloaded_bytes',
|
'downloaded_bytes',
|
||||||
'speed',
|
'speed',
|
||||||
'eta',
|
'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):
|
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':
|
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||||
filepath = d['info_dict']['filepath']
|
filepath = d['info_dict']['filepath']
|
||||||
if '__finaldir' in d['info_dict']:
|
if '__finaldir' in d['info_dict']:
|
||||||
|
|
@ -573,14 +635,15 @@ class Download:
|
||||||
)
|
)
|
||||||
|
|
||||||
ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
|
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}")
|
log.info(f"Finished download for: {self.info.title}")
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
log.error(f"Download error for {self.info.title}: {str(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):
|
async def start(self, notifier):
|
||||||
log.info(f"Preparing download for: {self.info.title}")
|
log.info(f"Preparing download for: {self.info.title}")
|
||||||
|
self.paused = False
|
||||||
if Download.manager is None:
|
if Download.manager is None:
|
||||||
Download.manager = multiprocessing.Manager()
|
Download.manager = multiprocessing.Manager()
|
||||||
self.status_queue = Download.manager.Queue()
|
self.status_queue = Download.manager.Queue()
|
||||||
|
|
@ -589,6 +652,7 @@ class Download:
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.info.status = 'preparing'
|
self.info.status = 'preparing'
|
||||||
|
self.info.download_phase = None
|
||||||
await self.notifier.updated(self.info)
|
await self.notifier.updated(self.info)
|
||||||
self.status_task = asyncio.create_task(self.update_status())
|
self.status_task = asyncio.create_task(self.update_status())
|
||||||
await self.loop.run_in_executor(None, self.proc.join)
|
await self.loop.run_in_executor(None, self.proc.join)
|
||||||
|
|
@ -610,6 +674,22 @@ class Download:
|
||||||
if self.status_queue is not None:
|
if self.status_queue is not None:
|
||||||
self.status_queue.put(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):
|
def close(self):
|
||||||
log.info(f"Closing download process for: {self.info.title}")
|
log.info(f"Closing download process for: {self.info.title}")
|
||||||
if self.started():
|
if self.started():
|
||||||
|
|
@ -629,6 +709,14 @@ class Download:
|
||||||
status = await self.loop.run_in_executor(None, self.status_queue.get)
|
status = await self.loop.run_in_executor(None, self.status_queue.get)
|
||||||
if status is None:
|
if status is None:
|
||||||
log.info(f"Status update finished for: {self.info.title}")
|
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
|
return
|
||||||
if self.canceled:
|
if self.canceled:
|
||||||
log.info(f"Download {self.info.title} is canceled; stopping status updates.")
|
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'
|
str(getattr(self.info, 'format', '')).lower() == 'txt'
|
||||||
):
|
):
|
||||||
self.info.filename = rel_path
|
self.info.filename = rel_path
|
||||||
|
continue
|
||||||
|
|
||||||
if 'thumbnail_file' in status:
|
if 'thumbnail_file' in status:
|
||||||
thumbnail_file = status.get('thumbnail_file')
|
thumbnail_file = status.get('thumbnail_file')
|
||||||
|
|
@ -702,8 +791,15 @@ class Download:
|
||||||
self.info.size = os.path.getsize(thumbnail_file)
|
self.info.size = os.path.getsize(thumbnail_file)
|
||||||
continue
|
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.status = status['status']
|
||||||
self.info.msg = status.get('msg')
|
self.info.msg = status.get('msg')
|
||||||
|
if 'download_phase' in status:
|
||||||
|
self.info.download_phase = status['download_phase']
|
||||||
if 'downloaded_bytes' in status:
|
if 'downloaded_bytes' in status:
|
||||||
total = status.get('total_bytes') or status.get('total_bytes_estimate')
|
total = status.get('total_bytes') or status.get('total_bytes_estimate')
|
||||||
if total:
|
if total:
|
||||||
|
|
@ -892,18 +988,29 @@ class DownloadQueue:
|
||||||
asyncio.create_task(self.__import_queue())
|
asyncio.create_task(self.__import_queue())
|
||||||
asyncio.create_task(self.__import_pending())
|
asyncio.create_task(self.__import_pending())
|
||||||
|
|
||||||
async def __start_download(self, download):
|
async def __start_download(self, download, generation):
|
||||||
if download.canceled:
|
if generation != download.start_generation or download.canceled or download.paused:
|
||||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
|
||||||
return
|
return
|
||||||
async with self.semaphore:
|
async with self.semaphore:
|
||||||
if download.canceled:
|
if generation != download.start_generation or download.canceled or download.paused:
|
||||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
|
||||||
return
|
return
|
||||||
await download.start(self.notifier)
|
await download.start(self.notifier)
|
||||||
self._post_download_cleanup(download)
|
self._post_download_cleanup(download)
|
||||||
|
|
||||||
def _post_download_cleanup(self, 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.info.status != 'finished':
|
||||||
if download.tmpfilename and os.path.isfile(download.tmpfilename):
|
if download.tmpfilename and os.path.isfile(download.tmpfilename):
|
||||||
try:
|
try:
|
||||||
|
|
@ -911,8 +1018,34 @@ class DownloadQueue:
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
download.info.status = 'error'
|
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()
|
download.close()
|
||||||
key = getattr(download.info, 'key', download.info.url)
|
|
||||||
if self.queue.exists(key):
|
if self.queue.exists(key):
|
||||||
self.queue.delete(key)
|
self.queue.delete(key)
|
||||||
if download.canceled:
|
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)
|
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
||||||
if auto_start is True:
|
if auto_start is True:
|
||||||
self.queue.put(download)
|
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:
|
else:
|
||||||
self.pending.put(download)
|
self.pending.put(download)
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
|
|
@ -1297,13 +1431,25 @@ class DownloadQueue:
|
||||||
|
|
||||||
async def start_pending(self, ids):
|
async def start_pending(self, ids):
|
||||||
for id in 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):
|
if not self.pending.exists(id):
|
||||||
log.warning(f'requested start for non-existent download {id}')
|
log.warning(f'requested start for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
dl = self.pending.get(id)
|
dl = self.pending.get(id)
|
||||||
self.queue.put(dl)
|
self.queue.put(dl)
|
||||||
self.pending.delete(id)
|
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'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
|
|
@ -1318,6 +1464,11 @@ 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 getattr(dl.info, 'status', None) == 'paused' or dl.paused:
|
||||||
|
dl.cancel()
|
||||||
|
self.queue.delete(id)
|
||||||
|
await self.notifier.canceled(id)
|
||||||
|
continue
|
||||||
if dl.started():
|
if dl.started():
|
||||||
dl.cancel()
|
dl.cancel()
|
||||||
else:
|
else:
|
||||||
|
|
@ -1326,6 +1477,17 @@ class DownloadQueue:
|
||||||
await self.notifier.canceled(id)
|
await self.notifier.canceled(id)
|
||||||
return {'status': 'ok'}
|
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):
|
async def clear(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.done.exists(id):
|
if not self.done.exists(id):
|
||||||
|
|
@ -1333,11 +1495,23 @@ class DownloadQueue:
|
||||||
continue
|
continue
|
||||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||||
dl = self.done.get(id)
|
dl = self.done.get(id)
|
||||||
try:
|
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||||
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
# Delete the primary downloaded file
|
||||||
os.remove(os.path.join(dldirectory, dl.info.filename))
|
files_to_delete = [dl.info.filename]
|
||||||
except Exception as e:
|
# Also delete chapter files and subtitle files
|
||||||
log.warning(f'deleting file for download {id} failed with error message {e!r}')
|
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)
|
self.done.delete(id)
|
||||||
await self.notifier.cleared(id)
|
await self.notifier.cleared(id)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
|
||||||
122
docker-compose.local.yml
Normal file
122
docker-compose.local.yml
Normal 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
|
||||||
|
|
@ -480,6 +480,20 @@
|
||||||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||||
</div>
|
</div>
|
||||||
</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="col-md-6">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">Subscription Check (min)</span>
|
<span class="input-group-text">Subscription Check (min)</span>
|
||||||
|
|
@ -688,6 +702,7 @@
|
||||||
<div class="px-2 py-3 border-bottom">
|
<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" /> Cancel selected</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> 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" /> Download 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" /> 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" /> Pause selected</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|
@ -710,7 +725,12 @@
|
||||||
</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>
|
||||||
|
{{ 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"
|
<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" />
|
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -722,6 +742,12 @@
|
||||||
@if (download.value.status === 'pending') {
|
@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>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
@ -878,7 +904,36 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { App } from './app';
|
||||||
import { DownloadsService } from './services/downloads.service';
|
import { DownloadsService } from './services/downloads.service';
|
||||||
import { SubscriptionsService } from './services/subscriptions.service';
|
import { SubscriptionsService } from './services/subscriptions.service';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
|
import { Download } from './interfaces';
|
||||||
|
|
||||||
class DownloadsServiceStub {
|
class DownloadsServiceStub {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
@ -18,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>();
|
||||||
|
completedDownload = new Subject<Download>();
|
||||||
|
|
||||||
getCookieStatus() {
|
getCookieStatus() {
|
||||||
return of({ status: 'ok', has_cookies: false });
|
return of({ status: 'ok', has_cookies: false });
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
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 { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
||||||
|
|
@ -78,6 +78,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
folder!: string;
|
folder!: string;
|
||||||
customNamePrefix!: string;
|
customNamePrefix!: string;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
|
autoDownloadCompleted: boolean;
|
||||||
playlistItemLimit!: number;
|
playlistItemLimit!: number;
|
||||||
splitByChapters: boolean;
|
splitByChapters: boolean;
|
||||||
chapterTemplate: string;
|
chapterTemplate: string;
|
||||||
|
|
@ -128,6 +129,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
cachedSortedDone: [string, Download][] = [];
|
cachedSortedDone: [string, Download][] = [];
|
||||||
lastCopiedErrorId: string | null = null;
|
lastCopiedErrorId: string | null = null;
|
||||||
private previousDownloadType = 'video';
|
private previousDownloadType = 'video';
|
||||||
|
private autoDownloadedResults = new Set<string>();
|
||||||
private addRequestSub?: Subscription;
|
private addRequestSub?: Subscription;
|
||||||
private selectionsByType: Record<string, {
|
private selectionsByType: Record<string, {
|
||||||
codec: string;
|
codec: string;
|
||||||
|
|
@ -158,6 +160,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
readonly queueMasterCheckbox = viewChild<SelectAllCheckboxComponent>('queueMasterCheckboxRef');
|
readonly queueMasterCheckbox = viewChild<SelectAllCheckboxComponent>('queueMasterCheckboxRef');
|
||||||
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
|
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
|
||||||
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
|
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
|
||||||
|
readonly queuePauseSelected = viewChild.required<ElementRef>('queuePauseSelected');
|
||||||
readonly doneMasterCheckbox = viewChild<SelectAllCheckboxComponent>('doneMasterCheckboxRef');
|
readonly doneMasterCheckbox = viewChild<SelectAllCheckboxComponent>('doneMasterCheckboxRef');
|
||||||
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
|
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
|
||||||
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
|
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
|
||||||
|
|
@ -185,6 +188,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
faUpload = faUpload;
|
faUpload = faUpload;
|
||||||
faPause = faPause;
|
faPause = faPause;
|
||||||
faPlay = faPlay;
|
faPlay = faPlay;
|
||||||
|
faClosedCaptioning = faClosedCaptioning;
|
||||||
subtitleLanguages = [
|
subtitleLanguages = [
|
||||||
{ id: 'en', text: 'English' },
|
{ id: 'en', text: 'English' },
|
||||||
{ id: 'ar', text: 'Arabic' },
|
{ id: 'ar', text: 'Arabic' },
|
||||||
|
|
@ -242,6 +246,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
this.format = this.cookieService.get('metube_format') || 'any';
|
this.format = this.cookieService.get('metube_format') || 'any';
|
||||||
this.quality = this.cookieService.get('metube_quality') || 'best';
|
this.quality = this.cookieService.get('metube_quality') || 'best';
|
||||||
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
|
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';
|
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
|
||||||
// Will be set from backend configuration, use empty string as placeholder
|
// Will be set from backend configuration, use empty string as placeholder
|
||||||
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
|
||||||
|
|
@ -293,6 +298,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
|
this.downloads.completedDownload.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((download) => {
|
||||||
|
this.autoDownloadResult(download);
|
||||||
|
});
|
||||||
|
|
||||||
this.subscriptionsSvc.subscriptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
this.subscriptionsSvc.subscriptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
this.rebuildCachedSubs();
|
this.rebuildCachedSubs();
|
||||||
|
|
@ -885,6 +893,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
queueSelectionChanged(checked: number) {
|
queueSelectionChanged(checked: number) {
|
||||||
this.queueDelSelected().nativeElement.disabled = checked === 0;
|
this.queueDelSelected().nativeElement.disabled = checked === 0;
|
||||||
this.queueDownloadSelected().nativeElement.disabled = checked === 0;
|
this.queueDownloadSelected().nativeElement.disabled = checked === 0;
|
||||||
|
this.queuePauseSelected().nativeElement.disabled = checked === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
doneSelectionChanged(checked: number) {
|
doneSelectionChanged(checked: number) {
|
||||||
|
|
@ -1128,6 +1137,16 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
this.downloads.startById([id]).subscribe();
|
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) {
|
retryDownload(key: string, download: Download) {
|
||||||
this.addDownload({
|
this.addDownload({
|
||||||
url: download.url,
|
url: download.url,
|
||||||
|
|
@ -1161,6 +1180,18 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||||
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
|
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) {
|
delSelectedDownloads(where: State) {
|
||||||
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
|
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) {
|
buildDownloadLink(download: Download) {
|
||||||
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
||||||
if (download.download_type === 'audio' || download.filename.endsWith('.mp3')) {
|
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);
|
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) {
|
buildResultItemTooltip(download: Download) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (download.msg) {
|
if (download.msg) {
|
||||||
|
|
@ -1581,7 +1652,9 @@ 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 === 'postprocessing') {
|
||||||
|
active++;
|
||||||
|
} else if (download.status === 'pending' || download.status === 'paused') {
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface Download {
|
||||||
clip_start?: number;
|
clip_start?: number;
|
||||||
clip_end?: number;
|
clip_end?: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
download_phase?: string;
|
||||||
msg: string;
|
msg: string;
|
||||||
percent: number;
|
percent: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
|
|
@ -30,4 +31,5 @@ export interface Download {
|
||||||
error?: string;
|
error?: string;
|
||||||
deleting?: boolean;
|
deleting?: boolean;
|
||||||
chapter_files?: { filename: string, size: number }[];
|
chapter_files?: { filename: string, size: number }[];
|
||||||
|
subtitle_files?: { filename: string, size: number }[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,13 @@ describe('DownloadsService', () => {
|
||||||
req.flush({});
|
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', () => {
|
it('delById marks items deleting and posts delete', () => {
|
||||||
const dl = makeDownload({ deleting: false });
|
const dl = makeDownload({ deleting: false });
|
||||||
service.queue.set('u1', dl);
|
service.queue.set('u1', dl);
|
||||||
|
|
@ -271,7 +278,7 @@ describe('DownloadsService', () => {
|
||||||
expect(updated?.deleting).toBe(true);
|
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', {
|
service.queue.set('u1', {
|
||||||
id: '1',
|
id: '1',
|
||||||
title: 't',
|
title: 't',
|
||||||
|
|
@ -290,9 +297,14 @@ describe('DownloadsService', () => {
|
||||||
filename: '',
|
filename: '',
|
||||||
checked: false,
|
checked: false,
|
||||||
});
|
});
|
||||||
|
let completed: Download | undefined;
|
||||||
|
service.completedDownload.subscribe((download) => {
|
||||||
|
completed = download;
|
||||||
|
});
|
||||||
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
|
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
|
||||||
expect(service.queue.has('u1')).toBe(false);
|
expect(service.queue.has('u1')).toBe(false);
|
||||||
expect(service.done.has('u1')).toBe(true);
|
expect(service.done.has('u1')).toBe(true);
|
||||||
|
expect(completed?.url).toBe('u1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('socket canceled removes from queue', () => {
|
it('socket canceled removes from queue', () => {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export class DownloadsService {
|
||||||
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
||||||
configurationChanged = new Subject<Record<string, unknown>>();
|
configurationChanged = new Subject<Record<string, unknown>>();
|
||||||
updated = new Subject<void>();
|
updated = new Subject<void>();
|
||||||
|
completedDownload = new Subject<Download>();
|
||||||
|
|
||||||
configuration: Record<string, unknown> = {};
|
configuration: Record<string, unknown> = {};
|
||||||
customDirs: Record<string, string[]> = {};
|
customDirs: Record<string, string[]> = {};
|
||||||
|
|
@ -90,6 +91,7 @@ export class DownloadsService {
|
||||||
this.done.set(key, data);
|
this.done.set(key, data);
|
||||||
this.queueChanged.next();
|
this.queueChanged.next();
|
||||||
this.doneChanged.next();
|
this.doneChanged.next();
|
||||||
|
this.completedDownload.next(data);
|
||||||
});
|
});
|
||||||
this.socket.fromEvent('canceled')
|
this.socket.fromEvent('canceled')
|
||||||
.pipe(takeUntilDestroyed())
|
.pipe(takeUntilDestroyed())
|
||||||
|
|
@ -177,6 +179,10 @@ export class DownloadsService {
|
||||||
return this.http.post('start', {ids: ids});
|
return this.http.post('start', {ids: ids});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public pauseById(ids: string[]) {
|
||||||
|
return this.http.post('pause', {ids: ids});
|
||||||
|
}
|
||||||
|
|
||||||
public delById(where: State, ids: string[]) {
|
public delById(where: State, ids: string[]) {
|
||||||
const map = this[where];
|
const map = this[where];
|
||||||
const touched: [string, Download, boolean | undefined][] = [];
|
const touched: [string, Download, boolean | undefined][] = [];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue