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_branch: master
|
||||
target_sync_branch: master
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
target_repo_token: ${{ secrets.UPSTREAM_TOKEN }}
|
||||
test_mode: false
|
||||
|
||||
- name: Sync check
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
11
app/main.py
11
app/main.py
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
208
app/ytdl.py
208
app/ytdl.py
|
|
@ -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
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)">
|
||||
</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" /> 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 #queuePauseSelected (click)="pauseSelectedDownloads()"><fa-icon [icon]="faPause" /> 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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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][] = [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue